| 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 | } |