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:async'; import '../../../core/providers/app_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 '../../auth/providers/unified_auth_providers.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/voice_input_sheet.dart'; // deprecated: replaced by inline voice input import '../services/voice_input_service.dart'; import '../services/file_attachment_service.dart'; import 'package:path/path.dart' as path; import '../../../shared/services/tasks/task_queue.dart'; import '../../tools/providers/tools_providers.dart'; import '../../navigation/widgets/chats_drawer.dart'; import '../../../shared/widgets/offline_indicator.dart'; import '../../../core/services/connectivity_service.dart'; import '../../../core/models/chat_message.dart'; import '../../../core/models/model.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'; // Removed unused PlatformUtils import 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; String? _cachedGreetingName; bool _greetingReady = false; String _formatModelDisplayName(String name) { return name.trim(); } bool validateFileCount(int currentCount, int newCount, int maxCount) { return (currentCount + newCount) <= maxCount; } 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(); // Scroll to top if (_scrollController.hasClients) { _scrollController.jumpTo(0); } _shouldAutoScrollToBottom = true; _pendingConversationScrollReset = 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; } DebugLogger.log('auto-select-start', scope: 'chat/model'); try { // First ensure models are loaded final modelsAsync = ref.read(modelsProvider); List models; if (modelsAsync.hasValue) { models = modelsAsync.value!; } else { DebugLogger.log('models-fetch', scope: 'chat/model'); models = await ref.read(modelsProvider.future); } DebugLogger.log( 'models-count', scope: 'chat/model', data: {'count': models.length}, ); if (models.isEmpty) { DebugLogger.warning('models-empty', scope: 'chat/model'); return; } // Try to use the default model provider try { final Model? model = await ref.read(defaultModelProvider.future); if (model != null) { DebugLogger.log( 'auto-select', scope: 'chat/model', data: {'name': model.name}, ); } } catch (e) { DebugLogger.warning('provider-fallback', scope: 'chat/model'); // Fallback: select the first available model ref.read(selectedModelProvider.notifier).set(models.first); DebugLogger.log( 'fallback', scope: 'chat/model', data: {'name': models.first.name}, ); } } catch (e) { DebugLogger.error('auto-select-failed', scope: 'chat/model', error: e); } } 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() { 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(), ), ); } 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; // 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 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; } final isOnline = ref.read(isOnlineProvider); final isReviewerMode = ref.read(reviewerModeProvider); if (!isOnline && !isReviewerMode) { 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(); // 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 files = await fileService.pickFiles(); if (files.isEmpty) return; // Validate file count final currentFiles = ref.read(attachedFilesProvider); if (!validateFileCount(currentFiles.length, files.length, 10)) { if (!mounted) return; return; } // Validate file sizes for (final file in files) { final fileSize = await file.length(); if (!validateFileSize(fileSize, 20)) { if (!mounted) return; return; } } // Add files to the attachment list ref.read(attachedFilesProvider.notifier).addFiles(files); // Enqueue uploads via task queue for unified retry/progress final activeConv = ref.read(activeConversationProvider); for (final file in files) { try { await ref .read(taskQueueProvider.notifier) .enqueueUploadMedia( conversationId: activeConv?.id, filePath: file.path, fileName: path.basename(file.path), fileSize: await 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 image = fromCamera ? await fileService.takePhoto() : await fileService.pickImage(); if (image == null) { DebugLogger.log('No image selected', scope: 'chat/page'); return; } DebugLogger.log('Image selected: ${image.path}', scope: 'chat/page'); final imageSize = await image.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; } // Validate file count (default 10 files limit like OpenWebUI) final currentFiles = ref.read(attachedFilesProvider); if (!validateFileCount(currentFiles.length, 1, 10)) { if (!mounted) return; return; } // Add image to the attachment list ref.read(attachedFilesProvider.notifier).addFiles([image]); 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: image.path, fileName: path.basename(image.path), 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; } } 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; }); } } // 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; 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(); } 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() { return ListView.builder( key: const ValueKey('loading_messages'), // Do not reuse the primary scroll controller here to avoid // attaching the same controller to multiple lists during // AnimatedSwitcher transitions. controller: null, padding: const EdgeInsets.fromLTRB( Spacing.lg, Spacing.md, Spacing.lg, Spacing.lg, ), physics: const AlwaysScrollableScrollPhysics(), // Allow pull-to-refresh while loading // Modest cache extent to avoid offscreen overwork but keep shimmer smooth cacheExtent: 300, itemCount: 6, itemBuilder: (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), ], ], ), ), ); }, ); } 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(); } } if (_shouldAutoScrollToBottom) { _scheduleAutoScrollToBottom(); } else { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; const double keepPinnedThreshold = 60.0; final distanceFromBottom = _distanceFromBottom(); if (distanceFromBottom > 0 && distanceFromBottom <= keepPinnedThreshold) { _scrollToBottom(smooth: false); } }); } return OptimizedList( key: const ValueKey('actual_messages'), scrollController: _scrollController, physics: const AlwaysScrollableScrollPhysics(), items: messages, padding: const EdgeInsets.fromLTRB( Spacing.lg, Spacing.md, Spacing.lg, Spacing.lg, ), 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, ); // 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, 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) { Clipboard.setData(ClipboardData(text: content)); } 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 SSE 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; } // Remove the assistant message we want to regenerate ref.read(chatMessagesProvider.notifier).removeLastMessage(); // 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 currentUserAsync = ref.watch(currentUserProvider); final userFromProfile = currentUserAsync.maybeWhen( data: (user) => user, orElse: () => null, ); final authUser = ref.watch(currentUserProvider2); final user = userFromProfile ?? 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; 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: const EdgeInsets.symmetric(horizontal: Spacing.lg), child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.max, children: [ 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 connectivity status to reduce rebuilds final isOnline = ref.watch(isOnlineProvider.select((status) => status)); // 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; 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; final formattedModelName = selectedModel != null ? _formatModelDisplayName(selectedModel.name) : null; final modelLabel = formattedModelName ?? l10n.chooseModel; final hasConversationTitle = displayConversationTitle != null; 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 final keyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0; // Whether the messages list can actually scroll (avoids showing button when not needed) final canScroll = _scrollController.hasClients && _scrollController.position.maxScrollExtent > 0; // 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 if (!_didStartupFocus) { _didStartupFocus = true; WidgetsBinding.instance.addPostFrameCallback((_) { Future.delayed(const Duration(milliseconds: 200), () { if (!mounted) return; final current = ref.read(inputFocusTriggerProvider); ref.read(inputFocusTriggerProvider.notifier).set(current + 1); }); }); } 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: Scaffold( backgroundColor: context.conduitTheme.surfaceBackground, // Left navigation drawer with draggable edge open (native, finger-following) drawerEnableOpenDragGesture: true, drawerDragStartBehavior: DragStartBehavior.start, drawerEdgeDragWidth: MediaQuery.of(context).size.width * 0.75, drawerScrimColor: context.colorTokens.overlayStrong, drawer: Drawer( width: (MediaQuery.of(context).size.width * 0.80).clamp( 280.0, 420.0, ), backgroundColor: context.conduitTheme.surfaceBackground, child: SafeArea( top: true, bottom: true, left: false, right: false, child: const ChatsDrawer(), ), ), appBar: AppBar( backgroundColor: context.conduitTheme.surfaceBackground, elevation: Elevation.none, surfaceTintColor: Colors.transparent, shadowColor: Colors.transparent, toolbarHeight: kToolbarHeight + 8, centerTitle: true, titleSpacing: 0.0, leading: _isSelectionMode ? IconButton( icon: Icon( Platform.isIOS ? CupertinoIcons.xmark : Icons.close, color: context.conduitTheme.textPrimary, size: IconSize.appBar, ), onPressed: _clearSelection, ) : Builder( builder: (ctx) => Padding( padding: const EdgeInsets.only( left: Spacing.inputPadding, ), child: IconButton( onPressed: () { // Open left drawer instead of bottom sheet Scaffold.of(ctx).openDrawer(); }, icon: Icon( Platform.isIOS ? CupertinoIcons.line_horizontal_3 : Icons.menu, color: context.conduitTheme.textPrimary, size: IconSize.appBar, ), ), ), ), title: _isSelectionMode ? Text( '${_selectedMessageIds.length} selected', style: AppTypography.headlineSmallStyle.copyWith( color: context.conduitTheme.textPrimary, fontWeight: FontWeight.w500, ), ) : GestureDetector( onTap: () async { final modelsAsync = ref.read(modelsProvider); // Handle all async states properly if (modelsAsync.isLoading) { // If still loading, wait for it to complete try { final models = await ref.read(modelsProvider.future); // Check mounted and use context immediately together 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) { // If we have data, show immediately (no async gap) _showModelDropdown(context, ref, modelsAsync.value!); } else if (modelsAsync.hasError) { // If there's an error, try to refresh and load try { ref.invalidate(modelsProvider); final models = await ref.read(modelsProvider.future); // Check mounted and use context immediately together 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, ); } } }, onLongPress: () { final conversation = ref.read(activeConversationProvider); if (conversation == null) return; showConversationContextMenu( context: context, ref: ref, conversation: conversation, ); }, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ AnimatedSwitcher( duration: const Duration(milliseconds: 250), switchInCurve: Curves.easeOutCubic, switchOutCurve: Curves.easeInCubic, child: displayConversationTitle != null ? Column( key: ValueKey( displayConversationTitle, ), mainAxisSize: MainAxisSize.min, children: [ StreamingTitleText( title: displayConversationTitle, style: AppTypography.headlineSmallStyle .copyWith( color: context .conduitTheme .textPrimary, fontWeight: FontWeight.w600, fontSize: 18, height: 1.3, ), cursorColor: context .conduitTheme .textPrimary .withValues(alpha: 0.8), ), const SizedBox(height: Spacing.xs), ], ) : const SizedBox.shrink( key: ValueKey('empty-title'), ), ), Transform.translate( offset: const Offset(0, 0), child: () { final row = Row( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Opacity( opacity: 0.0, child: Container( padding: const EdgeInsets.symmetric( horizontal: Spacing.xs, vertical: Spacing.xxs, ), decoration: BoxDecoration( color: context .conduitTheme .surfaceBackground .withValues(alpha: 0.3), borderRadius: BorderRadius.circular( AppBorderRadius.badge, ), border: Border.all( color: context.conduitTheme.dividerColor, width: BorderWidth.thin, ), ), child: Icon( Platform.isIOS ? CupertinoIcons.chevron_down : Icons.keyboard_arrow_down, color: context.conduitTheme.iconSecondary, size: IconSize.small, ), ), ), const SizedBox(width: Spacing.xs), Flexible( child: MiddleEllipsisText( modelLabel, style: modelTextStyle, textAlign: TextAlign.center, semanticsLabel: modelLabel, ), ), const SizedBox(width: Spacing.xs), Container( padding: const EdgeInsets.symmetric( horizontal: Spacing.xs, vertical: Spacing.xxs, ), decoration: BoxDecoration( color: context .conduitTheme .surfaceBackground .withValues(alpha: 0.3), borderRadius: BorderRadius.circular( AppBorderRadius.badge, ), border: Border.all( color: context.conduitTheme.dividerColor, width: BorderWidth.thin, ), ), child: Icon( Platform.isIOS ? CupertinoIcons.chevron_down : Icons.keyboard_arrow_down, color: context.conduitTheme.iconSecondary, size: IconSize.small, ), ), ], ); return hasConversationTitle ? SizedBox(height: 24, child: row) : row; }(), ), if (isReviewerMode) Padding( padding: const EdgeInsets.only(top: 2.0), 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: IconButton( icon: Icon( Platform.isIOS ? CupertinoIcons.create : Icons.add_comment, color: context.conduitTheme.textPrimary, size: IconSize.appBar, ), onPressed: _handleNewChat, tooltip: AppLocalizations.of(context)!.newChat, ), ), ] else ...[ IconButton( icon: Icon( Platform.isIOS ? CupertinoIcons.delete : Icons.delete, color: context.conduitTheme.error, size: IconSize.appBar, ), onPressed: _deleteSelectedMessages, ), ], ], ), body: GestureDetector( behavior: HitTestBehavior.translucent, onTap: () { FocusManager.instance.primaryFocus?.unfocus(); try { SystemChannels.textInput.invokeMethod('TextInput.hide'); } catch (_) {} }, child: Stack( children: [ Column( children: [ // Messages Area with pull-to-refresh Expanded( child: ConduitRefreshIndicator( 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), ), ), ), ), // File attachments const FileAttachmentWidget(), // Offline indicator const ChatOfflineOverlay(), // Modern Input (root matches input background including safe area) RepaintBoundary( child: MeasureSize( onChange: (size) { if (mounted) { setState(() { _inputHeight = size.height; }); } }, child: ModernChatInput( enabled: (isOnline || ref.watch(reviewerModeProvider)), onSendMessage: (text) => _handleMessageSend(text, selectedModel), onVoiceInput: null, onFileAttachment: _handleFileAttachment, onImageAttachment: _handleImageAttachment, onCameraCapture: () => _handleImageAttachment(fromCamera: true), ), ), ), ], ), // Floating Scroll to Bottom Button with smooth appear/disappear Positioned( bottom: ((_inputHeight > 0) ? _inputHeight : (Spacing.xxl + Spacing.xxxl)) + Spacing.sm, 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: Container( decoration: BoxDecoration( color: context .conduitTheme .surfaceContainerHighest .withValues(alpha: 0.75), border: Border.all( color: context.conduitTheme.cardBorder .withValues(alpha: 0.3), width: BorderWidth.regular, ), borderRadius: BorderRadius.circular( AppBorderRadius.floatingButton, ), boxShadow: ConduitShadows.button(context), ), child: SizedBox( width: TouchTarget.button, height: TouchTarget.button, child: IconButton( onPressed: _scrollToBottom, splashRadius: 24, icon: Icon( 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 ), // 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) { // for (final selectedMessage in selectedMessages) { // ref.read(chatMessagesProvider.notifier).removeMessage(selectedMessage.id); // } _clearSelection(); if (mounted) {} } }); } } 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.md), child: Container( margin: const EdgeInsets.only(bottom: Spacing.md), decoration: BoxDecoration( gradient: isSelected ? LinearGradient( colors: [ context.conduitTheme.buttonPrimary.withValues(alpha: 0.2), context.conduitTheme.buttonPrimary.withValues(alpha: 0.1), ], ) : null, color: isSelected ? null : context.conduitTheme.surfaceBackground.withValues(alpha: 0.05), borderRadius: BorderRadius.circular(AppBorderRadius.md), border: Border.all( color: isSelected ? context.conduitTheme.buttonPrimary.withValues(alpha: 0.5) : context.conduitTheme.dividerColor, width: BorderWidth.regular, ), boxShadow: isSelected ? ConduitShadows.card(context) : null, ), child: Padding( padding: const EdgeInsets.symmetric( horizontal: Spacing.md, vertical: Spacing.sm, ), child: Row( children: [ ModelAvatar(size: 32, imageUrl: iconUrl, label: model.name), const SizedBox(width: Spacing.md), 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, ), const SizedBox(height: Spacing.xs), Row( children: [ if (model.isMultimodal) _capabilityChip( icon: Platform.isIOS ? CupertinoIcons.photo : Icons.image, label: 'Multimodal', ), if (_modelSupportsReasoning(model)) _capabilityChip( icon: Platform.isIOS ? CupertinoIcons.lightbulb : Icons.psychology_alt, label: 'Reasoning', ), ], ), ], ), ), const SizedBox(width: Spacing.md), AnimatedOpacity( opacity: isSelected ? 1 : 0.6, duration: AnimationDuration.fast, child: Container( padding: const EdgeInsets.all(Spacing.xxs), decoration: BoxDecoration( color: isSelected ? context.conduitTheme.buttonPrimary : context.conduitTheme.surfaceBackground, borderRadius: BorderRadius.circular(AppBorderRadius.md), border: Border.all( color: isSelected ? context.conduitTheme.buttonPrimary.withValues( alpha: 0.6, ) : context.conduitTheme.dividerColor, ), ), child: Icon( isSelected ? (Platform.isIOS ? CupertinoIcons.check_mark : Icons.check) : (Platform.isIOS ? CupertinoIcons.add : Icons.add), color: isSelected ? context.conduitTheme.textInverse : context.conduitTheme.iconSecondary, size: 14, ), ), ), ], ), ), ), ).animate().fadeIn(duration: AnimationDuration.microInteraction); } // 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; }); if (mounted) {} } } // Server transcription removed; only on-device STT is supported 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 {}