@tduncan / Cortana / commits / 5afaaf2

refactor: extract chat components into separate files and modularize chat screen widgets

abdulmominsakib committed Jul 7, 2026 at 12:09 UTC 5afaaf2aee6afe61851f31040e227c8c9102bc82
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: