16 files changed
+629
-424
docs/superpowers/specs/2026-05-24-flutter-gemma-migration-design.md
deleted
-340
@@ -1,340 +0,0 @@
1
-# Migration: flutter_litert_lm to flutter_gemma
2
-
3
-**Date:** 2026-05-24
4
-**Status:** Approved
5
-**Scope:** Replace on-device inference engine and model management with flutter_gemma for cross-platform support and better performance/stability.
6
-
7
-## Goals
8
-
9
-1. Replace `flutter_litert_lm` with `flutter_gemma` ^0.16.1
10
-2. Enable on-device inference on Android and iOS (desktop platforms deferred)
11
-3. Use flutter_gemma's built-in model download/management system
12
-4. Maintain existing UX patterns (model manager, chat streaming, backend selection)
13
-
14
-## Non-Goals
15
-
16
-- Adding multimodal (vision/audio) support in this migration
17
-- Adding function calling or thinking mode support
18
-- Adding embedding/RAG capabilities
19
-- Changing the remote server chat services (LM Studio, Ollama, etc.)
20
-
21
----
22
-
23
-## Dependency Changes
24
-
25
-### pubspec.yaml
26
-
27
-**Remove:**
28
-```yaml
29
-flutter_litert_lm: ^0.3.0
30
-```
31
-
32
-**Add:**
33
-```yaml
34
-flutter_gemma: ^0.16.1
35
-```
36
-
37
----
38
-
39
-## Files to Delete
40
-
41
-These files are entirely replaced by flutter_gemma's built-in APIs:
42
-
43
-| File | Lines | Reason |
44
-|------|-------|--------|
45
-| `lib/features/on_device/data/on_device_engine_service.dart` | 132 | Replaced by `FlutterGemma.getActiveModel()` |
46
-| `lib/features/on_device/data/model_downloader.dart` | 218 | Replaced by `FlutterGemma.installModel().fromNetwork()` |
47
-| `lib/features/on_device/data/on_device_model_download_service.dart` | 101 | Replaced by FlutterGemma model management |
48
-| `lib/features/on_device/data/foreground_download_service.dart` | 171 | Replaced by FlutterGemma progress callbacks |
49
-| `lib/features/on_device/data/download_notification_service.dart` | ~100 | FlutterGemma handles Android foreground service |
50
-| `lib/features/on_device/data/models/model_download_progress.dart` | ~50 | Replaced by int progress callback |
51
-
52
-**Total removed: ~770 lines**
53
-
54
----
55
-
56
-## Files to Create
57
-
58
-### `lib/features/on_device/data/on_device_gemma_service.dart`
59
-
60
-New engine service wrapping flutter_gemma. Responsibilities:
61
-- Initialize FlutterGmma at app startup
62
-- Install models from network with progress tracking
63
-- Load/unload models with backend preference
64
-- Create chat instances for inference
65
-- Check installed models
66
-- Delete models
67
-
68
-**Key API surface:**
69
-
70
-```dart
71
-class OnDeviceGemmaService {
72
- // Initialization
73
- static Future<void> initialize({String? huggingFaceToken});
74
-
75
- // Model management
76
- Future<void> installModel(OnDeviceModel model, {void Function(int)? onProgress, String? token});
77
- Future<bool> isModelInstalled(String modelId);
78
- Future<List<String>> getInstalledModelIds();
79
- Future<void> deleteModel(String modelId);
80
-
81
- // Engine lifecycle
82
- Future<void> loadModel(String modelId, PreferredBackend backend, {int maxTokens = 2048});
83
- Future<void> unloadModel();
84
- bool get isLoaded;
85
-
86
- // Chat
87
- InferenceChat? createChat({String? systemInstruction});
88
- InferenceModel? get activeModel;
89
-}
90
-```
91
-
92
-**Implementation notes:**
93
-- `installModel` calls `FlutterGemma.installModel(modelType:, fileType: ModelFileType.litertlm).fromNetwork(url).withProgress(cb).install()`
94
-- `loadModel` calls `FlutterGemma.getActiveModel(maxTokens:, preferredBackend:)`
95
-- `createChat` calls `model.createChat(systemInstruction:)`
96
-- `deleteModel` calls `FlutterGemma.uninstallModel(fileName)`
97
-- `isModelInstalled` calls `FlutterGemma.isModelInstalled(fileName)`
98
-- Model IDs map to filenames: `'$modelId.litertlm'`
99
-
100
----
101
-
102
-## Files to Rewrite
103
-
104
-### `lib/features/on_device/data/on_device_chat_service.dart`
105
-
106
-**Current:** Wraps `LiteLmConversation`, streams `LiteLmMessage`
107
-**New:** Creates `InferenceChat` from `OnDeviceGemmaService`, streams `ModelResponse`
108
-
109
-**Key changes:**
110
-- Remove all `flutter_litert_lm` imports
111
-- Use `OnDeviceGemmaService` instead of `OnDeviceEngineService`
112
-- For each `sendMessage()` call:
113
- 1. Get or create `InferenceChat` from service
114
- 2. Add conversation history as `Message.text()` chunks via `chat.addQueryChunk()`
115
- 3. Call `chat.generateChatResponseAsync()` for streaming
116
- 4. Map `TextResponse` → `ChatResponse(type: message, content: token)`
117
- 5. Map `FunctionCallResponse` → `ChatResponse(type: toolCall, ...)`
118
- 6. Map `ThinkingResponse` → `ChatResponse(type: reasoning, ...)`
119
-- `cancelStream()` calls `chat.stopGeneration()` then `chat.close()`
120
-- Store active chat reference for cancellation
121
-
122
-**Message conversion:**
123
-```dart
124
-List<Message> _convertMessages(List<Message> messages) {
125
- return messages
126
- .where((m) => m.role == MessageRole.user || m.role == MessageRole.assistant || m.role == MessageRole.system)
127
- .map((m) => Message.text(text: m.content, isUser: m.role == MessageRole.user))
128
- .toList();
129
-}
130
-```
131
-
132
-**Streaming mapping:**
133
-```dart
134
-Stream<ChatResponse> _mapGemmaStream(Stream<ModelResponse> stream) async* {
135
- await for (final response in stream) {
136
- if (response is TextResponse) {
137
- yield ChatResponse(type: ChatResponseType.message, content: response.token);
138
- } else if (response is FunctionCallResponse) {
139
- yield ChatResponse(type: ChatResponseType.toolCall,
140
- toolCall: ToolCallData(tool: response.name, arguments: response.args));
141
- } else if (response is ThinkingResponse) {
142
- yield ChatResponse(type: ChatResponseType.reasoning,
143
- reasoningContent: response.content);
144
- }
145
- }
146
- yield ChatResponse(type: ChatResponseType.done);
147
-}
148
-```
149
-
150
-### `lib/features/on_device/providers/on_device_providers.dart`
151
-
152
-**Key changes:**
153
-- Replace `OnDeviceEngineService` references with `OnDeviceGemmaService`
154
-- Replace `LiteLmBackendType` with `PreferredBackend` from flutter_gemma
155
-- `OnDeviceEngineNotifier`:
156
- - `loadModel()` calls `gemmaService.loadModel(modelId, backend)`
157
- - Uses `ChatBackgroundService` for model loading (heavy mmap operation)
158
- - `unloadModel()` calls `gemmaService.unloadModel()`
159
-- `OnDeviceModelStateNotifier`: no major changes
160
-- Remove `modelDownloaderProvider`, `onDeviceDownloadServiceProvider`, `foregroundDownloadServiceProvider`
161
-- Add `onDeviceGemmaServiceProvider` providing `OnDeviceGemmaService`
162
-- Update `isOnDevicePlatformSupportedProvider` to support iOS
163
-
164
-**Platform support provider:**
165
-```dart
166
-final isOnDevicePlatformSupportedProvider = Provider<bool>((ref) {
167
- return Platform.isAndroid || Platform.isIOS;
168
-});
169
-```
170
-
171
-### `lib/features/on_device/providers/foreground_download_providers.dart`
172
-
173
-**Rewrite to use flutter_gemma's download system:**
174
-- `ForegroundDownloadNotifier.startDownload()` calls `OnDeviceGemmaService.installModel()` with progress callback
175
-- Progress callback updates state directly (int 0-100)
176
-- No more stream subscriptions or broadcast controllers
177
-- Simplified pause/resume (flutter_gemma handles retry internally)
178
-- Keep SharedPreferences persistence for download state across restarts
179
-
180
-### `lib/features/on_device/views/model_manager_screen.dart`
181
-
182
-**Key changes:**
183
-- Remove `if (!isAndroid)` warning banner — all platforms supported
184
-- Update download progress to use simplified flutter_gemma progress (int 0-100)
185
-- Remove custom download UI complexity (pause/resume buttons — flutter_gemma handles retry)
186
-- Keep memory health display
187
-- Keep model cards with load/unload/delete actions
188
-
189
-### `lib/features/on_device/data/models/on_device_model.dart`
190
-
191
-**Add ModelType mapping:**
192
-```dart
193
-ModelType get flutterGemmaModelType {
194
- switch (id) {
195
- case 'qwen3-0.6b': return ModelType.qwen3;
196
- case 'qwen2.5-1.5b-instruct': return ModelType.qwen;
197
- case 'deepseek-r1-distill-qwen-1.5b': return ModelType.deepSeek;
198
- case 'gemma4-e2b-instruct': return ModelType.gemma4;
199
- default: return ModelType.general;
200
- }
201
-}
202
-```
203
-
204
-**Update `fileName` getter:**
205
-```dart
206
-String get fileName => '$id.litertlm'; // unchanged — same format
207
-```
208
-
209
-### `lib/core/models/enums.dart`
210
-
211
-**Changes:**
212
-- Remove `LiteLmBackendType` enum (replaced by `PreferredBackend` from flutter_gemma)
213
-- Keep `OnDeviceEngineStatus` and `OnDeviceModelState` enums
214
-- Add import for `package:flutter_gemma/flutter_gemma.dart`
215
-
216
----
217
-
218
-## Files to Update (Minor)
219
-
220
-### `lib/features/chat/data/chat_service.dart`
221
-
222
-Update `ChatService.forServer()` factory:
223
-```dart
224
-case ServerType.onDevice:
225
- if (onDeviceGemma == null) {
226
- throw StateError('OnDeviceGemmaService is required for onDevice server type');
227
- }
228
- return OnDeviceChatService(onDeviceGemma);
229
-```
230
-
231
-Change parameter from `OnDeviceEngineService? onDeviceEngine` to `OnDeviceGemmaService? onDeviceGemma`.
232
-
233
-### `lib/features/chat/providers/chat_providers.dart`
234
-
235
-Update `chatServiceProvider`:
236
-```dart
237
-final chatServiceProvider = Provider<ChatService?>((ref) {
238
- final server = ref.watch(activeServerProvider);
239
- if (server == null) return null;
240
- if (server.type == ServerType.onDevice) {
241
- final gemmaService = ref.read(onDeviceGemmaServiceProvider);
242
- return ChatService.forServer(server.type, ref.read(dioProvider), onDeviceGemma: gemmaService);
243
- }
244
- return ChatService.forServer(server.type, ref.read(dioProvider));
245
-});
246
-```
247
-
248
-### `lib/core/services/chat_background_service.dart`
249
-
250
-**Simplify:** Still needed for model loading (heavy mmap), but download foreground service is handled by flutter_gemma. Keep start/stop for model load operations.
251
-
252
-### `android/app/src/main/AndroidManifest.xml`
253
-
254
-Add GPU support if not already present:
255
-```xml
256
-<uses-native-library android:name="libOpenCL.so" android:required="false"/>
257
-<uses-native-library android:name="libOpenCL-car.so" android:required="false"/>
258
-<uses-native-library android:name="libOpenCL-pixel.so" android:required="false"/>
259
-```
260
-
261
-### `ios/Podfile`
262
-
263
-```ruby
264
-platform :ios, '16.0'
265
-use_frameworks! :linkage => :static
266
-```
267
-
268
-### `ios/Runner/Info.plist`
269
-
270
-Add:
271
-```xml
272
-<key>UIFileSharingEnabled</key>
273
-<true/>
274
-<key>NSLocalNetworkUsageDescription</key>
275
-<string>This app requires local network access for model inference services.</string>
276
-```
277
-
278
-### `ios/Runner/Runner.entitlements`
279
-
280
-Add memory entitlements:
281
-```xml
282
-<key>com.apple.developer.kernel.extended-virtual-addressing</key>
283
-<true/>
284
-<key>com.apple.developer.kernel.increased-memory-limit</key>
285
-<true/>
286
-```
287
-
288
----
289
-
290
-## Model Storage Consideration
291
-
292
-flutter_gemma manages its own model storage. Existing models downloaded by the custom `ModelDownloader` to `{appSupportDir}/on_device_models/{modelId}.litertlm` will NOT be automatically recognized by flutter_gemma.
293
-
294
-**Migration approach:**
295
-1. On first launch after migration, check for existing `.litertlm` files in the old location
296
-2. For each found model, use `FlutterGemma.installModel().fromFile(oldPath).install()` to register it
297
-3. This avoids re-downloading multi-GB models
298
-4. Old directory can be cleaned up after successful registration
299
-
300
----
301
-
302
-## Initialization
303
-
304
-Add to `main.dart`:
305
-```dart
306
-void main() async {
307
- WidgetsFlutterBinding.ensureInitialized();
308
-
309
- // Initialize flutter_gemma
310
- await OnDeviceGemmaService.initialize(
311
- huggingFaceToken: const String.fromEnvironment('HUGGINGFACE_TOKEN'),
312
- );
313
-
314
- runApp(MyApp());
315
-}
316
-```
317
-
318
----
319
-
320
-## Testing Strategy
321
-
322
-1. **Unit tests:** Mock `FlutterGemmaPlugin` for `OnDeviceGemmaService` tests
323
-2. **Widget tests:** Model manager screen with mocked providers
324
-3. **Integration tests:** Full download → load → inference flow on each platform
325
-4. **Platform matrix:** Android (arm64), iOS (arm64)
326
-
327
----
328
-
329
-## Platform Setup Checklist
330
-
331
-### Android
332
-- [x] Already supported
333
-- [ ] Add OpenCL manifest entries for GPU support
334
-
335
-### iOS (NEW)
336
-- [ ] Set minimum iOS 16.0 in Podfile
337
-- [ ] Enable file sharing in Info.plist
338
-- [ ] Add memory entitlements in Runner.entitlements
339
-- [ ] Set static linkage in Podfile
340
-- [ ] Add network usage description
lib/core/services/data_backup_service.dart
+1
-1
@@ -160,7 +160,7 @@ class DataBackupService {
160
'savedMessages': _exportSavedMessages(store),
161
'savedMessageFolders': _exportSavedMessageFolders(store),
162
'servers': _exportServers(store),
163
- if (modelMetadata != null) 'modelMetadata': modelMetadata,
163
+ 'modelMetadata': ?modelMetadata,
164
};
165
}
166
lib/features/chat/views/components/chat_input_bar.dart
+1
-3
@@ -335,8 +335,6 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
335
336
allowedExtensions: const ['txt', 'md'],
337
338
- allowMultiple: true,
339
-
338
);
339
340
if (result == null || result.files.isEmpty) return;
@@ -1126,7 +1124,7 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
1124
1125
itemCount: _attachedFiles.length,
1126
1129
- separatorBuilder: (_, __) => const SizedBox(width: 8),
1127
+ separatorBuilder: (_, _) => const SizedBox(width: 8),
1128
1129
itemBuilder: (context, index) {
1130
lib/features/chat/views/components/message_list/empty_state.dart
+1
-1
@@ -115,7 +115,7 @@ class _EmptyStateState extends State<EmptyState>
115
duration: const Duration(milliseconds: 400),
116
layoutBuilder: (current, previous) => Stack(
117
alignment: Alignment.center,
118
- children: [...previous, if (current != null) current],
118
+ children: [...previous, ?current],
119
),
120
child: Text(
121
_welcomeMessages(l10n)[_welcomeIndex],
lib/features/chat/views/components/message_list/message_list.dart
+16
-18
@@ -239,8 +239,9 @@ class _MessageList extends ConsumerWidget {
239
Widget build(BuildContext context, WidgetRef ref) {
240
final selectionMode = ref.watch(messageSelectionModeProvider);
241
final selectedIds = ref.watch(selectedMessageIdsProvider);
242
- final showSystemMessages =
243
- ref.watch(settingsProvider.select((s) => s.showSystemMessagesInChat));
242
+ final showSystemMessages = ref.watch(
243
+ settingsProvider.select((s) => s.showSystemMessagesInChat),
244
+ );
245
final visibleMessages = <Message>[];
246
247
// The streaming message only belongs at the end of the currently
@@ -248,7 +249,8 @@ class _MessageList extends ConsumerWidget {
249
// to it — otherwise the user has cycled to a different variant while
250
// a sibling variant is still generating in the background, and the
251
// live bubble must not be appended to whatever branch is now on screen.
251
- final streamingBelongsToActiveTimeline = streamingMessage != null &&
252
+ final streamingBelongsToActiveTimeline =
253
+ streamingMessage != null &&
254
messages.any((m) => m.id == streamingMessage!.id);
255
256
for (final message in messages) {
@@ -272,15 +274,13 @@ class _MessageList extends ConsumerWidget {
274
top: 16,
275
bottom: 120 + (hasSmartReplies ? 64 : 0) + bottomInset,
276
),
275
- itemCount:
276
- visibleMessages.length +
277
- (showTrailingStreamingBubble ? 1 : 0),
277
+ itemCount: visibleMessages.length + (showTrailingStreamingBubble ? 1 : 0),
278
itemBuilder: (context, index) {
279
if (showTrailingStreamingBubble && index == visibleMessages.length) {
280
return ChatBubble(
281
key: ValueKey(streamingMessage!.id),
282
message: streamingMessage!,
283
- allMessages: this.allMessages,
283
+ allMessages: allMessages,
284
isStreaming: true,
285
onModelTap: onModelPicker,
286
);
@@ -288,14 +288,14 @@ class _MessageList extends ConsumerWidget {
288
289
final message = visibleMessages[index];
290
final isLast = index == visibleMessages.length - 1;
291
- final itemKey =
292
- messageKeys.putIfAbsent(message.id, GlobalKey.new);
291
+ final itemKey = messageKeys.putIfAbsent(message.id, GlobalKey.new);
292
293
final bubble = ChatBubble(
294
key: itemKey,
295
message: message,
296
allMessages: this.allMessages,
298
- isStreaming: isLast &&
297
+ isStreaming:
298
+ isLast &&
299
showTrailingStreamingBubble &&
300
message.id == streamingMessage?.id,
301
onRetry: () => onRetry(message.id),
@@ -303,24 +303,22 @@ class _MessageList extends ConsumerWidget {
303
onEdit: message.role == MessageRole.user
304
? () => onEdit(message.id, message.content)
305
: message.role == MessageRole.assistant
306
- ? () => onEditAssistant(message.id, message.content)
307
- : null,
306
+ ? () => onEditAssistant(message.id, message.content)
307
+ : null,
308
onBranch: () => onBranch(message.id),
309
onContinue: message.role == MessageRole.assistant && isLast
310
? () => onContinue(message.id)
311
: null,
312
- onCycleVariant: (direction) =>
313
- onCycleVariant(message.id, direction),
312
+ onCycleVariant: (direction) => onCycleVariant(message.id, direction),
313
onModelTap: onModelPicker,
315
- onModelLongPress: message.modelId != null &&
314
+ onModelLongPress:
315
+ message.modelId != null &&
316
message.role == MessageRole.assistant &&
317
onModelLongPress != null
318
? () => onModelLongPress!(message.modelId!)
319
: null,
320
onSave: onSave,
321
- onShare: onShare == null
322
- ? null
323
- : () => onShare!(message),
321
+ onShare: onShare == null ? null : () => onShare!(message),
322
);
323
324
return Column(
lib/features/conversations/views/components/conversation_folder_bar.dart
+1
-1
@@ -52,7 +52,7 @@ class ConversationFolderBar extends ConsumerWidget {
52
onCreateFolder: () => _createFolder(context, ref),
53
),
54
loading: () => const SizedBox(height: 44),
55
- error: (_, __) => const SizedBox.shrink(),
55
+ error: (_, _) => const SizedBox.shrink(),
56
);
57
}
58
}
lib/features/conversations/views/components/message_search_results_list.dart
+1
-1
@@ -33,7 +33,7 @@ class MessageSearchResultsList extends ConsumerWidget {
33
shrinkWrap: true,
34
physics: const NeverScrollableScrollPhysics(),
35
itemCount: hits.length,
36
- separatorBuilder: (_, __) => const Divider(height: 1, indent: 16),
36
+ separatorBuilder: (_, _) => const Divider(height: 1, indent: 16),
37
itemBuilder: (context, index) {
38
final hit = hits[index];
39
return ListTile(
lib/features/saved_messages/views/components/saved_message_folder_bar.dart
+1
-1
@@ -55,7 +55,7 @@ class SavedMessageFolderBar extends ConsumerWidget {
55
showCreateFolder: showCreateFolder,
56
),
57
loading: () => const SizedBox(height: 44),
58
- error: (_, __) => const SizedBox.shrink(),
58
+ error: (_, _) => const SizedBox.shrink(),
59
);
60
}
61
}
lib/features/saved_messages/views/components/saved_message_picker_sheet.dart
+1
-1
@@ -41,7 +41,7 @@ class _SavedMessagePickerSheet extends ConsumerWidget {
41
return ListView.separated(
42
padding: const EdgeInsets.only(bottom: 8),
43
itemCount: messages.length,
44
- separatorBuilder: (_, __) => Divider(
44
+ separatorBuilder: (_, _) => Divider(
45
height: 1,
46
color: isDark
47
? const Color(0xFF2A2A2A)
lib/features/saved_messages/views/saved_messages_screen.dart
+1
-1
@@ -154,7 +154,7 @@ class SavedMessagesScreen extends ConsumerWidget {
154
return ListView.separated(
155
padding: const EdgeInsets.only(bottom: 8),
156
itemCount: messages.length,
157
- separatorBuilder: (_, __) => Divider(
157
+ separatorBuilder: (_, _) => Divider(
158
height: 1,
159
color: isDark
160
? const Color(0xFF2A2A2A)
lib/features/settings/views/settings_screen.dart
+1
-1
@@ -16,7 +16,7 @@ import '../../on_device/providers/on_device_providers.dart';
16
import '../../personas/providers/personas_providers.dart';
17
import '../../servers/providers/server_providers.dart';
18
import '../data/models/app_settings.dart';
19
-import 'data_backup_actions.dart';
19
+
20
21
class SettingsViews extends ConsumerWidget {
22
const SettingsViews({super.key});
session_tts-audio-player-enhancement_2026-06-10-224608.md
deleted
-55
@@ -1,55 +0,0 @@
1
-# Session Summary: TTS Audio Player Enhancement
2
-
3
-**Date:** 2026-06-10
4
-**Duration:** ~1-2 hours
5
-**Files changed:** 6 (296 insertions, 142 deletions)
6
-
7
-## Recap of Key Actions
8
-
9
-1. **Analysis** — Mapped the existing TTS architecture: three engines (System/Kitten/Piper), chunked streaming via `sherpa_onnx` isolate + `audioplayers`, and a mini player bar with stop-only support.
10
-
11
-2. **Plan** — Proposed and refined a plan to add pause/resume, a progress bar with timestamps, persistent player across routes, and background audio support.
12
-
13
-3. **Implementation:**
14
- - Added `isPaused` state, `pause()`/`resume()`/`togglePauseResume()` methods to `TtsNotifier`.
15
- - Added progress tracking — WAV header parsing to compute per-chunk durations, `_totalDuration` and `_chunkOffset` for elapsed time calculation.
16
- - Configured `AudioContextConfig(stayAwake: true, focus: gain)` for lock-screen background playback.
17
- - Rewrote `TtsPlayerBar` from a `ConsumerWidget` to a `ConsumerStatefulWidget` subscribing to player streams, showing play/pause, elapsed/total time, linear progress bar, and stop.
18
- - Moved `TtsPlayerBar` from `ChatScreen`/`SidebarWidget` to `AppShell`, making it persistent across all ShellRoutes.
19
- - Updated `MessageActionBar` "Read Aloud" button to show pause/play/resume states.
20
- - Removed old imports and widget references from `ChatScreen` and `SidebarWidget`.
21
-
22
-4. **Code Review** — Sub-agent identified 5 issues which were all fixed:
23
- - `_chunkDurations` changed from `List<Duration>` to `Map<int, Duration>` to avoid null crashes on out-of-order chunk arrivals.
24
- - Stale system TTS completion handler race fixed by clearing the old handler before setting a new one.
25
- - Temp WAV file leak addressed with `_deleteChunkFile()` / `_cleanupSessionFiles()` called on stop and playback finish.
26
- - Stale position on chunk boundaries fixed with an `_onChunkChanged` broadcast stream.
27
- - `ref.listen` moved from `build()` to `initState()` using `ref.listenManual`.
28
-
29
-## Total Cost
30
-
31
-Not available — no billing or token usage data is exposed in this environment.
32
-
33
-## Efficiency Insights
34
-
35
-- **Sub-agent for code review was effective** — catching 5 issues that would have been subtle runtime bugs (null crash, race condition, file leak).
36
-- **Riverpod pattern issues** — one finding (`ref.listen` in build) was a common anti-pattern that's easy to miss when rapidly iterating.
37
-- **Chunked architecture complexity** — the streaming/chunked approach adds significant complexity for progress tracking and position calculation. A single-file synthesis approach would be simpler but trades off latency-to-first-audio.
38
-
39
-## Possible Process Improvements
40
-
41
-- **Run code review sub-agent proactively** after any batch of changes, not just when explicitly asked.
42
-- **Track durations via the worker** — instead of parsing WAV headers in the main isolate, have the worker return `(wavBytes, sampleCount, sampleRate)` directly for cleaner progress computation.
43
-- **Audio session configuration** should be tested on physical iOS/Android devices to verify lock-screen and background behavior works as expected.
44
-- **Lock-screen media controls** (`audio_service` package) could be a follow-up for full media notification integration.
45
-
46
-## Conversation Turns
47
-
48
-~7 user turns.
49
-
50
-## Other Observations
51
-
52
-- iOS `UIBackgroundModes: audio` and Android `WAKE_LOCK` were already configured in the project, which simplified background audio setup.
53
-- The existing `AudioPlayerWidget` (for chat attachments) served as a good reference for the progress bar + timestamps pattern.
54
-- The `audioplayers ^6.6.0` package already provides `AudioContextConfig` with `stayAwake` and `focus` — no extra platform packages needed.
55
-- All 6 modified files pass `dart analyze` with zero issues.
skills/flutter-feature-based-architecture/SKILL.md
new
+196
@@ -0,0 +1,196 @@
1
+---
2
+name: flutter-feature-based-architecture
3
+description: Design, implement, refactor, and review Flutter apps that use feature-based vertical slices, handwritten Dart models/providers, componentized StatelessWidget UI, Riverpod state management, and a shared core layer. Use when adding Flutter features, organizing lib/, moving shared code, wiring repositories/providers/routes, avoiding code generation, avoiding private UI build helper methods, or checking architecture consistency in a Riverpod Flutter project.
4
+---
5
+
6
+# Flutter Feature-Based Architecture
7
+
8
+Use this skill to keep Flutter code organized by product capability instead of broad technical layers. Prefer vertical feature slices under `lib/features/`, keep stable cross-feature code in `lib/core/`, use Riverpod as the state boundary between UI and data access, write models/providers by hand, and split UI into small `StatelessWidget` components.
9
+
10
+## First Pass
11
+
12
+Before editing a project:
13
+
14
+1. Inspect `pubspec.yaml`, `lib/`, route setup, serialization conventions, and existing Riverpod style.
15
+2. Preserve coherent local conventions, especially folder names such as `repository/` vs `repositories/`, `components/` vs `widgets/`, and handwritten provider organization.
16
+3. Prefer the smallest architecture change that solves the request.
17
+4. State any dependency assumptions if Riverpod, routing, or serialization helpers are missing.
18
+5. Run the project-standard checks after changes, usually `flutter analyze` and relevant tests.
19
+
20
+## Target Shape
21
+
22
+Use this default shape for new projects or features unless the existing project already has a consistent equivalent:
23
+
24
+```text
25
+lib/
26
+├── app/ # Optional app composition: shell, router, bootstrap
27
+├── core/ # Shared, cross-cutting concerns
28
+│ ├── components/ # Reusable presentational widgets
29
+│ ├── constants/ # App-wide constants and non-secret config
30
+│ ├── extensions/ # Shared Dart extensions
31
+│ ├── locales/ # Localization setup and handwritten keys
32
+│ ├── logger/ # App-wide logging wrapper
33
+│ ├── models/ # Cross-feature models only
34
+│ ├── providers/ # App-wide Riverpod providers
35
+│ ├── repositories/ # Shared repositories and infrastructure services
36
+│ ├── routes/ # Route config if the app has no app/ layer
37
+│ ├── theme/ # ThemeData, text styles, component themes
38
+│ └── utils/ # General-purpose helpers
39
+├── features/
40
+│ └── orders/
41
+│ ├── data/
42
+│ │ ├── models/
43
+│ │ └── repositories/
44
+│ ├── providers/
45
+│ └── views/
46
+│ └── components/
47
+└── main.dart
48
+```
49
+
50
+### Core Layer
51
+
52
+Move code to `core/` only when it is genuinely app-wide or shared by multiple features. Keep feature-specific models, providers, repository methods, and UI components inside the owning feature until there is a stable reason to extract them.
53
+
54
+Place composition code carefully:
55
+
56
+- Let `main.dart`, `app/`, or `core/routes/` import feature entry pages to wire navigation.
57
+- Do not let one feature import another feature directly.
58
+- Move shared contracts, route constants, data types, or UI primitives to `core/` when two features need them.
59
+
60
+## Feature Layer
61
+
62
+Use this internal structure for each feature:
63
+
64
+```text
65
+features/<feature_name>/
66
+├── data/
67
+│ ├── models/ # Feature-specific data/domain models
68
+│ └── repositories/ # API, database, cache, and persistence access
69
+├── providers/ # Riverpod state, controllers, and query providers
70
+└── views/
71
+ ├── <feature>_page.dart # Page/screen widgets
72
+ └── components/ # Feature-only StatelessWidget components
73
+```
74
+
75
+Add optional folders only when they carry real weight:
76
+
77
+| Folder | Use for |
78
+| --- | --- |
79
+| `data/datasources/` | Remote/local data source adapters when repositories would become too large |
80
+| `data/dto/` | Transport-specific objects that should not leak into UI state |
81
+| `services/` | Stateless feature services such as validators, schedulers, or mappers |
82
+| `utils/` | Helpers that are private to one feature |
83
+
84
+## Dependency Rules
85
+
86
+- Import from `core/` and the current feature only.
87
+- Do not import from sibling features. Extract shared code to `core/` instead.
88
+- Keep views lean. Views handle layout, gestures, animation, navigation calls, and local-only UI concerns.
89
+- Keep business rules, async work, persistence, and mutation orchestration in Riverpod notifiers/controllers or repositories.
90
+- Keep repositories free of widget concerns. They should expose clear async APIs and translate data-source errors into app-level failures where the project has a failure type.
91
+- Use `setState` only for strictly local ephemeral UI state. Do not use it for business data or cross-widget state.
92
+- Do not split UI with private helper methods such as `_buildHeader`, `_buildBody`, `_buildList`, or `_buildItem`.
93
+- Extract UI sections into named `StatelessWidget` classes under `views/components/` instead.
94
+
95
+## Handwritten Dart Only
96
+
97
+Do not introduce code generation for models, providers, routes, or serialization. Avoid `freezed`, `json_serializable`, `riverpod_annotation`, `build_runner`, generated `part` files, `.g.dart`, and `.freezed.dart` files unless the user explicitly asks for generated code.
98
+
99
+When a project already contains generated files:
100
+
101
+- Do not delete or rewrite existing generated architecture as part of an unrelated feature.
102
+- Do not expand the generated surface for new work.
103
+- Interact with existing generated APIs only when needed for compatibility.
104
+- Ask before changing generator inputs if the task cannot be completed cleanly with handwritten Dart.
105
+
106
+For handwritten models and state classes:
107
+
108
+- Use immutable classes with `final` fields and `const` constructors where possible.
109
+- Add `factory Model.fromJson(Map<String, dynamic> json)`, `Map<String, dynamic> toJson()`, and `copyWith()`.
110
+- Add `==` and `hashCode` manually when value equality affects provider rebuilds, tests, collections, or state comparison.
111
+- Keep parsing and serialization defensive enough for the app's data source.
112
+
113
+## Riverpod Defaults
114
+
115
+Prefer the project's existing Riverpod style when it is internally consistent. For new handwritten Riverpod code, prefer current Riverpod primitives:
116
+
117
+| Need | Default |
118
+| --- | --- |
119
+| Repository/service dependency | `Provider<T>` |
120
+| Simple local filter/toggle/query text | `StateProvider<T>` |
121
+| Read-only one-shot async query | `FutureProvider<T>` |
122
+| Read-only realtime data | `StreamProvider<T>` |
123
+| Synchronous controller with mutable state | `NotifierProvider<Notifier, State>` |
124
+| Async controller with loading/error/data state | `AsyncNotifierProvider<Notifier, State>` |
125
+| Parameterized provider | `.family` |
126
+| Screen-scoped state | `.autoDispose` |
127
+
128
+Use `StateNotifierProvider` only when the project already standardizes on `StateNotifier` or dependency constraints require it.
129
+
130
+### Provider Organization
131
+
132
+In a feature's provider file, use this order:
133
+
134
+1. Repository/service providers.
135
+2. Simple filter or UI-state providers.
136
+3. Query providers or notifier classes.
137
+4. Provider declarations for controllers/notifiers.
138
+
139
+Split a provider file when it becomes hard to scan, not just because a second provider exists. Prefer names that read naturally at call sites: `orderRepositoryProvider`, `orderSearchQueryProvider`, `orderListProvider`, `orderControllerProvider`.
140
+
141
+### Async UI Rules
142
+
143
+- Use `AsyncValue.when`, pattern matching, or project-standard helpers to render loading/error/data states.
144
+- Use `ref.watch()` for reactive state in `build`.
145
+- Use `ref.read()` for event handlers and one-shot actions.
146
+- Use `ref.listen()` for side effects such as snackbars, dialogs, and redirects.
147
+- Do not start network fetches, mutations, navigation, or provider invalidation from `build`, `itemBuilder`, or other repeatedly-called render callbacks.
148
+
149
+## UI Composition
150
+
151
+Use page widgets as thin state connectors. Prefer `ConsumerWidget` pages when Riverpod state is needed, and use `ConsumerStatefulWidget` only for unavoidable local lifecycle concerns such as controllers, focus nodes, or animations. Extracted UI sections should be `StatelessWidget` components by default.
152
+
153
+Follow these rules:
154
+
155
+- Keep each required widget `build` override focused on composing named widgets.
156
+- Do not create private methods that return widgets for page sections.
157
+- Move page sections, cards, list rows, empty states, form sections, buttons groups, and reusable layout pieces into `views/components/`.
158
+- Pass plain data and callbacks into components instead of reading providers inside every component.
159
+- Use `ConsumerWidget` in a component only when passing data/callbacks would create worse coupling or excessive plumbing.
160
+- Promote a feature component to `core/components/` only after it is reused by multiple features.
161
+
162
+## Feature Implementation Workflow
163
+
164
+When adding a feature:
165
+
166
+1. Create `lib/features/<feature_name>/` with `data/models/`, `data/repositories/`, `providers/`, and `views/`.
167
+2. Define handwritten models with `fromJson`, `toJson`, `copyWith`, and manual equality when needed.
168
+3. Implement the repository. Inject shared clients from `core/providers/` or `core/repositories/`; do not instantiate global dependencies deep in views.
169
+4. Add Riverpod providers. Start with repository providers, then query/controller providers.
170
+5. Implement views as thin `ConsumerWidget` shells, using `ConsumerStatefulWidget` only for unavoidable local lifecycle state, then extract UI sections into `StatelessWidget` files under `views/components/`.
171
+6. Wire navigation in the existing composition layer.
172
+7. Extract shared code to `core/` only after confirming it is cross-feature or app-wide.
173
+8. Run formatting, static analysis, and focused tests.
174
+
175
+## Naming
176
+
177
+| Item | Convention | Example |
178
+| --- | --- | --- |
179
+| Feature directory | `snake_case` | `order_history/` |
180
+| Model file | `snake_case` | `order_model.dart` |
181
+| Repository file | `<feature>_repository.dart` | `orders_repository.dart` |
182
+| Provider file | `<feature>_providers.dart` | `orders_providers.dart` |
183
+| Page file | `<feature>_page.dart` or specific screen name | `orders_page.dart`, `order_details_page.dart` |
184
+| Component file | `<component_name>.dart` | `orders_list.dart`, `order_summary_card.dart` |
185
+| Component class | `PascalCase` widget name | `OrdersList`, `OrderSummaryCard` |
186
+| Notifier/controller class | `PascalCase` + `Notifier` or `Controller` | `OrdersNotifier`, `OrderFormController` |
187
+| State class | `PascalCase` + `State` | `OrdersState`, `OrderFormState` |
188
+| Provider variable | `camelCase` + `Provider` | `ordersProvider`, `orderRepositoryProvider` |
189
+| Barrel file | Directory summary name | `models.dart`, `components.dart` |
190
+
191
+## Reference Files
192
+
193
+Load reference files only when useful for the current task:
194
+
195
+- Read [references/handwritten-feature-example.md](references/handwritten-feature-example.md) when implementing a concrete feature or when a full handwritten model, repository, provider, page, and component example would reduce ambiguity.
196
+- Read [references/pagination-state-notifier-example.md](references/pagination-state-notifier-example.md) when implementing page-number pagination in a project that already uses `StateNotifierProvider`, or when the user asks for an indexed pagination controller pattern.
skills/flutter-feature-based-architecture/agents/openai.yaml
new
+4
@@ -0,0 +1,4 @@
1
+interface:
2
+ display_name: "Flutter Feature Architecture"
3
+ short_description: "Handwritten componentized Flutter features"
4
+ default_prompt: "Use $flutter-feature-based-architecture to add a handwritten, componentized Flutter feature with Riverpod."
skills/flutter-feature-based-architecture/references/handwritten-feature-example.md
new
+210
@@ -0,0 +1,210 @@
1
+# Handwritten Feature Example
2
+
3
+Use this as a compact reference for new handwritten Riverpod code. Adapt it to the project's repository, error, pagination, and serialization conventions.
4
+
5
+## Contents
6
+
7
+- [Model](#model)
8
+- [Repository](#repository)
9
+- [Providers](#providers)
10
+- [Page](#page)
11
+- [Components](#components)
12
+
13
+## Model
14
+
15
+```dart
16
+// features/orders/data/models/order_model.dart
17
+class OrderModel {
18
+ const OrderModel({
19
+ required this.id,
20
+ required this.customerName,
21
+ required this.total,
22
+ });
23
+
24
+ final String id;
25
+ final String customerName;
26
+ final double total;
27
+
28
+ factory OrderModel.fromJson(Map<String, dynamic> json) {
29
+ return OrderModel(
30
+ id: json['id'] as String? ?? '',
31
+ customerName: json['customerName'] as String? ?? '',
32
+ total: (json['total'] as num?)?.toDouble() ?? 0,
33
+ );
34
+ }
35
+
36
+ Map<String, dynamic> toJson() {
37
+ return {
38
+ 'id': id,
39
+ 'customerName': customerName,
40
+ 'total': total,
41
+ };
42
+ }
43
+
44
+ OrderModel copyWith({
45
+ String? id,
46
+ String? customerName,
47
+ double? total,
48
+ }) {
49
+ return OrderModel(
50
+ id: id ?? this.id,
51
+ customerName: customerName ?? this.customerName,
52
+ total: total ?? this.total,
53
+ );
54
+ }
55
+
56
+ @override
57
+ bool operator ==(Object other) {
58
+ return identical(this, other) ||
59
+ other is OrderModel &&
60
+ other.id == id &&
61
+ other.customerName == customerName &&
62
+ other.total == total;
63
+ }
64
+
65
+ @override
66
+ int get hashCode => Object.hash(id, customerName, total);
67
+}
68
+```
69
+
70
+## Repository
71
+
72
+```dart
73
+// features/orders/data/repositories/orders_repository.dart
74
+class OrdersRepository {
75
+ const OrdersRepository(this._client);
76
+
77
+ final ApiClient _client;
78
+
79
+ Future<List<OrderModel>> fetchOrders({String query = ''}) async {
80
+ final response = await _client.get('/orders', query: {'q': query});
81
+ return (response as List)
82
+ .map((json) => OrderModel.fromJson(json as Map<String, dynamic>))
83
+ .toList();
84
+ }
85
+
86
+ Future<void> deleteOrder(String id) {
87
+ return _client.delete('/orders/$id');
88
+ }
89
+}
90
+```
91
+
92
+## Providers
93
+
94
+```dart
95
+// features/orders/providers/orders_providers.dart
96
+final ordersRepositoryProvider = Provider<OrdersRepository>((ref) {
97
+ final client = ref.watch(apiClientProvider);
98
+ return OrdersRepository(client);
99
+});
100
+
101
+final orderSearchQueryProvider = StateProvider.autoDispose<String>((ref) => '');
102
+
103
+final ordersProvider =
104
+ AsyncNotifierProvider.autoDispose<OrdersNotifier, List<OrderModel>>(
105
+ OrdersNotifier.new,
106
+);
107
+
108
+class OrdersNotifier extends AutoDisposeAsyncNotifier<List<OrderModel>> {
109
+ @override
110
+ Future<List<OrderModel>> build() {
111
+ final repository = ref.watch(ordersRepositoryProvider);
112
+ final query = ref.watch(orderSearchQueryProvider);
113
+ return repository.fetchOrders(query: query);
114
+ }
115
+
116
+ Future<void> deleteOrder(String id) async {
117
+ state = const AsyncLoading<List<OrderModel>>();
118
+ state = await AsyncValue.guard(() async {
119
+ final repository = ref.read(ordersRepositoryProvider);
120
+ await repository.deleteOrder(id);
121
+ final query = ref.read(orderSearchQueryProvider);
122
+ return repository.fetchOrders(query: query);
123
+ });
124
+ }
125
+}
126
+```
127
+
128
+## Page
129
+
130
+```dart
131
+// features/orders/views/orders_page.dart
132
+class OrdersPage extends ConsumerWidget {
133
+ const OrdersPage({super.key});
134
+
135
+ @override
136
+ Widget build(BuildContext context, WidgetRef ref) {
137
+ final orders = ref.watch(ordersProvider);
138
+
139
+ return orders.when(
140
+ loading: () => const Center(child: CircularProgressIndicator()),
141
+ error: (error, stackTrace) => ErrorView(error: error),
142
+ data: (items) => OrdersList(
143
+ orders: items,
144
+ onDelete: (id) => ref.read(ordersProvider.notifier).deleteOrder(id),
145
+ ),
146
+ );
147
+ }
148
+}
149
+```
150
+
151
+## Components
152
+
153
+```dart
154
+// features/orders/views/components/orders_list.dart
155
+class OrdersList extends StatelessWidget {
156
+ const OrdersList({
157
+ super.key,
158
+ required this.orders,
159
+ required this.onDelete,
160
+ });
161
+
162
+ final List<OrderModel> orders;
163
+ final ValueChanged<String> onDelete;
164
+
165
+ @override
166
+ Widget build(BuildContext context) {
167
+ if (orders.isEmpty) {
168
+ return const Center(child: Text('No orders found'));
169
+ }
170
+
171
+ return ListView.separated(
172
+ itemCount: orders.length,
173
+ separatorBuilder: (context, index) => const Divider(height: 1),
174
+ itemBuilder: (context, index) {
175
+ final order = orders[index];
176
+ return OrderListTile(
177
+ order: order,
178
+ onDelete: () => onDelete(order.id),
179
+ );
180
+ },
181
+ );
182
+ }
183
+}
184
+```
185
+
186
+```dart
187
+// features/orders/views/components/order_list_tile.dart
188
+class OrderListTile extends StatelessWidget {
189
+ const OrderListTile({
190
+ super.key,
191
+ required this.order,
192
+ required this.onDelete,
193
+ });
194
+
195
+ final OrderModel order;
196
+ final VoidCallback onDelete;
197
+
198
+ @override
199
+ Widget build(BuildContext context) {
200
+ return ListTile(
201
+ title: Text(order.customerName),
202
+ subtitle: Text('\$${order.total.toStringAsFixed(2)}'),
203
+ trailing: IconButton(
204
+ icon: const Icon(Icons.delete_outline),
205
+ onPressed: onDelete,
206
+ ),
207
+ );
208
+ }
209
+}
210
+```
skills/flutter-feature-based-architecture/references/pagination-state-notifier-example.md
new
+194
@@ -0,0 +1,194 @@
1
+# Pagination StateNotifier Example
2
+
3
+Use this reference when a project already standardizes on `StateNotifierProvider` or when the user specifically asks for this indexed page-pagination style. For new projects, prefer the main skill's current Riverpod defaults first.
4
+
5
+## Contents
6
+
7
+- [State](#state)
8
+- [Provider and Controller](#provider-and-controller)
9
+- [UI Trigger Guidance](#ui-trigger-guidance)
10
+
11
+## State
12
+
13
+Keep pagination state immutable and handwritten. Track the loaded items, next page, initial load, pagination spinner, error text, and whether more data is available.
14
+
15
+```dart
16
+import 'package:flutter/foundation.dart';
17
+
18
+import '../data/models/article_model.dart';
19
+
20
+class PostPagination {
21
+ static const pageSize = 10;
22
+
23
+ const PostPagination({
24
+ required this.posts,
25
+ required this.page,
26
+ required this.errorMessage,
27
+ required this.initialLoaded,
28
+ required this.isPaginationLoading,
29
+ required this.hasMore,
30
+ });
31
+
32
+ const PostPagination.initial()
33
+ : posts = const [],
34
+ page = 1,
35
+ errorMessage = '',
36
+ initialLoaded = false,
37
+ isPaginationLoading = false,
38
+ hasMore = true;
39
+
40
+ final List<ArticleModel> posts;
41
+ final int page;
42
+ final String errorMessage;
43
+ final bool initialLoaded;
44
+ final bool isPaginationLoading;
45
+ final bool hasMore;
46
+
47
+ bool get hasRefreshError {
48
+ return errorMessage.isNotEmpty && posts.length <= pageSize;
49
+ }
50
+
51
+ PostPagination copyWith({
52
+ List<ArticleModel>? posts,
53
+ int? page,
54
+ String? errorMessage,
55
+ bool? initialLoaded,
56
+ bool? isPaginationLoading,
57
+ bool? hasMore,
58
+ }) {
59
+ return PostPagination(
60
+ posts: posts ?? this.posts,
61
+ page: page ?? this.page,
62
+ errorMessage: errorMessage ?? this.errorMessage,
63
+ initialLoaded: initialLoaded ?? this.initialLoaded,
64
+ isPaginationLoading: isPaginationLoading ?? this.isPaginationLoading,
65
+ hasMore: hasMore ?? this.hasMore,
66
+ );
67
+ }
68
+
69
+ @override
70
+ bool operator ==(Object other) {
71
+ return identical(this, other) ||
72
+ other is PostPagination &&
73
+ listEquals(other.posts, posts) &&
74
+ other.page == page &&
75
+ other.errorMessage == errorMessage &&
76
+ other.initialLoaded == initialLoaded &&
77
+ other.isPaginationLoading == isPaginationLoading &&
78
+ other.hasMore == hasMore;
79
+ }
80
+
81
+ @override
82
+ int get hashCode {
83
+ return Object.hash(
84
+ Object.hashAll(posts),
85
+ page,
86
+ errorMessage,
87
+ initialLoaded,
88
+ isPaginationLoading,
89
+ hasMore,
90
+ );
91
+ }
92
+}
93
+```
94
+
95
+## Provider and Controller
96
+
97
+Keep widget APIs out of the controller. Do not import `material.dart` or use `WidgetsBinding` here. Let the provider create the controller and start the first page load.
98
+
99
+```dart
100
+import 'dart:async';
101
+
102
+import 'package:flutter_riverpod/flutter_riverpod.dart';
103
+
104
+import '../data/models/article_model.dart';
105
+import '../data/repositories/post_repository.dart';
106
+import 'post_pagination.dart';
107
+
108
+final recentPostsControllerProvider =
109
+ StateNotifierProvider.autoDispose<RecentPostsController, PostPagination>(
110
+ (ref) {
111
+ final repository = ref.watch(postRepositoryProvider);
112
+ final controller = RecentPostsController(repository);
113
+ unawaited(controller.loadNextPage());
114
+ return controller;
115
+ },
116
+);
117
+
118
+class RecentPostsController extends StateNotifier<PostPagination> {
119
+ RecentPostsController(this._repository) : super(const PostPagination.initial());
120
+
121
+ final PostRepository _repository;
122
+
123
+ bool _isAlreadyLoading = false;
124
+ int _requestGeneration = 0;
125
+
126
+ Future<void> loadNextPage() async {
127
+ if (_isAlreadyLoading || !state.hasMore) return;
128
+
129
+ _isAlreadyLoading = true;
130
+ final requestGeneration = _requestGeneration;
131
+ final pageToLoad = state.page;
132
+ final isFirstPage = pageToLoad == 1;
133
+
134
+ state = state.copyWith(
135
+ errorMessage: '',
136
+ isPaginationLoading: !isFirstPage,
137
+ );
138
+
139
+ try {
140
+ final fetched = await _repository.getAllPosts(pageNumber: pageToLoad);
141
+
142
+ if (!mounted || requestGeneration != _requestGeneration) return;
143
+
144
+ final posts = fetched
145
+ .map((post) => post.copyWith(heroTag: '${post.link}recents'))
146
+ .toList();
147
+
148
+ state = state.copyWith(
149
+ posts: [...state.posts, ...posts],
150
+ page: pageToLoad + 1,
151
+ initialLoaded: true,
152
+ isPaginationLoading: false,
153
+ hasMore: posts.length >= PostPagination.pageSize,
154
+ );
155
+ } on Exception {
156
+ if (!mounted || requestGeneration != _requestGeneration) return;
157
+
158
+ state = state.copyWith(
159
+ errorMessage: 'Fetch Error',
160
+ initialLoaded: true,
161
+ isPaginationLoading: false,
162
+ );
163
+ } finally {
164
+ if (requestGeneration == _requestGeneration) {
165
+ _isAlreadyLoading = false;
166
+ }
167
+ }
168
+ }
169
+
170
+ void handleVisibleIndex(int index) {
171
+ final itemPosition = index + 1;
172
+ final reachedPageBoundary =
173
+ itemPosition % PostPagination.pageSize == 0 && itemPosition != 0;
174
+ final viewedPage = itemPosition ~/ PostPagination.pageSize;
175
+
176
+ if (reachedPageBoundary && viewedPage + 1 >= state.page) {
177
+ loadNextPage();
178
+ }
179
+ }
180
+
181
+ Future<void> refresh() async {
182
+ _requestGeneration++;
183
+ _isAlreadyLoading = false;
184
+ state = const PostPagination.initial();
185
+ await loadNextPage();
186
+ }
187
+}
188
+```
189
+
190
+## UI Trigger Guidance
191
+
192
+Use `handleVisibleIndex` when a list component has a stable visible-index callback. Avoid calling pagination triggers directly from `itemBuilder`; render callbacks can run repeatedly for reasons unrelated to user scroll intent.
193
+
194
+If the UI has only a standard `ListView`, prefer triggering `loadNextPage()` from a scroll notification, scroll controller, or explicit "load more" affordance. Keep the list UI in `views/components/` as `StatelessWidget` components and pass callbacks from the page.