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 {