6 files changed
+546
-194
lib/core/providers/storage_providers.dart
+1
-1
@@ -27,7 +27,7 @@ final conversationsProvider = Provider<List<Conversation>>((ref) {
27
28
final recentConversationsProvider = Provider<List<Conversation>>((ref) {
29
final all = ref.watch(conversationsProvider);
30
- return all.take(5).toList();
30
+ return all.take(3).toList();
31
});
32
33
final personasBoxProvider = Provider<Box<Persona>>((ref) {
lib/features/chat/data/chat_service.dart
+1
-3
@@ -359,9 +359,7 @@ class LMStudioChatService implements ChatService {
359
dynamic _formatInput(List<Message> messages) {
360
final formattedInputs = <Map<String, dynamic>>[];
361
for (final m in messages) {
362
- if (m.role == MessageRole.system) {
363
- formattedInputs.add({'type': 'system_prompt', 'content': m.content});
364
- } else {
362
+ if (m.role != MessageRole.system) {
363
formattedInputs.add({'type': 'text', 'content': m.content});
364
}
365
}
lib/features/chat/providers/chat_providers.dart
+20
-8
@@ -13,6 +13,7 @@ import 'package:localmind/features/conversations/providers/conversation_provider
13
import 'package:localmind/features/models/data/models/model_info.dart';
14
import 'package:localmind/features/servers/providers/server_providers.dart';
15
import 'package:localmind/core/providers/app_providers.dart';
16
+import 'package:localmind/features/personas/providers/personas_providers.dart';
17
18
final selectedModelProvider =
19
NotifierProvider<SelectedModelNotifier, ModelInfo?>(() {
@@ -131,6 +132,7 @@ final chatParamsProvider = Provider<ChatParameters>((ref) {
132
int maxTokens = settings.maxTokens;
133
int contextLength = settings.contextLength;
134
135
+ String? systemPrompt;
136
if (activeConv?.personaId != null) {
137
final personaId = activeConv!.personaId;
138
try {
@@ -139,14 +141,17 @@ final chatParamsProvider = Provider<ChatParameters>((ref) {
141
(p) => p.id == personaId,
142
orElse: () => null,
143
);
142
- if (persona != null && persona.preferredParams != null) {
143
- final params = persona.preferredParams as Map<String, dynamic>;
144
- if (params['temperature'] != null) {
145
- temperature = (params['temperature'] as num).toDouble();
146
- }
147
- if (params['topP'] != null) topP = (params['topP'] as num).toDouble();
148
- if (params['maxTokens'] != null) {
149
- maxTokens = (params['maxTokens'] as num).toInt();
144
+ if (persona != null) {
145
+ systemPrompt = persona.systemPrompt;
146
+ if (persona.preferredParams != null) {
147
+ final params = persona.preferredParams as Map<String, dynamic>;
148
+ if (params['temperature'] != null) {
149
+ temperature = (params['temperature'] as num).toDouble();
150
+ }
151
+ if (params['topP'] != null) topP = (params['topP'] as num).toDouble();
152
+ if (params['maxTokens'] != null) {
153
+ maxTokens = (params['maxTokens'] as num).toInt();
154
+ }
155
}
156
}
157
} catch (_) {}
@@ -157,6 +162,7 @@ final chatParamsProvider = Provider<ChatParameters>((ref) {
162
topP: topP,
163
maxTokens: maxTokens,
164
contextLength: contextLength,
165
+ systemPrompt: systemPrompt,
166
);
167
});
168
@@ -324,6 +330,7 @@ class ChatNotifier extends Notifier<ChatState> {
330
if (_currentConversationId == null) {
331
final server = ref.read(activeServerProvider);
332
final selectedModel = ref.read(selectedModelProvider);
333
+ final selectedPersona = ref.read(selectedPersonaProvider);
334
final conversation = await ref
335
.read(conv.conversationsProvider.notifier)
336
.createConversation(
@@ -332,11 +339,16 @@ class ChatNotifier extends Notifier<ChatState> {
339
: content,
340
serverId: server?.id,
341
modelId: selectedModel?.id,
342
+ personaId: selectedPersona?.id,
343
+ systemPrompt: selectedPersona?.systemPrompt,
344
);
345
_currentConversationId = conversation.id;
346
ref
347
.read(conv.activeConversationProvider.notifier)
348
.setActiveConversation(conversation);
349
+
350
+ // Clear preselected persona after it's applied to the new conversation
351
+ ref.read(selectedPersonaProvider.notifier).clear();
352
}
353
354
final userMessage = Message(
lib/features/chat/views/chat_screen.dart
+467
-149
@@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
2
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
import 'package:go_router/go_router.dart';
4
import 'package:shadcn_ui/shadcn_ui.dart';
5
+import 'package:hugeicons/hugeicons.dart';
6
+import 'package:localmind/features/servers/views/components/server_icon_picker.dart';
7
+import 'package:localmind/features/servers/data/models/server.dart';
8
import 'package:localmind/core/models/enums.dart';
9
import 'package:localmind/core/providers/app_providers.dart';
10
import 'package:localmind/core/providers/storage_providers.dart';
@@ -75,7 +78,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
78
final chatState = ref.watch(chatProvider);
79
final selectedModel = ref.watch(selectedModelProvider);
80
final connectionStatus = ref.watch(connectionStatusProvider);
78
- final isStreaming = ref.watch(isStreamingProvider);
81
+ final activeServer = ref.watch(activeServerProvider);
82
final activeConversation = ref.watch(conv.activeConversationProvider);
83
final personaId = activeConversation?.personaId;
84
final persona = personaId != null
@@ -108,140 +111,146 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
111
112
return Column(
113
children: [
111
- AppBar(
112
- leading: ShadResponsiveBuilder(
113
- builder: (context, breakpoint) {
114
- final isDesktop =
115
- breakpoint >= ShadTheme.of(context).breakpoints.md;
116
- if (isDesktop) return const SizedBox.shrink();
117
- return IconButton(
118
- icon: const Icon(Icons.menu),
119
- onPressed: () => Scaffold.of(context).openDrawer(),
120
- );
121
- },
122
- ),
123
- title: GestureDetector(
124
- onTap: () => _showModelPicker(context),
125
- child: Row(
126
- mainAxisSize: MainAxisSize.min,
127
- children: [
128
- if (persona != null) ...[
129
- Text(persona.emoji, style: const TextStyle(fontSize: 18)),
130
- const SizedBox(width: 4),
131
- Text(
132
- persona.name,
133
- style: TextStyle(
134
- fontSize: 14,
135
- color: isDark
136
- ? const Color(0xFF9CA3AF)
137
- : const Color(0xFF6B7280),
114
+ _ModelTopBar(
115
+ selectedModel: selectedModel,
116
+ onTap: () => _showModelPicker(context),
117
+ ),
118
+ Container(
119
+ height: 56,
120
+ padding: const EdgeInsets.symmetric(horizontal: 4),
121
+ child: Row(
122
+ children: [
123
+ ShadResponsiveBuilder(
124
+ builder: (context, breakpoint) {
125
+ final isDesktop =
126
+ breakpoint >= ShadTheme.of(context).breakpoints.md;
127
+ if (isDesktop) return const SizedBox.shrink();
128
+ return IconButton(
129
+ icon: const Icon(Icons.menu),
130
+ onPressed: () => Scaffold.of(context).openDrawer(),
131
+ );
132
+ },
133
+ ),
134
+ const SizedBox(width: 8),
135
+ Expanded(
136
+ child: Row(
137
+ children: [
138
+ if (activeServer != null) ...[
139
+ _buildServerIcon(context, activeServer),
140
+ const SizedBox(width: 8),
141
+ ],
142
+ Text(
143
+ activeServer?.name ?? 'LocalMind',
144
+ style: const TextStyle(
145
+ fontSize: 18,
146
+ fontWeight: FontWeight.bold,
147
+ ),
148
),
139
- ),
140
- const SizedBox(width: 8),
141
- ],
142
- if (isStreaming) ...[
143
- const SizedBox(
144
- width: 14,
145
- height: 14,
146
- child: CircularProgressIndicator(
147
- strokeWidth: 2,
148
- valueColor: AlwaysStoppedAnimation<Color>(
149
- Color(0xFF4CAF50),
149
+ const SizedBox(width: 8),
150
+ Container(
151
+ width: 8,
152
+ height: 8,
153
+ decoration: BoxDecoration(
154
+ shape: BoxShape.circle,
155
+ color:
156
+ connectionStatus == ConnectionStatus.connected
157
+ ? Colors.green
158
+ : Colors.grey,
159
),
160
),
152
- ),
153
- const SizedBox(width: 6),
154
- ],
155
- Text(
156
- selectedModel?.displayName ?? 'Select Model',
157
- style: const TextStyle(fontSize: 16),
158
- ),
159
- const SizedBox(width: 4),
160
- Icon(
161
- Icons.expand_more,
162
- size: 16,
163
- color: isDark
164
- ? const Color(0xFF888888)
165
- : const Color(0xFF666666),
166
- ),
167
- ],
168
- ),
169
- ),
170
- actions: [
171
- Consumer(
172
- builder: (context, ref, child) {
173
- final settings = ref.watch(settingsProvider);
174
- final mcpConfig = ref.watch(chatMcpConfigProvider);
175
- final isEnabled = settings.mcpEnabled && mcpConfig.enabled;
176
- return IconButton(
177
- icon: Icon(
178
- isEnabled ? Icons.extension : Icons.extension_off_outlined,
179
- color: isEnabled
180
- ? theme.colorScheme.primary
181
- : (isDark
182
- ? const Color(0xFF666666)
183
- : const Color(0xFF999999)),
184
- ),
185
- tooltip: isEnabled ? 'MCP Enabled' : 'MCP Disabled',
186
- onPressed: () => showMcpConfigSheet(context),
187
- );
188
- },
189
- ),
190
- IconButton(
191
- icon: const Icon(Icons.settings_outlined),
192
- onPressed: () => context.push(AppRoutes.settings),
193
- tooltip: 'Settings',
194
- ),
195
- PopupMenuButton<String>(
196
- icon: const Icon(Icons.more_vert),
197
- onSelected: (value) => _handleMenuAction(value, context),
198
- itemBuilder: (context) => [
199
- const PopupMenuItem(
200
- value: 'new_chat',
201
- child: ListTile(
202
- leading: Icon(Icons.add),
203
- title: Text('New Chat'),
204
- contentPadding: EdgeInsets.zero,
205
- ),
161
+ ],
162
),
207
- PopupMenuItem(
208
- value: 'persona',
209
- child: ListTile(
210
- leading: Icon(
211
- persona != null
212
- ? Icons.swap_horiz
213
- : Icons.smart_toy_outlined,
163
+ ),
164
+ Consumer(
165
+ builder: (context, ref, child) {
166
+ final settings = ref.watch(settingsProvider);
167
+ final mcpConfig = ref.watch(chatMcpConfigProvider);
168
+ final isEnabled = settings.mcpEnabled && mcpConfig.enabled;
169
+ return IconButton(
170
+ icon: Icon(
171
+ isEnabled
172
+ ? Icons.extension
173
+ : Icons.extension_off_outlined,
174
+ color: isEnabled
175
+ ? theme.colorScheme.primary
176
+ : (isDark
177
+ ? const Color(0xFF666666)
178
+ : const Color(0xFF999999)),
179
),
215
- title: Text(
216
- persona != null ? 'Change Persona' : 'Set Persona',
180
+ tooltip: isEnabled ? 'MCP Enabled' : 'MCP Disabled',
181
+ onPressed: () => showMcpConfigSheet(context),
182
+ );
183
+ },
184
+ ),
185
+ IconButton(
186
+ icon: const Icon(Icons.settings_outlined),
187
+ onPressed: () => context.push(AppRoutes.settings),
188
+ tooltip: 'Settings',
189
+ ),
190
+ PopupMenuButton<String>(
191
+ icon: const Icon(Icons.more_vert),
192
+ onSelected: (value) => _handleMenuAction(value, context),
193
+ itemBuilder: (context) => [
194
+ const PopupMenuItem(
195
+ value: 'new_chat',
196
+ child: ListTile(
197
+ leading: Icon(Icons.add),
198
+ title: Text('New Chat'),
199
+ contentPadding: EdgeInsets.zero,
200
),
218
- contentPadding: EdgeInsets.zero,
201
),
220
- ),
221
- if (persona != null)
222
- const PopupMenuItem(
223
- value: 'remove_persona',
202
+ PopupMenuItem(
203
+ value: 'persona',
204
child: ListTile(
225
- leading: Icon(Icons.person_remove_outlined),
226
- title: Text('Remove Persona'),
205
+ leading: Icon(
206
+ persona != null
207
+ ? Icons.swap_horiz
208
+ : Icons.smart_toy_outlined,
209
+ ),
210
+ title: Text(
211
+ persona != null ? 'Change Persona' : 'Set Persona',
212
+ ),
213
contentPadding: EdgeInsets.zero,
214
),
215
),
230
- const PopupMenuItem(
231
- value: 'clear',
232
- child: ListTile(
233
- leading: Icon(Icons.delete_outline),
234
- title: Text('Clear Conversation'),
235
- contentPadding: EdgeInsets.zero,
216
+ if (persona != null)
217
+ const PopupMenuItem(
218
+ value: 'remove_persona',
219
+ child: ListTile(
220
+ leading: Icon(Icons.person_remove_outlined),
221
+ title: Text('Remove Persona'),
222
+ contentPadding: EdgeInsets.zero,
223
+ ),
224
+ ),
225
+ const PopupMenuItem(
226
+ value: 'clear',
227
+ child: ListTile(
228
+ leading: Icon(Icons.delete_outline),
229
+ title: Text('Clear Conversation'),
230
+ contentPadding: EdgeInsets.zero,
231
+ ),
232
),
237
- ),
238
- ],
239
- ),
240
- ],
233
+ ],
234
+ ),
235
+ ],
236
+ ),
237
),
238
if (connectionStatus == ConnectionStatus.disconnected ||
239
connectionStatus == ConnectionStatus.error)
240
_ConnectionBanner(status: connectionStatus),
241
+ if (persona != null)
242
+ _PersonaIndicator(
243
+ persona: persona,
244
+ onTap: () => _showPersonaPicker(context),
245
+ onRemove: () {
246
+ final activeConv = ref.read(conv.activeConversationProvider);
247
+ if (activeConv != null) {
248
+ ref
249
+ .read(conv.conversationsProvider.notifier)
250
+ .updatePersona(activeConv.id, null, null);
251
+ }
252
+ },
253
+ ),
254
Expanded(
255
child: Stack(
256
children: [
@@ -258,6 +267,10 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
267
Scaffold.of(context).openDrawer();
268
}
269
},
270
+ selectedModel: selectedModel,
271
+ onModelTap: () => _showModelPicker(context),
272
+ selectedPersona: ref.watch(selectedPersonaProvider),
273
+ onPersonaTap: () => _showPersonaPickerForPreselection(context),
274
)
275
: _MessageList(
276
scrollController: _scrollController,
@@ -520,6 +533,161 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
533
},
534
);
535
}
536
+
537
+ void _showPersonaPickerForPreselection(BuildContext context) {
538
+ final personas = ref.read(personasNotifierProvider);
539
+ final isDark = Theme.of(context).brightness == Brightness.dark;
540
+ final currentPersona = ref.watch(selectedPersonaProvider);
541
+
542
+ showModalBottomSheet(
543
+ context: context,
544
+ isScrollControlled: true,
545
+ builder: (ctx) {
546
+ return SafeArea(
547
+ child: Container(
548
+ padding: const EdgeInsets.symmetric(vertical: 16),
549
+ child: Column(
550
+ mainAxisSize: MainAxisSize.min,
551
+ children: [
552
+ Center(
553
+ child: Container(
554
+ width: 40,
555
+ height: 4,
556
+ decoration: BoxDecoration(
557
+ color: isDark ? Colors.grey[600] : Colors.grey[300],
558
+ borderRadius: BorderRadius.circular(2),
559
+ ),
560
+ ),
561
+ ),
562
+ const SizedBox(height: 16),
563
+ Text(
564
+ 'Select Persona',
565
+ style: TextStyle(
566
+ fontSize: 18,
567
+ fontWeight: FontWeight.bold,
568
+ color: isDark ? Colors.white : Colors.black,
569
+ ),
570
+ ),
571
+ const SizedBox(height: 8),
572
+ Flexible(
573
+ child: ListView.builder(
574
+ shrinkWrap: true,
575
+ itemCount: personas.length,
576
+ itemBuilder: (context, index) {
577
+ final p = personas[index];
578
+ final isSelected = p.id == currentPersona?.id;
579
+ final accent = isDark
580
+ ? const Color(0xFF3B82F6)
581
+ : const Color(0xFF2563EB);
582
+ return ListTile(
583
+ leading: Text(
584
+ p.emoji,
585
+ style: const TextStyle(fontSize: 22),
586
+ ),
587
+ title: Text(
588
+ p.name,
589
+ style: TextStyle(
590
+ fontWeight: isSelected
591
+ ? FontWeight.w600
592
+ : FontWeight.normal,
593
+ color: isDark ? Colors.white : Colors.black,
594
+ ),
595
+ ),
596
+ subtitle: p.description != null
597
+ ? Text(
598
+ p.description!,
599
+ style: TextStyle(
600
+ fontSize: 12,
601
+ color: isDark
602
+ ? const Color(0xFF888888)
603
+ : const Color(0xFF999999),
604
+ ),
605
+ maxLines: 1,
606
+ overflow: TextOverflow.ellipsis,
607
+ )
608
+ : null,
609
+ trailing: isSelected
610
+ ? Icon(Icons.check_circle, color: accent)
611
+ : null,
612
+ onTap: () {
613
+ ref.read(selectedPersonaProvider.notifier).select(p);
614
+ Navigator.pop(ctx);
615
+ },
616
+ );
617
+ },
618
+ ),
619
+ ),
620
+ ],
621
+ ),
622
+ ),
623
+ );
624
+ },
625
+ );
626
+ }
627
+
628
+ Widget _buildServerIcon(BuildContext context, Server server) {
629
+ final iconData =
630
+ server.iconName != null
631
+ ? getHugeIconByName(server.iconName)
632
+ : getDefaultServerIcon(server.type.name);
633
+
634
+ if (iconData == null) {
635
+ return const Icon(Icons.dns, size: 18);
636
+ }
637
+
638
+ return HugeIcon(
639
+ icon: iconData.icon,
640
+ size: 18,
641
+ color: Theme.of(context).colorScheme.primary,
642
+ );
643
+ }
644
+}
645
+
646
+class _ModelTopBar extends StatelessWidget {
647
+ const _ModelTopBar({required this.selectedModel, required this.onTap});
648
+
649
+ final dynamic selectedModel;
650
+ final VoidCallback onTap;
651
+
652
+ @override
653
+ Widget build(BuildContext context) {
654
+ final theme = Theme.of(context);
655
+ final isDark = theme.brightness == Brightness.dark;
656
+
657
+ return Container(
658
+ width: double.infinity,
659
+ color: theme.colorScheme.surface,
660
+ child: SafeArea(
661
+ bottom: false,
662
+ child: GestureDetector(
663
+ onTap: onTap,
664
+ behavior: HitTestBehavior.opaque,
665
+ child: Padding(
666
+ padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
667
+ child: Row(
668
+ mainAxisAlignment: MainAxisAlignment.center,
669
+ children: [
670
+ Text(
671
+ selectedModel?.displayName ?? 'Select Model',
672
+ style: TextStyle(
673
+ fontSize: 13,
674
+ fontWeight: FontWeight.w500,
675
+ color: isDark ? Colors.white70 : Colors.black87,
676
+ ),
677
+ ),
678
+ const SizedBox(width: 4),
679
+ Icon(
680
+ Icons.expand_more,
681
+ size: 14,
682
+ color: isDark ? Colors.white38 : Colors.black38,
683
+ ),
684
+ ],
685
+ ),
686
+ ),
687
+ ),
688
+ ),
689
+ );
690
+ }
691
}
692
693
class _ConnectionBanner extends StatelessWidget {
@@ -580,12 +748,20 @@ class _EmptyState extends StatefulWidget {
748
required this.quickPrompts,
749
required this.recentConversations,
750
required this.onSeeAll,
751
+ required this.selectedModel,
752
+ required this.onModelTap,
753
+ this.selectedPersona,
754
+ required this.onPersonaTap,
755
});
756
757
final void Function(String) onQuickPrompt;
758
final List<String> quickPrompts;
759
final List<Conversation> recentConversations;
760
final VoidCallback onSeeAll;
761
+ final dynamic selectedModel;
762
+ final VoidCallback onModelTap;
763
+ final dynamic selectedPersona;
764
+ final VoidCallback onPersonaTap;
765
766
@override
767
State<_EmptyState> createState() => _EmptyStateState();
@@ -668,37 +844,112 @@ class _EmptyStateState extends State<_EmptyState>
844
),
845
);
846
},
671
- child: Text(
672
- 'Select Model LocalMind',
673
- style: TextStyle(
674
- fontSize: 28,
675
- fontWeight: FontWeight.bold,
676
- color: isDark ? Colors.white : Colors.black,
677
- ),
678
- ),
679
- ),
680
- const SizedBox(height: 8),
681
- TweenAnimationBuilder<double>(
682
- tween: Tween(begin: 0.0, end: 1.0),
683
- duration: const Duration(milliseconds: 350),
684
- curve: Curves.easeOut,
685
- builder: (context, value, child) {
686
- return Opacity(
687
- opacity: value,
688
- child: Transform.translate(
689
- offset: Offset(0, 15 * (1 - value)),
690
- child: child,
847
+ child: Column(
848
+ children: [
849
+ GestureDetector(
850
+ onTap: widget.onModelTap,
851
+ child: Container(
852
+ padding: const EdgeInsets.symmetric(
853
+ horizontal: 16,
854
+ vertical: 12,
855
+ ),
856
+ decoration: BoxDecoration(
857
+ color: isDark ? const Color(0xFF1A1A1A) : Colors.white,
858
+ borderRadius: BorderRadius.circular(12),
859
+ border: Border.all(
860
+ color: isDark
861
+ ? const Color(0xFF333333)
862
+ : const Color(0xFFE5E5E5),
863
+ ),
864
+ ),
865
+ child: Row(
866
+ mainAxisSize: MainAxisSize.min,
867
+ children: [
868
+ Icon(
869
+ Icons.settings_suggest,
870
+ size: 18,
871
+ color: theme.colorScheme.primary,
872
+ ),
873
+ const SizedBox(width: 8),
874
+ Text(
875
+ widget.selectedModel?.displayName ?? 'Select Model',
876
+ style: TextStyle(
877
+ fontSize: 15,
878
+ fontWeight: FontWeight.w600,
879
+ color: isDark ? Colors.white : Colors.black,
880
+ ),
881
+ ),
882
+ const SizedBox(width: 4),
883
+ const Icon(
884
+ Icons.expand_more,
885
+ size: 16,
886
+ color: Colors.grey,
887
+ ),
888
+ ],
889
+ ),
890
+ ),
891
),
692
- );
693
- },
694
- child: Text(
695
- 'Your AI. Your Device. Your Rules.',
696
- style: TextStyle(
697
- fontSize: 14,
698
- color: isDark
699
- ? const Color(0xFF888888)
700
- : const Color(0xFF666666),
701
- ),
892
+ const SizedBox(height: 12),
893
+ GestureDetector(
894
+ onTap: widget.onPersonaTap,
895
+ child: Container(
896
+ padding: const EdgeInsets.symmetric(
897
+ horizontal: 16,
898
+ vertical: 12,
899
+ ),
900
+ decoration: BoxDecoration(
901
+ color: isDark ? const Color(0xFF1A1A1A) : Colors.white,
902
+ borderRadius: BorderRadius.circular(12),
903
+ border: Border.all(
904
+ color: isDark
905
+ ? const Color(0xFF333333)
906
+ : const Color(0xFFE5E5E5),
907
+ ),
908
+ ),
909
+ child: Row(
910
+ mainAxisSize: MainAxisSize.min,
911
+ children: [
912
+ if (widget.selectedPersona != null) ...[
913
+ Text(
914
+ widget.selectedPersona!.emoji,
915
+ style: const TextStyle(fontSize: 18),
916
+ ),
917
+ const SizedBox(width: 8),
918
+ Text(
919
+ widget.selectedPersona!.name,
920
+ style: TextStyle(
921
+ fontSize: 15,
922
+ fontWeight: FontWeight.w600,
923
+ color: isDark ? Colors.white : Colors.black,
924
+ ),
925
+ ),
926
+ ] else ...[
927
+ Icon(
928
+ Icons.smart_toy_outlined,
929
+ size: 18,
930
+ color: theme.colorScheme.primary,
931
+ ),
932
+ const SizedBox(width: 8),
933
+ Text(
934
+ 'Select Persona',
935
+ style: TextStyle(
936
+ fontSize: 15,
937
+ fontWeight: FontWeight.w600,
938
+ color: isDark ? Colors.white : Colors.black,
939
+ ),
940
+ ),
941
+ ],
942
+ const SizedBox(width: 4),
943
+ const Icon(
944
+ Icons.expand_more,
945
+ size: 16,
946
+ color: Colors.grey,
947
+ ),
948
+ ],
949
+ ),
950
+ ),
951
+ ),
952
+ ],
953
),
954
),
955
const SizedBox(height: 32),
@@ -1120,3 +1371,70 @@ class _SmartReplyChipsState extends ConsumerState<_SmartReplyChips>
1371
);
1372
}
1373
}
1374
+class _PersonaIndicator extends StatelessWidget {
1375
+ const _PersonaIndicator({
1376
+ required this.persona,
1377
+ required this.onTap,
1378
+ required this.onRemove,
1379
+ });
1380
+
1381
+ final dynamic persona;
1382
+ final VoidCallback onTap;
1383
+ final VoidCallback onRemove;
1384
+
1385
+ @override
1386
+ Widget build(BuildContext context) {
1387
+ final theme = Theme.of(context);
1388
+ final isDark = theme.brightness == Brightness.dark;
1389
+
1390
+ return Container(
1391
+ width: double.infinity,
1392
+ color: isDark ? const Color(0xFF121212) : const Color(0xFFF9F9F9),
1393
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
1394
+ child: Row(
1395
+ children: [
1396
+ GestureDetector(
1397
+ onTap: onTap,
1398
+ child: Container(
1399
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
1400
+ decoration: BoxDecoration(
1401
+ color: theme.colorScheme.primary.withValues(alpha: 0.1),
1402
+ borderRadius: BorderRadius.circular(16),
1403
+ border: Border.all(
1404
+ color: theme.colorScheme.primary.withValues(alpha: 0.2),
1405
+ ),
1406
+ ),
1407
+ child: Row(
1408
+ mainAxisSize: MainAxisSize.min,
1409
+ children: [
1410
+ Text(
1411
+ persona.emoji,
1412
+ style: const TextStyle(fontSize: 16),
1413
+ ),
1414
+ const SizedBox(width: 6),
1415
+ Text(
1416
+ persona.name,
1417
+ style: TextStyle(
1418
+ fontSize: 13,
1419
+ fontWeight: FontWeight.w600,
1420
+ color: theme.colorScheme.primary,
1421
+ ),
1422
+ ),
1423
+ ],
1424
+ ),
1425
+ ),
1426
+ ),
1427
+ const Spacer(),
1428
+ IconButton(
1429
+ icon: const Icon(Icons.close, size: 16),
1430
+ onPressed: onRemove,
1431
+ padding: EdgeInsets.zero,
1432
+ constraints: const BoxConstraints(),
1433
+ tooltip: 'Remove Persona',
1434
+ color: isDark ? Colors.white38 : Colors.black38,
1435
+ ),
1436
+ ],
1437
+ ),
1438
+ );
1439
+ }
1440
+}
lib/features/chat/views/components/chat_input_bar.dart
+39
-33
@@ -330,40 +330,46 @@ class _ChatInputBarState extends ConsumerState<ChatInputBar>
330
),
331
),
332
),
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),
333
+ ValueListenableBuilder<TextEditingValue>(
334
+ valueListenable: _controller,
335
+ builder: (context, value, child) {
336
+ final showCounter = _isComposing && value.text.length >= 300;
337
+ return AnimatedSize(
338
+ duration: const Duration(milliseconds: 150),
339
+ curve: Curves.easeOut,
340
+ child: AnimatedOpacity(
341
+ opacity: showCounter ? 1.0 : 0.0,
342
+ duration: const Duration(milliseconds: 150),
343
+ child: showCounter
344
+ ? Padding(
345
+ padding: const EdgeInsets.only(
346
+ right: 12,
347
+ bottom: 12,
348
),
362
- ),
363
- ),
364
- )
365
- : const SizedBox(width: 0),
366
- ),
349
+ child: TweenAnimationBuilder<double>(
350
+ tween: Tween(begin: 0.5, end: 1.0),
351
+ duration: const Duration(milliseconds: 150),
352
+ builder: (context, scale, child) {
353
+ return Transform.scale(
354
+ scale: scale,
355
+ child: child,
356
+ );
357
+ },
358
+ child: Text(
359
+ '${value.text.length}',
360
+ style: TextStyle(
361
+ fontSize: 11,
362
+ color: isDark
363
+ ? const Color(0xFF666666)
364
+ : const Color(0xFF999999),
365
+ ),
366
+ ),
367
+ ),
368
+ )
369
+ : const SizedBox(width: 0),
370
+ ),
371
+ );
372
+ },
373
),
374
],
375
),
lib/features/personas/providers/personas_providers.dart
+18
@@ -8,6 +8,24 @@ final personaSearchQueryProvider =
8
_PersonaSearchNotifier.new,
9
);
10
11
+final selectedPersonaProvider =
12
+ NotifierProvider<SelectedPersonaNotifier, Persona?>(
13
+ SelectedPersonaNotifier.new,
14
+ );
15
+
16
+class SelectedPersonaNotifier extends Notifier<Persona?> {
17
+ @override
18
+ Persona? build() => null;
19
+
20
+ void select(Persona? persona) {
21
+ state = persona;
22
+ }
23
+
24
+ void clear() {
25
+ state = null;
26
+ }
27
+}
28
+
29
class _PersonaSearchNotifier extends Notifier<String> {
30
@override
31
String build() => '';