diff --git a/lib/core/services/navigation_service.dart b/lib/core/services/navigation_service.dart index c4d1806..3fcb4e3 100644 --- a/lib/core/services/navigation_service.dart +++ b/lib/core/services/navigation_service.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import '../../features/auth/views/connect_signin_page.dart'; import '../../features/chat/views/chat_page.dart'; import '../../features/files/views/files_page.dart'; -import '../../features/navigation/views/chats_list_page.dart'; import '../../features/profile/views/profile_page.dart'; import '../../shared/widgets/themed_dialogs.dart'; @@ -92,10 +91,7 @@ class NavigationService { return navigateTo(Routes.serverConnection); } - /// Navigate to chats list - static Future navigateToChatsList() { - return navigateTo(Routes.chatsList); - } + // Chats list is now provided as a left drawer in ChatPage /// Clear navigation stack (useful for logout) static void clearNavigationStack() { @@ -138,9 +134,7 @@ class NavigationService { page = const FilesPage(); break; - case Routes.chatsList: - page = const ChatsListPage(); - break; + // chats list route removed (replaced by drawer) // Removed navigation drawer route @@ -161,5 +155,4 @@ class Routes { static const String profile = '/profile'; static const String serverConnection = '/server-connection'; static const String files = '/files'; - static const String chatsList = '/chats-list'; } diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index 1346c65..7fdd52a 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -1,5 +1,4 @@ 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'; @@ -19,10 +18,10 @@ 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 '../../navigation/widgets/chats_drawer.dart'; import '../../../shared/widgets/offline_indicator.dart'; import '../../../core/services/connectivity_service.dart'; import '../../../core/models/chat_message.dart'; @@ -463,47 +462,7 @@ class _ChatPageState extends ConsumerState { } } - 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)), - ], - ), - ), - ), - ); - } + // Replaced bottom-sheet chat list with left drawer (see ChatsDrawer) void _showQuickAccessMenu() { showModalBottomSheet( @@ -1117,39 +1076,16 @@ class _ChatPageState extends ConsumerState { onPopInvokedWithResult: (bool didPop, Object? result) async { if (didPop) return; - // Check if there's unsaved content + // Auto-handle leaving without confirmation final messages = ref.read(chatMessagesProvider); - if (messages.isNotEmpty) { - // Check if currently streaming - final isStreaming = messages.any((msg) => msg.isStreaming); + final isStreaming = messages.any((msg) => msg.isStreaming); + if (isStreaming) { + ref.read(chatMessagesProvider.notifier).finishStreaming(); + } - 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(); - } + await _saveConversationBeforeLeaving(ref); - // 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) { + if (context.mounted) { final canPopNavigator = Navigator.of(context).canPop(); if (canPopNavigator) { Navigator.of(context).pop(); @@ -1160,6 +1096,14 @@ class _ChatPageState extends ConsumerState { }, child: Scaffold( backgroundColor: context.conduitTheme.surfaceBackground, + // Left navigation drawer with draggable edge open + drawerEnableOpenDragGesture: true, + drawerEdgeDragWidth: 32, + drawer: Drawer( + width: (MediaQuery.of(context).size.width * 0.88).clamp(280.0, 420.0), + backgroundColor: context.conduitTheme.surfaceBackground, + child: const SafeArea(child: ChatsDrawer()), + ), appBar: AppBar( backgroundColor: context.conduitTheme.surfaceBackground, elevation: Elevation.none, @@ -1176,22 +1120,25 @@ class _ChatPageState extends ConsumerState { ), 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, + : Builder( + builder: (ctx) => GestureDetector( + onTap: () { + // Open left drawer instead of bottom sheet + Scaffold.of(ctx).openDrawer(); + }, + 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, + ), ), ), ), diff --git a/lib/features/chat/widgets/modern_chat_input.dart b/lib/features/chat/widgets/modern_chat_input.dart index 671a15b..f94a6f7 100644 --- a/lib/features/chat/widgets/modern_chat_input.dart +++ b/lib/features/chat/widgets/modern_chat_input.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; import '../../../shared/theme/theme_extensions.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -220,18 +221,17 @@ class _ModernChatInputState extends ConsumerState ), border: Border( top: BorderSide( - color: context.conduitTheme.inputBorder, + color: context.conduitTheme.dividerColor, width: BorderWidth.regular, ), left: BorderSide( - color: context.conduitTheme.inputBorder, + color: context.conduitTheme.dividerColor, width: BorderWidth.regular, ), right: BorderSide( - color: context.conduitTheme.inputBorder, + color: context.conduitTheme.dividerColor, width: BorderWidth.regular, ), - // Removed bottom border to eliminate divider ), boxShadow: ConduitShadows.input, ), @@ -431,41 +431,48 @@ class _ModernChatInputState extends ConsumerState if (isGenerating) { return Tooltip( message: 'Stop generating', - child: GestureDetector( - onTap: stopGeneration, - child: Container( - width: buttonSize, - height: buttonSize, - decoration: BoxDecoration( - color: context.conduitTheme.error.withValues( - alpha: Alpha.buttonPressed, + child: Material( + color: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radius), + side: BorderSide(color: context.conduitTheme.error, width: BorderWidth.regular), + ), + child: InkWell( + borderRadius: BorderRadius.circular(radius), + onTap: () { + HapticFeedback.lightImpact(); + stopGeneration(); + }, + child: Container( + width: buttonSize, + height: buttonSize, + decoration: BoxDecoration( + color: context.conduitTheme.error.withValues( + alpha: Alpha.buttonPressed, + ), + borderRadius: BorderRadius.circular(radius), + boxShadow: ConduitShadows.button, ), - borderRadius: BorderRadius.circular(radius), - border: Border.all( - color: context.conduitTheme.error, - width: BorderWidth.regular, - ), - boxShadow: ConduitShadows.button, - ), - child: Stack( - alignment: Alignment.center, - children: [ - SizedBox( - width: buttonSize - 18, - height: buttonSize - 18, - child: CircularProgressIndicator( - strokeWidth: BorderWidth.medium, - valueColor: AlwaysStoppedAnimation( - context.conduitTheme.error, + child: Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: buttonSize - 18, + height: buttonSize - 18, + child: CircularProgressIndicator( + strokeWidth: BorderWidth.medium, + valueColor: AlwaysStoppedAnimation( + context.conduitTheme.error, + ), ), ), - ), - Icon( - Platform.isIOS ? CupertinoIcons.stop_fill : Icons.stop, - size: IconSize.medium, - color: context.conduitTheme.error, - ), - ], + Icon( + Platform.isIOS ? CupertinoIcons.stop_fill : Icons.stop, + size: IconSize.medium, + color: context.conduitTheme.error, + ), + ], + ), ), ), ), @@ -475,36 +482,44 @@ class _ModernChatInputState extends ConsumerState // Default SEND variant return Tooltip( message: enabled ? 'Send message' : 'Send', - child: GestureDetector( - onTap: enabled ? _sendMessage : null, - child: Opacity( + child: Opacity( opacity: enabled ? Alpha.primary : Alpha.disabled, - child: IgnorePointer( - ignoring: !enabled, - child: Container( - width: buttonSize, - height: buttonSize, - decoration: BoxDecoration( - color: context.conduitTheme.cardBackground, - borderRadius: BorderRadius.circular(radius), - border: Border.all( - color: enabled - ? context.conduitTheme.cardBorder - : context.conduitTheme.cardBorder.withValues( - alpha: Alpha.medium, - ), - width: BorderWidth.regular, - ), - boxShadow: ConduitShadows.button, - ), - child: Icon( - Platform.isIOS ? CupertinoIcons.arrow_up : Icons.arrow_upward, - size: IconSize.medium, + child: IgnorePointer( + ignoring: !enabled, + child: Material( + color: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radius), + side: BorderSide( color: enabled - ? context.conduitTheme.textPrimary - : context.conduitTheme.textPrimary.withValues( - alpha: Alpha.disabled, - ), + ? context.conduitTheme.cardBorder + : context.conduitTheme.cardBorder.withValues(alpha: Alpha.medium), + width: BorderWidth.regular, + ), + ), + child: InkWell( + borderRadius: BorderRadius.circular(radius), + onTap: enabled + ? () { + PlatformUtils.lightHaptic(); + _sendMessage(); + } + : null, + child: Container( + width: buttonSize, + height: buttonSize, + decoration: BoxDecoration( + color: context.conduitTheme.cardBackground, + borderRadius: BorderRadius.circular(radius), + boxShadow: ConduitShadows.button, + ), + child: Icon( + Platform.isIOS ? CupertinoIcons.arrow_up : Icons.arrow_upward, + size: IconSize.medium, + color: enabled + ? context.conduitTheme.textPrimary + : context.conduitTheme.textPrimary.withValues(alpha: Alpha.disabled), + ), ), ), ), @@ -522,9 +537,30 @@ class _ModernChatInputState extends ConsumerState }) { return Tooltip( message: tooltip ?? '', - child: GestureDetector( - onTap: onTap, - child: Container( + child: Material( + color: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.xl), + side: BorderSide( + color: isActive + ? context.conduitTheme.textPrimary.withValues( + alpha: Alpha.buttonHover + Alpha.subtle, + ) + : showBackground + ? context.conduitTheme.cardBorder + : Colors.transparent, + width: BorderWidth.regular, + ), + ), + child: InkWell( + borderRadius: BorderRadius.circular(AppBorderRadius.xl), + onTap: onTap == null + ? null + : () { + HapticFeedback.selectionClick(); + onTap(); + }, + child: Container( width: TouchTarget.comfortable, height: TouchTarget.comfortable, decoration: BoxDecoration( @@ -536,16 +572,6 @@ class _ModernChatInputState extends ConsumerState ? context.conduitTheme.cardBackground : Colors.transparent, borderRadius: BorderRadius.circular(AppBorderRadius.xl), - border: Border.all( - color: isActive - ? context.conduitTheme.textPrimary.withValues( - alpha: Alpha.buttonHover + Alpha.subtle, - ) - : showBackground - ? context.conduitTheme.cardBorder - : Colors.transparent, - width: BorderWidth.regular, - ), boxShadow: (isActive || showBackground) ? ConduitShadows.button : null, @@ -565,10 +591,11 @@ class _ModernChatInputState extends ConsumerState ), ), ), - ); + )); } void _showAttachmentOptions() { + HapticFeedback.selectionClick(); showModalBottomSheet( context: context, backgroundColor: Colors.transparent, @@ -578,6 +605,10 @@ class _ModernChatInputState extends ConsumerState borderRadius: const BorderRadius.vertical( top: Radius.circular(AppBorderRadius.bottomSheet), ), + border: Border.all( + color: context.conduitTheme.dividerColor, + width: BorderWidth.regular, + ), boxShadow: ConduitShadows.modal, ), padding: const EdgeInsets.all(Spacing.bottomSheetPadding), @@ -599,34 +630,41 @@ class _ModernChatInputState extends ConsumerState // Options grid Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - _buildAttachmentOption( + Expanded( + child: _buildAttachmentOption( icon: Platform.isIOS ? CupertinoIcons.doc : Icons.attach_file, label: 'File', onTap: () { + HapticFeedback.lightImpact(); Navigator.pop(context); // Close modal widget.onFileAttachment?.call(); }, - ), - _buildAttachmentOption( + )), + const SizedBox(width: Spacing.md), + Expanded( + child: _buildAttachmentOption( icon: Platform.isIOS ? CupertinoIcons.photo : Icons.image, label: 'Photo', onTap: () { + HapticFeedback.lightImpact(); Navigator.pop(context); // Close modal widget.onImageAttachment?.call(); }, - ), - _buildAttachmentOption( + )), + const SizedBox(width: Spacing.md), + Expanded( + child: _buildAttachmentOption( icon: Platform.isIOS ? CupertinoIcons.camera : Icons.camera_alt, label: 'Camera', onTap: () { + HapticFeedback.lightImpact(); Navigator.pop(context); // Close modal widget.onCameraCapture?.call(); }, - ), + )), ], ), const SizedBox(height: Spacing.lg), @@ -637,6 +675,7 @@ class _ModernChatInputState extends ConsumerState } void _showUnifiedToolsModal() { + HapticFeedback.selectionClick(); showModalBottomSheet( context: context, backgroundColor: Colors.transparent, @@ -649,40 +688,45 @@ class _ModernChatInputState extends ConsumerState required String label, VoidCallback? onTap, }) { - return GestureDetector( - onTap: onTap, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 64, - height: 64, - decoration: BoxDecoration( - color: context.conduitTheme.textPrimary.withValues( - alpha: Alpha.subtle, - ), - borderRadius: BorderRadius.circular(AppBorderRadius.lg), - border: Border.all( - color: context.conduitTheme.textPrimary.withValues( - alpha: Alpha.subtle, + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(AppBorderRadius.lg), + onTap: onTap == null + ? null + : () { + HapticFeedback.selectionClick(); + onTap(); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: context.conduitTheme.cardBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.lg), + border: Border.all( + color: context.conduitTheme.cardBorder, + width: BorderWidth.regular, ), - width: BorderWidth.regular, + ), + child: Icon( + icon, + color: context.conduitTheme.iconPrimary, + size: IconSize.xl, ), ), - child: Icon( - icon, - color: context.conduitTheme.textPrimary, - size: IconSize.xl, + const SizedBox(height: Spacing.sm), + Text( + label, + style: AppTypography.labelStyle.copyWith( + color: context.conduitTheme.textPrimary, + ), ), - ), - const SizedBox(height: Spacing.sm), - Text( - label, - style: AppTypography.labelStyle.copyWith( - color: context.conduitTheme.textPrimary, - ), - ), - ], + ], + ), ), ); } diff --git a/lib/features/navigation/views/chats_list_page.dart b/lib/features/navigation/views/chats_list_page.dart deleted file mode 100644 index dc148b4..0000000 --- a/lib/features/navigation/views/chats_list_page.dart +++ /dev/null @@ -1,1894 +0,0 @@ -import 'package:flutter/material.dart'; -import '../../../core/widgets/error_boundary.dart'; -import '../../../core/services/focus_management_service.dart'; -import '../../../shared/theme/theme_extensions.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_animate/flutter_animate.dart'; -import '../../../shared/widgets/loading_states.dart'; -import 'dart:async'; -import 'dart:io' show Platform; - -import '../../../core/providers/app_providers.dart'; -import '../../../shared/widgets/themed_dialogs.dart'; -import '../../../shared/widgets/conduit_components.dart'; -import '../../chat/providers/chat_providers.dart'; -import '../../chat/views/chat_page_helpers.dart'; -import '../../../core/utils/debug_logger.dart'; - -/// Optimized conversation list page with Conduit design aesthetics -class ChatsListPage extends ConsumerStatefulWidget { - final bool isOverlay; - - const ChatsListPage({super.key, this.isOverlay = false}); - - @override - ConsumerState createState() => _ChatsListPageState(); -} - -class _ChatsListPageState extends ConsumerState - with AutomaticKeepAliveClientMixin { - final TextEditingController _searchController = TextEditingController(); - late final FocusNode _searchFocusNode; - final ScrollController _scrollController = ScrollController(); - - // Debounce search to improve performance - String _searchQuery = ''; - Timer? _debounceTimer; - bool _isLoadingConversation = false; - bool _hasAddedFocusListener = false; - - // Provider for archived section visibility - static final _showArchivedProvider = StateProvider((ref) => false); - - // Provider for folder expansion state (Map) - // Start with folders expanded by default for better discoverability - static final _expandedFoldersProvider = StateProvider>( - (ref) => {}, - ); - - @override - bool get wantKeepAlive => true; // Keep state alive for better performance - - @override - void initState() { - super.initState(); - _searchFocusNode = FocusManagementService.registerFocusNode( - 'chats_list_search', - debugLabel: 'Chats List Search', - ); - _searchController.addListener(_onSearchChanged); - } - - @override - void dispose() { - _searchController.removeListener(_onSearchChanged); - _searchController.dispose(); - _scrollController.dispose(); - _debounceTimer?.cancel(); - FocusManagementService.disposeFocusNode('chats_list_search'); - super.dispose(); - } - - void _onSearchChanged() { - // Cancel previous timer - _debounceTimer?.cancel(); - - // Set new timer for debounced search - _debounceTimer = Timer(const Duration(milliseconds: 300), () { - if (_searchQuery != _searchController.text) { - setState(() { - _searchQuery = _searchController.text; - }); - ref.read(searchQueryProvider.notifier).state = _searchQuery; - } - }); - } - - @override - Widget build(BuildContext context) { - super.build(context); // Required for AutomaticKeepAliveClientMixin - - return ErrorBoundary( - child: Scaffold( - backgroundColor: context.conduitTheme.surfaceBackground, - appBar: _buildAppBar(), - body: Column( - children: [ - _buildSearchBar(), - Expanded(child: _wrapWithRefresh(_buildConversationsList())), - _buildBottomActions(), - ], - ), - floatingActionButton: FloatingActionButton( - onPressed: _showCreateMenu, - backgroundColor: context.conduitTheme.buttonPrimary, - foregroundColor: context.conduitTheme.buttonPrimaryText, - elevation: Elevation.medium, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppBorderRadius.floatingButton), - ), - child: Icon( - Platform.isIOS ? CupertinoIcons.plus : Icons.add_rounded, - size: IconSize.large, - ), - ), - ), - ); - } - - PreferredSizeWidget _buildAppBar() { - return AppBar( - backgroundColor: context.conduitTheme.surfaceBackground, - elevation: Elevation.none, - scrolledUnderElevation: Elevation.none, - leading: widget.isOverlay - ? ConduitIconButton( - icon: Platform.isIOS ? CupertinoIcons.xmark : Icons.close_rounded, - onPressed: () => Navigator.pop(context), - ) - : ConduitIconButton( - icon: Platform.isIOS - ? CupertinoIcons.back - : Icons.arrow_back_rounded, - onPressed: () => Navigator.pop(context), - ), - title: Text( - 'Chats', - style: AppTypography.headlineMediumStyle.copyWith( - color: context.conduitTheme.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - actions: [ - ConduitIconButton( - icon: Platform.isIOS - ? CupertinoIcons.ellipsis - : Icons.more_vert_rounded, - onPressed: _showOptions, - ), - ], - ); - } - - Widget _buildSearchBar() { - // Listen to focus changes and update UI - final isFocused = _searchFocusNode.hasFocus; - - // Attach listener only once - if (!_hasAddedFocusListener) { - _searchFocusNode.addListener(() { - setState(() {}); - }); - _hasAddedFocusListener = true; - } - - return GestureDetector( - onTap: () { - // Focus the search field when the container is tapped - _searchFocusNode.requestFocus(); - }, - child: Container( - margin: const EdgeInsets.all(Spacing.md), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - context.conduitTheme.inputBackground.withValues(alpha: 0.6), - context.conduitTheme.inputBackground.withValues(alpha: 0.3), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(AppBorderRadius.lg), - border: Border.all( - color: isFocused - ? context.conduitTheme.buttonPrimary.withValues(alpha: 0.8) - : context.conduitTheme.inputBorder.withValues(alpha: 0.3), - width: BorderWidth.thin, - ), - ), - child: TextField( - controller: _searchController, - focusNode: _searchFocusNode, - style: TextStyle( - color: context.conduitTheme.inputText, - fontSize: AppTypography.bodyMedium, - ), - decoration: InputDecoration( - hintText: 'Search conversations...', - hintStyle: TextStyle( - color: context.conduitTheme.inputPlaceholder.withValues( - alpha: 0.8, - ), - fontSize: AppTypography.bodyMedium, - ), - prefixIcon: Icon( - Platform.isIOS ? CupertinoIcons.search : Icons.search, - color: context.conduitTheme.iconSecondary, - size: IconSize.md, - ), - suffixIcon: _searchQuery.isNotEmpty - ? IconButton( - icon: Icon( - Platform.isIOS - ? CupertinoIcons.clear_circled_solid - : Icons.clear, - color: context.conduitTheme.iconSecondary, - size: IconSize.md, - ), - onPressed: () { - _searchController.clear(); - _searchQuery = ''; - ref.read(searchQueryProvider.notifier).state = ''; - _searchFocusNode.unfocus(); - }, - ) - : null, - border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, - ), - ), - ), - ), - ).animate().fadeIn( - duration: AnimationDuration.microInteraction, - curve: AnimationCurves.microInteraction, - ); - } - - Widget _buildConversationsList() { - return Consumer( - builder: (context, ref, child) { - // Use ref.watch to properly react to changes - final conversationsAsync = ref.watch(conversationsProvider); - - return conversationsAsync.when( - data: (conversations) { - if (conversations.isEmpty) { - return _buildEmptyState(); - } - - final filteredConversations = _filterConversations(conversations); - - if (filteredConversations.isEmpty) { - return _buildNoResultsState(); - } - - // Deduplicate by ID as a safety measure in case provider has duplicates - final deduplicatedConversations = {}; - for (final conv in filteredConversations) { - deduplicatedConversations[conv.id] = conv; - } - final uniqueConversations = deduplicatedConversations.values - .toList(); - - // Separate conversations by status and folder - final pinnedConversations = uniqueConversations - .where((c) => c.pinned == true) - .toList(); - final regularConversations = uniqueConversations - .where( - (c) => - c.pinned != true && - c.archived != true && - (c.folderId == null || c.folderId!.isEmpty), - ) - .toList(); - final folderConversations = uniqueConversations - .where( - (c) => - c.pinned != true && - c.archived != true && - c.folderId != null && - c.folderId!.isNotEmpty, - ) - .toList(); - final archivedConversations = uniqueConversations - .where((c) => c.archived == true) - .toList(); - - // Debug logging - DebugLogger.log( - 'Total conversations: ${uniqueConversations.length} (filtered: ${filteredConversations.length}, original: ${conversations.length})', - ); - DebugLogger.log('Pinned: ${pinnedConversations.length}'); - DebugLogger.log('Regular: ${regularConversations.length}'); - DebugLogger.log('Folder: ${folderConversations.length}'); - DebugLogger.log('Archived: ${archivedConversations.length}'); - - // Check first few conversations for folder IDs - for (int i = 0; i < uniqueConversations.take(5).length; i++) { - final conv = uniqueConversations[i]; - DebugLogger.log( - 'Conv $i: id=${conv.id.substring(0, 8)}, folderId=${conv.folderId}, pinned=${conv.pinned}, archived=${conv.archived}', - ); - } - - return ListView( - controller: _scrollController, - padding: const EdgeInsets.all(Spacing.md), - children: [ - // Pinned conversations section - if (pinnedConversations.isNotEmpty) ...[ - _buildSectionHeader('Pinned', pinnedConversations.length), - ...pinnedConversations.asMap().entries.map((entry) { - return _buildConversationTile( - entry.value, - entry.key, - isPinned: true, - ); - }), - const SizedBox(height: Spacing.md), - ], - - // Folder conversations sections (after pinned, before recent) - if (folderConversations.isNotEmpty) ...[ - ...ref - .watch(foldersProvider) - .when( - data: (folders) { - // Group conversations by folder - final groupedByFolder = >{}; - for (final conv in folderConversations) { - if (conv.folderId != null) { - groupedByFolder - .putIfAbsent(conv.folderId!, () => []) - .add(conv); - } - } - - // Build folder sections - return folders - .where( - (folder) => - groupedByFolder.containsKey(folder.id), - ) - .map((folder) { - final conversations = - groupedByFolder[folder.id]!; - final expandedFolders = ref.watch( - _expandedFoldersProvider, - ); - final isExpanded = - expandedFolders[folder.id] ?? false; - - return Column( - children: [ - _buildFolderHeader( - folder.id, - folder.name, - conversations.length, - ), - // Only show conversations if folder is expanded - if (isExpanded) ...[ - ...conversations.asMap().entries.map(( - entry, - ) { - return _buildConversationTile( - entry.value, - entry.key, - inFolder: true, - ); - }), - ], - const SizedBox(height: Spacing.md), - ], - ); - }) - .toList(); - }, - loading: () => [const SizedBox.shrink()], - error: (_, stackTrace) => [const SizedBox.shrink()], - ), - ], - - // Regular conversations section - if (regularConversations.isNotEmpty) ...[ - _buildSectionHeader('Recent', regularConversations.length), - ...regularConversations.asMap().entries.map((entry) { - return _buildConversationTile(entry.value, entry.key); - }), - ], - - // Archived conversations section (collapsed by default) - if (archivedConversations.isNotEmpty) ...[ - const SizedBox(height: Spacing.md), - _buildArchivedSection(archivedConversations), - ], - ], - ); - }, - loading: () => _buildLoadingState(), - error: (error, stackTrace) => _buildErrorState(error), - ); - }, - ); - } - - Widget _wrapWithRefresh(Widget child) { - return ConduitRefreshIndicator( - onRefresh: () async { - // Invalidate to force a fresh fetch - ref.invalidate(conversationsProvider); - // Wait for the provider to complete - await ref.read(conversationsProvider.future); - }, - child: child, - ); - } - - Widget _buildConversationTile( - dynamic conversation, - int index, { - bool isPinned = false, - bool isArchived = false, - bool inFolder = false, - }) { - final isSelected = - ref.watch(activeConversationProvider)?.id == conversation.id; - final isLoading = _isLoadingConversation && isSelected; - - return PressableScale( - onTap: isLoading ? null : () => _selectConversation(conversation), - borderRadius: BorderRadius.circular(AppBorderRadius.md), - child: Container( - margin: EdgeInsets.only( - bottom: Spacing.md, - left: inFolder ? Spacing.md : 0.0, - ), - decoration: BoxDecoration( - gradient: isSelected - ? LinearGradient( - colors: [ - context.conduitTheme.buttonPrimary.withValues(alpha: 0.2), - context.conduitTheme.buttonPrimary.withValues(alpha: 0.1), - ], - ) - : null, - color: isSelected - ? null - : isArchived - ? context.conduitTheme.surfaceBackground.withValues(alpha: 0.3) - : 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: [ - // Conversation icon (32x32 like model selector) - 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.chat_bubble - : Icons.chat_rounded, - color: context.conduitTheme.buttonPrimary, - size: 16, - ), - ), - const SizedBox(width: Spacing.md), - - // Conversation details - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - conversation.title ?? 'New Chat', - style: TextStyle( - color: isArchived - ? context.conduitTheme.textSecondary - : context.conduitTheme.textPrimary, - fontWeight: FontWeight.w600, - fontSize: AppTypography.bodyMedium, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: Spacing.xs), - Row( - children: [ - if (isPinned) - _buildStatusChip( - icon: Platform.isIOS - ? CupertinoIcons.pin_fill - : Icons.push_pin, - label: 'Pinned', - color: context.conduitTheme.warning, - ), - if (isArchived) - _buildStatusChip( - icon: Platform.isIOS - ? CupertinoIcons.archivebox_fill - : Icons.archive, - label: 'Archived', - color: context.conduitTheme.textSecondary, - ), - if (!isPinned && !isArchived) - Text( - _formatConversationDate(conversation.updatedAt), - style: TextStyle( - color: context.conduitTheme.textTertiary, - fontSize: AppTypography.labelSmall, - ), - ), - ], - ), - ], - ), - ), - - // Action indicator (like model selector check) - GestureDetector( - onTap: () => _showConversationOptions(conversation), - child: 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: isLoading - ? SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - isSelected - ? context.conduitTheme.textInverse - : context.conduitTheme.buttonPrimary, - ), - ), - ) - : Icon( - isSelected - ? (Platform.isIOS - ? CupertinoIcons.check_mark - : Icons.check) - : (Platform.isIOS - ? CupertinoIcons.ellipsis - : Icons.more_vert), - color: isSelected - ? context.conduitTheme.textInverse - : context.conduitTheme.iconSecondary, - size: 14, - ), - ), - ), - ), - ], - ), - ), - ), - ).animate().fadeIn( - duration: AnimationDuration.messageAppear, - delay: Duration( - milliseconds: index * AnimationDelay.staggeredDelay.inMilliseconds, - ), - curve: AnimationCurves.messageSlide, - ); - } - - Widget _buildStatusChip({ - required IconData icon, - required String label, - required Color color, - }) { - return Container( - margin: const EdgeInsets.only(right: Spacing.xs), - padding: const EdgeInsets.symmetric(horizontal: Spacing.xs, vertical: 2), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(AppBorderRadius.chip), - border: Border.all( - color: color.withValues(alpha: 0.3), - width: BorderWidth.thin, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 12, color: color), - const SizedBox(width: 4), - Text( - label, - style: TextStyle( - fontSize: AppTypography.labelSmall, - color: color, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ); - } - - Widget _buildEmptyState() { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Platform.isIOS ? CupertinoIcons.chat_bubble : Icons.chat_rounded, - size: IconSize.xxl, - color: context.conduitTheme.iconSecondary, - ), - const SizedBox(height: Spacing.lg), - Text( - 'No conversations yet', - style: AppTypography.headlineSmallStyle.copyWith( - color: context.conduitTheme.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: Spacing.sm), - Text( - 'Start a new chat to begin your conversation', - style: AppTypography.bodyMediumStyle.copyWith( - color: context.conduitTheme.textSecondary, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: Spacing.xl), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: _startNewChat, - style: ElevatedButton.styleFrom( - backgroundColor: context.conduitTheme.buttonPrimary, - foregroundColor: context.conduitTheme.buttonPrimaryText, - padding: const EdgeInsets.symmetric( - horizontal: Spacing.buttonPadding, - vertical: Spacing.md, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppBorderRadius.button), - ), - elevation: Elevation.none, - ), - child: Text( - 'Start New Chat', - style: AppTypography.labelStyle.copyWith( - color: context.conduitTheme.buttonPrimaryText, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ], - ), - ).animate().fadeIn( - duration: AnimationDuration.pageTransition, - curve: AnimationCurves.pageTransition, - ); - } - - Widget _buildNoResultsState() { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Platform.isIOS ? CupertinoIcons.search : Icons.search_rounded, - size: IconSize.xxl, - color: context.conduitTheme.iconSecondary, - ), - const SizedBox(height: Spacing.lg), - Text( - 'No conversations found', - style: AppTypography.headlineSmallStyle.copyWith( - color: context.conduitTheme.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: Spacing.sm), - Text( - 'Try adjusting your search terms', - style: AppTypography.bodyMediumStyle.copyWith( - color: context.conduitTheme.textSecondary, - ), - textAlign: TextAlign.center, - ), - ], - ), - ).animate().fadeIn( - duration: AnimationDuration.pageTransition, - curve: AnimationCurves.pageTransition, - ); - } - - Widget _buildLoadingState() { - return ListView.builder( - padding: const EdgeInsets.all(Spacing.pagePadding), - itemCount: 6, - itemBuilder: (context, index) { - return Container( - margin: const EdgeInsets.only(bottom: Spacing.listGap), - padding: const EdgeInsets.all(Spacing.listItemPadding), - decoration: BoxDecoration( - color: context.conduitTheme.cardBackground, - borderRadius: BorderRadius.circular(AppBorderRadius.card), - border: Border.all( - color: context.conduitTheme.cardBorder, - width: BorderWidth.regular, - ), - boxShadow: ConduitShadows.low, - ), - child: Row( - children: [ - Container( - width: IconSize.avatar, - height: IconSize.avatar, - decoration: BoxDecoration( - color: context.conduitTheme.shimmerBase, - borderRadius: BorderRadius.circular(AppBorderRadius.avatar), - ), - ).animate().shimmer(duration: AnimationDuration.slow), - const SizedBox(width: Spacing.md), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - height: AppTypography.bodyLarge, - decoration: BoxDecoration( - color: context.conduitTheme.shimmerBase, - borderRadius: BorderRadius.circular(AppBorderRadius.xs), - ), - ).animate().shimmer(duration: AnimationDuration.slow), - const SizedBox(height: Spacing.xs), - Container( - height: AppTypography.bodySmall, - width: double.infinity, - decoration: BoxDecoration( - color: context.conduitTheme.shimmerBase, - borderRadius: BorderRadius.circular(AppBorderRadius.xs), - ), - ).animate().shimmer(duration: AnimationDuration.slow), - ], - ), - ), - ], - ), - ); - }, - ); - } - - Widget _buildErrorState(Object error) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Platform.isIOS - ? CupertinoIcons.exclamationmark_triangle - : Icons.error_rounded, - size: IconSize.xxl, - color: context.conduitTheme.error, - ), - const SizedBox(height: Spacing.lg), - Text( - 'Failed to load conversations', - style: AppTypography.headlineSmallStyle.copyWith( - color: context.conduitTheme.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: Spacing.sm), - Text( - 'Please try again later', - style: AppTypography.bodyMediumStyle.copyWith( - color: context.conduitTheme.textSecondary, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: Spacing.xl), - ElevatedButton( - onPressed: () => ref.invalidate(conversationsProvider), - style: ElevatedButton.styleFrom( - backgroundColor: context.conduitTheme.buttonPrimary, - foregroundColor: context.conduitTheme.buttonPrimaryText, - padding: const EdgeInsets.symmetric( - horizontal: Spacing.buttonPadding, - vertical: Spacing.md, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppBorderRadius.button), - ), - elevation: Elevation.none, - ), - child: Text( - 'Retry', - style: AppTypography.labelStyle.copyWith( - color: context.conduitTheme.buttonPrimaryText, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - ); - } - - Widget _buildBottomActions() { - return const SizedBox.shrink(); // Remove bottom actions since we'll use FAB - } - - // Helper methods - List _filterConversations(List conversations) { - if (_searchQuery.isEmpty) return conversations; - - return conversations.where((conversation) { - final title = conversation.title?.toLowerCase() ?? ''; - final query = _searchQuery.toLowerCase(); - - return title.contains(query); - }).toList(); - } - - String _formatConversationDate(DateTime? date) { - if (date == null) return ''; - - final now = DateTime.now(); - final difference = now.difference(date); - - if (difference.inDays == 0) { - // Same day - show time - final hour = date.hour; - final minute = date.minute; - final period = hour >= 12 ? 'PM' : 'AM'; - final displayHour = hour > 12 ? hour - 12 : (hour == 0 ? 12 : hour); - return '$displayHour:${minute.toString().padLeft(2, '0')} $period'; - } else if (difference.inDays == 1) { - return 'Yesterday'; - } else if (difference.inDays < 7) { - // Show day name for this week - final days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; - return days[date.weekday - 1]; - } else if (difference.inDays < 365) { - // Show month and day for this year - final months = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ]; - return '${months[date.month - 1]} ${date.day}'; - } else { - // Show full date for older conversations - return '${date.month}/${date.day}/${date.year}'; - } - } - - // TODO: Implement search toggle functionality when needed - // void _toggleSearch() { - // // Focus the search field when search is toggled - // FocusScope.of(context).requestFocus(FocusNode()); - // _searchController.clear(); - // setState(() { - // _searchQuery = ''; - // }); - // ref.read(searchQueryProvider.notifier).state = ''; - // } - - void _showOptions() { - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - builder: (context) => 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( - mainAxisSize: MainAxisSize.min, - children: [ - // Handle bar - Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(bottom: Spacing.md), - decoration: BoxDecoration( - color: context.conduitTheme.dividerColor, - borderRadius: BorderRadius.circular(AppBorderRadius.xs), - ), - ), - // Options - ListTile( - leading: Icon( - Platform.isIOS - ? CupertinoIcons.archivebox - : Icons.archive_rounded, - color: context.conduitTheme.iconPrimary, - ), - title: Text( - 'Archived Chats', - style: AppTypography.bodyMediumStyle.copyWith( - color: context.conduitTheme.textPrimary, - ), - ), - onTap: () { - Navigator.pop(context); - _showArchivedSection(); - }, - ), - ], - ), - ), - ), - ), - ); - } - - void _selectConversation(dynamic conversation) async { - if (_isLoadingConversation) return; // Prevent multiple loads - - setState(() { - _isLoadingConversation = true; - }); - - try { - // Mark global conversation loading state to show skeletons in chat - ref.read(isLoadingConversationProvider.notifier).state = true; - // Load the full conversation with messages - final api = ref.read(apiServiceProvider); - if (api != null) { - DebugLogger.log('Loading full conversation: ${conversation.id}'); - final fullConversation = await api.getConversation(conversation.id); - DebugLogger.log( - 'Loaded conversation with ${fullConversation.messages.length} messages', - ); - - // Set the full conversation as active - ref.read(activeConversationProvider.notifier).state = fullConversation; - // Clear global loading before navigating so chat doesn't stick on skeletons - ref.read(isLoadingConversationProvider.notifier).state = false; - } else { - // Fallback to the conversation from the list - ref.read(activeConversationProvider.notifier).state = conversation; - // Clear global loading before navigating - ref.read(isLoadingConversationProvider.notifier).state = false; - } - - // Do not navigate synchronously after async awaits; schedule for next frame - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - if (widget.isOverlay) { - Navigator.of(context).pop(); - } else { - Navigator.of(context).pop(); - } - }); - } catch (e) { - DebugLogger.error('Error loading conversation', e); - // Fallback to the conversation from the list - ref.read(activeConversationProvider.notifier).state = conversation; - // Ensure global loading is cleared even on error - ref.read(isLoadingConversationProvider.notifier).state = false; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - if (widget.isOverlay) { - Navigator.of(context).pop(); - } else { - Navigator.of(context).pop(); - } - }); - } finally { - if (mounted) { - setState(() { - _isLoadingConversation = false; - }); - } - } - } - - void _showConversationOptions(dynamic conversation) { - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - builder: (context) => 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( - mainAxisSize: MainAxisSize.min, - children: [ - // Handle bar - Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(bottom: Spacing.md), - decoration: BoxDecoration( - color: context.conduitTheme.dividerColor, - borderRadius: BorderRadius.circular(AppBorderRadius.xs), - ), - ), - // Conversation title - Padding( - padding: const EdgeInsets.only(bottom: Spacing.sm), - child: Text( - conversation.title ?? 'New Chat', - style: AppTypography.headlineSmallStyle.copyWith( - color: context.conduitTheme.textPrimary, - fontWeight: FontWeight.w600, - ), - textAlign: TextAlign.center, - ), - ), - // Options - ListTile( - leading: Icon( - Platform.isIOS ? CupertinoIcons.pin : Icons.push_pin, - color: conversation.pinned == true - ? context.conduitTheme.warning - : context.conduitTheme.iconPrimary, - ), - title: Text( - conversation.pinned == true ? 'Unpin Chat' : 'Pin Chat', - style: AppTypography.bodyMediumStyle.copyWith( - color: context.conduitTheme.textPrimary, - ), - ), - onTap: () { - Navigator.pop(context); - _togglePinConversation(conversation); - }, - ), - - ListTile( - leading: Icon( - Platform.isIOS - ? CupertinoIcons.archivebox - : Icons.archive_rounded, - color: context.conduitTheme.iconPrimary, - ), - title: Text( - 'Archive Chat', - style: AppTypography.bodyMediumStyle.copyWith( - color: context.conduitTheme.textPrimary, - ), - ), - onTap: () { - Navigator.pop(context); - _archiveConversation(conversation); - }, - ), - ListTile( - leading: Icon( - Platform.isIOS - ? CupertinoIcons.delete - : Icons.delete_rounded, - color: context.conduitTheme.error, - ), - title: Text( - 'Delete Chat', - style: AppTypography.bodyMediumStyle.copyWith( - color: context.conduitTheme.error, - ), - ), - onTap: () { - Navigator.pop(context); - _deleteConversation(conversation); - }, - ), - ], - ), - ), - ), - ), - ); - } - - void _startNewChat() { - startNewChat(ref); - if (widget.isOverlay) { - Navigator.of(context).pop(); // Close the overlay - } else { - Navigator.of(context).pop(); // Go back to main navigation - } - } - - void _showCreateMenu() { - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - builder: (context) => 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( - mainAxisSize: MainAxisSize.min, - children: [ - // Handle bar - Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(bottom: Spacing.lg), - decoration: BoxDecoration( - color: context.conduitTheme.dividerColor, - borderRadius: BorderRadius.circular(AppBorderRadius.xs), - ), - ), - // Header - Padding( - padding: const EdgeInsets.only(bottom: Spacing.md), - child: Text( - 'Create New', - style: AppTypography.headlineSmallStyle.copyWith( - color: context.conduitTheme.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - ), - // Options - ListTile( - leading: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: context.conduitTheme.buttonPrimary.withValues( - alpha: 0.1, - ), - borderRadius: BorderRadius.circular(AppBorderRadius.lg), - ), - child: Icon( - Platform.isIOS - ? CupertinoIcons.chat_bubble - : Icons.chat_rounded, - color: context.conduitTheme.buttonPrimary, - size: IconSize.medium, - ), - ), - title: Text( - 'New Chat', - style: AppTypography.bodyLargeStyle.copyWith( - color: context.conduitTheme.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - subtitle: Text( - 'Start a new conversation', - style: AppTypography.bodyMediumStyle.copyWith( - color: context.conduitTheme.textSecondary, - ), - ), - onTap: () { - Navigator.pop(context); - _startNewChat(); - }, - ), - const SizedBox(height: Spacing.sm), - ListTile( - leading: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: context.conduitTheme.info.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(AppBorderRadius.lg), - ), - child: Icon( - Platform.isIOS - ? CupertinoIcons.folder_badge_plus - : Icons.create_new_folder_rounded, - color: context.conduitTheme.info, - size: IconSize.medium, - ), - ), - title: Text( - 'New Folder', - style: AppTypography.bodyLargeStyle.copyWith( - color: context.conduitTheme.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - subtitle: Text( - 'Create a folder to organize chats', - style: AppTypography.bodyMediumStyle.copyWith( - color: context.conduitTheme.textSecondary, - ), - ), - onTap: () { - Navigator.pop(context); - _showCreateFolderDialog(); - }, - ), - ], - ), - ), - ), - ), - ); - } - - void _showCreateFolderDialog() { - final nameController = TextEditingController(); - - showDialog( - context: context, - builder: (dialogContext) => _CreateFolderDialog( - nameController: nameController, - onCreateFolder: (name) => _createFolderFromDialog(name, dialogContext), - ), - ); - } - - Future _createFolderFromDialog( - String name, - BuildContext dialogContext, - ) async { - // Begin async operation - - try { - final api = ref.read(apiServiceProvider); - if (api == null) throw Exception('No API service available'); - - await api.createFolder(name: name); - ref.invalidate(foldersProvider); - - if (mounted && dialogContext.mounted) { - Navigator.pop(dialogContext); - } - if (context.mounted) {} - } catch (e) { - if (context.mounted) {} - } - } - - void _togglePinConversation(dynamic conversation) async { - try { - final api = ref.read(apiServiceProvider); - if (api != null) { - final newPinnedState = !(conversation.pinned ?? false); - await api.pinConversation(conversation.id, newPinnedState); - - // Refresh conversations list - ref.invalidate(conversationsProvider); - - if (mounted) {} - } - } catch (e) { - DebugLogger.error('Error toggling pin', e); - if (mounted) {} - } - } - - void _archiveConversation(dynamic conversation) async { - try { - final api = ref.read(apiServiceProvider); - if (api != null) { - await api.archiveConversation(conversation.id, true); - - // Refresh conversations list - ref.invalidate(conversationsProvider); - - if (mounted) {} - } - } catch (e) { - DebugLogger.error('Error archiving conversation', e); - if (mounted) {} - } - } - - void _deleteConversation(dynamic conversation) async { - // Show confirmation dialog - final confirmed = await ThemedDialogs.confirm( - context, - title: 'Delete Chat', - message: - 'Are you sure you want to delete "${conversation.title ?? 'New Chat'}"? This action cannot be undone.', - confirmText: 'Delete', - isDestructive: true, - barrierDismissible: true, - ); - - if (confirmed == true) { - try { - final api = ref.read(apiServiceProvider); - if (api != null) { - await api.deleteConversation(conversation.id); - - // Refresh conversations list - ref.invalidate(conversationsProvider); - - if (mounted) {} - } - } catch (e) { - DebugLogger.error('Error deleting conversation', e); - if (mounted) {} - } - } - } - - void _showArchivedSection() { - // Set the archived section to be visible - ref.read(_showArchivedProvider.notifier).state = true; - - // Scroll to the archived section - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scrollController.hasClients) { - _scrollController.animateTo( - _scrollController.position.maxScrollExtent, - duration: const Duration(milliseconds: 500), - curve: Curves.easeInOut, - ); - } - }); - } - - Widget _buildSectionHeader(String title, int count) { - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.md, - vertical: Spacing.sm, - ), - child: Row( - children: [ - Text( - title, - style: AppTypography.labelStyle.copyWith( - color: context.conduitTheme.textSecondary, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - ), - ), - const SizedBox(width: Spacing.sm), - Container( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.xs, - vertical: Spacing.xxs, - ), - decoration: BoxDecoration( - color: context.conduitTheme.surfaceContainer, - borderRadius: BorderRadius.circular(AppBorderRadius.badge), - ), - child: Text( - count.toString(), - style: AppTypography.captionStyle.copyWith( - color: context.conduitTheme.textTertiary, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - ); - } - - Widget _buildFolderHeader(String folderId, String folderName, int count) { - final expandedFolders = ref.watch(_expandedFoldersProvider); - final isExpanded = expandedFolders[folderId] ?? false; - - return InkWell( - onTap: () { - final currentState = ref.read(_expandedFoldersProvider); - final newState = Map.from(currentState); - newState[folderId] = !isExpanded; - ref.read(_expandedFoldersProvider.notifier).state = newState; - }, - borderRadius: BorderRadius.circular(AppBorderRadius.md), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.md, - vertical: Spacing.sm, - ), - child: Row( - children: [ - Icon( - isExpanded - ? (Platform.isIOS - ? CupertinoIcons.chevron_down - : Icons.expand_more_rounded) - : (Platform.isIOS - ? CupertinoIcons.chevron_right - : Icons.chevron_right_rounded), - size: IconSize.small, - color: context.conduitTheme.textSecondary, - ), - const SizedBox(width: Spacing.sm), - Icon( - Platform.isIOS - ? CupertinoIcons.folder_fill - : Icons.folder_rounded, - size: IconSize.small, - color: context.conduitTheme.textSecondary, - ), - const SizedBox(width: Spacing.sm), - Text( - folderName, - style: AppTypography.labelStyle.copyWith( - color: context.conduitTheme.textSecondary, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - ), - ), - const SizedBox(width: Spacing.sm), - Container( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.xs, - vertical: Spacing.xxs, - ), - decoration: BoxDecoration( - color: context.conduitTheme.surfaceContainer, - borderRadius: BorderRadius.circular(AppBorderRadius.badge), - ), - child: Text( - count.toString(), - style: AppTypography.captionStyle.copyWith( - color: context.conduitTheme.textTertiary, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - ), - ); - } - - Widget _buildArchivedSection(List archivedConversations) { - return Consumer( - builder: (context, ref, child) { - final showArchived = ref.watch(_showArchivedProvider); - - return Column( - children: [ - // Collapsible header - InkWell( - onTap: () { - ref.read(_showArchivedProvider.notifier).state = !showArchived; - }, - borderRadius: BorderRadius.circular(AppBorderRadius.card), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.md, - vertical: Spacing.sm, - ), - child: Row( - children: [ - Icon( - Platform.isIOS - ? CupertinoIcons.archivebox - : Icons.archive_rounded, - size: IconSize.small, - color: context.conduitTheme.textSecondary, - ), - const SizedBox(width: Spacing.sm), - Text( - 'Archived', - style: AppTypography.labelStyle.copyWith( - color: context.conduitTheme.textSecondary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(width: Spacing.xs), - Container( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.xs, - vertical: Spacing.xxs, - ), - decoration: BoxDecoration( - color: context.conduitTheme.surfaceContainer, - borderRadius: BorderRadius.circular( - AppBorderRadius.badge, - ), - ), - child: Text( - archivedConversations.length.toString(), - style: AppTypography.captionStyle.copyWith( - color: context.conduitTheme.textTertiary, - fontWeight: FontWeight.w600, - ), - ), - ), - const Spacer(), - Icon( - showArchived - ? (Platform.isIOS - ? CupertinoIcons.chevron_up - : Icons.keyboard_arrow_up) - : (Platform.isIOS - ? CupertinoIcons.chevron_down - : Icons.keyboard_arrow_down), - size: IconSize.small, - color: context.conduitTheme.textSecondary, - ), - ], - ), - ), - ), - - // Archived conversations (collapsible) - if (showArchived) ...[ - const SizedBox(height: Spacing.sm), - ...archivedConversations.asMap().entries.map((entry) { - return _buildConversationTile( - entry.value, - entry.key, - isArchived: true, - ); - }), - ], - ], - ); - }, - ); - } -} - -class _CreateFolderDialog extends StatefulWidget { - final TextEditingController nameController; - final Future Function(String name) onCreateFolder; - - const _CreateFolderDialog({ - required this.nameController, - required this.onCreateFolder, - }); - - @override - State<_CreateFolderDialog> createState() => _CreateFolderDialogState(); -} - -class _CreateFolderDialogState extends State<_CreateFolderDialog> { - bool isCreating = false; - - @override - Widget build(BuildContext context) { - return Directionality( - textDirection: TextDirection.ltr, - child: Dialog( - backgroundColor: Colors.transparent, - child: - Container( - width: 400, - decoration: BoxDecoration( - color: context.conduitTheme.surfaceBackground, - borderRadius: BorderRadius.circular(AppBorderRadius.modal), - border: Border.all( - color: context.conduitTheme.cardBorder.withValues( - alpha: 0.2, - ), - width: BorderWidth.regular, - ), - boxShadow: ConduitShadows.modal, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Header - Container( - padding: const EdgeInsets.all(Spacing.xl), - decoration: BoxDecoration( - color: context.conduitTheme.cardBackground, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(AppBorderRadius.modal), - ), - border: Border( - bottom: BorderSide( - color: context.conduitTheme.dividerColor - .withValues(alpha: 0.1), - width: BorderWidth.regular, - ), - ), - ), - child: Row( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: context.conduitTheme.info.withValues( - alpha: 0.1, - ), - borderRadius: BorderRadius.circular( - AppBorderRadius.lg, - ), - ), - child: Icon( - Platform.isIOS - ? CupertinoIcons.folder_badge_plus - : Icons.create_new_folder_rounded, - color: context.conduitTheme.info, - size: IconSize.medium, - ), - ), - const SizedBox(width: Spacing.md), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Create New Folder', - style: AppTypography.headlineSmallStyle - .copyWith( - color: - context.conduitTheme.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: Spacing.xs), - Text( - 'Enter a name for your folder', - style: AppTypography.bodyMediumStyle - .copyWith( - color: context - .conduitTheme - .textSecondary, - ), - ), - ], - ), - ), - ConduitIconButton( - icon: Platform.isIOS - ? CupertinoIcons.xmark - : Icons.close_rounded, - onPressed: isCreating - ? null - : () => Navigator.pop(context), - ), - ], - ), - ), - - // Content - Padding( - padding: const EdgeInsets.all(Spacing.xl), - child: TextField( - controller: widget.nameController, - autofocus: true, - enabled: !isCreating, - decoration: InputDecoration( - labelText: 'Folder Name', - hintText: 'Enter folder name', - prefixIcon: Icon( - Platform.isIOS - ? CupertinoIcons.folder - : Icons.folder_outlined, - color: context.conduitTheme.iconSecondary, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular( - AppBorderRadius.input, - ), - borderSide: BorderSide( - color: context.conduitTheme.inputBorder, - width: BorderWidth.regular, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular( - AppBorderRadius.input, - ), - borderSide: BorderSide( - color: context.conduitTheme.buttonPrimary, - width: BorderWidth.regular, - ), - ), - labelStyle: AppTypography.bodyMediumStyle.copyWith( - color: context.conduitTheme.textSecondary, - ), - hintStyle: AppTypography.bodyMediumStyle.copyWith( - color: context.conduitTheme.inputPlaceholder, - ), - ), - style: AppTypography.bodyMediumStyle.copyWith( - color: context.conduitTheme.textPrimary, - ), - onSubmitted: (value) { - if (value.trim().isNotEmpty && !isCreating) { - _createFolder(); - } - }, - ), - ), - - // Actions - Container( - padding: const EdgeInsets.all(Spacing.xl), - decoration: BoxDecoration( - color: context.conduitTheme.cardBackground, - borderRadius: const BorderRadius.vertical( - bottom: Radius.circular(AppBorderRadius.modal), - ), - border: Border( - top: BorderSide( - color: context.conduitTheme.dividerColor - .withValues(alpha: 0.1), - width: BorderWidth.regular, - ), - ), - ), - child: Row( - children: [ - Expanded( - child: TextButton( - onPressed: isCreating - ? null - : () => Navigator.pop(context), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric( - vertical: Spacing.md, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - AppBorderRadius.button, - ), - ), - ), - child: Text( - 'Cancel', - style: AppTypography.labelStyle.copyWith( - color: context.conduitTheme.textSecondary, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - const SizedBox(width: Spacing.md), - Expanded( - child: ElevatedButton( - onPressed: isCreating - ? null - : () { - final name = widget.nameController.text - .trim(); - if (name.isNotEmpty) { - _createFolder(); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: - context.conduitTheme.buttonPrimary, - foregroundColor: - context.conduitTheme.buttonPrimaryText, - padding: const EdgeInsets.symmetric( - vertical: Spacing.md, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - AppBorderRadius.button, - ), - ), - elevation: Elevation.none, - ), - child: isCreating - ? SizedBox( - width: IconSize.medium, - height: IconSize.medium, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: - AlwaysStoppedAnimation( - context - .conduitTheme - .buttonPrimaryText, - ), - ), - ) - : Text( - 'Create', - style: AppTypography.labelStyle - .copyWith( - color: context - .conduitTheme - .buttonPrimaryText, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ], - ), - ), - ], - ), - ) - .animate() - .slideY( - begin: 0.1, - duration: AnimationDuration.modalPresentation, - curve: AnimationCurves.modalPresentation, - ) - .fadeIn( - duration: AnimationDuration.modalPresentation, - curve: AnimationCurves.easeOut, - ), - ), - ); - } - - Future _createFolder() async { - final name = widget.nameController.text.trim(); - if (name.isEmpty) return; - - setState(() => isCreating = true); - - try { - await widget.onCreateFolder(name); - } finally { - if (mounted) { - setState(() => isCreating = false); - } - } - } -} diff --git a/lib/features/navigation/widgets/chats_drawer.dart b/lib/features/navigation/widgets/chats_drawer.dart new file mode 100644 index 0000000..a46d2c4 --- /dev/null +++ b/lib/features/navigation/widgets/chats_drawer.dart @@ -0,0 +1,1046 @@ +import 'dart:async'; +import 'dart:io' show Platform; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/providers/app_providers.dart'; +import '../../../core/auth/auth_state_manager.dart'; +import '../../../shared/theme/theme_extensions.dart'; +import '../../chat/providers/chat_providers.dart' as chat; +import '../../files/views/files_page.dart'; +import '../../profile/views/profile_page.dart'; +import '../../../shared/utils/ui_utils.dart'; + +class ChatsDrawer extends ConsumerStatefulWidget { + const ChatsDrawer({super.key}); + + @override + ConsumerState createState() => _ChatsDrawerState(); +} + +class _ChatsDrawerState extends ConsumerState { + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(debugLabel: 'drawer_search'); + Timer? _debounce; + String _query = ''; + bool _isLoadingConversation = false; + + // UI state providers for sections + static final _showArchivedProvider = StateProvider((ref) => false); + static final _expandedFoldersProvider = + StateProvider>((ref) => {}); + + @override + void dispose() { + _debounce?.cancel(); + _searchController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + + void _onSearchChanged() { + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 250), () { + if (!mounted) return; + setState(() => _query = _searchController.text.trim()); + }); + } + + @override + Widget build(BuildContext context) { + final theme = context.conduitTheme; + + return SafeArea( + child: Container( + color: theme.surfaceBackground, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildHeader(context), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm, + ), + child: _buildSearchField(context), + ), + Expanded(child: _buildConversationList(context)), + const Divider(height: 1), + _buildBottomSection(context), + ], + ), + ), + ); + } + + Widget _buildHeader(BuildContext context) { + final theme = context.conduitTheme; + return Padding( + padding: const EdgeInsets.fromLTRB(Spacing.md, Spacing.md, Spacing.md, 0), + child: Row( + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.chat_bubble_2 + : Icons.chat_bubble_outline_rounded, + color: theme.iconPrimary, + ), + const SizedBox(width: Spacing.sm), + Expanded( + child: Text( + 'Chats', + style: AppTypography.headlineSmallStyle.copyWith( + color: theme.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + icon: Icon( + Platform.isIOS ? CupertinoIcons.plus : Icons.add_rounded, + color: theme.iconPrimary, + ), + onPressed: () { + chat.startNewChat(ref); + if (mounted) Navigator.of(context).maybePop(); + }, + tooltip: 'New Chat', + ), + ], + ), + ); + } + + Widget _buildSearchField(BuildContext context) { + final theme = context.conduitTheme; + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + theme.inputBackground.withValues(alpha: 0.6), + theme.inputBackground.withValues(alpha: 0.3), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.lg), + border: Border.all( + color: _searchFocusNode.hasFocus + ? theme.buttonPrimary.withValues(alpha: 0.8) + : theme.inputBorder.withValues(alpha: 0.3), + width: BorderWidth.thin, + ), + ), + child: TextField( + controller: _searchController, + focusNode: _searchFocusNode, + onChanged: (_) => _onSearchChanged(), + style: TextStyle( + color: theme.inputText, + fontSize: AppTypography.bodyMedium, + ), + decoration: InputDecoration( + hintText: 'Search conversations...', + hintStyle: TextStyle( + color: theme.inputPlaceholder.withValues(alpha: 0.8), + fontSize: AppTypography.bodyMedium, + ), + prefixIcon: Icon( + Platform.isIOS ? CupertinoIcons.search : Icons.search, + color: theme.iconSecondary, + size: IconSize.md, + ), + suffixIcon: _query.isNotEmpty + ? IconButton( + onPressed: () { + _searchController.clear(); + setState(() => _query = ''); + _searchFocusNode.unfocus(); + }, + icon: Icon( + Platform.isIOS + ? CupertinoIcons.clear_circled_solid + : Icons.clear, + color: theme.iconSecondary, + size: IconSize.md, + ), + ) + : null, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + ), + ); + } + + Widget _buildConversationList(BuildContext context) { + final theme = context.conduitTheme; + + if (_query.isEmpty) { + final conversationsAsync = ref.watch(conversationsProvider); + return conversationsAsync.when( + data: (items) { + final list = items; + + if (list.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(Spacing.lg), + child: Text( + 'No conversations yet', + style: AppTypography.bodyMediumStyle.copyWith( + color: theme.textSecondary, + ), + ), + ), + ); + } + + // Build sections + final pinned = list.where((c) => c.pinned == true).toList(); + final regular = list + .where((c) => + c.pinned != true && + c.archived != true && + (c.folderId == null || c.folderId!.isEmpty)) + .toList(); + final foldered = list + .where((c) => + c.pinned != true && + c.archived != true && + c.folderId != null && + c.folderId!.isNotEmpty) + .toList(); + final archived = list.where((c) => c.archived == true).toList(); + + return ListView( + padding: const EdgeInsets.fromLTRB( + Spacing.md, + Spacing.sm, + Spacing.md, + Spacing.md, + ), + children: [ + if (pinned.isNotEmpty) ...[ + _buildSectionHeader('Pinned', pinned.length), + const SizedBox(height: Spacing.xs), + ...pinned.map((conv) => _buildTileFor(conv)), + const SizedBox(height: Spacing.md), + ], + + if (foldered.isNotEmpty) ...[ + ...ref.watch(foldersProvider).when( + data: (folders) { + final grouped = >{}; + for (final c in foldered) { + final id = c.folderId!; + grouped.putIfAbsent(id, () => []).add(c); + } + + // Only show folders that have items + final sections = folders + .where((f) => grouped.containsKey(f.id)) + .map((folder) { + final expandedMap = ref.watch(_expandedFoldersProvider); + final isExpanded = expandedMap[folder.id] ?? false; + final convs = grouped[folder.id]!; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildFolderHeader(folder.id, folder.name, convs.length), + if (isExpanded) ...[ + const SizedBox(height: Spacing.xs), + ...convs.map((c) => _buildTileFor(c, inFolder: true)), + const SizedBox(height: Spacing.sm), + ], + ], + ); + }).toList(); + return sections; + }, + loading: () => [const SizedBox.shrink()], + error: (e, st) => [const SizedBox.shrink()], + ), + const SizedBox(height: Spacing.md), + ], + + if (regular.isNotEmpty) ...[ + _buildSectionHeader('Recent', regular.length), + const SizedBox(height: Spacing.xs), + ...regular.map(_buildTileFor), + ], + + if (archived.isNotEmpty) ...[ + const SizedBox(height: Spacing.md), + _buildArchivedSection(archived), + ], + ], + ); + }, + loading: () => const Center(child: CircularProgressIndicator(strokeWidth: 2.0)), + error: (e, _) => Center( + child: Padding( + padding: const EdgeInsets.all(Spacing.md), + child: Text( + 'Failed to load chats', + style: AppTypography.bodyMediumStyle.copyWith( + color: theme.textSecondary, + ), + ), + ), + ), + ); + } + + // Server-backed search + final searchAsync = ref.watch(serverSearchProvider(_query)); + return searchAsync.when( + data: (list) { + if (list.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(Spacing.lg), + child: Text( + 'No results for "$_query"', + style: AppTypography.bodyMediumStyle.copyWith( + color: theme.textSecondary, + ), + ), + ), + ); + } + + final pinned = list.where((c) => c.pinned == true).toList(); + final regular = list + .where((c) => + c.pinned != true && + c.archived != true && + (c.folderId == null || c.folderId!.isEmpty)) + .toList(); + final foldered = list + .where((c) => + c.pinned != true && + c.archived != true && + c.folderId != null && + c.folderId!.isNotEmpty) + .toList(); + final archived = list.where((c) => c.archived == true).toList(); + + return ListView( + padding: const EdgeInsets.fromLTRB( + Spacing.md, + Spacing.sm, + Spacing.md, + Spacing.md, + ), + children: [ + _buildSectionHeader('Results', list.length), + const SizedBox(height: Spacing.xs), + if (pinned.isNotEmpty) ...[ + _buildSectionHeader('Pinned', pinned.length), + const SizedBox(height: Spacing.xs), + ...pinned.map((conv) => _buildTileFor(conv)), + const SizedBox(height: Spacing.md), + ], + if (foldered.isNotEmpty) ...[ + ...ref.watch(foldersProvider).when( + data: (folders) { + final grouped = >{}; + for (final c in foldered) { + final id = c.folderId!; + grouped.putIfAbsent(id, () => []).add(c); + } + + final sections = folders + .where((f) => grouped.containsKey(f.id)) + .map((folder) { + final expandedMap = ref.watch(_expandedFoldersProvider); + final isExpanded = expandedMap[folder.id] ?? false; + final convs = grouped[folder.id]!; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildFolderHeader(folder.id, folder.name, convs.length), + if (isExpanded) ...[ + const SizedBox(height: Spacing.xs), + ...convs.map((c) => _buildTileFor(c, inFolder: true)), + const SizedBox(height: Spacing.sm), + ], + ], + ); + }).toList(); + return sections; + }, + loading: () => [const SizedBox.shrink()], + error: (e, st) => [const SizedBox.shrink()], + ), + const SizedBox(height: Spacing.md), + ], + if (regular.isNotEmpty) ...[ + _buildSectionHeader('Recent', regular.length), + const SizedBox(height: Spacing.xs), + ...regular.map(_buildTileFor), + ], + if (archived.isNotEmpty) ...[ + const SizedBox(height: Spacing.md), + _buildArchivedSection(archived), + ], + ], + ); + }, + loading: () => const Center(child: CircularProgressIndicator(strokeWidth: 2.0)), + error: (e, _) => Center( + child: Padding( + padding: const EdgeInsets.all(Spacing.md), + child: Text( + 'Search failed', + style: AppTypography.bodyMediumStyle.copyWith( + color: theme.textSecondary, + ), + ), + ), + ), + ); + } + + Widget _buildSectionHeader(String title, int count) { + final theme = context.conduitTheme; + return Row( + children: [ + Text( + title, + style: AppTypography.bodySmallStyle.copyWith( + fontWeight: FontWeight.w600, + color: theme.textSecondary, + letterSpacing: 0.2, + ), + ), + const SizedBox(width: Spacing.xs), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: theme.surfaceBackground.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + border: Border.all(color: theme.dividerColor, width: BorderWidth.thin), + ), + child: Text( + '$count', + style: AppTypography.bodySmallStyle.copyWith( + color: theme.textSecondary, + ), + ), + ), + ], + ); + } + + Widget _buildFolderHeader(String folderId, String name, int count) { + final theme = context.conduitTheme; + final expandedMap = ref.watch(_expandedFoldersProvider); + final isExpanded = expandedMap[folderId] ?? false; + return Material( + color: theme.surfaceBackground.withValues(alpha: 0.05), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + side: BorderSide(color: theme.dividerColor, width: BorderWidth.regular), + ), + child: InkWell( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + onTap: () { + final current = {...ref.read(_expandedFoldersProvider)}; + current[folderId] = !isExpanded; + ref.read(_expandedFoldersProvider.notifier).state = current; + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm, + ), + child: Row( + children: [ + Icon( + isExpanded + ? (Platform.isIOS ? CupertinoIcons.folder_open : Icons.folder_open) + : (Platform.isIOS ? CupertinoIcons.folder : Icons.folder), + color: theme.iconPrimary, + ), + const SizedBox(width: Spacing.sm), + Expanded( + child: Text( + name, + style: AppTypography.bodyLargeStyle.copyWith( + color: theme.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + ), + Text( + '$count', + style: AppTypography.bodySmallStyle.copyWith( + color: theme.textSecondary, + ), + ), + const SizedBox(width: Spacing.xs), + Icon( + isExpanded + ? (Platform.isIOS ? CupertinoIcons.chevron_up : Icons.expand_less) + : (Platform.isIOS ? CupertinoIcons.chevron_down : Icons.expand_more), + color: theme.iconSecondary, + ) + ], + ), + ), + ), + ); + } + + Widget _buildTileFor(dynamic conv, {bool inFolder = false}) { + final isActive = ref.watch(activeConversationProvider)?.id == conv.id; + return Padding( + padding: EdgeInsets.only(bottom: Spacing.xs, left: inFolder ? Spacing.md : 0), + child: _ConversationTile( + title: conv.title?.isEmpty == true ? 'Chat' : (conv.title ?? 'Chat'), + pinned: conv.pinned == true, + selected: isActive, + onTap: _isLoadingConversation ? null : () => _selectConversation(context, conv.id), + onLongPress: () { + HapticFeedback.selectionClick(); + _showConversationContextMenu(context, conv); + }, + onMorePressed: () { + HapticFeedback.selectionClick(); + _showConversationContextMenu(context, conv); + }, + ), + ); + } + + Widget _buildArchivedSection(List archived) { + final theme = context.conduitTheme; + final show = ref.watch(_showArchivedProvider); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Material( + color: theme.surfaceBackground.withValues(alpha: 0.05), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + side: BorderSide(color: theme.dividerColor, width: BorderWidth.regular), + ), + child: InkWell( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + onTap: () => + ref.read(_showArchivedProvider.notifier).state = !show, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm, + ), + child: Row( + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.archivebox + : Icons.archive_rounded, + color: theme.iconPrimary, + ), + const SizedBox(width: Spacing.sm), + Expanded( + child: Text( + 'Archived', + style: AppTypography.bodyLargeStyle.copyWith( + color: theme.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + ), + Text( + '${archived.length}', + style: AppTypography.bodySmallStyle.copyWith( + color: theme.textSecondary, + ), + ), + const SizedBox(width: Spacing.xs), + Icon( + show + ? (Platform.isIOS + ? CupertinoIcons.chevron_up + : Icons.expand_less) + : (Platform.isIOS + ? CupertinoIcons.chevron_down + : Icons.expand_more), + color: theme.iconSecondary, + ), + ], + ), + ), + ), + ), + if (show) ...[ + const SizedBox(height: Spacing.xs), + ...archived.map((c) => _buildTileFor(c)), + ], + ], + ); + } + + Future _selectConversation(BuildContext context, String id) async { + if (_isLoadingConversation) return; + setState(() => _isLoadingConversation = true); + final navigator = Navigator.of(context); + try { + // Mark global loading to show skeletons in chat + ref.read(chat.isLoadingConversationProvider.notifier).state = true; + + final api = ref.read(apiServiceProvider); + if (api != null) { + final full = await api.getConversation(id); + ref.read(activeConversationProvider.notifier).state = full; + } else { + // Fallback: let ChatPage handle if API missing + ref.read(activeConversationProvider.notifier).state = + (await ref.read(conversationsProvider.future)) + .firstWhere((c) => c.id == id); + } + + // Clear global loading before closing drawer + ref.read(chat.isLoadingConversationProvider.notifier).state = false; + + if (mounted) navigator.maybePop(); + } catch (_) { + ref.read(chat.isLoadingConversationProvider.notifier).state = false; + if (mounted) navigator.maybePop(); + } finally { + if (mounted) setState(() => _isLoadingConversation = false); + } + } + + Widget _buildBottomSection(BuildContext context) { + final theme = context.conduitTheme; + final user = ref.watch(authUserProvider); + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(Spacing.sm, 0, Spacing.sm, Spacing.sm), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (user != null) ...[ + Container( + padding: const EdgeInsets.all(Spacing.sm), + decoration: BoxDecoration( + color: theme.surfaceBackground.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all(color: theme.dividerColor, width: BorderWidth.regular), + ), + child: Row( + children: [ + Container( + width: IconSize.avatar, + height: IconSize.avatar, + decoration: BoxDecoration( + color: theme.buttonPrimary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(AppBorderRadius.avatar), + border: Border.all(color: theme.buttonPrimary.withValues(alpha: 0.35), width: BorderWidth.thin), + ), + alignment: Alignment.center, + child: Text( + (user.name ?? user.username ?? 'U').toString().substring(0, 1).toUpperCase(), + style: AppTypography.bodyLargeStyle.copyWith( + color: theme.buttonPrimary, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: Spacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + (user.name ?? user.username ?? 'User').toString(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTypography.bodyLargeStyle.copyWith( + color: theme.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + Text( + 'You', + style: AppTypography.bodySmallStyle.copyWith( + color: theme.textSecondary, + ), + ), + ], + ), + ), + TextButton( + onPressed: () { + Navigator.of(context).maybePop(); + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ProfilePage()), + ); + }, + child: const Text('Manage'), + ) + ], + ), + ), + const SizedBox(height: Spacing.sm), + ], + Row( + children: [ + Expanded( + child: _BottomAction( + icon: Platform.isIOS ? CupertinoIcons.doc : Icons.description_outlined, + label: 'Files', + onTap: () { + Navigator.of(context).maybePop(); + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const FilesPage()), + ); + }, + ), + ), + const SizedBox(width: Spacing.sm), + Expanded( + child: _BottomAction( + icon: Platform.isIOS ? CupertinoIcons.person : Icons.person_outline, + label: 'Profile', + onTap: () { + Navigator.of(context).maybePop(); + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ProfilePage()), + ); + }, + ), + ), + ], + ), + ], + ), + ), + ); + } + + void _showConversationContextMenu(BuildContext context, dynamic conv) { + final theme = context.conduitTheme; + final bool isPinned = conv.pinned == true; + final bool isArchived = conv.archived == true; + + HapticFeedback.selectionClick(); + showModalBottomSheet( + context: context, + backgroundColor: theme.surfaceBackground, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppBorderRadius.lg)), + ), + builder: (sheetContext) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: Icon( + isPinned + ? (Platform.isIOS ? CupertinoIcons.pin_slash : Icons.push_pin_outlined) + : (Platform.isIOS ? CupertinoIcons.pin_fill : Icons.push_pin_rounded), + color: theme.iconPrimary, + ), + title: Text(isPinned ? 'Unpin' : 'Pin', style: TextStyle(color: theme.textPrimary)), + onTap: () async { + HapticFeedback.lightImpact(); + Navigator.pop(sheetContext); + try { + await chat.pinConversation(ref, conv.id, !isPinned); + } catch (_) { + if (!mounted) return; + UiUtils.showMessage(this.context, 'Failed to update pin', isError: true); + } + }, + ), + ListTile( + leading: Icon( + isArchived + ? (Platform.isIOS ? CupertinoIcons.archivebox_fill : Icons.unarchive_rounded) + : (Platform.isIOS ? CupertinoIcons.archivebox : Icons.archive_rounded), + color: theme.iconPrimary, + ), + title: Text(isArchived ? 'Unarchive' : 'Archive', style: TextStyle(color: theme.textPrimary)), + onTap: () async { + HapticFeedback.lightImpact(); + Navigator.pop(sheetContext); + try { + await chat.archiveConversation(ref, conv.id, !isArchived); + } catch (_) { + if (!mounted) return; + UiUtils.showMessage(this.context, 'Failed to update archive', isError: true); + } + }, + ), + ListTile( + leading: Icon( + Platform.isIOS ? CupertinoIcons.pencil : Icons.edit_rounded, + color: theme.iconPrimary, + ), + title: Text('Rename', style: TextStyle(color: theme.textPrimary)), + onTap: () async { + HapticFeedback.selectionClick(); + Navigator.pop(sheetContext); + await _renameConversation(context, conv.id, conv.title ?? ''); + }, + ), + const Divider(height: 1), + ListTile( + leading: Icon( + Platform.isIOS ? CupertinoIcons.delete : Icons.delete_rounded, + color: theme.error, + ), + title: Text('Delete', style: TextStyle(color: theme.error)), + onTap: () async { + HapticFeedback.mediumImpact(); + Navigator.pop(sheetContext); + await _confirmAndDeleteConversation(context, conv.id); + }, + ), + ], + ), + ); + }, + ); + } + + Future _renameConversation( + BuildContext context, + String conversationId, + String currentTitle, + ) async { + final theme = context.conduitTheme; + final controller = TextEditingController(text: currentTitle); + + final newName = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + backgroundColor: theme.surfaceBackground, + title: Text('Rename Chat', style: TextStyle(color: theme.textPrimary)), + content: TextField( + controller: controller, + autofocus: true, + style: TextStyle(color: theme.inputText), + decoration: InputDecoration( + hintText: 'Enter chat name', + hintStyle: TextStyle(color: theme.inputPlaceholder), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: theme.inputBorder), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: theme.buttonPrimary), + ), + ), + textInputAction: TextInputAction.done, + onSubmitted: (value) => Navigator.pop(dialogContext, value.trim()), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + HapticFeedback.lightImpact(); + Navigator.pop(dialogContext, controller.text.trim()); + }, + child: const Text('Save'), + ), + ], + ); + }, + ); + + if (newName == null) return; + if (newName.isEmpty || newName == currentTitle) return; + + try { + final api = ref.read(apiServiceProvider); + if (api == null) throw Exception('No API service'); + await api.updateConversation(conversationId, title: newName); + HapticFeedback.selectionClick(); + // Reflect changes + ref.invalidate(conversationsProvider); + final active = ref.read(activeConversationProvider); + if (active?.id == conversationId) { + ref.read(activeConversationProvider.notifier).state = + active!.copyWith(title: newName); + } + } catch (_) { + if (!mounted) return; + UiUtils.showMessage(this.context, 'Failed to rename chat', isError: true); + } + } + + Future _confirmAndDeleteConversation( + BuildContext context, + String conversationId, + ) async { + final confirmed = await UiUtils.showConfirmationDialog( + context, + title: 'Delete Chat', + message: 'This chat will be permanently deleted.', + confirmText: 'Delete', + isDestructive: true, + ); + if (!confirmed) return; + + try { + final api = ref.read(apiServiceProvider); + if (api == null) throw Exception('No API service'); + await api.deleteConversation(conversationId); + HapticFeedback.mediumImpact(); + // Clear if deleting active + final active = ref.read(activeConversationProvider); + if (active?.id == conversationId) { + ref.read(activeConversationProvider.notifier).state = null; + ref.read(chat.chatMessagesProvider.notifier).clearMessages(); + } + ref.invalidate(conversationsProvider); + } catch (_) { + if (!mounted) return; + UiUtils.showMessage(this.context, 'Failed to delete chat', isError: true); + } + } +} + +class _ConversationTile extends StatelessWidget { + final String title; + final bool pinned; + final bool selected; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + final VoidCallback? onMorePressed; + + const _ConversationTile({ + required this.title, + required this.pinned, + required this.selected, + required this.onTap, + this.onLongPress, + this.onMorePressed, + }); + + @override + Widget build(BuildContext context) { + final theme = context.conduitTheme; + return Material( + color: selected + ? theme.buttonPrimary.withValues(alpha: 0.08) + : theme.surfaceBackground.withValues(alpha: 0.03), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + side: BorderSide( + color: selected ? theme.buttonPrimary.withValues(alpha: 0.5) : theme.dividerColor, + width: BorderWidth.regular, + ), + ), + child: InkWell( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + onTap: onTap, + onLongPress: onLongPress, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm, + ), + child: Row( + children: [ + Icon( + pinned + ? (Platform.isIOS ? CupertinoIcons.pin_fill : Icons.push_pin) + : (Platform.isIOS ? CupertinoIcons.chat_bubble : Icons.chat_bubble_outline_rounded), + color: selected ? theme.buttonPrimary : theme.iconSecondary, + size: IconSize.md, + ), + const SizedBox(width: Spacing.sm), + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTypography.bodyLargeStyle.copyWith( + color: theme.textPrimary, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + ), + ), + ), + const SizedBox(width: Spacing.xs), + if (onMorePressed != null) + IconButton( + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + icon: Icon( + Platform.isIOS ? CupertinoIcons.ellipsis : Icons.more_vert_rounded, + color: theme.iconSecondary, + size: IconSize.md, + ), + onPressed: onMorePressed, + tooltip: 'More', + ), + ], + ), + ), + ), + ); + } +} + +class _BottomAction extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + + const _BottomAction({ + required this.icon, + required this.label, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = context.conduitTheme; + return Material( + color: theme.surfaceBackground.withValues(alpha: 0.04), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + side: BorderSide(color: theme.dividerColor, width: BorderWidth.regular), + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppBorderRadius.md), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Spacing.sm), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: theme.iconPrimary, size: IconSize.lg), + const SizedBox(height: 6), + Text( + label, + style: AppTypography.bodySmallStyle.copyWith( + color: theme.textPrimary, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/profile/views/profile_page.dart b/lib/features/profile/views/profile_page.dart index fc73cc9..bd829ce 100644 --- a/lib/features/profile/views/profile_page.dart +++ b/lib/features/profile/views/profile_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../../shared/theme/theme_extensions.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -797,7 +798,7 @@ class _DefaultModelBottomSheetState extends ConsumerState<_DefaultModelBottomShe width: Spacing.xxl, height: Spacing.xs, decoration: BoxDecoration( - color: context.conduitTheme.dividerColor, + color: context.conduitTheme.textPrimary.withValues(alpha: Alpha.medium), borderRadius: BorderRadius.circular(AppBorderRadius.xs), ), ), @@ -807,6 +808,11 @@ class _DefaultModelBottomSheetState extends ConsumerState<_DefaultModelBottomShe padding: const EdgeInsets.only(bottom: Spacing.md), child: Row( children: [ + Icon( + Platform.isIOS ? CupertinoIcons.cube : Icons.psychology, + color: context.conduitTheme.iconPrimary, + ), + const SizedBox(width: Spacing.sm), Expanded( child: Text( 'Default Model', @@ -817,7 +823,10 @@ class _DefaultModelBottomSheetState extends ConsumerState<_DefaultModelBottomShe ), ), TextButton( - onPressed: () => Navigator.pop(context, _selectedModelId), + onPressed: () { + HapticFeedback.lightImpact(); + Navigator.pop(context, _selectedModelId); + }, child: Text( 'Save', style: context.conduitTheme.bodyMedium?.copyWith( @@ -837,7 +846,7 @@ class _DefaultModelBottomSheetState extends ConsumerState<_DefaultModelBottomShe controller: _searchController, style: TextStyle(color: context.conduitTheme.textPrimary), decoration: InputDecoration( - hintText: 'Search...', + hintText: 'Search models...', hintStyle: TextStyle( color: context.conduitTheme.inputPlaceholder, ), @@ -874,6 +883,41 @@ class _DefaultModelBottomSheetState extends ConsumerState<_DefaultModelBottomShe ), ), + // Section header (cohesive with Chats Drawer) + Padding( + padding: const EdgeInsets.only(bottom: Spacing.sm), + child: Row( + children: [ + Text( + 'Available Models', + style: AppTypography.bodySmallStyle.copyWith( + fontWeight: FontWeight.w600, + color: context.conduitTheme.textSecondary, + letterSpacing: 0.2, + ), + ), + const SizedBox(width: Spacing.xs), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + border: Border.all( + color: context.conduitTheme.dividerColor, + width: BorderWidth.thin, + ), + ), + child: Text( + '${_filteredModels.length}', + style: AppTypography.bodySmallStyle.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + ), + ], + ), + ), + const SizedBox(height: Spacing.sm), // Models list @@ -919,6 +963,7 @@ class _DefaultModelBottomSheetState extends ConsumerState<_DefaultModelBottomShe isSelected: isSelected, isAutoSelect: isAutoSelect, onTap: () { + HapticFeedback.selectionClick(); setState(() { _selectedModelId = isAutoSelect ? 'auto-select' : model.id; }); diff --git a/lib/features/tools/widgets/unified_tools_modal.dart b/lib/features/tools/widgets/unified_tools_modal.dart index cf3fb04..ade575b 100644 --- a/lib/features/tools/widgets/unified_tools_modal.dart +++ b/lib/features/tools/widgets/unified_tools_modal.dart @@ -25,161 +25,184 @@ class _UnifiedToolsModalState extends ConsumerState { final selectedToolIds = ref.watch(selectedToolIdsProvider); final toolsAsync = ref.watch(toolsListProvider); + final theme = context.conduitTheme; return Container( decoration: BoxDecoration( - color: context.conduitTheme.surfaceBackground, + color: theme.surfaceBackground, borderRadius: const BorderRadius.vertical( top: Radius.circular(AppBorderRadius.bottomSheet), ), + border: Border.all(color: theme.dividerColor, width: BorderWidth.regular), boxShadow: ConduitShadows.modal, ), - padding: const EdgeInsets.all(Spacing.bottomSheetPadding), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Handle bar - Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: context.conduitTheme.textPrimary.withValues( - alpha: Alpha.medium, - ), - borderRadius: BorderRadius.circular(2), - ), + child: SafeArea( + top: false, + bottom: true, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.8, ), - const SizedBox(height: Spacing.lg), - - // Title - Text( - 'Tools & Search', - style: AppTypography.headlineSmallStyle.copyWith( - color: context.conduitTheme.textPrimary, - ), - ), - const SizedBox(height: Spacing.lg), - - // Web Search Toggle - _buildWebSearchToggle(webSearchEnabled), - const SizedBox(height: Spacing.md), - - // Image Generation Toggle (conditionally shown) - if (imageGenAvailable) ...[ - _buildImageGenerationToggle(imageGenEnabled), - const SizedBox(height: Spacing.md), - ], - - // Tools Section - toolsAsync.when( - data: (tools) { - if (tools.isEmpty) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(Spacing.md), - decoration: BoxDecoration( - color: context.conduitTheme.cardBackground, - borderRadius: BorderRadius.circular(AppBorderRadius.md), - border: Border.all( - color: context.conduitTheme.cardBorder, - width: BorderWidth.regular, + child: SingleChildScrollView( + padding: const EdgeInsets.all(Spacing.bottomSheetPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Handle bar + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: theme.textPrimary.withValues(alpha: Alpha.medium), + borderRadius: BorderRadius.circular(2), ), ), - child: Text( - 'No tools available', - style: AppTypography.bodySmallStyle.copyWith( - color: context.conduitTheme.textSecondary, - ), - ), - ); - } + ), + const SizedBox(height: Spacing.md), - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Available Tools', - style: AppTypography.labelStyle.copyWith( - color: context.conduitTheme.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: Spacing.sm), - ...tools.map( - (tool) => Padding( - padding: const EdgeInsets.only(bottom: Spacing.sm), - child: _buildToolCard( - tool, - selectedToolIds.contains(tool.id), + // Removed header for minimal, focused layout + + // Web Search Toggle + _buildWebSearchToggle(webSearchEnabled), + const SizedBox(height: Spacing.md), + + // Image Generation Toggle (conditionally shown) + if (imageGenAvailable) ...[ + _buildImageGenerationToggle(imageGenEnabled), + const SizedBox(height: Spacing.md), + ], + + // Tools Section + toolsAsync.when( + data: (tools) { + if (tools.isEmpty) { + return _buildNeutralCard( + child: Text( + 'No tools available', + style: AppTypography.bodySmallStyle.copyWith( + color: theme.textSecondary, + ), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader('Available Tools', tools.length), + const SizedBox(height: Spacing.sm), + ...tools.map( + (tool) => Padding( + padding: const EdgeInsets.only(bottom: Spacing.sm), + child: _buildToolCard( + tool, + selectedToolIds.contains(tool.id), + ), + ), + ), + ], + ); + }, + loading: () => _buildNeutralCard( + child: const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ), + error: (error, stack) => _buildNeutralCard( + child: Text( + 'Failed to load tools', + style: AppTypography.bodySmallStyle.copyWith( + color: theme.error, ), ), ), - ], - ); - }, - loading: () => Container( - width: double.infinity, - padding: const EdgeInsets.all(Spacing.md), - decoration: BoxDecoration( - color: context.conduitTheme.cardBackground, - borderRadius: BorderRadius.circular(AppBorderRadius.md), - border: Border.all( - color: context.conduitTheme.cardBorder, - width: BorderWidth.regular, ), - ), - child: const Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), - ), - error: (error, stack) => Container( - width: double.infinity, - padding: const EdgeInsets.all(Spacing.md), - decoration: BoxDecoration( - color: context.conduitTheme.cardBackground, - borderRadius: BorderRadius.circular(AppBorderRadius.md), - border: Border.all( - color: context.conduitTheme.cardBorder, - width: BorderWidth.regular, - ), - ), - child: Text( - 'Failed to load tools', - style: AppTypography.bodySmallStyle.copyWith( - color: context.conduitTheme.error, - ), - ), + ], ), ), - ], + ), ), ); } - Widget _buildWebSearchToggle(bool webSearchEnabled) { - return GestureDetector( - onTap: () { - HapticFeedback.lightImpact(); - ref.read(webSearchEnabledProvider.notifier).state = !webSearchEnabled; - }, - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(Spacing.md), - decoration: BoxDecoration( - color: webSearchEnabled - ? context.conduitTheme.buttonPrimary - : context.conduitTheme.cardBackground, - borderRadius: BorderRadius.circular(AppBorderRadius.md), - border: Border.all( - color: webSearchEnabled - ? context.conduitTheme.buttonPrimary - : context.conduitTheme.cardBorder, - width: BorderWidth.regular, + Widget _buildNeutralCard({required Widget child}) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + color: context.conduitTheme.cardBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: context.conduitTheme.cardBorder, + width: BorderWidth.regular, + ), + ), + child: child, + ); + } + + Widget _buildSectionHeader(String title, int count) { + final theme = context.conduitTheme; + return Row( + children: [ + Text( + title, + style: AppTypography.bodySmallStyle.copyWith( + fontWeight: FontWeight.w600, + color: theme.textSecondary, + letterSpacing: 0.2, ), ), + const SizedBox(width: Spacing.xs), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: theme.surfaceBackground.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + border: Border.all(color: theme.dividerColor, width: BorderWidth.thin), + ), + child: Text( + '$count', + style: AppTypography.bodySmallStyle.copyWith( + color: theme.textSecondary, + ), + ), + ), + ], + ); + } + + Widget _buildWebSearchToggle(bool webSearchEnabled) { + return Material( + color: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + side: BorderSide( + color: webSearchEnabled + ? context.conduitTheme.buttonPrimary + : context.conduitTheme.cardBorder, + width: BorderWidth.regular, + ), + ), + child: InkWell( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + onTap: () { + HapticFeedback.lightImpact(); + ref.read(webSearchEnabledProvider.notifier).state = !webSearchEnabled; + }, + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + color: webSearchEnabled + ? context.conduitTheme.buttonPrimary + : context.conduitTheme.cardBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.md), + ), child: Row( children: [ Icon( @@ -231,32 +254,39 @@ class _UnifiedToolsModalState extends ConsumerState { ), ], ), + ), ), ); } Widget _buildImageGenerationToggle(bool imageGenEnabled) { - return GestureDetector( - onTap: () { - HapticFeedback.lightImpact(); - ref.read(imageGenerationEnabledProvider.notifier).state = - !imageGenEnabled; - }, - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(Spacing.md), - decoration: BoxDecoration( + return Material( + color: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + side: BorderSide( color: imageGenEnabled ? context.conduitTheme.buttonPrimary - : context.conduitTheme.cardBackground, - borderRadius: BorderRadius.circular(AppBorderRadius.md), - border: Border.all( + : context.conduitTheme.cardBorder, + width: BorderWidth.regular, + ), + ), + child: InkWell( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + onTap: () { + HapticFeedback.lightImpact(); + ref.read(imageGenerationEnabledProvider.notifier).state = + !imageGenEnabled; + }, + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( color: imageGenEnabled ? context.conduitTheme.buttonPrimary - : context.conduitTheme.cardBorder, - width: BorderWidth.regular, + : context.conduitTheme.cardBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.md), ), - ), child: Row( children: [ Icon( @@ -306,42 +336,49 @@ class _UnifiedToolsModalState extends ConsumerState { ), ], ), + ), ), ); } Widget _buildToolCard(Tool tool, bool isSelected) { - return GestureDetector( - onTap: () { - HapticFeedback.lightImpact(); - final currentIds = ref.read(selectedToolIdsProvider); - if (isSelected) { - ref.read(selectedToolIdsProvider.notifier).state = currentIds - .where((id) => id != tool.id) - .toList(); - } else { - ref.read(selectedToolIdsProvider.notifier).state = [ - ...currentIds, - tool.id, - ]; - } - }, - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(Spacing.md), - decoration: BoxDecoration( + return Material( + color: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + side: BorderSide( color: isSelected ? context.conduitTheme.buttonPrimary - : context.conduitTheme.cardBackground, - borderRadius: BorderRadius.circular(AppBorderRadius.md), - border: Border.all( + : context.conduitTheme.cardBorder, + width: BorderWidth.regular, + ), + ), + child: InkWell( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + onTap: () { + HapticFeedback.lightImpact(); + final currentIds = ref.read(selectedToolIdsProvider); + if (isSelected) { + ref.read(selectedToolIdsProvider.notifier).state = currentIds + .where((id) => id != tool.id) + .toList(); + } else { + ref.read(selectedToolIdsProvider.notifier).state = [ + ...currentIds, + tool.id, + ]; + } + }, + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( color: isSelected ? context.conduitTheme.buttonPrimary - : context.conduitTheme.cardBorder, - width: BorderWidth.regular, + : context.conduitTheme.cardBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.md), ), - ), - child: Row( + child: Row( children: [ Icon( _getToolIcon(tool), @@ -391,6 +428,7 @@ class _UnifiedToolsModalState extends ConsumerState { : context.conduitTheme.textSecondary, ), ], + ), ), ), );