From 07ac0195f9351edfa6260e449abb09b6fd3bcb88 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 18:51:03 +0000 Subject: [PATCH 1/3] feat: Add support for nested folders Implements nested folder support with the following features: - Created FolderHierarchy utility to build hierarchical tree from flat folder list - Updated UI to render nested folders with proper indentation - Added "Create Subfolder" option to folder context menu - Implemented drag-and-drop support for: - Dragging conversations into nested folders - Dragging folders into other folders to create nesting - Prevention of circular references (can't drop folder into itself or descendants) - Folders automatically expand when new subfolders are created or moved into them - Visual indentation scales with folder depth for clear hierarchy display The implementation leverages the existing parentId field in the Folder model, which already supported nesting at the API level. The changes are primarily UI enhancements to display and manage the folder hierarchy. Addresses: https://github.com/cogwheel0/conduit/issues/81 --- lib/core/utils/folder_hierarchy.dart | 129 ++++ .../navigation/widgets/chats_drawer.dart | 624 +++++++++++++----- lib/l10n/app_en.arb | 4 + 3 files changed, 574 insertions(+), 183 deletions(-) create mode 100644 lib/core/utils/folder_hierarchy.dart diff --git a/lib/core/utils/folder_hierarchy.dart b/lib/core/utils/folder_hierarchy.dart new file mode 100644 index 00000000..c0e3eb70 --- /dev/null +++ b/lib/core/utils/folder_hierarchy.dart @@ -0,0 +1,129 @@ +import '../models/folder.dart'; + +/// Represents a folder node in the hierarchy tree +class FolderNode { + FolderNode({ + required this.folder, + this.children = const [], + this.depth = 0, + }); + + final Folder folder; + final List children; + final int depth; + + FolderNode copyWith({ + Folder? folder, + List? children, + int? depth, + }) { + return FolderNode( + folder: folder ?? this.folder, + children: children ?? this.children, + depth: depth ?? this.depth, + ); + } +} + +/// Builds a hierarchical tree structure from a flat list of folders +class FolderHierarchy { + FolderHierarchy(List folders) { + _buildHierarchy(folders); + } + + final List _rootNodes = []; + final Map _nodeMap = {}; + + /// Get root-level folders (folders with no parent) + List get rootNodes => _rootNodes; + + /// Get all folders in a flattened list with depth information + List get flattenedNodes { + final result = []; + void addNodesRecursively(List nodes) { + for (final node in nodes) { + result.add(node); + addNodesRecursively(node.children); + } + } + + addNodesRecursively(_rootNodes); + return result; + } + + /// Find a folder node by ID + FolderNode? findNode(String folderId) { + return _nodeMap[folderId]; + } + + /// Build the hierarchy from a flat list + void _buildHierarchy(List folders) { + _rootNodes.clear(); + _nodeMap.clear(); + + // First pass: Create nodes for all folders + for (final folder in folders) { + _nodeMap[folder.id] = FolderNode(folder: folder); + } + + // Second pass: Build parent-child relationships + final childrenMap = >{}; + + for (final folder in folders) { + final node = _nodeMap[folder.id]!; + if (folder.parentId == null || folder.parentId!.isEmpty) { + // Root level folder + _rootNodes.add(node); + } else { + // Child folder - add to parent's children list + childrenMap.putIfAbsent(folder.parentId!, () => []).add(node); + } + } + + // Third pass: Update children and depths recursively + void updateDepthsRecursively(FolderNode node, int depth) { + final updatedNode = node.copyWith( + depth: depth, + children: childrenMap[node.folder.id] ?? const [], + ); + _nodeMap[node.folder.id] = updatedNode; + + for (final child in updatedNode.children) { + updateDepthsRecursively(child, depth + 1); + } + } + + // Start with root nodes at depth 0 + final updatedRoots = []; + for (final root in _rootNodes) { + updateDepthsRecursively(root, 0); + updatedRoots.add(_nodeMap[root.folder.id]!); + } + _rootNodes + ..clear() + ..addAll(updatedRoots); + } + + /// Check if a folder has any children + bool hasChildren(String folderId) { + final node = _nodeMap[folderId]; + return node != null && node.children.isNotEmpty; + } + + /// Get all child folder IDs recursively + List getDescendantIds(String folderId) { + final node = _nodeMap[folderId]; + if (node == null) return []; + + final result = []; + void collectIds(FolderNode n) { + for (final child in n.children) { + result.add(child.folder.id); + collectIds(child); + } + } + + collectIds(node); + return result; + } +} diff --git a/lib/features/navigation/widgets/chats_drawer.dart b/lib/features/navigation/widgets/chats_drawer.dart index 60e9feaf..5d2cbc47 100644 --- a/lib/features/navigation/widgets/chats_drawer.dart +++ b/lib/features/navigation/widgets/chats_drawer.dart @@ -26,6 +26,7 @@ import '../../../shared/widgets/responsive_drawer_layout.dart'; import '../../../core/models/model.dart'; import '../../../core/models/conversation.dart'; import '../../../core/models/folder.dart'; +import '../../../core/utils/folder_hierarchy.dart'; class ChatsDrawer extends ConsumerStatefulWidget { const ChatsDrawer({super.key}); @@ -359,56 +360,91 @@ class _ChatsDrawerState extends ConsumerState { final expandedMap = ref.watch(_expandedFoldersProvider); + // Build folder hierarchy + final hierarchy = FolderHierarchy(folders); + final out = []; - for (final folder in folders) { - final existing = grouped[folder.id] ?? const []; - final convs = _resolveFolderConversations( - folder, - existing, - ); - final isExpanded = - expandedMap[folder.id] ?? folder.isExpanded; - final hasItems = convs.isNotEmpty; - out.add( - SliverPadding( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.md, - ), - sliver: SliverToBoxAdapter( - child: _buildFolderHeader( - folder.id, - folder.name, - convs.length, - defaultExpanded: folder.isExpanded, - ), - ), - ), - ); - if (isExpanded && hasItems) { - out.add( - const SliverToBoxAdapter( - child: SizedBox(height: Spacing.xs), - ), + + // Recursively build folder widgets + void buildFolderWidgets( + List nodes, + int depth, + ) { + for (final node in nodes) { + final folder = node.folder; + final existing = + grouped[folder.id] ?? const []; + final convs = _resolveFolderConversations( + folder, + existing, ); + final isExpanded = + expandedMap[folder.id] ?? folder.isExpanded; + final hasItems = convs.isNotEmpty; + final hasChildren = node.children.isNotEmpty; + + // Add folder header with proper indentation out.add( - _conversationsSliver( - convs, - inFolder: true, - modelsById: modelsById, + SliverPadding( + padding: EdgeInsets.only( + left: Spacing.md + (depth * Spacing.lg), + right: Spacing.md, + ), + sliver: SliverToBoxAdapter( + child: _buildFolderHeader( + folder.id, + folder.name, + convs.length, + defaultExpanded: folder.isExpanded, + depth: depth, + hasChildren: hasChildren, + ), + ), ), ); + + // If expanded, show conversations + if (isExpanded && hasItems) { + out.add( + const SliverToBoxAdapter( + child: SizedBox(height: Spacing.xs), + ), + ); + out.add( + SliverPadding( + padding: EdgeInsets.only( + left: depth * Spacing.lg, + ), + sliver: _conversationsSliver( + convs, + inFolder: true, + modelsById: modelsById, + ), + ), + ); + out.add( + const SliverToBoxAdapter( + child: SizedBox(height: Spacing.xs), + ), + ); + } + + // If expanded and has children, show nested folders + if (isExpanded && hasChildren) { + buildFolderWidgets(node.children, depth + 1); + } + out.add( const SliverToBoxAdapter( child: SizedBox(height: Spacing.xs), ), ); } - out.add( - const SliverToBoxAdapter( - child: SizedBox(height: Spacing.xs), - ), - ); } + + // Start building from root nodes + buildFolderWidgets(hierarchy.rootNodes, 0); + return out.isEmpty ? [ const SliverToBoxAdapter(child: SizedBox.shrink()), @@ -582,49 +618,73 @@ class _ChatsDrawerState extends ConsumerState { grouped.putIfAbsent(id, () => []).add(c); } final expandedMap = ref.watch(_expandedFoldersProvider); + + // Build folder hierarchy + final hierarchy = FolderHierarchy(folders); final out = []; - for (final folder in folders) { - final existing = grouped[folder.id] ?? const []; - final convs = _resolveFolderConversations(folder, existing); - final isExpanded = - expandedMap[folder.id] ?? folder.isExpanded; - final hasItems = convs.isNotEmpty; - - out.add( - SliverPadding( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.md, - ), - sliver: SliverToBoxAdapter( - child: _buildFolderHeader( - folder.id, - folder.name, - convs.length, - defaultExpanded: folder.isExpanded, - ), - ), - ), - ); - if (isExpanded && hasItems) { - out.add( - const SliverToBoxAdapter( - child: SizedBox(height: Spacing.xs), - ), - ); - out.add( - _conversationsSliver( - convs, - inFolder: true, - modelsById: modelsById, - ), - ); + + // Recursively build folder widgets + void buildFolderWidgets(List nodes, int depth) { + for (final node in nodes) { + final folder = node.folder; + final existing = grouped[folder.id] ?? const []; + final convs = _resolveFolderConversations(folder, existing); + final isExpanded = + expandedMap[folder.id] ?? folder.isExpanded; + final hasItems = convs.isNotEmpty; + final hasChildren = node.children.isNotEmpty; + out.add( - const SliverToBoxAdapter( - child: SizedBox(height: Spacing.sm), + SliverPadding( + padding: EdgeInsets.only( + left: Spacing.md + (depth * Spacing.lg), + right: Spacing.md, + ), + sliver: SliverToBoxAdapter( + child: _buildFolderHeader( + folder.id, + folder.name, + convs.length, + defaultExpanded: folder.isExpanded, + depth: depth, + hasChildren: hasChildren, + ), + ), ), ); + if (isExpanded && hasItems) { + out.add( + const SliverToBoxAdapter( + child: SizedBox(height: Spacing.xs), + ), + ); + out.add( + SliverPadding( + padding: EdgeInsets.only(left: depth * Spacing.lg), + sliver: _conversationsSliver( + convs, + inFolder: true, + modelsById: modelsById, + ), + ), + ); + out.add( + const SliverToBoxAdapter( + child: SizedBox(height: Spacing.sm), + ), + ); + } + + // If expanded and has children, show nested folders + if (isExpanded && hasChildren) { + buildFolderWidgets(node.children, depth + 1); + } } } + + // Start building from root nodes + buildFolderWidgets(hierarchy.rootNodes, 0); + return out.isEmpty ? [ const SliverToBoxAdapter(child: SizedBox.shrink()), @@ -789,22 +849,185 @@ class _ChatsDrawerState extends ConsumerState { } } + Future _promptCreateSubfolder( + BuildContext context, + String parentId, + ) async { + final name = await ThemedDialogs.promptTextInput( + context, + title: AppLocalizations.of(context)!.newSubfolder, + hintText: AppLocalizations.of(context)!.folderName, + confirmText: AppLocalizations.of(context)!.create, + cancelText: AppLocalizations.of(context)!.cancel, + ); + + if (name == null) return; + if (name.isEmpty) return; + try { + final api = ref.read(apiServiceProvider); + if (api == null) throw Exception('No API service'); + final created = await api.createFolder(name: name, parentId: parentId); + final folder = Folder.fromJson(Map.from(created)); + HapticFeedback.lightImpact(); + ref.read(foldersProvider.notifier).upsertFolder(folder); + refreshConversationsCache(ref, includeFolders: true); + + // Automatically expand the parent folder to show the new subfolder + final expandedMap = {...ref.read(_expandedFoldersProvider)}; + expandedMap[parentId] = true; + ref.read(_expandedFoldersProvider.notifier).set(expandedMap); + } catch (e, stackTrace) { + if (!mounted) return; + DebugLogger.error( + 'create-subfolder-failed', + scope: 'drawer', + error: e, + stackTrace: stackTrace, + ); + await _showDrawerError( + AppLocalizations.of(context)!.failedToCreateFolder, + ); + } + } + Widget _buildFolderHeader( String folderId, String name, int count, { bool defaultExpanded = false, + int depth = 0, + bool hasChildren = false, }) { final theme = context.conduitTheme; final expandedMap = ref.watch(_expandedFoldersProvider); final isExpanded = expandedMap[folderId] ?? defaultExpanded; final isHover = _dragHoverFolderId == folderId; + + // Build the folder header widget + Widget buildFolderHeaderWidget({ + required bool isConversationHover, + required bool isFolderHover, + }) { + final combinedHover = isConversationHover || isFolderHover; + final baseColor = theme.surfaceContainer; + final hoverColor = theme.buttonPrimary.withValues(alpha: 0.08); + final borderColor = combinedHover + ? theme.buttonPrimary.withValues(alpha: 0.60) + : theme.surfaceContainerHighest.withValues(alpha: 0.40); + + Color? overlayForStates(Set states) { + if (states.contains(WidgetState.pressed)) { + return theme.buttonPrimary.withValues(alpha: Alpha.buttonPressed); + } + if (states.contains(WidgetState.hovered) || + states.contains(WidgetState.focused)) { + return theme.buttonPrimary.withValues(alpha: Alpha.hover); + } + return Colors.transparent; + } + + return Material( + color: combinedHover ? hoverColor : baseColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.small), + side: BorderSide(color: borderColor, width: BorderWidth.thin), + ), + child: InkWell( + borderRadius: BorderRadius.circular(AppBorderRadius.small), + onTap: () { + final current = {...ref.read(_expandedFoldersProvider)}; + final next = !isExpanded; + current[folderId] = next; + ref.read(_expandedFoldersProvider.notifier).set(current); + }, + onLongPress: () { + HapticFeedback.selectionClick(); + _showFolderContextMenu(context, folderId, name); + }, + overlayColor: WidgetStateProperty.resolveWith(overlayForStates), + child: ConstrainedBox( + constraints: const BoxConstraints( + minHeight: TouchTarget.listItem, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.xs, + ), + child: LayoutBuilder( + builder: (context, constraints) { + final hasFiniteWidth = constraints.maxWidth.isFinite; + final textFit = + hasFiniteWidth ? FlexFit.tight : FlexFit.loose; + + return Row( + mainAxisSize: + hasFiniteWidth ? MainAxisSize.max : MainAxisSize.min, + children: [ + Icon( + isExpanded + ? (Platform.isIOS + ? CupertinoIcons.folder_open + : Icons.folder_open) + : (Platform.isIOS + ? CupertinoIcons.folder + : Icons.folder), + color: theme.iconPrimary, + size: IconSize.listItem, + ), + const SizedBox(width: Spacing.sm), + Flexible( + fit: textFit, + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTypography.standard.copyWith( + color: theme.textPrimary, + fontWeight: FontWeight.w400, + ), + ), + ), + const SizedBox(width: Spacing.sm), + Text( + '$count', + style: AppTypography.standard.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, + size: IconSize.listItem, + ), + ], + ); + }, + ), + ), + ), + ), + ); + } + + // Wrap in drag targets for both conversations and folders return DragTarget<_DragConversationData>( onWillAcceptWithDetails: (details) { setState(() => _dragHoverFolderId = folderId); return true; }, - onLeave: (_) => setState(() => _dragHoverFolderId = null), + onLeave: (_) { + if (_dragHoverFolderId == folderId) { + setState(() => _dragHoverFolderId = null); + } + }, onAcceptWithDetails: (details) async { setState(() { _dragHoverFolderId = null; @@ -815,9 +1038,7 @@ class _ChatsDrawerState extends ConsumerState { if (api == null) throw Exception('No API service'); await api.moveConversationToFolder(details.data.id, folderId); HapticFeedback.selectionClick(); - ref - .read(conversationsProvider.notifier) - .updateConversation( + ref.read(conversationsProvider.notifier).updateConversation( details.data.id, (conversation) => conversation.copyWith( folderId: folderId, @@ -839,114 +1060,136 @@ class _ChatsDrawerState extends ConsumerState { } } }, - builder: (context, candidateData, rejectedData) { - final baseColor = theme.surfaceContainer; - final hoverColor = theme.buttonPrimary.withValues(alpha: 0.08); - final borderColor = isHover - ? theme.buttonPrimary.withValues(alpha: 0.60) - : theme.surfaceContainerHighest.withValues(alpha: 0.40); - - Color? overlayForStates(Set states) { - if (states.contains(WidgetState.pressed)) { - return theme.buttonPrimary.withValues(alpha: Alpha.buttonPressed); - } - if (states.contains(WidgetState.hovered) || - states.contains(WidgetState.focused)) { - return theme.buttonPrimary.withValues(alpha: Alpha.hover); - } - return Colors.transparent; - } - - return Material( - color: isHover ? hoverColor : baseColor, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppBorderRadius.small), - side: BorderSide(color: borderColor, width: BorderWidth.thin), - ), - child: InkWell( - borderRadius: BorderRadius.circular(AppBorderRadius.small), - onTap: () { - final current = {...ref.read(_expandedFoldersProvider)}; - final next = !isExpanded; - current[folderId] = next; - ref.read(_expandedFoldersProvider.notifier).set(current); - }, - onLongPress: () { + builder: (context, candidateConvData, rejectedConvData) { + final isConversationHover = candidateConvData.isNotEmpty; + + // Nest another DragTarget for folders + return DragTarget<_DragFolderData>( + onWillAcceptWithDetails: (details) { + // Prevent dropping a folder into itself or its descendants + final hierarchy = FolderHierarchy( + ref.read(foldersProvider).value ?? [], + ); + final descendants = hierarchy.getDescendantIds(details.data.id); + if (details.data.id == folderId || + descendants.contains(folderId)) { + return false; + } + setState(() => _dragHoverFolderId = folderId); + return true; + }, + onLeave: (_) { + if (_dragHoverFolderId == folderId) { + setState(() => _dragHoverFolderId = null); + } + }, + onAcceptWithDetails: (details) async { + setState(() { + _dragHoverFolderId = null; + _isDragging = false; + }); + try { + final api = ref.read(apiServiceProvider); + if (api == null) throw Exception('No API service'); + await api.updateFolder(details.data.id, parentId: folderId); HapticFeedback.selectionClick(); - _showFolderContextMenu(context, folderId, name); - }, - overlayColor: WidgetStateProperty.resolveWith(overlayForStates), - child: ConstrainedBox( - constraints: const BoxConstraints( - minHeight: TouchTarget.listItem, - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.md, - vertical: Spacing.xs, - ), - child: LayoutBuilder( - builder: (context, constraints) { - final hasFiniteWidth = constraints.maxWidth.isFinite; - final textFit = hasFiniteWidth - ? FlexFit.tight - : FlexFit.loose; - - return Row( - mainAxisSize: hasFiniteWidth - ? MainAxisSize.max - : MainAxisSize.min, - children: [ - Icon( - isExpanded - ? (Platform.isIOS - ? CupertinoIcons.folder_open - : Icons.folder_open) - : (Platform.isIOS - ? CupertinoIcons.folder - : Icons.folder), - color: theme.iconPrimary, - size: IconSize.listItem, - ), - const SizedBox(width: Spacing.sm), - Flexible( - fit: textFit, - child: Text( - name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: AppTypography.standard.copyWith( - color: theme.textPrimary, - fontWeight: FontWeight.w400, - ), - ), - ), - const SizedBox(width: Spacing.sm), - Text( - '$count', - style: AppTypography.standard.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, - size: IconSize.listItem, + ref.read(foldersProvider.notifier).updateFolder( + details.data.id, + (folder) => folder.copyWith( + parentId: folderId, + updatedAt: DateTime.now(), + ), + ); + refreshConversationsCache(ref, includeFolders: true); + + // Automatically expand the target folder + final expandedMap = {...ref.read(_expandedFoldersProvider)}; + expandedMap[folderId] = true; + ref.read(_expandedFoldersProvider.notifier).set(expandedMap); + } catch (e, stackTrace) { + DebugLogger.error( + 'move-folder-failed', + scope: 'drawer', + error: e, + stackTrace: stackTrace, + ); + if (mounted) { + await _showDrawerError('Failed to move folder'); + } + } + }, + builder: (context, candidateFolderData, rejectedFolderData) { + final isFolderHover = candidateFolderData.isNotEmpty; + + // Make the folder header draggable + return LongPressDraggable<_DragFolderData>( + data: _DragFolderData(id: folderId, name: name), + dragAnchorStrategy: pointerDragAnchorStrategy, + feedback: Material( + color: Colors.transparent, + elevation: Elevation.low, + borderRadius: BorderRadius.circular(AppBorderRadius.small), + 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.circular(AppBorderRadius.small), + border: Border.all( + color: theme.surfaceContainerHighest + .withValues(alpha: 0.40), + 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), + Text( + name, + style: AppTypography.standard.copyWith( + color: theme.textPrimary, + fontWeight: FontWeight.w400, ), - ], - ); - }, + ), + ], + ), ), ), - ), - ), + childWhenDragging: Opacity( + opacity: 0.5, + child: IgnorePointer( + child: buildFolderHeaderWidget( + isConversationHover: false, + isFolderHover: false, + ), + ), + ), + onDragStarted: () { + HapticFeedback.lightImpact(); + setState(() => _isDragging = true); + }, + onDragEnd: (_) => setState(() { + _dragHoverFolderId = null; + _isDragging = false; + }), + child: buildFolderHeaderWidget( + isConversationHover: isConversationHover, + isFolderHover: isFolderHover, + ), + ); + }, ); }, ); @@ -1051,6 +1294,15 @@ class _ChatsDrawerState extends ConsumerState { showConduitContextMenu( context: context, actions: [ + ConduitContextMenuAction( + cupertinoIcon: CupertinoIcons.folder_badge_plus, + materialIcon: Icons.create_new_folder_outlined, + label: l10n.newSubfolder, + onBeforeClose: () => HapticFeedback.selectionClick(), + onSelected: () async { + await _promptCreateSubfolder(context, folderId); + }, + ), ConduitContextMenuAction( cupertinoIcon: CupertinoIcons.pencil, materialIcon: Icons.edit_rounded, @@ -1589,6 +1841,12 @@ class _DragConversationData { const _DragConversationData({required this.id, required this.title}); } +class _DragFolderData { + final String id; + final String name; + const _DragFolderData({required this.id, required this.name}); +} + class _ConversationDragFeedback extends StatelessWidget { final String title; final bool pinned; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 253762b4..11ad9ece 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -734,6 +734,10 @@ "@newFolder": { "description": "Action to create a new folder." }, + "newSubfolder": "New Subfolder", + "@newSubfolder": { + "description": "Action to create a new subfolder inside a folder." + }, "folderName": "Folder name", "@folderName": { "description": "Label for entering a folder's name." From 06caea4744cbb33f4b9a4f2f7ead06217754a3ff Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 19:07:21 +0000 Subject: [PATCH 2/3] fix: Enable drag-and-drop for folders by removing gesture conflict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, long-press on folders triggered the context menu, which prevented the LongPressDraggable widget from initiating drag operations. Changes: - Removed onLongPress handler from folder InkWell - Added explicit menu button (three dots icon) to folder headers - Long-press gesture now exclusively handles drag-and-drop UX flow: - Tap folder → expand/collapse - Long-press and drag folder → nest folder into another - Click menu button → show context menu (new subfolder, rename, delete) --- .../navigation/widgets/chats_drawer.dart | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/lib/features/navigation/widgets/chats_drawer.dart b/lib/features/navigation/widgets/chats_drawer.dart index 5d2cbc47..18ca3b55 100644 --- a/lib/features/navigation/widgets/chats_drawer.dart +++ b/lib/features/navigation/widgets/chats_drawer.dart @@ -940,10 +940,6 @@ class _ChatsDrawerState extends ConsumerState { current[folderId] = next; ref.read(_expandedFoldersProvider.notifier).set(current); }, - onLongPress: () { - HapticFeedback.selectionClick(); - _showFolderContextMenu(context, folderId, name); - }, overlayColor: WidgetStateProperty.resolveWith(overlayForStates), child: ConstrainedBox( constraints: const BoxConstraints( @@ -995,6 +991,37 @@ class _ChatsDrawerState extends ConsumerState { color: theme.textSecondary, ), ), + const SizedBox(width: Spacing.sm), + Builder( + builder: (buttonContext) { + return IconButton( + iconSize: IconSize.sm, + visualDensity: const VisualDensity( + horizontal: -2, + vertical: -2, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: TouchTarget.listItem, + minHeight: TouchTarget.listItem, + ), + icon: Icon( + Platform.isIOS + ? CupertinoIcons.ellipsis + : Icons.more_vert_rounded, + color: theme.iconSecondary, + ), + onPressed: () { + _showFolderContextMenu( + buttonContext, + folderId, + name, + ); + }, + tooltip: AppLocalizations.of(context)!.more, + ); + }, + ), const SizedBox(width: Spacing.xs), Icon( isExpanded From eb68dd9a25d6f296641924baada904fec740b0a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 19:11:47 +0000 Subject: [PATCH 3/3] fix: Show subfolder count in folder display Previously, folder counts only showed conversations, making empty folders with subfolders appear as "0", with no indication they contained nested folders. This made it unclear that clicking to expand would reveal content. Changes: - Updated folder count to include both conversations AND subfolders - Empty parent folders now show correct count (e.g., "1" if containing 1 subfolder) - Makes nested folder structure more discoverable and intuitive Example: - Before: Folder with 0 chats and 2 subfolders showed "0" - After: Same folder now shows "2" --- lib/features/navigation/widgets/chats_drawer.dart | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/features/navigation/widgets/chats_drawer.dart b/lib/features/navigation/widgets/chats_drawer.dart index 18ca3b55..b25217cf 100644 --- a/lib/features/navigation/widgets/chats_drawer.dart +++ b/lib/features/navigation/widgets/chats_drawer.dart @@ -383,6 +383,9 @@ class _ChatsDrawerState extends ConsumerState { final hasItems = convs.isNotEmpty; final hasChildren = node.children.isNotEmpty; + // Count both conversations and subfolders + final totalCount = convs.length + node.children.length; + // Add folder header with proper indentation out.add( SliverPadding( @@ -394,7 +397,7 @@ class _ChatsDrawerState extends ConsumerState { child: _buildFolderHeader( folder.id, folder.name, - convs.length, + totalCount, defaultExpanded: folder.isExpanded, depth: depth, hasChildren: hasChildren, @@ -634,6 +637,9 @@ class _ChatsDrawerState extends ConsumerState { final hasItems = convs.isNotEmpty; final hasChildren = node.children.isNotEmpty; + // Count both conversations and subfolders + final totalCount = convs.length + node.children.length; + out.add( SliverPadding( padding: EdgeInsets.only( @@ -644,7 +650,7 @@ class _ChatsDrawerState extends ConsumerState { child: _buildFolderHeader( folder.id, folder.name, - convs.length, + totalCount, defaultExpanded: folder.isExpanded, depth: depth, hasChildren: hasChildren,