2748 lines
96 KiB
Dart
2748 lines
96 KiB
Dart
import 'package:flutter/material.dart';
|
|
import '../../../core/services/navigation_service.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, File;
|
|
import 'dart:async';
|
|
import 'package:path/path.dart' as path;
|
|
import '../../../core/providers/app_providers.dart';
|
|
import '../providers/chat_providers.dart';
|
|
import '../../../core/utils/debug_logger.dart';
|
|
|
|
import '../widgets/modern_chat_input.dart';
|
|
import '../widgets/user_message_bubble.dart';
|
|
import '../widgets/assistant_message_widget.dart' as assistant;
|
|
import '../widgets/file_attachment_widget.dart';
|
|
import '../services/voice_input_service.dart';
|
|
import '../services/file_attachment_service.dart';
|
|
import '../../navigation/views/chats_list_page.dart';
|
|
import '../../files/views/files_page.dart';
|
|
import '../../profile/views/profile_page.dart';
|
|
import '../../tools/providers/tools_providers.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';
|
|
|
|
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;
|
|
|
|
String _formatModelDisplayName(String name) {
|
|
var display = name.trim();
|
|
// Prefer the segment after the last '/'
|
|
if (display.contains('/')) {
|
|
display = display.split('/').last.trim();
|
|
}
|
|
// If an org prefix like 'OpenAI: gpt-4o' exists, use the part after ':'
|
|
if (display.contains(':')) {
|
|
final parts = display.split(':');
|
|
display = parts.last.trim();
|
|
}
|
|
return display;
|
|
}
|
|
|
|
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).state = null;
|
|
|
|
// Scroll to top
|
|
if (_scrollController.hasClients) {
|
|
_scrollController.jumpTo(0);
|
|
}
|
|
}
|
|
|
|
Future<void> _checkAndAutoSelectModel() async {
|
|
// Check if a model is already selected
|
|
final selectedModel = ref.read(selectedModelProvider);
|
|
if (selectedModel != null) {
|
|
DebugLogger.log('Model already selected: ${selectedModel.name}');
|
|
return;
|
|
}
|
|
|
|
DebugLogger.log('No model selected, attempting auto-selection');
|
|
|
|
try {
|
|
// First ensure models are loaded
|
|
final modelsAsync = ref.read(modelsProvider);
|
|
List<Model> models;
|
|
|
|
if (modelsAsync.hasValue) {
|
|
models = modelsAsync.value!;
|
|
} else {
|
|
DebugLogger.log('Models not loaded yet, fetching...');
|
|
models = await ref.read(modelsProvider.future);
|
|
}
|
|
|
|
DebugLogger.log('Found ${models.length} models available');
|
|
|
|
if (models.isEmpty) {
|
|
DebugLogger.log('No models available for selection');
|
|
return;
|
|
}
|
|
|
|
// Try to use the default model provider
|
|
try {
|
|
final Model? model = await ref.read(defaultModelProvider.future);
|
|
if (model != null) {
|
|
DebugLogger.log('Model auto-selected via provider: ${model.name}');
|
|
}
|
|
} catch (e) {
|
|
DebugLogger.log(
|
|
'Default provider failed, selecting first model directly',
|
|
);
|
|
// Fallback: select the first available model
|
|
ref.read(selectedModelProvider.notifier).state = models.first;
|
|
DebugLogger.log('Fallback model selected: ${models.first.name}');
|
|
}
|
|
} catch (e) {
|
|
DebugLogger.error('Failed to auto-select model', e);
|
|
}
|
|
}
|
|
|
|
Future<void> _checkAndShowOnboarding() async {
|
|
try {
|
|
// Check if onboarding has been seen
|
|
final storage = ref.read(optimizedStorageServiceProvider);
|
|
final seen = await storage.getOnboardingSeen();
|
|
DebugLogger.log('Chat page - Onboarding seen status: $seen');
|
|
|
|
if (!seen && mounted) {
|
|
// Small delay to ensure navigation has settled
|
|
await Future.delayed(const Duration(milliseconds: 500));
|
|
if (!mounted) return;
|
|
|
|
DebugLogger.log('Showing onboarding from chat page');
|
|
_showOnboarding();
|
|
await storage.setOnboardingSeen(true);
|
|
DebugLogger.log('Onboarding marked as seen');
|
|
}
|
|
} catch (e) {
|
|
DebugLogger.error('Error checking onboarding status', 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,
|
|
),
|
|
child: const OnboardingSheet(),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _checkAndLoadDemoConversation() async {
|
|
final isReviewerMode = ref.read(reviewerModeProvider);
|
|
if (!isReviewerMode) return;
|
|
|
|
// Check if there's already an active conversation
|
|
final activeConversation = ref.read(activeConversationProvider);
|
|
if (activeConversation != null) {
|
|
DebugLogger.log(
|
|
'Conversation already active: ${activeConversation.title}',
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Force refresh conversations provider to ensure we get the demo conversations
|
|
ref.invalidate(conversationsProvider);
|
|
|
|
// Try to load demo conversation
|
|
for (int i = 0; i < 10; i++) {
|
|
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,
|
|
);
|
|
|
|
ref.read(activeConversationProvider.notifier).state = welcomeConv;
|
|
debugPrint('Auto-loaded demo conversation: ${welcomeConv.title}');
|
|
return;
|
|
}
|
|
|
|
// If conversations are still loading, wait a bit and retry
|
|
if (conversationsAsync.isLoading || i == 0) {
|
|
await Future.delayed(const Duration(milliseconds: 200));
|
|
continue;
|
|
}
|
|
|
|
// If there was an error or no conversations, break
|
|
break;
|
|
}
|
|
|
|
debugPrint('Failed to auto-load demo conversation');
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
// Listen to scroll events to show/hide scroll to bottom button
|
|
_scrollController.addListener(_onScroll);
|
|
|
|
// Initialize chat page components
|
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
// First, ensure a model is selected
|
|
await _checkAndAutoSelectModel();
|
|
|
|
// Then check for demo conversation in reviewer mode
|
|
await _checkAndLoadDemoConversation();
|
|
|
|
// Finally, show onboarding if needed
|
|
await _checkAndShowOnboarding();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_scrollController.dispose();
|
|
_scrollDebounceTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
void _handleMessageSend(String text, dynamic selectedModel) async {
|
|
debugPrint('DEBUG: Starting message send process');
|
|
debugPrint('DEBUG: Message text: $text');
|
|
debugPrint('DEBUG: Selected model: ${selectedModel?.name ?? 'null'}');
|
|
|
|
if (selectedModel == null) {
|
|
debugPrint('DEBUG: No model selected');
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Please select a model first')),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
final isOnline = ref.read(isOnlineProvider);
|
|
final isReviewerMode = ref.read(reviewerModeProvider);
|
|
debugPrint(
|
|
'DEBUG: Online status: $isOnline, Reviewer mode: $isReviewerMode',
|
|
);
|
|
if (!isOnline && !isReviewerMode) {
|
|
debugPrint('DEBUG: Offline - cannot send message');
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: const Text(
|
|
'You\'re offline. Message will be sent when connection is restored.',
|
|
),
|
|
backgroundColor: context.conduitTheme.warning,
|
|
),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Get attached files and use uploadedFileIds when sendMessage is updated to accept file IDs
|
|
final attachedFiles = ref.read(attachedFilesProvider);
|
|
debugPrint('DEBUG: Attached files count: ${attachedFiles.length}');
|
|
|
|
for (final file in attachedFiles) {
|
|
debugPrint(
|
|
'DEBUG: File - Name: ${file.fileName}, Status: ${file.status}, FileId: ${file.fileId}',
|
|
);
|
|
}
|
|
|
|
final uploadedFileIds = attachedFiles
|
|
.where(
|
|
(file) =>
|
|
file.status == FileUploadStatus.completed &&
|
|
file.fileId != null,
|
|
)
|
|
.map((file) => file.fileId!)
|
|
.toList();
|
|
|
|
debugPrint('DEBUG: Uploaded file IDs: $uploadedFileIds');
|
|
|
|
// Get selected tools
|
|
final toolIds = ref.read(selectedToolIdsProvider);
|
|
debugPrint('DEBUG: Selected tool IDs: $toolIds');
|
|
|
|
// Send message with file attachments and tools using existing provider logic
|
|
await sendMessage(
|
|
ref,
|
|
text,
|
|
uploadedFileIds.isNotEmpty ? uploadedFileIds : null,
|
|
toolIds.isNotEmpty ? toolIds : null,
|
|
);
|
|
|
|
debugPrint('DEBUG: Message sent successfully');
|
|
|
|
// Clear attachments after successful send
|
|
ref.read(attachedFilesProvider.notifier).clearAll();
|
|
debugPrint('DEBUG: Attachments cleared');
|
|
|
|
// Scroll to bottom after sending message (only if user was near bottom)
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (_scrollController.hasClients) {
|
|
final maxScroll = _scrollController.position.maxScrollExtent;
|
|
final currentScroll = _scrollController.position.pixels;
|
|
// Only auto-scroll if user was already near the bottom (within 300px)
|
|
if (maxScroll - currentScroll < 300) {
|
|
_scrollToBottom();
|
|
}
|
|
}
|
|
});
|
|
} catch (e) {
|
|
debugPrint('DEBUG: Message send error: $e');
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: const Text(
|
|
'Message failed to send. Check your connection and try again.',
|
|
),
|
|
backgroundColor: context.conduitTheme.error,
|
|
action: SnackBarAction(
|
|
label: 'Retry',
|
|
textColor: Colors.white,
|
|
onPressed: () => _handleMessageSend(text, selectedModel),
|
|
),
|
|
duration: const Duration(seconds: 6),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _handleVoiceInput() async {
|
|
// TODO: Implement voice input functionality
|
|
final isAvailable = await ref.read(voiceInputAvailableProvider.future);
|
|
|
|
if (!isAvailable) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: const Text('Voice input unavailable. Check permissions.'),
|
|
backgroundColor: context.conduitTheme.warning,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Show voice input dialog
|
|
if (!mounted) return;
|
|
showModalBottomSheet(
|
|
context: context,
|
|
backgroundColor: Colors.transparent,
|
|
isScrollControlled: true,
|
|
builder: (context) => _VoiceInputSheet(
|
|
onTextReceived: (text) {
|
|
if (text.isNotEmpty) {
|
|
final selectedModel = ref.read(selectedModelProvider);
|
|
if (selectedModel != null) {
|
|
_handleMessageSend(text, selectedModel);
|
|
}
|
|
}
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
void _handleFileAttachment() async {
|
|
// Check if selected model supports file upload
|
|
final fileUploadCapableModels = ref.read(fileUploadCapableModelsProvider);
|
|
if (fileUploadCapableModels.isEmpty) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: const Text('Selected model does not support file upload'),
|
|
backgroundColor: context.conduitTheme.error,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
final fileService = ref.read(fileAttachmentServiceProvider);
|
|
if (fileService == null) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(const SnackBar(content: Text('File service unavailable')));
|
|
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;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: const Text('Maximum 10 files allowed'),
|
|
backgroundColor: context.conduitTheme.error,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Validate file sizes
|
|
for (final file in files) {
|
|
final fileSize = await file.length();
|
|
if (!validateFileSize(fileSize, 20)) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
'File ${path.basename(file.path)} exceeds 20MB limit',
|
|
),
|
|
backgroundColor: context.conduitTheme.error,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Add files to the attachment list
|
|
ref.read(attachedFilesProvider.notifier).addFiles(files);
|
|
|
|
// Start uploading files
|
|
for (final file in files) {
|
|
final uploadStream = fileService.uploadFile(file);
|
|
uploadStream.listen(
|
|
(state) {
|
|
ref
|
|
.read(attachedFilesProvider.notifier)
|
|
.updateFileState(file.path, state);
|
|
},
|
|
onError: (error) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Upload failed: $error'),
|
|
backgroundColor: context.conduitTheme.error,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('File selection failed: $e'),
|
|
backgroundColor: context.conduitTheme.error,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
void _handleImageAttachment({bool fromCamera = false}) async {
|
|
debugPrint(
|
|
'DEBUG: Starting image attachment process - fromCamera: $fromCamera',
|
|
);
|
|
|
|
// Check if selected model supports vision
|
|
final visionCapableModels = ref.read(visionCapableModelsProvider);
|
|
if (visionCapableModels.isEmpty) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: const Text('Selected model does not support image inputs'),
|
|
backgroundColor: context.conduitTheme.error,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
final fileService = ref.read(fileAttachmentServiceProvider);
|
|
if (fileService == null) {
|
|
debugPrint('DEBUG: File service is null - cannot proceed');
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(const SnackBar(content: Text('File service unavailable')));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
debugPrint('DEBUG: Picking image...');
|
|
final image = fromCamera
|
|
? await fileService.takePhoto()
|
|
: await fileService.pickImage();
|
|
if (image == null) {
|
|
debugPrint('DEBUG: No image selected');
|
|
return;
|
|
}
|
|
|
|
debugPrint('DEBUG: Image selected: ${image.path}');
|
|
final imageSize = await image.length();
|
|
debugPrint('DEBUG: Image size: $imageSize bytes');
|
|
|
|
// Validate file size (default 20MB limit like OpenWebUI)
|
|
if (!validateFileSize(imageSize, 20)) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: const Text('Image size exceeds 20MB limit'),
|
|
backgroundColor: context.conduitTheme.error,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Validate file count (default 10 files limit like OpenWebUI)
|
|
final currentFiles = ref.read(attachedFilesProvider);
|
|
if (!validateFileCount(currentFiles.length, 1, 10)) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: const Text('Maximum 10 files allowed'),
|
|
backgroundColor: context.conduitTheme.error,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Add image to the attachment list
|
|
ref.read(attachedFilesProvider.notifier).addFiles([image]);
|
|
debugPrint('DEBUG: Image added to attachment list');
|
|
|
|
// Start uploading image
|
|
debugPrint('DEBUG: Starting image upload...');
|
|
final uploadStream = fileService.uploadFile(image);
|
|
uploadStream.listen(
|
|
(state) {
|
|
debugPrint(
|
|
'DEBUG: Upload state update - Status: ${state.status}, Progress: ${state.progress}, FileId: ${state.fileId}',
|
|
);
|
|
ref
|
|
.read(attachedFilesProvider.notifier)
|
|
.updateFileState(image.path, state);
|
|
},
|
|
onError: (error) {
|
|
debugPrint('DEBUG: Image upload error: $error');
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Image upload failed: $error'),
|
|
backgroundColor: context.conduitTheme.error,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
} catch (e) {
|
|
debugPrint('DEBUG: Image attachment error: $e');
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Image attachment failed: $e'),
|
|
backgroundColor: context.conduitTheme.error,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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;
|
|
});
|
|
}
|
|
|
|
// Show success message
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('New chat started'),
|
|
duration: Duration(seconds: 2),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showChatsListOverlay() {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (context) => Container(
|
|
height: MediaQuery.of(context).size.height * 0.9,
|
|
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,
|
|
),
|
|
child: SafeArea(
|
|
top: false,
|
|
bottom: true,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Handle bar
|
|
Container(
|
|
width: 40,
|
|
height: 4,
|
|
margin: const EdgeInsets.symmetric(vertical: Spacing.sm),
|
|
decoration: BoxDecoration(
|
|
color: context.conduitTheme.dividerColor,
|
|
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
|
),
|
|
),
|
|
Expanded(child: const ChatsListPage(isOverlay: true)),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showQuickAccessMenu() {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (context) => Container(
|
|
decoration: BoxDecoration(
|
|
color: context.conduitTheme.surfaceBackground,
|
|
borderRadius: const BorderRadius.vertical(
|
|
top: Radius.circular(AppBorderRadius.modal),
|
|
),
|
|
),
|
|
child: SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Handle bar
|
|
Container(
|
|
width: 40,
|
|
height: 4,
|
|
margin: const EdgeInsets.symmetric(vertical: Spacing.sm),
|
|
decoration: BoxDecoration(
|
|
color: context.conduitTheme.dividerColor,
|
|
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
|
),
|
|
),
|
|
// Hint text
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
|
|
child: Text(
|
|
'Quick Actions',
|
|
style: AppTypography.bodySmallStyle.copyWith(
|
|
color: context.conduitTheme.textSecondary,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
const SizedBox(height: Spacing.xs),
|
|
// Menu items
|
|
ListTile(
|
|
leading: Icon(
|
|
Platform.isIOS ? CupertinoIcons.plus : Icons.add_rounded,
|
|
color: context.conduitTheme.iconPrimary,
|
|
),
|
|
title: Text(
|
|
'New Chat',
|
|
style: AppTypography.bodyLargeStyle.copyWith(
|
|
color: context.conduitTheme.textPrimary,
|
|
),
|
|
),
|
|
subtitle: Text(
|
|
'Start a new conversation',
|
|
style: AppTypography.bodySmallStyle.copyWith(
|
|
color: context.conduitTheme.textSecondary,
|
|
),
|
|
),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_handleNewChat();
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: Icon(
|
|
Platform.isIOS
|
|
? CupertinoIcons.doc
|
|
: Icons.description_outlined,
|
|
color: context.conduitTheme.iconPrimary,
|
|
),
|
|
title: Text(
|
|
'Files',
|
|
style: AppTypography.bodyLargeStyle.copyWith(
|
|
color: context.conduitTheme.textPrimary,
|
|
),
|
|
),
|
|
subtitle: Text(
|
|
'Manage your files and documents',
|
|
style: AppTypography.bodySmallStyle.copyWith(
|
|
color: context.conduitTheme.textSecondary,
|
|
),
|
|
),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_navigateToFiles();
|
|
},
|
|
),
|
|
|
|
ListTile(
|
|
leading: Icon(
|
|
Platform.isIOS ? CupertinoIcons.person : Icons.person_outline,
|
|
color: context.conduitTheme.iconPrimary,
|
|
),
|
|
title: Text(
|
|
'Profile',
|
|
style: AppTypography.bodyLargeStyle.copyWith(
|
|
color: context.conduitTheme.textPrimary,
|
|
),
|
|
),
|
|
subtitle: Text(
|
|
'View and manage your profile',
|
|
style: AppTypography.bodySmallStyle.copyWith(
|
|
color: context.conduitTheme.textSecondary,
|
|
),
|
|
),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_navigateToProfile();
|
|
},
|
|
),
|
|
const SizedBox(height: Spacing.sm),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _navigateToFiles() {
|
|
Navigator.of(
|
|
context,
|
|
).push(MaterialPageRoute(builder: (context) => const FilesPage()));
|
|
}
|
|
|
|
void _navigateToProfile() {
|
|
Navigator.of(
|
|
context,
|
|
).push(MaterialPageRoute(builder: (context) => const ProfilePage()));
|
|
}
|
|
|
|
void _onScroll() {
|
|
if (!_scrollController.hasClients) return;
|
|
|
|
// Debounce scroll handling to reduce rebuilds
|
|
if (_scrollDebounceTimer?.isActive == true) return;
|
|
|
|
_scrollDebounceTimer = Timer(const Duration(milliseconds: 50), () {
|
|
if (!mounted || !_scrollController.hasClients) return;
|
|
|
|
final maxScroll = _scrollController.position.maxScrollExtent;
|
|
final currentScroll = _scrollController.position.pixels;
|
|
|
|
// Only show button if user has scrolled up significantly
|
|
final showButton = maxScroll > 100 && currentScroll < maxScroll - 200;
|
|
|
|
if (showButton != _showScrollToBottom && mounted) {
|
|
setState(() {
|
|
_showScrollToBottom = showButton;
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
void _scrollToBottom({bool smooth = true}) {
|
|
if (!_scrollController.hasClients) return;
|
|
|
|
final maxScroll = _scrollController.position.maxScrollExtent;
|
|
if (maxScroll <= 0) return;
|
|
|
|
if (smooth) {
|
|
_scrollController.animateTo(
|
|
maxScroll,
|
|
duration: const Duration(milliseconds: 200),
|
|
curve: Curves.easeOutCubic,
|
|
);
|
|
} else {
|
|
_scrollController.jumpTo(maxScroll);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
// TODO: Implement select all functionality when needed
|
|
// void _selectAllMessages() {
|
|
// final messages = ref.read(chatMessagesProvider);
|
|
// setState(() {
|
|
// _selectedMessageIds.clear();
|
|
// _selectedMessageIds.addAll(messages.map((m) => m.id));
|
|
// });
|
|
// }
|
|
|
|
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);
|
|
|
|
// 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() {
|
|
return ListView.builder(
|
|
key: const ValueKey('loading_messages'),
|
|
controller: _scrollController,
|
|
padding: const EdgeInsets.fromLTRB(
|
|
Spacing.lg,
|
|
Spacing.md,
|
|
Spacing.lg,
|
|
Spacing.lg,
|
|
),
|
|
physics:
|
|
const NeverScrollableScrollPhysics(), // Prevent scrolling during load
|
|
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,
|
|
),
|
|
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<ChatMessage> messages) {
|
|
if (messages.isEmpty) {
|
|
return _buildEmptyState(Theme.of(context));
|
|
}
|
|
|
|
return OptimizedList<ChatMessage>(
|
|
key: const ValueKey('actual_messages'),
|
|
scrollController: _scrollController,
|
|
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);
|
|
|
|
// 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: message.model,
|
|
onCopy: () => _copyMessage(message.content),
|
|
onEdit: () => _editMessage(message),
|
|
onRegenerate: () => _regenerateMessage(message),
|
|
onLike: () => _likeMessage(message),
|
|
onDislike: () => _dislikeMessage(message),
|
|
);
|
|
} else {
|
|
messageWidget = assistant.AssistantMessageWidget(
|
|
key: ValueKey('assistant-${message.id}'),
|
|
message: message,
|
|
isStreaming: isStreaming,
|
|
modelName: message.model,
|
|
onCopy: () => _copyMessage(message.content),
|
|
onRegenerate: () => _regenerateMessage(message),
|
|
onLike: () => _likeMessage(message),
|
|
onDislike: () => _dislikeMessage(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));
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Copied to clipboard'),
|
|
duration: Duration(seconds: 2),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _regenerateMessage(dynamic message) async {
|
|
final selectedModel = ref.read(selectedModelProvider);
|
|
if (selectedModel == null) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Please select a model first')),
|
|
);
|
|
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') {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Cannot regenerate this message')),
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// 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,
|
|
);
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Regenerating...'),
|
|
duration: Duration(seconds: 2),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
'Failed to regenerate message. Try again or check your connection.',
|
|
),
|
|
backgroundColor: context.conduitTheme.error,
|
|
action: SnackBarAction(
|
|
label: 'Retry',
|
|
textColor: Colors.white,
|
|
onPressed: () => _regenerateMessage(message),
|
|
),
|
|
duration: const Duration(seconds: 6),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _editMessage(dynamic message) async {
|
|
if (message.role != 'user') {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Only user messages can be edited')),
|
|
);
|
|
return;
|
|
}
|
|
|
|
final controller = TextEditingController(text: message.content);
|
|
final result = await showDialog<String>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
backgroundColor: context.conduitTheme.surfaceBackground,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(AppBorderRadius.dialog),
|
|
),
|
|
title: Text(
|
|
'Edit Message',
|
|
style: TextStyle(color: context.conduitTheme.textPrimary),
|
|
),
|
|
content: TextField(
|
|
controller: controller,
|
|
style: TextStyle(color: context.conduitTheme.textPrimary),
|
|
maxLines: null,
|
|
decoration: InputDecoration(
|
|
hintText: 'Enter your message',
|
|
hintStyle: TextStyle(color: context.conduitTheme.inputPlaceholder),
|
|
border: OutlineInputBorder(
|
|
borderSide: BorderSide(color: context.conduitTheme.inputBorder),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderSide: BorderSide(color: context.conduitTheme.inputBorder),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderSide: BorderSide(color: context.conduitTheme.buttonPrimary),
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text(
|
|
'Cancel',
|
|
style: TextStyle(color: context.conduitTheme.textSecondary),
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, controller.text.trim()),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: context.conduitTheme.buttonPrimary,
|
|
),
|
|
child: const Text('Save'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (result != null && result.isNotEmpty && result != message.content) {
|
|
try {
|
|
// Find the message index and remove all messages after it
|
|
final messages = ref.read(chatMessagesProvider);
|
|
final messageIndex = messages.indexOf(message);
|
|
|
|
if (messageIndex >= 0) {
|
|
// Remove messages from this point onwards
|
|
final messagesToKeep = messages.take(messageIndex).toList();
|
|
ref.read(chatMessagesProvider.notifier).setMessages(messagesToKeep);
|
|
|
|
// Send the edited message
|
|
final selectedModel = ref.read(selectedModelProvider);
|
|
if (selectedModel != null) {
|
|
await sendMessage(ref, result, null);
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Message updated'),
|
|
duration: Duration(seconds: 2),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Failed to edit message: $e'),
|
|
backgroundColor: context.conduitTheme.error,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
controller.dispose();
|
|
}
|
|
|
|
void _likeMessage(dynamic message) {
|
|
// TODO: Implement message liking
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(const SnackBar(content: Text('Message liked!')));
|
|
}
|
|
|
|
void _dislikeMessage(dynamic message) {
|
|
// TODO: Implement message disliking
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(const SnackBar(content: Text('Message disliked!')));
|
|
}
|
|
|
|
Widget _buildEmptyState(ThemeData theme) {
|
|
return Center(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(Spacing.lg),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
// Minimal, clean empty state
|
|
Container(
|
|
width: Spacing.xxl + Spacing.xxxl,
|
|
height: Spacing.xxl + Spacing.xxxl,
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [
|
|
context.conduitTheme.buttonPrimary,
|
|
context.conduitTheme.buttonPrimary.withValues(
|
|
alpha: 0.8,
|
|
),
|
|
],
|
|
),
|
|
borderRadius: BorderRadius.circular(AppBorderRadius.round),
|
|
boxShadow: ConduitShadows.glow,
|
|
),
|
|
child: Icon(
|
|
Platform.isIOS ? CupertinoIcons.chat_bubble_2 : Icons.chat,
|
|
size: Spacing.xxxl - Spacing.xs,
|
|
color: context.conduitTheme.textInverse,
|
|
),
|
|
)
|
|
.animate()
|
|
.scale(duration: const Duration(milliseconds: 300))
|
|
.then()
|
|
.shimmer(duration: const Duration(milliseconds: 1200)),
|
|
|
|
const SizedBox(height: Spacing.xl),
|
|
|
|
Text(
|
|
'Start a conversation',
|
|
style: theme.textTheme.headlineSmall?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
color: context.conduitTheme.textPrimary,
|
|
),
|
|
).animate().fadeIn(delay: const Duration(milliseconds: 150)),
|
|
|
|
const SizedBox(height: Spacing.sm),
|
|
|
|
Text(
|
|
'Type below to begin',
|
|
style: theme.textTheme.bodyLarge?.copyWith(
|
|
color: context.conduitTheme.textSecondary,
|
|
fontWeight: FontWeight.w400,
|
|
),
|
|
).animate().fadeIn(delay: const Duration(milliseconds: 300)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Removed detailed help items from chat page; guidance now lives in Onboarding
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.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);
|
|
|
|
// Auto-select model when in reviewer mode with no selection
|
|
if (isReviewerMode && selectedModel == null) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_checkAndAutoSelectModel();
|
|
});
|
|
}
|
|
|
|
return ErrorBoundary(
|
|
child: PopScope(
|
|
canPop: false,
|
|
onPopInvokedWithResult: (bool didPop, Object? result) async {
|
|
if (didPop) return;
|
|
|
|
// Check if there's unsaved content
|
|
final messages = ref.read(chatMessagesProvider);
|
|
if (messages.isNotEmpty) {
|
|
// Check if currently streaming
|
|
final isStreaming = messages.any((msg) => msg.isStreaming);
|
|
|
|
final shouldPop = await NavigationService.confirmNavigation(
|
|
title: 'Leave Chat?',
|
|
message: isStreaming
|
|
? 'The AI is still responding. Leave anyway?'
|
|
: 'Your conversation will be saved.',
|
|
confirmText: 'Leave',
|
|
cancelText: 'Stay',
|
|
);
|
|
if (shouldPop && context.mounted) {
|
|
// If streaming, stop it first
|
|
if (isStreaming) {
|
|
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
|
}
|
|
|
|
// Save the conversation before leaving
|
|
await _saveConversationBeforeLeaving(ref);
|
|
|
|
if (context.mounted) {
|
|
final canPopNavigator = Navigator.of(context).canPop();
|
|
if (canPopNavigator) {
|
|
Navigator.of(context).pop();
|
|
} else {
|
|
SystemNavigator.pop();
|
|
}
|
|
}
|
|
}
|
|
} else if (context.mounted) {
|
|
final canPopNavigator = Navigator.of(context).canPop();
|
|
if (canPopNavigator) {
|
|
Navigator.of(context).pop();
|
|
} else {
|
|
SystemNavigator.pop();
|
|
}
|
|
}
|
|
},
|
|
child: Scaffold(
|
|
backgroundColor: context.conduitTheme.surfaceBackground,
|
|
appBar: AppBar(
|
|
backgroundColor: context.conduitTheme.surfaceBackground,
|
|
elevation: Elevation.none,
|
|
surfaceTintColor: Colors.transparent,
|
|
shadowColor: Colors.transparent,
|
|
toolbarHeight: kToolbarHeight,
|
|
titleSpacing: 0.0,
|
|
leading: _isSelectionMode
|
|
? IconButton(
|
|
icon: Icon(
|
|
Platform.isIOS ? CupertinoIcons.xmark : Icons.close,
|
|
color: context.conduitTheme.textPrimary,
|
|
size: IconSize.appBar,
|
|
),
|
|
onPressed: _clearSelection,
|
|
)
|
|
: GestureDetector(
|
|
onTap: () {
|
|
_showChatsListOverlay();
|
|
},
|
|
onLongPress: () {
|
|
HapticFeedback.mediumImpact();
|
|
_showQuickAccessMenu();
|
|
},
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(4.0),
|
|
child: 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,
|
|
),
|
|
)
|
|
: selectedModel != null
|
|
? GestureDetector(
|
|
onTap: () {
|
|
final modelsAsync = ref.read(modelsProvider);
|
|
modelsAsync.whenData(
|
|
(models) => _showModelDropdown(context, ref, models),
|
|
);
|
|
},
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Flexible(
|
|
child: Text(
|
|
_formatModelDisplayName(selectedModel.name),
|
|
style: AppTypography.headlineSmallStyle
|
|
.copyWith(
|
|
color: context.conduitTheme.textPrimary,
|
|
fontWeight: FontWeight.w400,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (ref.watch(reviewerModeProvider))
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: GestureDetector(
|
|
onTap: () {
|
|
final modelsAsync = ref.read(modelsProvider);
|
|
modelsAsync.whenData(
|
|
(models) => _showModelDropdown(context, ref, models),
|
|
);
|
|
},
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Flexible(
|
|
child: Text(
|
|
'Choose Model',
|
|
style: AppTypography.headlineSmallStyle
|
|
.copyWith(
|
|
color: context.conduitTheme.textPrimary,
|
|
fontWeight: FontWeight.w400,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (ref.watch(reviewerModeProvider))
|
|
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) ...[
|
|
IconButton(
|
|
icon: Icon(
|
|
Platform.isIOS
|
|
? CupertinoIcons.bubble_left
|
|
: Icons.chat_bubble_outline,
|
|
color: context.conduitTheme.textPrimary,
|
|
size: IconSize.appBar,
|
|
),
|
|
onPressed: _handleNewChat,
|
|
tooltip: 'New Chat',
|
|
),
|
|
] else ...[
|
|
IconButton(
|
|
icon: Icon(
|
|
Platform.isIOS ? CupertinoIcons.delete : Icons.delete,
|
|
color: context.conduitTheme.error,
|
|
size: IconSize.appBar,
|
|
),
|
|
onPressed: _deleteSelectedMessages,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
body: 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)
|
|
.state =
|
|
full;
|
|
} catch (e) {
|
|
debugPrint(
|
|
'DEBUG: Failed to refresh conversation: $e',
|
|
);
|
|
// Could show a snackbar here if needed
|
|
}
|
|
}
|
|
// Add small delay for better UX feedback
|
|
await Future.delayed(const Duration(milliseconds: 300));
|
|
},
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: () =>
|
|
FocusManager.instance.primaryFocus?.unfocus(),
|
|
child: _buildMessagesList(theme),
|
|
),
|
|
),
|
|
),
|
|
|
|
// File attachments
|
|
const FileAttachmentWidget(),
|
|
|
|
// Offline indicator
|
|
const ChatOfflineOverlay(),
|
|
|
|
// Modern Input (root matches input background including safe area)
|
|
ModernChatInput(
|
|
enabled:
|
|
selectedModel != null &&
|
|
(isOnline || ref.watch(reviewerModeProvider)),
|
|
onSendMessage: (text) =>
|
|
_handleMessageSend(text, selectedModel),
|
|
onVoiceInput: _handleVoiceInput,
|
|
onFileAttachment: _handleFileAttachment,
|
|
onImageAttachment: _handleImageAttachment,
|
|
onCameraCapture: () =>
|
|
_handleImageAttachment(fromCamera: true),
|
|
),
|
|
],
|
|
),
|
|
|
|
// Floating Scroll to Bottom Button (only if there are messages)
|
|
if (_showScrollToBottom &&
|
|
ref.watch(chatMessagesProvider).isNotEmpty)
|
|
Positioned(
|
|
bottom:
|
|
Spacing.xxl +
|
|
Spacing
|
|
.xxxl, // Position higher to avoid overlapping chat input
|
|
right: Spacing.lg,
|
|
child: FloatingActionButton(
|
|
onPressed: _scrollToBottom,
|
|
backgroundColor: context.conduitTheme.buttonPrimary,
|
|
foregroundColor: context.conduitTheme.buttonPrimaryText,
|
|
elevation: Elevation.medium,
|
|
child: Icon(
|
|
Platform.isIOS
|
|
? CupertinoIcons.arrow_down
|
|
: Icons.keyboard_arrow_down,
|
|
size: IconSize.large,
|
|
),
|
|
),
|
|
)
|
|
.animate()
|
|
.fadeIn(duration: AnimationDuration.microInteraction)
|
|
.slideY(
|
|
begin: AnimationValues.slideInFromBottom.dy,
|
|
end: AnimationValues.slideCenter.dy,
|
|
duration: AnimationDuration.microInteraction,
|
|
curve: AnimationCurves.microInteraction,
|
|
),
|
|
],
|
|
),
|
|
), // Scaffold
|
|
), // PopScope
|
|
); // ErrorBoundary
|
|
}
|
|
|
|
Future<void> _saveConversationBeforeLeaving(WidgetRef ref) async {
|
|
try {
|
|
final api = ref.read(apiServiceProvider);
|
|
final messages = ref.read(chatMessagesProvider);
|
|
final activeConversation = ref.read(activeConversationProvider);
|
|
final selectedModel = ref.read(selectedModelProvider);
|
|
|
|
if (api == null || messages.isEmpty || activeConversation == null) {
|
|
return;
|
|
}
|
|
|
|
// Remove trailing assistant message only if it has no text and no files
|
|
final lastMessage = messages.isNotEmpty ? messages.last : null;
|
|
if (lastMessage != null &&
|
|
lastMessage.role == 'assistant' &&
|
|
lastMessage.content.trim().isEmpty &&
|
|
(lastMessage.files == null || lastMessage.files!.isEmpty) &&
|
|
(lastMessage.attachmentIds == null ||
|
|
lastMessage.attachmentIds!.isEmpty)) {
|
|
messages.removeLast();
|
|
if (messages.isEmpty) return;
|
|
}
|
|
|
|
// Update the existing conversation with all messages
|
|
await api.updateConversationWithMessages(
|
|
activeConversation.id,
|
|
messages,
|
|
model: selectedModel?.id,
|
|
);
|
|
|
|
debugPrint('DEBUG: Conversation saved before leaving');
|
|
} catch (e) {
|
|
debugPrint('DEBUG: Failed to save conversation before leaving: $e');
|
|
// Don't block navigation even if save fails
|
|
}
|
|
}
|
|
|
|
void _showModelDropdown(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
List<Model> models,
|
|
) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (context) => _ModelSelectorSheet(models: models, ref: ref),
|
|
);
|
|
}
|
|
|
|
// TODO: Implement chat options when needed
|
|
// void _showChatOptions() {
|
|
// ScaffoldMessenger.of(
|
|
// context,
|
|
// ).showSnackBar(const SnackBar(content: Text('Chat options coming soon!')));
|
|
// }
|
|
|
|
void _deleteSelectedMessages() {
|
|
final selectedMessages = _getSelectedMessages();
|
|
if (selectedMessages.isEmpty) return;
|
|
|
|
ThemedDialogs.confirm(
|
|
context,
|
|
title: 'Delete Messages',
|
|
message: 'Delete ${selectedMessages.length} messages?',
|
|
confirmText: 'Delete',
|
|
isDestructive: true,
|
|
).then((confirmed) async {
|
|
if (confirmed == true) {
|
|
// TODO: Implement message removal
|
|
// for (final selectedMessage in selectedMessages) {
|
|
// ref.read(chatMessagesProvider.notifier).removeMessage(selectedMessage.id);
|
|
// }
|
|
_clearSelection();
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Messages removed'),
|
|
duration: Duration(seconds: 2),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
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) {
|
|
// Debounce for fast search
|
|
_searchDebounce?.cancel();
|
|
_searchDebounce = Timer(const Duration(milliseconds: 160), () {
|
|
setState(() {
|
|
_searchQuery = query.toLowerCase();
|
|
Iterable<Model> list = widget.models;
|
|
if (_searchQuery.isNotEmpty) {
|
|
list = list.where((model) {
|
|
return model.name.toLowerCase().contains(_searchQuery) ||
|
|
model.id.toLowerCase().contains(_searchQuery);
|
|
});
|
|
}
|
|
// No capability filters
|
|
_filteredModels = list.toList();
|
|
});
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return DraggableScrollableSheet(
|
|
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,
|
|
),
|
|
child: SafeArea(
|
|
top: false,
|
|
bottom: true,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(Spacing.bottomSheetPadding),
|
|
child: Column(
|
|
children: [
|
|
// Handle bar
|
|
Container(
|
|
margin: const EdgeInsets.only(
|
|
top: Spacing.sm,
|
|
bottom: Spacing.md,
|
|
),
|
|
width: Spacing.xxl,
|
|
height: Spacing.xs,
|
|
decoration: BoxDecoration(
|
|
color: context.conduitTheme.dividerColor,
|
|
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
|
),
|
|
),
|
|
|
|
// Search field
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: Spacing.md),
|
|
child: TextField(
|
|
controller: _searchController,
|
|
style: TextStyle(color: context.conduitTheme.textPrimary),
|
|
decoration: InputDecoration(
|
|
hintText: 'Search...',
|
|
hintStyle: TextStyle(
|
|
color: context.conduitTheme.inputPlaceholder,
|
|
),
|
|
prefixIcon: Icon(
|
|
Platform.isIOS ? CupertinoIcons.search : Icons.search,
|
|
color: context.conduitTheme.iconSecondary,
|
|
),
|
|
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.md,
|
|
),
|
|
),
|
|
onChanged: _filterModels,
|
|
),
|
|
),
|
|
|
|
// 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,
|
|
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,
|
|
)
|
|
.state =
|
|
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,
|
|
}) {
|
|
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 : null,
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: Spacing.md,
|
|
vertical: Spacing.sm,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 32,
|
|
height: 32,
|
|
decoration: BoxDecoration(
|
|
color: context.conduitTheme.buttonPrimary.withValues(
|
|
alpha: 0.15,
|
|
),
|
|
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
|
),
|
|
child: Icon(
|
|
Platform.isIOS ? CupertinoIcons.cube : Icons.psychology,
|
|
color: context.conduitTheme.buttonPrimary,
|
|
size: 16,
|
|
),
|
|
),
|
|
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
|
|
}
|
|
|
|
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;
|
|
bool _isTranscribing = false;
|
|
String _languageTag = 'en';
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_voiceService = ref.read(voiceInputServiceProvider);
|
|
try {
|
|
_languageTag = WidgetsBinding.instance.platformDispatcher.locale
|
|
.toLanguageTag()
|
|
.split(RegExp('[-_]'))
|
|
.first
|
|
.toLowerCase();
|
|
} catch (_) {
|
|
_languageTag = 'en';
|
|
}
|
|
}
|
|
|
|
void _startListening() async {
|
|
setState(() {
|
|
_isListening = true;
|
|
_recognizedText = '';
|
|
_elapsedSeconds = 0;
|
|
});
|
|
|
|
try {
|
|
// Ensure service is initialized and has permission
|
|
final ok = await _voiceService.initialize();
|
|
if (!ok || !await _voiceService.checkPermissions()) {
|
|
throw Exception('Microphone permission not granted');
|
|
}
|
|
|
|
// Start elapsed timer for UX
|
|
_elapsedTimer?.cancel();
|
|
_elapsedTimer = Timer.periodic(const Duration(seconds: 1), (t) {
|
|
if (!mounted || !_isListening) {
|
|
t.cancel();
|
|
return;
|
|
}
|
|
setState(() => _elapsedSeconds += 1);
|
|
});
|
|
|
|
final stream = _voiceService.startListening();
|
|
_intensitySub = _voiceService.intensityStream.listen((value) {
|
|
if (!mounted) return;
|
|
setState(() => _intensity = value);
|
|
});
|
|
_textSub = stream.listen(
|
|
(text) {
|
|
// If we receive a special token with recorded audio path, transcribe it via API
|
|
if (text.startsWith('[[AUDIO_FILE_PATH]]:')) {
|
|
final filePath = text.split(':').skip(1).join(':');
|
|
debugPrint(
|
|
'DEBUG: VoiceInputSheet received audio file path: $filePath',
|
|
);
|
|
_transcribeRecordedFile(filePath);
|
|
} else {
|
|
setState(() {
|
|
_recognizedText = text;
|
|
});
|
|
}
|
|
},
|
|
onDone: () {
|
|
debugPrint('DEBUG: VoiceInputSheet stream done');
|
|
setState(() {
|
|
_isListening = false;
|
|
});
|
|
_elapsedTimer?.cancel();
|
|
},
|
|
onError: (error) {
|
|
debugPrint('DEBUG: VoiceInputSheet stream error: $error');
|
|
setState(() {
|
|
_isListening = false;
|
|
});
|
|
_elapsedTimer?.cancel();
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Voice input error: $error'),
|
|
backgroundColor: context.conduitTheme.error,
|
|
),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
} catch (e) {
|
|
setState(() {
|
|
_isListening = false;
|
|
});
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Failed to start voice input: $e'),
|
|
backgroundColor: context.conduitTheme.error,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _transcribeRecordedFile(String filePath) async {
|
|
try {
|
|
setState(() => _isTranscribing = true);
|
|
final api = ref.read(apiServiceProvider);
|
|
if (api == null) throw Exception('API service unavailable');
|
|
final file = File(filePath);
|
|
final bytes = await file.readAsBytes();
|
|
// Try to use device locale; fall back to en-US
|
|
String? language;
|
|
try {
|
|
language = WidgetsBinding.instance.platformDispatcher.locale
|
|
.toLanguageTag();
|
|
} catch (_) {
|
|
language = 'en-US';
|
|
}
|
|
final text = await api.transcribeAudio(
|
|
bytes.toList(),
|
|
language: language,
|
|
);
|
|
debugPrint(
|
|
'DEBUG: Transcription received: ${text.isEmpty ? '[empty]' : text}',
|
|
);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_recognizedText = text;
|
|
});
|
|
// Stop listening state if we have a result
|
|
setState(() => _isListening = false);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Transcription failed: $e'),
|
|
backgroundColor: context.conduitTheme.error,
|
|
),
|
|
);
|
|
setState(() => _isListening = false);
|
|
} finally {
|
|
if (mounted) setState(() => _isTranscribing = false);
|
|
}
|
|
}
|
|
|
|
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;
|
|
});
|
|
}
|
|
}
|
|
|
|
void _sendText() {
|
|
if (_recognizedText.isNotEmpty) {
|
|
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 _cancel() {
|
|
_stopListening();
|
|
Navigator.pop(context);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_intensitySub?.cancel();
|
|
_textSub?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
height: MediaQuery.of(context).size.height * 0.6,
|
|
decoration: BoxDecoration(
|
|
color: context.conduitTheme.surfaceBackground,
|
|
borderRadius: const BorderRadius.vertical(
|
|
top: Radius.circular(AppBorderRadius.lg),
|
|
),
|
|
border: Border.all(color: context.conduitTheme.dividerColor, width: 1),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
// Handle bar
|
|
Container(
|
|
margin: const EdgeInsets.only(top: Spacing.sm),
|
|
width: 40,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: context.conduitTheme.dividerColor,
|
|
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
|
),
|
|
),
|
|
|
|
// Header: Title + timer + language chip
|
|
Padding(
|
|
padding: const EdgeInsets.all(Spacing.lg),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
_isListening
|
|
? 'Listening\u2026'
|
|
: _isTranscribing
|
|
? 'Transcribing\u2026'
|
|
: 'Voice',
|
|
style: TextStyle(
|
|
fontSize: AppTypography.headlineMedium,
|
|
fontWeight: FontWeight.w600,
|
|
color: context.conduitTheme.textPrimary,
|
|
),
|
|
),
|
|
Row(
|
|
children: [
|
|
// Language chip
|
|
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: Text(
|
|
_languageTag.toUpperCase(),
|
|
style: TextStyle(
|
|
fontSize: AppTypography.labelSmall,
|
|
color: context.conduitTheme.textSecondary,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Microphone animation and waveform
|
|
Expanded(
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
// Microphone icon with animation (tap to toggle)
|
|
GestureDetector(
|
|
onTap: () =>
|
|
_isListening ? _stopListening() : _startListening(),
|
|
child: Container(
|
|
width: 100,
|
|
height: 100,
|
|
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: 40,
|
|
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),
|
|
),
|
|
|
|
const SizedBox(height: Spacing.md),
|
|
// Simple animated bars waveform based on intensity proxy
|
|
SizedBox(
|
|
height: 32,
|
|
child: AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 150),
|
|
child: Row(
|
|
key: ValueKey<int>(_intensity),
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: List.generate(12, (i) {
|
|
final normalized = ((_intensity + i) % 10) / 10.0;
|
|
final barHeight = 8 + (normalized * 24);
|
|
return Container(
|
|
width: 4,
|
|
height: barHeight,
|
|
margin: const EdgeInsets.symmetric(horizontal: 2),
|
|
decoration: BoxDecoration(
|
|
color: context.conduitTheme.buttonPrimary
|
|
.withValues(alpha: 0.7),
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: Spacing.xl),
|
|
|
|
// Recognized text / Transcribing state
|
|
Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: 20),
|
|
padding: const EdgeInsets.all(Spacing.md),
|
|
constraints: BoxConstraints(
|
|
maxHeight: MediaQuery.of(context).size.height * 0.2,
|
|
minHeight: 80,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: context.conduitTheme.inputBackground,
|
|
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
|
border: Border.all(
|
|
color: context.conduitTheme.inputBorder,
|
|
width: 1,
|
|
),
|
|
),
|
|
child: _isTranscribing
|
|
? Center(
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: context.conduitTheme.buttonPrimary,
|
|
),
|
|
),
|
|
const SizedBox(width: Spacing.xs),
|
|
Text(
|
|
'Transcribing…',
|
|
style: TextStyle(
|
|
fontSize: AppTypography.bodyLarge,
|
|
color: context.conduitTheme.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: SingleChildScrollView(
|
|
child: Text(
|
|
_recognizedText.isEmpty
|
|
? (_isListening
|
|
? 'Speak now…'
|
|
: 'Tap Start to begin')
|
|
: _recognizedText,
|
|
style: TextStyle(
|
|
fontSize: AppTypography.bodyLarge,
|
|
color: _recognizedText.isEmpty
|
|
? context.conduitTheme.inputPlaceholder
|
|
: context.conduitTheme.textPrimary,
|
|
height: 1.5,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
// Action buttons
|
|
Padding(
|
|
padding: const EdgeInsets.all(Spacing.lg),
|
|
child: Row(
|
|
children: [
|
|
// Start/Stop toggle button
|
|
Expanded(
|
|
child: FilledButton.tonal(
|
|
onPressed: _isListening ? _stopListening : _startListening,
|
|
style: FilledButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(vertical: Spacing.md),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
|
),
|
|
),
|
|
child: Text(
|
|
_isListening ? 'Stop' : 'Start',
|
|
style: TextStyle(
|
|
fontSize: AppTypography.bodyLarge,
|
|
fontWeight: FontWeight.w600,
|
|
color: context.conduitTheme.textPrimary,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
const SizedBox(width: Spacing.xs),
|
|
// Cancel button
|
|
Expanded(
|
|
child: TextButton(
|
|
onPressed: _cancel,
|
|
style: TextButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(vertical: Spacing.md),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
|
side: BorderSide(
|
|
color: context.conduitTheme.dividerColor,
|
|
width: 1,
|
|
),
|
|
),
|
|
),
|
|
child: Text(
|
|
'Cancel',
|
|
style: TextStyle(
|
|
color: context.conduitTheme.textPrimary,
|
|
fontSize: AppTypography.bodyLarge,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
const SizedBox(width: Spacing.xs),
|
|
|
|
// Send button
|
|
Expanded(
|
|
child: FilledButton(
|
|
onPressed: _recognizedText.isNotEmpty ? _sendText : null,
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: context.conduitTheme.buttonPrimary,
|
|
foregroundColor: context.conduitTheme.buttonPrimaryText,
|
|
padding: const EdgeInsets.symmetric(vertical: Spacing.md),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
|
),
|
|
),
|
|
child: Text(
|
|
'Send',
|
|
style: TextStyle(
|
|
fontSize: AppTypography.bodyLarge,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
),
|
|
child: Icon(
|
|
Icons.check,
|
|
color: context.conduitTheme.textInverse,
|
|
size: 16,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Extension on _ChatPageState for utility methods
|
|
extension on _ChatPageState {}
|