9 files changed
+773
-762
lib/features/chat/views/chat_screen.dart
+8
-710
@@ -2,13 +2,7 @@ import 'dart:convert';
2
3
import 'package:flutter/material.dart';
4
import 'package:flutter_riverpod/flutter_riverpod.dart';
5
-import 'package:go_router/go_router.dart';
6
-import 'package:hugeicons/hugeicons.dart';
5
import 'package:shadcn_ui/shadcn_ui.dart';
8
-import 'package:localmind/core/models/enums.dart';
9
-import 'package:localmind/core/providers/app_providers.dart';
10
-import 'package:localmind/core/routes/app_routes.dart';
11
-import 'package:localmind/core/theme/colors.dart';
6
import 'package:localmind/core/utils/system_insets.dart';
7
import 'package:localmind/l10n/app_localizations.dart';
8
import 'package:localmind/core/providers/storage_providers.dart';
@@ -18,30 +12,23 @@ import 'package:localmind/core/services/share_service.dart';
12
import 'package:localmind/features/conversations/data/models/conversation.dart';
13
import 'package:localmind/features/conversations/providers/conversation_providers.dart'
14
as conv;
21
-import 'package:localmind/features/conversations/views/components/conversation_list.dart';
15
import 'package:localmind/features/conversations/views/components/rename_conversation_dialog.dart';
16
import 'package:localmind/features/models/views/model_picker_sheet.dart';
17
import 'package:localmind/features/personas/providers/personas_providers.dart';
18
import 'package:localmind/features/servers/providers/server_providers.dart';
26
-import 'package:localmind/features/saved_messages/views/components/save_message_sheet.dart';
19
import 'package:localmind/features/tts/views/components/tts_player_bar.dart';
20
import '../data/export_service.dart';
29
-import '../data/models/message.dart';
30
-import '../providers/chat_mcp_providers.dart';
21
import '../providers/chat_providers.dart';
22
import 'components/chat_auto_scroll_controller.dart';
33
-import 'components/model_info_sheet.dart';
34
-import 'components/chat_input_bar.dart';
35
-import 'components/chat_settings_sheet.dart';
36
-import 'components/edit_message_dialog.dart';
23
import 'components/notification_permission_banner.dart';
38
-import 'components/message_list/message_list.dart';
39
-import 'components/message_list/empty_state.dart';
40
-import 'components/message_list/corrupted_state.dart';
24
import 'components/top_bar/model_top_bar.dart';
25
import 'components/top_bar/connection_banner.dart';
26
import 'components/top_bar/persona_indicator.dart';
44
-import 'components/top_bar/smart_reply_chips.dart';
27
+import 'components/top_bar/screen_app_bar.dart';
28
+import 'components/message_list/message_area.dart';
29
+import 'components/chat_bottom_bar.dart';
30
+import 'package:localmind/core/models/enums.dart';
31
+import 'package:localmind/features/conversations/views/components/conversation_list.dart';
32
import 'package:localmind/features/personas/views/components/persona_picker_sheet.dart';
33
34
/// Height the always-on token usage row adds below the input box (its own
@@ -325,7 +312,7 @@ class _ChatBody extends ConsumerWidget {
312
return Column(
313
children: [
314
ModelTopBar(selectedModel: selectedModel, onModelTap: onModelPicker),
328
- _ScreenAppBar(
315
+ ScreenAppBar(
316
activeConversation: activeConversation,
317
isDark: isDark,
318
hasPersonas: hasPersonas,
@@ -367,7 +354,7 @@ class _ChatBody extends ConsumerWidget {
354
: const BoxDecoration(),
355
child: Stack(
356
children: [
370
- _MessageArea(
357
+ MessageArea(
358
isLoading: isLoading,
359
messages: messages,
360
activeConversation: activeConversation,
@@ -379,7 +366,7 @@ class _ChatBody extends ConsumerWidget {
366
keyboardBottomInset: keyboardBottomInset,
367
onModelPicker: onModelPicker,
368
),
382
- _ChatBottomBar(
369
+ ChatBottomBar(
370
isStreaming: isStreaming,
371
keyboardBottomInset: keyboardBottomInset,
372
inputFocusNode: inputFocusNode,
@@ -393,136 +380,6 @@ class _ChatBody extends ConsumerWidget {
380
}
381
}
382
396
-class _MessageArea extends ConsumerWidget {
397
- const _MessageArea({
398
- required this.isLoading,
399
- required this.messages,
400
- required this.activeConversation,
401
- this.errorMessage,
402
- required this.selectedModel,
403
- required this.isStreaming,
404
- required this.scrollController,
405
- required this.effectiveBottomInset,
406
- required this.keyboardBottomInset,
407
- required this.onModelPicker,
408
- });
409
-
410
- final bool isLoading;
411
- final List<Message> messages;
412
- final dynamic activeConversation;
413
- final String? errorMessage;
414
- final dynamic selectedModel;
415
- final bool isStreaming;
416
- final ScrollController scrollController;
417
- final double effectiveBottomInset;
418
- final double keyboardBottomInset;
419
- final VoidCallback onModelPicker;
420
-
421
- @override
422
- Widget build(BuildContext context, WidgetRef ref) {
423
- final l10n = AppLocalizations.of(context)!;
424
-
425
- if (isLoading) {
426
- return const Center(child: CircularProgressIndicator(strokeWidth: 2));
427
- }
428
-
429
- if (messages.isEmpty && activeConversation != null) {
430
- return CorruptedChatState(
431
- conversation: activeConversation,
432
- errorMessage: errorMessage,
433
- onStartNewChat: () =>
434
- ref.read(chatProvider.notifier).startNewConversation(),
435
- );
436
- }
437
-
438
- if (messages.isEmpty) {
439
- return EmptyState(
440
- onQuickPrompt: (prompt) =>
441
- ref.read(chatProvider.notifier).sendMessage(prompt),
442
- quickPrompts: [
443
- l10n.quick_write,
444
- l10n.quick_explain,
445
- l10n.quick_debug,
446
- l10n.quick_async,
447
- ],
448
- recentConversations: ref.watch(conv.recentConversationsProvider),
449
- onSeeAll: () => context.push(AppRoutes.chatHistory),
450
- selectedModel: selectedModel,
451
- onModelTap: onModelPicker,
452
- selectedPersonas: ref.watch(selectedPersonasProvider),
453
- onPersonaTap: () => showPersonaPickerSheet(
454
- context,
455
- mode: PersonaPickerMode.preselection,
456
- ),
457
- );
458
- }
459
-
460
- return MessageList(
461
- scrollController: scrollController,
462
- messages: messages,
463
- allMessages: ref.watch(chatProvider.select((s) => s.allMessages)),
464
- isStreaming: isStreaming,
465
- onRetry: (messageId) =>
466
- ref.read(chatProvider.notifier).retryMessage(messageId),
467
- onDelete: (messageId) =>
468
- ref.read(chatProvider.notifier).deleteMessage(messageId),
469
- onEdit: (messageId, currentContent) async {
470
- final result = await EditMessageDialog.showUserEdit(
471
- context,
472
- initialContent: currentContent,
473
- );
474
- if (result == null || result.content == currentContent) return;
475
- if (result.regenerate) {
476
- await ref
477
- .read(chatProvider.notifier)
478
- .editMessage(messageId, result.content);
479
- } else {
480
- await ref
481
- .read(chatProvider.notifier)
482
- .editMessageSaveOnly(messageId, result.content);
483
- }
484
- },
485
- onEditAssistant: (messageId, currentContent) async {
486
- final editL10n = AppLocalizations.of(context)!;
487
- final newContent = await EditMessageDialog.show(
488
- context,
489
- initialContent: currentContent,
490
- description: editL10n.edit_assistant_message_desc,
491
- saveLabel: editL10n.save,
492
- );
493
- if (newContent != null && newContent != currentContent) {
494
- await ref
495
- .read(chatProvider.notifier)
496
- .editAssistantMessage(messageId, newContent);
497
- }
498
- },
499
- onBranch: (messageId) =>
500
- ref.read(chatProvider.notifier).branchFromMessage(messageId),
501
- onContinue: (messageId) =>
502
- ref.read(chatProvider.notifier).continueFromMessage(messageId),
503
- onCycleVariant: (messageId, direction) => ref
504
- .read(chatProvider.notifier)
505
- .cycleMessageVariant(messageId, direction),
506
- onSave: (message) => showSaveMessageSheet(
507
- context,
508
- ref,
509
- message,
510
- isTemporaryChat: ref.read(chatProvider.select((s) => s.isTemporary)),
511
- ),
512
- onShare: (message) => ShareService.shareText(message.content),
513
- onGenerateResponse: () =>
514
- ref.read(chatProvider.notifier).generateResponseForLastUser(),
515
- onModelPicker: onModelPicker,
516
- onModelLongPress: (modelId) => showModelInfoSheet(context, ref, modelId),
517
- hasSmartReplies:
518
- !isStreaming &&
519
- keyboardBottomInset == 0 &&
520
- (ref.watch(smartRepliesProvider).asData?.value.isNotEmpty ?? false),
521
- bottomInset: effectiveBottomInset,
522
- );
523
- }
524
-}
525
-
383
void _clearPersonas(WidgetRef ref) {
384
final activeConv = ref.read(conv.activeConversationProvider);
385
if (activeConv != null) {
@@ -576,562 +433,3 @@ void _handleChatModeAction(
433
ref.read(chatProvider.notifier).startNewConversation();
434
}
435
579
-class _ScreenAppBar extends ConsumerWidget {
580
- const _ScreenAppBar({
581
- required this.activeConversation,
582
- required this.isDark,
583
- required this.hasPersonas,
584
- required this.isTemporary,
585
- required this.hasMessages,
586
- required this.onMenuAction,
587
- required this.onPersonaPicker,
588
- required this.onChatModeAction,
589
- });
590
-
591
- final Conversation? activeConversation;
592
- final bool isDark;
593
- final bool hasPersonas;
594
- final bool isTemporary;
595
- final bool hasMessages;
596
- final void Function(String) onMenuAction;
597
- final VoidCallback onPersonaPicker;
598
- final VoidCallback onChatModeAction;
599
-
600
- @override
601
- Widget build(BuildContext context, WidgetRef ref) {
602
- final l10n = AppLocalizations.of(context)!;
603
- final settings = ref.watch(settingsProvider);
604
- final mcpConfig = ref.watch(chatMcpConfigProvider);
605
- final isMcpEnabled = settings.mcpEnabled && mcpConfig.enabled;
606
- final messageSelectionMode = ref.watch(messageSelectionModeProvider);
607
- final selectedMessageIds = ref.watch(selectedMessageIdsProvider);
608
-
609
- if (messageSelectionMode) {
610
- return Container(
611
- height: 56,
612
- padding: const EdgeInsets.symmetric(horizontal: 4),
613
- child: Row(
614
- children: [
615
- IconButton(
616
- icon: const Icon(Icons.close),
617
- onPressed: () =>
618
- ref.read(messageSelectionModeProvider.notifier).disable(),
619
- ),
620
- Expanded(
621
- child: Text(
622
- l10n.selected_count(selectedMessageIds.length),
623
- style: const TextStyle(
624
- fontSize: 16,
625
- fontWeight: FontWeight.w600,
626
- ),
627
- ),
628
- ),
629
- IconButton(
630
- icon: const Icon(Icons.ios_share),
631
- tooltip: l10n.export_conversation,
632
- onPressed: selectedMessageIds.isEmpty
633
- ? null
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
645
- : () => _deleteSelectedMessages(
646
- context,
647
- ref,
648
- selectedMessageIds,
649
- ),
650
- ),
651
- ],
652
- ),
653
- );
654
- }
655
-
656
- return Container(
657
- height: 56,
658
- padding: const EdgeInsets.symmetric(horizontal: 4),
659
- child: Row(
660
- children: [
661
- ShadResponsiveBuilder(
662
- builder: (context, breakpoint) {
663
- final isDesktop =
664
- breakpoint >= ShadTheme.of(context).breakpoints.md;
665
- if (isDesktop) return const SizedBox.shrink();
666
- return IconButton(
667
- icon: const Icon(Icons.menu),
668
- onPressed: () => Scaffold.of(context).openDrawer(),
669
- );
670
- },
671
- ),
672
- const SizedBox(width: 8),
673
- Expanded(
674
- child: Text(
675
- _appBarTitle(l10n),
676
- style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
677
- overflow: TextOverflow.ellipsis,
678
- ),
679
- ),
680
- Stack(
681
- children: [
682
- IconButton(
683
- icon: HugeIcon(
684
- icon: HugeIcons.strokeRoundedFilterHorizontal,
685
- size: 24,
686
- color: isDark ? Colors.white70 : Colors.black87,
687
- ),
688
- onPressed: () =>
689
- showChatSettingsSheet(context, initialTab: 'parameters'),
690
- tooltip: l10n.chat_parameters_tooltip,
691
- ),
692
- PositionedDirectional(
693
- top: 4,
694
- end: 0,
695
- child: Container(
696
- padding: const EdgeInsets.all(2),
697
- decoration: BoxDecoration(
698
- color: isMcpEnabled ? Colors.green : Colors.grey,
699
- shape: BoxShape.circle,
700
- ),
701
- child: const HugeIcon(
702
- icon: HugeIcons.strokeRoundedTools,
703
- size: 10,
704
- color: Colors.white,
705
- ),
706
- ),
707
- ),
708
- ],
709
- ),
710
- _ChatModeIconButton(
711
- hasMessages: hasMessages,
712
- isTemporary: isTemporary,
713
- isDark: isDark,
714
- onPressed: onChatModeAction,
715
- ),
716
- PopupMenuButton<String>(
717
- icon: const Icon(Icons.more_vert),
718
- onSelected: onMenuAction,
719
- itemBuilder: (context) => [
720
- PopupMenuItem(
721
- value: 'new_chat',
722
- child: ListTile(
723
- leading: const Icon(Icons.add),
724
- title: Text(l10n.nav_new_chat),
725
- contentPadding: EdgeInsets.zero,
726
- ),
727
- ),
728
- if (activeConversation != null && !isTemporary) ...[
729
- PopupMenuItem(
730
- value: 'rename',
731
- child: ListTile(
732
- leading: const Icon(Icons.drive_file_rename_outline),
733
- title: Text(l10n.rename_conversation),
734
- contentPadding: EdgeInsets.zero,
735
- ),
736
- ),
737
- PopupMenuItem(
738
- value: 'move_to_folder',
739
- child: ListTile(
740
- leading: const Icon(Icons.folder_outlined),
741
- title: Text(l10n.move_to_folder),
742
- contentPadding: EdgeInsets.zero,
743
- ),
744
- ),
745
- ],
746
- if (hasMessages) ...[
747
- PopupMenuItem(
748
- value: 'export_chat',
749
- child: ListTile(
750
- leading: const Icon(Icons.upload_outlined),
751
- title: Text(l10n.export_conversation),
752
- contentPadding: EdgeInsets.zero,
753
- ),
754
- ),
755
- PopupMenuItem(
756
- value: 'share_chat',
757
- child: ListTile(
758
- leading: const Icon(Icons.share_outlined),
759
- title: Text(l10n.share_conversation),
760
- contentPadding: EdgeInsets.zero,
761
- ),
762
- ),
763
- ],
764
- PopupMenuItem(
765
- value: 'persona',
766
- child: ListTile(
767
- leading: Icon(
768
- hasPersonas ? Icons.swap_horiz : Icons.smart_toy_outlined,
769
- ),
770
- title: Text(
771
- hasPersonas ? l10n.change_persona : l10n.set_persona,
772
- ),
773
- contentPadding: EdgeInsets.zero,
774
- ),
775
- ),
776
- PopupMenuItem(
777
- value: 'clear',
778
- child: ListTile(
779
- leading: const Icon(Icons.delete_outline),
780
- title: Text(l10n.clear_conversation),
781
- contentPadding: EdgeInsets.zero,
782
- ),
783
- ),
784
- ],
785
- ),
786
- ],
787
- ),
788
- );
789
- }
790
-
791
- String _appBarTitle(AppLocalizations l10n) {
792
- if (isTemporary) return l10n.temporary_chat;
793
- if (activeConversation?.title.isNotEmpty == true) {
794
- return activeConversation!.title;
795
- }
796
- return l10n.nav_new_chat;
797
- }
798
-
799
- Future<void> _shareSelectedMessages(
800
- BuildContext context,
801
- WidgetRef ref,
802
- Set<String> selectedIds,
803
- ) async {
804
- final messages = ref
805
- .read(chatProvider)
806
- .messages
807
- .where((m) => selectedIds.contains(m.id))
808
- .toList();
809
- if (messages.isEmpty) return;
810
- final text = ExportService.exportAsText(messages);
811
- ref.read(messageSelectionModeProvider.notifier).disable();
812
- if (!context.mounted) return;
813
- await showExportChoiceDialog(context, content: text);
814
- }
815
-
816
- void _deleteSelectedMessages(
817
- BuildContext context,
818
- WidgetRef ref,
819
- Set<String> selectedIds,
820
- ) {
821
- final l10n = AppLocalizations.of(context)!;
822
- showDialog<void>(
823
- context: context,
824
- builder: (dialogContext) {
825
- return AlertDialog(
826
- title: Text(l10n.delete_message_title),
827
- content: Text(l10n.selected_count(selectedIds.length)),
828
- actions: [
829
- TextButton(
830
- onPressed: () => Navigator.pop(dialogContext),
831
- child: Text(l10n.cancel),
832
- ),
833
- TextButton(
834
- onPressed: () async {
835
- Navigator.pop(dialogContext);
836
- for (final id in selectedIds) {
837
- await ref.read(chatProvider.notifier).deleteMessage(id);
838
- }
839
- ref.read(messageSelectionModeProvider.notifier).disable();
840
- },
841
- style: TextButton.styleFrom(foregroundColor: Colors.red),
842
- child: Text(l10n.delete),
843
- ),
844
- ],
845
- );
846
- },
847
- );
848
- }
849
-}
850
-
851
-class _ChatModeIconButton extends StatelessWidget {
852
- const _ChatModeIconButton({
853
- required this.hasMessages,
854
- required this.isTemporary,
855
- required this.isDark,
856
- required this.onPressed,
857
- });
858
-
859
- final bool hasMessages;
860
- final bool isTemporary;
861
- final bool isDark;
862
- final VoidCallback onPressed;
863
-
864
- @override
865
- Widget build(BuildContext context) {
866
- final l10n = AppLocalizations.of(context)!;
867
- final showGhost = !hasMessages || isTemporary;
868
- final ghostActive = isTemporary;
869
-
870
- if (showGhost) {
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
- tooltip: ghostActive
879
- ? l10n.exit_temporary_chat_title
880
- : l10n.temporary_chat,
881
- icon: ghostActive
882
- ? Container(
883
- padding: const EdgeInsets.all(4),
884
- decoration: BoxDecoration(
885
- color: activeColor.withValues(alpha: 0.25),
886
- shape: BoxShape.circle,
887
- ),
888
- child: Icon(LucideIcons.ghost, size: 20, color: activeColor),
889
- )
890
- : Icon(LucideIcons.ghost, size: 22, color: inactiveColor),
891
- );
892
- }
893
-
894
- return IconButton(
895
- onPressed: onPressed,
896
- tooltip: l10n.nav_new_chat,
897
- icon: Icon(
898
- Icons.add_comment_outlined,
899
- size: 22,
900
- color: isDark ? Colors.white70 : Colors.black87,
901
- ),
902
- );
903
- }
904
-}
905
-
906
-class _ChatBottomBar extends ConsumerWidget {
907
- const _ChatBottomBar({
908
- required this.isStreaming,
909
- required this.keyboardBottomInset,
910
- required this.inputFocusNode,
911
- });
912
-
913
- final bool isStreaming;
914
- final double keyboardBottomInset;
915
- final FocusNode inputFocusNode;
916
-
917
- @override
918
- Widget build(BuildContext context, WidgetRef ref) {
919
- final isTemporary = ref.watch(chatProvider.select((s) => s.isTemporary));
920
- final keyboardIncognito =
921
- isTemporary &&
922
- ref.watch(settingsProvider.select((s) => s.tempChatKeyboardIncognito));
923
- final messages = ref.watch(chatProvider.select((s) => s.messages));
924
- final hasActiveConv = ref.watch(conv.activeConversationProvider) != null;
925
- final dbTokenCount = ref.watch(
926
- conv.activeConversationProvider.select((c) => c?.totalTokenCount),
927
- );
928
- final streamingId = ref.watch(chatProvider.select((s) => s.streamingMessage?.id));
929
-
930
- int totalTokenCount = 0;
931
- if (hasActiveConv && dbTokenCount != null && dbTokenCount > 0) {
932
- totalTokenCount = dbTokenCount;
933
- } else {
934
- final lastWithStats = messages.reversed
935
- .where((m) =>
936
- m.id != streamingId &&
937
- m.role == MessageRole.assistant &&
938
- (m.inputTokenCount != null || m.tokenCount != null))
939
- .firstOrNull;
940
- if (lastWithStats != null) {
941
- totalTokenCount = (lastWithStats.inputTokenCount ?? 0) + (lastWithStats.tokenCount ?? 0);
942
- } else {
943
- totalTokenCount = messages
944
- .where((m) => m.id != streamingId)
945
- .fold<int>(
946
- 0,
947
- (sum, m) => sum + (m.content.length / 4).round(),
948
- );
949
- }
950
- }
951
-
952
- return Positioned(
953
- bottom: 0,
954
- left: 0,
955
- right: 0,
956
- child: Container(
957
- decoration: BoxDecoration(
958
- gradient: LinearGradient(
959
- begin: Alignment.topCenter,
960
- end: Alignment.bottomCenter,
961
- colors: [
962
- Theme.of(context).scaffoldBackgroundColor.withValues(alpha: 0.1),
963
- Theme.of(context).scaffoldBackgroundColor.withValues(alpha: 0.8),
964
- Theme.of(context).scaffoldBackgroundColor,
965
- ],
966
- stops: const [0.0, 0.7, 1.0],
967
- ),
968
- ),
969
- child: Column(
970
- mainAxisSize: MainAxisSize.min,
971
- children: [
972
- if (!isStreaming && keyboardBottomInset == 0) ...[
973
- const SizedBox(height: 4),
974
- _SmartReplyChipsWrapper(),
975
- ],
976
- ChatInputBar(
977
- focusNode: inputFocusNode,
978
- isStreaming: isStreaming,
979
- keyboardIncognito: keyboardIncognito,
980
- onSend: (message, {attachments}) {
981
- ref
982
- .read(chatProvider.notifier)
983
- .sendMessage(message, attachments: attachments);
984
- },
985
- onStop: () => ref.read(chatProvider.notifier).cancelStream(),
986
- ),
987
- if (keyboardBottomInset == 0)
988
- Padding(
989
- padding: const EdgeInsetsDirectional.only(bottom: 6, end: 16),
990
- child: Align(
991
- alignment: AlignmentDirectional.centerEnd,
992
- child: _TokenUsageIndicator(
993
- totalTokenCount: totalTokenCount ?? 0,
994
- ),
995
- ),
996
- ),
997
- ],
998
- ),
999
- ),
1000
- );
1001
- }
1002
-}
1003
-
1004
-class _TokenUsageIndicator extends ConsumerWidget {
1005
- const _TokenUsageIndicator({required this.totalTokenCount});
1006
-
1007
- final int totalTokenCount;
1008
-
1009
- @override
1010
- Widget build(BuildContext context, WidgetRef ref) {
1011
- final theme = Theme.of(context);
1012
- final isDark = theme.brightness == Brightness.dark;
1013
- final liveContextLength = ref.watch(activeModelContextLengthProvider).value;
1014
- final fallbackContextLength = ref.watch(
1015
- settingsProvider.select((s) => s.contextLength),
1016
- );
1017
- final int contextLength = liveContextLength ?? fallbackContextLength;
1018
-
1019
- // totalTokenCount only updates once a response finishes (it's the real
1020
- // server-reported count), so while one is streaming in, grow the ring
1021
- // with a rough chars-per-token estimate of the in-progress reply —
1022
- // corrected back to the exact figure the moment the stream ends.
1023
- final isStreaming = ref.watch(chatProvider.select((s) => s.isStreaming));
1024
- final streamingLength = ref.watch(
1025
- chatProvider.select((s) => s.streamingMessage?.content.length ?? 0),
1026
- );
1027
- final estimatedTokenCount = isStreaming
1028
- ? totalTokenCount + (streamingLength / 4).round()
1029
- : totalTokenCount;
1030
-
1031
- final ratio = contextLength > 0
1032
- ? (estimatedTokenCount / contextLength).clamp(0.0, 1.0)
1033
- : 0.0;
1034
- final ringColor = ratio >= 0.9 ? Colors.red : theme.colorScheme.primary;
1035
-
1036
- return GestureDetector(
1037
- onTap: () => _showTokenUsageSheet(context, contextLength, ratio),
1038
- child: SizedBox(
1039
- height: 16,
1040
- child: Row(
1041
- mainAxisSize: MainAxisSize.min,
1042
- children: [
1043
- SizedBox(
1044
- width: 12,
1045
- height: 12,
1046
- child: CircularProgressIndicator(
1047
- value: ratio,
1048
- strokeWidth: 2,
1049
- backgroundColor: (isDark ? Colors.white : Colors.black)
1050
- .withValues(alpha: 0.12),
1051
- valueColor: AlwaysStoppedAnimation<Color>(ringColor),
1052
- ),
1053
- ),
1054
- const SizedBox(width: 4),
1055
- Text(
1056
- '${(ratio * 100).round()}%',
1057
- style: TextStyle(
1058
- fontSize: 10,
1059
- color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
1060
- ),
1061
- ),
1062
- ],
1063
- ),
1064
- ),
1065
- );
1066
- }
1067
-
1068
- void _showTokenUsageSheet(
1069
- BuildContext context,
1070
- int contextLength,
1071
- double ratio,
1072
- ) {
1073
- final l10n = AppLocalizations.of(context)!;
1074
- showModalBottomSheet(
1075
- context: context,
1076
- builder: (ctx) {
1077
- final sheetTheme = Theme.of(ctx);
1078
- final isDark = sheetTheme.brightness == Brightness.dark;
1079
- final muted = isDark
1080
- ? AppColors.darkMutedText
1081
- : AppColors.lightMutedText;
1082
- return SafeArea(
1083
- child: Padding(
1084
- padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
1085
- child: Column(
1086
- mainAxisSize: MainAxisSize.min,
1087
- crossAxisAlignment: CrossAxisAlignment.start,
1088
- children: [
1089
- Text(
1090
- l10n.token_usage_title,
1091
- style: sheetTheme.textTheme.titleMedium,
1092
- ),
1093
- const SizedBox(height: 12),
1094
- _usageRow(l10n.total_tokens_label, '$totalTokenCount', muted),
1095
- _usageRow(l10n.context_length, '$contextLength', muted),
1096
- _usageRow(
1097
- l10n.usage_percent_label,
1098
- '${(ratio * 100).toStringAsFixed(1)}%',
1099
- muted,
1100
- ),
1101
- ],
1102
- ),
1103
- ),
1104
- );
1105
- },
1106
- );
1107
- }
1108
-
1109
- Widget _usageRow(String label, String value, Color muted) {
1110
- return Padding(
1111
- padding: const EdgeInsets.symmetric(vertical: 4),
1112
- child: Row(
1113
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
1114
- children: [
1115
- Text(label, style: TextStyle(fontSize: 13, color: muted)),
1116
- Text(
1117
- value,
1118
- style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
1119
- ),
1120
- ],
1121
- ),
1122
- );
1123
- }
1124
-}
1125
-
1126
-class _SmartReplyChipsWrapper extends ConsumerWidget {
1127
- const _SmartReplyChipsWrapper();
1128
-
1129
- @override
1130
- Widget build(BuildContext context, WidgetRef ref) {
1131
- return SmartReplyChips(
1132
- onSend: (message) {
1133
- ref.read(chatProvider.notifier).sendMessage(message);
1134
- },
1135
- );
1136
- }
1137
-}
lib/features/chat/views/components/chat_bottom_bar.dart
new
+114
@@ -0,0 +1,114 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:localmind/core/models/enums.dart';
4
+import 'package:localmind/core/providers/app_providers.dart';
5
+import 'package:localmind/features/chat/views/components/chat_input_bar.dart';
6
+import 'package:localmind/features/chat/views/components/top_bar/smart_reply_chips.dart';
7
+import 'package:localmind/features/chat/providers/chat_providers.dart';
8
+import 'package:localmind/features/conversations/providers/conversation_providers.dart' as conv;
9
+
10
+class ChatBottomBar extends ConsumerWidget {
11
+ const ChatBottomBar({
12
+ super.key,
13
+ required this.isStreaming,
14
+ required this.keyboardBottomInset,
15
+ required this.inputFocusNode,
16
+ });
17
+
18
+ final bool isStreaming;
19
+ final double keyboardBottomInset;
20
+ final FocusNode inputFocusNode;
21
+
22
+ @override
23
+ Widget build(BuildContext context, WidgetRef ref) {
24
+ final isTemporary = ref.watch(chatProvider.select((s) => s.isTemporary));
25
+ final keyboardIncognito =
26
+ isTemporary &&
27
+ ref.watch(settingsProvider.select((s) => s.tempChatKeyboardIncognito));
28
+ final messages = ref.watch(chatProvider.select((s) => s.messages));
29
+ final hasActiveConv = ref.watch(conv.activeConversationProvider) != null;
30
+ final dbTokenCount = ref.watch(
31
+ conv.activeConversationProvider.select((c) => c?.totalTokenCount),
32
+ );
33
+ final streamingId = ref.watch(
34
+ chatProvider.select((s) => s.streamingMessage?.id),
35
+ );
36
+
37
+ int totalTokenCount = 0;
38
+ if (hasActiveConv && dbTokenCount != null && dbTokenCount > 0) {
39
+ totalTokenCount = dbTokenCount;
40
+ } else {
41
+ final lastWithStats = messages.reversed
42
+ .where(
43
+ (m) =>
44
+ m.id != streamingId &&
45
+ m.role == MessageRole.assistant &&
46
+ (m.inputTokenCount != null || m.tokenCount != null),
47
+ )
48
+ .firstOrNull;
49
+ if (lastWithStats != null) {
50
+ totalTokenCount =
51
+ (lastWithStats.inputTokenCount ?? 0) +
52
+ (lastWithStats.tokenCount ?? 0);
53
+ } else {
54
+ totalTokenCount = messages
55
+ .where((m) => m.id != streamingId)
56
+ .fold<int>(0, (sum, m) => sum + (m.content.length / 4).round());
57
+ }
58
+ }
59
+
60
+ return Positioned(
61
+ bottom: 0,
62
+ left: 0,
63
+ right: 0,
64
+ child: Container(
65
+ decoration: BoxDecoration(
66
+ gradient: LinearGradient(
67
+ begin: Alignment.topCenter,
68
+ end: Alignment.bottomCenter,
69
+ colors: [
70
+ Theme.of(context).scaffoldBackgroundColor.withValues(alpha: 0.1),
71
+ Theme.of(context).scaffoldBackgroundColor.withValues(alpha: 0.8),
72
+ Theme.of(context).scaffoldBackgroundColor,
73
+ ],
74
+ stops: const [0.0, 0.7, 1.0],
75
+ ),
76
+ ),
77
+ child: Column(
78
+ mainAxisSize: MainAxisSize.min,
79
+ children: [
80
+ if (!isStreaming && keyboardBottomInset == 0) ...[
81
+ const SizedBox(height: 4),
82
+ const SmartReplyChipsWrapper(),
83
+ ],
84
+ ChatInputBar(
85
+ focusNode: inputFocusNode,
86
+ isStreaming: isStreaming,
87
+ keyboardIncognito: keyboardIncognito,
88
+ onSend: (message, {attachments}) {
89
+ ref
90
+ .read(chatProvider.notifier)
91
+ .sendMessage(message, attachments: attachments);
92
+ },
93
+ onStop: () => ref.read(chatProvider.notifier).cancelStream(),
94
+ totalTokenCount: totalTokenCount,
95
+ ),
96
+ ],
97
+ ),
98
+ ),
99
+ );
100
+ }
101
+}
102
+
103
+class SmartReplyChipsWrapper extends ConsumerWidget {
104
+ const SmartReplyChipsWrapper({super.key});
105
+
106
+ @override
107
+ Widget build(BuildContext context, WidgetRef ref) {
108
+ return SmartReplyChips(
109
+ onSend: (message) {
110
+ ref.read(chatProvider.notifier).sendMessage(message);
111
+ },
112
+ );
113
+ }
114
+}
lib/features/chat/views/components/chat_input_bar.dart
+16
-1
@@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
6
import 'package:haptic_feedback/haptic_feedback.dart';
7
import 'package:hugeicons/hugeicons.dart';
8
import 'package:image_picker/image_picker.dart';
9
+import 'package:localmind/features/chat/views/components/token_usage_indicator.dart';
10
import 'package:permission_handler/permission_handler.dart';
11
12
import '../../../../core/models/enums.dart';
@@ -37,6 +38,8 @@ class ChatInputBar extends ConsumerStatefulWidget {
38
this.focusNode,
39
40
this.keyboardIncognito = false,
41
+
42
+ this.totalTokenCount = 0,
43
});
44
45
final void Function(String message, {List<File>? attachments}) onSend;
@@ -53,6 +56,8 @@ class ChatInputBar extends ConsumerStatefulWidget {
56
57
final bool keyboardIncognito;
58
59
+ final int totalTokenCount;
60
+
61
@override
62
ConsumerState<ChatInputBar> createState() => ChatInputBarState();
63
}
@@ -743,6 +748,13 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
748
color: iconColor,
749
size: 18,
750
)
751
+ : canSend
752
+ ? HugeIcon(
753
+ icon: HugeIcons.strokeRoundedArrowRight01,
754
+ key: ValueKey(canSend),
755
+ color: iconColor,
756
+ size: 18,
757
+ )
758
: HugeIcon(
759
icon: HugeIcons.strokeRoundedArrowUp01,
760
key: ValueKey(canSend),
@@ -1112,8 +1124,11 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
1124
),
1125
),
1126
1127
+ if (_controller.text.isEmpty) ...[
1128
+ const SizedBox(width: 8),
1129
+ TokenUsageIndicator(totalTokenCount: widget.totalTokenCount),
1130
+ ],
1131
const SizedBox(width: 8),
1116
-
1132
_buildMicButton(isListening, theme),
1133
1134
const SizedBox(width: 8),
lib/features/chat/views/components/message_list/message_area.dart
new
+149
@@ -0,0 +1,149 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:go_router/go_router.dart';
4
+import 'package:localmind/core/routes/app_routes.dart';
5
+import 'package:localmind/l10n/app_localizations.dart';
6
+import 'package:localmind/core/services/share_service.dart';
7
+import 'package:localmind/features/chat/data/models/message.dart';
8
+import 'package:localmind/features/chat/providers/chat_providers.dart';
9
+import 'package:localmind/features/conversations/data/models/conversation.dart';
10
+import 'package:localmind/features/conversations/providers/conversation_providers.dart' as conv;
11
+import 'package:localmind/features/personas/providers/personas_providers.dart';
12
+import 'package:localmind/features/chat/views/components/model_info_sheet.dart';
13
+import 'package:localmind/features/chat/views/components/edit_message_dialog.dart';
14
+import 'package:localmind/features/chat/views/components/message_list/message_list.dart';
15
+import 'package:localmind/features/chat/views/components/message_list/empty_state.dart';
16
+import 'package:localmind/features/chat/views/components/message_list/corrupted_state.dart';
17
+import 'package:localmind/features/saved_messages/views/components/save_message_sheet.dart';
18
+import 'package:localmind/features/personas/views/components/persona_picker_sheet.dart';
19
+
20
+class MessageArea extends ConsumerWidget {
21
+ const MessageArea({
22
+ super.key,
23
+ required this.isLoading,
24
+ required this.messages,
25
+ required this.activeConversation,
26
+ this.errorMessage,
27
+ required this.selectedModel,
28
+ required this.isStreaming,
29
+ required this.scrollController,
30
+ required this.effectiveBottomInset,
31
+ required this.keyboardBottomInset,
32
+ required this.onModelPicker,
33
+ });
34
+
35
+ final bool isLoading;
36
+ final List<Message> messages;
37
+ final Conversation? activeConversation;
38
+ final String? errorMessage;
39
+ final dynamic selectedModel;
40
+ final bool isStreaming;
41
+ final ScrollController scrollController;
42
+ final double effectiveBottomInset;
43
+ final double keyboardBottomInset;
44
+ final VoidCallback onModelPicker;
45
+
46
+ @override
47
+ Widget build(BuildContext context, WidgetRef ref) {
48
+ final l10n = AppLocalizations.of(context)!;
49
+
50
+ if (isLoading) {
51
+ return const Center(child: CircularProgressIndicator(strokeWidth: 2));
52
+ }
53
+
54
+ if (messages.isEmpty && activeConversation != null) {
55
+ return CorruptedChatState(
56
+ conversation: activeConversation!,
57
+ errorMessage: errorMessage,
58
+ onStartNewChat: () =>
59
+ ref.read(chatProvider.notifier).startNewConversation(),
60
+ );
61
+ }
62
+
63
+ if (messages.isEmpty) {
64
+ return EmptyState(
65
+ onQuickPrompt: (prompt) =>
66
+ ref.read(chatProvider.notifier).sendMessage(prompt),
67
+ quickPrompts: [
68
+ l10n.quick_write,
69
+ l10n.quick_explain,
70
+ l10n.quick_debug,
71
+ l10n.quick_async,
72
+ ],
73
+ recentConversations: ref.watch(conv.recentConversationsProvider),
74
+ onSeeAll: () => context.push(AppRoutes.chatHistory),
75
+ selectedModel: selectedModel,
76
+ onModelTap: onModelPicker,
77
+ selectedPersonas: ref.watch(selectedPersonasProvider),
78
+ onPersonaTap: () => showPersonaPickerSheet(
79
+ context,
80
+ mode: PersonaPickerMode.preselection,
81
+ ),
82
+ );
83
+ }
84
+
85
+ return MessageList(
86
+ scrollController: scrollController,
87
+ messages: messages,
88
+ allMessages: ref.watch(chatProvider.select((s) => s.allMessages)),
89
+ isStreaming: isStreaming,
90
+ onRetry: (messageId) =>
91
+ ref.read(chatProvider.notifier).retryMessage(messageId),
92
+ onDelete: (messageId) =>
93
+ ref.read(chatProvider.notifier).deleteMessage(messageId),
94
+ onEdit: (messageId, currentContent) async {
95
+ final result = await EditMessageDialog.showUserEdit(
96
+ context,
97
+ initialContent: currentContent,
98
+ );
99
+ if (result == null || result.content == currentContent) return;
100
+ if (result.regenerate) {
101
+ await ref
102
+ .read(chatProvider.notifier)
103
+ .editMessage(messageId, result.content);
104
+ } else {
105
+ await ref
106
+ .read(chatProvider.notifier)
107
+ .editMessageSaveOnly(messageId, result.content);
108
+ }
109
+ },
110
+ onEditAssistant: (messageId, currentContent) async {
111
+ final editL10n = AppLocalizations.of(context)!;
112
+ final newContent = await EditMessageDialog.show(
113
+ context,
114
+ initialContent: currentContent,
115
+ description: editL10n.edit_assistant_message_desc,
116
+ saveLabel: editL10n.save,
117
+ );
118
+ if (newContent != null && newContent != currentContent) {
119
+ await ref
120
+ .read(chatProvider.notifier)
121
+ .editAssistantMessage(messageId, newContent);
122
+ }
123
+ },
124
+ onBranch: (messageId) =>
125
+ ref.read(chatProvider.notifier).branchFromMessage(messageId),
126
+ onContinue: (messageId) =>
127
+ ref.read(chatProvider.notifier).continueFromMessage(messageId),
128
+ onCycleVariant: (messageId, direction) => ref
129
+ .read(chatProvider.notifier)
130
+ .cycleMessageVariant(messageId, direction),
131
+ onSave: (message) => showSaveMessageSheet(
132
+ context,
133
+ ref,
134
+ message,
135
+ isTemporaryChat: ref.read(chatProvider.select((s) => s.isTemporary)),
136
+ ),
137
+ onShare: (message) => ShareService.shareText(message.content),
138
+ onGenerateResponse: () =>
139
+ ref.read(chatProvider.notifier).generateResponseForLastUser(),
140
+ onModelPicker: onModelPicker,
141
+ onModelLongPress: (modelId) => showModelInfoSheet(context, ref, modelId),
142
+ hasSmartReplies:
143
+ !isStreaming &&
144
+ keyboardBottomInset == 0 &&
145
+ (ref.watch(smartRepliesProvider).asData?.value.isNotEmpty ?? false),
146
+ bottomInset: effectiveBottomInset,
147
+ );
148
+ }
149
+}
lib/features/chat/views/components/token_usage_indicator.dart
new
+130
@@ -0,0 +1,130 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:localmind/core/providers/app_providers.dart';
4
+import 'package:localmind/core/theme/colors.dart';
5
+import 'package:localmind/features/chat/providers/chat_providers.dart';
6
+import 'package:localmind/l10n/app_localizations.dart';
7
+
8
+class TokenUsageIndicator extends ConsumerWidget {
9
+ const TokenUsageIndicator({required this.totalTokenCount});
10
+
11
+ final int totalTokenCount;
12
+
13
+ @override
14
+ Widget build(BuildContext context, WidgetRef ref) {
15
+ final theme = Theme.of(context);
16
+ final isDark = theme.brightness == Brightness.dark;
17
+ final liveContextLength = ref.watch(activeModelContextLengthProvider).value;
18
+ final fallbackContextLength = ref.watch(
19
+ settingsProvider.select((s) => s.contextLength),
20
+ );
21
+ final int contextLength = liveContextLength ?? fallbackContextLength;
22
+
23
+ // totalTokenCount only updates once a response finishes (it's the real
24
+ // server-reported count), so while one is streaming in, grow the ring
25
+ // with a rough chars-per-token estimate of the in-progress reply —
26
+ // corrected back to the exact figure the moment the stream ends.
27
+ final isStreaming = ref.watch(chatProvider.select((s) => s.isStreaming));
28
+ final streamingLength = ref.watch(
29
+ chatProvider.select((s) => s.streamingMessage?.content.length ?? 0),
30
+ );
31
+ final estimatedTokenCount = isStreaming
32
+ ? totalTokenCount + (streamingLength / 4).round()
33
+ : totalTokenCount;
34
+
35
+ final ratio = contextLength > 0
36
+ ? (estimatedTokenCount / contextLength).clamp(0.0, 1.0)
37
+ : 0.0;
38
+ final ringColor = ratio >= 0.9 ? Colors.red : theme.colorScheme.primary;
39
+
40
+ return GestureDetector(
41
+ onTap: () => _showTokenUsageSheet(context, contextLength, ratio),
42
+ child: Container(
43
+ padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
44
+ decoration: BoxDecoration(borderRadius: BorderRadius.circular(4)),
45
+ height: 20,
46
+ child: Row(
47
+ mainAxisSize: MainAxisSize.min,
48
+ children: [
49
+ SizedBox(
50
+ width: 12,
51
+ height: 12,
52
+ child: CircularProgressIndicator(
53
+ value: ratio,
54
+ strokeWidth: 2,
55
+ backgroundColor: (isDark ? Colors.white : Colors.black)
56
+ .withValues(alpha: 0.12),
57
+ valueColor: AlwaysStoppedAnimation<Color>(ringColor),
58
+ ),
59
+ ),
60
+ const SizedBox(width: 4),
61
+ Text(
62
+ '${(ratio * 100).round()}%',
63
+ style: TextStyle(
64
+ fontSize: 10,
65
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
66
+ ),
67
+ ),
68
+ ],
69
+ ),
70
+ ),
71
+ );
72
+ }
73
+
74
+ void _showTokenUsageSheet(
75
+ BuildContext context,
76
+ int contextLength,
77
+ double ratio,
78
+ ) {
79
+ final l10n = AppLocalizations.of(context)!;
80
+ showModalBottomSheet(
81
+ context: context,
82
+ builder: (ctx) {
83
+ final sheetTheme = Theme.of(ctx);
84
+ final isDark = sheetTheme.brightness == Brightness.dark;
85
+ final muted = isDark
86
+ ? AppColors.darkMutedText
87
+ : AppColors.lightMutedText;
88
+ return SafeArea(
89
+ child: Padding(
90
+ padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
91
+ child: Column(
92
+ mainAxisSize: MainAxisSize.min,
93
+ crossAxisAlignment: CrossAxisAlignment.start,
94
+ children: [
95
+ Text(
96
+ l10n.token_usage_title,
97
+ style: sheetTheme.textTheme.titleMedium,
98
+ ),
99
+ const SizedBox(height: 12),
100
+ _usageRow(l10n.total_tokens_label, '$totalTokenCount', muted),
101
+ _usageRow(l10n.context_length, '$contextLength', muted),
102
+ _usageRow(
103
+ l10n.usage_percent_label,
104
+ '${(ratio * 100).toStringAsFixed(1)}%',
105
+ muted,
106
+ ),
107
+ ],
108
+ ),
109
+ ),
110
+ );
111
+ },
112
+ );
113
+ }
114
+
115
+ Widget _usageRow(String label, String value, Color muted) {
116
+ return Padding(
117
+ padding: const EdgeInsets.symmetric(vertical: 4),
118
+ child: Row(
119
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
120
+ children: [
121
+ Text(label, style: TextStyle(fontSize: 13, color: muted)),
122
+ Text(
123
+ value,
124
+ style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
125
+ ),
126
+ ],
127
+ ),
128
+ );
129
+ }
130
+}
lib/features/chat/views/components/top_bar/screen_app_bar.dart
new
+347
@@ -0,0 +1,347 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:hugeicons/hugeicons.dart';
4
+import 'package:shadcn_ui/shadcn_ui.dart';
5
+import 'package:auto_size_text_plus/auto_size_text_plus.dart';
6
+import 'package:localmind/core/providers/app_providers.dart';
7
+import 'package:localmind/l10n/app_localizations.dart';
8
+import 'package:localmind/core/services/export_choice_dialog.dart';
9
+import 'package:localmind/features/conversations/data/models/conversation.dart';
10
+import 'package:localmind/features/chat/views/components/chat_settings_sheet.dart';
11
+import 'package:localmind/features/chat/providers/chat_mcp_providers.dart';
12
+import 'package:localmind/features/chat/providers/chat_providers.dart';
13
+import 'package:localmind/features/chat/data/export_service.dart';
14
+
15
+class ScreenAppBar extends ConsumerWidget {
16
+ const ScreenAppBar({
17
+ super.key,
18
+ required this.activeConversation,
19
+ required this.isDark,
20
+ required this.hasPersonas,
21
+ required this.isTemporary,
22
+ required this.hasMessages,
23
+ required this.onMenuAction,
24
+ required this.onPersonaPicker,
25
+ required this.onChatModeAction,
26
+ });
27
+
28
+ final Conversation? activeConversation;
29
+ final bool isDark;
30
+ final bool hasPersonas;
31
+ final bool isTemporary;
32
+ final bool hasMessages;
33
+ final void Function(String) onMenuAction;
34
+ final VoidCallback onPersonaPicker;
35
+ final VoidCallback onChatModeAction;
36
+
37
+ @override
38
+ Widget build(BuildContext context, WidgetRef ref) {
39
+ final l10n = AppLocalizations.of(context)!;
40
+ final settings = ref.watch(settingsProvider);
41
+ final mcpConfig = ref.watch(chatMcpConfigProvider);
42
+ final isMcpEnabled = settings.mcpEnabled && mcpConfig.enabled;
43
+ final messageSelectionMode = ref.watch(messageSelectionModeProvider);
44
+ final selectedMessageIds = ref.watch(selectedMessageIdsProvider);
45
+
46
+ if (messageSelectionMode) {
47
+ return Container(
48
+ height: 56,
49
+ padding: const EdgeInsets.symmetric(horizontal: 4),
50
+ child: Row(
51
+ children: [
52
+ IconButton(
53
+ icon: const Icon(Icons.close),
54
+ onPressed: () =>
55
+ ref.read(messageSelectionModeProvider.notifier).disable(),
56
+ ),
57
+ Expanded(
58
+ child: Text(
59
+ l10n.selected_count(selectedMessageIds.length),
60
+ style: const TextStyle(
61
+ fontSize: 16,
62
+ fontWeight: FontWeight.w600,
63
+ ),
64
+ ),
65
+ ),
66
+ IconButton(
67
+ icon: const Icon(Icons.ios_share),
68
+ tooltip: l10n.export_conversation,
69
+ onPressed: selectedMessageIds.isEmpty
70
+ ? null
71
+ : () => _shareSelectedMessages(
72
+ context,
73
+ ref,
74
+ selectedMessageIds,
75
+ ),
76
+ ),
77
+ IconButton(
78
+ icon: const Icon(Icons.delete_outline, color: Colors.red),
79
+ tooltip: l10n.delete,
80
+ onPressed: selectedMessageIds.isEmpty
81
+ ? null
82
+ : () => _deleteSelectedMessages(
83
+ context,
84
+ ref,
85
+ selectedMessageIds,
86
+ ),
87
+ ),
88
+ ],
89
+ ),
90
+ );
91
+ }
92
+
93
+ return Container(
94
+ height: 56,
95
+ padding: const EdgeInsets.symmetric(horizontal: 4),
96
+ child: Row(
97
+ children: [
98
+ ShadResponsiveBuilder(
99
+ builder: (context, breakpoint) {
100
+ final isDesktop =
101
+ breakpoint >= ShadTheme.of(context).breakpoints.md;
102
+ if (isDesktop) return const SizedBox.shrink();
103
+ return IconButton(
104
+ icon: const Icon(Icons.menu),
105
+ onPressed: () => Scaffold.of(context).openDrawer(),
106
+ );
107
+ },
108
+ ),
109
+ const SizedBox(width: 8),
110
+ Expanded(
111
+ child: AutoSizeText(
112
+ _appBarTitle(l10n),
113
+ style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
114
+ maxLines: 1,
115
+ overflow: TextOverflow.ellipsis,
116
+ ),
117
+ ),
118
+ Stack(
119
+ children: [
120
+ IconButton(
121
+ icon: HugeIcon(
122
+ icon: HugeIcons.strokeRoundedFilterHorizontal,
123
+ size: 24,
124
+ color: isDark ? Colors.white70 : Colors.black87,
125
+ ),
126
+ onPressed: () =>
127
+ showChatSettingsSheet(context, initialTab: 'parameters'),
128
+ tooltip: l10n.chat_parameters_tooltip,
129
+ ),
130
+ PositionedDirectional(
131
+ top: 4,
132
+ end: 0,
133
+ child: Container(
134
+ padding: const EdgeInsets.all(2),
135
+ decoration: BoxDecoration(
136
+ color: isMcpEnabled ? Colors.green : Colors.grey,
137
+ shape: BoxShape.circle,
138
+ ),
139
+ child: const HugeIcon(
140
+ icon: HugeIcons.strokeRoundedTools,
141
+ size: 10,
142
+ color: Colors.white,
143
+ ),
144
+ ),
145
+ ),
146
+ ],
147
+ ),
148
+ ChatModeIconButton(
149
+ hasMessages: hasMessages,
150
+ isTemporary: isTemporary,
151
+ isDark: isDark,
152
+ onPressed: onChatModeAction,
153
+ ),
154
+ PopupMenuButton<String>(
155
+ icon: const Icon(Icons.more_vert),
156
+ onSelected: onMenuAction,
157
+ itemBuilder: (context) => [
158
+ PopupMenuItem(
159
+ value: 'new_chat',
160
+ child: ListTile(
161
+ leading: const Icon(Icons.add),
162
+ title: Text(l10n.nav_new_chat),
163
+ contentPadding: EdgeInsets.zero,
164
+ ),
165
+ ),
166
+ if (activeConversation != null && !isTemporary) ...[
167
+ PopupMenuItem(
168
+ value: 'rename',
169
+ child: ListTile(
170
+ leading: const Icon(Icons.drive_file_rename_outline),
171
+ title: Text(l10n.rename_conversation),
172
+ contentPadding: EdgeInsets.zero,
173
+ ),
174
+ ),
175
+ PopupMenuItem(
176
+ value: 'move_to_folder',
177
+ child: ListTile(
178
+ leading: const Icon(Icons.folder_outlined),
179
+ title: Text(l10n.move_to_folder),
180
+ contentPadding: EdgeInsets.zero,
181
+ ),
182
+ ),
183
+ ],
184
+ if (hasMessages) ...[
185
+ PopupMenuItem(
186
+ value: 'export_chat',
187
+ child: ListTile(
188
+ leading: const Icon(Icons.upload_outlined),
189
+ title: Text(l10n.export_conversation),
190
+ contentPadding: EdgeInsets.zero,
191
+ ),
192
+ ),
193
+ PopupMenuItem(
194
+ value: 'share_chat',
195
+ child: ListTile(
196
+ leading: const Icon(Icons.share_outlined),
197
+ title: Text(l10n.share_conversation),
198
+ contentPadding: EdgeInsets.zero,
199
+ ),
200
+ ),
201
+ ],
202
+ PopupMenuItem(
203
+ value: 'persona',
204
+ child: ListTile(
205
+ leading: Icon(
206
+ hasPersonas ? Icons.swap_horiz : Icons.smart_toy_outlined,
207
+ ),
208
+ title: Text(
209
+ hasPersonas ? l10n.change_persona : l10n.set_persona,
210
+ ),
211
+ contentPadding: EdgeInsets.zero,
212
+ ),
213
+ ),
214
+ PopupMenuItem(
215
+ value: 'clear',
216
+ child: ListTile(
217
+ leading: const Icon(Icons.delete_outline),
218
+ title: Text(l10n.clear_conversation),
219
+ contentPadding: EdgeInsets.zero,
220
+ ),
221
+ ),
222
+ ],
223
+ ),
224
+ ],
225
+ ),
226
+ );
227
+ }
228
+
229
+ String _appBarTitle(AppLocalizations l10n) {
230
+ if (isTemporary) return l10n.temporary_chat;
231
+ if (activeConversation?.title.isNotEmpty == true) {
232
+ final title = activeConversation!.title;
233
+ if (title.length > 30) {
234
+ return '${title.substring(0, 30)}...';
235
+ }
236
+ return title;
237
+ }
238
+ return l10n.nav_new_chat;
239
+ }
240
+
241
+ Future<void> _shareSelectedMessages(
242
+ BuildContext context,
243
+ WidgetRef ref,
244
+ Set<String> selectedIds,
245
+ ) async {
246
+ final messages = ref
247
+ .read(chatProvider)
248
+ .messages
249
+ .where((m) => selectedIds.contains(m.id))
250
+ .toList();
251
+ if (messages.isEmpty) return;
252
+ final text = ExportService.exportAsText(messages);
253
+ ref.read(messageSelectionModeProvider.notifier).disable();
254
+ if (!context.mounted) return;
255
+ await showExportChoiceDialog(context, content: text);
256
+ }
257
+
258
+ void _deleteSelectedMessages(
259
+ BuildContext context,
260
+ WidgetRef ref,
261
+ Set<String> selectedIds,
262
+ ) {
263
+ final l10n = AppLocalizations.of(context)!;
264
+ showDialog<void>(
265
+ context: context,
266
+ builder: (dialogContext) {
267
+ return AlertDialog(
268
+ title: Text(l10n.delete_message_title),
269
+ content: Text(l10n.selected_count(selectedIds.length)),
270
+ actions: [
271
+ TextButton(
272
+ onPressed: () => Navigator.pop(dialogContext),
273
+ child: Text(l10n.cancel),
274
+ ),
275
+ TextButton(
276
+ onPressed: () async {
277
+ Navigator.pop(dialogContext);
278
+ for (final id in selectedIds) {
279
+ await ref.read(chatProvider.notifier).deleteMessage(id);
280
+ }
281
+ ref.read(messageSelectionModeProvider.notifier).disable();
282
+ },
283
+ style: TextButton.styleFrom(foregroundColor: Colors.red),
284
+ child: Text(l10n.delete),
285
+ ),
286
+ ],
287
+ );
288
+ },
289
+ );
290
+ }
291
+}
292
+
293
+class ChatModeIconButton extends StatelessWidget {
294
+ const ChatModeIconButton({
295
+ super.key,
296
+ required this.hasMessages,
297
+ required this.isTemporary,
298
+ required this.isDark,
299
+ required this.onPressed,
300
+ });
301
+
302
+ final bool hasMessages;
303
+ final bool isTemporary;
304
+ final bool isDark;
305
+ final VoidCallback onPressed;
306
+
307
+ @override
308
+ Widget build(BuildContext context) {
309
+ final l10n = AppLocalizations.of(context)!;
310
+ final showGhost = !hasMessages || isTemporary;
311
+ final ghostActive = isTemporary;
312
+
313
+ if (showGhost) {
314
+ final activeColor = isDark
315
+ ? const Color(0xFFE6C35C)
316
+ : const Color(0xFF9A7B1A);
317
+ final inactiveColor = isDark ? Colors.white54 : Colors.black45;
318
+
319
+ return IconButton(
320
+ onPressed: onPressed,
321
+ tooltip: ghostActive
322
+ ? l10n.exit_temporary_chat_title
323
+ : l10n.temporary_chat,
324
+ icon: ghostActive
325
+ ? Container(
326
+ padding: const EdgeInsets.all(4),
327
+ decoration: BoxDecoration(
328
+ color: activeColor.withValues(alpha: 0.25),
329
+ shape: BoxShape.circle,
330
+ ),
331
+ child: Icon(LucideIcons.ghost, size: 20, color: activeColor),
332
+ )
333
+ : Icon(LucideIcons.ghost, size: 22, color: inactiveColor),
334
+ );
335
+ }
336
+
337
+ return IconButton(
338
+ onPressed: onPressed,
339
+ tooltip: l10n.nav_new_chat,
340
+ icon: Icon(
341
+ Icons.add_comment_outlined,
342
+ size: 22,
343
+ color: isDark ? Colors.white70 : Colors.black87,
344
+ ),
345
+ );
346
+ }
347
+}
lib/features/chat/views/components/typing_indicator.dart
-51
@@ -80,54 +80,3 @@ class _TypingIndicatorState extends State<TypingIndicator>
80
}
81
}
82
83
-class StreamingIndicator extends StatefulWidget {
84
- const StreamingIndicator({super.key});
85
-
86
- @override
87
- State<StreamingIndicator> createState() => _StreamingIndicatorState();
88
-}
89
-
90
-class _StreamingIndicatorState extends State<StreamingIndicator>
91
- with SingleTickerProviderStateMixin {
92
- late final AnimationController _cursorController;
93
-
94
- @override
95
- void initState() {
96
- super.initState();
97
- _cursorController = AnimationController(
98
- duration: const Duration(milliseconds: 530),
99
- vsync: this,
100
- )..repeat(reverse: true);
101
- }
102
-
103
- @override
104
- void dispose() {
105
- _cursorController.dispose();
106
- super.dispose();
107
- }
108
-
109
- @override
110
- Widget build(BuildContext context) {
111
- final theme = Theme.of(context);
112
- final isDark = theme.brightness == Brightness.dark;
113
-
114
- final cursorColor = isDark
115
- ? AppColors.darkMutedText
116
- : AppColors.lightMutedText;
117
-
118
- return AnimatedBuilder(
119
- animation: _cursorController,
120
- builder: (context, child) {
121
- return Opacity(
122
- opacity: _cursorController.value,
123
- child: Container(
124
- width: 2,
125
- height: 18,
126
- margin: const EdgeInsetsDirectional.only(start: 2),
127
- color: cursorColor,
128
- ),
129
- );
130
- },
131
- );
132
- }
133
-}
pubspec.lock
+8
@@ -137,6 +137,14 @@ packages:
137
url: "https://pub.dev"
138
source: hosted
139
version: "4.3.1"
140
+ auto_size_text_plus:
141
+ dependency: "direct main"
142
+ description:
143
+ name: auto_size_text_plus
144
+ sha256: "21c522949ad4eadf16f5a525f348200783ebbefc02242d90624fc30279c53216"
145
+ url: "https://pub.dev"
146
+ source: hosted
147
+ version: "3.0.2"
148
background_downloader:
149
dependency: transitive
150
description:
pubspec.yaml
+1
@@ -81,6 +81,7 @@ dependencies:
81
share_plus: ^13.2.0
82
image_picker: ^1.1.2
83
permission_handler: ^11.4.0
84
+ auto_size_text_plus: ^3.0.2
85
86
dev_dependencies:
87
flutter_test: