import 'package:flutter/material.dart'; import 'package:conduit/l10n/app_localizations.dart'; import '../../../core/widgets/error_boundary.dart'; import '../../../shared/widgets/optimized_list.dart'; import '../../../shared/theme/theme_extensions.dart'; import 'package:flutter/services.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'dart:io' show Platform; import 'dart:ui' show ImageFilter; import '../../../shared/widgets/responsive_drawer_layout.dart'; import '../../navigation/widgets/chats_drawer.dart'; import 'dart:async'; import '../../../core/providers/app_providers.dart'; import '../../auth/providers/unified_auth_providers.dart'; import '../providers/chat_providers.dart'; import '../../../core/utils/debug_logger.dart'; import '../../../core/utils/user_display_name.dart'; import '../../../core/utils/model_icon_utils.dart'; import '../../../shared/widgets/markdown/markdown_preprocessor.dart'; import '../../../core/utils/android_assistant_handler.dart'; import '../widgets/modern_chat_input.dart'; import '../widgets/user_message_bubble.dart'; import '../widgets/assistant_message_widget.dart' as assistant; import '../widgets/streaming_title_text.dart'; import '../widgets/file_attachment_widget.dart'; import '../widgets/context_attachment_widget.dart'; import '../services/voice_input_service.dart'; import '../services/file_attachment_service.dart'; import 'voice_call_page.dart'; import '../../../shared/services/tasks/task_queue.dart'; import '../../tools/providers/tools_providers.dart'; import '../../../core/models/chat_message.dart'; import '../../../core/models/folder.dart'; import '../../../core/models/model.dart'; import '../providers/context_attachments_provider.dart'; import '../../../shared/widgets/loading_states.dart'; import 'chat_page_helpers.dart'; import '../../../shared/widgets/themed_dialogs.dart'; import '../../onboarding/views/onboarding_sheet.dart'; import '../../../shared/widgets/sheet_handle.dart'; import '../../../shared/widgets/measure_size.dart'; import '../../../shared/widgets/conduit_components.dart'; import '../../../shared/widgets/middle_ellipsis_text.dart'; import '../../../shared/widgets/modal_safe_area.dart'; import '../../../core/services/settings_service.dart'; import '../../../shared/utils/conversation_context_menu.dart'; import '../../../shared/widgets/model_avatar.dart'; import '../../../core/services/platform_service.dart' as ps; import 'package:flutter/gestures.dart' show DragStartBehavior; class ChatPage extends ConsumerStatefulWidget { const ChatPage({super.key}); @override ConsumerState createState() => _ChatPageState(); } class _ChatPageState extends ConsumerState { final ScrollController _scrollController = ScrollController(); bool _showScrollToBottom = false; bool _isSelectionMode = false; final Set _selectedMessageIds = {}; Timer? _scrollDebounceTimer; bool _isDeactivated = false; double _inputHeight = 0; // dynamic input height to position scroll button bool _lastKeyboardVisible = false; // track keyboard visibility transitions bool _didStartupFocus = false; // one-time auto-focus on startup String? _lastConversationId; bool _shouldAutoScrollToBottom = true; bool _autoScrollCallbackScheduled = false; bool _pendingConversationScrollReset = false; bool _suppressKeepPinnedOnce = false; // skip keep-pinned bottom after reset bool _userPausedAutoScroll = false; // user scrolled away during generation String? _cachedGreetingName; bool _greetingReady = false; String _formatModelDisplayName(String name) { return name.trim(); } bool validateFileSize(int fileSize, int maxSizeMB) { return fileSize <= (maxSizeMB * 1024 * 1024); } void startNewChat() { // Clear current conversation ref.read(chatMessagesProvider.notifier).clearMessages(); ref.read(activeConversationProvider.notifier).clear(); // Clear context attachments (web pages, YouTube, knowledge base docs) ref.read(contextAttachmentsProvider.notifier).clear(); // Clear any pending folder selection ref.read(pendingFolderIdProvider.notifier).clear(); // Reset to default model for new conversations (fixes #296) restoreDefaultModel(ref); // Scroll to top if (_scrollController.hasClients) { _scrollController.jumpTo(0); } _shouldAutoScrollToBottom = true; _pendingConversationScrollReset = false; _userPausedAutoScroll = false; _scheduleAutoScrollToBottom(); } Future _checkAndAutoSelectModel() async { // Check if a model is already selected final selectedModel = ref.read(selectedModelProvider); if (selectedModel != null) { DebugLogger.log( 'selected', scope: 'chat/model', data: {'name': selectedModel.name}, ); return; } // Use shared restore logic which handles settings priority and fallbacks await restoreDefaultModel(ref); } Future _checkAndShowOnboarding() async { try { // Check if onboarding has been seen final storage = ref.read(optimizedStorageServiceProvider); final seen = await storage.getOnboardingSeen(); DebugLogger.log( 'onboarding-status', scope: 'chat/onboarding', data: {'seen': seen}, ); if (!seen && mounted) { // Small delay to ensure navigation has settled await Future.delayed(const Duration(milliseconds: 500)); if (!mounted) return; DebugLogger.log('onboarding-show', scope: 'chat/onboarding'); _showOnboarding(); await storage.setOnboardingSeen(true); DebugLogger.log('onboarding-marked', scope: 'chat/onboarding'); } } catch (e) { DebugLogger.error( 'onboarding-status-failed', scope: 'chat/onboarding', error: e, ); } } void _showOnboarding() { try { ref.read(composerAutofocusEnabledProvider.notifier).set(false); } catch (_) {} try { FocusManager.instance.primaryFocus?.unfocus(); SystemChannels.textInput.invokeMethod('TextInput.hide'); } catch (_) {} showModalBottomSheet( context: context, backgroundColor: Colors.transparent, isScrollControlled: true, builder: (context) => Container( decoration: BoxDecoration( color: context.conduitTheme.surfaceBackground, borderRadius: const BorderRadius.vertical( top: Radius.circular(AppBorderRadius.modal), ), boxShadow: ConduitShadows.modal(context), ), child: const OnboardingSheet(), ), ).whenComplete(() { if (!mounted) return; try { ref.read(composerAutofocusEnabledProvider.notifier).set(true); } catch (_) {} }); } Future _checkAndLoadDemoConversation() async { if (!mounted) return; final isReviewerMode = ref.read(reviewerModeProvider); if (!isReviewerMode) return; // Check if there's already an active conversation if (!mounted) return; final activeConversation = ref.read(activeConversationProvider); if (activeConversation != null) { DebugLogger.log( 'active', scope: 'chat/demo', data: {'title': activeConversation.title}, ); return; } // Force refresh conversations provider to ensure we get the demo conversations if (!mounted) return; refreshConversationsCache(ref); // Try to load demo conversation for (int i = 0; i < 10; i++) { if (!mounted) return; final conversationsAsync = ref.read(conversationsProvider); if (conversationsAsync.hasValue && conversationsAsync.value!.isNotEmpty) { // Find and load the welcome conversation final welcomeConv = conversationsAsync.value!.firstWhere( (conv) => conv.id == 'demo-conv-1', orElse: () => conversationsAsync.value!.first, ); if (!mounted) return; ref.read(activeConversationProvider.notifier).set(welcomeConv); DebugLogger.log('Auto-loaded demo conversation', scope: 'chat/page'); return; } // If conversations are still loading, wait a bit and retry if (conversationsAsync.isLoading || i == 0) { await Future.delayed(const Duration(milliseconds: 200)); if (!mounted) return; continue; } // If there was an error or no conversations, break break; } DebugLogger.log( 'Failed to auto-load demo conversation', scope: 'chat/page', ); } @override void initState() { super.initState(); // Listen to scroll events to show/hide scroll to bottom button _scrollController.addListener(_onScroll); _scheduleAutoScrollToBottom(); // Initialize chat page components WidgetsBinding.instance.addPostFrameCallback((_) async { if (!mounted) return; // Initialize Android Assistant Handler ref.read(androidAssistantProvider); // First, ensure a model is selected await _checkAndAutoSelectModel(); if (!mounted) return; // Then check for demo conversation in reviewer mode await _checkAndLoadDemoConversation(); if (!mounted) return; // Finally, show onboarding if needed await _checkAndShowOnboarding(); }); } @override void didChangeDependencies() { super.didChangeDependencies(); // Listen for screen context from Android Assistant final screenContext = ref.watch(screenContextProvider); if (screenContext != null && screenContext.isNotEmpty) { // Clear the context so we don't process it again WidgetsBinding.instance.addPostFrameCallback((_) { ref.read(screenContextProvider.notifier).setContext(null); final currentModel = ref.read(selectedModelProvider); _handleMessageSend( "Here is the content of my screen:\n\n$screenContext\n\nCan you summarize this?", currentModel, ); }); } } @override void dispose() { _scrollController.dispose(); _scrollDebounceTimer?.cancel(); super.dispose(); } @override void deactivate() { _isDeactivated = true; _scrollDebounceTimer?.cancel(); super.deactivate(); } @override void activate() { super.activate(); _isDeactivated = false; } void _handleMessageSend(String text, dynamic selectedModel) async { // Resolve model on-demand if none selected yet if (selectedModel == null) { try { // Prefer already-loaded models List models; final modelsAsync = ref.read(modelsProvider); if (modelsAsync.hasValue) { models = modelsAsync.value!; } else { models = await ref.read(modelsProvider.future); } if (models.isNotEmpty) { selectedModel = models.first; ref.read(selectedModelProvider.notifier).set(selectedModel); } } catch (_) { // If models cannot be resolved, bail out without sending return; } if (selectedModel == null) return; } try { // Get attached files and collect uploaded file IDs (including data URLs for images) final attachedFiles = ref.read(attachedFilesProvider); final uploadedFileIds = attachedFiles .where( (file) => file.status == FileUploadStatus.completed && file.fileId != null, ) .map((file) => file.fileId!) .toList(); // Get selected tools final toolIds = ref.read(selectedToolIdsProvider); // Enqueue task-based send to unify flow across text, images, and tools final activeConv = ref.read(activeConversationProvider); await ref .read(taskQueueProvider.notifier) .enqueueSendText( conversationId: activeConv?.id, text: text, attachments: uploadedFileIds.isNotEmpty ? uploadedFileIds : null, toolIds: toolIds.isNotEmpty ? toolIds : null, ); // Clear attachments after successful send ref.read(attachedFilesProvider.notifier).clearAll(); // Reset auto-scroll pause when user sends a new message _userPausedAutoScroll = false; // Scroll to bottom after enqueuing (only if user was near bottom) WidgetsBinding.instance.addPostFrameCallback((_) { // Only auto-scroll if user was already near the bottom (within 300 px) final distanceFromBottom = _distanceFromBottom(); if (distanceFromBottom <= 300) { _scrollToBottom(); } }); } catch (e) { // Message send failed - error already handled by sendMessage } } // Inline voice input now handled directly inside ModernChatInput. void _handleFileAttachment() async { // Check if selected model supports file upload final fileUploadCapableModels = ref.read(fileUploadCapableModelsProvider); if (fileUploadCapableModels.isEmpty) { if (!mounted) return; return; } final fileService = ref.read(fileAttachmentServiceProvider); if (fileService == null) { return; } try { final attachments = await fileService.pickFiles(); if (attachments.isEmpty) return; // Validate file sizes for (final attachment in attachments) { final fileSize = await attachment.file.length(); if (!validateFileSize(fileSize, 20)) { if (!mounted) return; return; } } // Add files to the attachment list ref.read(attachedFilesProvider.notifier).addFiles(attachments); // Enqueue uploads via task queue for unified retry/progress final activeConv = ref.read(activeConversationProvider); for (final attachment in attachments) { try { await ref .read(taskQueueProvider.notifier) .enqueueUploadMedia( conversationId: activeConv?.id, filePath: attachment.file.path, fileName: attachment.displayName, fileSize: await attachment.file.length(), ); } catch (e) { if (!mounted) return; DebugLogger.log('Enqueue upload failed: $e', scope: 'chat/page'); } } } catch (e) { if (!mounted) return; DebugLogger.log('File selection failed: $e', scope: 'chat/page'); } } void _handleImageAttachment({bool fromCamera = false}) async { DebugLogger.log( 'Starting image attachment process - fromCamera: $fromCamera', scope: 'chat/page', ); // Check if selected model supports vision final visionCapableModels = ref.read(visionCapableModelsProvider); if (visionCapableModels.isEmpty) { if (!mounted) return; return; } final fileService = ref.read(fileAttachmentServiceProvider); if (fileService == null) { DebugLogger.log( 'File service is null - cannot proceed', scope: 'chat/page', ); return; } try { DebugLogger.log('Picking image...', scope: 'chat/page'); final attachment = fromCamera ? await fileService.takePhoto() : await fileService.pickImage(); if (attachment == null) { DebugLogger.log('No image selected', scope: 'chat/page'); return; } DebugLogger.log( 'Image selected: ${attachment.file.path}', scope: 'chat/page', ); DebugLogger.log( 'Image display name: ${attachment.displayName}', scope: 'chat/page', ); final imageSize = await attachment.file.length(); DebugLogger.log('Image size: $imageSize bytes', scope: 'chat/page'); // Validate file size (default 20MB limit like OpenWebUI) if (!validateFileSize(imageSize, 20)) { if (!mounted) return; return; } // Add image to the attachment list ref.read(attachedFilesProvider.notifier).addFiles([attachment]); DebugLogger.log('Image added to attachment list', scope: 'chat/page'); // Enqueue upload via task queue for unified retry/progress DebugLogger.log('Enqueueing image upload...', scope: 'chat/page'); final activeConv = ref.read(activeConversationProvider); try { await ref .read(taskQueueProvider.notifier) .enqueueUploadMedia( conversationId: activeConv?.id, filePath: attachment.file.path, fileName: attachment.displayName, fileSize: imageSize, ); } catch (e) { DebugLogger.log('Enqueue image upload failed: $e', scope: 'chat/page'); } } catch (e) { DebugLogger.log('Image attachment error: $e', scope: 'chat/page'); if (!mounted) return; } } /// Handles images/files pasted from clipboard into the chat input. Future _handlePastedAttachments( List attachments, ) async { if (attachments.isEmpty) return; DebugLogger.log( 'Processing ${attachments.length} pasted attachment(s)', scope: 'chat/page', ); // Add attachments to the list ref.read(attachedFilesProvider.notifier).addFiles(attachments); // Enqueue uploads via task queue for unified retry/progress final activeConv = ref.read(activeConversationProvider); for (final attachment in attachments) { try { final fileSize = await attachment.file.length(); DebugLogger.log( 'Pasted file: ${attachment.displayName}, size: $fileSize bytes', scope: 'chat/page', ); await ref .read(taskQueueProvider.notifier) .enqueueUploadMedia( conversationId: activeConv?.id, filePath: attachment.file.path, fileName: attachment.displayName, fileSize: fileSize, ); } catch (e) { DebugLogger.log('Enqueue pasted upload failed: $e', scope: 'chat/page'); } } DebugLogger.log( 'Added ${attachments.length} pasted attachment(s)', scope: 'chat/page', ); } /// Checks if a URL is a YouTube URL. bool _isYoutubeUrl(String url) { return url.startsWith('https://www.youtube.com') || url.startsWith('https://youtu.be') || url.startsWith('https://youtube.com') || url.startsWith('https://m.youtube.com'); } Future _promptAttachWebpage() async { final api = ref.read(apiServiceProvider); if (api == null) return; final l10n = AppLocalizations.of(context)!; String url = ''; bool submitting = false; await showDialog( context: context, builder: (dialogContext) { String? errorText; return StatefulBuilder( builder: (innerContext, setState) { void setError(String? msg) { setState(() { errorText = msg; }); } return AlertDialog( title: const Text('Attach webpage'), content: SizedBox( width: 400, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Paste a URL to ingest its content into the chat.', style: Theme.of(innerContext).textTheme.bodySmall, ), const SizedBox(height: 12), TextField( decoration: InputDecoration( labelText: 'Webpage URL', hintText: 'https://example.com/article', border: const OutlineInputBorder(), errorText: errorText, ), onChanged: (value) { url = value; if (errorText != null) setError(null); }, autofocus: true, keyboardType: TextInputType.url, ), ], ), ), actions: [ TextButton( onPressed: submitting ? null : () { Navigator.of(dialogContext).pop(); }, child: Text(l10n.cancel), ), ElevatedButton( onPressed: submitting ? null : () async { final parsed = Uri.tryParse(url.trim()); if (parsed == null || !(parsed.isScheme('http') || parsed.isScheme('https'))) { setError('Enter a valid http(s) URL.'); return; } setState(() { submitting = true; errorText = null; }); try { final trimmedUrl = url.trim(); final isYoutube = _isYoutubeUrl(trimmedUrl); // Use appropriate API based on URL type final result = isYoutube ? await api.processYoutube(url: trimmedUrl) : await api.processWebpage(url: trimmedUrl); final file = (result?['file'] as Map?) ?.cast(); final fileData = (file?['data'] as Map?) ?.cast(); final content = fileData?['content']?.toString() ?? ''; if (content.isEmpty) { setError( isYoutube ? 'Could not fetch YouTube transcript.' : 'The page had no readable content.', ); return; } final meta = (file?['meta'] as Map?) ?.cast(); final name = meta?['name']?.toString() ?? parsed.host; final collectionName = result?['collection_name'] ?.toString(); // Add as appropriate type final notifier = ref.read( contextAttachmentsProvider.notifier, ); if (isYoutube) { notifier.addYoutube( displayName: name, content: content, url: trimmedUrl, collectionName: collectionName, ); } else { notifier.addWeb( displayName: name, content: content, url: trimmedUrl, collectionName: collectionName, ); } if (!mounted || !dialogContext.mounted) { return; } Navigator.of(dialogContext).pop(); } catch (_) { setError('Failed to attach content.'); } finally { if (mounted) { setState(() => submitting = false); } } }, child: submitting ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('Attach'), ), ], ); }, ); }, ); } void _handleNewChat() { // Start a new chat using the existing function startNewChat(); // Hide scroll-to-bottom button for a fresh chat if (mounted) { setState(() { _showScrollToBottom = false; }); } } void _handleVoiceCall() { // Dismiss keyboard before navigating FocusScope.of(context).unfocus(); // Navigate to voice call page Navigator.of(context).push( MaterialPageRoute( builder: (context) => const VoiceCallPage(), fullscreenDialog: true, ), ); } // Replaced bottom-sheet chat list with left drawer (see ChatsDrawer) void _onScroll() { if (!_scrollController.hasClients) return; // Debounce scroll handling to reduce rebuilds if (_scrollDebounceTimer?.isActive == true) return; _scrollDebounceTimer = Timer(const Duration(milliseconds: 80), () { if (!mounted || _isDeactivated || !_scrollController.hasClients) return; final maxScroll = _scrollController.position.maxScrollExtent; final distanceFromBottom = _distanceFromBottom(); const double showThreshold = 300.0; const double hideThreshold = 150.0; final bool farFromBottom = distanceFromBottom > showThreshold; final bool nearBottom = distanceFromBottom <= hideThreshold; final bool hasScrollableContent = maxScroll.isFinite && maxScroll > showThreshold; final bool showButton = _showScrollToBottom ? !nearBottom && hasScrollableContent : farFromBottom && hasScrollableContent; if (showButton != _showScrollToBottom && mounted && !_isDeactivated) { setState(() { _showScrollToBottom = showButton; }); } }); } double _distanceFromBottom() { if (!_scrollController.hasClients) { return double.infinity; } final position = _scrollController.position; final maxScroll = position.maxScrollExtent; if (!maxScroll.isFinite) { return double.infinity; } final distance = maxScroll - position.pixels; return distance >= 0 ? distance : 0.0; } void _scheduleAutoScrollToBottom() { if (_autoScrollCallbackScheduled) return; _autoScrollCallbackScheduled = true; WidgetsBinding.instance.addPostFrameCallback((_) { _autoScrollCallbackScheduled = false; if (!mounted || !_shouldAutoScrollToBottom) return; if (!_scrollController.hasClients) { _scheduleAutoScrollToBottom(); return; } _scrollToBottom(smooth: false); _shouldAutoScrollToBottom = false; }); } void _resetScrollToTop() { if (!_scrollController.hasClients) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !_scrollController.hasClients) { return; } _scrollController.jumpTo(0); }); return; } if (_scrollController.position.pixels != 0) { _scrollController.jumpTo(0); } } void _scrollToBottom({bool smooth = true}) { if (!_scrollController.hasClients) return; // Reset user pause when explicitly scrolling to bottom if (_userPausedAutoScroll) { setState(() { _userPausedAutoScroll = false; }); } final position = _scrollController.position; final maxScroll = position.maxScrollExtent; final target = maxScroll.isFinite ? maxScroll : 0.0; if (smooth) { _scrollController.animateTo( target, duration: const Duration(milliseconds: 200), curve: Curves.easeOutCubic, ); } else { _scrollController.jumpTo(target); } } void _toggleSelectionMode() { setState(() { _isSelectionMode = !_isSelectionMode; if (!_isSelectionMode) { _selectedMessageIds.clear(); } }); } void _toggleMessageSelection(String messageId) { setState(() { if (_selectedMessageIds.contains(messageId)) { _selectedMessageIds.remove(messageId); if (_selectedMessageIds.isEmpty) { _isSelectionMode = false; } } else { _selectedMessageIds.add(messageId); } }); } void _clearSelection() { setState(() { _selectedMessageIds.clear(); _isSelectionMode = false; }); } List _getSelectedMessages() { final messages = ref.read(chatMessagesProvider); return messages.where((m) => _selectedMessageIds.contains(m.id)).toList(); } /// Builds a styled container with high-contrast background for app bar /// widgets, matching the floating chat input styling. Widget _buildAppBarPill({ required BuildContext context, required Widget child, bool isCircular = false, }) { return FloatingAppBarPill(isCircular: isCircular, child: child); } Widget _buildMessagesList(ThemeData theme) { // Use select to watch only the messages list to reduce rebuilds final messages = ref.watch( chatMessagesProvider.select((messages) => messages), ); final isLoadingConversation = ref.watch(isLoadingConversationProvider); // Use AnimatedSwitcher for smooth transition between loading and loaded states return AnimatedSwitcher( duration: const Duration(milliseconds: 400), switchInCurve: Curves.easeInOut, switchOutCurve: Curves.easeInOut, layoutBuilder: (currentChild, previousChildren) { return Stack( alignment: Alignment.topCenter, children: [ ...previousChildren, if (currentChild != null) currentChild, ], ); }, child: isLoadingConversation && messages.isEmpty ? _buildLoadingMessagesList() : _buildActualMessagesList(messages), ); } Widget _buildLoadingMessagesList() { // Use slivers to align with the actual messages view. // Do not attach the primary scroll controller here to avoid // AnimatedSwitcher attaching the same controller twice. // Add top padding for floating app bar, bottom padding for floating input. final topPadding = MediaQuery.of(context).padding.top + kToolbarHeight + Spacing.md; final bottomPadding = Spacing.lg + _inputHeight; return CustomScrollView( key: const ValueKey('loading_messages'), controller: null, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, physics: const AlwaysScrollableScrollPhysics(), cacheExtent: 300, slivers: [ SliverPadding( padding: EdgeInsets.fromLTRB( Spacing.lg, topPadding, Spacing.lg, bottomPadding, ), sliver: SliverList( delegate: SliverChildBuilderDelegate((context, index) { final isUser = index.isOdd; return Align( alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(bottom: Spacing.md), constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.82, ), padding: const EdgeInsets.all(Spacing.md), decoration: BoxDecoration( color: isUser ? context.conduitTheme.buttonPrimary.withValues( alpha: 0.15, ) : context.conduitTheme.cardBackground, borderRadius: BorderRadius.circular( AppBorderRadius.messageBubble, ), border: Border.all( color: context.conduitTheme.cardBorder, width: BorderWidth.regular, ), boxShadow: ConduitShadows.messageBubble(context), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( height: 14, width: index % 3 == 0 ? 140 : 220, decoration: BoxDecoration( color: context.conduitTheme.shimmerBase, borderRadius: BorderRadius.circular( AppBorderRadius.xs, ), ), ).animate().shimmer(duration: AnimationDuration.slow), const SizedBox(height: Spacing.xs), Container( height: 14, width: double.infinity, decoration: BoxDecoration( color: context.conduitTheme.shimmerBase, borderRadius: BorderRadius.circular( AppBorderRadius.xs, ), ), ).animate().shimmer(duration: AnimationDuration.slow), if (index % 3 != 0) ...[ const SizedBox(height: Spacing.xs), Container( height: 14, width: index % 2 == 0 ? 180 : 120, decoration: BoxDecoration( color: context.conduitTheme.shimmerBase, borderRadius: BorderRadius.circular( AppBorderRadius.xs, ), ), ).animate().shimmer(duration: AnimationDuration.slow), ], ], ), ), ); }, childCount: 6), ), ), ], ); } Widget _buildActualMessagesList(List messages) { if (messages.isEmpty) { return _buildEmptyState(Theme.of(context)); } final apiService = ref.watch(apiServiceProvider); if (_pendingConversationScrollReset) { _pendingConversationScrollReset = false; if (messages.length <= 1) { _shouldAutoScrollToBottom = true; } else { // When opening an existing conversation, start reading from the top _shouldAutoScrollToBottom = false; _resetScrollToTop(); _suppressKeepPinnedOnce = true; } } if (_shouldAutoScrollToBottom) { _scheduleAutoScrollToBottom(); } else if (!_userPausedAutoScroll) { // Only keep-pinned to bottom if user hasn't paused auto-scroll WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; if (_suppressKeepPinnedOnce) { // Skip the one-time keep-pinned-to-bottom adjustment right after // a conversation switch so we remain at the top. _suppressKeepPinnedOnce = false; return; } // Skip if user has paused auto-scroll (double-check in callback) if (_userPausedAutoScroll) return; const double keepPinnedThreshold = 60.0; final distanceFromBottom = _distanceFromBottom(); if (distanceFromBottom > 0 && distanceFromBottom <= keepPinnedThreshold) { _scrollToBottom(smooth: false); } }); } // Add top padding for floating app bar, bottom padding for floating input. final topPadding = MediaQuery.of(context).padding.top + kToolbarHeight + Spacing.md; final bottomPadding = Spacing.lg + _inputHeight; // Check if any message is currently streaming final isStreaming = messages.any((msg) => msg.isStreaming); return NotificationListener( onNotification: (notification) { // Detect user-initiated scroll (drag gesture) if (notification is ScrollStartNotification && notification.dragDetails != null) { // User started dragging - pause auto-scroll during generation if (isStreaming && !_userPausedAutoScroll) { setState(() { _userPausedAutoScroll = true; }); } } // Re-enable auto-scroll when user scrolls to bottom if (notification is ScrollEndNotification) { final distanceFromBottom = _distanceFromBottom(); if (distanceFromBottom <= 5 && _userPausedAutoScroll) { setState(() { _userPausedAutoScroll = false; }); } } return false; // Allow notification to continue bubbling }, child: CustomScrollView( key: const ValueKey('actual_messages'), controller: _scrollController, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, physics: const AlwaysScrollableScrollPhysics(), cacheExtent: 600, slivers: [ SliverPadding( padding: EdgeInsets.fromLTRB( Spacing.lg, topPadding, Spacing.lg, bottomPadding, ), sliver: OptimizedSliverList( items: messages, itemBuilder: (context, message, index) { final isUser = message.role == 'user'; final isStreaming = message.isStreaming; final isSelected = _selectedMessageIds.contains(message.id); // Resolve a friendly model display name for message headers String? displayModelName; Model? matchedModel; final rawModel = message.model; if (rawModel != null && rawModel.isNotEmpty) { final modelsAsync = ref.watch(modelsProvider); if (modelsAsync.hasValue) { final models = modelsAsync.value!; try { // Prefer exact ID match; fall back to exact name match final match = models.firstWhere( (m) => m.id == rawModel || m.name == rawModel, ); matchedModel = match; displayModelName = _formatModelDisplayName(match.name); } catch (_) { // As a fallback, format the raw value to be more readable displayModelName = _formatModelDisplayName(rawModel); } } else { // Models not loaded yet; format raw value for readability displayModelName = _formatModelDisplayName(rawModel); } } final modelIconUrl = resolveModelIconUrlForModel( apiService, matchedModel, ); var hasUserBubbleBelow = false; var hasAssistantBubbleBelow = false; for (var i = index + 1; i < messages.length; i++) { final role = messages[i].role; if (role == 'user') { hasUserBubbleBelow = true; break; } if (role == 'assistant') { hasAssistantBubbleBelow = true; break; } } // Hide archived assistant variants in the linear view final isArchivedVariant = !isUser && (message.metadata?['archivedVariant'] == true); if (isArchivedVariant) { return const SizedBox.shrink(); } final showFollowUps = !isUser && !hasUserBubbleBelow && !hasAssistantBubbleBelow; // Wrap message in selection container if in selection mode Widget messageWidget; // Use documentation style for assistant messages, bubble for user messages if (isUser) { messageWidget = UserMessageBubble( key: ValueKey('user-${message.id}'), message: message, isUser: isUser, isStreaming: isStreaming, modelName: displayModelName, onCopy: () => _copyMessage(message.content), onRegenerate: () => _regenerateMessage(message), ); } else { messageWidget = assistant.AssistantMessageWidget( key: ValueKey('assistant-${message.id}'), message: message, isStreaming: isStreaming, showFollowUps: showFollowUps, modelName: displayModelName, modelIconUrl: modelIconUrl, onCopy: () => _copyMessage(message.content), onRegenerate: () => _regenerateMessage(message), ); } // Add selection functionality if in selection mode if (_isSelectionMode) { return _SelectableMessageWrapper( isSelected: isSelected, onTap: () => _toggleMessageSelection(message.id), onLongPress: () { if (!_isSelectionMode) { _toggleSelectionMode(); _toggleMessageSelection(message.id); } }, child: messageWidget, ); } else { return GestureDetector( onLongPress: () { _toggleSelectionMode(); _toggleMessageSelection(message.id); }, child: messageWidget, ); } }, ), ), ], ), ); } void _copyMessage(String content) { // Strip reasoning blocks and annotations from copied content final cleanedContent = ConduitMarkdownPreprocessor.sanitize(content); Clipboard.setData(ClipboardData(text: cleanedContent)); } void _regenerateMessage(dynamic message) async { final selectedModel = ref.read(selectedModelProvider); if (selectedModel == null) { return; } // Find the user message that prompted this assistant response final messages = ref.read(chatMessagesProvider); final messageIndex = messages.indexOf(message); if (messageIndex <= 0 || messages[messageIndex - 1].role != 'user') { return; } try { // If assistant message has generated images and it's the last message, // use image-only regenerate flow instead of text streaming regeneration if (message.role == 'assistant' && (message.files?.any((f) => f['type'] == 'image') == true) && messageIndex == messages.length - 1) { final regenerateImages = ref.read(regenerateLastMessageProvider); await regenerateImages(); return; } // Mark previous assistant as archived for UI; keep it for server history ref.read(chatMessagesProvider.notifier).updateLastMessageWithFunction(( m, ) { final meta = Map.from(m.metadata ?? const {}); meta['archivedVariant'] = true; return m.copyWith(metadata: meta, isStreaming: false); }); // Regenerate response for the previous user message (without duplicating it) final userMessage = messages[messageIndex - 1]; await regenerateMessage( ref, userMessage.content, userMessage.attachmentIds, ); } catch (e) { DebugLogger.log('Regenerate failed: $e', scope: 'chat/page'); } } // Inline editing handled by UserMessageBubble. Dialog flow removed. Widget _buildEmptyState(ThemeData theme) { final l10n = AppLocalizations.of(context)!; final authUser = ref.watch(currentUserProvider2); final asyncUser = ref.watch(currentUserProvider); final user = asyncUser.maybeWhen( data: (value) => value ?? authUser, orElse: () => authUser, ); String? greetingName; if (user != null) { final derived = deriveUserDisplayName(user, fallback: '').trim(); if (derived.isNotEmpty) { greetingName = derived; _cachedGreetingName = derived; } } greetingName ??= _cachedGreetingName; final hasGreeting = greetingName != null && greetingName.isNotEmpty; if (hasGreeting && !_greetingReady) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; setState(() { _greetingReady = true; }); }); } else if (!hasGreeting && _greetingReady) { _greetingReady = false; } final greetingStyle = theme.textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.w600, color: context.conduitTheme.textPrimary, ); final greetingHeight = (greetingStyle?.fontSize ?? 24) * (greetingStyle?.height ?? 1.1); final String? resolvedGreetingName = hasGreeting ? greetingName : null; final greetingText = resolvedGreetingName != null ? l10n.onboardStartTitle(resolvedGreetingName) : null; // Check if there's a pending folder for the new chat final pendingFolderId = ref.watch(pendingFolderIdProvider); final folders = ref .watch(foldersProvider) .maybeWhen(data: (list) => list, orElse: () => []); final pendingFolder = pendingFolderId != null ? folders.where((f) => f.id == pendingFolderId).firstOrNull : null; // Add top padding for floating app bar, bottom padding for floating input. final topPadding = MediaQuery.of(context).padding.top + kToolbarHeight + Spacing.md; final bottomPadding = _inputHeight; return LayoutBuilder( builder: (context, constraints) { final greetingDisplay = greetingText ?? ''; return MediaQuery.removeViewInsets( context: context, removeBottom: true, child: SizedBox( width: double.infinity, height: constraints.maxHeight, child: Padding( padding: EdgeInsets.fromLTRB( Spacing.lg, topPadding, Spacing.lg, bottomPadding, ), child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.max, children: [ if (pendingFolder != null) ...[ Column( mainAxisSize: MainAxisSize.min, children: [ Text( l10n.newChat, style: greetingStyle, textAlign: TextAlign.center, ), const SizedBox(height: Spacing.sm), Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Platform.isIOS ? CupertinoIcons.folder_fill : Icons.folder_rounded, size: 14, color: context.conduitTheme.textSecondary, ), const SizedBox(width: Spacing.xs), Text( pendingFolder.name, style: AppTypography.small.copyWith( color: context.conduitTheme.textSecondary, ), textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ], ), ] else ...[ SizedBox( height: greetingHeight, child: AnimatedOpacity( duration: const Duration(milliseconds: 260), curve: Curves.easeOutCubic, opacity: _greetingReady ? 1 : 0, child: Align( alignment: Alignment.center, child: Text( _greetingReady ? greetingDisplay : '', style: greetingStyle, textAlign: TextAlign.center, ), ), ), ), ], ], ), ), ), ); }, ); } // Removed detailed help items from chat page; guidance now lives in Onboarding @override Widget build(BuildContext context) { final theme = Theme.of(context); final l10n = AppLocalizations.of(context)!; // Use select to watch only the selected model to reduce rebuilds final selectedModel = ref.watch( selectedModelProvider.select((model) => model), ); // Watch reviewer mode and auto-select model if needed final isReviewerMode = ref.watch(reviewerModeProvider); final conversationId = ref.watch( activeConversationProvider.select((conv) => conv?.id), ); if (conversationId != _lastConversationId) { _lastConversationId = conversationId; _userPausedAutoScroll = false; // Reset pause on conversation change if (conversationId == null) { _shouldAutoScrollToBottom = true; _pendingConversationScrollReset = false; _scheduleAutoScrollToBottom(); } else { _pendingConversationScrollReset = true; _shouldAutoScrollToBottom = false; } } final conversationTitle = ref.watch( activeConversationProvider.select((conv) => conv?.title), ); final trimmedConversationTitle = conversationTitle?.trim(); final displayConversationTitle = (trimmedConversationTitle != null && trimmedConversationTitle.isNotEmpty) ? trimmedConversationTitle : null; // Watch loading state for app bar skeleton final isLoadingConversation = ref.watch(isLoadingConversationProvider); final formattedModelName = selectedModel != null ? _formatModelDisplayName(selectedModel.name) : null; final modelLabel = formattedModelName ?? l10n.chooseModel; final hasConversationTitle = displayConversationTitle != null || isLoadingConversation; final TextStyle modelTextStyle = hasConversationTitle ? AppTypography.small.copyWith( color: context.conduitTheme.textSecondary, fontWeight: FontWeight.w600, height: 1.2, ) : AppTypography.headlineSmallStyle.copyWith( color: context.conduitTheme.textPrimary, fontWeight: FontWeight.w600, fontSize: 18, height: 1.3, ); // Keyboard visibility - use viewInsetsOf for more efficient partial subscription final keyboardVisible = MediaQuery.viewInsetsOf(context).bottom > 0; // Whether the messages list can actually scroll (avoids showing button when not needed) final canScroll = _scrollController.hasClients && _scrollController.position.maxScrollExtent > 0; // Use dedicated streaming provider to avoid iterating all messages on rebuild final isStreamingAnyMessage = ref.watch(isChatStreamingProvider); // On keyboard open, if already near bottom, auto-scroll to bottom to keep input visible if (keyboardVisible && !_lastKeyboardVisible) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; final distanceFromBottom = _distanceFromBottom(); if (distanceFromBottom <= 300) { _scrollToBottom(smooth: true); } }); } _lastKeyboardVisible = keyboardVisible; // Auto-select model when in reviewer mode with no selection if (isReviewerMode && selectedModel == null) { WidgetsBinding.instance.addPostFrameCallback((_) { _checkAndAutoSelectModel(); }); } // Focus composer on app startup once (minimal delay for layout to settle) if (!_didStartupFocus) { _didStartupFocus = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; ref.read(inputFocusTriggerProvider.notifier).increment(); }); } return ErrorBoundary( child: PopScope( canPop: false, onPopInvokedWithResult: (bool didPop, Object? result) async { if (didPop) return; // First, if any input has focus, clear focus and consume back press final currentFocus = FocusManager.instance.primaryFocus; if (currentFocus != null && currentFocus.hasFocus) { currentFocus.unfocus(); return; } // Auto-handle leaving without confirmation final messages = ref.read(chatMessagesProvider); final isStreaming = messages.any((msg) => msg.isStreaming); if (isStreaming) { ref.read(chatMessagesProvider.notifier).finishStreaming(); } // Do not push conversation state back to server on exit. // Server already maintains chat state from message sends. // Keep any local persistence only. if (context.mounted) { final navigator = Navigator.of(context); if (navigator.canPop()) { navigator.pop(); } else { final shouldExit = await ThemedDialogs.confirm( context, title: l10n.appTitle, message: l10n.endYourSession, confirmText: l10n.confirm, cancelText: l10n.cancel, isDestructive: Platform.isAndroid, ); if (!shouldExit || !context.mounted) return; if (Platform.isAndroid) { SystemNavigator.pop(); } } } }, child: Builder( builder: (outerCtx) { final size = MediaQuery.of(outerCtx).size; final isTablet = size.shortestSide >= 600; final maxFraction = isTablet ? 0.42 : 0.84; final edgeFraction = isTablet ? 0.36 : 0.50; // large phone edge final scrim = Platform.isIOS ? context.colorTokens.scrimMedium : context.colorTokens.scrimStrong; return ResponsiveDrawerLayout( maxFraction: maxFraction, edgeFraction: edgeFraction, settleFraction: 0.06, // even gentler settle for instant open feel scrimColor: scrim, contentScaleDelta: 0.0, contentBlurSigma: 0.0, tabletDrawerWidth: 320.0, onOpenStart: () { // Suppress composer auto-focus once we unfocus for the drawer try { ref .read(composerAutofocusEnabledProvider.notifier) .set(false); } catch (_) {} }, drawer: Container( color: context.sidebarTheme.background, child: SafeArea( top: true, bottom: true, left: false, right: false, child: const ChatsDrawer(), ), ), child: Scaffold( backgroundColor: context.conduitTheme.surfaceBackground, // Replace Scaffold drawer with a tunable slide drawer for gentler snap behavior. drawerEnableOpenDragGesture: false, drawerDragStartBehavior: DragStartBehavior.down, extendBodyBehindAppBar: true, appBar: AppBar( backgroundColor: Colors.transparent, elevation: Elevation.none, surfaceTintColor: Colors.transparent, shadowColor: Colors.transparent, toolbarHeight: kToolbarHeight + 8, centerTitle: true, titleSpacing: 0.0, leadingWidth: 44 + Spacing.inputPadding + Spacing.xs, leading: _isSelectionMode ? Padding( padding: const EdgeInsets.only( left: Spacing.inputPadding, ), child: Center( child: GestureDetector( onTap: _clearSelection, child: _buildAppBarPill( context: context, isCircular: true, child: Icon( Platform.isIOS ? CupertinoIcons.xmark : Icons.close, color: context.conduitTheme.textPrimary, size: IconSize.appBar, ), ), ), ), ) : Builder( builder: (ctx) => Padding( padding: const EdgeInsets.only( left: Spacing.inputPadding, ), child: Center( child: GestureDetector( onTap: () { final layout = ResponsiveDrawerLayout.of(ctx); if (layout == null) return; final isDrawerOpen = layout.isOpen; if (!isDrawerOpen) { try { ref .read( composerAutofocusEnabledProvider .notifier, ) .set(false); FocusManager.instance.primaryFocus ?.unfocus(); SystemChannels.textInput.invokeMethod( 'TextInput.hide', ); } catch (_) {} } layout.toggle(); }, child: _buildAppBarPill( context: ctx, isCircular: true, child: Icon( Platform.isIOS ? CupertinoIcons.line_horizontal_3 : Icons.menu, color: context.conduitTheme.textPrimary, size: IconSize.appBar, ), ), ), ), ), ), title: _isSelectionMode ? _buildAppBarPill( context: context, child: Padding( padding: const EdgeInsets.symmetric( horizontal: Spacing.md, vertical: Spacing.sm, ), child: Text( '${_selectedMessageIds.length} selected', style: AppTypography.headlineSmallStyle.copyWith( color: context.conduitTheme.textPrimary, fontWeight: FontWeight.w500, ), ), ), ) : LayoutBuilder( builder: (context, constraints) { // Build title pill (tappable for context menu) // Show skeleton when loading, actual title otherwise Widget? titlePill; if (isLoadingConversation) { // Show skeleton pill while loading conversation titlePill = _buildAppBarPill( context: context, child: Padding( padding: const EdgeInsets.symmetric( horizontal: Spacing.md, vertical: Spacing.xs, ), child: ConduitLoading.skeleton( width: 120, height: 18, borderRadius: BorderRadius.circular( AppBorderRadius.sm, ), ), ), ); } else if (displayConversationTitle != null) { final conversation = ref.read( activeConversationProvider, ); titlePill = ConduitContextMenu( actions: buildConversationActions( context: context, ref: ref, conversation: conversation, ), child: _buildAppBarPill( context: context, child: Padding( padding: const EdgeInsets.symmetric( horizontal: Spacing.md, vertical: Spacing.xs, ), child: ConstrainedBox( constraints: BoxConstraints( maxWidth: constraints.maxWidth - Spacing.xxxl, ), child: StreamingTitleText( title: displayConversationTitle, style: AppTypography.headlineSmallStyle .copyWith( color: context .conduitTheme .textPrimary, fontWeight: FontWeight.w600, fontSize: 16, height: 1.3, ), cursorColor: context .conduitTheme .textPrimary .withValues(alpha: 0.8), ), ), ), ), ); } // Build model selector pill // Show skeleton when loading, actual model selector otherwise final Widget modelPill; if (isLoadingConversation) { // Show skeleton pill while loading conversation modelPill = _buildAppBarPill( context: context, child: Padding( padding: const EdgeInsets.symmetric( horizontal: Spacing.sm, vertical: Spacing.xs, ), child: ConduitLoading.skeleton( width: 80, height: 14, borderRadius: BorderRadius.circular( AppBorderRadius.sm, ), ), ), ); } else { modelPill = GestureDetector( onTap: () async { final modelsAsync = ref.read(modelsProvider); if (modelsAsync.isLoading) { try { final models = await ref.read( modelsProvider.future, ); if (!mounted) return; // ignore: use_build_context_synchronously _showModelDropdown(context, ref, models); } catch (e) { DebugLogger.error( 'model-load-failed', scope: 'chat/model-selector', error: e, ); } } else if (modelsAsync.hasValue) { _showModelDropdown( context, ref, modelsAsync.value!, ); } else if (modelsAsync.hasError) { try { ref.invalidate(modelsProvider); final models = await ref.read( modelsProvider.future, ); if (!mounted) return; // ignore: use_build_context_synchronously _showModelDropdown(context, ref, models); } catch (e) { DebugLogger.error( 'model-refresh-failed', scope: 'chat/model-selector', error: e, ); } } }, child: _buildAppBarPill( context: context, child: Padding( padding: const EdgeInsets.symmetric( horizontal: Spacing.sm, vertical: Spacing.xs, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ ConstrainedBox( constraints: BoxConstraints( maxWidth: constraints.maxWidth - Spacing.xxl, ), child: MiddleEllipsisText( modelLabel, style: modelTextStyle, textAlign: TextAlign.center, semanticsLabel: modelLabel, ), ), const SizedBox(width: Spacing.xs), Icon( Platform.isIOS ? CupertinoIcons.chevron_down : Icons.keyboard_arrow_down, color: context .conduitTheme .iconSecondary, size: IconSize.small, ), ], ), ), ), ); } return Column( mainAxisSize: MainAxisSize.min, children: [ if (titlePill != null) ...[ AnimatedSwitcher( duration: const Duration(milliseconds: 200), switchInCurve: Curves.easeOut, switchOutCurve: Curves.easeIn, child: KeyedSubtree( key: ValueKey( isLoadingConversation ? 'loading' : 'title-$displayConversationTitle', ), child: titlePill, ), ), const SizedBox(height: Spacing.xs), ], AnimatedSwitcher( duration: const Duration(milliseconds: 200), switchInCurve: Curves.easeOut, switchOutCurve: Curves.easeIn, child: KeyedSubtree( key: ValueKey( isLoadingConversation ? 'model-loading' : 'model-$modelLabel', ), child: modelPill, ), ), if (isReviewerMode) Padding( padding: const EdgeInsets.only( top: Spacing.xs, ), child: Container( padding: const EdgeInsets.symmetric( horizontal: Spacing.sm, vertical: 1.0, ), decoration: BoxDecoration( color: context.conduitTheme.success .withValues(alpha: 0.1), borderRadius: BorderRadius.circular( AppBorderRadius.badge, ), border: Border.all( color: context.conduitTheme.success .withValues(alpha: 0.3), width: BorderWidth.thin, ), ), child: Text( 'REVIEWER MODE', style: AppTypography.captionStyle .copyWith( color: context.conduitTheme.success, fontWeight: FontWeight.w600, fontSize: 9, ), ), ), ), ], ); }, ), actions: [ if (!_isSelectionMode) ...[ Padding( padding: const EdgeInsets.only( right: Spacing.inputPadding, ), child: Tooltip( message: AppLocalizations.of(context)!.newChat, child: GestureDetector( onTap: _handleNewChat, child: _buildAppBarPill( context: context, isCircular: true, child: Icon( Platform.isIOS ? CupertinoIcons.create : Icons.add_comment, color: context.conduitTheme.textPrimary, size: IconSize.appBar, ), ), ), ), ), ] else ...[ Padding( padding: const EdgeInsets.only( right: Spacing.inputPadding, ), child: GestureDetector( onTap: _deleteSelectedMessages, child: _buildAppBarPill( context: context, isCircular: true, child: Icon( Platform.isIOS ? CupertinoIcons.delete : Icons.delete, color: context.conduitTheme.error, size: IconSize.appBar, ), ), ), ), ], ], ), body: GestureDetector( behavior: HitTestBehavior.translucent, onTap: () { FocusManager.instance.primaryFocus?.unfocus(); try { SystemChannels.textInput.invokeMethod('TextInput.hide'); } catch (_) {} }, child: Stack( children: [ // Messages Area fills entire space with pull-to-refresh Positioned.fill( child: ConduitRefreshIndicator( // Position indicator below the floating app bar edgeOffset: MediaQuery.of(context).padding.top + kToolbarHeight, onRefresh: () async { // Reload active conversation messages from server final api = ref.read(apiServiceProvider); final active = ref.read(activeConversationProvider); if (api != null && active != null) { try { final full = await api.getConversation( active.id, ); ref .read(activeConversationProvider.notifier) .set(full); } catch (e) { DebugLogger.log( 'Failed to refresh conversation: $e', scope: 'chat/page', ); } } // Also refresh the conversations list to reconcile missed events // and keep timestamps/order in sync with the server. try { refreshConversationsCache(ref); // Best-effort await to stabilize UI; ignore errors. await ref.read(conversationsProvider.future); } catch (_) {} // Add small delay for better UX feedback await Future.delayed( const Duration(milliseconds: 300), ); }, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { FocusManager.instance.primaryFocus?.unfocus(); try { SystemChannels.textInput.invokeMethod( 'TextInput.hide', ); } catch (_) {} }, child: RepaintBoundary( child: _buildMessagesList(theme), ), ), ), ), // Floating input area with attachments and blur background Positioned( left: 0, right: 0, bottom: 0, child: RepaintBoundary( child: MeasureSize( onChange: (size) { if (mounted) { setState(() { _inputHeight = size.height; }); } }, child: Container( decoration: BoxDecoration( // Gradient fade from transparent to solid background gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, stops: const [0.0, 0.4, 1.0], colors: [ theme.scaffoldBackgroundColor.withValues( alpha: 0.0, ), theme.scaffoldBackgroundColor.withValues( alpha: 0.85, ), theme.scaffoldBackgroundColor, ], ), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ // Top padding for gradient fade area const SizedBox(height: Spacing.xl), // File attachments const FileAttachmentWidget(), const ContextAttachmentWidget(), // Modern Input ModernChatInput( onSendMessage: (text) => _handleMessageSend(text, selectedModel), onVoiceInput: null, onVoiceCall: _handleVoiceCall, onFileAttachment: _handleFileAttachment, onImageAttachment: _handleImageAttachment, onCameraCapture: () => _handleImageAttachment( fromCamera: true, ), onWebAttachment: _promptAttachWebpage, onPastedAttachments: _handlePastedAttachments, ), ], ), ), ), ), ), // Floating app bar gradient overlay Positioned( top: 0, left: 0, right: 0, child: IgnorePointer( child: Container( height: MediaQuery.of(context).padding.top + kToolbarHeight + Spacing.xl, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, stops: const [0.0, 0.4, 1.0], colors: [ theme.scaffoldBackgroundColor, theme.scaffoldBackgroundColor.withValues( alpha: 0.85, ), theme.scaffoldBackgroundColor.withValues( alpha: 0.0, ), ], ), ), ), ), ), // Floating Scroll to Bottom Button with smooth appear/disappear Positioned( bottom: (_inputHeight > 0) ? _inputHeight : (Spacing.xxl + Spacing.xxxl), left: 0, right: 0, child: AnimatedSwitcher( duration: AnimationDuration.microInteraction, switchInCurve: AnimationCurves.microInteraction, switchOutCurve: AnimationCurves.microInteraction, transitionBuilder: (child, animation) { final slideAnimation = Tween( begin: const Offset(0, 0.15), end: Offset.zero, ).animate(animation); return FadeTransition( opacity: animation, child: SlideTransition( position: slideAnimation, child: child, ), ); }, child: (_showScrollToBottom && !keyboardVisible && canScroll && ref.watch(chatMessagesProvider).isNotEmpty) ? Center( key: const ValueKey( 'scroll_to_bottom_visible', ), child: ClipRRect( borderRadius: BorderRadius.circular( AppBorderRadius.floatingButton, ), child: BackdropFilter( filter: ImageFilter.blur( sigmaX: 16, sigmaY: 16, ), child: Container( decoration: BoxDecoration( // Use same high-contrast colors as floating input color: theme.brightness == Brightness.dark ? Color.lerp( context .conduitTheme .cardBackground, Colors.white, 0.08, )!.withValues(alpha: 0.85) : Color.lerp( context .conduitTheme .inputBackground, Colors.black, 0.06, )!.withValues(alpha: 0.85), border: Border.all( color: context .conduitTheme .cardBorder .withValues(alpha: 0.55), width: BorderWidth.thin, ), borderRadius: BorderRadius.circular( AppBorderRadius.floatingButton, ), boxShadow: ConduitShadows.button( context, ), ), child: SizedBox( width: TouchTarget.button, height: TouchTarget.button, child: IconButton( onPressed: _scrollToBottom, splashRadius: 24, tooltip: _userPausedAutoScroll && isStreamingAnyMessage ? 'Resume auto-scroll' : 'Scroll to bottom', icon: Icon( // Show play icon when auto-scroll // is paused during streaming _userPausedAutoScroll && isStreamingAnyMessage ? (Platform.isIOS ? CupertinoIcons .play_arrow_solid : Icons.play_arrow) : (Platform.isIOS ? CupertinoIcons .arrow_down : Icons .keyboard_arrow_down), size: IconSize.lg, color: context .conduitTheme .iconPrimary .withValues(alpha: 0.9), ), ), ), ), ), ), ) : const SizedBox.shrink( key: ValueKey('scroll_to_bottom_hidden'), ), ), ), // Edge overlay removed; rely on native interactive drawer drag ], ), ), ), // Scaffold inside ResponsiveDrawerLayout ); }, ), ), // PopScope ); // ErrorBoundary } // Removed legacy save-before-leave hook; server manages chat state via background pipeline. void _showModelDropdown( BuildContext context, WidgetRef ref, List models, ) { // Ensure keyboard is closed before presenting modal final hadFocus = ref.read(composerHasFocusProvider); try { FocusManager.instance.primaryFocus?.unfocus(); SystemChannels.textInput.invokeMethod('TextInput.hide'); } catch (_) {} showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (context) => _ModelSelectorSheet(models: models, ref: ref), ).whenComplete(() { if (!mounted) return; if (hadFocus) { // Bump focus trigger to restore composer focus + IME final cur = ref.read(inputFocusTriggerProvider); ref.read(inputFocusTriggerProvider.notifier).set(cur + 1); } }); } void _deleteSelectedMessages() { final selectedMessages = _getSelectedMessages(); if (selectedMessages.isEmpty) return; final l10n = AppLocalizations.of(context)!; ThemedDialogs.confirm( context, title: l10n.deleteMessagesTitle, message: l10n.deleteMessagesMessage(selectedMessages.length), confirmText: l10n.delete, cancelText: l10n.cancel, isDestructive: true, ).then((confirmed) async { if (confirmed == true) { _clearSelection(); } }); } } class _ModelSelectorSheet extends ConsumerStatefulWidget { final List models; final WidgetRef ref; const _ModelSelectorSheet({required this.models, required this.ref}); @override ConsumerState<_ModelSelectorSheet> createState() => _ModelSelectorSheetState(); } class _ModelSelectorSheetState extends ConsumerState<_ModelSelectorSheet> { final TextEditingController _searchController = TextEditingController(); String _searchQuery = ''; List _filteredModels = []; Timer? _searchDebounce; // No capability filters // Grid view removed Widget _capabilityChip({required IconData icon, required String label}) { return Container( margin: const EdgeInsets.only(right: Spacing.xs), padding: const EdgeInsets.symmetric(horizontal: Spacing.xs, vertical: 2), decoration: BoxDecoration( color: context.conduitTheme.buttonPrimary.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(AppBorderRadius.chip), border: Border.all( color: context.conduitTheme.buttonPrimary.withValues(alpha: 0.3), width: BorderWidth.thin, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 12, color: context.conduitTheme.buttonPrimary), const SizedBox(width: 4), Text( label, style: TextStyle( fontSize: AppTypography.labelSmall, color: context.conduitTheme.textSecondary, fontWeight: FontWeight.w500, ), ), ], ), ); } // Removed filter toggle UI and logic @override void initState() { super.initState(); _filteredModels = widget.models; } @override void dispose() { _searchController.dispose(); _searchDebounce?.cancel(); super.dispose(); } void _filterModels(String query) { setState(() => _searchQuery = query); _searchDebounce?.cancel(); _searchDebounce = Timer(const Duration(milliseconds: 160), () { if (!mounted) return; final normalized = query.trim().toLowerCase(); Iterable list = widget.models; if (normalized.isNotEmpty) { list = list.where((model) { final name = model.name.toLowerCase(); final id = model.id.toLowerCase(); return name.contains(normalized) || id.contains(normalized); }); } setState(() { _filteredModels = list.toList(); }); }); } @override Widget build(BuildContext context) { return Stack( children: [ Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => Navigator.of(context).maybePop(), child: const SizedBox.shrink(), ), ), DraggableScrollableSheet( expand: false, initialChildSize: 0.75, maxChildSize: 0.92, minChildSize: 0.45, builder: (context, scrollController) { return Container( decoration: BoxDecoration( color: context.conduitTheme.surfaceBackground, borderRadius: const BorderRadius.vertical( top: Radius.circular(AppBorderRadius.bottomSheet), ), border: Border.all( color: context.conduitTheme.dividerColor, width: BorderWidth.regular, ), boxShadow: ConduitShadows.modal(context), ), child: ModalSheetSafeArea( padding: const EdgeInsets.symmetric( horizontal: Spacing.modalPadding, vertical: Spacing.modalPadding, ), child: Column( children: [ // Handle bar (standardized) const SheetHandle(), // Search field Padding( padding: const EdgeInsets.only(bottom: Spacing.md), child: TextField( controller: _searchController, style: AppTypography.standard.copyWith( color: context.conduitTheme.textPrimary, ), onChanged: _filterModels, decoration: InputDecoration( isDense: true, hintText: AppLocalizations.of(context)!.searchModels, hintStyle: AppTypography.standard.copyWith( color: context.conduitTheme.inputPlaceholder, ), prefixIcon: Icon( Platform.isIOS ? CupertinoIcons.search : Icons.search, color: context.conduitTheme.iconSecondary, size: IconSize.input, ), prefixIconConstraints: const BoxConstraints( minWidth: TouchTarget.minimum, minHeight: TouchTarget.minimum, ), suffixIcon: _searchQuery.isNotEmpty ? IconButton( onPressed: () { _searchController.clear(); _filterModels(''); }, icon: Icon( Platform.isIOS ? CupertinoIcons.clear_circled_solid : Icons.clear, color: context.conduitTheme.iconSecondary, size: IconSize.input, ), ) : null, suffixIconConstraints: const BoxConstraints( minWidth: TouchTarget.minimum, minHeight: TouchTarget.minimum, ), filled: true, fillColor: context.conduitTheme.inputBackground, border: OutlineInputBorder( borderRadius: BorderRadius.circular( AppBorderRadius.md, ), borderSide: BorderSide.none, ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular( AppBorderRadius.md, ), borderSide: BorderSide( color: context.conduitTheme.inputBorder, width: 1, ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular( AppBorderRadius.md, ), borderSide: BorderSide( color: context.conduitTheme.buttonPrimary, width: 1, ), ), contentPadding: const EdgeInsets.symmetric( horizontal: Spacing.md, vertical: Spacing.xs, ), ), ), ), // Removed capability filters const SizedBox(height: Spacing.sm), // Models list Expanded( child: Scrollbar( controller: scrollController, child: _filteredModels.isEmpty ? Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Platform.isIOS ? CupertinoIcons.search_circle : Icons.search_off, size: 48, color: context.conduitTheme.iconSecondary, ), const SizedBox(height: Spacing.md), Text( 'No results', style: TextStyle( color: context.conduitTheme.textSecondary, fontSize: AppTypography.bodyLarge, ), ), ], ), ) : ListView.builder( controller: scrollController, padding: EdgeInsets.zero, cacheExtent: 400, itemCount: _filteredModels.length, itemBuilder: (context, index) { final model = _filteredModels[index]; final isSelected = widget.ref .watch(selectedModelProvider) ?.id == model.id; return _buildModelListTile( model: model, isSelected: isSelected, onTap: () { HapticFeedback.selectionClick(); widget.ref .read(selectedModelProvider.notifier) .set(model); Navigator.pop(context); }, ); }, ), ), ), ], ), ), ); }, ), ], ); } // Layout toggle removed // Removed grid card renderer (grid view removed) bool _modelSupportsReasoning(Model model) { // Only rely on supported_parameters containing 'reasoning' final params = model.supportedParameters ?? const []; return params.any((p) => p.toLowerCase().contains('reasoning')); } // Removed: _capabilityBadge no longer used // Removed: _capabilityPlusBadge no longer used Widget _buildModelListTile({ required Model model, required bool isSelected, required VoidCallback onTap, }) { final api = ref.watch(apiServiceProvider); final iconUrl = resolveModelIconUrlForModel(api, model); return PressableScale( onTap: onTap, borderRadius: BorderRadius.circular(AppBorderRadius.small), child: Container( margin: const EdgeInsets.only(bottom: Spacing.sm), decoration: BoxDecoration( color: isSelected ? context.conduitTheme.buttonPrimary.withValues(alpha: 0.1) : context.conduitTheme.surfaceBackground.withValues(alpha: 0.05), borderRadius: BorderRadius.circular(AppBorderRadius.small), border: Border.all( color: isSelected ? context.conduitTheme.buttonPrimary.withValues(alpha: 0.3) : context.conduitTheme.dividerColor.withValues(alpha: 0.5), width: BorderWidth.standard, ), ), child: Padding( padding: const EdgeInsets.all(Spacing.sm), child: Row( children: [ ModelAvatar(size: 32, imageUrl: iconUrl, label: model.name), const SizedBox(width: Spacing.sm), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( model.name, style: TextStyle( color: context.conduitTheme.textPrimary, fontWeight: FontWeight.w600, fontSize: AppTypography.bodyMedium, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), if (model.isMultimodal || _modelSupportsReasoning(model)) ...[ const SizedBox(height: Spacing.xs), Row( children: [ if (model.isMultimodal) _capabilityChip( icon: Platform.isIOS ? CupertinoIcons.photo : Icons.image, label: AppLocalizations.of( context, )!.modelCapabilityMultimodal, ), if (_modelSupportsReasoning(model)) _capabilityChip( icon: Platform.isIOS ? CupertinoIcons.lightbulb : Icons.psychology_alt, label: AppLocalizations.of( context, )!.modelCapabilityReasoning, ), ], ), ], ], ), ), const SizedBox(width: Spacing.sm), if (isSelected) Icon( Platform.isIOS ? CupertinoIcons.check_mark : Icons.check, color: context.conduitTheme.buttonPrimary, size: IconSize.small, ), ], ), ), ), ); } // Intentionally left blank placeholder for nested helper; moved to top-level below } // Removed custom edge gesture in favor of native Drawer drag behavior. class _VoiceInputSheet extends ConsumerStatefulWidget { final Function(String) onTextReceived; const _VoiceInputSheet({required this.onTextReceived}); @override ConsumerState<_VoiceInputSheet> createState() => _VoiceInputSheetState(); } class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> { bool _isListening = false; String _recognizedText = ''; late VoiceInputService _voiceService; StreamSubscription? _intensitySub; int _intensity = 0; StreamSubscription? _textSub; int _elapsedSeconds = 0; Timer? _elapsedTimer; // Removed server transcription; keep only on-device listening state String _languageTag = 'en'; bool _holdToTalk = false; bool _autoSendFinal = false; @override void initState() { super.initState(); _voiceService = ref.read(voiceInputServiceProvider); try { final preset = _voiceService.selectedLocaleId; if (preset != null && preset.isNotEmpty) { _languageTag = preset.split(RegExp('[-_]')).first.toLowerCase(); } else { _languageTag = WidgetsBinding.instance.platformDispatcher.locale .toLanguageTag() .split(RegExp('[-_]')) .first .toLowerCase(); } } catch (_) { _languageTag = 'en'; } // Load voice settings from app settings final settings = ref.read(appSettingsProvider); _holdToTalk = settings.voiceHoldToTalk; _autoSendFinal = settings.voiceAutoSendFinal; if (settings.voiceLocaleId != null && settings.voiceLocaleId!.isNotEmpty) { _voiceService.setLocale(settings.voiceLocaleId); _languageTag = settings.voiceLocaleId! .split(RegExp('[-_]')) .first .toLowerCase(); } } void _startListening() async { setState(() { _isListening = true; _recognizedText = ''; _elapsedSeconds = 0; }); // Haptic: indicate start listening final hapticEnabled = ref.read(hapticEnabledProvider); ps.PlatformService.hapticFeedbackWithSettings( type: ps.HapticType.medium, hapticEnabled: hapticEnabled, ); try { // Ensure service is initialized final ok = await _voiceService.initialize(); if (!ok) { throw Exception('Voice service unavailable'); } // Start elapsed timer for UX _elapsedTimer?.cancel(); _elapsedTimer = Timer.periodic(const Duration(seconds: 1), (t) { if (!mounted || !_isListening) { t.cancel(); return; } setState(() => _elapsedSeconds += 1); }); // Centralized permission + start final stream = await _voiceService.beginListening(); _intensitySub = _voiceService.intensityStream.listen((value) { if (!mounted) return; setState(() => _intensity = value); }); _textSub = stream.listen( (text) { setState(() { _recognizedText = text; }); }, onDone: () { DebugLogger.log('VoiceInputSheet stream done', scope: 'chat/page'); setState(() { _isListening = false; }); _elapsedTimer?.cancel(); // Auto-send on final local result if enabled if (_autoSendFinal && _recognizedText.trim().isNotEmpty) { _sendText(); } }, onError: (error) { DebugLogger.log( 'VoiceInputSheet stream error: $error', scope: 'chat/page', ); setState(() { _isListening = false; }); _elapsedTimer?.cancel(); if (mounted) { final hapticEnabled = ref.read(hapticEnabledProvider); ps.PlatformService.hapticFeedbackWithSettings( type: ps.HapticType.warning, hapticEnabled: hapticEnabled, ); } }, ); } catch (e) { setState(() { _isListening = false; }); } } // When on-device STT is unavailable we fall back to server transcription. Future _stopListening() async { _intensitySub?.cancel(); _intensitySub = null; // Keep text subscription active to receive final audio path emission await _voiceService.stopListening(); _elapsedTimer?.cancel(); if (mounted) { setState(() { _isListening = false; }); } // Haptic: subtle stop confirmation final hapticEnabled = ref.read(hapticEnabledProvider); ps.PlatformService.hapticFeedbackWithSettings( type: ps.HapticType.selection, hapticEnabled: hapticEnabled, ); } void _sendText() { if (_recognizedText.isNotEmpty) { // Haptic: success send final hapticEnabled = ref.read(hapticEnabledProvider); ps.PlatformService.hapticFeedbackWithSettings( type: ps.HapticType.success, hapticEnabled: hapticEnabled, ); widget.onTextReceived(_recognizedText); Navigator.pop(context); } } String _formatSeconds(int seconds) { final m = (seconds ~/ 60).toString().padLeft(1, '0'); final s = (seconds % 60).toString().padLeft(2, '0'); return '$m:$s'; } void _pickLanguage() async { // Only for local STT if (!_voiceService.hasLocalStt) return; final locales = _voiceService.locales; if (locales.isEmpty) return; if (!mounted) return; final selected = await showModalBottomSheet( context: context, backgroundColor: Colors.transparent, builder: (context) { final l10n = AppLocalizations.of(context)!; return Container( decoration: BoxDecoration( color: context.conduitTheme.surfaceBackground, borderRadius: const BorderRadius.vertical( top: Radius.circular(AppBorderRadius.bottomSheet), ), border: Border.all( color: context.conduitTheme.dividerColor, width: BorderWidth.regular, ), boxShadow: ConduitShadows.modal(context), ), padding: const EdgeInsets.all(Spacing.bottomSheetPadding), child: SafeArea( top: false, child: Column( mainAxisSize: MainAxisSize.min, children: [ const SheetHandle(), const SizedBox(height: Spacing.md), Text( l10n.selectLanguage, style: TextStyle( fontSize: AppTypography.headlineSmall, color: context.conduitTheme.textPrimary, fontWeight: FontWeight.w600, ), ), const SizedBox(height: Spacing.sm), Flexible( child: ListView.separated( shrinkWrap: true, itemCount: locales.length, separatorBuilder: (_, sep) => Divider( height: 1, color: context.conduitTheme.dividerColor, ), itemBuilder: (ctx, i) { final l = locales[i]; final isSelected = l.localeId == _voiceService.selectedLocaleId; return ListTile( title: Text( l.name, style: TextStyle( color: context.conduitTheme.textPrimary, ), ), subtitle: Text( l.localeId, style: TextStyle( color: context.conduitTheme.textSecondary, ), ), trailing: isSelected ? Icon( Icons.check, color: context.conduitTheme.buttonPrimary, ) : null, onTap: () => Navigator.pop(ctx, l.localeId), ); }, ), ), ], ), ), ); }, ); if (selected != null && mounted) { setState(() { _voiceService.setLocale(selected); _languageTag = selected.split(RegExp('[-_]')).first.toLowerCase(); }); // Persist preferred locale await ref.read(appSettingsProvider.notifier).setVoiceLocaleId(selected); if (_isListening) { // Restart listening to apply new language await _voiceService.stopListening(); _startListening(); } } } Widget _buildThemedSwitch({ required bool value, required ValueChanged onChanged, }) { final theme = context.conduitTheme; return ps.PlatformService.getPlatformSwitch( value: value, onChanged: onChanged, activeColor: theme.buttonPrimary, ); } @override void dispose() { _intensitySub?.cancel(); _textSub?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { final media = MediaQuery.of(context); final isCompact = media.size.height < 680; final l10n = AppLocalizations.of(context)!; final statusText = _isListening ? (_voiceService.hasLocalStt ? l10n.voiceStatusListening : l10n.voiceStatusRecording) : l10n.voice; return Container( height: media.size.height * (isCompact ? 0.45 : 0.6), decoration: BoxDecoration( color: context.conduitTheme.surfaceBackground, borderRadius: const BorderRadius.vertical( top: Radius.circular(AppBorderRadius.bottomSheet), ), border: Border.all(color: context.conduitTheme.dividerColor, width: 1), boxShadow: ConduitShadows.modal(context), ), child: SafeArea( top: false, bottom: true, child: Padding( padding: const EdgeInsets.all(Spacing.bottomSheetPadding), child: Column( children: [ // Handle bar const SheetHandle(), // Header: Title + timer + language chip Padding( padding: const EdgeInsets.only( top: Spacing.md, bottom: Spacing.md, ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( statusText, style: TextStyle( fontSize: AppTypography.headlineMedium, fontWeight: FontWeight.w600, color: context.conduitTheme.textPrimary, ), ), Row( children: [ // Language chip GestureDetector( onTap: _voiceService.hasLocalStt ? _pickLanguage : null, child: Container( padding: const EdgeInsets.symmetric( horizontal: Spacing.xs, vertical: 4, ), decoration: BoxDecoration( color: context.conduitTheme.surfaceBackground .withValues(alpha: 0.4), borderRadius: BorderRadius.circular( AppBorderRadius.badge, ), border: Border.all( color: context.conduitTheme.dividerColor, width: BorderWidth.thin, ), ), child: Row( children: [ Text( _languageTag.toUpperCase(), style: TextStyle( fontSize: AppTypography.labelSmall, color: context.conduitTheme.textSecondary, fontWeight: FontWeight.w600, ), ), if (_voiceService.hasLocalStt) ...[ const SizedBox(width: 4), Icon( Icons.arrow_drop_down, size: 16, color: context.conduitTheme.iconSecondary, ), ], ], ), ), ), const SizedBox(width: Spacing.sm), // Timer AnimatedOpacity( opacity: _isListening ? 1 : 0.6, duration: AnimationDuration.fast, child: Text( _formatSeconds(_elapsedSeconds), style: TextStyle( color: context.conduitTheme.textSecondary, fontWeight: FontWeight.w600, ), ), ), const SizedBox(width: Spacing.sm), // Close sheet ConduitIconButton( icon: Platform.isIOS ? CupertinoIcons.xmark : Icons.close, tooltip: AppLocalizations.of( context, )!.closeButtonSemantic, isCompact: true, onPressed: () => Navigator.of(context).pop(), ), ], ), ], ), ), // Toggles row: Hold to talk, Auto-send Padding( padding: const EdgeInsets.only(bottom: Spacing.sm), child: Row( children: [ Expanded( child: Row( mainAxisSize: MainAxisSize.min, children: [ _buildThemedSwitch( value: _holdToTalk, onChanged: (v) async { setState(() => _holdToTalk = v); await ref .read(appSettingsProvider.notifier) .setVoiceHoldToTalk(v); }, ), const SizedBox(width: Spacing.xs), Text( l10n.voiceHoldToTalk, style: TextStyle( color: context.conduitTheme.textSecondary, ), ), ], ), ), Expanded( child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ _buildThemedSwitch( value: _autoSendFinal, onChanged: (v) async { setState(() => _autoSendFinal = v); await ref .read(appSettingsProvider.notifier) .setVoiceAutoSendFinal(v); }, ), const SizedBox(width: Spacing.xs), Text( l10n.voiceAutoSend, style: TextStyle( color: context.conduitTheme.textSecondary, ), ), ], ), ), ], ), ), // Microphone + waveform Expanded( child: LayoutBuilder( builder: (context, viewport) { final isUltra = media.size.height < 560; final double micSize = isUltra ? 64 : (isCompact ? 80 : 100); final double micIconSize = isUltra ? 26 : (isCompact ? 32 : 40); // Extra top padding so scale animation (up to 1.2x) never clips final double topPaddingForScale = ((micSize * 1.2) - micSize) / 2 + 8; final content = Center( child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ // Top spacer (baseline); additional padding handled by scroll view SizedBox(height: isUltra ? Spacing.sm : Spacing.md), // Microphone control GestureDetector( onTapDown: _holdToTalk ? (_) { if (!_isListening) _startListening(); } : null, onTapUp: _holdToTalk ? (_) { if (_isListening) _stopListening(); } : null, onTapCancel: _holdToTalk ? () { if (_isListening) _stopListening(); } : null, onTap: () => _holdToTalk ? null : (_isListening ? _stopListening() : _startListening()), child: Container( width: micSize, height: micSize, decoration: BoxDecoration( color: _isListening ? context.conduitTheme.error.withValues( alpha: 0.2, ) : context.conduitTheme.surfaceBackground .withValues(alpha: Alpha.subtle), shape: BoxShape.circle, border: Border.all( color: _isListening ? context.conduitTheme.error .withValues(alpha: 0.5) : context.conduitTheme.dividerColor, width: 2, ), ), child: Icon( _isListening ? (Platform.isIOS ? CupertinoIcons.mic_fill : Icons.mic) : (Platform.isIOS ? CupertinoIcons.mic_off : Icons.mic_off), size: micIconSize, color: _isListening ? context.conduitTheme.error : context.conduitTheme.iconSecondary, ), ), ) .animate( onPlay: (controller) => _isListening ? controller.repeat() : null, ) .scale( duration: const Duration(milliseconds: 1000), begin: const Offset(1, 1), end: const Offset(1.2, 1.2), ) .then() .scale( duration: const Duration(milliseconds: 1000), begin: const Offset(1.2, 1.2), end: const Offset(1, 1), ), SizedBox( height: isUltra ? Spacing.xs : (isCompact ? Spacing.sm : Spacing.md), ), // Simple animated bars waveform based on intensity proxy SizedBox( height: isUltra ? 18 : (isCompact ? 24 : 32), child: AnimatedSwitcher( duration: const Duration(milliseconds: 150), child: Row( key: ValueKey(_intensity), mainAxisAlignment: MainAxisAlignment.center, children: List.generate(isUltra ? 10 : 12, (i) { final normalized = ((_intensity + i) % 10) / 10.0; final base = isUltra ? 4 : (isCompact ? 6 : 8); final range = isUltra ? 14 : (isCompact ? 18 : 24); final barHeight = base + (normalized * range); return Container( width: isUltra ? 2.5 : (isCompact ? 3 : 4), height: barHeight, margin: EdgeInsets.symmetric( horizontal: isUltra ? 1 : (isCompact ? 1.5 : 2), ), decoration: BoxDecoration( color: context.conduitTheme.buttonPrimary .withValues(alpha: 0.7), borderRadius: BorderRadius.circular(2), ), ); }), ), ), ), SizedBox( height: isUltra ? Spacing.sm : (isCompact ? Spacing.md : Spacing.xl), ), // Recognized text / Transcribing state with Clear action ConstrainedBox( constraints: BoxConstraints( maxHeight: media.size.height * (isUltra ? 0.13 : (isCompact ? 0.16 : 0.2)), minHeight: isUltra ? 56 : (isCompact ? 64 : 80), ), child: ConduitCard( isCompact: isCompact, padding: EdgeInsets.all( isCompact ? Spacing.md : Spacing.md, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Inline clear action aligned to the end Row( children: [ Text( l10n.voiceTranscript, style: TextStyle( fontSize: AppTypography.labelSmall, fontWeight: FontWeight.w600, color: context .conduitTheme .textSecondary, ), ), const Spacer(), ConduitIconButton( icon: Icons.close, isCompact: true, tooltip: AppLocalizations.of( context, )!.clear, onPressed: _recognizedText.isNotEmpty ? () { setState( () => _recognizedText = '', ); } : null, ), ], ), const SizedBox(height: Spacing.xs), Flexible( child: SingleChildScrollView( child: Text( _recognizedText.isEmpty ? (_isListening ? (_voiceService.hasLocalStt ? l10n.voicePromptSpeakNow : l10n.voiceStatusRecording) : l10n.voicePromptTapStart) : _recognizedText, style: TextStyle( fontSize: isUltra ? AppTypography.bodySmall : (isCompact ? AppTypography.bodyMedium : AppTypography.bodyLarge), color: _recognizedText.isEmpty ? context .conduitTheme .inputPlaceholder : context .conduitTheme .textPrimary, height: 1.4, ), textAlign: TextAlign.center, ), ), ), ], ), ), ), ], ), ); // Make scrollable if content exceeds available height return SingleChildScrollView( physics: const ClampingScrollPhysics(), padding: EdgeInsets.only(top: topPaddingForScale), child: ConstrainedBox( constraints: BoxConstraints( minHeight: viewport.maxHeight, ), child: content, ), ); }, ), ), // Action buttons Builder( builder: (context) { final showStartStop = !_holdToTalk; final showSend = !_autoSendFinal; if (!showStartStop && !showSend) { return const SizedBox.shrink(); } return Padding( padding: EdgeInsets.only( top: isCompact ? Spacing.sm : Spacing.md, ), child: Row( children: [ if (showStartStop) ...[ Expanded( child: ConduitButton( text: _isListening ? l10n.voiceActionStop : l10n.voiceActionStart, isSecondary: true, isCompact: isCompact, onPressed: _isListening ? _stopListening : _startListening, ), ), ], if (showStartStop && showSend) const SizedBox(width: Spacing.xs), if (showSend) ...[ Expanded( child: ConduitButton( text: l10n.send, isCompact: isCompact, onPressed: _recognizedText.isNotEmpty ? _sendText : null, ), ), ], ], ), ); }, ), ], ), ), ), ); } } /// Wrapper widget for selectable messages with visual selection indicators class _SelectableMessageWrapper extends StatelessWidget { final bool isSelected; final VoidCallback onTap; final VoidCallback? onLongPress; final Widget child; const _SelectableMessageWrapper({ required this.isSelected, required this.onTap, this.onLongPress, required this.child, }); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, onLongPress: onLongPress, child: Container( margin: const EdgeInsets.symmetric(vertical: Spacing.xs), decoration: BoxDecoration( color: isSelected ? context.conduitTheme.buttonPrimary.withValues(alpha: 0.1) : Colors.transparent, borderRadius: BorderRadius.circular(AppBorderRadius.md), border: isSelected ? Border.all( color: context.conduitTheme.buttonPrimary.withValues( alpha: 0.3, ), width: 2, ) : null, ), child: Stack( children: [ child, if (isSelected) Positioned( top: Spacing.sm, right: Spacing.sm, child: Container( width: 24, height: 24, decoration: BoxDecoration( color: context.conduitTheme.buttonPrimary, shape: BoxShape.circle, boxShadow: ConduitShadows.medium(context), ), child: Icon( Icons.check, color: context.conduitTheme.textInverse, size: 16, ), ), ), ], ), ), ); } } // Extension on _ChatPageState for utility methods extension on _ChatPageState {}