diff --git a/app/lib/features/geckoview/features/bookmarks/domain/entities/bookmark_list_ui_state.dart b/app/lib/features/geckoview/features/bookmarks/domain/entities/bookmark_list_ui_state.dart new file mode 100644 index 00000000..78d02954 --- /dev/null +++ b/app/lib/features/geckoview/features/bookmarks/domain/entities/bookmark_list_ui_state.dart @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:copy_with_extension/copy_with_extension.dart'; +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart'; + +part 'bookmark_list_ui_state.g.dart'; + +@CopyWith() +class BookmarkListUiState with FastEquatable { + final bool selectionMode; + final Set selectedGuids; + final BookmarkSortType sortType; + final bool foldersOnly; + + BookmarkListUiState({ + this.selectionMode = false, + this.selectedGuids = const {}, + this.sortType = BookmarkSortType.manual, + this.foldersOnly = false, + }); + + @override + List get hashParameters => [ + selectionMode, + selectedGuids, + sortType, + foldersOnly, + ]; +} diff --git a/app/lib/features/geckoview/features/bookmarks/domain/entities/bookmark_list_ui_state.g.dart b/app/lib/features/geckoview/features/bookmarks/domain/entities/bookmark_list_ui_state.g.dart new file mode 100644 index 00000000..64059268 --- /dev/null +++ b/app/lib/features/geckoview/features/bookmarks/domain/entities/bookmark_list_ui_state.g.dart @@ -0,0 +1,100 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'bookmark_list_ui_state.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$BookmarkListUiStateCWProxy { + BookmarkListUiState selectionMode(bool selectionMode); + + BookmarkListUiState selectedGuids(Set selectedGuids); + + BookmarkListUiState sortType(BookmarkSortType sortType); + + BookmarkListUiState foldersOnly(bool foldersOnly); + + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `BookmarkListUiState(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// BookmarkListUiState(...).copyWith(id: 12, name: "My name") + /// ``` + BookmarkListUiState call({ + bool selectionMode, + Set selectedGuids, + BookmarkSortType sortType, + bool foldersOnly, + }); +} + +/// Callable proxy for `copyWith` functionality. +/// Use as `instanceOfBookmarkListUiState.copyWith(...)` or call `instanceOfBookmarkListUiState.copyWith.fieldName(value)` for a single field. +class _$BookmarkListUiStateCWProxyImpl implements _$BookmarkListUiStateCWProxy { + const _$BookmarkListUiStateCWProxyImpl(this._value); + + final BookmarkListUiState _value; + + @override + BookmarkListUiState selectionMode(bool selectionMode) => + call(selectionMode: selectionMode); + + @override + BookmarkListUiState selectedGuids(Set selectedGuids) => + call(selectedGuids: selectedGuids); + + @override + BookmarkListUiState sortType(BookmarkSortType sortType) => + call(sortType: sortType); + + @override + BookmarkListUiState foldersOnly(bool foldersOnly) => + call(foldersOnly: foldersOnly); + + @override + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `BookmarkListUiState(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// BookmarkListUiState(...).copyWith(id: 12, name: "My name") + /// ``` + BookmarkListUiState call({ + Object? selectionMode = const $CopyWithPlaceholder(), + Object? selectedGuids = const $CopyWithPlaceholder(), + Object? sortType = const $CopyWithPlaceholder(), + Object? foldersOnly = const $CopyWithPlaceholder(), + }) { + return BookmarkListUiState( + selectionMode: + selectionMode == const $CopyWithPlaceholder() || selectionMode == null + ? _value.selectionMode + // ignore: cast_nullable_to_non_nullable + : selectionMode as bool, + selectedGuids: + selectedGuids == const $CopyWithPlaceholder() || selectedGuids == null + ? _value.selectedGuids + // ignore: cast_nullable_to_non_nullable + : selectedGuids as Set, + sortType: sortType == const $CopyWithPlaceholder() || sortType == null + ? _value.sortType + // ignore: cast_nullable_to_non_nullable + : sortType as BookmarkSortType, + foldersOnly: + foldersOnly == const $CopyWithPlaceholder() || foldersOnly == null + ? _value.foldersOnly + // ignore: cast_nullable_to_non_nullable + : foldersOnly as bool, + ); + } +} + +extension $BookmarkListUiStateCopyWith on BookmarkListUiState { + /// Returns a callable class used to build a new instance with modified fields. + /// Example: `instanceOfBookmarkListUiState.copyWith(...)` or `instanceOfBookmarkListUiState.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$BookmarkListUiStateCWProxy get copyWith => + _$BookmarkListUiStateCWProxyImpl(this); +} diff --git a/app/lib/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart b/app/lib/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart new file mode 100644 index 00000000..9fe12940 --- /dev/null +++ b/app/lib/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart'; + +enum BookmarkSortType { + manual('Default'), + titleAsc('Title A-Z'), + titleDesc('Title Z-A'), + urlAsc('URL A-Z'), + dateAddedDesc('Newest First'); + + final String label; + + const BookmarkSortType(this.label); +} + +int compareBookmarkItems( + BookmarkItem a, + BookmarkItem b, + BookmarkSortType sort, +) { + return switch (sort) { + BookmarkSortType.manual => 0, + BookmarkSortType.titleAsc => a.title.toLowerCase().compareTo( + b.title.toLowerCase(), + ), + BookmarkSortType.titleDesc => b.title.toLowerCase().compareTo( + a.title.toLowerCase(), + ), + BookmarkSortType.urlAsc => _compareByUrl(a, b), + BookmarkSortType.dateAddedDesc => b.dateAdded.compareTo(a.dateAdded), + }; +} + +int _compareByUrl(BookmarkItem a, BookmarkItem b) { + final aUrl = a is BookmarkEntry ? a.url.toString() : a.title.toLowerCase(); + final bUrl = b is BookmarkEntry ? b.url.toString() : b.title.toLowerCase(); + return aUrl.compareTo(bUrl); +} diff --git a/app/lib/features/geckoview/features/bookmarks/domain/providers/bookmark_list_ui_state.dart b/app/lib/features/geckoview/features/bookmarks/domain/providers/bookmark_list_ui_state.dart new file mode 100644 index 00000000..3b00f113 --- /dev/null +++ b/app/lib/features/geckoview/features/bookmarks/domain/providers/bookmark_list_ui_state.dart @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_list_ui_state.dart'; +import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart'; + +part 'bookmark_list_ui_state.g.dart'; + +@Riverpod() +class BookmarkListUiStateNotifier extends _$BookmarkListUiStateNotifier { + @override + BookmarkListUiState build() => BookmarkListUiState(); + + void enterSelectionMode({String? initialGuid}) { + state = state.copyWith( + selectionMode: true, + selectedGuids: initialGuid != null ? {initialGuid} : {}, + ); + } + + void exitSelectionMode() { + state = state.copyWith(selectionMode: false, selectedGuids: {}); + } + + void toggleSelection(String guid) { + final updated = Set.from(state.selectedGuids); + if (updated.contains(guid)) { + updated.remove(guid); + } else { + updated.add(guid); + } + if (updated.isEmpty) { + exitSelectionMode(); + } else { + state = state.copyWith(selectedGuids: updated); + } + } + + void selectAll(Iterable guids) { + state = state.copyWith( + selectionMode: true, + selectedGuids: Set.from(guids), + ); + } + + void clearSelection() { + exitSelectionMode(); + } + + void setSortType(BookmarkSortType sortType) { + state = state.copyWith(sortType: sortType); + } + + void toggleFoldersOnly() { + state = state.copyWith(foldersOnly: !state.foldersOnly); + } +} diff --git a/app/lib/features/geckoview/features/bookmarks/domain/providers/bookmark_list_ui_state.g.dart b/app/lib/features/geckoview/features/bookmarks/domain/providers/bookmark_list_ui_state.g.dart new file mode 100644 index 00000000..b82ee652 --- /dev/null +++ b/app/lib/features/geckoview/features/bookmarks/domain/providers/bookmark_list_ui_state.g.dart @@ -0,0 +1,65 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'bookmark_list_ui_state.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(BookmarkListUiStateNotifier) +final bookmarkListUiStateProvider = BookmarkListUiStateNotifierProvider._(); + +final class BookmarkListUiStateNotifierProvider + extends + $NotifierProvider { + BookmarkListUiStateNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'bookmarkListUiStateProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$bookmarkListUiStateNotifierHash(); + + @$internal + @override + BookmarkListUiStateNotifier create() => BookmarkListUiStateNotifier(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(BookmarkListUiState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$bookmarkListUiStateNotifierHash() => + r'7c764fc2f1deb178063ca95764775ca237471f9f'; + +abstract class _$BookmarkListUiStateNotifier + extends $Notifier { + BookmarkListUiState build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + BookmarkListUiState, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart b/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart index 31c488db..5f14828c 100644 --- a/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart +++ b/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart @@ -20,6 +20,7 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:nullability/nullability.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/logger.dart'; import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart'; import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_html_utils.dart'; import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_json_utils.dart'; @@ -36,16 +37,18 @@ class BookmarksRepository extends _$BookmarksRepository { required String parentGuid, required Uri url, required String title, + int? position, }) async { - await _service.addItem(parentGuid, url, title, null); + await _service.addItem(parentGuid, url, title, position); ref.invalidateSelf(); } Future addFolder({ required String parentGuid, required String title, + int? position, }) async { - await _service.addFolder(parentGuid, title, null); + await _service.addFolder(parentGuid, title, position); ref.invalidateSelf(); } @@ -54,10 +57,16 @@ class BookmarksRepository extends _$BookmarksRepository { String? title, Uri? url, String? parentGuid, + int? position, }) async { await _service.updateNode( guid, - BookmarkInfo(title: title, url: url?.toString(), parentGuid: parentGuid), + BookmarkInfo( + title: title, + url: url?.toString(), + parentGuid: parentGuid, + position: position, + ), ); ref.invalidateSelf(); } @@ -66,10 +75,11 @@ class BookmarksRepository extends _$BookmarksRepository { required String guid, String? title, String? parentGuid, + int? position, }) async { await _service.updateNode( guid, - BookmarkInfo(title: title, parentGuid: parentGuid), + BookmarkInfo(title: title, parentGuid: parentGuid, position: position), ); ref.invalidateSelf(); } @@ -79,6 +89,78 @@ class BookmarksRepository extends _$BookmarksRepository { ref.invalidateSelf(); } + Future moveMany({ + required Iterable items, + required String targetParentGuid, + }) async { + for (final item in items) { + if (bookmarkRootIds.contains(item.guid)) { + logger.w('Skipping move of root folder: ${item.guid}'); + continue; + } + if (item.parentGuid == targetParentGuid) continue; + await _service.updateNode( + item.guid, + BookmarkInfo(parentGuid: targetParentGuid), + ); + } + ref.invalidateSelf(); + } + + Future deleteMany(Iterable guids) async { + for (final guid in guids) { + if (bookmarkRootIds.contains(guid)) { + logger.w('Skipping delete of root folder: $guid'); + continue; + } + await _service.deleteNode(guid); + } + ref.invalidateSelf(); + } + + Future flattenFolder({required BookmarkFolder folder}) async { + if (folder.parentGuid == null || bookmarkRootIds.contains(folder.guid)) { + logger.w('Cannot flatten root or parentless folder: ${folder.guid}'); + return; + } + // Fetch the full folder tree from storage to avoid operating on a + // filtered subset (e.g. when search is active), which would silently + // delete children that were not moved. + final fullNode = await _service.getTree(folder.guid); + final children = fullNode?.children; + if (children != null) { + for (final child in children) { + await _service.updateNode( + child.guid, + BookmarkInfo(parentGuid: folder.parentGuid), + ); + } + } + await _service.deleteNode(folder.guid); + ref.invalidateSelf(); + } + + /// Returns the GUIDs of all descendant folders of [guid] by fetching the + /// full subtree from storage. This is safe to call even when the UI tree is + /// filtered (e.g. during search), unlike the pure-utility + /// [collectDescendantFolderGuids] which only walks the in-memory tree. + Future> getDescendantFolderGuids(String guid) async { + final node = await _service.getTree(guid, recursive: true); + if (node == null) return const {}; + final result = {}; + void collect(BookmarkNode n) { + for (final child in n.children ?? const []) { + if (child.type == BookmarkNodeType.folder) { + result.add(child.guid); + collect(child); + } + } + } + + collect(node); + return result; + } + Future eraseEverything(BookmarkRoot root) async { await _service.eraseEverything(root); ref.invalidateSelf(); diff --git a/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.g.dart b/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.g.dart index 458db048..f60cfda6 100644 --- a/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.g.dart +++ b/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.g.dart @@ -34,7 +34,7 @@ final class BookmarksRepositoryProvider } String _$bookmarksRepositoryHash() => - r'c53414612bf1d1da824e1150ca4eca7bb7c3bec7'; + r'2169d5b354c4a22192096451c96ab1490cf55ab4'; abstract class _$BookmarksRepository extends $AsyncNotifier { FutureOr build(); diff --git a/app/lib/features/geckoview/features/bookmarks/domain/utils/bookmark_tree_utils.dart b/app/lib/features/geckoview/features/bookmarks/domain/utils/bookmark_tree_utils.dart new file mode 100644 index 00000000..238babe9 --- /dev/null +++ b/app/lib/features/geckoview/features/bookmarks/domain/utils/bookmark_tree_utils.dart @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart'; +import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart'; + +/// Recursively sorts a bookmark tree by the given sort type. +/// Root-level built-in folders are kept in their canonical order. +BookmarkItem sortBookmarkTree( + BookmarkItem item, + BookmarkSortType sortType, { + bool isRoot = false, +}) { + if (sortType == BookmarkSortType.manual) return item; + + if (item is BookmarkFolder && item.children != null) { + final sortedChildren = item.children!.map((child) { + return sortBookmarkTree(child, sortType); + }).toList(); + + // At root level, keep built-in root folders pinned in original order + if (isRoot) { + final rootFolders = []; + final nonRootItems = []; + for (final child in sortedChildren) { + if (bookmarkRootIds.contains(child.guid)) { + rootFolders.add(child); + } else { + nonRootItems.add(child); + } + } + nonRootItems.sort((a, b) => compareBookmarkItems(a, b, sortType)); + return BookmarkFolder( + guid: item.guid, + parentGuid: item.parentGuid, + title: item.title, + position: item.position, + dateAdded: item.dateAdded, + children: [...rootFolders, ...nonRootItems], + ); + } + + sortedChildren.sort((a, b) => compareBookmarkItems(a, b, sortType)); + return BookmarkFolder( + guid: item.guid, + parentGuid: item.parentGuid, + title: item.title, + position: item.position, + dateAdded: item.dateAdded, + children: sortedChildren, + ); + } + + return item; +} + +/// Collects all descendant folder GUIDs from a folder (not including the folder itself). +Set collectDescendantFolderGuids(BookmarkFolder folder) { + final result = {}; + if (folder.children != null) { + for (final child in folder.children!) { + if (child is BookmarkFolder) { + result.add(child.guid); + result.addAll(collectDescendantFolderGuids(child)); + } + } + } + return result; +} + +/// Resolves BookmarkItems from a tree by their GUIDs. +List resolveSelectedItems(BookmarkItem root, Set guids) { + final result = []; + _collectByGuids(root, guids, result); + return result; +} + +void _collectByGuids( + BookmarkItem item, + Set guids, + List result, +) { + if (guids.contains(item.guid)) { + result.add(item); + } + if (item is BookmarkFolder && item.children != null) { + for (final child in item.children!) { + _collectByGuids(child, guids, result); + } + } +} + +/// Whether a folder can be flattened (non-root, has a parent, has children). +bool canFlattenFolder(BookmarkFolder folder) { + return folder.parentGuid != null && + !bookmarkRootIds.contains(folder.guid) && + folder.children != null && + folder.children!.isNotEmpty; +} + +/// Normalizes a selection set: removes items that are descendants of selected folders. +/// This prevents double-applying moves when both a folder and its children are selected. +Set normalizeSelection(BookmarkItem root, Set selectedGuids) { + final items = resolveSelectedItems(root, selectedGuids); + final folderGuidsToRemove = {}; + + for (final item in items) { + if (item is BookmarkFolder) { + _collectAllDescendantGuids(item, folderGuidsToRemove); + } + } + + return selectedGuids.difference(folderGuidsToRemove); +} + +void _collectAllDescendantGuids(BookmarkFolder folder, Set result) { + if (folder.children != null) { + for (final child in folder.children!) { + result.add(child.guid); + if (child is BookmarkFolder) { + _collectAllDescendantGuids(child, result); + } + } + } +} diff --git a/app/lib/features/geckoview/features/bookmarks/presentation/dialogs/select_bookmark_folder_dialog.dart b/app/lib/features/geckoview/features/bookmarks/presentation/dialogs/select_bookmark_folder_dialog.dart new file mode 100644 index 00000000..5ff03ec3 --- /dev/null +++ b/app/lib/features/geckoview/features/bookmarks/presentation/dialogs/select_bookmark_folder_dialog.dart @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/geckoview/features/bookmarks/presentation/widgets/folder_tree_picker.dart'; + +/// Shows a bottom sheet for selecting a bookmark folder destination (for move operations). +/// +/// Returns the selected folder GUID, or null if cancelled. +Future showSelectBookmarkFolderDialog( + BuildContext context, { + Set excludeFolderGuids = const {}, + String? initialFolderGuid, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => _SelectBookmarkFolderSheet( + excludeFolderGuids: excludeFolderGuids, + initialFolderGuid: initialFolderGuid, + ), + ); +} + +class _SelectBookmarkFolderSheet extends HookConsumerWidget { + final Set excludeFolderGuids; + final String? initialFolderGuid; + + const _SelectBookmarkFolderSheet({ + required this.excludeFolderGuids, + this.initialFolderGuid, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedGuid = useState(initialFolderGuid ?? BookmarkRoot.mobile.id); + + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Move to Folder', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.5, + ), + child: SingleChildScrollView( + child: FolderTreePicker( + selectedFolderGuid: selectedGuid, + excludeFolderGuids: excludeFolderGuids, + entryGuid: BookmarkRoot.root.id, + ), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => context.pop(), + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () => context.pop(selectedGuid.value), + child: const Text('Move'), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_entry_edit.dart b/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_entry_edit.dart index ea140b80..28c567dd 100644 --- a/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_entry_edit.dart +++ b/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_entry_edit.dart @@ -57,6 +57,8 @@ class BookmarkEntryEditScreen extends HookConsumerWidget { BookmarkRoot.mobile.id, ); + final addToTop = useState(false); + return Scaffold( appBar: AppBar( title: (exisitingEntry != null) @@ -104,6 +106,7 @@ class BookmarkEntryEditScreen extends HookConsumerWidget { parentGuid: parentGuid.value, title: nameTextController.text, url: newUrl, + position: addToTop.value ? 0 : null, ); if (context.mounted) { @@ -157,6 +160,15 @@ class BookmarkEntryEditScreen extends HookConsumerWidget { selectedFolderGuid: parentGuid, entryGuid: BookmarkRoot.root.id, ), + if (exisitingEntry == null) ...[ + const SizedBox(height: 8), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Add to top'), + value: addToTop.value, + onChanged: (value) => addToTop.value = value, + ), + ], const SizedBox(height: 16), if (exisitingEntry != null) SizedBox( diff --git a/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_folder_edit.dart b/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_folder_edit.dart index 398c41d9..155c5394 100644 --- a/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_folder_edit.dart +++ b/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_folder_edit.dart @@ -47,6 +47,8 @@ class BookmarkFolderEditScreen extends HookConsumerWidget { parentGuid ?? folder?.parentGuid ?? BookmarkRoot.mobile.id, ); + final addToTop = useState(false); + // Check if this is a bookmark root folder (these cannot be moved) final isBookmarkRoot = folder != null && bookmarkRootIds.contains(folder!.guid); @@ -84,6 +86,7 @@ class BookmarkFolderEditScreen extends HookConsumerWidget { .addFolder( parentGuid: currentParentGuid.value, title: nameTextController.text, + position: addToTop.value ? 0 : null, ); if (context.mounted) { @@ -114,9 +117,20 @@ class BookmarkFolderEditScreen extends HookConsumerWidget { if (!isBookmarkRoot) ...[ FolderTreePicker( selectedFolderGuid: currentParentGuid, - excludeFolderGuid: folder?.guid, + excludeFolderGuids: folder != null + ? {folder!.guid} + : const {}, entryGuid: BookmarkRoot.root.id, ), + if (folder == null) ...[ + const SizedBox(height: 8), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Add to top'), + value: addToTop.value, + onChanged: (value) => addToTop.value = value, + ), + ], const SizedBox(height: 16), ], if (folder != null) diff --git a/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_list.dart b/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_list.dart index 65ceca5e..cbc83134 100644 --- a/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_list.dart +++ b/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_list.dart @@ -28,13 +28,24 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:nullability/nullability.dart'; +import 'package:share_plus/share_plus.dart'; import 'package:weblibre/core/logger.dart'; import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart'; +import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_list_ui_state.dart'; +import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart'; +import 'package:weblibre/features/geckoview/features/bookmarks/domain/providers/bookmark_list_ui_state.dart'; import 'package:weblibre/features/geckoview/features/bookmarks/domain/providers/bookmarks.dart'; import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart'; +import 'package:weblibre/features/geckoview/features/bookmarks/domain/utils/bookmark_tree_utils.dart'; +import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/delete_bookmark_dialog.dart'; +import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/delete_folder_dialog.dart'; import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/import_bookmarks_dialog.dart'; +import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/select_bookmark_folder_dialog.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/presentation/hooks/menu_controller.dart'; import 'package:weblibre/presentation/widgets/failure_widget.dart'; import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; @@ -48,8 +59,16 @@ class BookmarkListScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final treeKey = useMemoized(() => GlobalKey()); + final treeController = + useState>?>( + null, + ); + // Tracks which folder GUIDs are expanded, so expansion state survives + // tree rebuilds caused by sort type changes. + final expandedGuids = useRef({}); final hideEmptyRoots = useState(true); + final uiState = ref.watch(bookmarkListUiStateProvider); + final uiStateNotifier = ref.read(bookmarkListUiStateProvider.notifier); final bookmarkList = ref.watch( seamlessBookmarksProvider( entryGuid, @@ -78,297 +97,901 @@ class BookmarkListScreen extends HookConsumerWidget { } }); - return Scaffold( - appBar: AppBar( - title: textFilterEnabled.value - ? TextField( - controller: textFilterController, - decoration: InputDecoration( - contentPadding: const EdgeInsets.only(top: 12), - border: InputBorder.none, - hintText: 'Filter bookmarks...', - floatingLabelBehavior: FloatingLabelBehavior.always, - suffixIcon: IconButton( - onPressed: () { - if (textFilterController.text.isNotEmpty) { - textFilterController.clear(); - } else { - textFilterEnabled.value = false; - } - }, - icon: const Icon(Icons.clear), - ), - ), - ) - : const Text('Bookmarks'), - actions: [ - if (!textFilterEnabled.value) - IconButton( - onPressed: () { - textFilterEnabled.value = !textFilterEnabled.value; - }, - icon: const Icon(Icons.search), - ), - MenuAnchor( - menuChildren: [ - MenuItemButton( - leadingIcon: const Icon(MdiIcons.expandAll), - child: const Text('Expand All Folders'), - onPressed: () { - treeKey.currentState?.controller.mapNotNull((controller) { - controller.expandAllChildren( - controller.tree, - recursive: true, - ); - }); - }, + return PopScope( + canPop: !uiState.selectionMode, + onPopInvokedWithResult: (didPop, _) { + if (!didPop && uiState.selectionMode) { + uiStateNotifier.exitSelectionMode(); + } + }, + child: Scaffold( + appBar: uiState.selectionMode + ? _buildSelectionAppBar(context, ref, uiState, uiStateNotifier) + : _buildNormalAppBar( + context, + ref, + treeController, + expandedGuids, + hideEmptyRoots, + textFilterEnabled, + textFilterController, + uiStateNotifier, + uiState, ), - if (entryGuid == BookmarkRoot.root.id) - MenuItemButton( - leadingIcon: Icon( - hideEmptyRoots.value ? MdiIcons.eyeOff : MdiIcons.eye, - ), - child: Text( - hideEmptyRoots.value - ? 'Show Empty Root Folders' - : 'Hide Empty Root Folders', - ), - onPressed: () { - hideEmptyRoots.value = !hideEmptyRoots.value; + body: SafeArea( + child: Padding( + padding: const EdgeInsets.only(left: 12.0), + child: bookmarkList.when( + skipLoadingOnReload: true, + data: (list) { + final sortedList = list != null + ? sortBookmarkTree( + list, + uiState.sortType, + isRoot: entryGuid == BookmarkRoot.root.id, + ) + : null; + + TreeNode addChildren( + TreeNode? parent, + BookmarkItem item, + ) { + if (uiState.foldersOnly && item is BookmarkEntry) { + return parent ?? TreeNode.root(); + } + + final node = TreeNode( + key: item.guid, + data: item, + parent: parent, + ); + final targetNode = (parent?..add(node)) ?? node; + + if (item is BookmarkFolder && item.children != null) { + for (final child in item.children!) { + addChildren(node, child); + } + } + + return targetNode; + } + + final root = (sortedList != null) + ? addChildren(null, sortedList) + : TreeNode.root(); + + return TreeView.simple( + key: ValueKey((uiState.sortType, uiState.foldersOnly)), + tree: root, + showRootNode: entryGuid != BookmarkRoot.root.id, + onTreeReady: (controller) { + treeController.value = controller; + if (expandedGuids.value.isNotEmpty) { + _restoreExpansion(controller, root, expandedGuids.value); + } else { + controller.expandAllChildren(root, recursive: true); + } + }, + expansionIndicatorBuilder: (context, tree) => + ChevronIndicator.upDown( + tree: tree, + padding: const EdgeInsets.symmetric( + vertical: 16.0, + horizontal: 12.0, + ), + ), + builder: (context, item) { + final BookmarkItem? data = item.data; + final isSelected = uiState.selectedGuids.contains( + data?.guid, + ); + final bool isLeaf = item.isLeaf; + final bool isExpanded = item.isExpanded; + + return switch (data) { + final BookmarkEntry bookmark => _buildEntryTile( + context, + ref, + bookmark, + uiState, + uiStateNotifier, + isSelected, + sortedList, + ), + final BookmarkFolder folder => _buildFolderTile( + context, + ref, + folder, + isLeaf: isLeaf, + isExpanded: isExpanded, + uiState: uiState, + uiStateNotifier: uiStateNotifier, + isSelected: isSelected, + rootItem: sortedList, + ), + null => const Center(child: Text('Empty')), + }; + }, + ); + }, + error: (error, stackTrace) => Center( + child: FailureWidget( + title: 'Failed to load Bookmarks', + exception: error, + onRetry: () { + // ignore: unused_result + ref.refresh(bookmarksProvider(entryGuid)); }, ), - SubmenuButton( - leadingIcon: const Icon(MdiIcons.import), - menuChildren: [ - MenuItemButton( - leadingIcon: const Icon(MdiIcons.codeJson), - child: const Text('JSON'), - onPressed: () => _handleImport(context, ref, 'json'), - ), - MenuItemButton( - leadingIcon: const Icon(MdiIcons.xml), - child: const Text('HTML'), - onPressed: () => _handleImport(context, ref, 'html'), - ), - ], - child: const Text('Import'), ), - SubmenuButton( - leadingIcon: const Icon(MdiIcons.export), - menuChildren: [ - MenuItemButton( - leadingIcon: const Icon(MdiIcons.codeJson), - child: const Text('JSON'), - onPressed: () => _handleExport(context, ref, 'json'), - ), - MenuItemButton( - leadingIcon: const Icon(MdiIcons.xml), - child: const Text('HTML'), - onPressed: () => _handleExport(context, ref, 'html'), - ), - ], - child: const Text('Export'), - ), - ], - builder: (context, controller, child) => IconButton( - onPressed: () { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); - } - }, - icon: const Icon(MdiIcons.dotsVertical), + loading: () => const Center(child: CircularProgressIndicator()), ), ), - ], - ), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.only(left: 12.0), - child: bookmarkList.when( - skipLoadingOnReload: true, - data: (list) { - TreeNode addChildren( - TreeNode? parent, - BookmarkItem item, - ) { - final node = TreeNode( - key: item.guid, - data: item, - parent: parent, - ); - final targetNode = (parent?..add(node)) ?? node; - - if (item is BookmarkFolder && item.children != null) { - for (final child in item.children!) { - addChildren(node, child); - } - } - - return targetNode; - } - - final root = (list != null) - ? addChildren(null, list) - : TreeNode.root(); - - return TreeView.simple( - key: treeKey, - tree: root, - showRootNode: entryGuid != BookmarkRoot.root.id, - onTreeReady: (controller) { - if (textFilterEnabled.value) { - controller.expandAllChildren(root, recursive: true); - } else { - controller.expandNode(root); - } - }, - expansionIndicatorBuilder: (context, tree) => - ChevronIndicator.upDown( - tree: tree, - padding: const EdgeInsets.symmetric( - vertical: 16.0, - horizontal: 12.0, - ), - ), - builder: (context, item) { - return switch (item.data) { - final BookmarkEntry bookmark => ListTile( - key: ValueKey(bookmark.guid), - contentPadding: EdgeInsets.zero, - leading: UrlIcon([bookmark.url], iconSize: 34.0), - trailing: IconButton( - icon: const Icon(Icons.edit), - onPressed: () async { - await BookmarkEntryEditRoute( - bookmarkEntry: jsonEncode(bookmark.toJson()), - ).push(context); - }, - ), - title: Text( - bookmark.title, - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - subtitle: UriBreadcrumb(uri: bookmark.url), - onTap: () async { - final result = await OpenSharedContentRoute( - sharedUrl: bookmark.url.toString(), - ).push(context); - - if (result == true) { - if (context.mounted) { - const BrowserRoute().go(context); - } - } - }, - ), - final BookmarkFolder folder => Padding( - key: ValueKey(folder.guid), - padding: (item.isLeaf) - ? const EdgeInsets.only(right: 4.0) - : const EdgeInsets.only(right: 42.0), - child: HookBuilder( - builder: (context) { - final controller = useMenuController(); - - return ListTile( - contentPadding: EdgeInsets.zero, - leading: (item.isExpanded) - ? const Icon(MdiIcons.folderOpen) - : const Icon(MdiIcons.folder), - title: Text(folder.title), - trailing: MenuAnchor( - controller: controller, - builder: (context, controller, child) => InkWell( - onTap: () { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); - } - }, - child: const Padding( - padding: EdgeInsets.symmetric( - horizontal: 8.0, - vertical: 15.0, - ), - child: Icon(MdiIcons.dotsVertical), - ), - ), - menuChildren: [ - if (!bookmarkRootIds.contains(folder.guid)) - MenuItemButton( - leadingIcon: const Icon( - MdiIcons.folderEdit, - ), - child: const Text('Edit'), - onPressed: () async { - await BookmarkFolderEditRoute( - folder: jsonEncode(folder.toJson()), - ).push(context); - }, - ), - MenuItemButton( - leadingIcon: const Icon(MdiIcons.folderPlus), - child: const Text('Add Subfolder'), - onPressed: () async { - await BookmarkFolderAddRoute( - parentGuid: folder.guid, - ).push(context); - }, - ), - MenuItemButton( - leadingIcon: const Icon( - MdiIcons.bookmarkPlus, - ), - child: const Text('Add Bookmark'), - onPressed: () async { - await BookmarkEntryAddRoute( - bookmarkInfo: jsonEncode( - BookmarkInfo( - parentGuid: folder.guid, - ).encode(), - ), - ).push(context); - }, - ), - ], - ), - onTap: () async { - if (folder.guid != entryGuid) { - await BookmarkListRoute( - entryGuid: folder.guid, - ).push(context); - } - }, - ); - }, - ), - ), - null => const Center(child: Text('Empty')), - }; - }, - ); - }, - error: (error, stackTrace) => Center( - child: FailureWidget( - title: 'Failed to load Bookmarks', - exception: error, - onRetry: () { - // ignore: unused_result - ref.refresh(bookmarksProvider(entryGuid)); - }, - ), - ), - loading: () => const Center(child: CircularProgressIndicator()), - ), ), ), ); } + // -- App Bars -- + + PreferredSizeWidget _buildSelectionAppBar( + BuildContext context, + WidgetRef ref, + BookmarkListUiState uiState, + BookmarkListUiStateNotifier uiStateNotifier, + ) { + final count = uiState.selectedGuids.length; + return AppBar( + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => uiStateNotifier.exitSelectionMode(), + ), + title: Text('$count selected'), + actions: [ + IconButton( + icon: const Icon(MdiIcons.tabPlus), + tooltip: 'Open in background', + onPressed: count > 0 + ? () => _bulkOpenInBackground(context, ref, uiState) + : null, + ), + IconButton( + icon: const Icon(MdiIcons.folderMove), + tooltip: 'Move selected', + onPressed: count > 0 ? () => _bulkMove(context, ref, uiState) : null, + ), + IconButton( + icon: const Icon(MdiIcons.delete), + tooltip: 'Delete selected', + onPressed: count > 0 + ? () => _bulkDelete(context, ref, uiState, uiStateNotifier) + : null, + ), + ], + ); + } + + AppBar _buildNormalAppBar( + BuildContext context, + WidgetRef ref, + ValueNotifier>?> + treeController, + ObjectRef> expandedGuids, + ValueNotifier hideEmptyRoots, + ValueNotifier textFilterEnabled, + TextEditingController textFilterController, + BookmarkListUiStateNotifier uiStateNotifier, + BookmarkListUiState uiState, + ) { + return AppBar( + title: textFilterEnabled.value + ? TextField( + controller: textFilterController, + decoration: InputDecoration( + contentPadding: const EdgeInsets.only(top: 12), + border: InputBorder.none, + hintText: 'Filter bookmarks...', + floatingLabelBehavior: FloatingLabelBehavior.always, + suffixIcon: IconButton( + onPressed: () { + if (textFilterController.text.isNotEmpty) { + textFilterController.clear(); + } else { + textFilterEnabled.value = false; + } + }, + icon: const Icon(Icons.clear), + ), + ), + ) + : const Text('Bookmarks'), + actions: [ + if (!textFilterEnabled.value) + IconButton( + onPressed: () { + textFilterEnabled.value = !textFilterEnabled.value; + }, + icon: const Icon(Icons.search), + ), + MenuAnchor( + menuChildren: [ + SubmenuButton( + leadingIcon: const Icon(MdiIcons.eye), + menuChildren: [ + MenuItemButton( + leadingIcon: const Icon(MdiIcons.expandAll), + child: const Text('Expand All'), + onPressed: () { + final controller = treeController.value; + if (controller != null) { + controller.expandAllChildren( + controller.tree, + recursive: true, + ); + } + }, + ), + if (entryGuid == BookmarkRoot.root.id) + MenuItemButton( + leadingIcon: Icon( + hideEmptyRoots.value + ? MdiIcons.folderOff + : MdiIcons.folder, + ), + child: Text( + hideEmptyRoots.value + ? 'Show Empty Folders' + : 'Hide Empty Folders', + ), + onPressed: () { + hideEmptyRoots.value = !hideEmptyRoots.value; + }, + ), + MenuItemButton( + leadingIcon: Icon( + uiState.foldersOnly + ? MdiIcons.bookmarkMultiple + : MdiIcons.folderOutline, + ), + child: Text( + uiState.foldersOnly ? 'Show Bookmarks' : 'Folders Only', + ), + onPressed: () { + _snapshotExpansion(treeController.value, expandedGuids); + uiStateNotifier.toggleFoldersOnly(); + }, + ), + ], + child: const Text('Visibility'), + ), + SubmenuButton( + leadingIcon: const Icon(MdiIcons.sort), + menuChildren: [ + for (final sortType in BookmarkSortType.values) + MenuItemButton( + leadingIcon: sortType == uiState.sortType + ? const Icon(Icons.check) + : const SizedBox(width: 24), + child: Text(sortType.label), + onPressed: () { + _snapshotExpansion(treeController.value, expandedGuids); + uiStateNotifier.setSortType(sortType); + }, + ), + ], + child: const Text('Sort'), + ), + SubmenuButton( + leadingIcon: const Icon(MdiIcons.import), + menuChildren: [ + MenuItemButton( + leadingIcon: const Icon(MdiIcons.codeJson), + child: const Text('JSON'), + onPressed: () => _handleImport(context, ref, 'json'), + ), + MenuItemButton( + leadingIcon: const Icon(MdiIcons.xml), + child: const Text('HTML'), + onPressed: () => _handleImport(context, ref, 'html'), + ), + ], + child: const Text('Import'), + ), + SubmenuButton( + leadingIcon: const Icon(MdiIcons.export), + menuChildren: [ + MenuItemButton( + leadingIcon: const Icon(MdiIcons.codeJson), + child: const Text('JSON'), + onPressed: () => _handleExport(context, ref, 'json'), + ), + MenuItemButton( + leadingIcon: const Icon(MdiIcons.xml), + child: const Text('HTML'), + onPressed: () => _handleExport(context, ref, 'html'), + ), + ], + child: const Text('Export'), + ), + ], + builder: (context, controller, child) => IconButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + icon: const Icon(MdiIcons.dotsVertical), + ), + ), + ], + ); + } + + // -- Entry Tile -- + + Widget _buildEntryTile( + BuildContext context, + WidgetRef ref, + BookmarkEntry bookmark, + BookmarkListUiState uiState, + BookmarkListUiStateNotifier uiStateNotifier, + bool isSelected, + BookmarkItem? rootItem, + ) { + if (uiState.selectionMode) { + return ListTile( + key: ValueKey(bookmark.guid), + contentPadding: EdgeInsets.zero, + leading: UrlIcon([bookmark.url], iconSize: 34.0), + trailing: Checkbox( + value: isSelected, + onChanged: (_) => uiStateNotifier.toggleSelection(bookmark.guid), + ), + title: Text( + bookmark.title, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + subtitle: UriBreadcrumb(uri: bookmark.url), + onTap: () => uiStateNotifier.toggleSelection(bookmark.guid), + ); + } + + return ListTile( + key: ValueKey(bookmark.guid), + contentPadding: EdgeInsets.zero, + leading: UrlIcon([bookmark.url], iconSize: 34.0), + trailing: _buildEntryMenu(context, ref, bookmark, rootItem), + title: Text(bookmark.title, maxLines: 3, overflow: TextOverflow.ellipsis), + subtitle: UriBreadcrumb(uri: bookmark.url), + onTap: () async { + final result = await OpenSharedContentRoute( + sharedUrl: bookmark.url.toString(), + ).push(context); + + if (result == true) { + if (context.mounted) { + const BrowserRoute().go(context); + } + } + }, + onLongPress: () { + uiStateNotifier.enterSelectionMode(initialGuid: bookmark.guid); + }, + ); + } + + Widget _buildEntryMenu( + BuildContext context, + WidgetRef ref, + BookmarkEntry bookmark, + BookmarkItem? rootItem, + ) { + return HookBuilder( + builder: (context) { + final controller = useMenuController(); + + return MenuAnchor( + controller: controller, + builder: (context, controller, child) => InkWell( + onTap: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 8.0, vertical: 15.0), + child: Icon(MdiIcons.dotsVertical), + ), + ), + menuChildren: [ + MenuItemButton( + leadingIcon: const Icon(MdiIcons.openInNew), + child: const Text('Open'), + onPressed: () async { + final result = await OpenSharedContentRoute( + sharedUrl: bookmark.url.toString(), + ).push(context); + if (result == true && context.mounted) { + const BrowserRoute().go(context); + } + }, + ), + MenuItemButton( + leadingIcon: const Icon(MdiIcons.tabPlus), + child: const Text('Open in New Tab'), + onPressed: () async { + await _openInNewTab( + context, + ref, + bookmark.url, + selectTab: true, + ); + }, + ), + MenuItemButton( + leadingIcon: const Icon(MdiIcons.tab), + child: const Text('Open in Background'), + onPressed: () async { + await _openInNewTab( + context, + ref, + bookmark.url, + selectTab: false, + ); + }, + ), + MenuItemButton( + leadingIcon: const Icon(Icons.share), + child: const Text('Share'), + onPressed: () async { + await SharePlus.instance.share( + ShareParams(text: bookmark.url.toString()), + ); + }, + ), + MenuItemButton( + leadingIcon: const Icon(MdiIcons.folderMove), + child: const Text('Move'), + onPressed: () async { + final targetGuid = await showSelectBookmarkFolderDialog( + context, + initialFolderGuid: bookmark.parentGuid, + ); + if (targetGuid != null) { + await ref + .read(bookmarksRepositoryProvider.notifier) + .editBookmark( + guid: bookmark.guid, + parentGuid: targetGuid, + ); + } + }, + ), + MenuItemButton( + leadingIcon: const Icon(Icons.edit), + child: const Text('Edit'), + onPressed: () async { + await BookmarkEntryEditRoute( + bookmarkEntry: jsonEncode(bookmark.toJson()), + ).push(context); + }, + ), + MenuItemButton( + leadingIcon: const Icon(MdiIcons.bookmarkRemove), + child: const Text('Delete'), + onPressed: () async { + final result = await showDeleteBookmarkDialog(context); + if (result == true) { + await ref + .read(bookmarksRepositoryProvider.notifier) + .delete(bookmark.guid); + } + }, + ), + ], + ); + }, + ); + } + + // -- Folder Tile -- + + Widget _buildFolderTile( + BuildContext context, + WidgetRef ref, + BookmarkFolder folder, { + required bool isLeaf, + required bool isExpanded, + required BookmarkListUiState uiState, + required BookmarkListUiStateNotifier uiStateNotifier, + required bool isSelected, + required BookmarkItem? rootItem, + }) { + final isRoot = bookmarkRootIds.contains(folder.guid); + + if (uiState.selectionMode) { + return Padding( + key: ValueKey(folder.guid), + padding: isLeaf + ? const EdgeInsets.only(right: 4.0) + : const EdgeInsets.only(right: 42.0), + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: isExpanded + ? const Icon(MdiIcons.folderOpen) + : const Icon(MdiIcons.folder), + title: Text(folder.title), + trailing: isRoot + ? null + : Checkbox( + value: isSelected, + onChanged: (_) => + uiStateNotifier.toggleSelection(folder.guid), + ), + onTap: isRoot + ? null + : () => uiStateNotifier.toggleSelection(folder.guid), + ), + ); + } + + return Padding( + key: ValueKey(folder.guid), + padding: isLeaf + ? const EdgeInsets.only(right: 4.0) + : const EdgeInsets.only(right: 42.0), + child: HookBuilder( + builder: (context) { + final controller = useMenuController(); + + return ListTile( + contentPadding: EdgeInsets.zero, + leading: isExpanded + ? const Icon(MdiIcons.folderOpen) + : const Icon(MdiIcons.folder), + title: Text(folder.title), + trailing: MenuAnchor( + controller: controller, + builder: (context, controller, child) => InkWell( + onTap: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: const Padding( + padding: EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 15.0, + ), + child: Icon(MdiIcons.dotsVertical), + ), + ), + menuChildren: [ + if (!isRoot) ...[ + MenuItemButton( + leadingIcon: const Icon(MdiIcons.folderMove), + child: const Text('Move'), + onPressed: () async { + final repo = ref.read( + bookmarksRepositoryProvider.notifier, + ); + final descendantGuids = await repo + .getDescendantFolderGuids(folder.guid); + final excludeGuids = {folder.guid, ...descendantGuids}; + if (!context.mounted) return; + final targetGuid = await showSelectBookmarkFolderDialog( + context, + excludeFolderGuids: excludeGuids, + initialFolderGuid: folder.parentGuid, + ); + if (targetGuid != null) { + await repo.editFolder( + guid: folder.guid, + parentGuid: targetGuid, + ); + } + }, + ), + if (canFlattenFolder(folder)) + MenuItemButton( + leadingIcon: const Icon(MdiIcons.folderRemove), + child: const Text('Flatten'), + onPressed: () async { + await ref + .read(bookmarksRepositoryProvider.notifier) + .flattenFolder(folder: folder); + }, + ), + MenuItemButton( + leadingIcon: const Icon(MdiIcons.folderEdit), + child: const Text('Edit'), + onPressed: () async { + await BookmarkFolderEditRoute( + folder: jsonEncode(folder.toJson()), + ).push(context); + }, + ), + MenuItemButton( + leadingIcon: const Icon(Icons.delete), + child: const Text('Delete'), + onPressed: () async { + final result = await showDeleteFolderDialog(context); + if (result == true) { + await ref + .read(bookmarksRepositoryProvider.notifier) + .delete(folder.guid); + } + }, + ), + ], + MenuItemButton( + leadingIcon: const Icon(MdiIcons.folderPlus), + child: const Text('Add Subfolder'), + onPressed: () async { + await BookmarkFolderAddRoute( + parentGuid: folder.guid, + ).push(context); + }, + ), + MenuItemButton( + leadingIcon: const Icon(MdiIcons.bookmarkPlus), + child: const Text('Add Bookmark'), + onPressed: () async { + await BookmarkEntryAddRoute( + bookmarkInfo: jsonEncode( + BookmarkInfo(parentGuid: folder.guid).encode(), + ), + ).push(context); + }, + ), + ], + ), + onTap: () async { + if (folder.guid != entryGuid) { + await BookmarkListRoute(entryGuid: folder.guid).push(context); + } + }, + onLongPress: isRoot + ? null + : () { + ref + .read(bookmarkListUiStateProvider.notifier) + .enterSelectionMode(initialGuid: folder.guid); + }, + ); + }, + ), + ); + } + + // -- Bulk Actions -- + + Future _bulkOpenInBackground( + BuildContext context, + WidgetRef ref, + BookmarkListUiState uiState, + ) async { + final bookmarkData = ref.read( + seamlessBookmarksProvider(entryGuid, hideEmptyRoots: true), + ); + final root = bookmarkData.value; + if (root == null) return; + + final items = resolveSelectedItems(root, uiState.selectedGuids); + final entries = items.whereType().toList(); + + if (entries.isEmpty) { + if (context.mounted) { + showInfoMessage(context, 'No bookmark entries selected'); + } + return; + } + + final currentTab = ref.read(selectedTabStateProvider); + final tabMode = + currentTab?.tabMode ?? + TabMode.fromTabType( + ref + .read(generalSettingsWithDefaultsProvider) + .effectiveDefaultCreateTabType, + ); + + for (final entry in entries) { + await ref + .read(tabRepositoryProvider.notifier) + .addTab(url: entry.url, selectTab: false, tabMode: tabMode); + } + + ref.read(bookmarkListUiStateProvider.notifier).exitSelectionMode(); + + if (context.mounted) { + showInfoMessage(context, 'Opened ${entries.length} tabs in background'); + } + } + + Future _bulkMove( + BuildContext context, + WidgetRef ref, + BookmarkListUiState uiState, + ) async { + final bookmarkData = ref.read( + seamlessBookmarksProvider(entryGuid, hideEmptyRoots: true), + ); + final root = bookmarkData.value; + if (root == null) return; + + final items = resolveSelectedItems(root, uiState.selectedGuids); + + // Build exclusion set from selected folders and their full descendant + // trees fetched from storage, so hidden folders (e.g. filtered by search) + // are still properly excluded as move targets. + final repo = ref.read(bookmarksRepositoryProvider.notifier); + final excludeGuids = {}; + for (final item in items) { + if (item is BookmarkFolder) { + excludeGuids.add(item.guid); + excludeGuids.addAll(await repo.getDescendantFolderGuids(item.guid)); + } + } + + if (!context.mounted) return; + + final targetGuid = await showSelectBookmarkFolderDialog( + context, + excludeFolderGuids: excludeGuids, + ); + + if (targetGuid == null) return; + + // Normalize selection to avoid double-moves + final normalizedGuids = normalizeSelection(root, uiState.selectedGuids); + final normalizedItems = resolveSelectedItems(root, normalizedGuids); + + await ref + .read(bookmarksRepositoryProvider.notifier) + .moveMany(items: normalizedItems, targetParentGuid: targetGuid); + + ref.read(bookmarkListUiStateProvider.notifier).exitSelectionMode(); + + if (context.mounted) { + showInfoMessage(context, 'Moved ${normalizedItems.length} items'); + } + } + + Future _bulkDelete( + BuildContext context, + WidgetRef ref, + BookmarkListUiState uiState, + BookmarkListUiStateNotifier uiStateNotifier, + ) async { + final bookmarkData = ref.read( + seamlessBookmarksProvider(entryGuid, hideEmptyRoots: true), + ); + final root = bookmarkData.value; + if (root == null) return; + + final items = resolveSelectedItems(root, uiState.selectedGuids); + final hasFolders = items.any((item) => item is BookmarkFolder); + + if (!context.mounted) return; + final result = await (hasFolders + ? showDeleteFolderDialog(context) + : showDeleteBookmarkDialog(context)); + if (result != true) return; + + // Normalize to avoid deleting children whose parent folder is also being deleted + final normalizedGuids = normalizeSelection(root, uiState.selectedGuids); + + await ref + .read(bookmarksRepositoryProvider.notifier) + .deleteMany(normalizedGuids); + + uiStateNotifier.exitSelectionMode(); + + if (context.mounted) { + showInfoMessage(context, 'Deleted ${normalizedGuids.length} items'); + } + } + + // -- Tree Expansion State Helpers -- + + /// Collects the GUIDs of all currently expanded nodes from the tree. + void _snapshotExpansion( + TreeViewController>? controller, + ObjectRef> expandedGuids, + ) { + if (controller == null) return; + final guids = {}; + _collectExpandedGuids(controller.tree, guids); + expandedGuids.value = guids; + } + + void _collectExpandedGuids(TreeNode node, Set guids) { + if (node.isExpanded && node.key != INode.ROOT_KEY) { + guids.add(node.key); + } + for (final child in node.childrenAsList) { + _collectExpandedGuids(child as TreeNode, guids); + } + } + + /// Restores expansion state by expanding nodes whose GUIDs are in the set. + void _restoreExpansion( + TreeViewController> controller, + TreeNode root, + Set guids, + ) { + // Always expand root + controller.expandNode(root); + _expandMatchingNodes(controller, root, guids); + } + + void _expandMatchingNodes( + TreeViewController> controller, + TreeNode node, + Set guids, + ) { + for (final child in node.childrenAsList) { + final typedChild = child as TreeNode; + if (guids.contains(typedChild.key)) { + controller.expandNode(typedChild); + } + _expandMatchingNodes(controller, typedChild, guids); + } + } + + // -- Tab Opening Helper -- + + Future _openInNewTab( + BuildContext context, + WidgetRef ref, + Uri url, { + required bool selectTab, + }) async { + final currentTab = ref.read(selectedTabStateProvider); + final tabMode = + currentTab?.tabMode ?? + TabMode.fromTabType( + ref + .read(generalSettingsWithDefaultsProvider) + .effectiveDefaultCreateTabType, + ); + + final tabId = await ref + .read(tabRepositoryProvider.notifier) + .addTab( + url: url, + parentId: currentTab?.id, + selectTab: selectTab, + tabMode: tabMode, + ); + + if (selectTab) { + if (context.mounted) { + const BrowserRoute().go(context); + } + } else { + if (context.mounted) { + final repo = ref.read(tabRepositoryProvider.notifier); + showTabSwitchMessage( + context, + onSwitch: () async { + await repo.selectTab(tabId); + }, + ); + } + } + } + + // -- Import/Export (unchanged) -- + Future _handleImport( BuildContext context, WidgetRef ref, diff --git a/app/lib/features/geckoview/features/bookmarks/presentation/widgets/folder_tree_picker.dart b/app/lib/features/geckoview/features/bookmarks/presentation/widgets/folder_tree_picker.dart index 5b17b9e0..8a1d6617 100644 --- a/app/lib/features/geckoview/features/bookmarks/presentation/widgets/folder_tree_picker.dart +++ b/app/lib/features/geckoview/features/bookmarks/presentation/widgets/folder_tree_picker.dart @@ -31,22 +31,22 @@ import 'package:weblibre/presentation/widgets/failure_widget.dart'; /// A widget that displays a tree view of bookmark folders and allows the user /// to select a parent folder. /// -/// When editing a folder, pass [excludeFolderGuid] to prevent selecting the -/// folder itself or its descendants as the parent (which would create a circular reference). +/// When editing a folder, pass [excludeFolderGuids] to prevent selecting the +/// folders or their descendants as the parent (which would create a circular reference). class FolderTreePicker extends HookConsumerWidget { /// The currently selected folder GUID final ValueNotifier selectedFolderGuid; - /// Optional folder GUID to exclude from the tree (along with its descendants). - /// Used when editing a folder to prevent circular parent relationships. - final String? excludeFolderGuid; + /// Optional folder GUIDs to exclude from the tree (along with their descendants). + /// Used when editing/moving folders to prevent circular parent relationships. + final Set excludeFolderGuids; final String entryGuid; const FolderTreePicker({ required this.selectedFolderGuid, required this.entryGuid, - this.excludeFolderGuid, + this.excludeFolderGuids = const {}, super.key, }); @@ -72,9 +72,9 @@ class FolderTreePicker extends HookConsumerWidget { if (item.children != null) { for (final child in item.children!) { - // Skip the excluded folder and its descendants + // Skip excluded folders and their descendants if (child is BookmarkFolder && - child.guid != excludeFolderGuid) { + !excludeFolderGuids.contains(child.guid)) { addChildren(node, child); } }