Files
iiEsaywebUIapp/lib/features/chat/views/chat_page.dart

2992 lines
116 KiB
Dart
Raw Normal View History

2025-09-25 23:22:48 +05:30
import 'package:flutter/material.dart';
import 'package:conduit/l10n/app_localizations.dart';
2025-08-10 01:20:45 +05:30
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 '../../../shared/widgets/responsive_drawer_layout.dart';
import '../../navigation/widgets/chats_drawer.dart';
2025-08-10 01:20:45 +05:30
import 'dart:async';
import '../../../core/providers/app_providers.dart';
import '../providers/chat_providers.dart';
2025-08-20 22:15:26 +05:30
import '../../../core/utils/debug_logger.dart';
2025-09-16 16:24:45 +05:30
import '../../../core/utils/user_display_name.dart';
2025-09-20 22:03:55 +05:30
import '../../../core/utils/model_icon_utils.dart';
2025-09-28 23:18:24 +05:30
import '../../auth/providers/unified_auth_providers.dart';
2025-08-10 01:20:45 +05:30
import '../widgets/modern_chat_input.dart';
2025-08-20 22:15:26 +05:30
import '../widgets/user_message_bubble.dart';
import '../widgets/assistant_message_widget.dart' as assistant;
2025-09-25 19:40:34 +05:30
import '../widgets/streaming_title_text.dart';
2025-08-10 01:20:45 +05:30
import '../widgets/file_attachment_widget.dart';
import '../services/voice_input_service.dart';
import '../services/file_attachment_service.dart';
import 'voice_call_page.dart';
2025-09-02 19:08:23 +05:30
import 'package:path/path.dart' as path;
import '../../../shared/services/tasks/task_queue.dart';
2025-08-19 20:26:19 +05:30
import '../../tools/providers/tools_providers.dart';
2025-08-10 01:20:45 +05:30
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';
2025-08-17 16:11:19 +05:30
import '../../onboarding/views/onboarding_sheet.dart';
2025-08-22 01:24:04 +05:30
import '../../../shared/widgets/sheet_handle.dart';
import '../../../shared/widgets/measure_size.dart';
2025-08-22 13:54:58 +05:30
import '../../../shared/widgets/conduit_components.dart';
import '../../../shared/widgets/middle_ellipsis_text.dart';
2025-09-19 21:12:15 +05:30
import '../../../shared/widgets/modal_safe_area.dart';
2025-08-22 13:54:58 +05:30
import '../../../core/services/settings_service.dart';
2025-09-19 23:35:46 +05:30
import '../../../shared/utils/conversation_context_menu.dart';
2025-09-20 22:03:55 +05:30
import '../../../shared/widgets/model_avatar.dart';
2025-08-22 13:54:58 +05:30
import '../../../core/services/platform_service.dart' as ps;
import 'package:flutter/gestures.dart' show DragStartBehavior;
2025-08-10 01:20:45 +05:30
class ChatPage extends ConsumerStatefulWidget {
const ChatPage({super.key});
@override
ConsumerState<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends ConsumerState<ChatPage> {
final ScrollController _scrollController = ScrollController();
bool _showScrollToBottom = false;
bool _isSelectionMode = false;
final Set<String> _selectedMessageIds = <String>{};
Timer? _scrollDebounceTimer;
2025-08-28 18:54:06 +05:30
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
String? _cachedGreetingName;
bool _greetingReady = false;
2025-08-10 01:20:45 +05:30
String _formatModelDisplayName(String name) {
return name.trim();
2025-08-10 01:20:45 +05:30
}
2025-08-17 16:11:19 +05:30
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();
2025-09-21 22:31:44 +05:30
ref.read(activeConversationProvider.notifier).clear();
2025-08-20 22:15:26 +05:30
2025-08-17 16:11:19 +05:30
// Scroll to top
if (_scrollController.hasClients) {
_scrollController.jumpTo(0);
}
_shouldAutoScrollToBottom = true;
_pendingConversationScrollReset = false;
_scheduleAutoScrollToBottom();
2025-08-17 16:11:19 +05:30
}
Future<void> _checkAndAutoSelectModel() async {
// Check if a model is already selected
final selectedModel = ref.read(selectedModelProvider);
if (selectedModel != null) {
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'selected',
scope: 'chat/model',
data: {'name': selectedModel.name},
);
2025-08-17 16:11:19 +05:30
return;
}
2025-08-20 22:15:26 +05:30
2025-09-25 22:36:42 +05:30
DebugLogger.log('auto-select-start', scope: 'chat/model');
2025-08-20 22:15:26 +05:30
2025-08-17 16:11:19 +05:30
try {
// First ensure models are loaded
final modelsAsync = ref.read(modelsProvider);
List<Model> models;
2025-08-20 22:15:26 +05:30
2025-08-17 16:11:19 +05:30
if (modelsAsync.hasValue) {
models = modelsAsync.value!;
} else {
2025-09-25 22:36:42 +05:30
DebugLogger.log('models-fetch', scope: 'chat/model');
2025-08-17 16:11:19 +05:30
models = await ref.read(modelsProvider.future);
}
2025-08-20 22:15:26 +05:30
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'models-count',
scope: 'chat/model',
data: {'count': models.length},
);
2025-08-20 22:15:26 +05:30
2025-08-17 16:11:19 +05:30
if (models.isEmpty) {
2025-09-25 22:36:42 +05:30
DebugLogger.warning('models-empty', scope: 'chat/model');
2025-08-17 16:11:19 +05:30
return;
}
2025-08-20 22:15:26 +05:30
2025-08-17 16:11:19 +05:30
// Try to use the default model provider
try {
2025-08-17 17:43:19 +05:30
final Model? model = await ref.read(defaultModelProvider.future);
2025-08-17 16:11:19 +05:30
if (model != null) {
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'auto-select',
scope: 'chat/model',
data: {'name': model.name},
);
2025-08-17 16:11:19 +05:30
}
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.warning('provider-fallback', scope: 'chat/model');
2025-08-17 16:11:19 +05:30
// Fallback: select the first available model
2025-09-21 22:31:44 +05:30
ref.read(selectedModelProvider.notifier).set(models.first);
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'fallback',
scope: 'chat/model',
data: {'name': models.first.name},
);
2025-08-17 16:11:19 +05:30
}
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error('auto-select-failed', scope: 'chat/model', error: e);
2025-08-17 16:11:19 +05:30
}
}
Future<void> _checkAndShowOnboarding() async {
try {
// Check if onboarding has been seen
final storage = ref.read(optimizedStorageServiceProvider);
final seen = await storage.getOnboardingSeen();
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'onboarding-status',
scope: 'chat/onboarding',
data: {'seen': seen},
);
2025-08-20 22:15:26 +05:30
2025-08-17 16:11:19 +05:30
if (!seen && mounted) {
// Small delay to ensure navigation has settled
await Future.delayed(const Duration(milliseconds: 500));
if (!mounted) return;
2025-08-20 22:15:26 +05:30
2025-09-25 22:36:42 +05:30
DebugLogger.log('onboarding-show', scope: 'chat/onboarding');
2025-08-17 16:11:19 +05:30
_showOnboarding();
await storage.setOnboardingSeen(true);
2025-09-25 22:36:42 +05:30
DebugLogger.log('onboarding-marked', scope: 'chat/onboarding');
2025-08-17 16:11:19 +05:30
}
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error(
'onboarding-status-failed',
scope: 'chat/onboarding',
error: e,
);
2025-08-17 16:11:19 +05:30
}
}
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),
2025-08-17 16:11:19 +05:30
),
child: const OnboardingSheet(),
),
);
}
Future<void> _checkAndLoadDemoConversation() async {
2025-08-24 14:35:17 +05:30
if (!mounted) return;
2025-08-17 16:11:19 +05:30
final isReviewerMode = ref.read(reviewerModeProvider);
if (!isReviewerMode) return;
2025-08-20 22:15:26 +05:30
2025-08-17 16:11:19 +05:30
// Check if there's already an active conversation
2025-08-24 14:35:17 +05:30
if (!mounted) return;
2025-08-17 16:11:19 +05:30
final activeConversation = ref.read(activeConversationProvider);
if (activeConversation != null) {
2025-08-20 22:15:26 +05:30
DebugLogger.log(
2025-09-25 22:36:42 +05:30
'active',
scope: 'chat/demo',
data: {'title': activeConversation.title},
2025-08-20 22:15:26 +05:30
);
2025-08-17 16:11:19 +05:30
return;
}
2025-08-20 22:15:26 +05:30
2025-08-17 16:11:19 +05:30
// Force refresh conversations provider to ensure we get the demo conversations
2025-08-24 14:35:17 +05:30
if (!mounted) return;
refreshConversationsCache(ref);
2025-08-20 22:15:26 +05:30
2025-08-17 16:11:19 +05:30
// Try to load demo conversation
for (int i = 0; i < 10; i++) {
2025-08-24 14:35:17 +05:30
if (!mounted) return;
2025-08-17 16:11:19 +05:30
final conversationsAsync = ref.read(conversationsProvider);
2025-08-20 22:15:26 +05:30
2025-08-17 16:11:19 +05:30
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,
);
2025-08-20 22:15:26 +05:30
2025-08-24 14:35:17 +05:30
if (!mounted) return;
2025-09-21 22:31:44 +05:30
ref.read(activeConversationProvider.notifier).set(welcomeConv);
2025-09-25 23:22:48 +05:30
DebugLogger.log('Auto-loaded demo conversation', scope: 'chat/page');
2025-08-17 16:11:19 +05:30
return;
}
2025-08-20 22:15:26 +05:30
2025-08-17 16:11:19 +05:30
// If conversations are still loading, wait a bit and retry
if (conversationsAsync.isLoading || i == 0) {
await Future.delayed(const Duration(milliseconds: 200));
2025-08-24 14:35:17 +05:30
if (!mounted) return;
2025-08-17 16:11:19 +05:30
continue;
}
2025-08-20 22:15:26 +05:30
2025-08-17 16:11:19 +05:30
// If there was an error or no conversations, break
break;
}
2025-08-20 22:15:26 +05:30
2025-09-25 23:22:48 +05:30
DebugLogger.log(
'Failed to auto-load demo conversation',
scope: 'chat/page',
);
2025-08-17 16:11:19 +05:30
}
2025-08-10 01:20:45 +05:30
@override
void initState() {
super.initState();
// Listen to scroll events to show/hide scroll to bottom button
_scrollController.addListener(_onScroll);
2025-08-20 22:15:26 +05:30
_scheduleAutoScrollToBottom();
2025-08-17 16:11:19 +05:30
// Initialize chat page components
WidgetsBinding.instance.addPostFrameCallback((_) async {
2025-08-24 14:35:17 +05:30
if (!mounted) return;
2025-08-17 16:11:19 +05:30
// First, ensure a model is selected
await _checkAndAutoSelectModel();
2025-08-24 14:35:17 +05:30
if (!mounted) return;
2025-08-20 22:15:26 +05:30
2025-08-17 16:11:19 +05:30
// Then check for demo conversation in reviewer mode
await _checkAndLoadDemoConversation();
2025-08-24 14:35:17 +05:30
if (!mounted) return;
2025-08-20 22:15:26 +05:30
2025-08-17 16:11:19 +05:30
// Finally, show onboarding if needed
await _checkAndShowOnboarding();
});
2025-08-10 01:20:45 +05:30
}
@override
void dispose() {
_scrollController.dispose();
_scrollDebounceTimer?.cancel();
super.dispose();
}
2025-08-28 18:54:06 +05:30
@override
void deactivate() {
_isDeactivated = true;
_scrollDebounceTimer?.cancel();
super.deactivate();
}
@override
void activate() {
super.activate();
_isDeactivated = false;
}
2025-08-10 01:20:45 +05:30
void _handleMessageSend(String text, dynamic selectedModel) async {
2025-09-16 20:10:53 +05:30
// Resolve model on-demand if none selected yet
2025-08-10 01:20:45 +05:30
if (selectedModel == null) {
2025-09-16 20:10:53 +05:30
try {
// Prefer already-loaded models
List<Model> 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;
2025-09-21 22:31:44 +05:30
ref.read(selectedModelProvider.notifier).set(selectedModel);
2025-09-16 20:10:53 +05:30
}
} catch (_) {
// If models cannot be resolved, bail out without sending
return;
}
if (selectedModel == null) return;
2025-08-10 01:20:45 +05:30
}
try {
2025-09-01 23:41:22 +05:30
// Get attached files and collect uploaded file IDs (including data URLs for images)
2025-08-10 01:20:45 +05:30
final attachedFiles = ref.read(attachedFilesProvider);
final uploadedFileIds = attachedFiles
2025-09-13 10:16:58 +05:30
.where(
(file) =>
file.status == FileUploadStatus.completed &&
file.fileId != null,
)
2025-08-10 01:20:45 +05:30
.map((file) => file.fileId!)
.toList();
2025-08-19 20:26:19 +05:30
// Get selected tools
final toolIds = ref.read(selectedToolIdsProvider);
2025-09-01 23:41:22 +05:30
// Enqueue task-based send to unify flow across text, images, and tools
final activeConv = ref.read(activeConversationProvider);
2025-09-13 10:16:58 +05:30
await ref
.read(taskQueueProvider.notifier)
.enqueueSendText(
2025-09-01 23:41:22 +05:30
conversationId: activeConv?.id,
text: text,
attachments: uploadedFileIds.isNotEmpty ? uploadedFileIds : null,
toolIds: toolIds.isNotEmpty ? toolIds : null,
);
2025-08-10 01:20:45 +05:30
// Clear attachments after successful send
ref.read(attachedFilesProvider.notifier).clearAll();
2025-09-01 23:41:22 +05:30
// Scroll to bottom after enqueuing (only if user was near bottom)
2025-08-10 01:20:45 +05:30
WidgetsBinding.instance.addPostFrameCallback((_) {
// Only auto-scroll if user was already near the bottom (within 300 px)
final distanceFromBottom = _distanceFromBottom();
if (distanceFromBottom <= 300) {
_scrollToBottom();
2025-08-10 01:20:45 +05:30
}
});
} catch (e) {
2025-08-21 19:11:17 +05:30
// Message send failed - error already handled by sendMessage
2025-08-10 01:20:45 +05:30
}
}
2025-08-25 10:35:48 +05:30
// Inline voice input now handled directly inside ModernChatInput.
2025-08-10 01:20:45 +05:30
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);
2025-09-02 19:08:23 +05:30
// Enqueue uploads via task queue for unified retry/progress
final activeConv = ref.read(activeConversationProvider);
2025-08-10 01:20:45 +05:30
for (final file in files) {
2025-09-02 19:08:23 +05:30
try {
2025-09-13 10:16:58 +05:30
await ref
.read(taskQueueProvider.notifier)
.enqueueUploadMedia(
conversationId: activeConv?.id,
filePath: file.path,
fileName: path.basename(file.path),
fileSize: await file.length(),
);
2025-09-02 19:08:23 +05:30
} catch (e) {
if (!mounted) return;
2025-09-25 23:22:48 +05:30
DebugLogger.log('Enqueue upload failed: $e', scope: 'chat/page');
2025-09-02 19:08:23 +05:30
}
2025-08-10 01:20:45 +05:30
}
} catch (e) {
if (!mounted) return;
2025-09-25 23:22:48 +05:30
DebugLogger.log('File selection failed: $e', scope: 'chat/page');
2025-08-10 01:20:45 +05:30
}
}
void _handleImageAttachment({bool fromCamera = false}) async {
2025-09-25 23:22:48 +05:30
DebugLogger.log(
'Starting image attachment process - fromCamera: $fromCamera',
scope: 'chat/page',
2025-08-10 01:20:45 +05:30
);
// 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) {
2025-09-25 23:22:48 +05:30
DebugLogger.log(
'File service is null - cannot proceed',
scope: 'chat/page',
);
2025-08-10 01:20:45 +05:30
return;
}
try {
2025-09-25 23:22:48 +05:30
DebugLogger.log('Picking image...', scope: 'chat/page');
2025-08-10 01:20:45 +05:30
final image = fromCamera
? await fileService.takePhoto()
: await fileService.pickImage();
if (image == null) {
2025-09-25 23:22:48 +05:30
DebugLogger.log('No image selected', scope: 'chat/page');
2025-08-10 01:20:45 +05:30
return;
}
2025-09-25 23:22:48 +05:30
DebugLogger.log('Image selected: ${image.path}', scope: 'chat/page');
2025-08-10 01:20:45 +05:30
final imageSize = await image.length();
2025-09-25 23:22:48 +05:30
DebugLogger.log('Image size: $imageSize bytes', scope: 'chat/page');
2025-08-10 01:20:45 +05:30
// 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]);
2025-09-25 23:22:48 +05:30
DebugLogger.log('Image added to attachment list', scope: 'chat/page');
2025-08-10 01:20:45 +05:30
2025-09-02 19:08:23 +05:30
// Enqueue upload via task queue for unified retry/progress
2025-09-25 23:22:48 +05:30
DebugLogger.log('Enqueueing image upload...', scope: 'chat/page');
2025-09-02 19:08:23 +05:30
final activeConv = ref.read(activeConversationProvider);
try {
2025-09-13 10:16:58 +05:30
await ref
.read(taskQueueProvider.notifier)
.enqueueUploadMedia(
2025-09-02 19:08:23 +05:30
conversationId: activeConv?.id,
filePath: image.path,
fileName: path.basename(image.path),
2025-09-13 10:16:58 +05:30
fileSize: imageSize,
2025-09-02 19:08:23 +05:30
);
} catch (e) {
2025-09-25 23:22:48 +05:30
DebugLogger.log('Enqueue image upload failed: $e', scope: 'chat/page');
2025-09-02 19:08:23 +05:30
}
2025-08-10 01:20:45 +05:30
} catch (e) {
2025-09-25 23:22:48 +05:30
DebugLogger.log('Image attachment error: $e', scope: 'chat/page');
2025-08-10 01:20:45 +05:30
if (!mounted) return;
}
}
void _handleNewChat() {
// Start a new chat using the existing function
2025-08-17 16:11:19 +05:30
startNewChat();
2025-08-10 01:20:45 +05:30
// Hide scroll-to-bottom button for a fresh chat
if (mounted) {
setState(() {
_showScrollToBottom = false;
});
}
}
void _handleVoiceCall() {
// Navigate to voice call page
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const VoiceCallPage(),
fullscreenDialog: true,
),
);
}
2025-08-21 23:56:47 +05:30
// Replaced bottom-sheet chat list with left drawer (see ChatsDrawer)
2025-08-10 01:20:45 +05:30
void _onScroll() {
if (!_scrollController.hasClients) return;
// Debounce scroll handling to reduce rebuilds
if (_scrollDebounceTimer?.isActive == true) return;
_scrollDebounceTimer = Timer(const Duration(milliseconds: 80), () {
2025-08-28 18:54:06 +05:30
if (!mounted || _isDeactivated || !_scrollController.hasClients) return;
2025-08-10 01:20:45 +05:30
final maxScroll = _scrollController.position.maxScrollExtent;
final distanceFromBottom = _distanceFromBottom();
2025-08-10 01:20:45 +05:30
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;
2025-08-10 01:20:45 +05:30
2025-08-28 18:54:06 +05:30
if (showButton != _showScrollToBottom && mounted && !_isDeactivated) {
2025-08-10 01:20:45 +05:30
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);
}
}
2025-08-10 01:20:45 +05:30
void _scrollToBottom({bool smooth = true}) {
if (!_scrollController.hasClients) return;
final position = _scrollController.position;
final maxScroll = position.maxScrollExtent;
final target = maxScroll.isFinite ? maxScroll : 0.0;
2025-08-10 01:20:45 +05:30
if (smooth) {
_scrollController.animateTo(
target,
2025-08-10 01:20:45 +05:30
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
);
} else {
_scrollController.jumpTo(target);
2025-08-10 01:20:45 +05:30
}
}
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<ChatMessage> _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);
2025-08-21 12:49:41 +05:30
// 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: <Widget>[
...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.
return CustomScrollView(
2025-08-21 12:49:41 +05:30
key: const ValueKey('loading_messages'),
controller: null,
physics: const AlwaysScrollableScrollPhysics(),
2025-09-23 11:00:25 +05:30
cacheExtent: 300,
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(
Spacing.lg,
Spacing.md,
Spacing.lg,
Spacing.lg,
),
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,
2025-08-21 12:49:41 +05:30
),
padding: const EdgeInsets.all(Spacing.md),
2025-08-21 12:49:41 +05:30
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,
2025-08-10 01:20:45 +05:30
),
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),
2025-08-21 12:49:41 +05:30
),
),
],
2025-08-21 12:49:41 +05:30
);
}
2025-08-10 01:20:45 +05:30
2025-08-21 12:49:41 +05:30
Widget _buildActualMessagesList(List<ChatMessage> messages) {
2025-08-10 01:20:45 +05:30
if (messages.isEmpty) {
2025-08-21 12:49:41 +05:30
return _buildEmptyState(Theme.of(context));
2025-08-10 01:20:45 +05:30
}
2025-09-20 22:03:55 +05:30
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 {
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;
}
const double keepPinnedThreshold = 60.0;
final distanceFromBottom = _distanceFromBottom();
if (distanceFromBottom > 0 &&
distanceFromBottom <= keepPinnedThreshold) {
_scrollToBottom(smooth: false);
}
});
}
return CustomScrollView(
2025-08-21 12:49:41 +05:30
key: const ValueKey('actual_messages'),
controller: _scrollController,
2025-09-24 10:52:15 +05:30
physics: const AlwaysScrollableScrollPhysics(),
cacheExtent: 600,
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(
Spacing.lg,
Spacing.md,
Spacing.lg,
Spacing.lg,
),
sliver: OptimizedSliverList<ChatMessage>(
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);
}
}
2025-09-01 18:49:43 +05:30
final modelIconUrl = resolveModelIconUrlForModel(
apiService,
matchedModel,
);
2025-09-20 22:03:55 +05:30
// 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),
);
}
2025-08-10 01:20:45 +05:30
// 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,
);
2025-08-10 01:20:45 +05:30
}
},
),
),
],
2025-08-10 01:20:45 +05:30
);
}
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 {
2025-08-21 16:19:21 +05:30
// 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;
}
2025-08-10 01:20:45 +05:30
// Remove the assistant message we want to regenerate
ref.read(chatMessagesProvider.notifier).removeLastMessage();
// Regenerate response for the previous user message (without duplicating it)
2025-08-10 01:20:45 +05:30
final userMessage = messages[messageIndex - 1];
2025-08-20 22:15:26 +05:30
await regenerateMessage(
ref,
userMessage.content,
userMessage.attachmentIds,
);
2025-08-10 01:20:45 +05:30
} catch (e) {
2025-09-25 23:22:48 +05:30
DebugLogger.log('Regenerate failed: $e', scope: 'chat/page');
2025-08-10 01:20:45 +05:30
}
}
2025-09-07 22:37:52 +05:30
// Inline editing handled by UserMessageBubble. Dialog flow removed.
2025-08-10 01:20:45 +05:30
Widget _buildEmptyState(ThemeData theme) {
2025-09-16 16:24:45 +05:30
final l10n = AppLocalizations.of(context)!;
final currentUserAsync = ref.watch(currentUserProvider);
final userFromProfile = currentUserAsync.maybeWhen(
data: (user) => user,
orElse: () => null,
);
2025-09-28 23:18:24 +05:30
final authUser = ref.watch(currentUserProvider2);
2025-09-16 16:24:45 +05:30
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;
2025-09-24 10:52:15 +05:30
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,
2025-09-24 10:52:15 +05:30
),
),
),
2025-08-10 01:20:45 +05:30
),
],
),
2025-09-24 10:52:15 +05:30
),
),
);
},
2025-08-10 01:20:45 +05:30
);
}
// Removed detailed help items from chat page; guidance now lives in Onboarding
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
2025-09-19 23:35:46 +05:30
final l10n = AppLocalizations.of(context)!;
2025-08-10 01:20:45 +05:30
// Use select to watch only the selected model to reduce rebuilds
final selectedModel = ref.watch(
selectedModelProvider.select((model) => model),
);
2025-08-20 22:15:26 +05:30
2025-08-17 16:11:19 +05:30
// Watch reviewer mode and auto-select model if needed
final isReviewerMode = ref.watch(reviewerModeProvider);
2025-08-20 22:15:26 +05:30
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;
}
}
2025-09-19 23:35:46 +05:30
final conversationTitle = ref.watch(
activeConversationProvider.select((conv) => conv?.title),
);
2025-09-20 18:09:22 +05:30
final trimmedConversationTitle = conversationTitle?.trim();
final displayConversationTitle =
(trimmedConversationTitle != null &&
trimmedConversationTitle.isNotEmpty)
? trimmedConversationTitle
: null;
2025-09-19 23:35:46 +05:30
final formattedModelName = selectedModel != null
? _formatModelDisplayName(selectedModel.name)
2025-09-19 23:35:46 +05:30
: null;
2025-09-20 18:09:22 +05:30
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,
);
2025-09-19 23:35:46 +05:30
// Keyboard visibility
final keyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
// Whether the messages list can actually scroll (avoids showing button when not needed)
2025-09-13 10:16:58 +05:30
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;
2025-08-17 16:11:19 +05:30
// Auto-select model when in reviewer mode with no selection
if (isReviewerMode && selectedModel == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkAndAutoSelectModel();
});
}
2025-08-10 01:20:45 +05:30
2025-09-16 20:10:53 +05:30
// 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);
});
});
}
2025-08-10 01:20:45 +05:30
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;
}
2025-08-21 23:56:47 +05:30
// Auto-handle leaving without confirmation
2025-08-10 01:20:45 +05:30
final messages = ref.read(chatMessagesProvider);
2025-08-21 23:56:47 +05:30
final isStreaming = messages.any((msg) => msg.isStreaming);
if (isStreaming) {
ref.read(chatMessagesProvider.notifier).finishStreaming();
}
2025-08-20 22:15:26 +05:30
2025-09-05 11:15:39 +05:30
// Do not push conversation state back to server on exit.
// Server already maintains chat state from message sends.
// Keep any local persistence only.
2025-08-20 22:15:26 +05:30
2025-08-21 23:56:47 +05:30
if (context.mounted) {
2025-09-23 00:58:58 +05:30
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.pop();
2025-08-10 01:20:45 +05:30
} else {
2025-09-23 00:58:58 +05:30
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();
}
2025-08-10 01:20:45 +05:30
}
}
},
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.overlayMedium
: context.colorTokens.overlayStrong;
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: 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,
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,
)
: (isTablet
? null // Hide menu button on tablets (drawer is always visible)
: Builder(
builder: (ctx) => Padding(
padding: const EdgeInsets.only(
left: Spacing.inputPadding,
),
child: IconButton(
onPressed: () {
// Suppress auto-focus and dismiss keyboard, then open drawer
try {
ref
.read(
composerAutofocusEnabledProvider
.notifier,
)
.set(false);
FocusManager.instance.primaryFocus
?.unfocus();
SystemChannels.textInput.invokeMethod(
'TextInput.hide',
);
} catch (_) {}
ResponsiveDrawerLayout.of(ctx)?.open();
},
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<String>(
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<String>('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(
2025-09-20 18:09:22 +05:30
color: context
.conduitTheme
.surfaceBackground
.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(
AppBorderRadius.badge,
),
border: Border.all(
color: context
.conduitTheme
.dividerColor,
width: BorderWidth.thin,
),
2025-09-20 18:09:22 +05:30
),
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,
),
),
),
),
],
2025-08-10 01:20:45 +05:30
),
),
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,
2025-09-13 10:16:58 +05:30
),
onPressed: _handleNewChat,
tooltip: AppLocalizations.of(context)!.newChat,
),
2025-08-10 01:20:45 +05:30
),
] else ...[
IconButton(
icon: Icon(
Platform.isIOS ? CupertinoIcons.delete : Icons.delete,
color: context.conduitTheme.error,
size: IconSize.appBar,
2025-09-13 10:16:58 +05:30
),
onPressed: _deleteSelectedMessages,
),
],
2025-09-13 10:16:58 +05:30
],
),
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),
),
),
),
),
2025-08-10 01:20:45 +05:30
// File attachments
const FileAttachmentWidget(),
// Modern Input (root matches input background including safe area)
RepaintBoundary(
child: MeasureSize(
onChange: (size) {
if (mounted) {
setState(() {
_inputHeight = size.height;
});
}
},
child: ModernChatInput(
onSendMessage: (text) =>
_handleMessageSend(text, selectedModel),
onVoiceInput: null,
onVoiceCall: _handleVoiceCall,
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<Offset>(
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',
2025-09-13 10:16:58 +05:30
),
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),
),
),
),
2025-09-13 10:16:58 +05:30
),
),
)
: const SizedBox.shrink(
key: ValueKey('scroll_to_bottom_hidden'),
),
),
),
// Edge overlay removed; rely on native interactive drawer drag
],
2025-09-13 10:16:58 +05:30
),
),
), // Scaffold inside ResponsiveDrawerLayout
);
},
),
2025-08-10 01:20:45 +05:30
), // PopScope
); // ErrorBoundary
}
2025-09-05 11:48:43 +05:30
// Removed legacy save-before-leave hook; server manages chat state via background pipeline.
2025-08-12 13:07:10 +05:30
2025-08-10 01:20:45 +05:30
void _showModelDropdown(
BuildContext context,
WidgetRef ref,
List<Model> models,
) {
2025-09-08 01:15:31 +05:30
// Ensure keyboard is closed before presenting modal
final hadFocus = ref.read(composerHasFocusProvider);
try {
FocusManager.instance.primaryFocus?.unfocus();
SystemChannels.textInput.invokeMethod('TextInput.hide');
} catch (_) {}
2025-08-10 01:20:45 +05:30
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => _ModelSelectorSheet(models: models, ref: ref),
2025-09-08 01:15:31 +05:30
).whenComplete(() {
if (!mounted) return;
if (hadFocus) {
// Bump focus trigger to restore composer focus + IME
final cur = ref.read(inputFocusTriggerProvider);
2025-09-21 22:31:44 +05:30
ref.read(inputFocusTriggerProvider.notifier).set(cur + 1);
2025-09-08 01:15:31 +05:30
}
});
2025-08-10 01:20:45 +05:30
}
void _deleteSelectedMessages() {
final selectedMessages = _getSelectedMessages();
if (selectedMessages.isEmpty) return;
final l10n = AppLocalizations.of(context)!;
2025-08-10 01:20:45 +05:30
ThemedDialogs.confirm(
context,
title: l10n.deleteMessagesTitle,
message: l10n.deleteMessagesMessage(selectedMessages.length),
confirmText: l10n.delete,
cancelText: l10n.cancel,
2025-08-10 01:20:45 +05:30
isDestructive: true,
).then((confirmed) async {
if (confirmed == true) {
// for (final selectedMessage in selectedMessages) {
// ref.read(chatMessagesProvider.notifier).removeMessage(selectedMessage.id);
// }
_clearSelection();
2025-08-21 16:15:27 +05:30
if (mounted) {}
2025-08-10 01:20:45 +05:30
}
});
}
}
class _ModelSelectorSheet extends ConsumerStatefulWidget {
final List<Model> 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<Model> _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) {
2025-09-19 21:12:15 +05:30
setState(() => _searchQuery = query);
2025-08-10 01:20:45 +05:30
_searchDebounce?.cancel();
_searchDebounce = Timer(const Duration(milliseconds: 160), () {
2025-09-19 21:12:15 +05:30
if (!mounted) return;
final normalized = query.trim().toLowerCase();
Iterable<Model> 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);
});
}
2025-08-10 01:20:45 +05:30
setState(() {
_filteredModels = list.toList();
});
});
}
@override
Widget build(BuildContext context) {
2025-09-19 21:12:15 +05:30
return Stack(
children: [
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => Navigator.of(context).maybePop(),
child: const SizedBox.shrink(),
2025-08-10 01:20:45 +05:30
),
2025-09-19 21:12:15 +05:30
),
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),
2025-09-19 21:12:15 +05:30
),
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,
2025-08-10 01:20:45 +05:30
),
2025-09-19 21:12:15 +05:30
onChanged: _filterModels,
decoration: InputDecoration(
isDense: true,
hintText: AppLocalizations.of(context)!.searchModels,
hintStyle: AppTypography.standard.copyWith(
color: context.conduitTheme.inputPlaceholder,
2025-08-10 01:20:45 +05:30
),
2025-09-19 21:12:15 +05:30
prefixIcon: Icon(
Platform.isIOS
? CupertinoIcons.search
: Icons.search,
color: context.conduitTheme.iconSecondary,
size: IconSize.input,
2025-08-10 01:20:45 +05:30
),
2025-09-19 21:12:15 +05:30
prefixIconConstraints: const BoxConstraints(
minWidth: TouchTarget.minimum,
minHeight: TouchTarget.minimum,
2025-08-10 01:20:45 +05:30
),
2025-09-19 21:12:15 +05:30
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,
2025-08-10 01:20:45 +05:30
),
2025-09-19 21:12:15 +05:30
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,
2025-08-10 01:20:45 +05:30
),
),
),
),
2025-09-19 21:12:15 +05:30
// 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,
2025-08-10 01:20:45 +05:30
),
2025-09-19 21:12:15 +05:30
const SizedBox(height: Spacing.md),
Text(
'No results',
style: TextStyle(
color:
context.conduitTheme.textSecondary,
fontSize: AppTypography.bodyLarge,
),
),
],
),
)
: ListView.builder(
controller: scrollController,
padding: EdgeInsets.zero,
2025-09-23 11:00:25 +05:30
cacheExtent: 400,
2025-09-19 21:12:15 +05:30
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
2025-09-21 22:31:44 +05:30
.read(selectedModelProvider.notifier)
.set(model);
2025-09-19 21:12:15 +05:30
Navigator.pop(context);
},
);
},
2025-08-10 01:20:45 +05:30
),
2025-09-19 21:12:15 +05:30
),
2025-08-10 01:20:45 +05:30
),
2025-09-19 21:12:15 +05:30
],
),
2025-08-10 01:20:45 +05:30
),
2025-09-19 21:12:15 +05:30
);
},
),
],
2025-08-10 01:20:45 +05:30
);
}
// 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,
}) {
2025-09-20 22:03:55 +05:30
final api = ref.watch(apiServiceProvider);
final iconUrl = resolveModelIconUrlForModel(api, model);
2025-08-10 01:20:45 +05:30
return PressableScale(
onTap: onTap,
borderRadius: BorderRadius.circular(AppBorderRadius.small),
2025-08-10 01:20:45 +05:30
child: Container(
margin: const EdgeInsets.only(bottom: Spacing.sm),
2025-08-10 01:20:45 +05:30
decoration: BoxDecoration(
color: isSelected
? context.conduitTheme.buttonPrimary.withValues(alpha: 0.1)
2025-08-10 01:20:45 +05:30
: context.conduitTheme.surfaceBackground.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(AppBorderRadius.small),
2025-08-10 01:20:45 +05:30
border: Border.all(
color: isSelected
? context.conduitTheme.buttonPrimary.withValues(alpha: 0.3)
: context.conduitTheme.dividerColor.withValues(alpha: 0.5),
width: BorderWidth.standard,
2025-08-10 01:20:45 +05:30
),
),
child: Padding(
padding: const EdgeInsets.all(Spacing.sm),
2025-08-10 01:20:45 +05:30
child: Row(
children: [
2025-09-20 22:03:55 +05:30
ModelAvatar(size: 32, imageUrl: iconUrl, label: model.name),
const SizedBox(width: Spacing.sm),
2025-08-10 01:20:45 +05:30
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: 'Multimodal',
),
if (_modelSupportsReasoning(model))
_capabilityChip(
icon: Platform.isIOS
? CupertinoIcons.lightbulb
: Icons.psychology_alt,
label: 'Reasoning',
),
],
),
],
2025-08-10 01:20:45 +05:30
],
),
),
const SizedBox(width: Spacing.sm),
if (isSelected)
Icon(
Platform.isIOS ? CupertinoIcons.check_mark : Icons.check,
color: context.conduitTheme.buttonPrimary,
size: IconSize.small,
2025-08-10 01:20:45 +05:30
),
],
),
),
),
);
2025-08-10 01:20:45 +05:30
}
// Intentionally left blank placeholder for nested helper; moved to top-level below
}
// Removed custom edge gesture in favor of native Drawer drag behavior.
2025-08-10 01:20:45 +05:30
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<int>? _intensitySub;
int _intensity = 0;
StreamSubscription<String>? _textSub;
int _elapsedSeconds = 0;
Timer? _elapsedTimer;
// Removed server transcription; keep only on-device listening state
2025-08-10 01:20:45 +05:30
String _languageTag = 'en';
2025-08-22 13:54:58 +05:30
bool _holdToTalk = false;
bool _autoSendFinal = false;
2025-08-10 01:20:45 +05:30
@override
void initState() {
super.initState();
_voiceService = ref.read(voiceInputServiceProvider);
try {
2025-08-22 13:54:58 +05:30
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();
}
2025-08-10 01:20:45 +05:30
} catch (_) {
_languageTag = 'en';
}
2025-08-22 13:54:58 +05:30
// 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);
2025-08-24 14:35:17 +05:30
_languageTag = settings.voiceLocaleId!
.split(RegExp('[-_]'))
.first
.toLowerCase();
2025-08-22 13:54:58 +05:30
}
2025-08-10 01:20:45 +05:30
}
void _startListening() async {
setState(() {
_isListening = true;
_recognizedText = '';
_elapsedSeconds = 0;
});
2025-08-22 13:54:58 +05:30
// Haptic: indicate start listening
final hapticEnabled = ref.read(hapticEnabledProvider);
ps.PlatformService.hapticFeedbackWithSettings(
type: ps.HapticType.medium,
hapticEnabled: hapticEnabled,
);
2025-08-10 01:20:45 +05:30
try {
2025-08-28 19:48:35 +05:30
// Ensure service is initialized
2025-08-10 01:20:45 +05:30
final ok = await _voiceService.initialize();
2025-08-22 13:54:58 +05:30
if (!ok) {
throw Exception('Voice service unavailable');
}
2025-08-10 01:20:45 +05:30
// Start elapsed timer for UX
_elapsedTimer?.cancel();
_elapsedTimer = Timer.periodic(const Duration(seconds: 1), (t) {
if (!mounted || !_isListening) {
t.cancel();
return;
}
setState(() => _elapsedSeconds += 1);
});
2025-08-28 19:48:35 +05:30
// Centralized permission + start
final stream = await _voiceService.beginListening();
2025-08-10 01:20:45 +05:30
_intensitySub = _voiceService.intensityStream.listen((value) {
if (!mounted) return;
setState(() => _intensity = value);
});
_textSub = stream.listen(
(text) {
setState(() {
_recognizedText = text;
});
2025-08-10 01:20:45 +05:30
},
onDone: () {
2025-09-25 23:22:48 +05:30
DebugLogger.log('VoiceInputSheet stream done', scope: 'chat/page');
2025-08-10 01:20:45 +05:30
setState(() {
_isListening = false;
});
_elapsedTimer?.cancel();
2025-08-22 13:54:58 +05:30
// Auto-send on final local result if enabled
if (_autoSendFinal && _recognizedText.trim().isNotEmpty) {
_sendText();
}
2025-08-10 01:20:45 +05:30
},
onError: (error) {
2025-09-25 23:22:48 +05:30
DebugLogger.log(
'VoiceInputSheet stream error: $error',
scope: 'chat/page',
);
2025-08-10 01:20:45 +05:30
setState(() {
_isListening = false;
});
_elapsedTimer?.cancel();
2025-08-22 13:54:58 +05:30
if (mounted) {
final hapticEnabled = ref.read(hapticEnabledProvider);
ps.PlatformService.hapticFeedbackWithSettings(
type: ps.HapticType.warning,
hapticEnabled: hapticEnabled,
);
}
2025-08-10 01:20:45 +05:30
},
);
} catch (e) {
setState(() {
_isListening = false;
});
2025-08-21 16:15:27 +05:30
if (mounted) {}
2025-08-10 01:20:45 +05:30
}
}
// Server transcription removed; only on-device STT is supported
2025-08-10 01:20:45 +05:30
Future<void> _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;
});
}
2025-08-22 13:54:58 +05:30
// Haptic: subtle stop confirmation
final hapticEnabled = ref.read(hapticEnabledProvider);
ps.PlatformService.hapticFeedbackWithSettings(
type: ps.HapticType.selection,
hapticEnabled: hapticEnabled,
);
2025-08-10 01:20:45 +05:30
}
void _sendText() {
if (_recognizedText.isNotEmpty) {
2025-08-22 13:54:58 +05:30
// Haptic: success send
final hapticEnabled = ref.read(hapticEnabledProvider);
ps.PlatformService.hapticFeedbackWithSettings(
type: ps.HapticType.success,
hapticEnabled: hapticEnabled,
);
2025-08-10 01:20:45 +05:30
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';
}
2025-08-22 13:54:58 +05:30
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<String>(
context: context,
backgroundColor: Colors.transparent,
builder: (context) {
final l10n = AppLocalizations.of(context)!;
2025-08-22 13:54:58 +05:30
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),
2025-08-22 13:54:58 +05:30
),
padding: const EdgeInsets.all(Spacing.bottomSheetPadding),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SheetHandle(),
const SizedBox(height: Spacing.md),
2025-08-24 14:35:17 +05:30
Text(
l10n.selectLanguage,
2025-08-24 14:35:17 +05:30
style: TextStyle(
fontSize: AppTypography.headlineSmall,
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w600,
),
),
2025-08-22 13:54:58 +05:30
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];
2025-08-24 14:35:17 +05:30
final isSelected =
l.localeId == _voiceService.selectedLocaleId;
2025-08-22 13:54:58 +05:30
return ListTile(
title: Text(
l.name,
2025-08-24 14:35:17 +05:30
style: TextStyle(
color: context.conduitTheme.textPrimary,
),
2025-08-22 13:54:58 +05:30
),
subtitle: Text(
l.localeId,
2025-08-24 14:35:17 +05:30
style: TextStyle(
color: context.conduitTheme.textSecondary,
),
2025-08-22 13:54:58 +05:30
),
trailing: isSelected
2025-08-24 14:35:17 +05:30
? Icon(
Icons.check,
color: context.conduitTheme.buttonPrimary,
)
2025-08-22 13:54:58 +05:30
: 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<bool> onChanged,
}) {
final theme = context.conduitTheme;
return ps.PlatformService.getPlatformSwitch(
value: value,
onChanged: onChanged,
activeColor: theme.buttonPrimary,
);
2025-08-10 01:20:45 +05:30
}
@override
void dispose() {
_intensitySub?.cancel();
_textSub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
2025-08-22 13:54:58 +05:30
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;
2025-08-10 01:20:45 +05:30
return Container(
2025-08-22 13:54:58 +05:30
height: media.size.height * (isCompact ? 0.45 : 0.6),
2025-08-10 01:20:45 +05:30
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground,
borderRadius: const BorderRadius.vertical(
2025-08-22 13:54:58 +05:30
top: Radius.circular(AppBorderRadius.bottomSheet),
2025-08-10 01:20:45 +05:30
),
border: Border.all(color: context.conduitTheme.dividerColor, width: 1),
boxShadow: ConduitShadows.modal(context),
2025-08-10 01:20:45 +05:30
),
2025-08-22 13:54:58 +05:30
child: SafeArea(
top: false,
bottom: true,
child: Padding(
padding: const EdgeInsets.all(Spacing.bottomSheetPadding),
child: Column(
2025-08-24 14:35:17 +05:30
children: [
// Handle bar
const SheetHandle(),
2025-08-10 01:20:45 +05:30
2025-08-24 14:35:17 +05:30
// Header: Title + timer + language chip
2025-08-22 13:54:58 +05:30
Padding(
2025-08-24 14:35:17 +05:30
padding: const EdgeInsets.only(
top: Spacing.md,
bottom: Spacing.md,
),
2025-08-22 13:54:58 +05:30
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
statusText,
2025-08-24 14:35:17 +05:30
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,
),
],
],
),
2025-08-22 13:54:58 +05:30
),
2025-08-10 01:20:45 +05:30
),
2025-08-24 14:35:17 +05:30
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,
2025-08-22 13:54:58 +05:30
),
2025-08-24 14:35:17 +05:30
),
2025-08-10 01:20:45 +05:30
),
2025-08-24 14:35:17 +05:30
const SizedBox(width: Spacing.sm),
// Close sheet
ConduitIconButton(
icon: Platform.isIOS
? CupertinoIcons.xmark
: Icons.close,
tooltip: AppLocalizations.of(
context,
)!.closeButtonSemantic,
2025-08-24 14:35:17 +05:30
isCompact: true,
onPressed: () => Navigator.of(context).pop(),
2025-08-10 01:20:45 +05:30
),
2025-08-24 14:35:17 +05:30
],
2025-08-22 13:54:58 +05:30
),
2025-08-10 01:20:45 +05:30
],
),
2025-08-24 14:35:17 +05:30
),
2025-08-22 13:54:58 +05:30
2025-08-24 14:35:17 +05:30
// 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);
},
2025-08-10 01:20:45 +05:30
),
2025-08-24 14:35:17 +05:30
const SizedBox(width: Spacing.xs),
Text(
l10n.voiceHoldToTalk,
2025-08-24 14:35:17 +05:30
style: TextStyle(
color: context.conduitTheme.textSecondary,
),
2025-08-10 01:20:45 +05:30
),
2025-08-24 14:35:17 +05:30
],
2025-08-10 01:20:45 +05:30
),
2025-08-24 14:35:17 +05:30
),
Expanded(
2025-08-10 01:20:45 +05:30
child: Row(
2025-08-24 14:35:17 +05:30
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,
2025-08-24 14:35:17 +05:30
style: TextStyle(
color: context.conduitTheme.textSecondary,
2025-08-10 01:20:45 +05:30
),
2025-08-24 14:35:17 +05:30
),
],
2025-08-10 01:20:45 +05:30
),
),
2025-08-24 14:35:17 +05:30
],
),
),
2025-08-10 01:20:45 +05:30
2025-08-24 14:35:17 +05:30
// 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(
2025-08-22 13:54:58 +05:30
child: Column(
mainAxisSize: MainAxisSize.min,
2025-08-24 14:35:17 +05:30
mainAxisAlignment: MainAxisAlignment.center,
2025-08-22 13:54:58 +05:30
children: [
2025-08-24 14:35:17 +05:30
// 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
2025-08-22 13:54:58 +05:30
? () {
2025-08-24 14:35:17 +05:30
if (_isListening) _stopListening();
2025-08-22 13:54:58 +05:30
}
: null,
2025-08-24 14:35:17 +05:30
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),
2025-08-22 13:54:58 +05:30
),
2025-08-24 14:35:17 +05:30
SizedBox(
height: isUltra
? Spacing.xs
: (isCompact ? Spacing.sm : Spacing.md),
2025-08-22 13:54:58 +05:30
),
2025-08-24 14:35:17 +05:30
// Simple animated bars waveform based on intensity proxy
SizedBox(
height: isUltra ? 18 : (isCompact ? 24 : 32),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
2025-08-22 13:54:58 +05:30
child: Row(
2025-08-24 14:35:17 +05:30
key: ValueKey<int>(_intensity),
2025-08-22 13:54:58 +05:30
mainAxisAlignment: MainAxisAlignment.center,
2025-08-24 14:35:17 +05:30
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,
2025-08-22 13:54:58 +05:30
children: [
2025-08-24 14:35:17 +05:30
// Inline clear action aligned to the end
Row(
children: [
Text(
l10n.voiceTranscript,
2025-08-24 14:35:17 +05:30
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
2025-08-24 14:35:17 +05:30
? () {
setState(
() => _recognizedText = '',
);
}
: null,
),
],
2025-08-22 13:54:58 +05:30
),
2025-08-24 14:35:17 +05:30
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,
2025-08-24 14:35:17 +05:30
),
textAlign: TextAlign.center,
2025-08-24 14:35:17 +05:30
),
2025-08-22 13:54:58 +05:30
),
),
2025-08-22 13:54:58 +05:30
],
),
2025-08-10 01:20:45 +05:30
),
2025-08-24 14:35:17 +05:30
),
2025-08-22 13:54:58 +05:30
],
2025-08-10 01:20:45 +05:30
),
2025-08-24 14:35:17 +05:30
);
// 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,
2025-08-10 01:20:45 +05:30
),
2025-08-24 14:35:17 +05:30
);
},
),
),
// 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,
2025-08-10 01:20:45 +05:30
),
2025-08-24 14:35:17 +05:30
child: Row(
children: [
if (showStartStop) ...[
Expanded(
child: ConduitButton(
text: _isListening
? l10n.voiceActionStop
: l10n.voiceActionStart,
2025-08-24 14:35:17 +05:30
isSecondary: true,
isCompact: isCompact,
onPressed: _isListening
? _stopListening
: _startListening,
),
),
],
if (showStartStop && showSend)
const SizedBox(width: Spacing.xs),
if (showSend) ...[
Expanded(
child: ConduitButton(
text: l10n.send,
2025-08-24 14:35:17 +05:30
isCompact: isCompact,
onPressed: _recognizedText.isNotEmpty
? _sendText
: null,
),
),
],
],
2025-08-10 01:20:45 +05:30
),
2025-08-24 14:35:17 +05:30
);
},
2025-08-22 13:54:58 +05:30
),
2025-08-24 14:35:17 +05:30
],
),
2025-08-22 13:54:58 +05:30
),
),
2025-08-10 01:20:45 +05:30
);
}
}
/// 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),
2025-08-10 01:20:45 +05:30
),
child: Icon(
Icons.check,
color: context.conduitTheme.textInverse,
size: 16,
),
),
),
],
),
),
);
}
}
// Extension on _ChatPageState for utility methods
2025-08-20 22:15:26 +05:30
extension on _ChatPageState {}