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: