@tduncan / Cortana / commits / d2480e0

refactor: simplify code patterns, improve null safety in backup service, and add architectural documentation for feature-based development.

abdulmominsakib committed Jul 5, 2026 at 10:25 UTC d2480e0a30e60b4bb02f759597e2cf621cacdcd0
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.