@tduncan / Cortana / commits / e1c75d6

refactor: move and consolidate ModelPickerSheet implementation into the features/models/views directory

abdulmominsakib committed Jul 6, 2026 at 10:04 UTC e1c75d65b54223ad4241fd387efd78365a460c56
4 files changed +328 -61
lib/features/chat/views/chat_screen.dart
+52 -51
@@ -20,7 +20,7 @@ import 'package:localmind/features/conversations/providers/conversation_provider
20 as conv;
21 import 'package:localmind/features/conversations/views/components/conversation_list.dart';
22 import 'package:localmind/features/conversations/views/components/rename_conversation_dialog.dart';
23 -import 'package:localmind/features/models/screens/model_picker_sheet.dart';
23 +import 'package:localmind/features/models/views/model_picker_sheet.dart';
24 import 'package:localmind/features/personas/providers/personas_providers.dart';
25 import 'package:localmind/features/servers/providers/server_providers.dart';
26 import 'package:localmind/features/saved_messages/views/components/save_message_sheet.dart';
@@ -368,26 +368,26 @@ class _ChatBody extends ConsumerWidget {
368 child: Stack(
369 children: [
370 _MessageArea(
371 - isLoading: isLoading,
372 - messages: messages,
373 - activeConversation: activeConversation,
374 - errorMessage: errorMessage,
375 - selectedModel: selectedModel,
376 - isStreaming: isStreaming,
377 - scrollController: scrollController,
378 - effectiveBottomInset: effectiveBottomInset,
379 - keyboardBottomInset: keyboardBottomInset,
380 - onModelPicker: onModelPicker,
381 - ),
382 - _ChatBottomBar(
383 - isStreaming: isStreaming,
384 - keyboardBottomInset: keyboardBottomInset,
385 - inputFocusNode: inputFocusNode,
386 - ),
387 - ],
371 + isLoading: isLoading,
372 + messages: messages,
373 + activeConversation: activeConversation,
374 + errorMessage: errorMessage,
375 + selectedModel: selectedModel,
376 + isStreaming: isStreaming,
377 + scrollController: scrollController,
378 + effectiveBottomInset: effectiveBottomInset,
379 + keyboardBottomInset: keyboardBottomInset,
380 + onModelPicker: onModelPicker,
381 + ),
382 + _ChatBottomBar(
383 + isStreaming: isStreaming,
384 + keyboardBottomInset: keyboardBottomInset,
385 + inputFocusNode: inputFocusNode,
386 + ),
387 + ],
388 + ),
389 ),
390 ),
390 - ),
391 ],
392 );
393 }
@@ -526,10 +526,9 @@ class _MessageArea extends ConsumerWidget {
526 void _clearPersonas(WidgetRef ref) {
527 final activeConv = ref.read(conv.activeConversationProvider);
528 if (activeConv != null) {
529 - ref.read(conv.conversationsProvider.notifier).updatePersonas(
530 - activeConv.id,
531 - const [],
532 - );
529 + ref
530 + .read(conv.conversationsProvider.notifier)
531 + .updatePersonas(activeConv.id, const []);
532 } else {
533 ref.read(selectedPersonasProvider.notifier).clear();
534 }
@@ -621,7 +620,10 @@ class _ScreenAppBar extends ConsumerWidget {
620 Expanded(
621 child: Text(
622 l10n.selected_count(selectedMessageIds.length),
624 - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
623 + style: const TextStyle(
624 + fontSize: 16,
625 + fontWeight: FontWeight.w600,
626 + ),
627 ),
628 ),
629 IconButton(
@@ -629,14 +631,22 @@ class _ScreenAppBar extends ConsumerWidget {
631 tooltip: l10n.export_conversation,
632 onPressed: selectedMessageIds.isEmpty
633 ? null
632 - : () => _shareSelectedMessages(context, ref, selectedMessageIds),
634 + : () => _shareSelectedMessages(
635 + context,
636 + ref,
637 + selectedMessageIds,
638 + ),
639 ),
640 IconButton(
641 icon: const Icon(Icons.delete_outline, color: Colors.red),
642 tooltip: l10n.delete,
643 onPressed: selectedMessageIds.isEmpty
644 ? null
639 - : () => _deleteSelectedMessages(context, ref, selectedMessageIds),
645 + : () => _deleteSelectedMessages(
646 + context,
647 + ref,
648 + selectedMessageIds,
649 + ),
650 ),
651 ],
652 ),
@@ -663,10 +673,7 @@ class _ScreenAppBar extends ConsumerWidget {
673 Expanded(
674 child: Text(
675 _appBarTitle(l10n),
666 - style: const TextStyle(
667 - fontSize: 18,
668 - fontWeight: FontWeight.bold,
669 - ),
676 + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
677 overflow: TextOverflow.ellipsis,
678 ),
679 ),
@@ -861,10 +868,10 @@ class _ChatModeIconButton extends StatelessWidget {
868 final ghostActive = isTemporary;
869
870 if (showGhost) {
864 - final activeColor =
865 - isDark ? const Color(0xFFE6C35C) : const Color(0xFF9A7B1A);
866 - final inactiveColor =
867 - isDark ? Colors.white54 : Colors.black45;
871 + final activeColor = isDark
872 + ? const Color(0xFFE6C35C)
873 + : const Color(0xFF9A7B1A);
874 + final inactiveColor = isDark ? Colors.white54 : Colors.black45;
875
876 return IconButton(
877 onPressed: onPressed,
@@ -878,17 +885,9 @@ class _ChatModeIconButton extends StatelessWidget {
885 color: activeColor.withValues(alpha: 0.25),
886 shape: BoxShape.circle,
887 ),
881 - child: Icon(
882 - LucideIcons.ghost,
883 - size: 20,
884 - color: activeColor,
885 - ),
888 + child: Icon(LucideIcons.ghost, size: 20, color: activeColor),
889 )
887 - : Icon(
888 - LucideIcons.ghost,
889 - size: 22,
890 - color: inactiveColor,
891 - ),
890 + : Icon(LucideIcons.ghost, size: 22, color: inactiveColor),
891 );
892 }
893
@@ -918,7 +917,8 @@ class _ChatBottomBar extends ConsumerWidget {
917 @override
918 Widget build(BuildContext context, WidgetRef ref) {
919 final isTemporary = ref.watch(chatProvider.select((s) => s.isTemporary));
921 - final keyboardIncognito = isTemporary &&
920 + final keyboardIncognito =
921 + isTemporary &&
922 ref.watch(settingsProvider.select((s) => s.tempChatKeyboardIncognito));
923 final totalTokenCount = ref.watch(
924 conv.activeConversationProvider.select((c) => c?.totalTokenCount),
@@ -985,10 +985,10 @@ class _TokenUsageIndicator extends ConsumerWidget {
985 Widget build(BuildContext context, WidgetRef ref) {
986 final theme = Theme.of(context);
987 final isDark = theme.brightness == Brightness.dark;
988 - final liveContextLength =
989 - ref.watch(activeModelContextLengthProvider).value;
990 - final fallbackContextLength =
991 - ref.watch(settingsProvider.select((s) => s.contextLength));
988 + final liveContextLength = ref.watch(activeModelContextLengthProvider).value;
989 + final fallbackContextLength = ref.watch(
990 + settingsProvider.select((s) => s.contextLength),
991 + );
992 final int contextLength = liveContextLength ?? fallbackContextLength;
993
994 // totalTokenCount only updates once a response finishes (it's the real
@@ -1051,8 +1051,9 @@ class _TokenUsageIndicator extends ConsumerWidget {
1051 builder: (ctx) {
1052 final sheetTheme = Theme.of(ctx);
1053 final isDark = sheetTheme.brightness == Brightness.dark;
1054 - final muted =
1055 - isDark ? AppColors.darkMutedText : AppColors.lightMutedText;
1054 + final muted = isDark
1055 + ? AppColors.darkMutedText
1056 + : AppColors.lightMutedText;
1057 return SafeArea(
1058 child: Padding(
1059 padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
lib/features/models/components/model_list.dart
+49 -1
@@ -178,9 +178,57 @@ class _ModelListState extends ConsumerState<ModelList> {
178 Widget build(BuildContext context) {
179 final l10n = AppLocalizations.of(context)!;
180 final searchQuery = ref.watch(modelSearchQueryProvider);
181 - final modelsAsync = ref.watch(availableModelsProvider(widget.serverId));
181 final metadata = ref.watch(modelMetadataProvider);
182
183 + final connectionStatus = ref.watch(connectionStatusProvider);
184 +
185 + if (connectionStatus == ConnectionStatus.error) {
186 + return Center(
187 + child: SingleChildScrollView(
188 + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
189 + child: Column(
190 + mainAxisAlignment: MainAxisAlignment.center,
191 + children: [
192 + Icon(Icons.cloud_off_rounded, size: 64, color: Colors.red[300]),
193 + const SizedBox(height: 16),
194 + Text(
195 + 'Server Offline',
196 + style: TextStyle(
197 + fontSize: 18,
198 + fontWeight: FontWeight.bold,
199 + color: widget.isDark ? Colors.white : Colors.black,
200 + ),
201 + ),
202 + const SizedBox(height: 8),
203 + Text(
204 + 'Could not establish a connection to the server. Please check if your server is running and the host/port settings are correct.',
205 + style: TextStyle(
206 + fontSize: 13,
207 + color: widget.isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
208 + ),
209 + textAlign: TextAlign.center,
210 + ),
211 + const SizedBox(height: 24),
212 + OutlinedButton.icon(
213 + onPressed: () async {
214 + await ref.read(connectionStatusProvider.notifier).refresh();
215 + ref.invalidate(availableModelsProvider(widget.serverId));
216 + },
217 + icon: const Icon(Icons.refresh),
218 + label: const Text('Retry Connection'),
219 + ),
220 + ],
221 + ),
222 + ),
223 + );
224 + }
225 +
226 + if (connectionStatus == ConnectionStatus.checking) {
227 + return const Center(child: CircularProgressIndicator());
228 + }
229 +
230 + final modelsAsync = ref.watch(availableModelsProvider(widget.serverId));
231 +
232 return modelsAsync.when(
233 loading: () => const Center(child: CircularProgressIndicator()),
234 error: (err, _) => Center(
lib/features/models/views/model_picker_sheet.dart renamed
+221 -9
@@ -10,8 +10,9 @@ import 'package:localmind/features/on_device/components/on_device_picker_section
10 import 'package:localmind/features/on_device/providers/on_device_providers.dart';
11 import 'package:localmind/features/servers/providers/server_providers.dart';
12 import 'package:localmind/features/lm_studio_catalog/views/lm_studio_download_widgets.dart';
13 +import 'package:localmind/core/providers/app_providers.dart';
14 +import 'package:localmind/features/conversations/providers/conversation_providers.dart' as conv;
15 import 'package:localmind/l10n/app_localizations.dart';
14 -import '../components/model_context_length_section.dart';
16 import '../components/model_list.dart';
17 import '../components/model_search_field.dart';
18 import '../components/model_sort_control.dart';
@@ -28,11 +29,12 @@ class ModelPickerSheet extends ConsumerStatefulWidget {
29
30 class _ModelPickerSheetState extends ConsumerState<ModelPickerSheet> {
31 @override
31 - void dispose() {
32 + void deactivate() {
33 // Reset search when the sheet closes so the next open starts from a
34 // clean, matching state instead of an empty box that's still filtering.
34 - ref.read(modelSearchQueryProvider.notifier).clear();
35 - super.dispose();
35 + final searchNotifier = ref.read(modelSearchQueryProvider.notifier);
36 + Future.microtask(() => searchNotifier.clear());
37 + super.deactivate();
38 }
39
40 @override
@@ -150,8 +152,6 @@ class _ModelPickerSheetState extends ConsumerState<ModelPickerSheet> {
152 ),
153 ),
154 const SizedBox(height: 12),
153 - ModelContextLengthSection(isDark: isDark),
154 - const SizedBox(height: 12),
155 Row(
156 children: [
157 const Expanded(child: ModelSearchField()),
@@ -221,7 +221,7 @@ class _ModelPickerSheetState extends ConsumerState<ModelPickerSheet> {
221 }
222 }
223
224 -class _ModelPickerHeader extends StatelessWidget {
224 +class _ModelPickerHeader extends ConsumerWidget {
225 const _ModelPickerHeader({
226 required this.isDark,
227 required this.isThinking,
@@ -241,11 +241,14 @@ class _ModelPickerHeader extends StatelessWidget {
241 final VoidCallback? onUnloadAll;
242
243 @override
244 - Widget build(BuildContext context) {
244 + Widget build(BuildContext context, WidgetRef ref) {
245 final l10n = AppLocalizations.of(context)!;
246 + final settings = ref.watch(settingsProvider);
247 + final activeConv = ref.watch(conv.activeConversationProvider);
248 + final contextLength = activeConv?.contextLength ?? settings.contextLength;
249
250 return Row(
248 - crossAxisAlignment: CrossAxisAlignment.start,
251 + crossAxisAlignment: CrossAxisAlignment.center,
252 children: [
253 Expanded(
254 child: Column(
@@ -299,6 +302,48 @@ class _ModelPickerHeader extends StatelessWidget {
302 Row(
303 mainAxisSize: MainAxisSize.min,
304 children: [
305 + Material(
306 + color: Colors.transparent,
307 + child: InkWell(
308 + onTap: () => _showContextLengthDialog(
309 + context,
310 + ref,
311 + contextLength,
312 + activeConv?.id,
313 + ),
314 + borderRadius: BorderRadius.circular(16),
315 + child: Container(
316 + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
317 + decoration: BoxDecoration(
318 + border: Border.all(
319 + color: isDark ? Colors.grey[800]! : Colors.grey[300]!,
320 + ),
321 + borderRadius: BorderRadius.circular(16),
322 + color: isDark ? Colors.grey[900] : Colors.grey[50],
323 + ),
324 + child: Row(
325 + mainAxisSize: MainAxisSize.min,
326 + children: [
327 + Icon(
328 + Icons.layers_outlined,
329 + size: 14,
330 + color: isDark ? Colors.grey[400] : Colors.grey[600],
331 + ),
332 + const SizedBox(width: 4),
333 + Text(
334 + '$contextLength',
335 + style: TextStyle(
336 + fontSize: 12,
337 + fontWeight: FontWeight.w600,
338 + color: isDark ? Colors.white : Colors.black87,
339 + ),
340 + ),
341 + ],
342 + ),
343 + ),
344 + ),
345 + ),
346 + const SizedBox(width: 4),
347 if (onUnloadAll != null)
348 IconButton(
349 onPressed: onUnloadAll,
@@ -328,3 +373,170 @@ class _ModelPickerHeader extends StatelessWidget {
373 );
374 }
375 }
376 +
377 +void _showContextLengthDialog(
378 + BuildContext context,
379 + WidgetRef ref,
380 + int currentLength,
381 + String? activeConversationId,
382 +) {
383 + showDialog(
384 + context: context,
385 + builder: (context) => _ContextLengthEditDialog(
386 + initialValue: currentLength,
387 + activeConversationId: activeConversationId,
388 + ),
389 + );
390 +}
391 +
392 +class _ContextLengthEditDialog extends ConsumerStatefulWidget {
393 + const _ContextLengthEditDialog({
394 + required this.initialValue,
395 + required this.activeConversationId,
396 + });
397 +
398 + final int initialValue;
399 + final String? activeConversationId;
400 +
401 + @override
402 + ConsumerState<_ContextLengthEditDialog> createState() =>
403 + __ContextLengthEditDialogState();
404 +}
405 +
406 +class __ContextLengthEditDialogState
407 + extends ConsumerState<_ContextLengthEditDialog> {
408 + late final TextEditingController _controller;
409 + late final FocusNode _focusNode;
410 +
411 + @override
412 + void initState() {
413 + super.initState();
414 + _controller = TextEditingController(text: widget.initialValue.toString());
415 + _focusNode = FocusNode();
416 + }
417 +
418 + @override
419 + void dispose() {
420 + _controller.dispose();
421 + _focusNode.dispose();
422 + super.dispose();
423 + }
424 +
425 + void _save(String value) {
426 + final parsed = int.tryParse(value.trim());
427 + if (parsed != null && parsed > 0) {
428 + if (widget.activeConversationId == null) {
429 + ref.read(settingsProvider.notifier).setContextLength(parsed);
430 + } else {
431 + ref.read(conv.conversationsProvider.notifier).updateChatParams(
432 + widget.activeConversationId!,
433 + contextLength: parsed,
434 + );
435 + }
436 + }
437 + Navigator.of(context).pop();
438 + }
439 +
440 + @override
441 + Widget build(BuildContext context) {
442 + final theme = Theme.of(context);
443 + final isDark = theme.brightness == Brightness.dark;
444 + final l10n = AppLocalizations.of(context)!;
445 +
446 + final suggestions = [2048, 4096, 8192, 16384, 32768, 65536, 131072];
447 +
448 + return Dialog(
449 + backgroundColor: isDark ? AppColors.darkBackground : AppColors.lightSurface,
450 + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
451 + child: SingleChildScrollView(
452 + child: Padding(
453 + padding: const EdgeInsets.all(20),
454 + child: Column(
455 + mainAxisSize: MainAxisSize.min,
456 + crossAxisAlignment: CrossAxisAlignment.stretch,
457 + children: [
458 + Text(
459 + l10n.context_length,
460 + style: TextStyle(
461 + fontSize: 16,
462 + fontWeight: FontWeight.bold,
463 + color: isDark ? Colors.white : Colors.black,
464 + ),
465 + ),
466 + const SizedBox(height: 16),
467 + TextField(
468 + controller: _controller,
469 + focusNode: _focusNode,
470 + autofocus: true,
471 + keyboardType: TextInputType.number,
472 + decoration: InputDecoration(
473 + labelText: 'Tokens',
474 + hintText: 'Enter context length...',
475 + border: OutlineInputBorder(
476 + borderRadius: BorderRadius.circular(10),
477 + ),
478 + suffixIcon: IconButton(
479 + icon: const Icon(Icons.clear, size: 18),
480 + onPressed: () => _controller.clear(),
481 + ),
482 + ),
483 + onSubmitted: _save,
484 + ),
485 + const SizedBox(height: 12),
486 + SingleChildScrollView(
487 + scrollDirection: Axis.horizontal,
488 + child: Row(
489 + children: suggestions.map((val) {
490 + final label = val >= 1024 ? '${val ~/ 1024}K' : '$val';
491 + return Padding(
492 + padding: const EdgeInsets.only(right: 8),
493 + child: ActionChip(
494 + label: Text(label),
495 + labelStyle: TextStyle(
496 + fontSize: 12,
497 + fontWeight: FontWeight.w500,
498 + color: isDark ? Colors.white70 : Colors.black87,
499 + ),
500 + backgroundColor: isDark ? Colors.grey[850] : Colors.grey[200],
501 + shape: RoundedRectangleBorder(
502 + borderRadius: BorderRadius.circular(8),
503 + side: BorderSide.none,
504 + ),
505 + onPressed: () {
506 + _controller.text = val.toString();
507 + _controller.selection = TextSelection.fromPosition(
508 + TextPosition(offset: _controller.text.length),
509 + );
510 + },
511 + ),
512 + );
513 + }).toList(),
514 + ),
515 + ),
516 + const SizedBox(height: 16),
517 + Row(
518 + mainAxisAlignment: MainAxisAlignment.end,
519 + children: [
520 + TextButton(
521 + onPressed: () => Navigator.of(context).pop(),
522 + child: Text(l10n.cancel),
523 + ),
524 + const SizedBox(width: 8),
525 + FilledButton(
526 + onPressed: () => _save(_controller.text),
527 + style: FilledButton.styleFrom(
528 + shape: RoundedRectangleBorder(
529 + borderRadius: BorderRadius.circular(8),
530 + ),
531 + ),
532 + child: Text(l10n.save),
533 + ),
534 + ],
535 + ),
536 + ],
537 + ),
538 + ),
539 + ),
540 + );
541 + }
542 +}
lib/features/settings/views/settings_screen.dart
+6
@@ -1808,6 +1808,12 @@ class _EngineDropdown extends StatelessWidget {
1808 l10n.tts_engine_kitten,
1809 Icons.auto_awesome_rounded,
1810 ),
1811 + _engineItem(
1812 + context,
1813 + EngineId.piper,
1814 + EngineMeta.piper.name,
1815 + Icons.memory_rounded,
1816 + ),
1817 ],
1818 onChanged: (value) {
1819 if (value != null) {