@tduncan / Cortana / commits / 27eb29b

feat: add saved message picker to chat input bar and update icon

abdulmominsakib committed Jul 6, 2026 at 10:50 UTC 27eb29b72e6e8b6249087789248ee756afdd6b8f
3 files changed +280 -548
lib/features/chat/views/chat_screen.dart
+26 -1
@@ -920,9 +920,34 @@ class _ChatBottomBar extends ConsumerWidget {
920 final keyboardIncognito =
921 isTemporary &&
922 ref.watch(settingsProvider.select((s) => s.tempChatKeyboardIncognito));
923 - final totalTokenCount = ref.watch(
923 + final messages = ref.watch(chatProvider.select((s) => s.messages));
924 + final hasActiveConv = ref.watch(conv.activeConversationProvider) != null;
925 + final dbTokenCount = ref.watch(
926 conv.activeConversationProvider.select((c) => c?.totalTokenCount),
927 );
928 + final streamingId = ref.watch(chatProvider.select((s) => s.streamingMessage?.id));
929 +
930 + int totalTokenCount = 0;
931 + if (hasActiveConv && dbTokenCount != null && dbTokenCount > 0) {
932 + totalTokenCount = dbTokenCount;
933 + } else {
934 + final lastWithStats = messages.reversed
935 + .where((m) =>
936 + m.id != streamingId &&
937 + m.role == MessageRole.assistant &&
938 + (m.inputTokenCount != null || m.tokenCount != null))
939 + .firstOrNull;
940 + if (lastWithStats != null) {
941 + totalTokenCount = (lastWithStats.inputTokenCount ?? 0) + (lastWithStats.tokenCount ?? 0);
942 + } else {
943 + totalTokenCount = messages
944 + .where((m) => m.id != streamingId)
945 + .fold<int>(
946 + 0,
947 + (sum, m) => sum + (m.content.length / 4).round(),
948 + );
949 + }
950 + }
951
952 return Positioned(
953 bottom: 0,
lib/features/chat/views/components/chat_input_bar.dart
+254 -525
@@ -1,45 +1,27 @@
1 import 'dart:io';
2
3 -
4 -
3 import 'package:file_picker/file_picker.dart';
6 -
4 import 'package:flutter/material.dart';
8 -
5 import 'package:flutter_riverpod/flutter_riverpod.dart';
10 -
6 import 'package:haptic_feedback/haptic_feedback.dart';
12 -
7 import 'package:hugeicons/hugeicons.dart';
14 -
8 import 'package:image_picker/image_picker.dart';
16 -
17 -import 'package:localmind/l10n/app_localizations.dart';
18 -
19 -import 'package:localmind/core/theme/colors.dart';
20 -
9 import 'package:permission_handler/permission_handler.dart';
10
23 -
24 -
11 import '../../../../core/models/enums.dart';
26 -
12 import '../../../../core/providers/app_providers.dart';
13 +import '../../../../core/theme/colors.dart';
14 +import '../../../../l10n/app_localizations.dart';
15 +import '../../../saved_messages/views/components/saved_message_picker_sheet.dart';
16 import '../../../servers/providers/server_providers.dart';
29 -
17 import '../../../stt/providers/stt_providers.dart';
18 import '../../providers/chat_providers.dart';
19 import '../../utils/attachment_helpers.dart';
20 import '../../utils/image_upload_utils.dart';
21 import 'image_preview_dialog.dart';
35 -import '../../../saved_messages/views/components/saved_message_picker_sheet.dart';
36 -
37 -
22
23 class ChatInputBar extends ConsumerStatefulWidget {
40 -
24 const ChatInputBar({
42 -
25 super.key,
26
27 required this.onSend,
@@ -55,11 +37,8 @@ class ChatInputBar extends ConsumerStatefulWidget {
37 this.focusNode,
38
39 this.keyboardIncognito = false,
58 -
40 });
41
61 -
62 -
42 final void Function(String message, {List<File>? attachments}) onSend;
43
44 final VoidCallback onStop;
@@ -74,20 +53,12 @@ class ChatInputBar extends ConsumerStatefulWidget {
53
54 final bool keyboardIncognito;
55
77 -
78 -
56 @override
80 -
57 ConsumerState<ChatInputBar> createState() => ChatInputBarState();
82 -
58 }
59
85 -
86 -
60 class ChatInputBarState extends ConsumerState<ChatInputBar>
88 -
61 with TickerProviderStateMixin {
90 -
62 final _normalController = TextEditingController();
63 final _incognitoController = TextEditingController();
64 late final FocusNode _focusNode;
@@ -118,8 +89,6 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
89
90 String _preSpeechText = '';
91
121 -
122 -
92 @override
93 void initState() {
94 super.initState();
@@ -128,42 +97,32 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
97 _incognitoFocus = FocusNode();
98
99 _sendButtonAnimController = AnimationController(
131 -
100 duration: const Duration(milliseconds: 200),
101
102 vsync: this,
135 -
103 );
104
105 _sendButtonScale = Tween<double>(begin: 1.0, end: 1.1).animate(
139 -
106 CurvedAnimation(parent: _sendButtonAnimController, curve: Curves.easeOut),
141 -
107 );
108
109 _micAnimController = AnimationController(
145 -
110 duration: const Duration(milliseconds: 800),
111
112 vsync: this,
149 -
113 );
114
152 - _holdProgressController = AnimationController(
153 - duration: _holdDuration,
154 - vsync: this,
155 - )..addStatusListener((status) {
156 - if (status == AnimationStatus.completed) {
157 - _holdTriggered = true;
158 - _holdProgressController.value = 0;
159 - _handleGenerateAiUser();
160 - }
161 - });
162 -
115 + _holdProgressController =
116 + AnimationController(duration: _holdDuration, vsync: this)
117 + ..addStatusListener((status) {
118 + if (status == AnimationStatus.completed) {
119 + _holdTriggered = true;
120 + _holdProgressController.value = 0;
121 + _handleGenerateAiUser();
122 + }
123 + });
124 }
125
165 -
166 -
126 @override
127 void didUpdateWidget(ChatInputBar oldWidget) {
128 super.didUpdateWidget(oldWidget);
@@ -173,24 +132,26 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
132 final selection = _normalController.selection;
133
134 if (widget.keyboardIncognito) {
176 - _incognitoController.value = TextEditingValue(text: text, selection: selection);
135 + _incognitoController.value = TextEditingValue(
136 + text: text,
137 + selection: selection,
138 + );
139 if (_focusNode.hasFocus) {
140 _incognitoFocus.requestFocus();
141 }
142 } else {
181 - _normalController.value = TextEditingValue(text: text, selection: selection);
143 + _normalController.value = TextEditingValue(
144 + text: text,
145 + selection: selection,
146 + );
147 if (_incognitoFocus.hasFocus) {
148 _focusNode.requestFocus();
149 }
150 }
151 }
152
188 -
189 -
153 @override
191 -
154 void dispose() {
193 -
155 _normalController.dispose();
156 _incognitoController.dispose();
157
@@ -206,13 +167,9 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
167 _holdProgressController.dispose();
168
169 super.dispose();
209 -
170 }
171
212 -
213 -
172 void insertText(String text) {
215 -
173 final insert = text.trim();
174
175 if (insert.isEmpty) return;
@@ -220,13 +177,9 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
177 final current = _controller.text;
178
179 if (current.isEmpty) {
223 -
180 _controller.text = insert;
225 -
181 } else {
227 -
182 _controller.text = '$current\n\n$insert';
229 -
183 }
184
185 setState(() {});
@@ -234,71 +187,45 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
187 _activeFocus.requestFocus();
188
189 _controller.selection = TextSelection.fromPosition(
237 -
190 TextPosition(offset: _controller.text.length),
239 -
191 );
241 -
192 }
193
244 -
245 -
194 Future<bool> _ensurePhotoPermission() async {
247 -
195 if (!Platform.isAndroid && !Platform.isIOS) return true;
196
250 -
251 -
197 var status = await Permission.photos.status;
198
199 if (status.isGranted || status.isLimited) return true;
200
256 -
257 -
201 status = await Permission.photos.request();
202
203 if (status.isGranted || status.isLimited) return true;
204
262 -
263 -
205 if (Platform.isAndroid) {
265 -
206 final storage = await Permission.storage.request();
207
208 return storage.isGranted;
269 -
209 }
210
211 return false;
273 -
212 }
213
276 -
277 -
214 Future<void> _pickImages() async {
279 -
215 final granted = await _ensurePhotoPermission();
216
217 if (!granted) {
283 -
218 if (!mounted) return;
219
220 ScaffoldMessenger.of(context).showSnackBar(
287 -
221 SnackBar(
289 -
222 content: Text(AppLocalizations.of(context)!.photo_permission_denied),
291 -
223 ),
293 -
224 );
225
226 return;
297 -
227 }
228
300 -
301 -
229 final picker = ImagePicker();
230
231 final images = await picker.pickMultiImage(imageQuality: 85);
@@ -322,71 +249,45 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
249 });
250
251 widget.onAttach?.call(_attachedFiles);
325 -
252 }
253
328 -
329 -
254 Future<void> _pickTextDocument() async {
331 -
255 final result = await FilePicker.pickFiles(
333 -
256 type: FileType.custom,
257
258 allowedExtensions: const ['txt', 'md'],
337 -
259 );
260
261 if (result == null || result.files.isEmpty) return;
262
342 -
343 -
263 setState(() {
345 -
264 _attachedFiles.addAll(
347 -
265 result.files.where((f) => f.path != null).map((f) => File(f.path!)),
349 -
266 );
351 -
267 });
268
269 widget.onAttach?.call(_attachedFiles);
355 -
270 }
271
358 -
359 -
272 Future<void> _handleInsertSavedMessage() async {
361 -
273 final content = await showSavedMessagePickerSheet(context);
274
275 if (content != null && content.isNotEmpty) {
365 -
276 insertText(content);
367 -
277 }
369 -
278 }
279
372 -
373 -
280 void _showAttachMenu() {
375 -
281 final l10n = AppLocalizations.of(context)!;
282
283 showModalBottomSheet(
379 -
284 context: context,
285
286 builder: (ctx) => SafeArea(
383 -
287 child: Column(
385 -
288 mainAxisSize: MainAxisSize.min,
289
290 children: [
389 -
291 ListTile(
292 leading: const Icon(Icons.description_outlined),
293 title: Text(l10n.attach_text_document),
@@ -405,201 +306,130 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
306 ),
307
308 ListTile(
408 -
309 leading: const Icon(Icons.bookmark_outline),
310
311 title: Text(l10n.insert_saved_message),
312
313 onTap: () {
414 -
314 Navigator.pop(ctx);
315
316 _handleInsertSavedMessage();
418 -
317 },
420 -
318 ),
319
320 const SizedBox(height: 8),
424 -
321 ],
426 -
322 ),
428 -
323 ),
430 -
324 );
432 -
325 }
326
435 -
436 -
327 Future<void> _handleMicToggle() async {
438 -
328 final stt = ref.read(sttProvider.notifier);
329
330 final isListening = ref.read(sttProvider).isListening;
331
443 -
444 -
332 Haptics.vibrate(HapticsType.light);
333
447 -
448 -
334 if (isListening) {
450 -
335 await stt.stopListening();
452 -
336 } else {
454 -
337 _preSpeechText = _controller.text;
338
339 await stt.startListening(
458 -
340 onResult: (words) {
460 -
341 if (words.isNotEmpty && mounted) {
462 -
342 setState(() {
464 -
343 if (_preSpeechText.isEmpty) {
466 -
344 _controller.text = words;
468 -
345 } else {
470 -
346 _controller.text = '$_preSpeechText $words';
472 -
347 }
348
349 _controller.selection = TextSelection.fromPosition(
476 -
350 TextPosition(offset: _controller.text.length),
478 -
351 );
480 -
352 });
482 -
353 }
484 -
354 },
486 -
355 );
488 -
356 }
490 -
357 }
358
493 -
494 -
359 Widget _buildMicButton(bool isListening, ThemeData theme) {
496 -
360 final l10n = AppLocalizations.of(context)!;
361
362 final isDark = theme.brightness == Brightness.dark;
363
364 return AnimatedBuilder(
502 -
365 animation: _micAnimController,
366
367 builder: (context, child) {
506 -
368 final pulseValue = isListening ? _micAnimController.value : 0.0;
369
370 return Container(
510 -
371 decoration: BoxDecoration(
512 -
372 shape: BoxShape.circle,
373
374 boxShadow: [
516 -
375 if (isListening)
518 -
376 BoxShadow(
520 -
377 color: Colors.red.withValues(alpha: 0.4 * pulseValue),
378
379 blurRadius: 8 + (8 * pulseValue),
380
381 spreadRadius: 1 + (3 * pulseValue),
526 -
382 ),
528 -
383 ],
530 -
384 ),
385
386 child: Container(
534 -
387 width: 36,
388
389 height: 36,
390
391 decoration: BoxDecoration(
540 -
392 color: isListening
542 -
393 ? Colors.red
544 -
394 : theme.colorScheme.onSurface.withValues(alpha: 0.05),
395
396 shape: BoxShape.circle,
548 -
397 ),
398
399 child: IconButton(
552 -
400 padding: EdgeInsets.zero,
401
555 - icon: Icon(
556 -
557 - Icons.mic_none_rounded,
558 -
402 + icon: HugeIcon(
403 + icon: HugeIcons.strokeRoundedMic01,
404 size: 20,
560 -
405 color: isListening
562 -
406 ? (isDark ? Colors.white : Colors.black)
564 -
407 : theme.colorScheme.onSurface.withValues(alpha: 0.7),
566 -
408 ),
409
410 onPressed: _handleMicToggle,
411
412 tooltip: isListening
572 -
413 ? l10n.stop_listening_tooltip
574 -
414 : l10n.start_listening_tooltip,
576 -
415 ),
578 -
416 ),
580 -
417 );
582 -
418 },
584 -
419 );
586 -
420 }
421
589 -
590 -
422 void _handleSubmit() {
592 -
423 final text = _controller.text.trim();
424
425 if (text.isEmpty && _attachedFiles.isEmpty) return;
426
597 -
598 -
427 Haptics.vibrate(HapticsType.medium);
428
429 if (_sendAsAssistant) {
602 - ref.read(chatProvider.notifier).insertMessageWithoutGenerating(
430 + ref
431 + .read(chatProvider.notifier)
432 + .insertMessageWithoutGenerating(
433 text,
434 role: MessageRole.assistant,
435 attachments: List.from(_attachedFiles),
@@ -611,12 +441,9 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
441 _controller.clear();
442
443 setState(() {
614 -
444 _attachedFiles.clear();
445 _sendAsAssistant = false;
617 -
446 });
619 -
447 }
448
449 void _handleInsertWithoutGenerating() {
@@ -624,7 +451,9 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
451 if (text.isEmpty && _attachedFiles.isEmpty) return;
452
453 Haptics.vibrate(HapticsType.medium);
627 - ref.read(chatProvider.notifier).insertMessageWithoutGenerating(
454 + ref
455 + .read(chatProvider.notifier)
456 + .insertMessageWithoutGenerating(
457 text,
458 role: _sendAsAssistant ? MessageRole.assistant : MessageRole.user,
459 attachments: List.from(_attachedFiles),
@@ -637,18 +466,12 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
466 });
467 }
468
640 -
641 -
469 void _handleStop() {
643 -
470 Haptics.vibrate(HapticsType.light);
471
472 widget.onStop();
647 -
473 }
474
650 -
651 -
475 Future<void> _handleGenerateAiUser() async {
476 if (_isGeneratingAiUser || widget.isStreaming || !widget.enabled) return;
477 setState(() => _isGeneratingAiUser = true);
@@ -788,27 +611,22 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
611 static const _insertHoldThreshold = 500 / 3000;
612
613 Widget _buildActionButton(bool canSend, ThemeData theme) {
791 -
614 final l10n = AppLocalizations.of(context)!;
615
616 final backgroundColor = widget.isStreaming
795 -
617 ? Colors.red
797 -
618 : theme.colorScheme.primary;
619
620 final iconColor = widget.isStreaming
801 -
621 ? Colors.white
803 -
622 : theme.colorScheme.onPrimary;
623
624 final connectionStatus = ref.watch(connectionStatusProvider);
625 final isConnected = connectionStatus == ConnectionStatus.connected;
626 final aiUserHoldEnabled =
627 ref.watch(settingsProvider.select((s) => s.aiUserResponseEnabled)) &&
810 - isConnected &&
811 - widget.enabled;
628 + isConnected &&
629 + widget.enabled;
630
631 void handleTapDown() {
632 _holdTriggered = false;
@@ -856,7 +674,6 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
674 // silently swallowing onTapUp and breaking the hold-timing logic below.
675 // Listener always fires regardless of who else claims the gesture.
676 return Listener(
859 -
677 onPointerDown: (_) => handleTapDown(),
678
679 onPointerUp: (_) => handleTapUp(),
@@ -920,18 +737,18 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
737 ),
738 )
739 : widget.isStreaming
923 - ? HugeIcon(
924 - icon: HugeIcons.strokeRoundedStop,
925 - key: const ValueKey('stop'),
926 - color: iconColor,
927 - size: 18,
928 - )
929 - : HugeIcon(
930 - icon: HugeIcons.strokeRoundedArrowUp01,
931 - key: ValueKey(canSend),
932 - color: iconColor,
933 - size: 18,
934 - ),
740 + ? HugeIcon(
741 + icon: HugeIcons.strokeRoundedStop,
742 + key: const ValueKey('stop'),
743 + color: iconColor,
744 + size: 18,
745 + )
746 + : HugeIcon(
747 + icon: HugeIcons.strokeRoundedArrowUp01,
748 + key: ValueKey(canSend),
749 + color: iconColor,
750 + size: 18,
751 + ),
752 ),
753 ),
754 ),
@@ -939,67 +756,45 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
756 ],
757 ),
758 ),
942 -
759 );
944 -
760 }
761
947 -
948 -
762 String? _mapSttError(String error) {
950 -
763 switch (error) {
952 -
764 case 'error_no_match':
954 -
765 return 'No speech recognized. Please try speaking again and check your microphone.';
766
767 case 'error_speech_timeout':
958 -
768 return 'No speech detected. Timed out.';
769
770 case 'error_permission':
962 -
771 return 'Microphone permission denied.';
772
773 case 'error_busy':
966 -
774 return 'Speech recognition is busy. Please try again.';
775
776 case 'error_network':
970 -
777 case 'error_network_timeout':
972 -
778 return 'Network error. Please check your connection and try again.';
779
780 case 'error_audio':
976 -
781 return 'Audio recording error. Please check your microphone.';
782
783 default:
980 -
784 if (error.startsWith('error_')) {
982 -
983 - final cleanName =
984 -
985 - error.replaceFirst('error_', '').replaceAll('_', ' ');
785 + final cleanName = error
786 + .replaceFirst('error_', '')
787 + .replaceAll('_', ' ');
788
789 return 'Speech recognition error: $cleanName';
988 -
790 }
791
792 return error;
992 -
793 }
994 -
794 }
795
997 -
998 -
796 @override
1000 -
797 Widget build(BuildContext context) {
1002 -
798 final l10n = AppLocalizations.of(context)!;
799
800 final theme = Theme.of(context);
@@ -1009,392 +804,326 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
804 final connectionStatus = ref.watch(connectionStatusProvider);
805
806 final isConnected = connectionStatus == ConnectionStatus.connected;
1012 - final showRoleSwapButton =
1013 - ref.watch(settingsProvider).roleSwapButtonEnabled;
1014 -
807 + final showRoleSwapButton = ref
808 + .watch(settingsProvider)
809 + .roleSwapButtonEnabled;
810
811 + final selectedModel = ref.watch(selectedModelProvider);
812
813 final sttState = ref.watch(sttProvider);
814
815 final isListening = sttState.isListening;
816
1021 -
1022 -
817 if (isListening) {
1024 -
818 if (!_micAnimController.isAnimating) {
1026 -
819 _micAnimController.repeat(reverse: true);
1028 -
820 }
1030 -
821 } else {
1032 -
822 if (_micAnimController.isAnimating) {
1034 -
823 _micAnimController.stop();
824
825 _micAnimController.reset();
1038 -
826 }
1040 -
827 }
828
829 + ref.listen<String?>(sttProvider.select((s) => s.error), (previous, next) {
830 + if (next != null) {
831 + final message = _mapSttError(next);
832
1044 -
1045 - ref.listen<String?>(
1046 -
1047 - sttProvider.select((s) => s.error),
1048 -
1049 - (previous, next) {
1050 -
1051 - if (next != null) {
1052 -
1053 - final message = _mapSttError(next);
1054 -
1055 - if (message != null) {
1056 -
1057 - ScaffoldMessenger.of(context).showSnackBar(
1058 -
1059 - SnackBar(content: Text(message)),
1060 -
1061 - );
1062 -
1063 - }
1064 -
833 + if (message != null) {
834 + ScaffoldMessenger.of(
835 + context,
836 + ).showSnackBar(SnackBar(content: Text(message)));
837 }
838 + }
839 + });
840
1067 - },
1068 -
1069 - );
1070 -
1071 -
1072 -
1073 - final canSend = widget.enabled &&
1074 -
841 + final canSend =
842 + widget.enabled &&
843 isConnected &&
1076 -
844 (_controller.text.trim().isNotEmpty || _attachedFiles.isNotEmpty) &&
1078 -
845 !widget.isStreaming;
846
1081 -
1082 -
847 return SafeArea(
1084 -
848 top: false,
849
850 child: Container(
851 + margin: const EdgeInsets.only(left: 16, right: 16, top: 8, bottom: 8),
852
1089 - margin: const EdgeInsets.only(left: 16, right: 16, top: 8, bottom: 6),
1090 -
1091 - padding: const EdgeInsets.fromLTRB(12, 10, 12, 8),
853 + padding: const EdgeInsets.fromLTRB(6, 4, 6, 4),
854
855 decoration: BoxDecoration(
1094 -
856 color: isDark ? AppColors.darkSurfaceInput : AppColors.lightSurface,
857
1097 - borderRadius: BorderRadius.circular(16),
858 + borderRadius: BorderRadius.circular(28),
859
860 border: Border.all(
1100 -
861 color: isDark ? AppColors.darkBorder : AppColors.lightBorder,
1102 -
862 + width: 1,
863 ),
1104 -
864 ),
865
866 child: Column(
1108 -
867 mainAxisSize: MainAxisSize.min,
868
869 crossAxisAlignment: CrossAxisAlignment.stretch,
870
871 children: [
872 + if (_attachedFiles.isNotEmpty) ...[
873 + Padding(
874 + padding: const EdgeInsets.only(left: 8, right: 8, top: 4),
875 + child: SizedBox(
876 + height: 68,
877
1115 - if (_attachedFiles.isNotEmpty)
1116 -
1117 - SizedBox(
1118 -
1119 - height: 68,
1120 -
1121 - child: ListView.separated(
1122 -
1123 - scrollDirection: Axis.horizontal,
1124 -
1125 - itemCount: _attachedFiles.length,
1126 -
1127 - separatorBuilder: (_, _) => const SizedBox(width: 8),
1128 -
1129 - itemBuilder: (context, index) {
1130 -
1131 - final file = _attachedFiles[index];
1132 - final isImage = AttachmentHelpers.isImagePath(file.path);
1133 -
1134 - return Stack(
1135 -
1136 - clipBehavior: Clip.none,
1137 -
1138 - children: [
878 + child: ListView.separated(
879 + scrollDirection: Axis.horizontal,
880
1140 - Container(
881 + itemCount: _attachedFiles.length,
882
1142 - decoration: BoxDecoration(
883 + separatorBuilder: (_, _) => const SizedBox(width: 8),
884
1144 - borderRadius: BorderRadius.circular(10),
885 + itemBuilder: (context, index) {
886 + final file = _attachedFiles[index];
887 + final isImage = AttachmentHelpers.isImagePath(file.path);
888
1146 - border: Border.all(
889 + return Stack(
890 + clipBehavior: Clip.none,
891
1148 - color: isDark
1149 -
1150 - ? AppColors.darkBorder
1151 -
1152 - : AppColors.lightBorder,
892 + children: [
893 + Container(
894 + decoration: BoxDecoration(
895 + borderRadius: BorderRadius.circular(10),
896
897 + border: Border.all(
898 + color: isDark
899 + ? AppColors.darkBorder
900 + : AppColors.lightBorder,
901 + ),
902 ),
903
1156 - ),
1157 -
1158 - child: ClipRRect(
1159 -
1160 - borderRadius: BorderRadius.circular(9),
1161 -
1162 - child: isImage
1163 - ? GestureDetector(
1164 - onTap: () =>
1165 - showImagePreview(context, file.path),
1166 - child: Image.file(
1167 - file,
904 + child: ClipRRect(
905 + borderRadius: BorderRadius.circular(9),
906 +
907 + child: isImage
908 + ? GestureDetector(
909 + onTap: () =>
910 + showImagePreview(context, file.path),
911 + child: Image.file(
912 + file,
913 + width: 48,
914 + height: 48,
915 + fit: BoxFit.cover,
916 + ),
917 + )
918 + : Container(
919 width: 48,
1169 - height: 48,
1170 - fit: BoxFit.cover,
1171 - ),
1172 - )
1173 -
1174 - : Container(
1175 -
1176 - width: 48,
1177 -
1178 - height: 48,
920
1180 - color: theme
1181 -
1182 - .colorScheme.surfaceContainerHighest,
1183 -
1184 - child: Icon(
921 + height: 48,
922
1186 - Icons.description_outlined,
923 + color: theme
924 + .colorScheme
925 + .surfaceContainerHighest,
926
1188 - size: 22,
927 + child: Icon(
928 + Icons.description_outlined,
929
1190 - color: theme.colorScheme.primary,
930 + size: 22,
931
932 + color: theme.colorScheme.primary,
933 + ),
934 ),
1193 -
1194 - ),
1195 -
935 + ),
936 ),
937
1198 - ),
1199 -
1200 - PositionedDirectional(
1201 -
1202 - top: -4,
1203 -
1204 - end: -4,
1205 -
1206 - child: GestureDetector(
1207 -
1208 - onTap: () =>
938 + PositionedDirectional(
939 + top: -4,
940
1210 - setState(() => _attachedFiles.removeAt(index)),
941 + end: -4,
942
1212 - child: Container(
1213 -
1214 - width: 18,
1215 -
1216 - height: 18,
1217 -
1218 - decoration: const BoxDecoration(
1219 -
1220 - color: Colors.black,
943 + child: GestureDetector(
944 + onTap: () => setState(
945 + () => _attachedFiles.removeAt(index),
946 + ),
947
1222 - shape: BoxShape.circle,
948 + child: Container(
949 + width: 18,
950
1224 - ),
951 + height: 18,
952
1226 - child: const Icon(
953 + decoration: const BoxDecoration(
954 + color: Colors.black,
955
1228 - Icons.close,
956 + shape: BoxShape.circle,
957 + ),
958
1230 - size: 10,
959 + child: const Icon(
960 + Icons.close,
961
1232 - color: Colors.white,
962 + size: 10,
963
964 + color: Colors.white,
965 + ),
966 ),
1235 -
967 ),
1237 -
968 ),
1239 -
1240 - ),
1241 -
1242 - ],
1243 -
1244 - );
1245 -
1246 - },
1247 -
969 + ],
970 + );
971 + },
972 + ),
973 ),
1249 -
974 ),
975 + const SizedBox(height: 8),
976 + ],
977
1252 - Stack(
1253 - children: [
1254 - IgnorePointer(
1255 - ignoring: widget.keyboardIncognito,
1256 - child: Opacity(
1257 - opacity: widget.keyboardIncognito ? 0 : 1,
1258 - child: TextField(
1259 - controller: _normalController,
1260 - focusNode: _focusNode,
1261 - enabled: widget.enabled,
1262 - maxLines: 6,
1263 - minLines: 1,
1264 - textInputAction: TextInputAction.newline,
1265 - keyboardType: TextInputType.multiline,
1266 - enableSuggestions: true,
1267 - autocorrect: true,
1268 - enableIMEPersonalizedLearning: true,
1269 - onChanged: (_) => setState(() {}),
1270 - style: TextStyle(
1271 - fontSize: 15,
1272 - color: theme.colorScheme.onSurface,
1273 - ),
1274 - decoration: InputDecoration(
1275 - filled: false,
1276 - border: InputBorder.none,
1277 - enabledBorder: InputBorder.none,
1278 - focusedBorder: InputBorder.none,
1279 - disabledBorder: InputBorder.none,
1280 - hintText: l10n.chat_input_hint,
1281 - hintStyle: TextStyle(
1282 - color: theme.colorScheme.onSurface
1283 - .withValues(alpha: 0.38),
1284 - fontSize: 15,
1285 - ),
1286 - isDense: true,
1287 - contentPadding:
1288 - const EdgeInsets.symmetric(vertical: 4),
1289 - ),
1290 - ),
1291 - ),
1292 - ),
1293 - IgnorePointer(
1294 - ignoring: !widget.keyboardIncognito,
1295 - child: Opacity(
1296 - opacity: widget.keyboardIncognito ? 1 : 0,
1297 - child: TextField(
1298 - controller: _incognitoController,
1299 - focusNode: _incognitoFocus,
1300 - enabled: widget.enabled,
1301 - maxLines: 6,
1302 - minLines: 1,
1303 - textInputAction: TextInputAction.newline,
1304 - keyboardType: TextInputType.multiline,
1305 - enableSuggestions: false,
1306 - autocorrect: false,
1307 - enableIMEPersonalizedLearning: false,
1308 - spellCheckConfiguration:
1309 - const SpellCheckConfiguration.disabled(),
1310 - smartDashesType: SmartDashesType.disabled,
1311 - smartQuotesType: SmartQuotesType.disabled,
1312 - onChanged: (_) => setState(() {}),
1313 - style: TextStyle(
1314 - fontSize: 15,
1315 - color: theme.colorScheme.onSurface,
1316 - ),
1317 - decoration: InputDecoration(
1318 - filled: false,
1319 - border: InputBorder.none,
1320 - enabledBorder: InputBorder.none,
1321 - focusedBorder: InputBorder.none,
1322 - disabledBorder: InputBorder.none,
1323 - hintText: l10n.chat_input_hint,
1324 - hintStyle: TextStyle(
1325 - color: theme.colorScheme.onSurface
1326 - .withValues(alpha: 0.38),
1327 - fontSize: 15,
1328 - ),
1329 - isDense: true,
1330 - contentPadding:
1331 - const EdgeInsets.symmetric(vertical: 4),
1332 - ),
1333 - ),
1334 - ),
978 + if (selectedModel?.supportsReasoning == true ||
979 + showRoleSwapButton) ...[
980 + Padding(
981 + padding: const EdgeInsets.only(left: 8, right: 8, bottom: 4),
982 + child: Row(
983 + children: [
984 + if (selectedModel?.supportsReasoning == true)
985 + _buildThinkButton(theme),
986 + const Spacer(),
987 + if (showRoleSwapButton) _buildRoleSwapButton(theme),
988 + ],
989 ),
1336 - ],
1337 - ),
1338 -
1339 - const SizedBox(height: 6),
990 + ),
991 + const SizedBox(height: 4),
992 + ],
993
994 Row(
1342 -
995 + crossAxisAlignment: CrossAxisAlignment.center,
996 children: [
1344 -
997 IconButton(
1346 -
998 visualDensity: VisualDensity.compact,
999
1000 padding: const EdgeInsets.all(8),
1001
1351 - constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
1002 + constraints: const BoxConstraints(
1003 + minWidth: 36,
1004 + minHeight: 36,
1005 + ),
1006
1007 icon: Icon(
1354 -
1008 Icons.add,
1009
1357 - size: 22,
1358 -
1359 - color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
1010 + size: 24,
1011
1012 + color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
1013 ),
1014
1015 onPressed: isConnected ? _showAttachMenu : null,
1016
1017 tooltip: l10n.add_attachment,
1366 -
1018 ),
1019
1369 - _buildThinkButton(theme),
1370 -
1371 - const Spacer(),
1020 + const SizedBox(width: 4),
1021 +
1022 + Expanded(
1023 + child: Stack(
1024 + children: [
1025 + IgnorePointer(
1026 + ignoring: widget.keyboardIncognito,
1027 + child: Opacity(
1028 + opacity: widget.keyboardIncognito ? 0 : 1,
1029 + child: TextField(
1030 + controller: _normalController,
1031 + focusNode: _focusNode,
1032 + enabled: widget.enabled,
1033 + maxLines: 6,
1034 + minLines: 1,
1035 + textInputAction: TextInputAction.newline,
1036 + keyboardType: TextInputType.multiline,
1037 + enableSuggestions: true,
1038 + autocorrect: true,
1039 + enableIMEPersonalizedLearning: true,
1040 + onChanged: (_) => setState(() {}),
1041 + style: TextStyle(
1042 + fontSize: 16,
1043 + color: theme.colorScheme.onSurface,
1044 + ),
1045 + decoration: InputDecoration(
1046 + filled: false,
1047 + border: InputBorder.none,
1048 + enabledBorder: InputBorder.none,
1049 + focusedBorder: InputBorder.none,
1050 + disabledBorder: InputBorder.none,
1051 + hintText: l10n.chat_input_hint,
1052 + hintStyle: TextStyle(
1053 + color: theme.colorScheme.onSurface.withValues(
1054 + alpha: 0.38,
1055 + ),
1056 + fontSize: 16,
1057 + ),
1058 + isDense: true,
1059 + contentPadding: const EdgeInsets.symmetric(
1060 + vertical: 8,
1061 + ),
1062 + ),
1063 + ),
1064 + ),
1065 + ),
1066 + IgnorePointer(
1067 + ignoring: !widget.keyboardIncognito,
1068 + child: Opacity(
1069 + opacity: widget.keyboardIncognito ? 1 : 0,
1070 + child: TextField(
1071 + controller: _incognitoController,
1072 + focusNode: _incognitoFocus,
1073 + enabled: widget.enabled,
1074 + maxLines: 6,
1075 + minLines: 1,
1076 + textInputAction: TextInputAction.newline,
1077 + keyboardType: TextInputType.multiline,
1078 + enableSuggestions: false,
1079 + autocorrect: false,
1080 + enableIMEPersonalizedLearning: false,
1081 + spellCheckConfiguration:
1082 + const SpellCheckConfiguration.disabled(),
1083 + smartDashesType: SmartDashesType.disabled,
1084 + smartQuotesType: SmartQuotesType.disabled,
1085 + onChanged: (_) => setState(() {}),
1086 + style: TextStyle(
1087 + fontSize: 16,
1088 + color: theme.colorScheme.onSurface,
1089 + ),
1090 + decoration: InputDecoration(
1091 + filled: false,
1092 + border: InputBorder.none,
1093 + enabledBorder: InputBorder.none,
1094 + focusedBorder: InputBorder.none,
1095 + disabledBorder: InputBorder.none,
1096 + hintText: l10n.chat_input_hint,
1097 + hintStyle: TextStyle(
1098 + color: theme.colorScheme.onSurface.withValues(
1099 + alpha: 0.38,
1100 + ),
1101 + fontSize: 16,
1102 + ),
1103 + isDense: true,
1104 + contentPadding: const EdgeInsets.symmetric(
1105 + vertical: 8,
1106 + ),
1107 + ),
1108 + ),
1109 + ),
1110 + ),
1111 + ],
1112 + ),
1113 + ),
1114
1373 - if (showRoleSwapButton) ...[
1374 - _buildRoleSwapButton(theme),
1375 - const SizedBox(width: 6),
1376 - ],
1115 + const SizedBox(width: 8),
1116
1117 _buildMicButton(isListening, theme),
1118
1380 - const SizedBox(width: 6),
1119 + const SizedBox(width: 8),
1120
1121 _buildActionButton(canSend, theme),
1383 -
1122 ],
1385 -
1123 ),
1387 -
1124 ],
1389 -
1125 ),
1391 -
1126 ),
1393 -
1127 );
1395 -
1128 }
1397 -
1129 }
1399 -
1400 -
lib/features/conversations/providers/conversation_providers.dart
-22
@@ -163,17 +163,6 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
163 db.conversationBox.put(entity);
164
165 state = AsyncData(await _loadAll());
166 -
167 - final activeId = ref.read(activeConversationProvider)?.id;
168 - if (activeId == id) {
169 - final refreshed = state.value?.firstWhere(
170 - (c) => c.id == id,
171 - orElse: () => updated,
172 - );
173 - ref
174 - .read(activeConversationProvider.notifier)
175 - .setActiveConversation(refreshed);
176 - }
166 }
167
168 Future<void> setTemporary(String id, bool isTemporary) async {
@@ -202,17 +191,6 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
191 db.conversationBox.put(entity);
192
193 state = AsyncData(await _loadAll());
205 -
206 - final activeId = ref.read(activeConversationProvider)?.id;
207 - if (activeId == id) {
208 - final refreshed = state.value?.firstWhere(
209 - (c) => c.id == id,
210 - orElse: () => updated,
211 - );
212 - ref
213 - .read(activeConversationProvider.notifier)
214 - .setActiveConversation(refreshed);
215 - }
194 }
195
196 Future<void> deleteConversation(String id) async {