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:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
import 'package:nullability/nullability.dart';
|
import 'package:nullability/nullability.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.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/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_html_utils.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_json_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 String parentGuid,
|
||||||
required Uri url,
|
required Uri url,
|
||||||
required String title,
|
required String title,
|
||||||
|
int? position,
|
||||||
}) async {
|
}) async {
|
||||||
await _service.addItem(parentGuid, url, title, null);
|
await _service.addItem(parentGuid, url, title, position);
|
||||||
ref.invalidateSelf();
|
ref.invalidateSelf();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> addFolder({
|
Future<void> addFolder({
|
||||||
required String parentGuid,
|
required String parentGuid,
|
||||||
required String title,
|
required String title,
|
||||||
|
int? position,
|
||||||
}) async {
|
}) async {
|
||||||
await _service.addFolder(parentGuid, title, null);
|
await _service.addFolder(parentGuid, title, position);
|
||||||
ref.invalidateSelf();
|
ref.invalidateSelf();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,10 +57,16 @@ class BookmarksRepository extends _$BookmarksRepository {
|
|||||||
String? title,
|
String? title,
|
||||||
Uri? url,
|
Uri? url,
|
||||||
String? parentGuid,
|
String? parentGuid,
|
||||||
|
int? position,
|
||||||
}) async {
|
}) async {
|
||||||
await _service.updateNode(
|
await _service.updateNode(
|
||||||
guid,
|
guid,
|
||||||
BookmarkInfo(title: title, url: url?.toString(), parentGuid: parentGuid),
|
BookmarkInfo(
|
||||||
|
title: title,
|
||||||
|
url: url?.toString(),
|
||||||
|
parentGuid: parentGuid,
|
||||||
|
position: position,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
ref.invalidateSelf();
|
ref.invalidateSelf();
|
||||||
}
|
}
|
||||||
@@ -66,10 +75,11 @@ class BookmarksRepository extends _$BookmarksRepository {
|
|||||||
required String guid,
|
required String guid,
|
||||||
String? title,
|
String? title,
|
||||||
String? parentGuid,
|
String? parentGuid,
|
||||||
|
int? position,
|
||||||
}) async {
|
}) async {
|
||||||
await _service.updateNode(
|
await _service.updateNode(
|
||||||
guid,
|
guid,
|
||||||
BookmarkInfo(title: title, parentGuid: parentGuid),
|
BookmarkInfo(title: title, parentGuid: parentGuid, position: position),
|
||||||
);
|
);
|
||||||
ref.invalidateSelf();
|
ref.invalidateSelf();
|
||||||
}
|
}
|
||||||
@@ -79,6 +89,78 @@ class BookmarksRepository extends _$BookmarksRepository {
|
|||||||
ref.invalidateSelf();
|
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 {
|
Future<void> eraseEverything(BookmarkRoot root) async {
|
||||||
await _service.eraseEverything(root);
|
await _service.eraseEverything(root);
|
||||||
ref.invalidateSelf();
|
ref.invalidateSelf();
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ final class BookmarksRepositoryProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$bookmarksRepositoryHash() =>
|
String _$bookmarksRepositoryHash() =>
|
||||||
r'c53414612bf1d1da824e1150ca4eca7bb7c3bec7';
|
r'2169d5b354c4a22192096451c96ab1490cf55ab4';
|
||||||
|
|
||||||
abstract class _$BookmarksRepository extends $AsyncNotifier<BookmarkItem?> {
|
abstract class _$BookmarksRepository extends $AsyncNotifier<BookmarkItem?> {
|
||||||
FutureOr<BookmarkItem?> build();
|
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,
|
BookmarkRoot.mobile.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final addToTop = useState(false);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: (exisitingEntry != null)
|
title: (exisitingEntry != null)
|
||||||
@@ -104,6 +106,7 @@ class BookmarkEntryEditScreen extends HookConsumerWidget {
|
|||||||
parentGuid: parentGuid.value,
|
parentGuid: parentGuid.value,
|
||||||
title: nameTextController.text,
|
title: nameTextController.text,
|
||||||
url: newUrl,
|
url: newUrl,
|
||||||
|
position: addToTop.value ? 0 : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -157,6 +160,15 @@ class BookmarkEntryEditScreen extends HookConsumerWidget {
|
|||||||
selectedFolderGuid: parentGuid,
|
selectedFolderGuid: parentGuid,
|
||||||
entryGuid: BookmarkRoot.root.id,
|
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),
|
const SizedBox(height: 16),
|
||||||
if (exisitingEntry != null)
|
if (exisitingEntry != null)
|
||||||
SizedBox(
|
SizedBox(
|
||||||
|
|||||||
+15
-1
@@ -47,6 +47,8 @@ class BookmarkFolderEditScreen extends HookConsumerWidget {
|
|||||||
parentGuid ?? folder?.parentGuid ?? BookmarkRoot.mobile.id,
|
parentGuid ?? folder?.parentGuid ?? BookmarkRoot.mobile.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final addToTop = useState(false);
|
||||||
|
|
||||||
// Check if this is a bookmark root folder (these cannot be moved)
|
// Check if this is a bookmark root folder (these cannot be moved)
|
||||||
final isBookmarkRoot =
|
final isBookmarkRoot =
|
||||||
folder != null && bookmarkRootIds.contains(folder!.guid);
|
folder != null && bookmarkRootIds.contains(folder!.guid);
|
||||||
@@ -84,6 +86,7 @@ class BookmarkFolderEditScreen extends HookConsumerWidget {
|
|||||||
.addFolder(
|
.addFolder(
|
||||||
parentGuid: currentParentGuid.value,
|
parentGuid: currentParentGuid.value,
|
||||||
title: nameTextController.text,
|
title: nameTextController.text,
|
||||||
|
position: addToTop.value ? 0 : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -114,9 +117,20 @@ class BookmarkFolderEditScreen extends HookConsumerWidget {
|
|||||||
if (!isBookmarkRoot) ...[
|
if (!isBookmarkRoot) ...[
|
||||||
FolderTreePicker(
|
FolderTreePicker(
|
||||||
selectedFolderGuid: currentParentGuid,
|
selectedFolderGuid: currentParentGuid,
|
||||||
excludeFolderGuid: folder?.guid,
|
excludeFolderGuids: folder != null
|
||||||
|
? {folder!.guid}
|
||||||
|
: const {},
|
||||||
entryGuid: BookmarkRoot.root.id,
|
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),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
if (folder != null)
|
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
|
/// A widget that displays a tree view of bookmark folders and allows the user
|
||||||
/// to select a parent folder.
|
/// to select a parent folder.
|
||||||
///
|
///
|
||||||
/// When editing a folder, pass [excludeFolderGuid] to prevent selecting the
|
/// When editing a folder, pass [excludeFolderGuids] to prevent selecting the
|
||||||
/// folder itself or its descendants as the parent (which would create a circular reference).
|
/// folders or their descendants as the parent (which would create a circular reference).
|
||||||
class FolderTreePicker extends HookConsumerWidget {
|
class FolderTreePicker extends HookConsumerWidget {
|
||||||
/// The currently selected folder GUID
|
/// The currently selected folder GUID
|
||||||
final ValueNotifier<String> selectedFolderGuid;
|
final ValueNotifier<String> selectedFolderGuid;
|
||||||
|
|
||||||
/// Optional folder GUID to exclude from the tree (along with its descendants).
|
/// Optional folder GUIDs to exclude from the tree (along with their descendants).
|
||||||
/// Used when editing a folder to prevent circular parent relationships.
|
/// Used when editing/moving folders to prevent circular parent relationships.
|
||||||
final String? excludeFolderGuid;
|
final Set<String> excludeFolderGuids;
|
||||||
|
|
||||||
final String entryGuid;
|
final String entryGuid;
|
||||||
|
|
||||||
const FolderTreePicker({
|
const FolderTreePicker({
|
||||||
required this.selectedFolderGuid,
|
required this.selectedFolderGuid,
|
||||||
required this.entryGuid,
|
required this.entryGuid,
|
||||||
this.excludeFolderGuid,
|
this.excludeFolderGuids = const {},
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,9 +72,9 @@ class FolderTreePicker extends HookConsumerWidget {
|
|||||||
|
|
||||||
if (item.children != null) {
|
if (item.children != null) {
|
||||||
for (final child in item.children!) {
|
for (final child in item.children!) {
|
||||||
// Skip the excluded folder and its descendants
|
// Skip excluded folders and their descendants
|
||||||
if (child is BookmarkFolder &&
|
if (child is BookmarkFolder &&
|
||||||
child.guid != excludeFolderGuid) {
|
!excludeFolderGuids.contains(child.guid)) {
|
||||||
addChildren(node, child);
|
addChildren(node, child);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user