@tduncan / Cortana / commits / 5fc0327

feat: add interactive animations and refined UI components to the chat input bar

abdulmominsakib committed Apr 1, 2026 at 18:56 UTC 5fc03278ee31a845e41e51c7975c2b51910df190
14 files changed +1136 -369
.gitignore
+3 -1
@@ -46,4 +46,6 @@ app.*.map.json
46
47 # FVM Version Cache
48 .fvm/
49 -playstore_keys
\ No newline at end of file
49 +playstore_keys
50 +.agent
51 +.claude
\ No newline at end of file
lib/core/providers/app_providers.dart
+2
@@ -75,6 +75,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
75 void setHasCompletedOnboarding(bool value) =>
76 _update(state.copyWith(hasCompletedOnboarding: value));
77 void setMcpEnabled(bool value) => _update(state.copyWith(mcpEnabled: value));
78 + void setCodeTheme(SyntaxThemeName value) =>
79 + _update(state.copyWith(codeTheme: value));
80
81 Future<void> _update(AppSettings updated) async {
82 state = updated;
lib/core/storage/hive_initializer.dart
+1
@@ -39,6 +39,7 @@ class HiveInitializer {
39 Hive.registerAdapter(PersonaAdapter());
40 Hive.registerAdapter(AppSettingsAdapter());
41 Hive.registerAdapter(ThemeModeAdapter());
42 + Hive.registerAdapter(SyntaxThemeNameAdapter());
43
44 final servers = await Hive.openBox<Server>(HiveKeys.servers);
45 final messages = await Hive.openBox<Message>(HiveKeys.messages);
lib/features/chat/screens/chat_screen.dart
+365 -107
@@ -279,40 +279,61 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
279 left: 0,
280 right: 0,
281 child: Center(
282 - child: GestureDetector(
283 - onTap: _scrollToBottom,
284 - child: Container(
285 - padding: const EdgeInsets.symmetric(
286 - horizontal: 12,
287 - vertical: 6,
288 - ),
289 - decoration: BoxDecoration(
290 - color: isDark
291 - ? const Color(0xFF3A3A3A)
292 - : const Color(0xFFE5E5E5),
293 - borderRadius: BorderRadius.circular(16),
294 - ),
295 - child: Row(
296 - mainAxisSize: MainAxisSize.min,
297 - children: [
298 - Icon(
299 - Icons.arrow_downward,
300 - size: 14,
301 - color: isDark
302 - ? const Color(0xFF888888)
303 - : const Color(0xFF666666),
304 - ),
305 - const SizedBox(width: 4),
306 - Text(
307 - 'New messages',
308 - style: TextStyle(
309 - fontSize: 12,
282 + child: TweenAnimationBuilder<double>(
283 + tween: Tween(begin: 0.0, end: 1.0),
284 + duration: const Duration(milliseconds: 200),
285 + curve: Curves.easeOutCubic,
286 + builder: (context, value, child) {
287 + return Opacity(
288 + opacity: value,
289 + child: Transform.translate(
290 + offset: Offset(0, 20 * (1 - value)),
291 + child: child,
292 + ),
293 + );
294 + },
295 + child: GestureDetector(
296 + onTap: _scrollToBottom,
297 + child: Container(
298 + padding: const EdgeInsets.symmetric(
299 + horizontal: 12,
300 + vertical: 6,
301 + ),
302 + decoration: BoxDecoration(
303 + color: isDark
304 + ? const Color(0xFF3A3A3A)
305 + : const Color(0xFFE5E5E5),
306 + borderRadius: BorderRadius.circular(16),
307 + boxShadow: [
308 + BoxShadow(
309 + color: Colors.black.withValues(alpha: 0.1),
310 + blurRadius: 8,
311 + spreadRadius: 1,
312 + ),
313 + ],
314 + ),
315 + child: Row(
316 + mainAxisSize: MainAxisSize.min,
317 + children: [
318 + Icon(
319 + Icons.arrow_downward,
320 + size: 14,
321 color: isDark
322 ? const Color(0xFF888888)
323 : const Color(0xFF666666),
324 ),
314 - ),
315 - ],
325 + const SizedBox(width: 4),
326 + Text(
327 + 'New messages',
328 + style: TextStyle(
329 + fontSize: 12,
330 + color: isDark
331 + ? const Color(0xFF888888)
332 + : const Color(0xFF666666),
333 + ),
334 + ),
335 + ],
336 + ),
337 ),
338 ),
339 ),
@@ -545,7 +566,7 @@ class _ConnectionBanner extends StatelessWidget {
566 }
567 }
568
548 -class _EmptyState extends StatelessWidget {
569 +class _EmptyState extends StatefulWidget {
570 const _EmptyState({
571 required this.onQuickPrompt,
572 required this.quickPrompts,
@@ -558,6 +579,62 @@ class _EmptyState extends StatelessWidget {
579 final List<Conversation> recentConversations;
580 final VoidCallback onSeeAll;
581
582 + @override
583 + State<_EmptyState> createState() => _EmptyStateState();
584 +}
585 +
586 +class _EmptyStateState extends State<_EmptyState>
587 + with SingleTickerProviderStateMixin {
588 + late AnimationController _controller;
589 + late List<Animation<double>> _fadeAnimations;
590 + late List<Animation<Offset>> _slideAnimations;
591 +
592 + @override
593 + void initState() {
594 + super.initState();
595 + _controller = AnimationController(
596 + duration: const Duration(milliseconds: 500),
597 + vsync: this,
598 + );
599 +
600 + _fadeAnimations = List.generate(widget.quickPrompts.length, (index) {
601 + return Tween<double>(begin: 0.0, end: 1.0).animate(
602 + CurvedAnimation(
603 + parent: _controller,
604 + curve: Interval(
605 + (index * 0.1).clamp(0.0, 0.7),
606 + (0.3 + index * 0.1).clamp(0.2, 1.0),
607 + curve: Curves.easeOut,
608 + ),
609 + ),
610 + );
611 + });
612 +
613 + _slideAnimations = List.generate(widget.quickPrompts.length, (index) {
614 + return Tween<Offset>(
615 + begin: const Offset(0.0, 0.3),
616 + end: Offset.zero,
617 + ).animate(
618 + CurvedAnimation(
619 + parent: _controller,
620 + curve: Interval(
621 + (index * 0.1).clamp(0.0, 0.7),
622 + (0.3 + index * 0.1).clamp(0.2, 1.0),
623 + curve: Curves.easeOutCubic,
624 + ),
625 + ),
626 + );
627 + });
628 +
629 + _controller.forward();
630 + }
631 +
632 + @override
633 + void dispose() {
634 + _controller.dispose();
635 + super.dispose();
636 + }
637 +
638 @override
639 Widget build(BuildContext context) {
640 final theme = Theme.of(context);
@@ -570,31 +647,67 @@ class _EmptyState extends StatelessWidget {
647 mainAxisAlignment: MainAxisAlignment.center,
648 children: [
649 const SizedBox(height: 24),
573 - Text(
574 - 'Select Model LocalMind',
575 - style: TextStyle(
576 - fontSize: 28,
577 - fontWeight: FontWeight.bold,
578 - color: isDark ? Colors.white : Colors.black,
650 + TweenAnimationBuilder<double>(
651 + tween: Tween(begin: 0.0, end: 1.0),
652 + duration: const Duration(milliseconds: 300),
653 + curve: Curves.easeOut,
654 + builder: (context, value, child) {
655 + return Opacity(
656 + opacity: value,
657 + child: Transform.translate(
658 + offset: Offset(0, 20 * (1 - value)),
659 + child: child,
660 + ),
661 + );
662 + },
663 + child: Text(
664 + 'Select Model LocalMind',
665 + style: TextStyle(
666 + fontSize: 28,
667 + fontWeight: FontWeight.bold,
668 + color: isDark ? Colors.white : Colors.black,
669 + ),
670 ),
671 ),
672 const SizedBox(height: 8),
582 - Text(
583 - 'Your AI. Your Device. Your Rules.',
584 - style: TextStyle(
585 - fontSize: 14,
586 - color: isDark
587 - ? const Color(0xFF888888)
588 - : const Color(0xFF666666),
673 + TweenAnimationBuilder<double>(
674 + tween: Tween(begin: 0.0, end: 1.0),
675 + duration: const Duration(milliseconds: 350),
676 + curve: Curves.easeOut,
677 + builder: (context, value, child) {
678 + return Opacity(
679 + opacity: value,
680 + child: Transform.translate(
681 + offset: Offset(0, 15 * (1 - value)),
682 + child: child,
683 + ),
684 + );
685 + },
686 + child: Text(
687 + 'Your AI. Your Device. Your Rules.',
688 + style: TextStyle(
689 + fontSize: 14,
690 + color: isDark
691 + ? const Color(0xFF888888)
692 + : const Color(0xFF666666),
693 + ),
694 ),
695 ),
696 const SizedBox(height: 32),
592 - Text(
593 - 'Start a conversation',
594 - style: TextStyle(
595 - fontSize: 16,
596 - fontWeight: FontWeight.w500,
597 - color: isDark ? Colors.white : Colors.black,
697 + TweenAnimationBuilder<double>(
698 + tween: Tween(begin: 0.0, end: 1.0),
699 + duration: const Duration(milliseconds: 400),
700 + curve: Curves.easeOut,
701 + builder: (context, value, child) {
702 + return Opacity(opacity: value, child: child);
703 + },
704 + child: Text(
705 + 'Start a conversation',
706 + style: TextStyle(
707 + fontSize: 16,
708 + fontWeight: FontWeight.w500,
709 + color: isDark ? Colors.white : Colors.black,
710 + ),
711 ),
712 ),
713 const SizedBox(height: 16),
@@ -602,22 +715,43 @@ class _EmptyState extends StatelessWidget {
715 spacing: 8,
716 runSpacing: 8,
717 alignment: WrapAlignment.center,
605 - children: quickPrompts.map((prompt) {
606 - return ActionChip(
607 - label: Text(prompt),
608 - onPressed: () => onQuickPrompt(prompt),
609 - backgroundColor: isDark
610 - ? const Color(0xFF2A2A2A)
611 - : const Color(0xFFF5F5F5),
612 - side: BorderSide(
613 - color: isDark
614 - ? const Color(0xFF3A3A3A)
615 - : const Color(0xFFE5E5E5),
718 + children: widget.quickPrompts.asMap().entries.map((entry) {
719 + final index = entry.key;
720 + final prompt = entry.value;
721 + final fadeAnimation = index < _fadeAnimations.length
722 + ? _fadeAnimations[index]
723 + : const AlwaysStoppedAnimation(1.0);
724 + final slideAnimation = index < _slideAnimations.length
725 + ? _slideAnimations[index]
726 + : const AlwaysStoppedAnimation(Offset.zero);
727 +
728 + return AnimatedBuilder(
729 + animation: _controller,
730 + builder: (context, child) {
731 + return FadeTransition(
732 + opacity: fadeAnimation,
733 + child: SlideTransition(
734 + position: slideAnimation,
735 + child: child,
736 + ),
737 + );
738 + },
739 + child: ActionChip(
740 + label: Text(prompt),
741 + onPressed: () => widget.onQuickPrompt(prompt),
742 + backgroundColor: isDark
743 + ? const Color(0xFF2A2A2A)
744 + : const Color(0xFFF5F5F5),
745 + side: BorderSide(
746 + color: isDark
747 + ? const Color(0xFF3A3A3A)
748 + : const Color(0xFFE5E5E5),
749 + ),
750 ),
751 );
752 }).toList(),
753 ),
620 - if (recentConversations.isNotEmpty) ...[
754 + if (widget.recentConversations.isNotEmpty) ...[
755 const SizedBox(height: 32),
756 Row(
757 mainAxisAlignment: MainAxisAlignment.center,
@@ -634,7 +768,7 @@ class _EmptyState extends StatelessWidget {
768 ),
769 const SizedBox(width: 8),
770 GestureDetector(
637 - onTap: onSeeAll,
771 + onTap: widget.onSeeAll,
772 child: Text(
773 'See all',
774 style: TextStyle(
@@ -649,14 +783,29 @@ class _EmptyState extends StatelessWidget {
783 ],
784 ),
785 const SizedBox(height: 12),
652 - ...recentConversations
653 - .take(5)
654 - .map(
655 - (conv) => Padding(
656 - padding: const EdgeInsets.only(bottom: 8),
657 - child: _RecentConversationItem(conversation: conv),
658 - ),
786 + ...List.generate(widget.recentConversations.take(5).length, (
787 + index,
788 + ) {
789 + final conv = widget.recentConversations.take(5).toList()[index];
790 + return TweenAnimationBuilder<double>(
791 + tween: Tween(begin: 0.0, end: 1.0),
792 + duration: Duration(milliseconds: 400 + (index * 50)),
793 + curve: Curves.easeOutCubic,
794 + builder: (context, value, child) {
795 + return Opacity(
796 + opacity: value,
797 + child: Transform.translate(
798 + offset: Offset(0, 10 * (1 - value)),
799 + child: child,
800 + ),
801 + );
802 + },
803 + child: Padding(
804 + padding: const EdgeInsets.only(bottom: 8),
805 + child: _RecentConversationItem(conversation: conv),
806 ),
807 + );
808 + }),
809 ],
810 ],
811 ),
@@ -797,54 +946,163 @@ class _MessageList extends StatelessWidget {
946 }
947 }
948
800 -class _SmartReplyChips extends ConsumerWidget {
949 +class _SmartReplyChips extends ConsumerStatefulWidget {
950 const _SmartReplyChips({required this.onSend});
951 final ValueChanged<String> onSend;
952
953 @override
805 - Widget build(BuildContext context, WidgetRef ref) {
954 + ConsumerState<_SmartReplyChips> createState() => _SmartReplyChipsState();
955 +}
956 +
957 +class _SmartReplyChipsState extends ConsumerState<_SmartReplyChips>
958 + with TickerProviderStateMixin {
959 + List<String> _previousSuggestions = [];
960 + late AnimationController _controller;
961 + List<Animation<double>> _fadeAnimations = [];
962 + List<Animation<Offset>> _slideAnimations = [];
963 +
964 + @override
965 + void initState() {
966 + super.initState();
967 + _controller = AnimationController(
968 + duration: const Duration(milliseconds: 400),
969 + vsync: this,
970 + );
971 + }
972 +
973 + @override
974 + void dispose() {
975 + _controller.dispose();
976 + super.dispose();
977 + }
978 +
979 + bool _listsEqual(List<String> a, List<String> b) {
980 + if (a.length != b.length) return false;
981 + for (int i = 0; i < a.length; i++) {
982 + if (a[i] != b[i]) return false;
983 + }
984 + return true;
985 + }
986 +
987 + void _updateAnimations(List<String> suggestions) {
988 + if (suggestions.isEmpty) {
989 + _previousSuggestions = [];
990 + return;
991 + }
992 + if (_listsEqual(suggestions, _previousSuggestions)) {
993 + return;
994 + }
995 +
996 + _previousSuggestions = List.from(suggestions);
997 +
998 + _fadeAnimations = List.generate(suggestions.length, (index) {
999 + return Tween<double>(begin: 0.0, end: 1.0).animate(
1000 + CurvedAnimation(
1001 + parent: _controller,
1002 + curve: Interval(
1003 + (index * 0.1).clamp(0.0, 0.8),
1004 + (0.3 + index * 0.1).clamp(0.2, 1.0),
1005 + curve: Curves.easeOut,
1006 + ),
1007 + ),
1008 + );
1009 + });
1010 +
1011 + _slideAnimations = List.generate(suggestions.length, (index) {
1012 + return Tween<Offset>(
1013 + begin: const Offset(0.0, 0.5),
1014 + end: Offset.zero,
1015 + ).animate(
1016 + CurvedAnimation(
1017 + parent: _controller,
1018 + curve: Interval(
1019 + (index * 0.1).clamp(0.0, 0.8),
1020 + (0.3 + index * 0.1).clamp(0.2, 1.0),
1021 + curve: Curves.easeOut,
1022 + ),
1023 + ),
1024 + );
1025 + });
1026 +
1027 + _controller.forward(from: 0.0);
1028 + }
1029 +
1030 + @override
1031 + Widget build(BuildContext context) {
1032 final suggestions = ref.watch(smartRepliesProvider);
807 - if (suggestions.isEmpty) return const SizedBox.shrink();
1033 +
1034 + if (suggestions.isEmpty) {
1035 + _previousSuggestions = [];
1036 + return const SizedBox.shrink();
1037 + }
1038 +
1039 + _updateAnimations(suggestions);
1040
1041 final theme = Theme.of(context);
1042 final isDark = theme.brightness == Brightness.dark;
1043
812 - return Container(
813 - height: 40,
814 - padding: const EdgeInsets.symmetric(horizontal: 8),
815 - child: ListView.separated(
816 - scrollDirection: Axis.horizontal,
817 - itemCount: suggestions.length,
818 - separatorBuilder: (_, _) => const SizedBox(width: 6),
819 - itemBuilder: (context, index) {
820 - final label = suggestions[index];
821 - return GestureDetector(
822 - onTap: () => onSend(label),
823 - child: Container(
824 - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
825 - decoration: BoxDecoration(
826 - color: isDark
827 - ? const Color(0xFF2A2A2A)
828 - : const Color(0xFFF0F0F0),
829 - borderRadius: BorderRadius.circular(16),
830 - border: Border.all(
831 - color: isDark
832 - ? const Color(0xFF3A3A3A)
833 - : const Color(0xFFE0E0E0),
834 - ),
835 - ),
836 - child: Text(
837 - label,
838 - style: TextStyle(
839 - fontSize: 13,
840 - color: isDark
841 - ? const Color(0xFFCCCCCC)
842 - : const Color(0xFF444444),
1044 + return AnimatedSize(
1045 + duration: const Duration(milliseconds: 200),
1046 + curve: Curves.easeOut,
1047 + child: Container(
1048 + height: 40,
1049 + padding: const EdgeInsets.symmetric(horizontal: 8),
1050 + child: ListView.separated(
1051 + scrollDirection: Axis.horizontal,
1052 + itemCount: suggestions.length,
1053 + separatorBuilder: (_, _) => const SizedBox(width: 6),
1054 + itemBuilder: (context, index) {
1055 + final label = suggestions[index];
1056 + final fadeAnimation = index < _fadeAnimations.length
1057 + ? _fadeAnimations[index]
1058 + : const AlwaysStoppedAnimation(1.0);
1059 + final slideAnimation = index < _slideAnimations.length
1060 + ? _slideAnimations[index]
1061 + : const AlwaysStoppedAnimation(Offset.zero);
1062 +
1063 + return AnimatedBuilder(
1064 + animation: _controller,
1065 + builder: (context, child) {
1066 + return FadeTransition(
1067 + opacity: fadeAnimation,
1068 + child: SlideTransition(
1069 + position: slideAnimation,
1070 + child: child,
1071 + ),
1072 + );
1073 + },
1074 + child: GestureDetector(
1075 + onTap: () => widget.onSend(label),
1076 + child: Container(
1077 + padding: const EdgeInsets.symmetric(
1078 + horizontal: 12,
1079 + vertical: 6,
1080 + ),
1081 + decoration: BoxDecoration(
1082 + color: isDark
1083 + ? const Color(0xFF2A2A2A)
1084 + : const Color(0xFFF0F0F0),
1085 + borderRadius: BorderRadius.circular(16),
1086 + border: Border.all(
1087 + color: isDark
1088 + ? const Color(0xFF3A3A3A)
1089 + : const Color(0xFFE0E0E0),
1090 + ),
1091 + ),
1092 + child: Text(
1093 + label,
1094 + style: TextStyle(
1095 + fontSize: 13,
1096 + color: isDark
1097 + ? const Color(0xFFCCCCCC)
1098 + : const Color(0xFF444444),
1099 + ),
1100 + ),
1101 ),
1102 ),
845 - ),
846 - );
847 - },
1103 + );
1104 + },
1105 + ),
1106 ),
1107 );
1108 }
lib/features/chat/views/components/chat_bubble.dart
+56 -9
@@ -27,23 +27,70 @@ class ChatBubble extends StatelessWidget {
27 Widget build(BuildContext context) {
28 switch (message.role) {
29 case MessageRole.user:
30 - return _UserBubble(message: message);
30 + return _AnimatedBubble(
31 + alignment: Alignment.centerRight,
32 + child: _UserBubble(message: message),
33 + );
34 case MessageRole.assistant:
32 - return _AssistantBubble(
33 - message: message,
34 - onCopy: onCopy,
35 - onRetry: onRetry,
36 - onDelete: onDelete,
37 - isStreaming: isStreaming,
35 + return _AnimatedBubble(
36 + alignment: Alignment.centerLeft,
37 + child: _AssistantBubble(
38 + message: message,
39 + onCopy: onCopy,
40 + onRetry: onRetry,
41 + onDelete: onDelete,
42 + isStreaming: isStreaming,
43 + ),
44 );
45 case MessageRole.system:
40 - return _SystemBubble(message: message);
46 + return _AnimatedBubble(
47 + alignment: Alignment.center,
48 + child: _SystemBubble(message: message),
49 + );
50 case MessageRole.tool:
42 - return _ToolBubble(message: message);
51 + return _AnimatedBubble(
52 + alignment: Alignment.centerLeft,
53 + child: _ToolBubble(message: message),
54 + );
55 }
56 }
57 }
58
59 +class _AnimatedBubble extends StatelessWidget {
60 + const _AnimatedBubble({required this.child, required this.alignment});
61 +
62 + final Widget child;
63 + final Alignment alignment;
64 +
65 + @override
66 + Widget build(BuildContext context) {
67 + final offset = alignment == Alignment.centerRight
68 + ? const Offset(0.15, 0.0)
69 + : alignment == Alignment.centerLeft
70 + ? const Offset(-0.15, 0.0)
71 + : const Offset(0.0, 0.1);
72 +
73 + return TweenAnimationBuilder<double>(
74 + tween: Tween(begin: 0.0, end: 1.0),
75 + duration: const Duration(milliseconds: 250),
76 + curve: Curves.easeOutCubic,
77 + builder: (context, value, child) {
78 + return Opacity(
79 + opacity: value,
80 + child: Transform.translate(
81 + offset: Offset(
82 + offset.dx * (1 - value) * 50,
83 + offset.dy * (1 - value) * 30,
84 + ),
85 + child: child,
86 + ),
87 + );
88 + },
89 + child: child,
90 + );
91 + }
92 +}
93 +
94 class _UserBubble extends StatelessWidget {
95 const _UserBubble({required this.message});
96
lib/features/chat/views/components/chat_input_bar.dart
+225 -98
@@ -26,16 +26,32 @@ class ChatInputBar extends ConsumerStatefulWidget {
26 ConsumerState<ChatInputBar> createState() => _ChatInputBarState();
27 }
28
29 -class _ChatInputBarState extends ConsumerState<ChatInputBar> {
29 +class _ChatInputBarState extends ConsumerState<ChatInputBar>
30 + with SingleTickerProviderStateMixin {
31 final _controller = TextEditingController();
32 final _focusNode = FocusNode();
33 bool _isComposing = false;
34 final List<File> _attachedFiles = [];
35 + late AnimationController _sendButtonAnimController;
36 + late Animation<double> _sendButtonScale;
37 +
38 + @override
39 + void initState() {
40 + super.initState();
41 + _sendButtonAnimController = AnimationController(
42 + duration: const Duration(milliseconds: 200),
43 + vsync: this,
44 + );
45 + _sendButtonScale = Tween<double>(begin: 1.0, end: 1.1).animate(
46 + CurvedAnimation(parent: _sendButtonAnimController, curve: Curves.easeOut),
47 + );
48 + }
49
50 @override
51 void dispose() {
52 _controller.dispose();
53 _focusNode.dispose();
54 + _sendButtonAnimController.dispose();
55 super.dispose();
56 }
57
@@ -69,6 +85,90 @@ class _ChatInputBarState extends ConsumerState<ChatInputBar> {
85 widget.onStop();
86 }
87
88 + Widget _buildSendButton(bool canSend, bool isDark) {
89 + return GestureDetector(
90 + onTapDown: canSend ? (_) => _sendButtonAnimController.forward() : null,
91 + onTapUp: canSend ? (_) => _sendButtonAnimController.reverse() : null,
92 + onTapCancel: canSend ? () => _sendButtonAnimController.reverse() : null,
93 + child: ScaleTransition(
94 + scale: _sendButtonScale,
95 + child: AnimatedContainer(
96 + duration: const Duration(milliseconds: 200),
97 + curve: Curves.easeOut,
98 + decoration: BoxDecoration(
99 + color: canSend
100 + ? (isDark ? const Color(0xFF3B82F6) : const Color(0xFF2563EB))
101 + : (isDark ? const Color(0xFF3A3A3A) : const Color(0xFFE5E5E5)),
102 + shape: BoxShape.circle,
103 + boxShadow: canSend
104 + ? [
105 + BoxShadow(
106 + color:
107 + (isDark
108 + ? const Color(0xFF3B82F6)
109 + : const Color(0xFF2563EB))
110 + .withValues(alpha: 0.3),
111 + blurRadius: 8,
112 + spreadRadius: 2,
113 + ),
114 + ]
115 + : null,
116 + ),
117 + child: IconButton(
118 + icon: AnimatedSwitcher(
119 + duration: const Duration(milliseconds: 150),
120 + transitionBuilder: (child, animation) {
121 + return ScaleTransition(scale: animation, child: child);
122 + },
123 + child: Icon(
124 + Icons.arrow_upward,
125 + key: ValueKey(canSend),
126 + color: canSend
127 + ? Colors.white
128 + : (isDark
129 + ? const Color(0xFF666666)
130 + : const Color(0xFF999999)),
131 + ),
132 + ),
133 + onPressed: canSend ? _handleSubmit : null,
134 + tooltip: 'Send message',
135 + ),
136 + ),
137 + ),
138 + );
139 + }
140 +
141 + Widget _buildStopButton() {
142 + return TweenAnimationBuilder<double>(
143 + tween: Tween(begin: 0.8, end: 1.0),
144 + duration: const Duration(milliseconds: 200),
145 + curve: Curves.elasticOut,
146 + builder: (context, scale, child) {
147 + return Transform.scale(
148 + scale: scale,
149 + child: Container(
150 + decoration: BoxDecoration(
151 + color: Colors.red[400],
152 + shape: BoxShape.circle,
153 + boxShadow: [
154 + BoxShadow(
155 + color: Colors.red.withValues(alpha: 0.3),
156 + blurRadius: 8,
157 + spreadRadius: 2,
158 + ),
159 + ],
160 + ),
161 + child: IconButton(
162 + icon: const Icon(Icons.stop, color: Colors.white),
163 + onPressed: _handleStop,
164 + tooltip: 'Stop generating',
165 + ),
166 + ),
167 + );
168 + },
169 + );
170 + }
171 +
172 @override
173 Widget build(BuildContext context) {
174 final theme = Theme.of(context);
@@ -100,53 +200,76 @@ class _ChatInputBarState extends ConsumerState<ChatInputBar> {
200 child: Column(
201 mainAxisSize: MainAxisSize.min,
202 children: [
103 - if (_attachedFiles.isNotEmpty)
104 - Container(
105 - height: 60,
106 - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
107 - child: ListView.separated(
108 - scrollDirection: Axis.horizontal,
109 - itemCount: _attachedFiles.length,
110 - separatorBuilder: (_, _) => const SizedBox(width: 6),
111 - itemBuilder: (context, index) {
112 - final file = _attachedFiles[index];
113 - return Stack(
114 - children: [
115 - ClipRRect(
116 - borderRadius: BorderRadius.circular(8),
117 - child: Image.file(
118 - file,
119 - width: 52,
120 - height: 52,
121 - fit: BoxFit.cover,
122 - ),
123 - ),
124 - Positioned(
125 - top: 0,
126 - right: 0,
127 - child: GestureDetector(
128 - onTap: () =>
129 - setState(() => _attachedFiles.removeAt(index)),
130 - child: Container(
131 - width: 18,
132 - height: 18,
133 - decoration: BoxDecoration(
134 - color: Colors.black54,
135 - shape: BoxShape.circle,
136 - ),
137 - child: const Icon(
138 - Icons.close,
139 - size: 12,
140 - color: Colors.white,
141 - ),
203 + AnimatedSize(
204 + duration: const Duration(milliseconds: 200),
205 + curve: Curves.easeOut,
206 + child: _attachedFiles.isEmpty
207 + ? const SizedBox.shrink()
208 + : Container(
209 + height: 60,
210 + padding: const EdgeInsets.symmetric(
211 + horizontal: 8,
212 + vertical: 4,
213 + ),
214 + child: ListView.separated(
215 + scrollDirection: Axis.horizontal,
216 + itemCount: _attachedFiles.length,
217 + separatorBuilder: (_, _) => const SizedBox(width: 6),
218 + itemBuilder: (context, index) {
219 + final file = _attachedFiles[index];
220 + return TweenAnimationBuilder<double>(
221 + tween: Tween(begin: 0.0, end: 1.0),
222 + duration: Duration(milliseconds: 200 + (index * 50)),
223 + curve: Curves.easeOut,
224 + builder: (context, value, child) {
225 + return Opacity(
226 + opacity: value,
227 + child: Transform.scale(
228 + scale: 0.8 + (0.2 * value),
229 + child: child,
230 + ),
231 + );
232 + },
233 + child: Stack(
234 + children: [
235 + ClipRRect(
236 + borderRadius: BorderRadius.circular(8),
237 + child: Image.file(
238 + file,
239 + width: 52,
240 + height: 52,
241 + fit: BoxFit.cover,
242 + ),
243 + ),
244 + Positioned(
245 + top: 0,
246 + right: 0,
247 + child: GestureDetector(
248 + onTap: () => setState(
249 + () => _attachedFiles.removeAt(index),
250 + ),
251 + child: Container(
252 + width: 18,
253 + height: 18,
254 + decoration: BoxDecoration(
255 + color: Colors.black54,
256 + shape: BoxShape.circle,
257 + ),
258 + child: const Icon(
259 + Icons.close,
260 + size: 12,
261 + color: Colors.white,
262 + ),
263 + ),
264 + ),
265 + ),
266 + ],
267 ),
143 - ),
144 - ),
145 - ],
146 - );
147 - },
148 - ),
149 - ),
268 + );
269 + },
270 + ),
271 + ),
272 + ),
273 Row(
274 crossAxisAlignment: CrossAxisAlignment.end,
275 children: [
@@ -154,10 +277,14 @@ class _ChatInputBarState extends ConsumerState<ChatInputBar> {
277 child: Container(
278 constraints: const BoxConstraints(maxHeight: 150),
279 decoration: BoxDecoration(
157 - color: isDark ? const Color(0xFF1F1F1F) : const Color(0xFFFFFFFF),
280 + color: isDark
281 + ? const Color(0xFF1F1F1F)
282 + : const Color(0xFFFFFFFF),
283 borderRadius: BorderRadius.circular(24),
284 border: Border.all(
160 - color: isDark ? const Color(0xFF333333) : const Color(0xFFE5E5E5),
285 + color: isDark
286 + ? const Color(0xFF333333)
287 + : const Color(0xFFE5E5E5),
288 ),
289 ),
290 child: Row(
@@ -183,7 +310,9 @@ class _ChatInputBarState extends ConsumerState<ChatInputBar> {
310 textInputAction: TextInputAction.newline,
311 keyboardType: TextInputType.multiline,
312 onChanged: (text) {
186 - setState(() => _isComposing = text.trim().isNotEmpty);
313 + setState(
314 + () => _isComposing = text.trim().isNotEmpty,
315 + );
316 },
317 onSubmitted: (_) => _handleSubmit(),
318 decoration: InputDecoration(
@@ -201,60 +330,58 @@ class _ChatInputBarState extends ConsumerState<ChatInputBar> {
330 ),
331 ),
332 ),
204 - if (_isComposing)
205 - Padding(
206 - padding: const EdgeInsets.only(right: 12, bottom: 12),
207 - child: Text(
208 - '${_controller.text.length}',
209 - style: TextStyle(
210 - fontSize: 11,
211 - color: isDark
212 - ? const Color(0xFF666666)
213 - : const Color(0xFF999999),
214 - ),
215 - ),
333 + AnimatedSize(
334 + duration: const Duration(milliseconds: 150),
335 + curve: Curves.easeOut,
336 + child: AnimatedOpacity(
337 + opacity: _isComposing ? 1.0 : 0.0,
338 + duration: const Duration(milliseconds: 150),
339 + child: _isComposing
340 + ? Padding(
341 + padding: const EdgeInsets.only(
342 + right: 12,
343 + bottom: 12,
344 + ),
345 + child: TweenAnimationBuilder<double>(
346 + tween: Tween(begin: 0.5, end: 1.0),
347 + duration: const Duration(milliseconds: 150),
348 + builder: (context, scale, child) {
349 + return Transform.scale(
350 + scale: scale,
351 + child: child,
352 + );
353 + },
354 + child: Text(
355 + '${_controller.text.length}',
356 + style: TextStyle(
357 + fontSize: 11,
358 + color: isDark
359 + ? const Color(0xFF666666)
360 + : const Color(0xFF999999),
361 + ),
362 + ),
363 + ),
364 + )
365 + : const SizedBox(width: 0),
366 ),
367 + ),
368 ],
369 ),
370 ),
371 ),
372 const SizedBox(width: 8),
222 - widget.isStreaming
223 - ? Container(
224 - decoration: BoxDecoration(
225 - color: Colors.red[400],
226 - shape: BoxShape.circle,
227 - ),
228 - child: IconButton(
229 - icon: const Icon(Icons.stop, color: Colors.white),
230 - onPressed: _handleStop,
231 - tooltip: 'Stop generating',
232 - ),
233 - )
234 - : Container(
235 - decoration: BoxDecoration(
236 - color: canSend
237 - ? (isDark
238 - ? const Color(0xFF3B82F6)
239 - : const Color(0xFF2563EB))
240 - : (isDark
241 - ? const Color(0xFF3A3A3A)
242 - : const Color(0xFFE5E5E5)),
243 - shape: BoxShape.circle,
244 - ),
245 - child: IconButton(
246 - icon: Icon(
247 - Icons.arrow_upward,
248 - color: canSend
249 - ? Colors.white
250 - : (isDark
251 - ? const Color(0xFF666666)
252 - : const Color(0xFF999999)),
253 - ),
254 - onPressed: canSend ? _handleSubmit : null,
255 - tooltip: 'Send message',
256 - ),
257 - ),
373 + AnimatedSwitcher(
374 + duration: const Duration(milliseconds: 200),
375 + transitionBuilder: (child, animation) {
376 + return ScaleTransition(
377 + scale: animation,
378 + child: FadeTransition(opacity: animation, child: child),
379 + );
380 + },
381 + child: widget.isStreaming
382 + ? _buildStopButton()
383 + : _buildSendButton(canSend, isDark),
384 + ),
385 ],
386 ),
387 ],
lib/features/chat/views/components/code_block.dart
+154 -54
@@ -1,37 +1,93 @@
1 import 'package:flutter/material.dart';
2 import 'package:flutter/services.dart';
3 +import 'package:flutter_riverpod/flutter_riverpod.dart';
4 +import 'package:flutter_syntax_view/flutter_syntax_view.dart';
5 +import 'package:localmind/core/providers/app_providers.dart';
6 +import 'package:localmind/features/settings/data/models/app_settings.dart';
7
4 -class CodeBlock extends StatefulWidget {
8 +class CodeBlock extends ConsumerWidget {
9 const CodeBlock({super.key, required this.code, this.language});
10
11 final String code;
12 final String? language;
13
10 - @override
11 - State<CodeBlock> createState() => _CodeBlockState();
12 -}
14 + Syntax? _mapLanguage(String? lang) {
15 + if (lang == null || lang.isEmpty) return null;
16
14 -class _CodeBlockState extends State<CodeBlock> {
15 - bool _copied = false;
17 + switch (lang.toLowerCase()) {
18 + case 'dart':
19 + case 'flutter':
20 + return Syntax.DART;
21 + case 'c':
22 + return Syntax.C;
23 + case 'cpp':
24 + case 'c++':
25 + case 'cxx':
26 + return Syntax.CPP;
27 + case 'java':
28 + return Syntax.JAVA;
29 + case 'javascript':
30 + case 'js':
31 + return Syntax.JAVASCRIPT;
32 + case 'kotlin':
33 + case 'kt':
34 + return Syntax.KOTLIN;
35 + case 'lua':
36 + return Syntax.LUA;
37 + case 'python':
38 + case 'py':
39 + return Syntax.PYTHON;
40 + case 'rust':
41 + case 'rs':
42 + return Syntax.RUST;
43 + case 'swift':
44 + return Syntax.SWIFT;
45 + case 'yaml':
46 + case 'yml':
47 + return Syntax.YAML;
48 + default:
49 + return null;
50 + }
51 + }
52 +
53 + SyntaxTheme _getSyntaxTheme(SyntaxThemeName themeName) {
54 + switch (themeName) {
55 + case SyntaxThemeName.vscodeDark:
56 + return SyntaxTheme.vscodeDark();
57 + case SyntaxThemeName.vscodeLight:
58 + return SyntaxTheme.vscodeLight();
59 + case SyntaxThemeName.dracula:
60 + return SyntaxTheme.dracula();
61 + case SyntaxThemeName.monokaiSublime:
62 + return SyntaxTheme.monokaiSublime();
63 + case SyntaxThemeName.ayuLight:
64 + return SyntaxTheme.ayuLight();
65 + case SyntaxThemeName.ayuDark:
66 + return SyntaxTheme.ayuDark();
67 + case SyntaxThemeName.gravityLight:
68 + return SyntaxTheme.gravityLight();
69 + case SyntaxThemeName.gravityDark:
70 + return SyntaxTheme.gravityDark();
71 + case SyntaxThemeName.obsidian:
72 + return SyntaxTheme.obsidian();
73 + case SyntaxThemeName.oceanSunset:
74 + return SyntaxTheme.oceanSunset();
75 + case SyntaxThemeName.standard:
76 + return SyntaxTheme.standard();
77 + }
78 + }
79
80 String _normalizeLanguage(String? lang) {
81 if (lang == null || lang.isEmpty) return '';
82 return lang.toUpperCase();
83 }
84
22 - Future<void> _copyToClipboard() async {
23 - await Clipboard.setData(ClipboardData(text: widget.code));
24 - setState(() => _copied = true);
25 - await Future.delayed(const Duration(seconds: 2));
26 - if (mounted) {
27 - setState(() => _copied = false);
28 - }
29 - }
30 -
85 @override
32 - Widget build(BuildContext context) {
86 + Widget build(BuildContext context, WidgetRef ref) {
87 final theme = Theme.of(context);
88 final isDark = theme.brightness == Brightness.dark;
89 + final settings = ref.watch(settingsProvider);
90 + final codeTheme = _getSyntaxTheme(settings.codeTheme);
91
92 final backgroundColor = isDark
93 ? const Color(0xFF1E1E1E)
@@ -49,6 +105,9 @@ class _CodeBlockState extends State<CodeBlock> {
105 ? const Color(0xFF9CDCFE)
106 : const Color(0xFF001080);
107
108 + final syntax = _mapLanguage(language);
109 + final syntaxTheme = codeTheme.copyWith(backgroundColor: Colors.transparent);
110 +
111 return Container(
112 decoration: BoxDecoration(
113 color: backgroundColor,
@@ -70,7 +129,7 @@ class _CodeBlockState extends State<CodeBlock> {
129 mainAxisAlignment: MainAxisAlignment.spaceBetween,
130 children: [
131 Text(
73 - _normalizeLanguage(widget.language),
132 + _normalizeLanguage(language),
133 style: TextStyle(
134 fontSize: 11,
135 fontWeight: FontWeight.w600,
@@ -80,49 +139,90 @@ class _CodeBlockState extends State<CodeBlock> {
139 fontFamily: 'monospace',
140 ),
141 ),
83 - GestureDetector(
84 - onTap: _copyToClipboard,
85 - child: Row(
86 - mainAxisSize: MainAxisSize.min,
87 - children: [
88 - Icon(
89 - _copied ? Icons.check : Icons.copy,
90 - size: 14,
91 - color: _copied
92 - ? Colors.green
93 - : (isDark
94 - ? const Color(0xFF888888)
95 - : const Color(0xFF666666)),
96 - ),
97 - const SizedBox(width: 4),
98 - Text(
99 - _copied ? 'Copied!' : 'Copy',
100 - style: TextStyle(
101 - fontSize: 11,
102 - color: _copied
103 - ? Colors.green
104 - : (isDark
105 - ? const Color(0xFF888888)
106 - : const Color(0xFF666666)),
107 - ),
108 - ),
109 - ],
110 - ),
111 - ),
142 + _CopyButton(code: code, isDark: isDark),
143 ],
144 ),
145 ),
146 SingleChildScrollView(
147 scrollDirection: Axis.horizontal,
148 padding: const EdgeInsets.all(12),
118 - child: SelectableText(
119 - widget.code,
120 - style: TextStyle(
121 - fontFamily: 'monospace',
122 - fontSize: 13,
123 - height: 1.5,
124 - color: textColor,
125 - ),
149 + child: syntax != null
150 + ? SelectableText.rich(
151 + TextSpan(
152 + style: TextStyle(
153 + fontFamily: 'monospace',
154 + fontSize: 13,
155 + height: 1.5,
156 + ),
157 + children: <TextSpan>[
158 + getSyntax(syntax, syntaxTheme).format(code),
159 + ],
160 + ),
161 + )
162 + : SelectableText(
163 + code,
164 + style: TextStyle(
165 + fontFamily: 'monospace',
166 + fontSize: 13,
167 + height: 1.5,
168 + color: textColor,
169 + ),
170 + ),
171 + ),
172 + ],
173 + ),
174 + );
175 + }
176 +}
177 +
178 +class _CopyButton extends StatefulWidget {
179 + const _CopyButton({required this.code, required this.isDark});
180 +
181 + final String code;
182 + final bool isDark;
183 +
184 + @override
185 + State<_CopyButton> createState() => _CopyButtonState();
186 +}
187 +
188 +class _CopyButtonState extends State<_CopyButton> {
189 + bool _copied = false;
190 +
191 + Future<void> _copyToClipboard() async {
192 + await Clipboard.setData(ClipboardData(text: widget.code));
193 + setState(() => _copied = true);
194 + await Future.delayed(const Duration(seconds: 2));
195 + if (mounted) {
196 + setState(() => _copied = false);
197 + }
198 + }
199 +
200 + @override
201 + Widget build(BuildContext context) {
202 + return GestureDetector(
203 + onTap: _copyToClipboard,
204 + child: Row(
205 + mainAxisSize: MainAxisSize.min,
206 + children: [
207 + Icon(
208 + _copied ? Icons.check : Icons.copy,
209 + size: 14,
210 + color: _copied
211 + ? Colors.green
212 + : (widget.isDark
213 + ? const Color(0xFF888888)
214 + : const Color(0xFF666666)),
215 + ),
216 + const SizedBox(width: 4),
217 + Text(
218 + _copied ? 'Copied!' : 'Copy',
219 + style: TextStyle(
220 + fontSize: 11,
221 + color: _copied
222 + ? Colors.green
223 + : (widget.isDark
224 + ? const Color(0xFF888888)
225 + : const Color(0xFF666666)),
226 ),
227 ),
228 ],
lib/features/chat/views/components/message_action_bar.dart
+70 -10
@@ -183,7 +183,7 @@ class _MessageActionBarState extends State<MessageActionBar> {
183 }
184 }
185
186 -class _ActionButton extends StatelessWidget {
186 +class _ActionButton extends StatefulWidget {
187 const _ActionButton({
188 required this.icon,
189 required this.label,
@@ -196,23 +196,83 @@ class _ActionButton extends StatelessWidget {
196 final VoidCallback? onTap;
197 final bool isDestructive;
198
199 + @override
200 + State<_ActionButton> createState() => _ActionButtonState();
201 +}
202 +
203 +class _ActionButtonState extends State<_ActionButton>
204 + with SingleTickerProviderStateMixin {
205 + late AnimationController _controller;
206 + late Animation<double> _scaleAnimation;
207 + bool _isHovered = false;
208 +
209 + @override
210 + void initState() {
211 + super.initState();
212 + _controller = AnimationController(
213 + duration: const Duration(milliseconds: 100),
214 + vsync: this,
215 + );
216 + _scaleAnimation = Tween<double>(
217 + begin: 1.0,
218 + end: 0.9,
219 + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
220 + }
221 +
222 + @override
223 + void dispose() {
224 + _controller.dispose();
225 + super.dispose();
226 + }
227 +
228 @override
229 Widget build(BuildContext context) {
230 final theme = Theme.of(context);
231 final isDark = theme.brightness == Brightness.dark;
232
204 - final color = isDestructive
233 + final baseColor = widget.isDestructive
234 ? (isDark ? Colors.red[300] : Colors.red[600])
235 : (isDark ? const Color(0xFF888888) : const Color(0xFF666666));
236
208 - return Tooltip(
209 - message: label,
210 - child: InkWell(
211 - onTap: onTap,
212 - borderRadius: BorderRadius.circular(4),
213 - child: Padding(
214 - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
215 - child: Icon(icon, size: 16, color: color),
237 + final hoverColor = widget.isDestructive
238 + ? (isDark ? Colors.red[200] : Colors.red[500])
239 + : (isDark ? const Color(0xFFAAAAAA) : const Color(0xFF444444));
240 +
241 + return MouseRegion(
242 + onEnter: (_) => setState(() => _isHovered = true),
243 + onExit: (_) => setState(() => _isHovered = false),
244 + child: Tooltip(
245 + message: widget.label,
246 + child: GestureDetector(
247 + onTapDown: widget.onTap != null ? (_) => _controller.forward() : null,
248 + onTapUp: widget.onTap != null ? (_) => _controller.reverse() : null,
249 + onTapCancel: widget.onTap != null
250 + ? () => _controller.reverse()
251 + : null,
252 + onTap: widget.onTap,
253 + child: AnimatedScale(
254 + scale: _scaleAnimation.value,
255 + duration: const Duration(milliseconds: 100),
256 + curve: Curves.easeInOut,
257 + child: AnimatedContainer(
258 + duration: const Duration(milliseconds: 150),
259 + curve: Curves.easeOut,
260 + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
261 + decoration: BoxDecoration(
262 + color: _isHovered
263 + ? (isDark
264 + ? const Color(0xFF2A2A2A)
265 + : const Color(0xFFE5E5E5))
266 + : Colors.transparent,
267 + borderRadius: BorderRadius.circular(4),
268 + ),
269 + child: Icon(
270 + widget.icon,
271 + size: 16,
272 + color: _isHovered ? hoverColor : baseColor,
273 + ),
274 + ),
275 + ),
276 ),
277 ),
278 );
lib/features/settings/data/models/app_settings.dart
+32
@@ -3,6 +3,32 @@ import 'package:hive_ce/hive.dart';
3
4 part 'app_settings.g.dart';
5
6 +@HiveType(typeId: 5)
7 +enum SyntaxThemeName {
8 + @HiveField(0)
9 + vscodeDark,
10 + @HiveField(1)
11 + vscodeLight,
12 + @HiveField(2)
13 + dracula,
14 + @HiveField(3)
15 + monokaiSublime,
16 + @HiveField(4)
17 + ayuLight,
18 + @HiveField(5)
19 + ayuDark,
20 + @HiveField(6)
21 + gravityLight,
22 + @HiveField(7)
23 + gravityDark,
24 + @HiveField(8)
25 + obsidian,
26 + @HiveField(9)
27 + oceanSunset,
28 + @HiveField(10)
29 + standard,
30 +}
31 +
32 @HiveType(typeId: 4)
33 class AppSettings extends HiveObject {
34 @HiveField(0)
@@ -53,6 +79,9 @@ class AppSettings extends HiveObject {
79 @HiveField(15, defaultValue: true)
80 final bool mcpEnabled;
81
82 + @HiveField(16, defaultValue: SyntaxThemeName.vscodeDark)
83 + final SyntaxThemeName codeTheme;
84 +
85 AppSettings({
86 this.temperature = 0.7,
87 this.topP = 0.9,
@@ -70,6 +99,7 @@ class AppSettings extends HiveObject {
99 this.defaultPersonaId,
100 this.hasCompletedOnboarding = false,
101 this.mcpEnabled = true,
102 + this.codeTheme = SyntaxThemeName.vscodeDark,
103 });
104
105 AppSettings copyWith({
@@ -89,6 +119,7 @@ class AppSettings extends HiveObject {
119 String? defaultPersonaId,
120 bool? hasCompletedOnboarding,
121 bool? mcpEnabled,
122 + SyntaxThemeName? codeTheme,
123 }) {
124 return AppSettings(
125 temperature: temperature ?? this.temperature,
@@ -109,6 +140,7 @@ class AppSettings extends HiveObject {
140 hasCompletedOnboarding:
141 hasCompletedOnboarding ?? this.hasCompletedOnboarding,
142 mcpEnabled: mcpEnabled ?? this.mcpEnabled,
143 + codeTheme: codeTheme ?? this.codeTheme,
144 );
145 }
146 }
lib/features/settings/data/models/app_settings.g.dart
+91 -2
@@ -6,6 +6,90 @@ part of 'app_settings.dart';
6 // TypeAdapterGenerator
7 // **************************************************************************
8
9 +class SyntaxThemeNameAdapter extends TypeAdapter<SyntaxThemeName> {
10 + @override
11 + final typeId = 5;
12 +
13 + @override
14 + SyntaxThemeName read(BinaryReader reader) {
15 + switch (reader.readByte()) {
16 + case 0:
17 + return SyntaxThemeName.vscodeDark;
18 + case 1:
19 + return SyntaxThemeName.vscodeLight;
20 + case 2:
21 + return SyntaxThemeName.dracula;
22 + case 3:
23 + return SyntaxThemeName.monokaiSublime;
24 + case 4:
25 + return SyntaxThemeName.ayuLight;
26 + case 5:
27 + return SyntaxThemeName.ayuDark;
28 + case 6:
29 + return SyntaxThemeName.gravityLight;
30 + case 7:
31 + return SyntaxThemeName.gravityDark;
32 + case 8:
33 + return SyntaxThemeName.obsidian;
34 + case 9:
35 + return SyntaxThemeName.oceanSunset;
36 + case 10:
37 + return SyntaxThemeName.standard;
38 + default:
39 + return SyntaxThemeName.vscodeDark;
40 + }
41 + }
42 +
43 + @override
44 + void write(BinaryWriter writer, SyntaxThemeName obj) {
45 + switch (obj) {
46 + case SyntaxThemeName.vscodeDark:
47 + writer.writeByte(0);
48 + break;
49 + case SyntaxThemeName.vscodeLight:
50 + writer.writeByte(1);
51 + break;
52 + case SyntaxThemeName.dracula:
53 + writer.writeByte(2);
54 + break;
55 + case SyntaxThemeName.monokaiSublime:
56 + writer.writeByte(3);
57 + break;
58 + case SyntaxThemeName.ayuLight:
59 + writer.writeByte(4);
60 + break;
61 + case SyntaxThemeName.ayuDark:
62 + writer.writeByte(5);
63 + break;
64 + case SyntaxThemeName.gravityLight:
65 + writer.writeByte(6);
66 + break;
67 + case SyntaxThemeName.gravityDark:
68 + writer.writeByte(7);
69 + break;
70 + case SyntaxThemeName.obsidian:
71 + writer.writeByte(8);
72 + break;
73 + case SyntaxThemeName.oceanSunset:
74 + writer.writeByte(9);
75 + break;
76 + case SyntaxThemeName.standard:
77 + writer.writeByte(10);
78 + break;
79 + }
80 + }
81 +
82 + @override
83 + int get hashCode => typeId.hashCode;
84 +
85 + @override
86 + bool operator ==(Object other) =>
87 + identical(this, other) ||
88 + other is SyntaxThemeNameAdapter &&
89 + runtimeType == other.runtimeType &&
90 + typeId == other.typeId;
91 +}
92 +
93 class AppSettingsAdapter extends TypeAdapter<AppSettings> {
94 @override
95 final typeId = 4;
@@ -33,13 +117,16 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
117 defaultPersonaId: fields[13] as String?,
118 hasCompletedOnboarding: fields[14] == null ? false : fields[14] as bool,
119 mcpEnabled: fields[15] == null ? true : fields[15] as bool,
120 + codeTheme: fields[16] == null
121 + ? SyntaxThemeName.vscodeDark
122 + : fields[16] as SyntaxThemeName,
123 );
124 }
125
126 @override
127 void write(BinaryWriter writer, AppSettings obj) {
128 writer
42 - ..writeByte(16)
129 + ..writeByte(17)
130 ..writeByte(0)
131 ..write(obj.temperature)
132 ..writeByte(1)
@@ -71,7 +158,9 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
158 ..writeByte(14)
159 ..write(obj.hasCompletedOnboarding)
160 ..writeByte(15)
74 - ..write(obj.mcpEnabled);
161 + ..write(obj.mcpEnabled)
162 + ..writeByte(16)
163 + ..write(obj.codeTheme);
164 }
165
166 @override
lib/features/settings/screens/settings_screen.dart
+110
@@ -6,6 +6,7 @@ import 'package:localmind/core/providers/storage_providers.dart' as storage;
6 import 'package:localmind/core/theme/app_theme.dart';
7 import 'package:localmind/features/conversations/providers/conversation_providers.dart';
8 import 'package:localmind/features/servers/providers/server_providers.dart';
9 +import 'package:localmind/features/settings/data/models/app_settings.dart';
10
11 class SettingsScreen extends ConsumerWidget {
12 const SettingsScreen({super.key});
@@ -82,6 +83,12 @@ class SettingsScreen extends ConsumerWidget {
83 valueFormat: (v) => v.toStringAsFixed(0),
84 previewText: 'The quick brown fox jumps over the lazy dog.',
85 ),
86 + _CodeThemeDropdown(
87 + current: settings.codeTheme,
88 + onChanged: (v) =>
89 + ref.read(settingsProvider.notifier).setCodeTheme(v),
90 + isDark: isDark,
91 + ),
92 const Divider(height: 32),
93 _SectionHeader(title: 'Behavior'),
94 _ToggleSetting(
@@ -714,6 +721,109 @@ class _DropdownSetting extends StatelessWidget {
721 }
722 }
723
724 +class _CodeThemeDropdown extends StatelessWidget {
725 + const _CodeThemeDropdown({
726 + required this.current,
727 + required this.onChanged,
728 + required this.isDark,
729 + });
730 +
731 + final SyntaxThemeName current;
732 + final ValueChanged<SyntaxThemeName> onChanged;
733 + final bool isDark;
734 +
735 + String _getDisplayName(SyntaxThemeName theme) {
736 + switch (theme) {
737 + case SyntaxThemeName.vscodeDark:
738 + return 'VS Code Dark';
739 + case SyntaxThemeName.vscodeLight:
740 + return 'VS Code Light';
741 + case SyntaxThemeName.dracula:
742 + return 'Dracula';
743 + case SyntaxThemeName.monokaiSublime:
744 + return 'Monokai';
745 + case SyntaxThemeName.ayuLight:
746 + return 'Ayu Light';
747 + case SyntaxThemeName.ayuDark:
748 + return 'Ayu Dark';
749 + case SyntaxThemeName.gravityLight:
750 + return 'Gravity Light';
751 + case SyntaxThemeName.gravityDark:
752 + return 'Gravity Dark';
753 + case SyntaxThemeName.obsidian:
754 + return 'Obsidian';
755 + case SyntaxThemeName.oceanSunset:
756 + return 'Ocean Sunset';
757 + case SyntaxThemeName.standard:
758 + return 'Standard';
759 + }
760 + }
761 +
762 + @override
763 + Widget build(BuildContext context) {
764 + return Padding(
765 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
766 + child: Column(
767 + crossAxisAlignment: CrossAxisAlignment.start,
768 + children: [
769 + Text(
770 + 'Code Theme',
771 + style: TextStyle(
772 + fontSize: 14,
773 + fontWeight: FontWeight.w500,
774 + color: isDark ? Colors.white : Colors.black,
775 + ),
776 + ),
777 + const SizedBox(height: 8),
778 + Container(
779 + decoration: BoxDecoration(
780 + color: isDark ? const Color(0xFF1F1F1F) : const Color(0xFFF5F5F5),
781 + borderRadius: BorderRadius.circular(8),
782 + border: Border.all(
783 + color: isDark
784 + ? const Color(0xFF3A3A3A)
785 + : const Color(0xFFE5E5E5),
786 + ),
787 + ),
788 + child: DropdownButtonHideUnderline(
789 + child: DropdownButton<SyntaxThemeName>(
790 + value: current,
791 + isExpanded: true,
792 + padding: const EdgeInsets.symmetric(horizontal: 12),
793 + borderRadius: BorderRadius.circular(8),
794 + dropdownColor: isDark ? const Color(0xFF1F1F1F) : Colors.white,
795 + items: SyntaxThemeName.values.map((theme) {
796 + return DropdownMenuItem(
797 + value: theme,
798 + child: Text(
799 + _getDisplayName(theme),
800 + style: TextStyle(
801 + fontSize: 14,
802 + color: isDark ? Colors.white : Colors.black,
803 + ),
804 + ),
805 + );
806 + }).toList(),
807 + onChanged: (v) {
808 + if (v != null) onChanged(v);
809 + },
810 + ),
811 + ),
812 + ),
813 + const SizedBox(height: 4),
814 + Text(
815 + 'Choose syntax highlighting theme for code blocks.',
816 + style: TextStyle(
817 + fontSize: 12,
818 + color: isDark ? const Color(0xFF666666) : const Color(0xFF999999),
819 + ),
820 + ),
821 + ],
822 + ),
823 + );
824 + }
825 +}
826 +
827 class _DangerousAction extends StatelessWidget {
828 const _DangerousAction({
829 required this.label,
lib/hive_registrar.g.dart
+2
@@ -11,6 +11,7 @@ import 'package:localmind/features/servers/data/models/server.dart';
11
12 extension HiveRegistrar on HiveInterface {
13 void registerAdapters() {
14 + registerAdapter(SyntaxThemeNameAdapter());
15 registerAdapter(AppSettingsAdapter());
16 registerAdapter(ConversationAdapter());
17 registerAdapter(MessageAdapter());
@@ -21,6 +22,7 @@ extension HiveRegistrar on HiveInterface {
22
23 extension IsolatedHiveRegistrar on IsolatedHiveInterface {
24 void registerAdapters() {
25 + registerAdapter(SyntaxThemeNameAdapter());
26 registerAdapter(AppSettingsAdapter());
27 registerAdapter(ConversationAdapter());
28 registerAdapter(MessageAdapter());
pubspec.lock
+20 -84
@@ -17,6 +17,14 @@ packages:
17 url: "https://pub.dev"
18 source: hosted
19 version: "10.0.1"
20 + animations:
21 + dependency: "direct main"
22 + description:
23 + name: animations
24 + sha256: a120785be876b24177e8af387929e786e7761d6574e63cad6c2ca28545b30186
25 + url: "https://pub.dev"
26 + source: hosted
27 + version: "2.1.2"
28 args:
29 dependency: transitive
30 description:
@@ -217,22 +225,6 @@ packages:
225 url: "https://pub.dev"
226 source: hosted
227 version: "0.7.12"
220 - device_info_plus:
221 - dependency: transitive
222 - description:
223 - name: device_info_plus
224 - sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a"
225 - url: "https://pub.dev"
226 - source: hosted
227 - version: "11.5.0"
228 - device_info_plus_platform_interface:
229 - dependency: transitive
230 - description:
231 - name: device_info_plus_platform_interface
232 - sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
233 - url: "https://pub.dev"
234 - source: hosted
235 - version: "7.0.3"
228 dio:
229 dependency: "direct main"
230 description:
@@ -371,6 +363,14 @@ packages:
363 url: "https://pub.dev"
364 source: hosted
365 version: "2.2.4"
366 + flutter_syntax_view:
367 + dependency: "direct main"
368 + description:
369 + name: flutter_syntax_view
370 + sha256: c5017bbedfdcf538daba765e16541fcb26434071655ca00cea7cbc205a70246a
371 + url: "https://pub.dev"
372 + source: hosted
373 + version: "4.1.7"
374 flutter_test:
375 dependency: "direct dev"
376 description: flutter
@@ -409,10 +409,10 @@ packages:
409 dependency: "direct main"
410 description:
411 name: go_router
412 - sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
412 + sha256: "7974313e217a7771557add6ff2238acb63f635317c35fa590d348fb238f00896"
413 url: "https://pub.dev"
414 source: hosted
415 - version: "14.8.1"
415 + version: "17.1.0"
416 google_fonts:
417 dependency: "direct main"
418 description:
@@ -525,22 +525,6 @@ packages:
525 url: "https://pub.dev"
526 source: hosted
527 version: "1.0.5"
528 - irondash_engine_context:
529 - dependency: transitive
530 - description:
531 - name: irondash_engine_context
532 - sha256: "2bb0bc13dfda9f5aaef8dde06ecc5feb1379f5bb387d59716d799554f3f305d7"
533 - url: "https://pub.dev"
534 - source: hosted
535 - version: "0.5.5"
536 - irondash_message_channel:
537 - dependency: transitive
538 - description:
539 - name: irondash_message_channel
540 - sha256: b4101669776509c76133b8917ab8cfc704d3ad92a8c450b92934dd8884a2f060
541 - url: "https://pub.dev"
542 - source: hosted
543 - version: "0.7.0"
528 isolate_channel:
529 dependency: transitive
530 description:
@@ -757,14 +741,6 @@ packages:
741 url: "https://pub.dev"
742 source: hosted
743 version: "7.0.2"
760 - pixel_snap:
761 - dependency: transitive
762 - description:
763 - name: pixel_snap
764 - sha256: "677410ea37b07cd37ecb6d5e6c0d8d7615a7cf3bd92ba406fd1ac57e937d1fb0"
765 - url: "https://pub.dev"
766 - source: hosted
767 - version: "0.1.5"
744 platform:
745 dependency: transitive
746 description:
@@ -825,10 +801,10 @@ packages:
801 dependency: "direct main"
802 description:
803 name: shadcn_ui
828 - sha256: "2b5fe5d30dd535611196e7403429b21b64dfd4bfec9ee694a711c99ed85c5414"
804 + sha256: "342da1212a10d662a8a1e7c99a09b2c806303be24f06a7a4921159a130b6d06d"
805 url: "https://pub.dev"
806 source: hosted
831 - version: "0.53.1"
807 + version: "0.53.3"
808 shelf:
809 dependency: transitive
810 description:
@@ -962,30 +938,6 @@ packages:
938 url: "https://pub.dev"
939 source: hosted
940 version: "1.4.1"
965 - super_clipboard:
966 - dependency: transitive
967 - description:
968 - name: super_clipboard
969 - sha256: e73f3bb7e66cc9260efa1dc507f979138e7e106c3521e2dda2d0311f6d728a16
970 - url: "https://pub.dev"
971 - source: hosted
972 - version: "0.9.1"
973 - super_native_extensions:
974 - dependency: transitive
975 - description:
976 - name: super_native_extensions
977 - sha256: b9611dcb68f1047d6f3ef11af25e4e68a21b1a705bbcc3eb8cb4e9f5c3148569
978 - url: "https://pub.dev"
979 - source: hosted
980 - version: "0.9.1"
981 - syntax_highlight:
982 - dependency: "direct main"
983 - description:
984 - name: syntax_highlight
985 - sha256: "4d3ba40658cadba6ba55d697f29f00b43538ebb6eb4a0ca0e895c568eaced138"
986 - url: "https://pub.dev"
987 - source: hosted
988 - version: "0.5.0"
941 term_glyph:
942 dependency: transitive
943 description:
@@ -1050,14 +1002,6 @@ packages:
1002 url: "https://pub.dev"
1003 source: hosted
1004 version: "1.0.11"
1053 - uuid:
1054 - dependency: transitive
1055 - description:
1056 - name: uuid
1057 - sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
1058 - url: "https://pub.dev"
1059 - source: hosted
1060 - version: "4.5.3"
1005 vector_graphics:
1006 dependency: transitive
1007 description:
@@ -1146,14 +1090,6 @@ packages:
1090 url: "https://pub.dev"
1091 source: hosted
1092 version: "5.15.0"
1149 - win32_registry:
1150 - dependency: transitive
1151 - description:
1152 - name: win32_registry
1153 - sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae"
1154 - url: "https://pub.dev"
1155 - source: hosted
1156 - version: "2.1.0"
1093 xdg_directories:
1094 dependency: transitive
1095 description:
pubspec.yaml
+5 -4
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
16 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17 # In Windows, build-name is used as the major, minor, and patch parts
18 # of the product and file versions while build-number is used as the build suffix.
19 -version: 1.0.0+1
19 +version: 1.0.0+2
20
21 environment:
22 sdk: ^3.11.0
@@ -35,19 +35,20 @@ dependencies:
35 # Use with the CupertinoIcons class for iOS style icons.
36 cupertino_icons: ^1.0.8
37 flutter_riverpod: ^3.3.1
38 - go_router: ^14.8.1
38 + go_router: ^17.1.0
39 hive_ce: ^2.19.3
40 hive_ce_flutter: ^2.3.4
41 - shadcn_ui: ^0.53.1
41 + shadcn_ui: ^0.53.3
42 dio: ^5.9.2
43 flutter_markdown_plus: ^1.0.7
44 - syntax_highlight: ^0.5.0
44 flutter_tts: ^4.2.5
45 file_picker: ^10.3.10
46 path_provider: ^2.1.5
47 haptic_feedback: ^0.6.4+3
48 hugeicons: ^1.1.5
49 google_fonts: ^6.2.1
50 + flutter_syntax_view: 4.1.7
51 + animations: 2.1.2
52
53 dev_dependencies:
54 flutter_test: