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)
|
||||||
|
|||||||
+723
-100
@@ -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_material_design_icons/flutter_material_design_icons.dart';
|
||||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/logger.dart';
|
||||||
import 'package:weblibre/core/routing/routes.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_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/providers/bookmarks.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/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/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/hooks/menu_controller.dart';
|
||||||
import 'package:weblibre/presentation/widgets/failure_widget.dart';
|
import 'package:weblibre/presentation/widgets/failure_widget.dart';
|
||||||
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
|
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
|
||||||
@@ -48,8 +59,16 @@ class BookmarkListScreen extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final treeKey = useMemoized(() => GlobalKey<TreeViewState>());
|
final treeController =
|
||||||
|
useState<TreeViewController<BookmarkItem, TreeNode<BookmarkItem>>?>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
// Tracks which folder GUIDs are expanded, so expansion state survives
|
||||||
|
// tree rebuilds caused by sort type changes.
|
||||||
|
final expandedGuids = useRef(<String>{});
|
||||||
final hideEmptyRoots = useState(true);
|
final hideEmptyRoots = useState(true);
|
||||||
|
final uiState = ref.watch(bookmarkListUiStateProvider);
|
||||||
|
final uiStateNotifier = ref.read(bookmarkListUiStateProvider.notifier);
|
||||||
final bookmarkList = ref.watch(
|
final bookmarkList = ref.watch(
|
||||||
seamlessBookmarksProvider(
|
seamlessBookmarksProvider(
|
||||||
entryGuid,
|
entryGuid,
|
||||||
@@ -78,8 +97,193 @@ class BookmarkListScreen extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return Scaffold(
|
return PopScope(
|
||||||
appBar: AppBar(
|
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,
|
||||||
|
),
|
||||||
|
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<BookmarkItem> addChildren(
|
||||||
|
TreeNode<BookmarkItem>? parent,
|
||||||
|
BookmarkItem item,
|
||||||
|
) {
|
||||||
|
if (uiState.foldersOnly && item is BookmarkEntry) {
|
||||||
|
return parent ?? TreeNode<BookmarkItem>.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<BookmarkItem>.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<BookmarkItem>(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<TreeViewController<BookmarkItem, TreeNode<BookmarkItem>>?>
|
||||||
|
treeController,
|
||||||
|
ObjectRef<Set<String>> expandedGuids,
|
||||||
|
ValueNotifier<bool> hideEmptyRoots,
|
||||||
|
ValueNotifier<bool> textFilterEnabled,
|
||||||
|
TextEditingController textFilterController,
|
||||||
|
BookmarkListUiStateNotifier uiStateNotifier,
|
||||||
|
BookmarkListUiState uiState,
|
||||||
|
) {
|
||||||
|
return AppBar(
|
||||||
title: textFilterEnabled.value
|
title: textFilterEnabled.value
|
||||||
? TextField(
|
? TextField(
|
||||||
controller: textFilterController,
|
controller: textFilterController,
|
||||||
@@ -110,33 +314,73 @@ class BookmarkListScreen extends HookConsumerWidget {
|
|||||||
icon: const Icon(Icons.search),
|
icon: const Icon(Icons.search),
|
||||||
),
|
),
|
||||||
MenuAnchor(
|
MenuAnchor(
|
||||||
|
menuChildren: [
|
||||||
|
SubmenuButton(
|
||||||
|
leadingIcon: const Icon(MdiIcons.eye),
|
||||||
menuChildren: [
|
menuChildren: [
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
leadingIcon: const Icon(MdiIcons.expandAll),
|
leadingIcon: const Icon(MdiIcons.expandAll),
|
||||||
child: const Text('Expand All Folders'),
|
child: const Text('Expand All'),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
treeKey.currentState?.controller.mapNotNull((controller) {
|
final controller = treeController.value;
|
||||||
|
if (controller != null) {
|
||||||
controller.expandAllChildren(
|
controller.expandAllChildren(
|
||||||
controller.tree,
|
controller.tree,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (entryGuid == BookmarkRoot.root.id)
|
if (entryGuid == BookmarkRoot.root.id)
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
leadingIcon: Icon(
|
leadingIcon: Icon(
|
||||||
hideEmptyRoots.value ? MdiIcons.eyeOff : MdiIcons.eye,
|
hideEmptyRoots.value
|
||||||
|
? MdiIcons.folderOff
|
||||||
|
: MdiIcons.folder,
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
hideEmptyRoots.value
|
hideEmptyRoots.value
|
||||||
? 'Show Empty Root Folders'
|
? 'Show Empty Folders'
|
||||||
: 'Hide Empty Root Folders',
|
: 'Hide Empty Folders',
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
hideEmptyRoots.value = !hideEmptyRoots.value;
|
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(
|
SubmenuButton(
|
||||||
leadingIcon: const Icon(MdiIcons.import),
|
leadingIcon: const Icon(MdiIcons.import),
|
||||||
menuChildren: [
|
menuChildren: [
|
||||||
@@ -182,69 +426,28 @@ class BookmarkListScreen extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
body: SafeArea(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 12.0),
|
|
||||||
child: bookmarkList.when(
|
|
||||||
skipLoadingOnReload: true,
|
|
||||||
data: (list) {
|
|
||||||
TreeNode<BookmarkItem> addChildren(
|
|
||||||
TreeNode<BookmarkItem>? 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;
|
// -- Entry Tile --
|
||||||
}
|
|
||||||
|
|
||||||
final root = (list != null)
|
Widget _buildEntryTile(
|
||||||
? addChildren(null, list)
|
BuildContext context,
|
||||||
: TreeNode<BookmarkItem>.root();
|
WidgetRef ref,
|
||||||
|
BookmarkEntry bookmark,
|
||||||
return TreeView.simple(
|
BookmarkListUiState uiState,
|
||||||
key: treeKey,
|
BookmarkListUiStateNotifier uiStateNotifier,
|
||||||
tree: root,
|
bool isSelected,
|
||||||
showRootNode: entryGuid != BookmarkRoot.root.id,
|
BookmarkItem? rootItem,
|
||||||
onTreeReady: (controller) {
|
) {
|
||||||
if (textFilterEnabled.value) {
|
if (uiState.selectionMode) {
|
||||||
controller.expandAllChildren(root, recursive: true);
|
return ListTile(
|
||||||
} 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),
|
key: ValueKey(bookmark.guid),
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
leading: UrlIcon([bookmark.url], iconSize: 34.0),
|
leading: UrlIcon([bookmark.url], iconSize: 34.0),
|
||||||
trailing: IconButton(
|
trailing: Checkbox(
|
||||||
icon: const Icon(Icons.edit),
|
value: isSelected,
|
||||||
onPressed: () async {
|
onChanged: (_) => uiStateNotifier.toggleSelection(bookmark.guid),
|
||||||
await BookmarkEntryEditRoute(
|
|
||||||
bookmarkEntry: jsonEncode(bookmark.toJson()),
|
|
||||||
).push(context);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
bookmark.title,
|
bookmark.title,
|
||||||
@@ -252,6 +455,17 @@ class BookmarkListScreen extends HookConsumerWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
subtitle: UriBreadcrumb(uri: bookmark.url),
|
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 {
|
onTap: () async {
|
||||||
final result = await OpenSharedContentRoute(
|
final result = await OpenSharedContentRoute(
|
||||||
sharedUrl: bookmark.url.toString(),
|
sharedUrl: bookmark.url.toString(),
|
||||||
@@ -263,10 +477,172 @@ class BookmarkListScreen extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
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),
|
||||||
),
|
),
|
||||||
final BookmarkFolder folder => Padding(
|
),
|
||||||
|
menuChildren: [
|
||||||
|
MenuItemButton(
|
||||||
|
leadingIcon: const Icon(MdiIcons.openInNew),
|
||||||
|
child: const Text('Open'),
|
||||||
|
onPressed: () async {
|
||||||
|
final result = await OpenSharedContentRoute(
|
||||||
|
sharedUrl: bookmark.url.toString(),
|
||||||
|
).push<bool>(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),
|
key: ValueKey(folder.guid),
|
||||||
padding: (item.isLeaf)
|
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: 4.0)
|
||||||
: const EdgeInsets.only(right: 42.0),
|
: const EdgeInsets.only(right: 42.0),
|
||||||
child: HookBuilder(
|
child: HookBuilder(
|
||||||
@@ -275,7 +651,7 @@ class BookmarkListScreen extends HookConsumerWidget {
|
|||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
leading: (item.isExpanded)
|
leading: isExpanded
|
||||||
? const Icon(MdiIcons.folderOpen)
|
? const Icon(MdiIcons.folderOpen)
|
||||||
: const Icon(MdiIcons.folder),
|
: const Icon(MdiIcons.folder),
|
||||||
title: Text(folder.title),
|
title: Text(folder.title),
|
||||||
@@ -298,11 +674,43 @@ class BookmarkListScreen extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
menuChildren: [
|
menuChildren: [
|
||||||
if (!bookmarkRootIds.contains(folder.guid))
|
if (!isRoot) ...[
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
leadingIcon: const Icon(
|
leadingIcon: const Icon(MdiIcons.folderMove),
|
||||||
MdiIcons.folderEdit,
|
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'),
|
child: const Text('Edit'),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await BookmarkFolderEditRoute(
|
await BookmarkFolderEditRoute(
|
||||||
@@ -310,6 +718,19 @@ class BookmarkListScreen extends HookConsumerWidget {
|
|||||||
).push(context);
|
).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(
|
MenuItemButton(
|
||||||
leadingIcon: const Icon(MdiIcons.folderPlus),
|
leadingIcon: const Icon(MdiIcons.folderPlus),
|
||||||
child: const Text('Add Subfolder'),
|
child: const Text('Add Subfolder'),
|
||||||
@@ -320,16 +741,12 @@ class BookmarkListScreen extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
leadingIcon: const Icon(
|
leadingIcon: const Icon(MdiIcons.bookmarkPlus),
|
||||||
MdiIcons.bookmarkPlus,
|
|
||||||
),
|
|
||||||
child: const Text('Add Bookmark'),
|
child: const Text('Add Bookmark'),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await BookmarkEntryAddRoute(
|
await BookmarkEntryAddRoute(
|
||||||
bookmarkInfo: jsonEncode(
|
bookmarkInfo: jsonEncode(
|
||||||
BookmarkInfo(
|
BookmarkInfo(parentGuid: folder.guid).encode(),
|
||||||
parentGuid: folder.guid,
|
|
||||||
).encode(),
|
|
||||||
),
|
),
|
||||||
).push(context);
|
).push(context);
|
||||||
},
|
},
|
||||||
@@ -338,37 +755,243 @@ class BookmarkListScreen extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
if (folder.guid != entryGuid) {
|
if (folder.guid != entryGuid) {
|
||||||
await BookmarkListRoute(
|
await BookmarkListRoute(entryGuid: folder.guid).push(context);
|
||||||
entryGuid: folder.guid,
|
|
||||||
).push(context);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
onLongPress: isRoot
|
||||||
},
|
? null
|
||||||
),
|
: () {
|
||||||
),
|
ref
|
||||||
null => const Center(child: Text('Empty')),
|
.read(bookmarkListUiStateProvider.notifier)
|
||||||
};
|
.enterSelectionMode(initialGuid: folder.guid);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
error: (error, stackTrace) => Center(
|
|
||||||
child: FailureWidget(
|
|
||||||
title: 'Failed to load Bookmarks',
|
|
||||||
exception: error,
|
|
||||||
onRetry: () {
|
|
||||||
// ignore: unused_result
|
|
||||||
ref.refresh(bookmarksProvider<BookmarkItem>(entryGuid));
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Bulk Actions --
|
||||||
|
|
||||||
|
Future<void> _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<BookmarkEntry>().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<void> _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 = <String>{};
|
||||||
|
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<void> _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<BookmarkItem, TreeNode<BookmarkItem>>? controller,
|
||||||
|
ObjectRef<Set<String>> expandedGuids,
|
||||||
|
) {
|
||||||
|
if (controller == null) return;
|
||||||
|
final guids = <String>{};
|
||||||
|
_collectExpandedGuids(controller.tree, guids);
|
||||||
|
expandedGuids.value = guids;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _collectExpandedGuids(TreeNode<BookmarkItem> node, Set<String> guids) {
|
||||||
|
if (node.isExpanded && node.key != INode.ROOT_KEY) {
|
||||||
|
guids.add(node.key);
|
||||||
|
}
|
||||||
|
for (final child in node.childrenAsList) {
|
||||||
|
_collectExpandedGuids(child as TreeNode<BookmarkItem>, guids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restores expansion state by expanding nodes whose GUIDs are in the set.
|
||||||
|
void _restoreExpansion(
|
||||||
|
TreeViewController<BookmarkItem, TreeNode<BookmarkItem>> controller,
|
||||||
|
TreeNode<BookmarkItem> root,
|
||||||
|
Set<String> guids,
|
||||||
|
) {
|
||||||
|
// Always expand root
|
||||||
|
controller.expandNode(root);
|
||||||
|
_expandMatchingNodes(controller, root, guids);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _expandMatchingNodes(
|
||||||
|
TreeViewController<BookmarkItem, TreeNode<BookmarkItem>> controller,
|
||||||
|
TreeNode<BookmarkItem> node,
|
||||||
|
Set<String> guids,
|
||||||
|
) {
|
||||||
|
for (final child in node.childrenAsList) {
|
||||||
|
final typedChild = child as TreeNode<BookmarkItem>;
|
||||||
|
if (guids.contains(typedChild.key)) {
|
||||||
|
controller.expandNode(typedChild);
|
||||||
|
}
|
||||||
|
_expandMatchingNodes(controller, typedChild, guids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Tab Opening Helper --
|
||||||
|
|
||||||
|
Future<void> _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<void> _handleImport(
|
Future<void> _handleImport(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
|
|||||||
+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