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..b25217cf 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,94 @@ 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; + + // Count both conversations and subfolders + final totalCount = convs.length + node.children.length; + + // 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, + totalCount, + 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 +621,76 @@ 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; + + // Count both conversations and subfolders + final totalCount = convs.length + node.children.length; + 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, + totalCount, + 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 +855,212 @@ 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); + }, + 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.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 + ? (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 +1071,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 +1093,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 +1327,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 +1874,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."