From b2774f72ba7bbe316add8ea5b06eadac7d821f7a Mon Sep 17 00:00:00 2001 From: Borja Balsera Date: Tue, 6 Jan 2026 02:19:35 +0100 Subject: [PATCH] feat(folder): Implement nested folders and drag-and-drop nesting --- .../navigation/widgets/chats_drawer.dart | 283 +++++++++++++++--- lib/l10n/app_en.arb | 4 + 2 files changed, 241 insertions(+), 46 deletions(-) diff --git a/lib/features/navigation/widgets/chats_drawer.dart b/lib/features/navigation/widgets/chats_drawer.dart index 4ff6e1b8..1b4292b0 100644 --- a/lib/features/navigation/widgets/chats_drawer.dart +++ b/lib/features/navigation/widgets/chats_drawer.dart @@ -444,8 +444,19 @@ class _ChatsDrawerState extends ConsumerState { final expandedMap = ref.watch(_expandedFoldersProvider); - final out = []; + // Build a map of children by parentId + final childrenByParentId = >{}; for (final folder in folders) { + final parentId = folder.parentId; + if (parentId != null && parentId.isNotEmpty) { + childrenByParentId + .putIfAbsent(parentId, () => []) + .add(folder); + } + } + + // Recursive helper to build folder widgets + List buildFolderWidgets(Folder folder, int depth) { final existing = grouped[folder.id] ?? const []; final convs = _resolveFolderConversations( @@ -454,8 +465,11 @@ class _ChatsDrawerState extends ConsumerState { ); final isExpanded = expandedMap[folder.id] ?? folder.isExpanded; - final hasItems = convs.isNotEmpty; - out.add( + final children = childrenByParentId[folder.id] ?? []; + final hasItems = convs.isNotEmpty || children.isNotEmpty; + + final widgets = []; + widgets.add( SliverPadding( padding: const EdgeInsets.symmetric( horizontal: Spacing.md, @@ -466,36 +480,54 @@ class _ChatsDrawerState extends ConsumerState { folder.name, convs.length, defaultExpanded: folder.isExpanded, + depth: depth, ), ), ), ); if (isExpanded && hasItems) { - out.add( + widgets.add( const SliverToBoxAdapter( child: SizedBox(height: Spacing.xs), ), ); - out.add( - _conversationsSliver( - convs, - inFolder: true, - modelsById: modelsById, - ), - ); - out.add( + // Add child folders first (nested) + for (final child in children) { + widgets.addAll(buildFolderWidgets(child, depth + 1)); + } + // Then add conversations in this folder + if (convs.isNotEmpty) { + widgets.add( + _conversationsSliver( + convs, + inFolder: true, + modelsById: modelsById, + ), + ); + } + widgets.add( const SliverToBoxAdapter( child: SizedBox(height: Spacing.sm), ), ); } else { // Only add spacing after collapsed folders - out.add( + widgets.add( const SliverToBoxAdapter( child: SizedBox(height: Spacing.xs), ), ); } + return widgets; + } + + // Only iterate root folders (parentId is null or empty) + final rootFolders = folders.where((f) => + f.parentId == null || f.parentId!.isEmpty).toList(); + + final out = []; + for (final folder in rootFolders) { + out.addAll(buildFolderWidgets(folder, 0)); } return out.isEmpty ? [ @@ -696,15 +728,29 @@ class _ChatsDrawerState extends ConsumerState { grouped.putIfAbsent(id, () => []).add(c); } final expandedMap = ref.watch(_expandedFoldersProvider); - final out = []; + + // Build a map of children by parentId + final childrenByParentId = >{}; for (final folder in folders) { + final parentId = folder.parentId; + if (parentId != null && parentId.isNotEmpty) { + childrenByParentId + .putIfAbsent(parentId, () => []) + .add(folder); + } + } + + // Recursive helper to build folder widgets + List buildFolderWidgets(Folder folder, int depth) { final existing = grouped[folder.id] ?? const []; final convs = _resolveFolderConversations(folder, existing); final isExpanded = expandedMap[folder.id] ?? folder.isExpanded; - final hasItems = convs.isNotEmpty; + final children = childrenByParentId[folder.id] ?? []; + final hasItems = convs.isNotEmpty || children.isNotEmpty; - out.add( + final widgets = []; + widgets.add( SliverPadding( padding: const EdgeInsets.symmetric( horizontal: Spacing.md, @@ -715,36 +761,54 @@ class _ChatsDrawerState extends ConsumerState { folder.name, convs.length, defaultExpanded: folder.isExpanded, + depth: depth, ), ), ), ); if (isExpanded && hasItems) { - out.add( + widgets.add( const SliverToBoxAdapter( child: SizedBox(height: Spacing.xs), ), ); - out.add( - _conversationsSliver( - convs, - inFolder: true, - modelsById: modelsById, - ), - ); - out.add( + // Add child folders first (nested) + for (final child in children) { + widgets.addAll(buildFolderWidgets(child, depth + 1)); + } + // Then add conversations in this folder + if (convs.isNotEmpty) { + widgets.add( + _conversationsSliver( + convs, + inFolder: true, + modelsById: modelsById, + ), + ); + } + widgets.add( const SliverToBoxAdapter( child: SizedBox(height: Spacing.sm), ), ); } else { // Only add spacing after collapsed folders - out.add( + widgets.add( const SliverToBoxAdapter( child: SizedBox(height: Spacing.xs), ), ); } + return widgets; + } + + // Only iterate root folders (parentId is null or empty) + final rootFolders = folders.where((f) => + f.parentId == null || f.parentId!.isEmpty).toList(); + + final out = []; + for (final folder in rootFolders) { + out.addAll(buildFolderWidgets(folder, 0)); } return out.isEmpty ? [ @@ -997,6 +1061,7 @@ class _ChatsDrawerState extends ConsumerState { String name, int count, { bool defaultExpanded = false, + int depth = 0, }) { final theme = context.conduitTheme; final expandedMap = ref.watch(_expandedFoldersProvider); @@ -1022,6 +1087,14 @@ class _ChatsDrawerState extends ConsumerState { return DropRegion( formats: const [], // Local data only onDropOver: (event) { + // Check if we're trying to drop a folder into itself + final localData = event.session.items.first.localData; + if (localData is Map && localData['type'] == 'folder') { + final droppedId = localData['id'] as String?; + if (droppedId == folderId) { + return DropOperation.none; // Cannot drop folder into itself + } + } setState(() => _dragHoverFolderId = folderId); return DropOperation.move; }, @@ -1035,38 +1108,95 @@ class _ChatsDrawerState extends ConsumerState { // Get local data from the drop event (serialized as Map) final localData = event.session.items.first.localData; if (localData is! Map) return; - final conversationId = localData['id'] as String?; - if (conversationId == null) return; + + final type = localData['type'] as String?; + final id = localData['id'] as String?; + if (id == null) return; + try { final api = ref.read(apiServiceProvider); if (api == null) throw Exception('No API service'); - await api.moveConversationToFolder(conversationId, folderId); - HapticFeedback.selectionClick(); - ref - .read(conversationsProvider.notifier) - .updateConversation( - conversationId, - (conversation) => conversation.copyWith( - folderId: folderId, - updatedAt: DateTime.now(), - ), - ); - refreshConversationsCache(ref, includeFolders: true); + + if (type == 'folder') { + // Moving a folder into this folder + if (id == folderId) return; // Cannot move folder into itself + await api.updateFolder(id, parentId: folderId); + HapticFeedback.selectionClick(); + ref.read(foldersProvider.notifier).updateFolder( + id, + (folder) => folder.copyWith(parentId: folderId), + ); + refreshConversationsCache(ref, includeFolders: true); + } else { + // Moving a conversation into this folder (existing logic) + await api.moveConversationToFolder(id, folderId); + HapticFeedback.selectionClick(); + ref + .read(conversationsProvider.notifier) + .updateConversation( + id, + (conversation) => conversation.copyWith( + folderId: folderId, + updatedAt: DateTime.now(), + ), + ); + refreshConversationsCache(ref, includeFolders: true); + } } catch (e, stackTrace) { DebugLogger.error( - 'move-conversation-failed', + type == 'folder' ? 'move-folder-failed' : 'move-conversation-failed', scope: 'drawer', error: e, stackTrace: stackTrace, ); if (mounted) { await _showDrawerError( - AppLocalizations.of(context)!.failedToMoveChat, + type == 'folder' + ? AppLocalizations.of(context)!.failedToMoveFolder + : AppLocalizations.of(context)!.failedToMoveChat, ); } } }, - child: ConduitContextMenu( + child: DragItemWidget( + canAddItemToExistingSession: false, + allowedOperations: () => [DropOperation.move], + dragItemProvider: (request) { + setState(() { + _isDragging = true; + _draggingHasFolder = true; + }); + + // Listen for drag completion to reset state + void onDragCompleted() { + if (mounted) { + setState(() { + _dragHoverFolderId = null; + _isDragging = false; + _draggingHasFolder = false; + }); + } + request.session.dragCompleted.removeListener(onDragCompleted); + } + + request.session.dragCompleted.addListener(onDragCompleted); + + // Provide drag data with folder info as serializable Map + final item = DragItem(localData: {'type': 'folder', 'id': folderId, 'name': name}); + return item; + }, + dragBuilder: (context, child) { + // Custom drag preview for folder + return Opacity( + opacity: 0.9, + child: _FolderDragFeedback( + name: name, + theme: theme, + ), + ); + }, + child: DraggableWidget( + child: ConduitContextMenu( actions: _buildFolderActions(folderId, name), child: Material( color: isHover ? hoverColor : baseColor, @@ -1089,9 +1219,11 @@ class _ChatsDrawerState extends ConsumerState { minHeight: TouchTarget.listItem, ), child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.md, - vertical: Spacing.xs, + padding: EdgeInsets.only( + left: Spacing.md + (depth * Spacing.lg), + right: Spacing.md, + top: Spacing.xs, + bottom: Spacing.xs, ), child: LayoutBuilder( builder: (context, constraints) { @@ -1209,6 +1341,8 @@ class _ChatsDrawerState extends ConsumerState { ), ), ), + ), + ), ), ); } @@ -1969,6 +2103,63 @@ class _ConversationDragFeedback extends StatelessWidget { } } +/// Visual drag preview for folders being dragged. +class _FolderDragFeedback extends StatelessWidget { + final String name; + final ConduitThemeExtension theme; + + const _FolderDragFeedback({ + required this.name, + required this.theme, + }); + + @override + Widget build(BuildContext context) { + final borderRadius = BorderRadius.circular(AppBorderRadius.small); + final borderColor = theme.surfaceContainerHighest.withValues(alpha: 0.40); + + return Material( + color: Colors.transparent, + elevation: Elevation.low, + borderRadius: borderRadius, + child: Container( + constraints: const BoxConstraints(minHeight: TouchTarget.listItem), + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.xs, + ), + decoration: BoxDecoration( + color: theme.surfaceContainer, + borderRadius: borderRadius, + border: Border.all(color: borderColor, width: BorderWidth.thin), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Platform.isIOS ? CupertinoIcons.folder : Icons.folder, + color: theme.iconPrimary, + size: IconSize.listItem, + ), + const SizedBox(width: Spacing.sm), + Flexible( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTypography.standard.copyWith( + color: theme.textPrimary, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ); + } +} + class _ConversationTileContent extends StatelessWidget { final String title; final bool pinned; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4f64584f..0dd9a944 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -766,6 +766,10 @@ "@failedToMoveChat": { "description": "Error notice when moving a chat fails." }, + "failedToMoveFolder": "Failed to move folder", + "@failedToMoveFolder": { + "description": "Error notice when moving a folder fails." + }, "failedToLoadChats": "Failed to load chats", "@failedToLoadChats": { "description": "Error notice when fetching chat list fails."