improved bookmark management
This commit is contained in:
+47
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<String> selectedGuids;
|
||||
final BookmarkSortType sortType;
|
||||
final bool foldersOnly;
|
||||
|
||||
BookmarkListUiState({
|
||||
this.selectionMode = false,
|
||||
this.selectedGuids = const {},
|
||||
this.sortType = BookmarkSortType.manual,
|
||||
this.foldersOnly = false,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [
|
||||
selectionMode,
|
||||
selectedGuids,
|
||||
sortType,
|
||||
foldersOnly,
|
||||
];
|
||||
}
|
||||
+100
@@ -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<String> 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<String> 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<String> 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<String>,
|
||||
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);
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
+74
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<String>.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<String> guids) {
|
||||
state = state.copyWith(
|
||||
selectionMode: true,
|
||||
selectedGuids: Set<String>.from(guids),
|
||||
);
|
||||
}
|
||||
|
||||
void clearSelection() {
|
||||
exitSelectionMode();
|
||||
}
|
||||
|
||||
void setSortType(BookmarkSortType sortType) {
|
||||
state = state.copyWith(sortType: sortType);
|
||||
}
|
||||
|
||||
void toggleFoldersOnly() {
|
||||
state = state.copyWith(foldersOnly: !state.foldersOnly);
|
||||
}
|
||||
}
|
||||
+65
@@ -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<BookmarkListUiStateNotifier, BookmarkListUiState> {
|
||||
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<BookmarkListUiState>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$bookmarkListUiStateNotifierHash() =>
|
||||
r'7c764fc2f1deb178063ca95764775ca237471f9f';
|
||||
|
||||
abstract class _$BookmarkListUiStateNotifier
|
||||
extends $Notifier<BookmarkListUiState> {
|
||||
BookmarkListUiState build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<BookmarkListUiState, BookmarkListUiState>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<BookmarkListUiState, BookmarkListUiState>,
|
||||
BookmarkListUiState,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -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<void> 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<void> moveMany({
|
||||
required Iterable<BookmarkItem> 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<void> deleteMany(Iterable<String> 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<void> 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<Set<String>> getDescendantFolderGuids(String guid) async {
|
||||
final node = await _service.getTree(guid, recursive: true);
|
||||
if (node == null) return const {};
|
||||
final result = <String>{};
|
||||
void collect(BookmarkNode n) {
|
||||
for (final child in n.children ?? const <BookmarkNode>[]) {
|
||||
if (child.type == BookmarkNodeType.folder) {
|
||||
result.add(child.guid);
|
||||
collect(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collect(node);
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> eraseEverything(BookmarkRoot root) async {
|
||||
await _service.eraseEverything(root);
|
||||
ref.invalidateSelf();
|
||||
|
||||
@@ -34,7 +34,7 @@ final class BookmarksRepositoryProvider
|
||||
}
|
||||
|
||||
String _$bookmarksRepositoryHash() =>
|
||||
r'c53414612bf1d1da824e1150ca4eca7bb7c3bec7';
|
||||
r'2169d5b354c4a22192096451c96ab1490cf55ab4';
|
||||
|
||||
abstract class _$BookmarksRepository extends $AsyncNotifier<BookmarkItem?> {
|
||||
FutureOr<BookmarkItem?> build();
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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 = <BookmarkItem>[];
|
||||
final nonRootItems = <BookmarkItem>[];
|
||||
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<String> collectDescendantFolderGuids(BookmarkFolder folder) {
|
||||
final result = <String>{};
|
||||
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<BookmarkItem> resolveSelectedItems(BookmarkItem root, Set<String> guids) {
|
||||
final result = <BookmarkItem>[];
|
||||
_collectByGuids(root, guids, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
void _collectByGuids(
|
||||
BookmarkItem item,
|
||||
Set<String> guids,
|
||||
List<BookmarkItem> 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<String> normalizeSelection(BookmarkItem root, Set<String> selectedGuids) {
|
||||
final items = resolveSelectedItems(root, selectedGuids);
|
||||
final folderGuidsToRemove = <String>{};
|
||||
|
||||
for (final item in items) {
|
||||
if (item is BookmarkFolder) {
|
||||
_collectAllDescendantGuids(item, folderGuidsToRemove);
|
||||
}
|
||||
}
|
||||
|
||||
return selectedGuids.difference(folderGuidsToRemove);
|
||||
}
|
||||
|
||||
void _collectAllDescendantGuids(BookmarkFolder folder, Set<String> result) {
|
||||
if (folder.children != null) {
|
||||
for (final child in folder.children!) {
|
||||
result.add(child.guid);
|
||||
if (child is BookmarkFolder) {
|
||||
_collectAllDescendantGuids(child, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+102
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<String?> showSelectBookmarkFolderDialog(
|
||||
BuildContext context, {
|
||||
Set<String> excludeFolderGuids = const {},
|
||||
String? initialFolderGuid,
|
||||
}) {
|
||||
return showModalBottomSheet<String>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => _SelectBookmarkFolderSheet(
|
||||
excludeFolderGuids: excludeFolderGuids,
|
||||
initialFolderGuid: initialFolderGuid,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _SelectBookmarkFolderSheet extends HookConsumerWidget {
|
||||
final Set<String> 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+12
@@ -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(
|
||||
|
||||
+15
-1
@@ -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)
|
||||
|
||||
+905
-282
File diff suppressed because it is too large
Load Diff
+8
-8
@@ -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<String> 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<String> 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user