main
dart 439 lines 15.2 KB
Raw
1 import 'dart:convert';
2
3 import 'package:flutter/material.dart';
4 import 'package:flutter_riverpod/flutter_riverpod.dart';
5 import 'package:shadcn_ui/shadcn_ui.dart';
6 import 'package:localmind/core/utils/system_insets.dart';
7 import 'package:localmind/l10n/app_localizations.dart';
8 import 'package:localmind/core/providers/storage_providers.dart';
9 import 'package:localmind/core/services/data_backup_service.dart';
10 import 'package:localmind/core/services/export_choice_dialog.dart';
11 import 'package:localmind/core/services/share_service.dart';
12 import 'package:localmind/features/conversations/data/models/conversation.dart';
13 import 'package:localmind/features/conversations/providers/conversation_providers.dart'
14 as conv;
15 import 'package:localmind/features/conversations/views/components/rename_conversation_dialog.dart';
16 import 'package:localmind/features/models/views/model_picker_sheet.dart';
17 import 'package:localmind/features/personas/providers/personas_providers.dart';
18 import 'package:localmind/features/servers/providers/server_providers.dart';
19 import 'package:localmind/features/tts/views/components/tts_player_bar.dart';
20 import '../data/export_service.dart';
21 import '../providers/chat_providers.dart';
22 import 'components/chat_auto_scroll_controller.dart';
23 import 'components/notification_permission_banner.dart';
24 import 'components/top_bar/connection_banner.dart';
25 import 'components/top_bar/persona_indicator.dart';
26 import 'components/top_bar/screen_app_bar.dart';
27 import 'components/message_list/message_area.dart';
28 import 'components/chat_bottom_bar.dart';
29 import 'package:localmind/core/models/enums.dart';
30 import 'package:localmind/features/conversations/views/components/conversation_list.dart';
31 import 'package:localmind/features/personas/views/components/persona_picker_sheet.dart';
32
33 /// Height the always-on token usage row adds below the input box (its own
34 /// content height plus the padding around it) — added to the message list's
35 /// bottom padding so streamed content never ends up hidden behind it.
36 const double _tokenIndicatorRowHeight = 22;
37
38 class ChatScreen extends ConsumerStatefulWidget {
39 const ChatScreen({super.key});
40
41 @override
42 ConsumerState<ChatScreen> createState() => _ChatScreenState();
43 }
44
45 class _ChatScreenState extends ConsumerState<ChatScreen>
46 with WidgetsBindingObserver {
47 final ScrollController _scrollController = ScrollController();
48 final FocusNode _inputFocusNode = FocusNode();
49 bool _isApprovalDialogOpen = false;
50 late final ChatAutoScrollController _autoScroll;
51
52 @override
53 void initState() {
54 super.initState();
55 WidgetsBinding.instance.addObserver(this);
56 _inputFocusNode.addListener(() => setState(() {}));
57 _scrollController.addListener(
58 () => _autoScroll.onScrollChanged(_scrollController),
59 );
60 _autoScroll = ChatAutoScrollController();
61 }
62
63 @override
64 void didChangeAppLifecycleState(AppLifecycleState state) {
65 if (state == AppLifecycleState.inactive ||
66 state == AppLifecycleState.paused ||
67 state == AppLifecycleState.hidden ||
68 state == AppLifecycleState.detached) {
69 ref.read(chatProvider.notifier).checkpointStreamingMessage(flush: true);
70 }
71 }
72
73 @override
74 void dispose() {
75 WidgetsBinding.instance.removeObserver(this);
76 _scrollController.dispose();
77 _inputFocusNode.dispose();
78 super.dispose();
79 }
80
81 @override
82 Widget build(BuildContext context) {
83 ref.watch(autoSelectFirstLoadedModelProvider);
84
85 ref.listen<PendingToolApproval?>(
86 chatProvider.select((s) => s.pendingToolApproval),
87 (previous, next) {
88 if (next != null && !_isApprovalDialogOpen) {
89 WidgetsBinding.instance.addPostFrameCallback((_) {
90 if (!mounted || _isApprovalDialogOpen) return;
91 _showToolApprovalDialog(context, next);
92 });
93 }
94 },
95 );
96
97 return _ChatBody(
98 scrollController: _scrollController,
99 inputFocusNode: _inputFocusNode,
100 autoScroll: _autoScroll,
101 onModelPicker: () => _showModelPicker(context),
102 onMenuAction: (action) => _handleMenuAction(action, context),
103 );
104 }
105
106 void _showModelPicker(BuildContext context) {
107 showModalBottomSheet(
108 context: context,
109 isScrollControlled: true,
110 useSafeArea: true,
111 builder: (context) => const ModelPickerSheet(),
112 );
113 }
114
115 void _handleMenuAction(String action, BuildContext context) {
116 switch (action) {
117 case 'new_chat':
118 ref.read(chatProvider.notifier).startNewConversation();
119 case 'share_chat':
120 _shareConversation(context);
121 case 'persona':
122 showPersonaPickerSheet(context);
123 case 'rename':
124 final activeConv = ref.read(conv.activeConversationProvider);
125 if (activeConv != null) {
126 _showRenameDialog(context, activeConv);
127 }
128 case 'move_to_folder':
129 final activeConv = ref.read(conv.activeConversationProvider);
130 if (activeConv != null) {
131 showMoveToFolderSheet(
132 context,
133 ref,
134 AppLocalizations.of(context)!,
135 activeConv,
136 );
137 }
138 case 'export_chat':
139 _exportConversation(context);
140 case 'clear':
141 showDialog(
142 context: context,
143 builder: (context) {
144 final dlgL10n = AppLocalizations.of(context)!;
145 return AlertDialog(
146 title: Text(dlgL10n.clear_conversation_title),
147 content: Text(dlgL10n.clear_conversation_body),
148 actions: [
149 TextButton(
150 onPressed: () => Navigator.pop(context),
151 child: Text(dlgL10n.cancel),
152 ),
153 TextButton(
154 onPressed: () {
155 Navigator.pop(context);
156 ref.read(chatProvider.notifier).clearConversation();
157 },
158 child: Text(dlgL10n.clear),
159 ),
160 ],
161 );
162 },
163 );
164 }
165 }
166
167 Future<void> _shareConversation(BuildContext context) async {
168 final messages = ref.read(chatProvider).messages;
169 if (messages.isEmpty) return;
170 final activeConv = ref.read(conv.activeConversationProvider);
171 final isTemporary = ref.read(chatProvider.select((s) => s.isTemporary));
172 final title = isTemporary
173 ? AppLocalizations.of(context)!.temporary_chat
174 : activeConv?.title;
175 final text = ExportService.exportAsText(messages, title: title);
176 await ShareService.shareText(text, subject: title);
177 }
178
179 Future<void> _exportConversation(BuildContext context) async {
180 final messages = ref.read(chatProvider).messages;
181 if (messages.isEmpty) return;
182
183 final activeConv = ref.read(conv.activeConversationProvider);
184 final isTemporary = ref.read(chatProvider.select((s) => s.isTemporary));
185 final title = isTemporary
186 ? AppLocalizations.of(context)!.temporary_chat
187 : activeConv?.title;
188
189 final content = activeConv != null && !isTemporary
190 ? DataBackupService().exportConversationAsJson(
191 ref.read(databaseProvider).store,
192 activeConv.id,
193 )
194 : await ExportService.exportAsMarkdown(messages, title: title);
195
196 if (!context.mounted) return;
197 await showExportChoiceDialog(context, content: content, subject: title);
198 }
199
200 void _showRenameDialog(BuildContext context, Conversation conversation) {
201 showRenameConversationDialog(context, ref, conversation: conversation);
202 }
203
204 void _showToolApprovalDialog(
205 BuildContext context,
206 PendingToolApproval approval,
207 ) {
208 setState(() => _isApprovalDialogOpen = true);
209 final l10n = AppLocalizations.of(context)!;
210
211 showShadDialog<bool>(
212 context: context,
213 barrierDismissible: false,
214 builder: (context) => ShadDialog.alert(
215 title: Text('${l10n.execute_tool_title}: ${approval.toolCall.name}?'),
216 description: Column(
217 crossAxisAlignment: CrossAxisAlignment.start,
218 mainAxisSize: MainAxisSize.min,
219 children: [
220 Text(l10n.execute_tool_request_desc),
221 const SizedBox(height: 8),
222 Container(
223 width: double.infinity,
224 padding: const EdgeInsets.all(8),
225 decoration: BoxDecoration(
226 color: Theme.of(context).colorScheme.surfaceContainer,
227 borderRadius: BorderRadius.circular(8),
228 ),
229 child: SingleChildScrollView(
230 scrollDirection: Axis.horizontal,
231 child: Text(
232 const JsonEncoder.withIndent(
233 ' ',
234 ).convert(approval.toolCall.arguments),
235 style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
236 ),
237 ),
238 ),
239 ],
240 ),
241 actions: [
242 ShadButton.outline(
243 child: Text(l10n.reject),
244 onPressed: () => Navigator.of(context).pop(false),
245 ),
246 ShadButton(
247 child: Text(l10n.approve),
248 onPressed: () => Navigator.of(context).pop(true),
249 ),
250 ],
251 ),
252 ).then((value) {
253 // `mounted` must guard every ref-touching branch here, not just the
254 // setState — calling `ref.read` on a deactivated State throws
255 // "Using 'ref' when a widget is about to or has been unmounted is unsafe".
256 if (!mounted) return;
257 setState(() => _isApprovalDialogOpen = false);
258 ref.read(chatProvider.notifier).approveTool(value ?? false);
259 });
260 }
261 }
262
263 class _ChatBody extends ConsumerWidget {
264 const _ChatBody({
265 required this.scrollController,
266 required this.inputFocusNode,
267 required this.autoScroll,
268 required this.onModelPicker,
269 required this.onMenuAction,
270 });
271
272 final ScrollController scrollController;
273 final FocusNode inputFocusNode;
274 final ChatAutoScrollController autoScroll;
275 final VoidCallback onModelPicker;
276 final void Function(String) onMenuAction;
277
278 @override
279 Widget build(BuildContext context, WidgetRef ref) {
280 final theme = Theme.of(context);
281 final isDark = theme.brightness == Brightness.dark;
282 final isLoading = ref.watch(chatProvider.select((s) => s.isLoading));
283 final messages = ref.watch(chatProvider.select((s) => s.messages));
284 final isStreaming = ref.watch(chatProvider.select((s) => s.isStreaming));
285 final streamingLength = ref.watch(
286 chatProvider.select((s) => s.streamingMessage?.content.length ?? 0),
287 );
288 final errorMessage = ref.watch(chatProvider.select((s) => s.errorMessage));
289 final selectedModel = ref.watch(selectedModelProvider);
290 final connectionStatus = ref.watch(connectionStatusProvider);
291 final activeConversation = ref.watch(conv.activeConversationProvider);
292 final isTemporary = ref.watch(chatProvider.select((s) => s.isTemporary));
293 final personas = ref.watch(
294 personasForConversationProvider(activeConversation?.personaId),
295 );
296 final hasPersonas = personas.isNotEmpty;
297 final keyboardBottomInset = bottomKeyboardInset(context);
298 final systemBottomInset = bottomSystemInset(context);
299 // The token usage row (below the input box) is hidden while the
300 // keyboard is open, same as the smart-reply chips, so only reserve
301 // extra scroll space for it when it's actually visible.
302 final effectiveBottomInset = keyboardBottomInset > 0
303 ? 0.0
304 : systemBottomInset + _tokenIndicatorRowHeight;
305
306 final needsScroll = autoScroll.checkAndUpdate(
307 messageCount: messages.length,
308 streamingLength: streamingLength,
309 isStreaming: isStreaming,
310 );
311 if (needsScroll) {
312 autoScroll.scheduleAutoScroll(
313 controller: scrollController,
314 streaming: isStreaming,
315 );
316 }
317
318 return Column(
319 children: [
320 ScreenAppBar(
321 activeConversation: activeConversation,
322 isDark: isDark,
323 hasPersonas: hasPersonas,
324 isTemporary: isTemporary,
325 hasMessages: messages.isNotEmpty,
326 onMenuAction: onMenuAction,
327 onPersonaPicker: () => showPersonaPickerSheet(context),
328 onChatModeAction: () => _handleChatModeAction(
329 context,
330 ref,
331 hasMessages: messages.isNotEmpty,
332 isTemporary: isTemporary,
333 ),
334 ),
335 PersonaIndicator(
336 personas: personas,
337 onTap: () => showPersonaPickerSheet(context),
338 onClear: () => _clearPersonas(ref),
339 ),
340 const NotificationPermissionBanner(),
341 if (connectionStatus == ConnectionStatus.disconnected ||
342 connectionStatus == ConnectionStatus.error)
343 ConnectionBanner(status: connectionStatus),
344 const TtsPlayerBar(),
345 Expanded(
346 child: DecoratedBox(
347 decoration: isTemporary
348 ? BoxDecoration(
349 color: isDark
350 ? const Color(0xFF15120C)
351 : const Color(0xFFFFFBF0),
352 border: Border.all(
353 color: isDark
354 ? const Color(0xFF6B5A2E)
355 : const Color(0xFFE6C35C),
356 width: 2,
357 ),
358 )
359 : const BoxDecoration(),
360 child: Stack(
361 children: [
362 MessageArea(
363 isLoading: isLoading,
364 messages: messages,
365 activeConversation: activeConversation,
366 errorMessage: errorMessage,
367 selectedModel: selectedModel,
368 isStreaming: isStreaming,
369 scrollController: scrollController,
370 effectiveBottomInset: effectiveBottomInset,
371 keyboardBottomInset: keyboardBottomInset,
372 onModelPicker: onModelPicker,
373 ),
374 ChatBottomBar(
375 isStreaming: isStreaming,
376 keyboardBottomInset: keyboardBottomInset,
377 inputFocusNode: inputFocusNode,
378 ),
379 ],
380 ),
381 ),
382 ),
383 ],
384 );
385 }
386 }
387
388 void _clearPersonas(WidgetRef ref) {
389 final activeConv = ref.read(conv.activeConversationProvider);
390 if (activeConv != null) {
391 ref
392 .read(conv.conversationsProvider.notifier)
393 .updatePersonas(activeConv.id, const []);
394 } else {
395 ref.read(selectedPersonasProvider.notifier).clear();
396 }
397 }
398
399 void _handleChatModeAction(
400 BuildContext context,
401 WidgetRef ref, {
402 required bool hasMessages,
403 required bool isTemporary,
404 }) {
405 final l10n = AppLocalizations.of(context)!;
406
407 if (!hasMessages) {
408 ref.read(chatProvider.notifier).setTemporaryMode(!isTemporary);
409 return;
410 }
411
412 if (isTemporary) {
413 showDialog<void>(
414 context: context,
415 builder: (ctx) => AlertDialog(
416 title: Text(l10n.exit_temporary_chat_title),
417 content: Text(l10n.exit_temporary_chat_body),
418 actions: [
419 TextButton(
420 onPressed: () => Navigator.pop(ctx),
421 child: Text(l10n.cancel),
422 ),
423 TextButton(
424 onPressed: () {
425 Navigator.pop(ctx);
426 FocusManager.instance.primaryFocus?.unfocus();
427 ref.read(chatProvider.notifier).startNewConversation();
428 },
429 child: Text(l10n.nav_new_chat),
430 ),
431 ],
432 ),
433 );
434 return;
435 }
436
437 FocusManager.instance.primaryFocus?.unfocus();
438 ref.read(chatProvider.notifier).startNewConversation();
439 }