add tab view filter and sort options
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/// Reusable sort directions for list items that share common sortable fields
|
||||
/// (title, URL, date).
|
||||
enum SortField { titleAsc, titleDesc, urlAsc, urlDesc, dateAsc, dateDesc }
|
||||
+24
-17
@@ -17,18 +17,22 @@
|
||||
* 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/core/sort_field.dart';
|
||||
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');
|
||||
manual('Default', null),
|
||||
titleAsc('Title A-Z', SortField.titleAsc),
|
||||
titleDesc('Title Z-A', SortField.titleDesc),
|
||||
urlAsc('URL A-Z', SortField.urlAsc),
|
||||
urlDesc('URL Z-A', SortField.urlDesc),
|
||||
dateAddedDesc('Newest First', SortField.dateDesc),
|
||||
dateAddedAsc('Oldest First', SortField.dateAsc);
|
||||
|
||||
final String label;
|
||||
final SortField? sortField;
|
||||
|
||||
const BookmarkSortType(this.label);
|
||||
const BookmarkSortType(this.label, this.sortField);
|
||||
}
|
||||
|
||||
int compareBookmarkItems(
|
||||
@@ -36,21 +40,24 @@ int compareBookmarkItems(
|
||||
BookmarkItem b,
|
||||
BookmarkSortType sort,
|
||||
) {
|
||||
return switch (sort) {
|
||||
BookmarkSortType.manual => 0,
|
||||
BookmarkSortType.titleAsc => a.title.toLowerCase().compareTo(
|
||||
final sortField = sort.sortField;
|
||||
if (sortField == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return switch (sortField) {
|
||||
SortField.titleAsc => a.title.toLowerCase().compareTo(
|
||||
b.title.toLowerCase(),
|
||||
),
|
||||
BookmarkSortType.titleDesc => b.title.toLowerCase().compareTo(
|
||||
SortField.titleDesc => b.title.toLowerCase().compareTo(
|
||||
a.title.toLowerCase(),
|
||||
),
|
||||
BookmarkSortType.urlAsc => _compareByUrl(a, b),
|
||||
BookmarkSortType.dateAddedDesc => b.dateAdded.compareTo(a.dateAdded),
|
||||
SortField.urlAsc => _urlKey(a).compareTo(_urlKey(b)),
|
||||
SortField.urlDesc => _urlKey(b).compareTo(_urlKey(a)),
|
||||
SortField.dateAsc => a.dateAdded.compareTo(b.dateAdded),
|
||||
SortField.dateDesc => 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);
|
||||
}
|
||||
String _urlKey(BookmarkItem item) =>
|
||||
item is BookmarkEntry ? item.url.toString() : item.title.toLowerCase();
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* 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:flutter/material.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:weblibre/core/sort_field.dart';
|
||||
import 'package:weblibre/data/database/converters/date_time_range.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||
|
||||
part 'tab_view_filter_options.g.dart';
|
||||
|
||||
enum TabTypeFilter {
|
||||
all('All Tabs'),
|
||||
regularOnly('Regular'),
|
||||
privateOnly('Private'),
|
||||
isolatedOnly('Isolated');
|
||||
|
||||
final String label;
|
||||
|
||||
const TabTypeFilter(this.label);
|
||||
|
||||
bool matches(TabMode? tabMode) => switch (this) {
|
||||
all => true,
|
||||
regularOnly => tabMode is RegularTabMode,
|
||||
privateOnly => tabMode is PrivateTabMode,
|
||||
isolatedOnly => tabMode is IsolatedTabMode,
|
||||
};
|
||||
}
|
||||
|
||||
enum TabSortType {
|
||||
manual('Default', null),
|
||||
titleAsc('Title A-Z', SortField.titleAsc),
|
||||
titleDesc('Title Z-A', SortField.titleDesc),
|
||||
urlAsc('URL A-Z', SortField.urlAsc),
|
||||
urlDesc('URL Z-A', SortField.urlDesc),
|
||||
newestFirst('Newest First', SortField.dateDesc),
|
||||
oldestFirst('Oldest First', SortField.dateAsc);
|
||||
|
||||
final String label;
|
||||
final SortField? sortField;
|
||||
|
||||
const TabSortType(this.label, this.sortField);
|
||||
}
|
||||
|
||||
enum TabQuickInterval {
|
||||
last1h('Last Hour', Duration(hours: 1)),
|
||||
last3h('Last 3 Hours', Duration(hours: 3)),
|
||||
last8h('Last 8 Hours', Duration(hours: 8)),
|
||||
last1d('Last Day', Duration(days: 1)),
|
||||
last3d('Last 3 Days', Duration(days: 3)),
|
||||
last1w('Last Week', Duration(days: 7)),
|
||||
last1m('Last Month', Duration(days: 30));
|
||||
|
||||
final String label;
|
||||
final Duration duration;
|
||||
|
||||
const TabQuickInterval(this.label, this.duration);
|
||||
|
||||
DateTimeRange<DateTime> toDateRange() {
|
||||
final now = DateTime.now();
|
||||
return DateTimeRange(start: now.subtract(duration), end: now);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
@CopyWith()
|
||||
class TabViewFilterOptions with FastEquatable {
|
||||
final TabTypeFilter tabTypeFilter;
|
||||
final TabSortType sortType;
|
||||
final bool sortPinnedFirst;
|
||||
@DateTimeRangeConverter()
|
||||
final DateTimeRange<DateTime>? dateRange;
|
||||
final TabQuickInterval? quickInterval;
|
||||
|
||||
TabViewFilterOptions({
|
||||
required this.tabTypeFilter,
|
||||
required this.sortType,
|
||||
required this.sortPinnedFirst,
|
||||
required this.dateRange,
|
||||
required this.quickInterval,
|
||||
});
|
||||
|
||||
TabViewFilterOptions.withDefaults()
|
||||
: this(
|
||||
tabTypeFilter: TabTypeFilter.all,
|
||||
sortType: TabSortType.manual,
|
||||
sortPinnedFirst: true,
|
||||
dateRange: null,
|
||||
quickInterval: null,
|
||||
);
|
||||
|
||||
bool get hasActiveFilter =>
|
||||
tabTypeFilter != TabTypeFilter.all ||
|
||||
sortType != TabSortType.manual ||
|
||||
dateRange != null ||
|
||||
quickInterval != null;
|
||||
|
||||
DateTimeRange<DateTime>? get effectiveDateRange =>
|
||||
quickInterval?.toDateRange() ?? dateRange;
|
||||
|
||||
bool matchesTab(TabMode? tabMode, DateTime? timestamp) {
|
||||
if (!tabTypeFilter.matches(tabMode)) return false;
|
||||
final range = effectiveDateRange;
|
||||
if (range != null &&
|
||||
timestamp != null &&
|
||||
(timestamp.isBefore(range.start) || timestamp.isAfter(range.end))) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [
|
||||
tabTypeFilter,
|
||||
sortType,
|
||||
sortPinnedFirst,
|
||||
dateRange,
|
||||
quickInterval,
|
||||
];
|
||||
|
||||
factory TabViewFilterOptions.fromJson(Map<String, dynamic> json) =>
|
||||
_$TabViewFilterOptionsFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$TabViewFilterOptionsToJson(this);
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'tab_view_filter_options.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// CopyWithGenerator
|
||||
// **************************************************************************
|
||||
|
||||
abstract class _$TabViewFilterOptionsCWProxy {
|
||||
TabViewFilterOptions tabTypeFilter(TabTypeFilter tabTypeFilter);
|
||||
|
||||
TabViewFilterOptions sortType(TabSortType sortType);
|
||||
|
||||
TabViewFilterOptions sortPinnedFirst(bool sortPinnedFirst);
|
||||
|
||||
TabViewFilterOptions dateRange(DateTimeRange<DateTime>? dateRange);
|
||||
|
||||
TabViewFilterOptions quickInterval(TabQuickInterval? quickInterval);
|
||||
|
||||
/// 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 `TabViewFilterOptions(...).copyWith.fieldName(value)`.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// TabViewFilterOptions(...).copyWith(id: 12, name: "My name")
|
||||
/// ```
|
||||
TabViewFilterOptions call({
|
||||
TabTypeFilter tabTypeFilter,
|
||||
TabSortType sortType,
|
||||
bool sortPinnedFirst,
|
||||
DateTimeRange<DateTime>? dateRange,
|
||||
TabQuickInterval? quickInterval,
|
||||
});
|
||||
}
|
||||
|
||||
/// Callable proxy for `copyWith` functionality.
|
||||
/// Use as `instanceOfTabViewFilterOptions.copyWith(...)` or call `instanceOfTabViewFilterOptions.copyWith.fieldName(value)` for a single field.
|
||||
class _$TabViewFilterOptionsCWProxyImpl
|
||||
implements _$TabViewFilterOptionsCWProxy {
|
||||
const _$TabViewFilterOptionsCWProxyImpl(this._value);
|
||||
|
||||
final TabViewFilterOptions _value;
|
||||
|
||||
@override
|
||||
TabViewFilterOptions tabTypeFilter(TabTypeFilter tabTypeFilter) =>
|
||||
call(tabTypeFilter: tabTypeFilter);
|
||||
|
||||
@override
|
||||
TabViewFilterOptions sortType(TabSortType sortType) =>
|
||||
call(sortType: sortType);
|
||||
|
||||
@override
|
||||
TabViewFilterOptions sortPinnedFirst(bool sortPinnedFirst) =>
|
||||
call(sortPinnedFirst: sortPinnedFirst);
|
||||
|
||||
@override
|
||||
TabViewFilterOptions dateRange(DateTimeRange<DateTime>? dateRange) =>
|
||||
call(dateRange: dateRange);
|
||||
|
||||
@override
|
||||
TabViewFilterOptions quickInterval(TabQuickInterval? quickInterval) =>
|
||||
call(quickInterval: quickInterval);
|
||||
|
||||
@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 `TabViewFilterOptions(...).copyWith.fieldName(value)`.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// TabViewFilterOptions(...).copyWith(id: 12, name: "My name")
|
||||
/// ```
|
||||
TabViewFilterOptions call({
|
||||
Object? tabTypeFilter = const $CopyWithPlaceholder(),
|
||||
Object? sortType = const $CopyWithPlaceholder(),
|
||||
Object? sortPinnedFirst = const $CopyWithPlaceholder(),
|
||||
Object? dateRange = const $CopyWithPlaceholder(),
|
||||
Object? quickInterval = const $CopyWithPlaceholder(),
|
||||
}) {
|
||||
return TabViewFilterOptions(
|
||||
tabTypeFilter:
|
||||
tabTypeFilter == const $CopyWithPlaceholder() || tabTypeFilter == null
|
||||
? _value.tabTypeFilter
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: tabTypeFilter as TabTypeFilter,
|
||||
sortType: sortType == const $CopyWithPlaceholder() || sortType == null
|
||||
? _value.sortType
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: sortType as TabSortType,
|
||||
sortPinnedFirst:
|
||||
sortPinnedFirst == const $CopyWithPlaceholder() ||
|
||||
sortPinnedFirst == null
|
||||
? _value.sortPinnedFirst
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: sortPinnedFirst as bool,
|
||||
dateRange: dateRange == const $CopyWithPlaceholder()
|
||||
? _value.dateRange
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: dateRange as DateTimeRange<DateTime>?,
|
||||
quickInterval: quickInterval == const $CopyWithPlaceholder()
|
||||
? _value.quickInterval
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: quickInterval as TabQuickInterval?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension $TabViewFilterOptionsCopyWith on TabViewFilterOptions {
|
||||
/// Returns a callable class used to build a new instance with modified fields.
|
||||
/// Example: `instanceOfTabViewFilterOptions.copyWith(...)` or `instanceOfTabViewFilterOptions.copyWith.fieldName(...)`.
|
||||
// ignore: library_private_types_in_public_api
|
||||
_$TabViewFilterOptionsCWProxy get copyWith =>
|
||||
_$TabViewFilterOptionsCWProxyImpl(this);
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
TabViewFilterOptions _$TabViewFilterOptionsFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => TabViewFilterOptions(
|
||||
tabTypeFilter: $enumDecode(_$TabTypeFilterEnumMap, json['tabTypeFilter']),
|
||||
sortType: $enumDecode(_$TabSortTypeEnumMap, json['sortType']),
|
||||
sortPinnedFirst: json['sortPinnedFirst'] as bool,
|
||||
dateRange: const DateTimeRangeConverter().fromJson(
|
||||
json['dateRange'] as Map<String, dynamic>?,
|
||||
),
|
||||
quickInterval: $enumDecodeNullable(
|
||||
_$TabQuickIntervalEnumMap,
|
||||
json['quickInterval'],
|
||||
),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TabViewFilterOptionsToJson(
|
||||
TabViewFilterOptions instance,
|
||||
) => <String, dynamic>{
|
||||
'tabTypeFilter': _$TabTypeFilterEnumMap[instance.tabTypeFilter]!,
|
||||
'sortType': _$TabSortTypeEnumMap[instance.sortType]!,
|
||||
'sortPinnedFirst': instance.sortPinnedFirst,
|
||||
'dateRange': const DateTimeRangeConverter().toJson(instance.dateRange),
|
||||
'quickInterval': _$TabQuickIntervalEnumMap[instance.quickInterval],
|
||||
};
|
||||
|
||||
const _$TabTypeFilterEnumMap = {
|
||||
TabTypeFilter.all: 'all',
|
||||
TabTypeFilter.regularOnly: 'regularOnly',
|
||||
TabTypeFilter.privateOnly: 'privateOnly',
|
||||
TabTypeFilter.isolatedOnly: 'isolatedOnly',
|
||||
};
|
||||
|
||||
const _$TabSortTypeEnumMap = {
|
||||
TabSortType.manual: 'manual',
|
||||
TabSortType.titleAsc: 'titleAsc',
|
||||
TabSortType.titleDesc: 'titleDesc',
|
||||
TabSortType.urlAsc: 'urlAsc',
|
||||
TabSortType.urlDesc: 'urlDesc',
|
||||
TabSortType.newestFirst: 'newestFirst',
|
||||
TabSortType.oldestFirst: 'oldestFirst',
|
||||
};
|
||||
|
||||
const _$TabQuickIntervalEnumMap = {
|
||||
TabQuickInterval.last1h: 'last1h',
|
||||
TabQuickInterval.last3h: 'last3h',
|
||||
TabQuickInterval.last8h: 'last8h',
|
||||
TabQuickInterval.last1d: 'last1d',
|
||||
TabQuickInterval.last3d: 'last3d',
|
||||
TabQuickInterval.last1w: 'last1w',
|
||||
TabQuickInterval.last1m: 'last1m',
|
||||
};
|
||||
@@ -23,6 +23,7 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:riverpod/riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/core/sort_field.dart';
|
||||
import 'package:weblibre/features/bangs/data/models/bang_data.dart';
|
||||
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
|
||||
import 'package:weblibre/features/bangs/domain/repositories/data.dart';
|
||||
@@ -30,6 +31,8 @@ import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/domain/entities/tab_view_filter_options.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/entities/tab_preview.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/container_filter.dart';
|
||||
@@ -46,6 +49,19 @@ part 'providers.g.dart';
|
||||
|
||||
typedef TabStateWirthContainer = (TabState, ContainerData?);
|
||||
|
||||
@Riverpod()
|
||||
bool canManualTabReorder(Ref ref) {
|
||||
final filterOptions = ref.watch(tabViewFilterControllerProvider);
|
||||
|
||||
final hasActiveSearch = ref.watch(
|
||||
tabSearchRepositoryProvider(
|
||||
TabSearchPartition.preview,
|
||||
).select((value) => (value.value?.query ?? '').isNotEmpty),
|
||||
);
|
||||
|
||||
return !filterOptions.hasActiveFilter && !hasActiveSearch;
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class SelectedBangTrigger extends _$SelectedBangTrigger {
|
||||
// ignore: document_ignores api decision
|
||||
@@ -100,6 +116,9 @@ EquatableValue<List<DefaultTabEntity>> containerTabEntities(
|
||||
).select((value) => value.value),
|
||||
);
|
||||
final tabList = ref.watch(tabListProvider);
|
||||
final orderKeys = ref.watch(
|
||||
watchTabOrderKeysProvider.select((value) => value.value),
|
||||
);
|
||||
|
||||
final availableTabs =
|
||||
containerTabs?.where((tabId) => tabList.value.contains(tabId)).toList() ??
|
||||
@@ -112,6 +131,7 @@ EquatableValue<List<DefaultTabEntity>> containerTabEntities(
|
||||
.map(
|
||||
(t) => DefaultTabEntity(
|
||||
tabId: t,
|
||||
orderKey: orderKeys?[t] ?? '',
|
||||
containerId: containerFilter.containerId,
|
||||
),
|
||||
)
|
||||
@@ -123,8 +143,11 @@ EquatableValue<List<DefaultTabEntity>> containerTabEntities(
|
||||
(value) => EquatableValue(
|
||||
value.value?.entries
|
||||
.map(
|
||||
(e) =>
|
||||
DefaultTabEntity(tabId: e.key, containerId: e.value),
|
||||
(e) => DefaultTabEntity(
|
||||
tabId: e.key,
|
||||
orderKey: orderKeys?[e.key] ?? '',
|
||||
containerId: e.value,
|
||||
),
|
||||
)
|
||||
.toList() ??
|
||||
[],
|
||||
@@ -199,7 +222,13 @@ selectedContainerTabStatesWithContainer(Ref ref) {
|
||||
|
||||
final tabStates = ref.watch(tabStatesProvider);
|
||||
|
||||
return EquatableValue([
|
||||
final pinnedTabIds = ref.watch(
|
||||
watchPinnedTabIdsProvider.select((value) => value.value),
|
||||
);
|
||||
|
||||
final orderKeys = {for (final tab in sortedTabs) tab.tabId: tab.orderKey};
|
||||
|
||||
final items = [
|
||||
for (final tabEntity in sortedTabs)
|
||||
if (tabStates.containsKey(tabEntity.tabId))
|
||||
(
|
||||
@@ -208,7 +237,22 @@ selectedContainerTabStatesWithContainer(Ref ref) {
|
||||
(containerId) => containerData?[containerId],
|
||||
),
|
||||
),
|
||||
]);
|
||||
];
|
||||
|
||||
items.sort((a, b) {
|
||||
final aPinned = pinnedTabIds?.contains(a.$1.id) ?? false;
|
||||
final bPinned = pinnedTabIds?.contains(b.$1.id) ?? false;
|
||||
|
||||
if (aPinned != bPinned) {
|
||||
return aPinned ? -1 : 1;
|
||||
}
|
||||
|
||||
final aOrderKey = orderKeys[a.$1.id] ?? '';
|
||||
final bOrderKey = orderKeys[b.$1.id] ?? '';
|
||||
return aOrderKey.compareTo(bOrderKey);
|
||||
});
|
||||
|
||||
return EquatableValue(items);
|
||||
}
|
||||
|
||||
@Riverpod()
|
||||
@@ -324,6 +368,10 @@ EquatableValue<List<TabEntity>> suggestedTabEntities(
|
||||
).select((value) => EquatableValue(value.value)),
|
||||
);
|
||||
|
||||
final orderKeys = ref.watch(
|
||||
watchTabOrderKeysProvider.select((value) => value.value),
|
||||
);
|
||||
|
||||
final suggestions = ref.watch(
|
||||
containerTabSuggestionsProvider(containerId).select(
|
||||
(value) => EquatableValue(
|
||||
@@ -335,6 +383,7 @@ EquatableValue<List<TabEntity>> suggestedTabEntities(
|
||||
.map(
|
||||
(tabId) => DefaultTabEntity(
|
||||
tabId: tabId,
|
||||
orderKey: orderKeys?[tabId] ?? '',
|
||||
containerId: containerId,
|
||||
),
|
||||
)
|
||||
@@ -348,6 +397,103 @@ EquatableValue<List<TabEntity>> suggestedTabEntities(
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
List<TabEntity> _applyTabFiltersAndSort(
|
||||
List<TabEntity> entities,
|
||||
TabViewFilterOptions filterOptions,
|
||||
Map<String, TabState> tabStates,
|
||||
Set<String> pinnedTabIds,
|
||||
Map<String, DateTime>? tabTimestamps,
|
||||
) {
|
||||
final sortField = filterOptions.sortType.sortField;
|
||||
final filteredRows = <_TabFilterRow>[];
|
||||
var hasPinned = false;
|
||||
|
||||
for (final entity in entities) {
|
||||
final tabState = tabStates[entity.tabId];
|
||||
final timestamp = tabTimestamps?[entity.tabId];
|
||||
|
||||
if (!filterOptions.matchesTab(tabState?.tabMode, timestamp)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final isPinned = pinnedTabIds.contains(entity.tabId);
|
||||
hasPinned = hasPinned || isPinned;
|
||||
|
||||
filteredRows.add(
|
||||
_TabFilterRow(
|
||||
entity: entity,
|
||||
isPinned: isPinned,
|
||||
titleKey:
|
||||
sortField == SortField.titleAsc || sortField == SortField.titleDesc
|
||||
? (tabState?.titleOrAuthority ?? '').toLowerCase()
|
||||
: null,
|
||||
urlKey: sortField == SortField.urlAsc || sortField == SortField.urlDesc
|
||||
? (tabState?.url.toString() ?? '')
|
||||
: null,
|
||||
dateKey:
|
||||
sortField == SortField.dateAsc || sortField == SortField.dateDesc
|
||||
? (timestamp ?? DateTime(0))
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (sortField != null) {
|
||||
filteredRows.sort((a, b) {
|
||||
if (filterOptions.sortPinnedFirst && a.isPinned != b.isPinned) {
|
||||
return b.isPinned ? 1 : -1;
|
||||
}
|
||||
|
||||
final cmp = switch (sortField) {
|
||||
SortField.titleAsc => a.titleKey!.compareTo(b.titleKey!),
|
||||
SortField.titleDesc => b.titleKey!.compareTo(a.titleKey!),
|
||||
SortField.urlAsc => a.urlKey!.compareTo(b.urlKey!),
|
||||
SortField.urlDesc => b.urlKey!.compareTo(a.urlKey!),
|
||||
SortField.dateAsc => a.dateKey!.compareTo(b.dateKey!),
|
||||
SortField.dateDesc => b.dateKey!.compareTo(a.dateKey!),
|
||||
};
|
||||
|
||||
if (cmp == 0) return a.entity.orderKey.compareTo(b.entity.orderKey);
|
||||
|
||||
return cmp;
|
||||
});
|
||||
|
||||
return filteredRows.map((row) => row.entity).toList();
|
||||
}
|
||||
|
||||
if (!hasPinned || !filterOptions.sortPinnedFirst) {
|
||||
return filteredRows.map((row) => row.entity).toList();
|
||||
}
|
||||
|
||||
final pinned = <TabEntity>[];
|
||||
final unpinned = <TabEntity>[];
|
||||
for (final row in filteredRows) {
|
||||
if (row.isPinned) {
|
||||
pinned.add(row.entity);
|
||||
} else {
|
||||
unpinned.add(row.entity);
|
||||
}
|
||||
}
|
||||
|
||||
return [...pinned, ...unpinned];
|
||||
}
|
||||
|
||||
class _TabFilterRow {
|
||||
final TabEntity entity;
|
||||
final bool isPinned;
|
||||
final String? titleKey;
|
||||
final String? urlKey;
|
||||
final DateTime? dateKey;
|
||||
|
||||
const _TabFilterRow({
|
||||
required this.entity,
|
||||
required this.isPinned,
|
||||
required this.titleKey,
|
||||
required this.urlKey,
|
||||
required this.dateKey,
|
||||
});
|
||||
}
|
||||
|
||||
@Riverpod()
|
||||
EquatableValue<List<TabEntity>> seamlessFilteredTabEntities(
|
||||
Ref ref, {
|
||||
@@ -355,6 +501,10 @@ EquatableValue<List<TabEntity>> seamlessFilteredTabEntities(
|
||||
required ContainerFilter containerFilter,
|
||||
required bool groupTrees,
|
||||
}) {
|
||||
final orderKeys = ref.watch(
|
||||
watchTabOrderKeysProvider.select((value) => value.value),
|
||||
);
|
||||
|
||||
final tabSearchResults = ref
|
||||
.watch(
|
||||
tabSearchRepositoryProvider(searchPartition).select(
|
||||
@@ -364,6 +514,7 @@ EquatableValue<List<TabEntity>> seamlessFilteredTabEntities(
|
||||
.map(
|
||||
(tab) => SearchResultTabEntity(
|
||||
tabId: tab.id,
|
||||
orderKey: orderKeys?[tab.id] ?? '',
|
||||
containerId: tab.containerId,
|
||||
searchQuery: result.query,
|
||||
),
|
||||
@@ -379,53 +530,98 @@ EquatableValue<List<TabEntity>> seamlessFilteredTabEntities(
|
||||
containerTabEntitiesProvider(containerFilter),
|
||||
);
|
||||
|
||||
if (tabSearchResults == null) {
|
||||
if (groupTrees) {
|
||||
final trees = ref.watch(
|
||||
watchTabTreesProvider.select(
|
||||
(value) => EquatableValue(
|
||||
value.value?.map((tree) {
|
||||
// Find the container ID for the latest tab
|
||||
final containerForTab = availableTabs.value
|
||||
.where((t) => t.tabId == tree.latestTabId)
|
||||
.firstOrNull
|
||||
?.containerId;
|
||||
// Tree mode: no filtering/sorting, return as-is
|
||||
if (groupTrees && tabSearchResults == null) {
|
||||
final trees = ref.watch(
|
||||
watchTabTreesProvider.select(
|
||||
(value) => EquatableValue(
|
||||
value.value?.map((tree) {
|
||||
// Find the container ID for the latest tab
|
||||
final containerForTab = availableTabs.value
|
||||
.where((t) => t.tabId == tree.latestTabId)
|
||||
.firstOrNull
|
||||
?.containerId;
|
||||
|
||||
return TabTreeEntity(
|
||||
tabId: tree.latestTabId,
|
||||
containerId: containerForTab,
|
||||
rootId: tree.rootTabId,
|
||||
totalTabs: tree.totalTabs,
|
||||
);
|
||||
}).toList() ??
|
||||
[],
|
||||
),
|
||||
return TabTreeEntity(
|
||||
tabId: tree.latestTabId,
|
||||
orderKey: orderKeys?[tree.latestTabId] ?? '',
|
||||
containerId: containerForTab,
|
||||
rootId: tree.rootTabId,
|
||||
totalTabs: tree.totalTabs,
|
||||
);
|
||||
}).toList() ??
|
||||
[],
|
||||
),
|
||||
);
|
||||
),
|
||||
);
|
||||
|
||||
return EquatableValue(
|
||||
trees.value
|
||||
.where(
|
||||
(tree) => availableTabs.value.any(
|
||||
(available) => available.tabId == tree.tabId,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
final tabStates = ref.watch(tabStatesProvider);
|
||||
|
||||
final filterOptions = ref.watch(tabViewFilterControllerProvider);
|
||||
|
||||
final pinnedTabIds = ref.watch(
|
||||
watchPinnedTabIdsProvider.select(
|
||||
(value) => value.value ?? const <String>{},
|
||||
),
|
||||
);
|
||||
|
||||
// Only pull timestamps from DB when date filtering/sorting is active
|
||||
final needsTimestamps =
|
||||
filterOptions.effectiveDateRange != null ||
|
||||
filterOptions.sortType.sortField == SortField.dateAsc ||
|
||||
filterOptions.sortType.sortField == SortField.dateDesc;
|
||||
final tabTimestamps = needsTimestamps
|
||||
? ref.watch(watchTabTimestampsProvider.select((value) => value.value))
|
||||
: null;
|
||||
|
||||
if (tabSearchResults == null) {
|
||||
if (filterOptions.hasActiveFilter || pinnedTabIds.isNotEmpty) {
|
||||
return EquatableValue(
|
||||
trees.value
|
||||
.where(
|
||||
(tree) => availableTabs.value.any(
|
||||
(available) => available.tabId == tree.tabId,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
_applyTabFiltersAndSort(
|
||||
availableTabs.value,
|
||||
filterOptions,
|
||||
tabStates,
|
||||
pinnedTabIds,
|
||||
tabTimestamps,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return availableTabs;
|
||||
}
|
||||
|
||||
return EquatableValue(
|
||||
tabSearchResults
|
||||
.where(
|
||||
(tab) => availableTabs.value.any(
|
||||
(available) => available.tabId == tab.tabId,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
final searchFiltered = tabSearchResults
|
||||
.where(
|
||||
(tab) => availableTabs.value.any(
|
||||
(available) => available.tabId == tab.tabId,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (filterOptions.hasActiveFilter || pinnedTabIds.isNotEmpty) {
|
||||
return EquatableValue(
|
||||
_applyTabFiltersAndSort(
|
||||
searchFiltered,
|
||||
filterOptions,
|
||||
tabStates,
|
||||
pinnedTabIds,
|
||||
tabTimestamps,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return EquatableValue(searchFiltered);
|
||||
}
|
||||
|
||||
@Riverpod()
|
||||
|
||||
@@ -9,6 +9,48 @@ part of 'providers.dart';
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(canManualTabReorder)
|
||||
final canManualTabReorderProvider = CanManualTabReorderProvider._();
|
||||
|
||||
final class CanManualTabReorderProvider
|
||||
extends $FunctionalProvider<bool, bool, bool>
|
||||
with $Provider<bool> {
|
||||
CanManualTabReorderProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'canManualTabReorderProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$canManualTabReorderHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
bool create(Ref ref) {
|
||||
return canManualTabReorder(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(bool value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<bool>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$canManualTabReorderHash() =>
|
||||
r'ba5d961933464b6e005d7945802908a9a4ae034b';
|
||||
|
||||
@ProviderFor(SelectedBangTrigger)
|
||||
final selectedBangTriggerProvider = SelectedBangTriggerFamily._();
|
||||
|
||||
@@ -269,7 +311,7 @@ final class ContainerTabEntitiesProvider
|
||||
}
|
||||
|
||||
String _$containerTabEntitiesHash() =>
|
||||
r'350b3e2a2672ab0a303344c7d8301af24c991ee5';
|
||||
r'bcd932968bae46fb5e27a60819ea7aad7a8e41e7';
|
||||
|
||||
final class ContainerTabEntitiesFamily extends $Family
|
||||
with
|
||||
@@ -487,7 +529,7 @@ final class SelectedContainerTabStatesWithContainerProvider
|
||||
}
|
||||
|
||||
String _$selectedContainerTabStatesWithContainerHash() =>
|
||||
r'2fd5e12595b3aced1e235c9626073c34655d57fa';
|
||||
r'e2dc85fc72ee29736d8fdaabb1abadd23cc4bac0';
|
||||
|
||||
@ProviderFor(quickTabSwitcherTabStates)
|
||||
final quickTabSwitcherTabStatesProvider = QuickTabSwitcherTabStatesFamily._();
|
||||
@@ -816,7 +858,7 @@ final class SuggestedTabEntitiesProvider
|
||||
}
|
||||
|
||||
String _$suggestedTabEntitiesHash() =>
|
||||
r'1c3d9a4f85db60301fe5c5b55fb55512d1b59696';
|
||||
r'b140a1d0badad3d976b91ab9154300873a8bd4e9';
|
||||
|
||||
final class SuggestedTabEntitiesFamily extends $Family
|
||||
with $FunctionalFamilyOverride<EquatableValue<List<TabEntity>>, String?> {
|
||||
@@ -920,7 +962,7 @@ final class SeamlessFilteredTabEntitiesProvider
|
||||
}
|
||||
|
||||
String _$seamlessFilteredTabEntitiesHash() =>
|
||||
r'b0477aa6297183575264d54a82575dfb01fa4c32';
|
||||
r'bc6833f975e9af3b2117268d7644dc56131c85a3';
|
||||
|
||||
final class SeamlessFilteredTabEntitiesFamily extends $Family
|
||||
with
|
||||
|
||||
+42
@@ -22,8 +22,10 @@ import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:riverpod/experimental/persist.dart';
|
||||
import 'package:riverpod_annotation/experimental/json_persist.dart';
|
||||
import 'package:riverpod_annotation/experimental/persist.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/domain/entities/tab_view_filter_options.dart';
|
||||
import 'package:weblibre/features/user/data/providers.dart';
|
||||
|
||||
part 'tab_view_controllers.g.dart';
|
||||
@@ -63,6 +65,46 @@ class TabsViewModeController extends _$TabsViewModeController {
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
@JsonPersist()
|
||||
class TabViewFilterController extends _$TabViewFilterController {
|
||||
void setTabTypeFilter(TabTypeFilter filter) {
|
||||
state = state.copyWith.tabTypeFilter(filter);
|
||||
}
|
||||
|
||||
void setSortType(TabSortType sort) {
|
||||
state = state.copyWith.sortType(sort);
|
||||
}
|
||||
|
||||
void setSortPinnedFirst(bool value) {
|
||||
state = state.copyWith(sortPinnedFirst: value);
|
||||
}
|
||||
|
||||
void setDateRange(DateTimeRange<DateTime>? range) {
|
||||
// ignore: avoid_redundant_argument_values
|
||||
state = state.copyWith(dateRange: range, quickInterval: null);
|
||||
}
|
||||
|
||||
void setQuickInterval(TabQuickInterval? interval) {
|
||||
// ignore: avoid_redundant_argument_values
|
||||
state = state.copyWith(quickInterval: interval, dateRange: null);
|
||||
}
|
||||
|
||||
void reset() {
|
||||
state = TabViewFilterOptions.withDefaults();
|
||||
}
|
||||
|
||||
@override
|
||||
TabViewFilterOptions build() {
|
||||
persist(
|
||||
ref.watch(riverpodDatabaseStorageProvider),
|
||||
key: 'TabViewFilterOptions',
|
||||
);
|
||||
|
||||
return stateOrNull ?? TabViewFilterOptions.withDefaults();
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod()
|
||||
class TabsReorderableController extends _$TabsReorderableController {
|
||||
void toggle() {
|
||||
|
||||
+94
@@ -62,6 +62,63 @@ abstract class _$TabsViewModeController extends $Notifier<TabsViewMode> {
|
||||
}
|
||||
}
|
||||
|
||||
@ProviderFor(TabViewFilterController)
|
||||
@JsonPersist()
|
||||
final tabViewFilterControllerProvider = TabViewFilterControllerProvider._();
|
||||
|
||||
@JsonPersist()
|
||||
final class TabViewFilterControllerProvider
|
||||
extends $NotifierProvider<TabViewFilterController, TabViewFilterOptions> {
|
||||
TabViewFilterControllerProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'tabViewFilterControllerProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$tabViewFilterControllerHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
TabViewFilterController create() => TabViewFilterController();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(TabViewFilterOptions value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<TabViewFilterOptions>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$tabViewFilterControllerHash() =>
|
||||
r'95e8a03d60ebe05e0c5df38f810f951a4fe2ed78';
|
||||
|
||||
@JsonPersist()
|
||||
abstract class _$TabViewFilterControllerBase
|
||||
extends $Notifier<TabViewFilterOptions> {
|
||||
TabViewFilterOptions build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<TabViewFilterOptions, TabViewFilterOptions>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<TabViewFilterOptions, TabViewFilterOptions>,
|
||||
TabViewFilterOptions,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
|
||||
@ProviderFor(TabsReorderableController)
|
||||
final tabsReorderableControllerProvider = TabsReorderableControllerProvider._();
|
||||
|
||||
@@ -114,3 +171,40 @@ abstract class _$TabsReorderableController extends $Notifier<bool> {
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// JsonGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
abstract class _$TabViewFilterController extends _$TabViewFilterControllerBase {
|
||||
/// The default key used by [persist].
|
||||
String get key {
|
||||
const resolvedKey = "TabViewFilterController";
|
||||
return resolvedKey;
|
||||
}
|
||||
|
||||
/// A variant of [persist], for JSON-specific encoding.
|
||||
///
|
||||
/// You can override [key] to customize the key used for storage.
|
||||
PersistResult persist(
|
||||
FutureOr<Storage<String, String>> storage, {
|
||||
String? key,
|
||||
String Function(TabViewFilterOptions state)? encode,
|
||||
TabViewFilterOptions Function(String encoded)? decode,
|
||||
StorageOptions options = const StorageOptions(),
|
||||
}) {
|
||||
return NotifierPersistX(this).persist<String, String>(
|
||||
storage,
|
||||
key: key ?? this.key,
|
||||
encode: encode ?? $jsonCodex.encode,
|
||||
decode:
|
||||
decode ??
|
||||
(encoded) {
|
||||
final e = $jsonCodex.decode(encoded);
|
||||
return TabViewFilterOptions.fromJson(e as Map<String, Object?>);
|
||||
},
|
||||
options: options,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,7 +291,6 @@ class BrowserScreen extends HookConsumerWidget {
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
ref.listen(overlayControllerProvider, (previous, next) {
|
||||
if (next != null) {
|
||||
overlayController.show();
|
||||
@@ -872,7 +871,10 @@ class _Browser extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
// Dismiss modal routes (e.g. showModalBottomSheet)
|
||||
final rootNavigator = Navigator.of(context, rootNavigator: true);
|
||||
final rootNavigator = Navigator.of(
|
||||
context,
|
||||
rootNavigator: true,
|
||||
);
|
||||
if (rootNavigator.canPop()) {
|
||||
rootNavigator.pop();
|
||||
return true;
|
||||
|
||||
+10
-3
@@ -1368,7 +1368,9 @@ class _ExtensionsCard extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final addonService = ref.watch(addonServiceProvider);
|
||||
final extensionsExpanded = ref.watch(persistedBoolProvider(PersistedBoolKey.extensionsExpanded));
|
||||
final extensionsExpanded = ref.watch(
|
||||
persistedBoolProvider(PersistedBoolKey.extensionsExpanded),
|
||||
);
|
||||
final pageExtensions = ref.watch(
|
||||
webExtensionsStateProvider(
|
||||
WebExtensionActionType.page,
|
||||
@@ -1393,8 +1395,13 @@ class _ExtensionsCard extends HookConsumerWidget {
|
||||
leading: const Icon(MdiIcons.puzzle),
|
||||
title: const Text('Extensions'),
|
||||
initiallyExpanded: extensionsExpanded,
|
||||
onExpansionChanged: (_) =>
|
||||
ref.read(persistedBoolProvider(PersistedBoolKey.extensionsExpanded).notifier).toggle(),
|
||||
onExpansionChanged: (_) => ref
|
||||
.read(
|
||||
persistedBoolProvider(
|
||||
PersistedBoolKey.extensionsExpanded,
|
||||
).notifier,
|
||||
)
|
||||
.toggle(),
|
||||
children: [
|
||||
// Page extensions
|
||||
if (pageExtensions.isNotEmpty) ...[
|
||||
|
||||
+57
-11
@@ -20,6 +20,7 @@
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fast_equatable/fast_equatable.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
@@ -369,15 +370,39 @@ class BrowserTabBar extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
typedef _QuickTabItem = ({
|
||||
Color? color,
|
||||
String id,
|
||||
TabMode tabMode,
|
||||
bool isHistory,
|
||||
String title,
|
||||
Uri url,
|
||||
TabState? tabState,
|
||||
});
|
||||
class _QuickTabItem with FastEquatable {
|
||||
final Color? color;
|
||||
final String id;
|
||||
final TabMode tabMode;
|
||||
final bool isHistory;
|
||||
final bool isPinned;
|
||||
final String title;
|
||||
final Uri url;
|
||||
final TabState? tabState;
|
||||
|
||||
_QuickTabItem({
|
||||
required this.color,
|
||||
required this.id,
|
||||
required this.tabMode,
|
||||
required this.isHistory,
|
||||
required this.isPinned,
|
||||
required this.title,
|
||||
required this.url,
|
||||
required this.tabState,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [
|
||||
color,
|
||||
id,
|
||||
tabMode,
|
||||
isHistory,
|
||||
isPinned,
|
||||
title,
|
||||
url,
|
||||
tabState,
|
||||
];
|
||||
}
|
||||
|
||||
class ContextualToolbar extends HookConsumerWidget {
|
||||
const ContextualToolbar({
|
||||
@@ -443,6 +468,14 @@ class QuickTabSwitcher extends HookConsumerWidget {
|
||||
(s) => s.quickTabSwitcherShowTitles,
|
||||
),
|
||||
);
|
||||
final effectiveMode = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(settings) => settings.effectiveUiQuickTabSwitcherMode(),
|
||||
),
|
||||
);
|
||||
final pinnedTabIds = ref.watch(
|
||||
watchPinnedTabIdsProvider.select((value) => value.value),
|
||||
);
|
||||
final tabStates = ref.watch(
|
||||
quickTabSwitcherTabStatesProvider(quickTabSwitcherMode),
|
||||
);
|
||||
@@ -451,11 +484,12 @@ class QuickTabSwitcher extends HookConsumerWidget {
|
||||
.value;
|
||||
final availableItems = tabStates.value
|
||||
.map<_QuickTabItem>(
|
||||
(state) => (
|
||||
(state) => _QuickTabItem(
|
||||
id: state.$1.id,
|
||||
title: state.$1.titleOrAuthority,
|
||||
tabMode: state.$1.tabMode,
|
||||
isHistory: false,
|
||||
isPinned: pinnedTabIds?.contains(state.$1.id) ?? false,
|
||||
url: state.$1.url,
|
||||
color: state.$2?.color,
|
||||
tabState: state.$1,
|
||||
@@ -465,11 +499,12 @@ class QuickTabSwitcher extends HookConsumerWidget {
|
||||
(historySuggestions ?? []).map<_QuickTabItem>((state) {
|
||||
final url = Uri.parse(state.url);
|
||||
|
||||
return (
|
||||
return _QuickTabItem(
|
||||
id: state.url,
|
||||
title: state.title ?? url.authority,
|
||||
tabMode: TabMode.regular,
|
||||
isHistory: true,
|
||||
isPinned: false,
|
||||
url: url,
|
||||
color: null,
|
||||
tabState: null,
|
||||
@@ -496,6 +531,7 @@ class QuickTabSwitcher extends HookConsumerWidget {
|
||||
labelPadding: (item) =>
|
||||
(!showTitles &&
|
||||
!item.isHistory &&
|
||||
!item.isPinned &&
|
||||
item.tabMode is! PrivateTabMode &&
|
||||
item.tabMode is! IsolatedTabMode)
|
||||
? EdgeInsets.zero
|
||||
@@ -527,6 +563,15 @@ class QuickTabSwitcher extends HookConsumerWidget {
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
if (item.isPinned)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Icon(
|
||||
MdiIcons.pin,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
if (item.isHistory)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 8.0),
|
||||
@@ -575,6 +620,7 @@ class QuickTabSwitcher extends HookConsumerWidget {
|
||||
enableReloadButton: false,
|
||||
enableNavigationButtons: false,
|
||||
enableAddToHomeScreen: false,
|
||||
enablePinTab: effectiveMode == QuickTabSwitcherMode.containerTabs,
|
||||
builder: (context, controller, _) {
|
||||
return InkWell(
|
||||
onLongPress: () {
|
||||
|
||||
@@ -68,6 +68,7 @@ class TabMenu extends HookConsumerWidget {
|
||||
final bool enableShare;
|
||||
final bool enableExport;
|
||||
final bool enableCloseTab;
|
||||
final bool enablePinTab;
|
||||
final bool enableReloadButton;
|
||||
final bool enableNavigationButtons;
|
||||
|
||||
@@ -87,6 +88,7 @@ class TabMenu extends HookConsumerWidget {
|
||||
this.enableShare = true,
|
||||
this.enableExport = true,
|
||||
this.enableCloseTab = true,
|
||||
this.enablePinTab = true,
|
||||
this.enableReloadButton = true,
|
||||
this.enableNavigationButtons = true,
|
||||
});
|
||||
@@ -579,6 +581,31 @@ class TabMenu extends HookConsumerWidget {
|
||||
leadingIcon: const Icon(MdiIcons.fileExport),
|
||||
child: const Text('Export'),
|
||||
),
|
||||
if (enablePinTab)
|
||||
Consumer(
|
||||
builder: (context, childRef, child) {
|
||||
final isPinned = childRef.watch(
|
||||
watchPinnedTabIdsProvider.select(
|
||||
(v) => v.value?.contains(selectedTabId) ?? false,
|
||||
),
|
||||
);
|
||||
|
||||
return MenuItemButton(
|
||||
closeOnActivate: false,
|
||||
onPressed: () async {
|
||||
await childRef
|
||||
.read(tabDataRepositoryProvider.notifier)
|
||||
.setPinned(selectedTabId, pinned: !isPinned);
|
||||
|
||||
if (context.mounted) {
|
||||
MenuController.maybeOf(context)?.close();
|
||||
}
|
||||
},
|
||||
leadingIcon: Icon(isPinned ? MdiIcons.pinOff : MdiIcons.pin),
|
||||
child: Text(isPinned ? 'Unpin tab' : 'Pin tab'),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (enableCloseTab)
|
||||
MenuItemButton(
|
||||
onPressed: () async {
|
||||
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* 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 'dart:async';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/data/models/drag_data.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_menu.dart';
|
||||
import 'package:weblibre/presentation/hooks/menu_controller.dart';
|
||||
|
||||
/// Chrome-like tab context menu + drag coordination.
|
||||
///
|
||||
/// On long press: shows a context menu via [TabMenu].
|
||||
/// If user moves finger: closes menu, starts drag.
|
||||
/// If user releases without moving: menu persists.
|
||||
///
|
||||
/// For non-reorderable mode: wraps with [LongPressDraggable].
|
||||
/// For reorderable mode: uses manual long-press timer (drag handled externally).
|
||||
class TabContextMenuDraggable extends HookConsumerWidget {
|
||||
final String tabId;
|
||||
final TabDragData? data;
|
||||
final Widget child;
|
||||
|
||||
/// Size for the drag feedback widget.
|
||||
final Size feedbackSize;
|
||||
|
||||
/// Whether drag is handled externally (reorderable mode).
|
||||
/// When true, no [LongPressDraggable] is used; only menu + manual timer.
|
||||
final bool externalDrag;
|
||||
|
||||
const TabContextMenuDraggable({
|
||||
required this.tabId,
|
||||
required this.child,
|
||||
required this.feedbackSize,
|
||||
this.data,
|
||||
this.externalDrag = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final menuController = useMenuController();
|
||||
final isDragMoving = useMemoized(() => ValueNotifier(false));
|
||||
final startPosition = useRef(Offset.zero);
|
||||
final isDragging = useRef(false);
|
||||
final longPressTimer = useRef<Timer?>(null);
|
||||
|
||||
// Clean up ValueNotifier
|
||||
useEffect(() => isDragMoving.dispose, [isDragMoving]);
|
||||
|
||||
if (externalDrag) {
|
||||
return _buildReorderableMode(
|
||||
context,
|
||||
menuController: menuController,
|
||||
startPosition: startPosition,
|
||||
longPressTimer: longPressTimer,
|
||||
);
|
||||
}
|
||||
|
||||
return _buildDraggableMode(
|
||||
context,
|
||||
menuController: menuController,
|
||||
isDragMoving: isDragMoving,
|
||||
startPosition: startPosition,
|
||||
isDragging: isDragging,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabMenu({
|
||||
required MenuController menuController,
|
||||
required Widget Function(BuildContext, MenuController, Widget?) builder,
|
||||
}) {
|
||||
return TabMenu(
|
||||
selectedTabId: tabId,
|
||||
controller: menuController,
|
||||
enableFindInPage: false,
|
||||
enableFetchFeeds: false,
|
||||
enableDesktopMode: false,
|
||||
enableReaderMode: false,
|
||||
enableReloadButton: false,
|
||||
enableNavigationButtons: false,
|
||||
enableAddToHomeScreen: false,
|
||||
enableCloseTab: false,
|
||||
builder: builder,
|
||||
);
|
||||
}
|
||||
|
||||
/// Non-reorderable mode: Listener + TabMenu + LongPressDraggable
|
||||
Widget _buildDraggableMode(
|
||||
BuildContext context, {
|
||||
required MenuController menuController,
|
||||
required ValueNotifier<bool> isDragMoving,
|
||||
required ObjectRef<Offset> startPosition,
|
||||
required ObjectRef<bool> isDragging,
|
||||
}) {
|
||||
return Listener(
|
||||
onPointerDown: (event) {
|
||||
startPosition.value = event.position;
|
||||
},
|
||||
onPointerMove: (event) {
|
||||
if (isDragging.value &&
|
||||
!isDragMoving.value &&
|
||||
(event.position - startPosition.value).distance > kTouchSlop) {
|
||||
isDragMoving.value = true;
|
||||
if (menuController.isOpen) {
|
||||
menuController.close();
|
||||
}
|
||||
}
|
||||
},
|
||||
onPointerUp: (_) {
|
||||
// Reset drag tracking on pointer up (for cases where drag wasn't started)
|
||||
isDragging.value = false;
|
||||
},
|
||||
child: _buildTabMenu(
|
||||
menuController: menuController,
|
||||
builder: (context, controller, _) {
|
||||
return LongPressDraggable<TabDragData>(
|
||||
data: data,
|
||||
onDragStarted: () {
|
||||
isDragging.value = true;
|
||||
isDragMoving.value = false;
|
||||
menuController.open();
|
||||
},
|
||||
onDragEnd: (_) {
|
||||
isDragging.value = false;
|
||||
isDragMoving.value = false;
|
||||
},
|
||||
onDraggableCanceled: (_, _) {
|
||||
isDragging.value = false;
|
||||
isDragMoving.value = false;
|
||||
},
|
||||
feedback: ValueListenableBuilder<bool>(
|
||||
valueListenable: isDragMoving,
|
||||
builder: (_, moving, feedbackChild) {
|
||||
if (!moving) return const SizedBox.shrink();
|
||||
return feedbackChild!;
|
||||
},
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Transform.scale(
|
||||
scale: 1.05,
|
||||
child: SizedBox(
|
||||
height: feedbackSize.height,
|
||||
width: feedbackSize.width,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
childWhenDragging: ValueListenableBuilder<bool>(
|
||||
valueListenable: isDragMoving,
|
||||
builder: (_, moving, _) {
|
||||
if (!moving) return child;
|
||||
return SizedBox(
|
||||
height: feedbackSize.height,
|
||||
width: feedbackSize.width,
|
||||
);
|
||||
},
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Reorderable mode: Listener + TabMenu (drag handled externally)
|
||||
Widget _buildReorderableMode(
|
||||
BuildContext context, {
|
||||
required MenuController menuController,
|
||||
required ObjectRef<Offset> startPosition,
|
||||
required ObjectRef<Timer?> longPressTimer,
|
||||
}) {
|
||||
return Listener(
|
||||
onPointerDown: (event) {
|
||||
startPosition.value = event.position;
|
||||
longPressTimer.value?.cancel();
|
||||
longPressTimer.value = Timer(kLongPressTimeout, () {
|
||||
menuController.open();
|
||||
});
|
||||
},
|
||||
onPointerMove: (event) {
|
||||
if ((event.position - startPosition.value).distance > kTouchSlop) {
|
||||
// User started moving - cancel menu timer and close menu if open
|
||||
longPressTimer.value?.cancel();
|
||||
longPressTimer.value = null;
|
||||
if (menuController.isOpen) {
|
||||
menuController.close();
|
||||
}
|
||||
}
|
||||
},
|
||||
onPointerUp: (_) {
|
||||
longPressTimer.value?.cancel();
|
||||
longPressTimer.value = null;
|
||||
},
|
||||
onPointerCancel: (_) {
|
||||
longPressTimer.value?.cancel();
|
||||
longPressTimer.value = null;
|
||||
},
|
||||
child: _buildTabMenu(
|
||||
menuController: menuController,
|
||||
builder: (context, controller, _) => child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+56
-32
@@ -37,6 +37,7 @@ import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_context_menu_draggable.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_drop_target.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart';
|
||||
@@ -141,6 +142,8 @@ class _TabGridView extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final canManualReorder = ref.watch(canManualTabReorderProvider);
|
||||
final reorderEnabled = tabsReorderable && canManualReorder;
|
||||
|
||||
final containerId = ref.watch(selectedContainerProvider);
|
||||
|
||||
@@ -154,7 +157,9 @@ class _TabGridView extends HookConsumerWidget {
|
||||
),
|
||||
);
|
||||
|
||||
final tabSuggestionsEnabled = ref.watch(persistedBoolProvider(PersistedBoolKey.tabSuggestions));
|
||||
final tabSuggestionsEnabled = ref.watch(
|
||||
persistedBoolProvider(PersistedBoolKey.tabSuggestions),
|
||||
);
|
||||
|
||||
final suggestedTabEntities = tabSuggestionsEnabled
|
||||
? ref.watch(suggestedTabEntitiesProvider(containerId))
|
||||
@@ -164,6 +169,9 @@ class _TabGridView extends HookConsumerWidget {
|
||||
filteredTabEntities.value.length +
|
||||
//Limit to 3 sugegstions for now
|
||||
math.min<int>(suggestedTabEntities.value.length, 3);
|
||||
final displayItemCount = reorderEnabled
|
||||
? filteredTabEntities.value.length
|
||||
: itemCount;
|
||||
|
||||
final activeTab = ref.watch(selectedTabProvider);
|
||||
|
||||
@@ -239,34 +247,22 @@ class _TabGridView extends HookConsumerWidget {
|
||||
fadingSize: 5,
|
||||
controller: scrollController,
|
||||
builder: (context, controller) {
|
||||
return !tabsReorderable
|
||||
return !reorderEnabled
|
||||
? _TabGrid(
|
||||
key: ValueKey(crossAxisCount),
|
||||
crossAxisCount: crossAxisCount,
|
||||
itemCount: itemCount,
|
||||
itemCount: displayItemCount,
|
||||
scrollController: controller,
|
||||
itemBuilder: (widget, _) {
|
||||
if (widget is CustomDraggable) {
|
||||
return LongPressDraggable(
|
||||
feedback: Material(
|
||||
color: Colors
|
||||
.transparent, // removes white corners when having shadow
|
||||
child: Transform.scale(
|
||||
scale: 1.05,
|
||||
child: SizedBox(
|
||||
height: itemSize.height,
|
||||
width: itemSize.width,
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
),
|
||||
data: widget.data,
|
||||
childWhenDragging: SizedBox(
|
||||
height: itemSize.height,
|
||||
width: itemSize.width,
|
||||
),
|
||||
child: widget.child,
|
||||
);
|
||||
if (widget.data case final TabDragData dragData) {
|
||||
return TabContextMenuDraggable(
|
||||
tabId: dragData.tabId,
|
||||
data: dragData,
|
||||
feedbackSize: itemSize,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return widget;
|
||||
@@ -280,7 +276,7 @@ class _TabGridView extends HookConsumerWidget {
|
||||
//Rebuild when cross axis count changes
|
||||
key: ValueKey(crossAxisCount),
|
||||
scrollController: controller,
|
||||
itemCount: itemCount,
|
||||
itemCount: displayItemCount,
|
||||
onDragStarted: (index) {
|
||||
ref.read(willAcceptDropProvider.notifier).clear();
|
||||
},
|
||||
@@ -307,25 +303,35 @@ class _TabGridView extends HookConsumerWidget {
|
||||
.read(tabDataRepositoryProvider.notifier)
|
||||
.getTabContainerId(tabId);
|
||||
|
||||
var targetIndex = newIndex;
|
||||
if (targetIndex > oldIndex) {
|
||||
targetIndex -= 1;
|
||||
}
|
||||
|
||||
targetIndex = targetIndex.clamp(
|
||||
0,
|
||||
filteredTabEntities.value.length - 1,
|
||||
);
|
||||
|
||||
final String key;
|
||||
if (newIndex <= 0) {
|
||||
if (targetIndex <= 0) {
|
||||
key = await containerRepository.getLeadingOrderKey(
|
||||
containerId,
|
||||
);
|
||||
} else if (newIndex >=
|
||||
} else if (targetIndex >=
|
||||
filteredTabEntities.value.length - 1) {
|
||||
key = await containerRepository.getTrailingOrderKey(
|
||||
containerId,
|
||||
);
|
||||
} else {
|
||||
if (newIndex < oldIndex) {
|
||||
if (targetIndex < oldIndex) {
|
||||
key = (await containerRepository.getOrderKeyAfterTab(
|
||||
filteredTabEntities.value[newIndex - 1].tabId,
|
||||
filteredTabEntities.value[targetIndex - 1].tabId,
|
||||
containerId,
|
||||
))!;
|
||||
} else {
|
||||
key = await containerRepository.getOrderKeyBeforeTab(
|
||||
filteredTabEntities.value[newIndex + 1].tabId,
|
||||
filteredTabEntities.value[targetIndex + 1].tabId,
|
||||
containerId,
|
||||
);
|
||||
}
|
||||
@@ -335,12 +341,30 @@ class _TabGridView extends HookConsumerWidget {
|
||||
.read(tabDataRepositoryProvider.notifier)
|
||||
.assignOrderKey(tabId, key);
|
||||
},
|
||||
childBuilder: (itemBuilder) {
|
||||
childBuilder: (reorderableItemBuilder) {
|
||||
return _TabGrid(
|
||||
crossAxisCount: crossAxisCount,
|
||||
itemCount: itemCount,
|
||||
itemCount: displayItemCount,
|
||||
scrollController: controller,
|
||||
itemBuilder: itemBuilder,
|
||||
itemBuilder: (widget, index) {
|
||||
// Wrap with context menu before passing to reorderable
|
||||
Widget wrapped = widget;
|
||||
if (widget is CustomDraggable) {
|
||||
if (widget.data case final TabDragData dragData) {
|
||||
wrapped = CustomDraggable(
|
||||
key: widget.key!,
|
||||
data: widget.data,
|
||||
child: TabContextMenuDraggable(
|
||||
tabId: dragData.tabId,
|
||||
feedbackSize: Size.zero,
|
||||
externalDrag: true,
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return reorderableItemBuilder(wrapped, index);
|
||||
},
|
||||
suggestedContainerId: containerId,
|
||||
filteredTabEntities: filteredTabEntities,
|
||||
suggestedTabEntities: suggestedTabEntities,
|
||||
|
||||
+49
-53
@@ -36,6 +36,7 @@ import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_context_menu_draggable.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_drop_target.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart';
|
||||
@@ -147,7 +148,7 @@ class _TabListView extends HookConsumerWidget {
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
static const _itemHeight = 80.0;
|
||||
static const _itemHeight = 86.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -204,6 +205,8 @@ class _TabListView extends HookConsumerWidget {
|
||||
|
||||
Widget _buildLocalTabsView(BuildContext context, WidgetRef ref) {
|
||||
final containerId = ref.watch(selectedContainerProvider);
|
||||
final canManualReorder = ref.watch(canManualTabReorderProvider);
|
||||
final reorderEnabled = tabsReorderable && canManualReorder;
|
||||
|
||||
final filteredTabEntities = ref.watch(
|
||||
seamlessFilteredTabEntitiesProvider(
|
||||
@@ -215,7 +218,9 @@ class _TabListView extends HookConsumerWidget {
|
||||
),
|
||||
);
|
||||
|
||||
final tabSuggestionsEnabled = ref.watch(persistedBoolProvider(PersistedBoolKey.tabSuggestions));
|
||||
final tabSuggestionsEnabled = ref.watch(
|
||||
persistedBoolProvider(PersistedBoolKey.tabSuggestions),
|
||||
);
|
||||
|
||||
final suggestedTabEntities = tabSuggestionsEnabled
|
||||
? ref.watch(suggestedTabEntitiesProvider(containerId))
|
||||
@@ -225,6 +230,9 @@ class _TabListView extends HookConsumerWidget {
|
||||
filteredTabEntities.value.length +
|
||||
//Limit to 3 sugegstions for now
|
||||
math.min<int>(suggestedTabEntities.value.length, 3);
|
||||
final displayItemCount = reorderEnabled
|
||||
? filteredTabEntities.value.length
|
||||
: itemCount;
|
||||
|
||||
final activeTab = ref.watch(selectedTabProvider);
|
||||
|
||||
@@ -282,11 +290,11 @@ class _TabListView extends HookConsumerWidget {
|
||||
fadingSize: 5,
|
||||
controller: scrollController,
|
||||
builder: (context, controller) {
|
||||
return !tabsReorderable
|
||||
return !reorderEnabled
|
||||
? ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
controller: scrollController,
|
||||
itemCount: itemCount,
|
||||
itemCount: displayItemCount,
|
||||
itemExtent: _itemHeight,
|
||||
itemBuilder: (context, index) {
|
||||
final TabEntity entity;
|
||||
@@ -322,29 +330,23 @@ class _TabListView extends HookConsumerWidget {
|
||||
return TabDropTarget(
|
||||
targetTabId: entity.tabId,
|
||||
enabled: suggestedId == null,
|
||||
child: LongPressDraggable(
|
||||
feedback: Material(
|
||||
color: Colors
|
||||
.transparent, // removes white corners when having shadow
|
||||
child: Transform.scale(
|
||||
scale: 1.05,
|
||||
child: SizedBox(
|
||||
height: _itemHeight,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
child: tab.data is TabDragData
|
||||
? TabContextMenuDraggable(
|
||||
tabId: (tab.data! as TabDragData).tabId,
|
||||
data: tab.data! as TabDragData,
|
||||
feedbackSize: Size(
|
||||
MediaQuery.of(context).size.width,
|
||||
_itemHeight,
|
||||
),
|
||||
child: tab.child,
|
||||
),
|
||||
),
|
||||
),
|
||||
data: tab.data,
|
||||
childWhenDragging: const SizedBox(height: _itemHeight),
|
||||
child: tab.child,
|
||||
),
|
||||
)
|
||||
: tab.child,
|
||||
);
|
||||
},
|
||||
)
|
||||
: ReorderableListView.builder(
|
||||
scrollController: controller,
|
||||
itemCount: itemCount,
|
||||
itemCount: displayItemCount,
|
||||
itemExtent: _itemHeight,
|
||||
onReorderStart: (index) {
|
||||
ref.read(willAcceptDropProvider.notifier).clear();
|
||||
@@ -364,25 +366,35 @@ class _TabListView extends HookConsumerWidget {
|
||||
.read(tabDataRepositoryProvider.notifier)
|
||||
.getTabContainerId(tabId);
|
||||
|
||||
var targetIndex = newIndex;
|
||||
if (targetIndex > oldIndex) {
|
||||
targetIndex -= 1;
|
||||
}
|
||||
|
||||
targetIndex = targetIndex.clamp(
|
||||
0,
|
||||
filteredTabEntities.value.length - 1,
|
||||
);
|
||||
|
||||
final String key;
|
||||
if (newIndex <= 0) {
|
||||
if (targetIndex <= 0) {
|
||||
key = await containerRepository.getLeadingOrderKey(
|
||||
containerId,
|
||||
);
|
||||
} else if (newIndex >=
|
||||
} else if (targetIndex >=
|
||||
filteredTabEntities.value.length - 1) {
|
||||
key = await containerRepository.getTrailingOrderKey(
|
||||
containerId,
|
||||
);
|
||||
} else {
|
||||
if (newIndex < oldIndex) {
|
||||
if (targetIndex < oldIndex) {
|
||||
key = (await containerRepository.getOrderKeyAfterTab(
|
||||
filteredTabEntities.value[newIndex - 1].tabId,
|
||||
filteredTabEntities.value[targetIndex - 1].tabId,
|
||||
containerId,
|
||||
))!;
|
||||
} else {
|
||||
key = await containerRepository.getOrderKeyBeforeTab(
|
||||
filteredTabEntities.value[newIndex + 1].tabId,
|
||||
filteredTabEntities.value[targetIndex + 1].tabId,
|
||||
containerId,
|
||||
);
|
||||
}
|
||||
@@ -393,17 +405,20 @@ class _TabListView extends HookConsumerWidget {
|
||||
.assignOrderKey(tabId, key);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final CustomDraggable tab;
|
||||
|
||||
if (index < filteredTabEntities.value.length) {
|
||||
final entity = filteredTabEntities.value[index];
|
||||
tab = CustomDraggable(
|
||||
return CustomDraggable(
|
||||
key: Key(entity.tabId),
|
||||
data: TabDragData(entity.tabId),
|
||||
child: _TabDraggable(
|
||||
entity: entity,
|
||||
onClose: onClose,
|
||||
height: _itemHeight,
|
||||
child: TabContextMenuDraggable(
|
||||
tabId: entity.tabId,
|
||||
feedbackSize: Size.zero,
|
||||
externalDrag: true,
|
||||
child: _TabDraggable(
|
||||
entity: entity,
|
||||
onClose: onClose,
|
||||
height: _itemHeight,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -411,7 +426,7 @@ class _TabListView extends HookConsumerWidget {
|
||||
index - filteredTabEntities.value.length;
|
||||
final entity = suggestedTabEntities.value[suggestedIndex];
|
||||
|
||||
tab = CustomDraggable(
|
||||
return CustomDraggable(
|
||||
key: Key('suggested_${entity.tabId}'),
|
||||
child: _TabDraggable(
|
||||
entity: entity,
|
||||
@@ -421,25 +436,6 @@ class _TabListView extends HookConsumerWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// return LongPressDraggable(
|
||||
// key: tab.key,
|
||||
// feedback: Material(
|
||||
// color: Colors
|
||||
// .transparent, // removes white corners when having shadow
|
||||
// child: Transform.scale(
|
||||
// scale: 1.05,
|
||||
// child: SizedBox(
|
||||
// height: _itemHeight,
|
||||
// width: MediaQuery.of(context).size.width,
|
||||
// child: tab.child,
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// data: tab.data,
|
||||
// child: tab.child,
|
||||
// );
|
||||
return tab;
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
+388
-223
@@ -32,6 +32,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/widget
|
||||
import 'package:weblibre/features/geckoview/features/find_in_page/domain/entities/find_in_page_state.dart';
|
||||
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/presentation/hooks/menu_controller.dart';
|
||||
@@ -73,29 +74,46 @@ class GridTabItemContainer extends StatelessWidget {
|
||||
super.key,
|
||||
});
|
||||
|
||||
static const borderRadius = BorderRadius.all(Radius.circular(12.0));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final appColors = AppColors.of(context);
|
||||
|
||||
final bgColor = switch (tabMode) {
|
||||
PrivateTabMode() => appColors.privateTabBackground,
|
||||
IsolatedTabMode() => appColors.isolatedTabBackground,
|
||||
RegularTabMode() => colorScheme.surfaceContainerHighest,
|
||||
PrivateTabMode() => appColors.privateTabBackground.withAlpha(80),
|
||||
IsolatedTabMode() => appColors.isolatedTabBackground.withAlpha(80),
|
||||
RegularTabMode() => colorScheme.surfaceContainerHigh,
|
||||
};
|
||||
|
||||
final borderWidth = isActive ? 4.0 : 3.0;
|
||||
final innerRadius = BorderRadius.all(Radius.circular(12.0 - borderWidth));
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isActive ? colorScheme.primary : colorScheme.outline,
|
||||
width: isActive ? 2.0 : 1.0,
|
||||
color: isActive
|
||||
? colorScheme.primary
|
||||
: colorScheme.outline.withAlpha(40),
|
||||
width: borderWidth,
|
||||
),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
|
||||
borderRadius: borderRadius,
|
||||
boxShadow: isActive
|
||||
? [
|
||||
BoxShadow(
|
||||
color: colorScheme.primary.withAlpha(60),
|
||||
blurRadius: 8.0,
|
||||
spreadRadius: 1.0,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Material(
|
||||
color: bgColor,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(14.0)),
|
||||
child: child,
|
||||
color: colorScheme.surface,
|
||||
borderRadius: innerRadius,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Material(color: bgColor, child: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -107,10 +125,11 @@ class GridTabPreview extends HookConsumerWidget {
|
||||
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onDoubleTap;
|
||||
final VoidCallback? onLongPress;
|
||||
final VoidCallback? onDelete;
|
||||
final void Function(String host)? onDeleteAll;
|
||||
|
||||
final bool showPinBadge;
|
||||
|
||||
final Widget? trailingChild;
|
||||
|
||||
const GridTabPreview({
|
||||
@@ -118,15 +137,17 @@ class GridTabPreview extends HookConsumerWidget {
|
||||
required this.isActive,
|
||||
this.onTap,
|
||||
this.onDoubleTap,
|
||||
this.onLongPress,
|
||||
this.onDelete,
|
||||
this.onDeleteAll,
|
||||
this.showPinBadge = false,
|
||||
this.trailingChild,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final appColors = AppColors.of(context);
|
||||
|
||||
final tabState =
|
||||
@@ -139,137 +160,232 @@ class GridTabPreview extends HookConsumerWidget {
|
||||
|
||||
final extendedDeleteMenuController = useMenuController();
|
||||
|
||||
// ignore: avoid_bool_literals_in_conditional_expressions
|
||||
final isPinned = showPinBadge
|
||||
? ref.watch(
|
||||
watchPinnedTabIdsProvider.select(
|
||||
(v) => v.value?.contains(tabId) ?? false,
|
||||
),
|
||||
)
|
||||
: false;
|
||||
|
||||
final modeTextColor = switch (tabState.tabMode) {
|
||||
PrivateTabMode() => appColors.privateTabForeground,
|
||||
IsolatedTabMode() => appColors.isolatedTabForeground,
|
||||
RegularTabMode() => null,
|
||||
};
|
||||
|
||||
final subtitleColor = switch (tabState.tabMode) {
|
||||
PrivateTabMode() => Color.lerp(
|
||||
appColors.privateTabPurple,
|
||||
Colors.white,
|
||||
0.4,
|
||||
)!,
|
||||
IsolatedTabMode() => Color.lerp(
|
||||
appColors.isolatedTabTeal,
|
||||
Colors.white,
|
||||
0.4,
|
||||
)!,
|
||||
RegularTabMode() => colorScheme.onSurfaceVariant,
|
||||
};
|
||||
|
||||
final (modeBadgeIcon, modeBadgeColor) = switch (tabState.tabMode) {
|
||||
PrivateTabMode() => (MdiIcons.dominoMask, appColors.privateTabPurple),
|
||||
IsolatedTabMode() => (MdiIcons.snowflake, appColors.isolatedTabTeal),
|
||||
RegularTabMode() => (null, null),
|
||||
};
|
||||
|
||||
return GridTabItemContainer(
|
||||
isActive: isActive,
|
||||
tabMode: tabState.tabMode,
|
||||
child: InkWell(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(14.0)),
|
||||
borderRadius: GridTabItemContainer.borderRadius,
|
||||
onTap: onTap,
|
||||
onDoubleTap: onDoubleTap,
|
||||
onLongPress: onLongPress,
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 6.0, top: 2.0),
|
||||
child: Text(
|
||||
overflow: TextOverflow.ellipsis,
|
||||
tabState.titleOrAuthority,
|
||||
maxLines: 2,
|
||||
style: modeTextColor != null
|
||||
? TextStyle(color: modeTextColor)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onDelete != null || onDeleteAll != null)
|
||||
MenuAnchor(
|
||||
controller: extendedDeleteMenuController,
|
||||
builder: (context, controller, child) {
|
||||
return child!;
|
||||
},
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
onPressed: () {
|
||||
onDeleteAll?.call(tabState.url.host);
|
||||
},
|
||||
|
||||
leadingIcon: const Icon(MdiIcons.closeBoxMultiple),
|
||||
child: Text('Close all from ${tabState.url.host}'),
|
||||
),
|
||||
],
|
||||
child: IconButton(
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4.0,
|
||||
vertical: -4.0,
|
||||
),
|
||||
onPressed: onDelete,
|
||||
onLongPress: onDeleteAll != null
|
||||
? () {
|
||||
if (extendedDeleteMenuController.isOpen) {
|
||||
extendedDeleteMenuController.close();
|
||||
} else {
|
||||
extendedDeleteMenuController.open();
|
||||
}
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
),
|
||||
?trailingChild,
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const SizedBox(width: 6.0),
|
||||
TabIcon(tabState: tabState, iconSize: 16.0),
|
||||
const SizedBox(width: 6.0),
|
||||
Expanded(
|
||||
child: Text(
|
||||
tabState.url.authority,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: modeTextColor),
|
||||
),
|
||||
),
|
||||
if (tabState.tabMode is PrivateTabMode ||
|
||||
tabState.tabMode is IsolatedTabMode) ...[
|
||||
const SizedBox(width: 6.0),
|
||||
SizedBox(
|
||||
height: 16,
|
||||
width: 24,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Positioned(
|
||||
top: -4,
|
||||
child: Icon(
|
||||
tabState.tabMode is IsolatedTabMode
|
||||
? MdiIcons.snowflake
|
||||
: MdiIcons.dominoMask,
|
||||
color: tabState.tabMode is IsolatedTabMode
|
||||
? appColors.isolatedTabTeal
|
||||
: appColors.privateTabPurple,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 8.0),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
if (tabState.thumbnail != null && !tabState.thumbnail!.isDisposed)
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(14.0),
|
||||
bottomRight: Radius.circular(14.0),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: RepaintBoundary(
|
||||
// Thumbnail or icon area
|
||||
Expanded(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (tabState.thumbnail != null &&
|
||||
!tabState.thumbnail!.isDisposed)
|
||||
RepaintBoundary(
|
||||
child: SafeRawImage(
|
||||
image: tabState.thumbnail,
|
||||
fit: BoxFit.fitWidth,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
)
|
||||
else
|
||||
Center(child: TabIcon(tabState: tabState, iconSize: 48)),
|
||||
// Close button overlay
|
||||
if (onDelete != null || onDeleteAll != null)
|
||||
Positioned(
|
||||
top: 6.0,
|
||||
right: 6.0,
|
||||
child: MenuAnchor(
|
||||
controller: extendedDeleteMenuController,
|
||||
builder: (context, controller, child) {
|
||||
return child!;
|
||||
},
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
onPressed: () {
|
||||
onDeleteAll?.call(tabState.url.host);
|
||||
},
|
||||
leadingIcon: const Icon(MdiIcons.closeBoxMultiple),
|
||||
child: Text('Close all from ${tabState.url.host}'),
|
||||
),
|
||||
],
|
||||
child: SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: Material(
|
||||
color: colorScheme.surfaceContainerHighest
|
||||
.withAlpha(200),
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(8.0),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(8.0),
|
||||
),
|
||||
onTap: onDelete,
|
||||
onLongPress: onDeleteAll != null
|
||||
? () {
|
||||
if (extendedDeleteMenuController.isOpen) {
|
||||
extendedDeleteMenuController.close();
|
||||
} else {
|
||||
extendedDeleteMenuController.open();
|
||||
}
|
||||
}
|
||||
: null,
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
size: 16,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: Center(child: TabIcon(tabState: tabState, iconSize: 48)),
|
||||
if (trailingChild != null || isPinned)
|
||||
Positioned(
|
||||
top: 6.0,
|
||||
left: 6.0,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (trailingChild != null) trailingChild!,
|
||||
if (isPinned)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: trailingChild != null ? 4.0 : 0,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: Material(
|
||||
color: colorScheme.surfaceContainerHighest
|
||||
.withAlpha(200),
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(8.0),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(8.0),
|
||||
),
|
||||
onTap: () async {
|
||||
await ref
|
||||
.read(
|
||||
tabDataRepositoryProvider.notifier,
|
||||
)
|
||||
.setPinned(tabId, pinned: false);
|
||||
|
||||
if (context.mounted) {
|
||||
ui_helper.showInfoMessage(
|
||||
context,
|
||||
'Tab unpinned',
|
||||
action: SnackBarAction(
|
||||
label: 'Undo',
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(
|
||||
tabDataRepositoryProvider
|
||||
.notifier,
|
||||
)
|
||||
.setPinned(
|
||||
tabId,
|
||||
pinned: true,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Icon(
|
||||
MdiIcons.pin,
|
||||
size: 16,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Bottom info band
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withAlpha(120),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
tabState.titleOrAuthority,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: modeTextColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2.0),
|
||||
Row(
|
||||
children: [
|
||||
TabIcon(tabState: tabState, iconSize: 14.0),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
tabState.url.authority,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: subtitleColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (modeBadgeIcon != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(modeBadgeIcon, color: modeBadgeColor, size: 14),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -282,19 +398,20 @@ class ListTabPreview extends HookConsumerWidget {
|
||||
final bool isActive;
|
||||
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onLongPress;
|
||||
final VoidCallback? onDelete;
|
||||
final void Function(String host)? onDeleteAll;
|
||||
|
||||
final bool showPinBadge;
|
||||
|
||||
final Widget? trailingChild;
|
||||
|
||||
const ListTabPreview({
|
||||
required this.tabId,
|
||||
required this.isActive,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.onDelete,
|
||||
this.onDeleteAll,
|
||||
this.showPinBadge = false,
|
||||
this.trailingChild,
|
||||
super.key,
|
||||
});
|
||||
@@ -302,6 +419,7 @@ class ListTabPreview extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final appColors = AppColors.of(context);
|
||||
|
||||
final tabState =
|
||||
@@ -318,17 +436,30 @@ class ListTabPreview extends HookConsumerWidget {
|
||||
|
||||
final extendedDeleteMenuController = useMenuController();
|
||||
|
||||
// ignore: avoid_bool_literals_in_conditional_expressions
|
||||
final isPinned = showPinBadge
|
||||
? ref.watch(
|
||||
watchPinnedTabIdsProvider.select(
|
||||
(v) => v.value?.contains(tabId) ?? false,
|
||||
),
|
||||
)
|
||||
: false;
|
||||
|
||||
final leadingWidget = switch ((tabListShowFavicons, tabState.thumbnail)) {
|
||||
(false, final thumbnail?) when !thumbnail.isDisposed => RepaintBoundary(
|
||||
child: SafeRawImage(image: thumbnail, fit: BoxFit.fitHeight),
|
||||
(false, final thumbnail?) when !thumbnail.isDisposed => ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
|
||||
child: RepaintBoundary(
|
||||
child: SafeRawImage(image: thumbnail, fit: BoxFit.fitHeight),
|
||||
),
|
||||
),
|
||||
_ => TabIcon(tabState: tabState, iconSize: 32),
|
||||
};
|
||||
|
||||
final listBgColor = switch (tabState.tabMode) {
|
||||
PrivateTabMode() => appColors.privateTabBackground,
|
||||
IsolatedTabMode() => appColors.isolatedTabBackground,
|
||||
RegularTabMode() => null,
|
||||
PrivateTabMode() => appColors.privateTabBackground.withAlpha(80),
|
||||
IsolatedTabMode() => appColors.isolatedTabBackground.withAlpha(80),
|
||||
RegularTabMode() when isActive => colorScheme.primary.withAlpha(20),
|
||||
RegularTabMode() => Colors.transparent,
|
||||
};
|
||||
final listTextColor = switch (tabState.tabMode) {
|
||||
PrivateTabMode() => appColors.privateTabForeground,
|
||||
@@ -341,84 +472,128 @@ class ListTabPreview extends HookConsumerWidget {
|
||||
RegularTabMode() => (null, null),
|
||||
};
|
||||
|
||||
final subtitleColor = switch (tabState.tabMode) {
|
||||
PrivateTabMode() => Color.lerp(
|
||||
appColors.privateTabPurple,
|
||||
Colors.white,
|
||||
0.4,
|
||||
)!,
|
||||
IsolatedTabMode() => Color.lerp(
|
||||
appColors.isolatedTabTeal,
|
||||
Colors.white,
|
||||
0.4,
|
||||
)!,
|
||||
RegularTabMode() => colorScheme.onSurfaceVariant,
|
||||
};
|
||||
|
||||
const borderRadius = BorderRadius.all(Radius.circular(12.0));
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 3.0, horizontal: 4.0),
|
||||
decoration: BoxDecoration(
|
||||
color: listBgColor,
|
||||
border: isActive ? Border.all(color: colorScheme.primary) : null,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4.0)),
|
||||
borderRadius: borderRadius,
|
||||
border: isActive
|
||||
? Border(left: BorderSide(color: colorScheme.primary, width: 4.0))
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListTile(
|
||||
key: ValueKey(tabState.id),
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
contentPadding: const EdgeInsets.only(left: 4),
|
||||
leading: leadingWidget,
|
||||
title: Text(
|
||||
overflow: TextOverflow.ellipsis,
|
||||
tabState.titleOrAuthority,
|
||||
maxLines: 2,
|
||||
style: listTextColor != null
|
||||
? TextStyle(color: listTextColor)
|
||||
: null,
|
||||
),
|
||||
subtitle: Row(
|
||||
children: [
|
||||
if (modeBadgeIcon != null) ...[
|
||||
Icon(modeBadgeIcon, color: modeBadgeColor, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Expanded(
|
||||
child: UriBreadcrumb(
|
||||
uri: tabState.url,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: listTextColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (onDelete != null || onDeleteAll != null)
|
||||
MenuAnchor(
|
||||
controller: extendedDeleteMenuController,
|
||||
builder: (context, controller, child) {
|
||||
return child!;
|
||||
},
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
onPressed: () {
|
||||
onDeleteAll?.call(tabState.url.host);
|
||||
},
|
||||
|
||||
leadingIcon: const Icon(MdiIcons.closeBoxMultiple),
|
||||
child: Text('Close all from ${tabState.url.host}'),
|
||||
),
|
||||
],
|
||||
child: IconButton(
|
||||
onPressed: onDelete,
|
||||
onLongPress: onDeleteAll != null
|
||||
? () {
|
||||
if (extendedDeleteMenuController.isOpen) {
|
||||
extendedDeleteMenuController.close();
|
||||
} else {
|
||||
extendedDeleteMenuController.open();
|
||||
}
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.close),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: borderRadius,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
borderRadius: borderRadius,
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 12.0, top: 10.0, bottom: 10.0),
|
||||
child: Row(
|
||||
children: [
|
||||
leadingWidget,
|
||||
const SizedBox(width: 14.0),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
tabState.titleOrAuthority,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: listTextColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3.0),
|
||||
Row(
|
||||
children: [
|
||||
if (isPinned) ...[
|
||||
Icon(
|
||||
MdiIcons.pin,
|
||||
color: colorScheme.primary,
|
||||
size: 14,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
if (modeBadgeIcon != null) ...[
|
||||
Icon(
|
||||
modeBadgeIcon,
|
||||
color: modeBadgeColor,
|
||||
size: 14,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Expanded(
|
||||
child: UriBreadcrumb(
|
||||
uri: tabState.url,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: subtitleColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
?trailingChild,
|
||||
],
|
||||
if (onDelete != null || onDeleteAll != null)
|
||||
MenuAnchor(
|
||||
controller: extendedDeleteMenuController,
|
||||
builder: (context, controller, child) {
|
||||
return child!;
|
||||
},
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
onPressed: () {
|
||||
onDeleteAll?.call(tabState.url.host);
|
||||
},
|
||||
leadingIcon: const Icon(MdiIcons.closeBoxMultiple),
|
||||
child: Text('Close all from ${tabState.url.host}'),
|
||||
),
|
||||
],
|
||||
child: IconButton(
|
||||
onPressed: onDelete,
|
||||
onLongPress: onDeleteAll != null
|
||||
? () {
|
||||
if (extendedDeleteMenuController.isOpen) {
|
||||
extendedDeleteMenuController.close();
|
||||
} else {
|
||||
extendedDeleteMenuController.open();
|
||||
}
|
||||
}
|
||||
: null,
|
||||
icon: Icon(
|
||||
Icons.close,
|
||||
size: 20,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
?trailingChild,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -530,6 +705,7 @@ class SingleGridTabPreview extends HookConsumerWidget {
|
||||
child: GridTabPreview(
|
||||
tabId: tabId,
|
||||
isActive: tabId == activeTabId,
|
||||
showPinBadge: true,
|
||||
onTap: () async {
|
||||
if (tabId != activeTabId) {
|
||||
//Close first to avoid rebuilds
|
||||
@@ -563,15 +739,6 @@ class SingleGridTabPreview extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
},
|
||||
// onDoubleTap: () {
|
||||
// ref.read(overlayDialogControllerProvider.notifier).show(
|
||||
// TabActionDialog(
|
||||
// initialTab: tab,
|
||||
// onDismiss:
|
||||
// ref.read(overlayDialogControllerProvider.notifier).dismiss,
|
||||
// ),
|
||||
// );
|
||||
// },
|
||||
onDelete: () async {
|
||||
onBeforeDelete?.call();
|
||||
|
||||
@@ -655,6 +822,7 @@ class SingleListTabPreview extends HookConsumerWidget {
|
||||
child: ListTabPreview(
|
||||
tabId: tabId,
|
||||
isActive: tabId == activeTabId,
|
||||
showPinBadge: true,
|
||||
onTap: () async {
|
||||
if (tabId != activeTabId) {
|
||||
//Close first to avoid rebuilds
|
||||
@@ -688,15 +856,6 @@ class SingleListTabPreview extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
},
|
||||
// onDoubleTap: () {
|
||||
// ref.read(overlayDialogControllerProvider.notifier).show(
|
||||
// TabActionDialog(
|
||||
// initialTab: tab,
|
||||
// onDismiss:
|
||||
// ref.read(overlayDialogControllerProvider.notifier).dismiss,
|
||||
// ),
|
||||
// );
|
||||
// },
|
||||
onDelete: () async {
|
||||
onBeforeDelete?.call();
|
||||
|
||||
@@ -741,10 +900,16 @@ class SuggestedSingleGridTabPreview extends StatelessWidget {
|
||||
tabId: tabId,
|
||||
isActive: tabId == activeTabId,
|
||||
onTap: onTap,
|
||||
trailingChild: const IconButton(
|
||||
visualDensity: VisualDensity(horizontal: -4.0, vertical: -4.0),
|
||||
icon: Icon(MdiIcons.creation),
|
||||
onPressed: null,
|
||||
trailingChild: SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: Material(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest.withAlpha(200),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
|
||||
child: const Icon(MdiIcons.creation, size: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
+2
-13
@@ -97,24 +97,13 @@ class _TabTreePreview extends HookConsumerWidget {
|
||||
Badge.count(
|
||||
isLabelVisible: entity.totalTabs > 1,
|
||||
count: entity.totalTabs,
|
||||
alignment: AlignmentDirectional.bottomEnd,
|
||||
offset: const Offset(-8, -24),
|
||||
alignment: AlignmentDirectional.topStart,
|
||||
offset: const Offset(8, 8),
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
textColor: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
child: GridTabPreview(
|
||||
tabId: entity.tabId,
|
||||
isActive: entity.tabId == activeTabId,
|
||||
onLongPress: () async {
|
||||
if (entity.tabId != activeTabId) {
|
||||
//Close first to avoid rebuilds
|
||||
onClose();
|
||||
await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.selectTab(entity.tabId);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
onTap: () async {
|
||||
if (entity.totalTabs > 1) {
|
||||
await TabTreeRoute(entity.rootId).push(context);
|
||||
|
||||
+614
-281
@@ -25,6 +25,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:weblibre/core/design/app_colors.dart';
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/core/providers/persisted_bool.dart';
|
||||
@@ -34,6 +35,8 @@ import 'package:weblibre/features/geckoview/domain/providers.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/repositories/bookmarks.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/domain/entities/tab_view_filter_options.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_data.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/bookmark_all_dialog.dart';
|
||||
@@ -220,6 +223,7 @@ class TabViewHeader extends HookConsumerWidget {
|
||||
|
||||
final viewModeMenuController = useMenuController();
|
||||
final tabsActionMenuController = useMenuController();
|
||||
final filterMenuController = useMenuController();
|
||||
|
||||
final hasSearchText = useListenableSelector(
|
||||
searchTextController,
|
||||
@@ -248,6 +252,39 @@ class TabViewHeader extends HookConsumerWidget {
|
||||
(scope) => scope == TabsTrayScope.synced,
|
||||
),
|
||||
);
|
||||
final canManualTabReorder = ref.watch(canManualTabReorderProvider);
|
||||
|
||||
final tabsReorderable = ref.watch(tabsReorderableControllerProvider);
|
||||
|
||||
final canManualReorder =
|
||||
!isSyncedScope &&
|
||||
tabsViewMode != TabsViewMode.tree &&
|
||||
canManualTabReorder;
|
||||
|
||||
final didShowReorderDisabledInfo = useRef(false);
|
||||
|
||||
useEffect(() {
|
||||
if (tabsReorderable && !canManualReorder) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(tabsReorderableControllerProvider.notifier).hide();
|
||||
|
||||
if (!didShowReorderDisabledInfo.value) {
|
||||
didShowReorderDisabledInfo.value = true;
|
||||
|
||||
if (context.mounted) {
|
||||
ui_helper.showInfoMessage(
|
||||
context,
|
||||
'Tab reordering is only available in default manual mode',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
didShowReorderDisabledInfo.value = false;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [tabsReorderable, canManualReorder]);
|
||||
|
||||
useOnListenableChange(searchTextController, () async {
|
||||
if (ref.exists(tabSearchRepositoryProvider(TabSearchPartition.preview))) {
|
||||
@@ -269,307 +306,600 @@ class TabViewHeader extends HookConsumerWidget {
|
||||
children: [
|
||||
if (!searchMode.value)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(MdiIcons.tabSearch),
|
||||
iconSize: 18,
|
||||
padding: EdgeInsets.zero,
|
||||
tooltip: 'Search inside tabs',
|
||||
onPressed: () {
|
||||
switch (ref.read(tabsViewModeControllerProvider)) {
|
||||
case TabsViewMode.tree:
|
||||
case TabsViewMode.list:
|
||||
break;
|
||||
case TabsViewMode.grid:
|
||||
if (tabsViewMode != TabsViewMode.tree)
|
||||
IconButton(
|
||||
icon: const Icon(MdiIcons.tabSearch),
|
||||
iconSize: 18,
|
||||
padding: EdgeInsets.zero,
|
||||
tooltip: 'Search inside tabs',
|
||||
onPressed: () {
|
||||
searchMode.value = true;
|
||||
searchTextFocus.requestFocus();
|
||||
},
|
||||
),
|
||||
if (!isSyncedScope && tabsViewMode != TabsViewMode.tree)
|
||||
Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final filterOptions = ref.watch(
|
||||
tabViewFilterControllerProvider,
|
||||
);
|
||||
final hasActiveFilter = filterOptions.hasActiveFilter;
|
||||
|
||||
return MenuAnchor(
|
||||
controller: filterMenuController,
|
||||
consumeOutsideTap: true,
|
||||
menuChildren: [
|
||||
// Tab type filter
|
||||
SubmenuButton(
|
||||
leadingIcon: const Icon(MdiIcons.tabUnselected),
|
||||
menuChildren: TabTypeFilter.values.map((type) {
|
||||
final appColors = AppColors.of(context);
|
||||
final (icon, color) = switch (type) {
|
||||
TabTypeFilter.all => (
|
||||
MdiIcons.tabUnselected,
|
||||
null,
|
||||
),
|
||||
TabTypeFilter.regularOnly => (
|
||||
MdiIcons.tab,
|
||||
null,
|
||||
),
|
||||
TabTypeFilter.privateOnly => (
|
||||
MdiIcons.dominoMask,
|
||||
appColors.privateTabPurple,
|
||||
),
|
||||
TabTypeFilter.isolatedOnly => (
|
||||
MdiIcons.snowflake,
|
||||
appColors.isolatedTabTeal,
|
||||
),
|
||||
};
|
||||
|
||||
return MenuItemButton(
|
||||
leadingIcon: Icon(
|
||||
filterOptions.tabTypeFilter == type
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
),
|
||||
trailingIcon: Icon(icon, color: color),
|
||||
child: Text(type.label),
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(
|
||||
tabViewFilterControllerProvider
|
||||
.notifier,
|
||||
)
|
||||
.setTabTypeFilter(type);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
child: const Text('Tab Type'),
|
||||
),
|
||||
// Sort
|
||||
SubmenuButton(
|
||||
leadingIcon: const Icon(Icons.sort),
|
||||
menuChildren: [
|
||||
...TabSortType.values.map(
|
||||
(sort) => MenuItemButton(
|
||||
leadingIcon: Icon(
|
||||
filterOptions.sortType == sort
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
),
|
||||
child: Text(sort.label),
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(
|
||||
tabViewFilterControllerProvider
|
||||
.notifier,
|
||||
)
|
||||
.setSortType(sort);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
MenuItemButton(
|
||||
leadingIcon: Icon(
|
||||
filterOptions.sortPinnedFirst ||
|
||||
filterOptions
|
||||
.sortType
|
||||
.sortField ==
|
||||
null
|
||||
? Icons.check_box
|
||||
: Icons.check_box_outline_blank,
|
||||
),
|
||||
onPressed:
|
||||
filterOptions.sortType.sortField == null
|
||||
? null
|
||||
: () {
|
||||
ref
|
||||
.read(
|
||||
tabViewFilterControllerProvider
|
||||
.notifier,
|
||||
)
|
||||
.setSortPinnedFirst(
|
||||
!filterOptions
|
||||
.sortPinnedFirst,
|
||||
);
|
||||
},
|
||||
child: const Text('Sort Pinned First'),
|
||||
),
|
||||
],
|
||||
child: const Text('Sort'),
|
||||
),
|
||||
const Divider(),
|
||||
// Date range picker
|
||||
MenuItemButton(
|
||||
closeOnActivate: false,
|
||||
leadingIcon: const Icon(MdiIcons.calendarRange),
|
||||
trailingIcon: filterOptions.dateRange != null
|
||||
? IconButton(
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(
|
||||
tabViewFilterControllerProvider
|
||||
.notifier,
|
||||
)
|
||||
.setDateRange(null);
|
||||
},
|
||||
icon: const Icon(Icons.clear),
|
||||
)
|
||||
: null,
|
||||
child: filterOptions.dateRange != null
|
||||
? Text(
|
||||
'${DateFormat.yMd().format(filterOptions.dateRange!.start)} - ${DateFormat.yMd().format(filterOptions.dateRange!.end)}',
|
||||
)
|
||||
: const Text('Filter Date'),
|
||||
onPressed: () async {
|
||||
final range = await showDateRangePicker(
|
||||
context: context,
|
||||
initialDateRange: filterOptions.dateRange,
|
||||
firstDate: DateTime.now().subtract(
|
||||
const Duration(days: 365),
|
||||
),
|
||||
lastDate: DateTime.now(),
|
||||
);
|
||||
if (range != null) {
|
||||
ref
|
||||
.read(
|
||||
tabViewFilterControllerProvider
|
||||
.notifier,
|
||||
)
|
||||
.setDateRange(
|
||||
DateTimeRange(
|
||||
start: range.start,
|
||||
end: range.end.add(
|
||||
const Duration(days: 1) -
|
||||
const Duration(
|
||||
milliseconds: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
// Quick intervals
|
||||
SubmenuButton(
|
||||
leadingIcon: const Icon(MdiIcons.clockOutline),
|
||||
menuChildren: TabQuickInterval.values
|
||||
.map(
|
||||
(interval) => MenuItemButton(
|
||||
leadingIcon: Icon(
|
||||
filterOptions.quickInterval ==
|
||||
interval
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
),
|
||||
child: Text(interval.label),
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(
|
||||
tabViewFilterControllerProvider
|
||||
.notifier,
|
||||
)
|
||||
.setQuickInterval(
|
||||
filterOptions.quickInterval ==
|
||||
interval
|
||||
? null
|
||||
: interval,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
child: const Text('Quick Interval'),
|
||||
),
|
||||
const Divider(),
|
||||
// Reset
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(MdiIcons.restore),
|
||||
child: const Text('Reset Filter'),
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(
|
||||
tabViewFilterControllerProvider
|
||||
.notifier,
|
||||
)
|
||||
.reset();
|
||||
},
|
||||
),
|
||||
],
|
||||
child: IconButton(
|
||||
tooltip: 'Filter & Sort',
|
||||
onPressed: () {
|
||||
if (filterMenuController.isOpen) {
|
||||
filterMenuController.close();
|
||||
} else {
|
||||
filterMenuController.open();
|
||||
}
|
||||
},
|
||||
icon: Badge(
|
||||
isLabelVisible: hasActiveFilter,
|
||||
child: const Icon(MdiIcons.filter, size: 18),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Spacer(),
|
||||
MenuAnchor(
|
||||
controller: viewModeMenuController,
|
||||
menuChildren: TabsViewMode.values
|
||||
.map(
|
||||
(mode) => MenuItemButton(
|
||||
leadingIcon: Icon(mode.icon),
|
||||
child: Text(mode.label),
|
||||
onPressed: () {
|
||||
if (mode == TabsViewMode.tree) {
|
||||
searchTextController.clear();
|
||||
searchMode.value = false;
|
||||
}
|
||||
|
||||
ref
|
||||
.read(
|
||||
tabsViewModeControllerProvider.notifier,
|
||||
)
|
||||
.set(TabsViewMode.list);
|
||||
}
|
||||
.set(mode);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
child: IconButton(
|
||||
tooltip: 'Change view mode',
|
||||
onPressed: isSyncedScope
|
||||
? null
|
||||
: () {
|
||||
if (viewModeMenuController.isOpen) {
|
||||
viewModeMenuController.close();
|
||||
} else {
|
||||
viewModeMenuController.open();
|
||||
}
|
||||
},
|
||||
icon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(tabsViewMode.icon, size: 18),
|
||||
const Icon(Icons.arrow_drop_down, size: 18),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (enableAiFeatures &&
|
||||
switch (tabsViewMode) {
|
||||
TabsViewMode.list || TabsViewMode.grid => true,
|
||||
TabsViewMode.tree => false,
|
||||
})
|
||||
Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final tabSuggestionsEnabled = ref.watch(
|
||||
persistedBoolProvider(
|
||||
PersistedBoolKey.tabSuggestions,
|
||||
),
|
||||
);
|
||||
final downloadProgress = ref.watch(
|
||||
mlDownloadStateProvider,
|
||||
);
|
||||
|
||||
searchMode.value = true;
|
||||
searchTextFocus.requestFocus();
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: 32,
|
||||
child: VerticalDivider(indent: 4, endIndent: 4),
|
||||
),
|
||||
MenuAnchor(
|
||||
controller: viewModeMenuController,
|
||||
menuChildren: TabsViewMode.values
|
||||
.map(
|
||||
(mode) => MenuItemButton(
|
||||
leadingIcon: Icon(mode.icon),
|
||||
child: Text(mode.label),
|
||||
onPressed: () {
|
||||
return Badge(
|
||||
isLabelVisible: downloadProgress != null,
|
||||
offset: const Offset(-2, 2),
|
||||
label: downloadProgress != null
|
||||
? Text(
|
||||
'${downloadProgress.progress.toInt()}%',
|
||||
style: const TextStyle(fontSize: 10),
|
||||
)
|
||||
: null,
|
||||
child: IconButton.filledTonal(
|
||||
icon: const Icon(MdiIcons.imageAutoAdjust),
|
||||
isSelected: tabSuggestionsEnabled,
|
||||
iconSize: 18,
|
||||
padding: EdgeInsets.zero,
|
||||
tooltip: downloadProgress != null
|
||||
? 'Downloading AI models (${downloadProgress.progress.toInt()}%)'
|
||||
: tabSuggestionsEnabled
|
||||
? 'Disable AI tab suggestions'
|
||||
: 'Enable AI tab suggestions',
|
||||
onPressed: () async {
|
||||
if (!tabSuggestionsEnabled) {
|
||||
final result =
|
||||
await showEnableAiTabSuggestionsDialog(
|
||||
context,
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
ref
|
||||
.read(
|
||||
tabsViewModeControllerProvider
|
||||
.notifier,
|
||||
persistedBoolProvider(
|
||||
PersistedBoolKey.tabSuggestions,
|
||||
).notifier,
|
||||
)
|
||||
.set(mode);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
child: IconButton(
|
||||
tooltip: 'Change view mode',
|
||||
onPressed: isSyncedScope
|
||||
? null
|
||||
: () {
|
||||
if (viewModeMenuController.isOpen) {
|
||||
viewModeMenuController.close();
|
||||
} else {
|
||||
viewModeMenuController.open();
|
||||
}
|
||||
},
|
||||
icon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(tabsViewMode.icon, size: 18),
|
||||
const Icon(Icons.arrow_drop_down, size: 18),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (enableAiFeatures &&
|
||||
switch (tabsViewMode) {
|
||||
TabsViewMode.list || TabsViewMode.grid => true,
|
||||
TabsViewMode.tree => false,
|
||||
})
|
||||
Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final tabSuggestionsEnabled = ref.watch(
|
||||
persistedBoolProvider(PersistedBoolKey.tabSuggestions),
|
||||
);
|
||||
final downloadProgress = ref.watch(
|
||||
mlDownloadStateProvider,
|
||||
);
|
||||
|
||||
return Badge(
|
||||
isLabelVisible: downloadProgress != null,
|
||||
offset: const Offset(-2, 2),
|
||||
label: downloadProgress != null
|
||||
? Text(
|
||||
'${downloadProgress.progress.toInt()}%',
|
||||
style: const TextStyle(fontSize: 10),
|
||||
.set(true);
|
||||
}
|
||||
} else {
|
||||
ref
|
||||
.read(
|
||||
persistedBoolProvider(
|
||||
PersistedBoolKey.tabSuggestions,
|
||||
).notifier,
|
||||
)
|
||||
: null,
|
||||
child: IconButton.filledTonal(
|
||||
icon: const Icon(MdiIcons.imageAutoAdjust),
|
||||
isSelected: tabSuggestionsEnabled,
|
||||
iconSize: 18,
|
||||
padding: EdgeInsets.zero,
|
||||
tooltip: downloadProgress != null
|
||||
? 'Downloading AI models (${downloadProgress.progress.toInt()}%)'
|
||||
: tabSuggestionsEnabled
|
||||
? 'Disable AI tab suggestions'
|
||||
: 'Enable AI tab suggestions',
|
||||
onPressed: () async {
|
||||
if (!tabSuggestionsEnabled) {
|
||||
.set(false);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (switch (tabsViewMode) {
|
||||
TabsViewMode.list || TabsViewMode.grid => true,
|
||||
TabsViewMode.tree => false,
|
||||
})
|
||||
IconButton.filledTonal(
|
||||
icon: const Icon(Icons.swap_vert),
|
||||
isSelected: tabsReorderable,
|
||||
iconSize: 18,
|
||||
padding: EdgeInsets.zero,
|
||||
tooltip: tabsReorderable
|
||||
? 'Disable reordering mode'
|
||||
: canManualReorder
|
||||
? 'Enable reordering mode'
|
||||
: 'Reordering requires default manual mode',
|
||||
onPressed: canManualReorder
|
||||
? () {
|
||||
final wasEnabled = tabsReorderable;
|
||||
|
||||
ref
|
||||
.read(
|
||||
tabsReorderableControllerProvider
|
||||
.notifier,
|
||||
)
|
||||
.toggle();
|
||||
|
||||
// Show info when enabling reordering
|
||||
if (!wasEnabled && context.mounted) {
|
||||
ui_helper.showInfoMessage(
|
||||
context,
|
||||
'Drag and drop tabs to reorder them',
|
||||
);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
),
|
||||
MenuAnchor(
|
||||
controller: tabsActionMenuController,
|
||||
menuChildren: [
|
||||
SubmenuButton(
|
||||
leadingIcon: const Icon(MdiIcons.closeCircle),
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(MdiIcons.closeCircle),
|
||||
onPressed: isSyncedScope
|
||||
? null
|
||||
: () async {
|
||||
final result =
|
||||
await showEnableAiTabSuggestionsDialog(
|
||||
await showCloseAllTabsDialog(context);
|
||||
|
||||
if (result == true) {
|
||||
final count = await ref
|
||||
.read(
|
||||
tabDataRepositoryProvider
|
||||
.notifier,
|
||||
)
|
||||
.closeContainerTabs(
|
||||
selectedContainerId,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
ui_helper.showTabUndoClose(
|
||||
context,
|
||||
ref
|
||||
.read(
|
||||
tabRepositoryProvider
|
||||
.notifier,
|
||||
)
|
||||
.undoClose,
|
||||
count: count.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('All Tabs'),
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: Icon(
|
||||
MdiIcons.dominoMask,
|
||||
color: AppColors.of(context).privateTabPurple,
|
||||
),
|
||||
onPressed: isSyncedScope
|
||||
? null
|
||||
: () async {
|
||||
final result =
|
||||
await showCloseAllPrivateTabsDialog(
|
||||
context,
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
ref
|
||||
final count = await ref
|
||||
.read(
|
||||
persistedBoolProvider(PersistedBoolKey.tabSuggestions)
|
||||
tabDataRepositoryProvider
|
||||
.notifier,
|
||||
)
|
||||
.set(true);
|
||||
}
|
||||
} else {
|
||||
ref
|
||||
.read(
|
||||
persistedBoolProvider(PersistedBoolKey.tabSuggestions)
|
||||
.notifier,
|
||||
)
|
||||
.set(false);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (switch (tabsViewMode) {
|
||||
TabsViewMode.list || TabsViewMode.grid => true,
|
||||
TabsViewMode.tree => false,
|
||||
})
|
||||
Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final tabsReorderabe = ref.watch(
|
||||
tabsReorderableControllerProvider,
|
||||
);
|
||||
.closeContainerTabs(
|
||||
selectedContainerId,
|
||||
includeRegular: false,
|
||||
includeIsolated: false,
|
||||
);
|
||||
|
||||
return IconButton.filledTonal(
|
||||
icon: const Icon(Icons.swap_vert),
|
||||
isSelected: tabsReorderabe,
|
||||
iconSize: 18,
|
||||
padding: EdgeInsets.zero,
|
||||
tooltip: tabsReorderabe
|
||||
? 'Disable reordering mode'
|
||||
: 'Enable reordering mode',
|
||||
onPressed: () {
|
||||
final wasEnabled = tabsReorderabe;
|
||||
|
||||
ref
|
||||
.read(
|
||||
tabsReorderableControllerProvider
|
||||
.notifier,
|
||||
)
|
||||
.toggle();
|
||||
|
||||
// Show info when enabling reordering
|
||||
if (!wasEnabled && context.mounted) {
|
||||
ui_helper.showInfoMessage(
|
||||
context,
|
||||
'Drag and drop tabs to reorder them',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
MenuAnchor(
|
||||
controller: tabsActionMenuController,
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(MdiIcons.closeCircle),
|
||||
onPressed: isSyncedScope
|
||||
? null
|
||||
: () async {
|
||||
final result = await showCloseAllTabsDialog(
|
||||
context,
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
final count = await ref
|
||||
.read(
|
||||
tabDataRepositoryProvider.notifier,
|
||||
)
|
||||
.closeContainerTabs(
|
||||
selectedContainerId,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
ui_helper.showTabUndoClose(
|
||||
context,
|
||||
ref
|
||||
.read(
|
||||
tabRepositoryProvider.notifier,
|
||||
)
|
||||
.undoClose,
|
||||
count: count.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Close All Tabs'),
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(MdiIcons.incognitoCircleOff),
|
||||
onPressed: isSyncedScope
|
||||
? null
|
||||
: () async {
|
||||
final result =
|
||||
await showCloseAllPrivateTabsDialog(
|
||||
context,
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
final count = await ref
|
||||
.read(
|
||||
tabDataRepositoryProvider.notifier,
|
||||
)
|
||||
.closeContainerTabs(
|
||||
selectedContainerId,
|
||||
includeRegular: false,
|
||||
includeIsolated: false,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
ui_helper.showTabUndoClose(
|
||||
context,
|
||||
ref
|
||||
.read(
|
||||
tabRepositoryProvider.notifier,
|
||||
)
|
||||
.undoClose,
|
||||
count: count.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Close Private Tabs'),
|
||||
),
|
||||
if (showIsolatedTabUi)
|
||||
MenuItemButton(
|
||||
leadingIcon: Icon(
|
||||
MdiIcons.snowflake,
|
||||
color: AppColors.of(context).isolatedTabTeal,
|
||||
),
|
||||
onPressed: isSyncedScope
|
||||
? null
|
||||
: () async {
|
||||
// Count distinct isolation groups that will be destroyed
|
||||
final allStates = ref.read(
|
||||
tabStatesProvider,
|
||||
);
|
||||
final isolatedContextIds = allStates.values
|
||||
.where(
|
||||
(s) =>
|
||||
s.tabMode is IsolatedTabMode &&
|
||||
s.isolationContextId != null,
|
||||
)
|
||||
.map((s) => s.isolationContextId!)
|
||||
.toSet();
|
||||
|
||||
if (isolatedContextIds.isNotEmpty &&
|
||||
context.mounted) {
|
||||
final confirmed = await ui_helper
|
||||
.confirmIsolatedTabClose(
|
||||
if (context.mounted) {
|
||||
ui_helper.showTabUndoClose(
|
||||
context,
|
||||
groupCount:
|
||||
isolatedContextIds.length,
|
||||
ref
|
||||
.read(
|
||||
tabRepositoryProvider
|
||||
.notifier,
|
||||
)
|
||||
.undoClose,
|
||||
count: count.length,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
final count = await ref
|
||||
.read(
|
||||
tabDataRepositoryProvider.notifier,
|
||||
)
|
||||
.closeContainerTabs(
|
||||
selectedContainerId,
|
||||
includeRegular: false,
|
||||
includePrivate: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Private Tabs'),
|
||||
),
|
||||
if (showIsolatedTabUi)
|
||||
MenuItemButton(
|
||||
leadingIcon: Icon(
|
||||
MdiIcons.snowflake,
|
||||
color: AppColors.of(context).isolatedTabTeal,
|
||||
),
|
||||
onPressed: isSyncedScope
|
||||
? null
|
||||
: () async {
|
||||
// Count distinct isolation groups that will be destroyed
|
||||
final allStates = ref.read(
|
||||
tabStatesProvider,
|
||||
);
|
||||
final isolatedContextIds = allStates
|
||||
.values
|
||||
.where(
|
||||
(s) =>
|
||||
s.tabMode
|
||||
is IsolatedTabMode &&
|
||||
s.isolationContextId != null,
|
||||
)
|
||||
.map((s) => s.isolationContextId!)
|
||||
.toSet();
|
||||
|
||||
if (context.mounted) {
|
||||
ui_helper.showTabUndoClose(
|
||||
context,
|
||||
ref
|
||||
if (isolatedContextIds.isNotEmpty &&
|
||||
context.mounted) {
|
||||
final confirmed = await ui_helper
|
||||
.confirmIsolatedTabClose(
|
||||
context,
|
||||
groupCount:
|
||||
isolatedContextIds.length,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
final count = await ref
|
||||
.read(
|
||||
tabDataRepositoryProvider
|
||||
.notifier,
|
||||
)
|
||||
.closeContainerTabs(
|
||||
selectedContainerId,
|
||||
includeRegular: false,
|
||||
includePrivate: false,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
ui_helper.showTabUndoClose(
|
||||
context,
|
||||
ref
|
||||
.read(
|
||||
tabRepositoryProvider
|
||||
.notifier,
|
||||
)
|
||||
.undoClose,
|
||||
count: count.length,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Isolated Tabs'),
|
||||
),
|
||||
if (tabsViewMode != TabsViewMode.tree)
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(MdiIcons.filterOutline),
|
||||
onPressed:
|
||||
isSyncedScope ||
|
||||
!ref
|
||||
.read(
|
||||
tabViewFilterControllerProvider,
|
||||
)
|
||||
.hasActiveFilter
|
||||
? null
|
||||
: () async {
|
||||
final filteredIds = await ref
|
||||
.read(
|
||||
tabDataRepositoryProvider
|
||||
.notifier,
|
||||
)
|
||||
.getFilteredTabIds(
|
||||
selectedContainerId,
|
||||
);
|
||||
|
||||
if (filteredIds.isEmpty) return;
|
||||
|
||||
// Check for isolated tabs
|
||||
final allStates = ref.read(
|
||||
tabStatesProvider,
|
||||
);
|
||||
final isolatedContextIds = filteredIds
|
||||
.map((id) => allStates[id])
|
||||
.where(
|
||||
(s) =>
|
||||
s != null &&
|
||||
s.tabMode
|
||||
is IsolatedTabMode &&
|
||||
s.isolationContextId != null,
|
||||
)
|
||||
.map((s) => s!.isolationContextId!)
|
||||
.toSet();
|
||||
|
||||
if (isolatedContextIds.isNotEmpty &&
|
||||
context.mounted) {
|
||||
final confirmed = await ui_helper
|
||||
.confirmIsolatedTabClose(
|
||||
context,
|
||||
groupCount:
|
||||
isolatedContextIds.length,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(
|
||||
tabRepositoryProvider.notifier,
|
||||
)
|
||||
.undoClose,
|
||||
count: count.length,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Close Isolated Tabs'),
|
||||
),
|
||||
const Divider(),
|
||||
.closeTabs(filteredIds);
|
||||
|
||||
if (context.mounted) {
|
||||
ui_helper.showTabUndoClose(
|
||||
context,
|
||||
ref
|
||||
.read(
|
||||
tabRepositoryProvider
|
||||
.notifier,
|
||||
)
|
||||
.undoClose,
|
||||
count: filteredIds.length,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Filtered Tabs'),
|
||||
),
|
||||
],
|
||||
child: const Text('Close Tabs'),
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(MdiIcons.bookmarkPlusOutline),
|
||||
onPressed: isSyncedScope
|
||||
@@ -799,11 +1129,10 @@ class TabViewHeader extends HookConsumerWidget {
|
||||
TextField(
|
||||
controller: searchTextController,
|
||||
focusNode: searchTextFocus,
|
||||
// enableIMEPersonalizedLearning: !incognitoEnabled,
|
||||
decoration: InputDecoration(
|
||||
// border: InputBorder.none,
|
||||
border: InputBorder.none,
|
||||
prefixIcon: const Icon(MdiIcons.tabSearch, size: 18),
|
||||
hintText: 'Search inside tabs...',
|
||||
hintText: 'Search tabs',
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
suffixIcon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -816,9 +1145,12 @@ class TabViewHeader extends HookConsumerWidget {
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
searchTextController.clear();
|
||||
searchTextFocus.requestFocus();
|
||||
searchMode.value = false;
|
||||
if (searchTextController.text.isNotEmpty) {
|
||||
searchTextController.clear();
|
||||
searchTextFocus.requestFocus();
|
||||
} else {
|
||||
searchMode.value = false;
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.clear),
|
||||
),
|
||||
@@ -826,6 +1158,7 @@ class TabViewHeader extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
if (showContainerUi)
|
||||
Consumer(
|
||||
builder: (context, ref, child) {
|
||||
|
||||
+8
-2
@@ -58,7 +58,9 @@ class FullSearchTermSuggestions extends HookConsumerWidget {
|
||||
|
||||
final searchSuggestions = ref.watch(searchSuggestionsProvider());
|
||||
final searchHistory = ref.watch(searchHistoryProvider);
|
||||
final expanded = ref.watch(persistedBoolProvider(PersistedBoolKey.searchSuggestionsExpanded));
|
||||
final expanded = ref.watch(
|
||||
persistedBoolProvider(PersistedBoolKey.searchSuggestionsExpanded),
|
||||
);
|
||||
|
||||
useOnListenableChange(searchTextController, () {
|
||||
ref
|
||||
@@ -111,7 +113,11 @@ class FullSearchTermSuggestions extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.only(top: 6.0),
|
||||
child: IconButton(
|
||||
onPressed: ref
|
||||
.read(persistedBoolProvider(PersistedBoolKey.searchSuggestionsExpanded).notifier)
|
||||
.read(
|
||||
persistedBoolProvider(
|
||||
PersistedBoolKey.searchSuggestionsExpanded,
|
||||
).notifier,
|
||||
)
|
||||
.toggle,
|
||||
icon: Icon(expanded ? Icons.unfold_less : Icons.unfold_more),
|
||||
),
|
||||
|
||||
@@ -128,6 +128,14 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
|
||||
);
|
||||
}
|
||||
|
||||
Selectable<MapEntry<String, String>> getTabOrderKeys() {
|
||||
final query = selectOnly(db.tab)..addColumns([db.tab.id, db.tab.orderKey]);
|
||||
|
||||
return query.map(
|
||||
(row) => MapEntry(row.read(db.tab.id)!, row.read(db.tab.orderKey)!),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> _generateOrderKey({
|
||||
required Value<String?> parentId,
|
||||
required Value<String?> containerId,
|
||||
@@ -463,6 +471,27 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
|
||||
return db.definitionsDrift.isolatedContextContainerPairs();
|
||||
}
|
||||
|
||||
Future<void> setPinned(String id, {required bool pinned}) {
|
||||
final statement = _updateByIdStatement(id);
|
||||
return statement.write(TabCompanion(isPinned: Value(pinned)));
|
||||
}
|
||||
|
||||
Selectable<String> getPinnedTabIds() {
|
||||
final query = selectOnly(db.tab)
|
||||
..addColumns([db.tab.id])
|
||||
..where(db.tab.isPinned.equals(true));
|
||||
|
||||
return query.map((row) => row.read(db.tab.id)!);
|
||||
}
|
||||
|
||||
Selectable<MapEntry<String, DateTime>> getTabTimestamps() {
|
||||
final query = selectOnly(db.tab)..addColumns([db.tab.id, db.tab.timestamp]);
|
||||
|
||||
return query.map(
|
||||
(row) => MapEntry(row.read(db.tab.id)!, row.read(db.tab.timestamp)!),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<String>> getUnassignedTabsOlderThan(DateTime threshold) {
|
||||
final query = selectOnly(db.tab)
|
||||
..addColumns([db.tab.id])
|
||||
|
||||
@@ -31,7 +31,7 @@ import 'package:weblibre/features/search/domain/fts_tokenizer.dart';
|
||||
@DriftDatabase(include: {'definitions.drift'}, daos: [ContainerDao, TabDao])
|
||||
class TabDatabase extends $TabDatabase with TrigramQueryBuilderMixin {
|
||||
@override
|
||||
final int schemaVersion = 6;
|
||||
final int schemaVersion = 7;
|
||||
|
||||
@override
|
||||
final int ftsTokenLimit = 10;
|
||||
@@ -115,5 +115,8 @@ class TabDatabase extends $TabDatabase with TrigramQueryBuilderMixin {
|
||||
|
||||
await m.alterTable(TableMigration(schema.tab));
|
||||
},
|
||||
from6To7: (m, schema) async {
|
||||
await m.addColumn(schema.tab, schema.tab.isPinned);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -594,11 +594,140 @@ i1.GeneratedColumn<String> _column_18(String aliasedName) =>
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: '',
|
||||
);
|
||||
|
||||
final class Schema7 extends i0.VersionedSchema {
|
||||
Schema7({required super.database}) : super(version: 7);
|
||||
@override
|
||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||
container,
|
||||
tab,
|
||||
tabFts,
|
||||
tabMaintainParentChainOnDelete,
|
||||
tabAfterInsert,
|
||||
tabAfterDelete,
|
||||
tabAfterUpdate,
|
||||
];
|
||||
late final Shape0 container = Shape0(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'container',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [],
|
||||
columns: [_column_0, _column_1, _column_2, _column_3],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape5 tab = Shape5(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'tab',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [
|
||||
'CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL))',
|
||||
],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_16,
|
||||
_column_4,
|
||||
_column_5,
|
||||
_column_6,
|
||||
_column_7,
|
||||
_column_8,
|
||||
_column_17,
|
||||
_column_18,
|
||||
_column_19,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_13,
|
||||
_column_14,
|
||||
_column_15,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape2 tabFts = Shape2(
|
||||
source: i0.VersionedVirtualTable(
|
||||
entityName: 'tab_fts',
|
||||
moduleAndArgs:
|
||||
'fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize="trigram")',
|
||||
columns: [_column_8, _column_7, _column_12, _column_14],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Trigger tabMaintainParentChainOnDelete = i1.Trigger(
|
||||
'CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN UPDATE tab SET parent_id = CASE WHEN OLD.parent_id IS NOT NULL AND EXISTS (SELECT 1 FROM tab WHERE id = OLD.parent_id) THEN OLD.parent_id ELSE NULL END WHERE parent_id = OLD.id;END',
|
||||
'tab_maintain_parent_chain_on_delete',
|
||||
);
|
||||
final i1.Trigger tabAfterInsert = i1.Trigger(
|
||||
'CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END',
|
||||
'tab_after_insert',
|
||||
);
|
||||
final i1.Trigger tabAfterDelete = i1.Trigger(
|
||||
'CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);END',
|
||||
'tab_after_delete',
|
||||
);
|
||||
final i1.Trigger tabAfterUpdate = i1.Trigger(
|
||||
'CREATE TRIGGER tab_after_update AFTER UPDATE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END',
|
||||
'tab_after_update',
|
||||
);
|
||||
}
|
||||
|
||||
class Shape5 extends i0.VersionedTable {
|
||||
Shape5({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get source =>
|
||||
columnsByName['source']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get parentId =>
|
||||
columnsByName['parent_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get containerId =>
|
||||
columnsByName['container_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get orderKey =>
|
||||
columnsByName['order_key']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get url =>
|
||||
columnsByName['url']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get title =>
|
||||
columnsByName['title']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get tabMode =>
|
||||
columnsByName['tab_mode']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get isolationContextId =>
|
||||
columnsByName['isolation_context_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get isPinned =>
|
||||
columnsByName['is_pinned']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get isProbablyReaderable =>
|
||||
columnsByName['is_probably_readerable']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get extractedContentMarkdown =>
|
||||
columnsByName['extracted_content_markdown']!
|
||||
as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get extractedContentPlain =>
|
||||
columnsByName['extracted_content_plain']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get fullContentMarkdown =>
|
||||
columnsByName['full_content_markdown']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get fullContentPlain =>
|
||||
columnsByName['full_content_plain']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get timestamp =>
|
||||
columnsByName['timestamp']! as i1.GeneratedColumn<int>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<int> _column_19(String aliasedName) =>
|
||||
i1.GeneratedColumn<int>(
|
||||
'is_pinned',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.int,
|
||||
$customConstraints: 'NOT NULL DEFAULT 0',
|
||||
defaultValue: const i1.CustomExpression('0'),
|
||||
);
|
||||
i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
|
||||
required Future<void> Function(i1.Migrator m, Schema5 schema) from4To5,
|
||||
required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6,
|
||||
required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7,
|
||||
}) {
|
||||
return (currentVersion, database) async {
|
||||
switch (currentVersion) {
|
||||
@@ -622,6 +751,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from5To6(migrator, schema);
|
||||
return 6;
|
||||
case 6:
|
||||
final schema = Schema7(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from6To7(migrator, schema);
|
||||
return 7;
|
||||
default:
|
||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||
}
|
||||
@@ -633,11 +767,13 @@ i1.OnUpgrade stepByStep({
|
||||
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
|
||||
required Future<void> Function(i1.Migrator m, Schema5 schema) from4To5,
|
||||
required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6,
|
||||
required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7,
|
||||
}) => i0.VersionedSchema.stepByStepHelper(
|
||||
step: migrationSteps(
|
||||
from2To3: from2To3,
|
||||
from3To4: from3To4,
|
||||
from4To5: from4To5,
|
||||
from5To6: from5To6,
|
||||
from6To7: from6To7,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@ CREATE TABLE tab(
|
||||
title TEXT,
|
||||
tab_mode ENUM(TabModeDbValue) NOT NULL DEFAULT 0,
|
||||
isolation_context_id TEXT,
|
||||
is_pinned BOOL NOT NULL DEFAULT 0,
|
||||
is_probably_readerable BOOL,
|
||||
extracted_content_markdown TEXT,
|
||||
extracted_content_plain TEXT,
|
||||
|
||||
@@ -333,6 +333,7 @@ typedef $TabCreateCompanionBuilder =
|
||||
i0.Value<String?> title,
|
||||
i0.Value<i7.TabModeDbValue> tabMode,
|
||||
i0.Value<String?> isolationContextId,
|
||||
i0.Value<bool> isPinned,
|
||||
i0.Value<bool?> isProbablyReaderable,
|
||||
i0.Value<String?> extractedContentMarkdown,
|
||||
i0.Value<String?> extractedContentPlain,
|
||||
@@ -352,6 +353,7 @@ typedef $TabUpdateCompanionBuilder =
|
||||
i0.Value<String?> title,
|
||||
i0.Value<i7.TabModeDbValue> tabMode,
|
||||
i0.Value<String?> isolationContextId,
|
||||
i0.Value<bool> isPinned,
|
||||
i0.Value<bool?> isProbablyReaderable,
|
||||
i0.Value<String?> extractedContentMarkdown,
|
||||
i0.Value<String?> extractedContentPlain,
|
||||
@@ -445,6 +447,11 @@ class $TabFilterComposer extends i0.Composer<i0.GeneratedDatabase, i3.Tab> {
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<bool> get isPinned => $composableBuilder(
|
||||
column: $table.isPinned,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<bool> get isProbablyReaderable => $composableBuilder(
|
||||
column: $table.isProbablyReaderable,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
@@ -551,6 +558,11 @@ class $TabOrderingComposer extends i0.Composer<i0.GeneratedDatabase, i3.Tab> {
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<bool> get isPinned => $composableBuilder(
|
||||
column: $table.isPinned,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<bool> get isProbablyReaderable => $composableBuilder(
|
||||
column: $table.isProbablyReaderable,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
@@ -643,6 +655,9 @@ class $TabAnnotationComposer extends i0.Composer<i0.GeneratedDatabase, i3.Tab> {
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
i0.GeneratedColumn<bool> get isPinned =>
|
||||
$composableBuilder(column: $table.isPinned, builder: (column) => column);
|
||||
|
||||
i0.GeneratedColumn<bool> get isProbablyReaderable => $composableBuilder(
|
||||
column: $table.isProbablyReaderable,
|
||||
builder: (column) => column,
|
||||
@@ -736,6 +751,7 @@ class $TabTableManager
|
||||
i0.Value<String?> title = const i0.Value.absent(),
|
||||
i0.Value<i7.TabModeDbValue> tabMode = const i0.Value.absent(),
|
||||
i0.Value<String?> isolationContextId = const i0.Value.absent(),
|
||||
i0.Value<bool> isPinned = const i0.Value.absent(),
|
||||
i0.Value<bool?> isProbablyReaderable = const i0.Value.absent(),
|
||||
i0.Value<String?> extractedContentMarkdown =
|
||||
const i0.Value.absent(),
|
||||
@@ -755,6 +771,7 @@ class $TabTableManager
|
||||
title: title,
|
||||
tabMode: tabMode,
|
||||
isolationContextId: isolationContextId,
|
||||
isPinned: isPinned,
|
||||
isProbablyReaderable: isProbablyReaderable,
|
||||
extractedContentMarkdown: extractedContentMarkdown,
|
||||
extractedContentPlain: extractedContentPlain,
|
||||
@@ -774,6 +791,7 @@ class $TabTableManager
|
||||
i0.Value<String?> title = const i0.Value.absent(),
|
||||
i0.Value<i7.TabModeDbValue> tabMode = const i0.Value.absent(),
|
||||
i0.Value<String?> isolationContextId = const i0.Value.absent(),
|
||||
i0.Value<bool> isPinned = const i0.Value.absent(),
|
||||
i0.Value<bool?> isProbablyReaderable = const i0.Value.absent(),
|
||||
i0.Value<String?> extractedContentMarkdown =
|
||||
const i0.Value.absent(),
|
||||
@@ -793,6 +811,7 @@ class $TabTableManager
|
||||
title: title,
|
||||
tabMode: tabMode,
|
||||
isolationContextId: isolationContextId,
|
||||
isPinned: isPinned,
|
||||
isProbablyReaderable: isProbablyReaderable,
|
||||
extractedContentMarkdown: extractedContentMarkdown,
|
||||
extractedContentPlain: extractedContentPlain,
|
||||
@@ -1312,6 +1331,15 @@ class Tab extends i0.Table with i0.TableInfo<Tab, i3.TabData> {
|
||||
requiredDuringInsert: false,
|
||||
$customConstraints: '',
|
||||
);
|
||||
late final i0.GeneratedColumn<bool> isPinned = i0.GeneratedColumn<bool>(
|
||||
'is_pinned',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i0.DriftSqlType.bool,
|
||||
requiredDuringInsert: false,
|
||||
$customConstraints: 'NOT NULL DEFAULT 0',
|
||||
defaultValue: const i0.CustomExpression('0'),
|
||||
);
|
||||
late final i0.GeneratedColumn<bool> isProbablyReaderable =
|
||||
i0.GeneratedColumn<bool>(
|
||||
'is_probably_readerable',
|
||||
@@ -1377,6 +1405,7 @@ class Tab extends i0.Table with i0.TableInfo<Tab, i3.TabData> {
|
||||
title,
|
||||
tabMode,
|
||||
isolationContextId,
|
||||
isPinned,
|
||||
isProbablyReaderable,
|
||||
extractedContentMarkdown,
|
||||
extractedContentPlain,
|
||||
@@ -1437,6 +1466,10 @@ class Tab extends i0.Table with i0.TableInfo<Tab, i3.TabData> {
|
||||
i0.DriftSqlType.string,
|
||||
data['${effectivePrefix}isolation_context_id'],
|
||||
),
|
||||
isPinned: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.bool,
|
||||
data['${effectivePrefix}is_pinned'],
|
||||
)!,
|
||||
isProbablyReaderable: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.bool,
|
||||
data['${effectivePrefix}is_probably_readerable'],
|
||||
@@ -1493,6 +1526,7 @@ class TabData extends i0.DataClass implements i0.Insertable<i3.TabData> {
|
||||
final String? title;
|
||||
final i7.TabModeDbValue tabMode;
|
||||
final String? isolationContextId;
|
||||
final bool isPinned;
|
||||
final bool? isProbablyReaderable;
|
||||
final String? extractedContentMarkdown;
|
||||
final String? extractedContentPlain;
|
||||
@@ -1509,6 +1543,7 @@ class TabData extends i0.DataClass implements i0.Insertable<i3.TabData> {
|
||||
this.title,
|
||||
required this.tabMode,
|
||||
this.isolationContextId,
|
||||
required this.isPinned,
|
||||
this.isProbablyReaderable,
|
||||
this.extractedContentMarkdown,
|
||||
this.extractedContentPlain,
|
||||
@@ -1544,6 +1579,7 @@ class TabData extends i0.DataClass implements i0.Insertable<i3.TabData> {
|
||||
if (!nullToAbsent || isolationContextId != null) {
|
||||
map['isolation_context_id'] = i0.Variable<String>(isolationContextId);
|
||||
}
|
||||
map['is_pinned'] = i0.Variable<bool>(isPinned);
|
||||
if (!nullToAbsent || isProbablyReaderable != null) {
|
||||
map['is_probably_readerable'] = i0.Variable<bool>(isProbablyReaderable);
|
||||
}
|
||||
@@ -1588,6 +1624,7 @@ class TabData extends i0.DataClass implements i0.Insertable<i3.TabData> {
|
||||
isolationContextId: serializer.fromJson<String?>(
|
||||
json['isolation_context_id'],
|
||||
),
|
||||
isPinned: serializer.fromJson<bool>(json['is_pinned']),
|
||||
isProbablyReaderable: serializer.fromJson<bool?>(
|
||||
json['is_probably_readerable'],
|
||||
),
|
||||
@@ -1621,6 +1658,7 @@ class TabData extends i0.DataClass implements i0.Insertable<i3.TabData> {
|
||||
i3.Tab.$convertertabMode.toJson(tabMode),
|
||||
),
|
||||
'isolation_context_id': serializer.toJson<String?>(isolationContextId),
|
||||
'is_pinned': serializer.toJson<bool>(isPinned),
|
||||
'is_probably_readerable': serializer.toJson<bool?>(isProbablyReaderable),
|
||||
'extracted_content_markdown': serializer.toJson<String?>(
|
||||
extractedContentMarkdown,
|
||||
@@ -1644,6 +1682,7 @@ class TabData extends i0.DataClass implements i0.Insertable<i3.TabData> {
|
||||
i0.Value<String?> title = const i0.Value.absent(),
|
||||
i7.TabModeDbValue? tabMode,
|
||||
i0.Value<String?> isolationContextId = const i0.Value.absent(),
|
||||
bool? isPinned,
|
||||
i0.Value<bool?> isProbablyReaderable = const i0.Value.absent(),
|
||||
i0.Value<String?> extractedContentMarkdown = const i0.Value.absent(),
|
||||
i0.Value<String?> extractedContentPlain = const i0.Value.absent(),
|
||||
@@ -1662,6 +1701,7 @@ class TabData extends i0.DataClass implements i0.Insertable<i3.TabData> {
|
||||
isolationContextId: isolationContextId.present
|
||||
? isolationContextId.value
|
||||
: this.isolationContextId,
|
||||
isPinned: isPinned ?? this.isPinned,
|
||||
isProbablyReaderable: isProbablyReaderable.present
|
||||
? isProbablyReaderable.value
|
||||
: this.isProbablyReaderable,
|
||||
@@ -1694,6 +1734,7 @@ class TabData extends i0.DataClass implements i0.Insertable<i3.TabData> {
|
||||
isolationContextId: data.isolationContextId.present
|
||||
? data.isolationContextId.value
|
||||
: this.isolationContextId,
|
||||
isPinned: data.isPinned.present ? data.isPinned.value : this.isPinned,
|
||||
isProbablyReaderable: data.isProbablyReaderable.present
|
||||
? data.isProbablyReaderable.value
|
||||
: this.isProbablyReaderable,
|
||||
@@ -1725,6 +1766,7 @@ class TabData extends i0.DataClass implements i0.Insertable<i3.TabData> {
|
||||
..write('title: $title, ')
|
||||
..write('tabMode: $tabMode, ')
|
||||
..write('isolationContextId: $isolationContextId, ')
|
||||
..write('isPinned: $isPinned, ')
|
||||
..write('isProbablyReaderable: $isProbablyReaderable, ')
|
||||
..write('extractedContentMarkdown: $extractedContentMarkdown, ')
|
||||
..write('extractedContentPlain: $extractedContentPlain, ')
|
||||
@@ -1746,6 +1788,7 @@ class TabData extends i0.DataClass implements i0.Insertable<i3.TabData> {
|
||||
title,
|
||||
tabMode,
|
||||
isolationContextId,
|
||||
isPinned,
|
||||
isProbablyReaderable,
|
||||
extractedContentMarkdown,
|
||||
extractedContentPlain,
|
||||
@@ -1766,6 +1809,7 @@ class TabData extends i0.DataClass implements i0.Insertable<i3.TabData> {
|
||||
other.title == this.title &&
|
||||
other.tabMode == this.tabMode &&
|
||||
other.isolationContextId == this.isolationContextId &&
|
||||
other.isPinned == this.isPinned &&
|
||||
other.isProbablyReaderable == this.isProbablyReaderable &&
|
||||
other.extractedContentMarkdown == this.extractedContentMarkdown &&
|
||||
other.extractedContentPlain == this.extractedContentPlain &&
|
||||
@@ -1784,6 +1828,7 @@ class TabCompanion extends i0.UpdateCompanion<i3.TabData> {
|
||||
final i0.Value<String?> title;
|
||||
final i0.Value<i7.TabModeDbValue> tabMode;
|
||||
final i0.Value<String?> isolationContextId;
|
||||
final i0.Value<bool> isPinned;
|
||||
final i0.Value<bool?> isProbablyReaderable;
|
||||
final i0.Value<String?> extractedContentMarkdown;
|
||||
final i0.Value<String?> extractedContentPlain;
|
||||
@@ -1801,6 +1846,7 @@ class TabCompanion extends i0.UpdateCompanion<i3.TabData> {
|
||||
this.title = const i0.Value.absent(),
|
||||
this.tabMode = const i0.Value.absent(),
|
||||
this.isolationContextId = const i0.Value.absent(),
|
||||
this.isPinned = const i0.Value.absent(),
|
||||
this.isProbablyReaderable = const i0.Value.absent(),
|
||||
this.extractedContentMarkdown = const i0.Value.absent(),
|
||||
this.extractedContentPlain = const i0.Value.absent(),
|
||||
@@ -1819,6 +1865,7 @@ class TabCompanion extends i0.UpdateCompanion<i3.TabData> {
|
||||
this.title = const i0.Value.absent(),
|
||||
this.tabMode = const i0.Value.absent(),
|
||||
this.isolationContextId = const i0.Value.absent(),
|
||||
this.isPinned = const i0.Value.absent(),
|
||||
this.isProbablyReaderable = const i0.Value.absent(),
|
||||
this.extractedContentMarkdown = const i0.Value.absent(),
|
||||
this.extractedContentPlain = const i0.Value.absent(),
|
||||
@@ -1840,6 +1887,7 @@ class TabCompanion extends i0.UpdateCompanion<i3.TabData> {
|
||||
i0.Expression<String>? title,
|
||||
i0.Expression<int>? tabMode,
|
||||
i0.Expression<String>? isolationContextId,
|
||||
i0.Expression<bool>? isPinned,
|
||||
i0.Expression<bool>? isProbablyReaderable,
|
||||
i0.Expression<String>? extractedContentMarkdown,
|
||||
i0.Expression<String>? extractedContentPlain,
|
||||
@@ -1859,6 +1907,7 @@ class TabCompanion extends i0.UpdateCompanion<i3.TabData> {
|
||||
if (tabMode != null) 'tab_mode': tabMode,
|
||||
if (isolationContextId != null)
|
||||
'isolation_context_id': isolationContextId,
|
||||
if (isPinned != null) 'is_pinned': isPinned,
|
||||
if (isProbablyReaderable != null)
|
||||
'is_probably_readerable': isProbablyReaderable,
|
||||
if (extractedContentMarkdown != null)
|
||||
@@ -1883,6 +1932,7 @@ class TabCompanion extends i0.UpdateCompanion<i3.TabData> {
|
||||
i0.Value<String?>? title,
|
||||
i0.Value<i7.TabModeDbValue>? tabMode,
|
||||
i0.Value<String?>? isolationContextId,
|
||||
i0.Value<bool>? isPinned,
|
||||
i0.Value<bool?>? isProbablyReaderable,
|
||||
i0.Value<String?>? extractedContentMarkdown,
|
||||
i0.Value<String?>? extractedContentPlain,
|
||||
@@ -1901,6 +1951,7 @@ class TabCompanion extends i0.UpdateCompanion<i3.TabData> {
|
||||
title: title ?? this.title,
|
||||
tabMode: tabMode ?? this.tabMode,
|
||||
isolationContextId: isolationContextId ?? this.isolationContextId,
|
||||
isPinned: isPinned ?? this.isPinned,
|
||||
isProbablyReaderable: isProbablyReaderable ?? this.isProbablyReaderable,
|
||||
extractedContentMarkdown:
|
||||
extractedContentMarkdown ?? this.extractedContentMarkdown,
|
||||
@@ -1949,6 +2000,9 @@ class TabCompanion extends i0.UpdateCompanion<i3.TabData> {
|
||||
isolationContextId.value,
|
||||
);
|
||||
}
|
||||
if (isPinned.present) {
|
||||
map['is_pinned'] = i0.Variable<bool>(isPinned.value);
|
||||
}
|
||||
if (isProbablyReaderable.present) {
|
||||
map['is_probably_readerable'] = i0.Variable<bool>(
|
||||
isProbablyReaderable.value,
|
||||
@@ -1993,6 +2047,7 @@ class TabCompanion extends i0.UpdateCompanion<i3.TabData> {
|
||||
..write('title: $title, ')
|
||||
..write('tabMode: $tabMode, ')
|
||||
..write('isolationContextId: $isolationContextId, ')
|
||||
..write('isPinned: $isPinned, ')
|
||||
..write('isProbablyReaderable: $isProbablyReaderable, ')
|
||||
..write('extractedContentMarkdown: $extractedContentMarkdown, ')
|
||||
..write('extractedContentPlain: $extractedContentPlain, ')
|
||||
|
||||
@@ -21,39 +21,56 @@ import 'package:fast_equatable/fast_equatable.dart';
|
||||
|
||||
sealed class TabEntity with FastEquatable {
|
||||
String get tabId;
|
||||
String get orderKey;
|
||||
}
|
||||
|
||||
class DefaultTabEntity extends TabEntity {
|
||||
@override
|
||||
final String tabId;
|
||||
@override
|
||||
final String orderKey;
|
||||
final String? containerId;
|
||||
|
||||
DefaultTabEntity({required this.tabId, required this.containerId});
|
||||
DefaultTabEntity({
|
||||
required this.tabId,
|
||||
required this.orderKey,
|
||||
required this.containerId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [tabId, containerId];
|
||||
List<Object?> get hashParameters => [tabId, orderKey, containerId];
|
||||
}
|
||||
|
||||
class SearchResultTabEntity extends TabEntity {
|
||||
@override
|
||||
final String tabId;
|
||||
@override
|
||||
final String orderKey;
|
||||
final String? containerId;
|
||||
|
||||
final String searchQuery;
|
||||
|
||||
SearchResultTabEntity({
|
||||
required this.tabId,
|
||||
required this.orderKey,
|
||||
required this.searchQuery,
|
||||
required this.containerId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [tabId, searchQuery, containerId];
|
||||
List<Object?> get hashParameters => [
|
||||
tabId,
|
||||
orderKey,
|
||||
searchQuery,
|
||||
containerId,
|
||||
];
|
||||
}
|
||||
|
||||
class TabTreeEntity extends TabEntity {
|
||||
@override
|
||||
final String tabId;
|
||||
@override
|
||||
final String orderKey;
|
||||
|
||||
final String? containerId;
|
||||
|
||||
@@ -63,11 +80,18 @@ class TabTreeEntity extends TabEntity {
|
||||
|
||||
TabTreeEntity({
|
||||
required this.tabId,
|
||||
required this.orderKey,
|
||||
required this.containerId,
|
||||
required this.rootId,
|
||||
required this.totalTabs,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [tabId, containerId, rootId, totalTabs];
|
||||
List<Object?> get hashParameters => [
|
||||
tabId,
|
||||
orderKey,
|
||||
containerId,
|
||||
rootId,
|
||||
totalTabs,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -115,6 +115,24 @@ Stream<List<TabData>> watchContainerTabsData(Ref ref, String? containerId) {
|
||||
return db.containerDao.getContainerTabsData(containerId).watch();
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Stream<Set<String>> watchPinnedTabIds(Ref ref) {
|
||||
final db = ref.watch(tabDatabaseProvider);
|
||||
return db.tabDao.getPinnedTabIds().watch().map((ids) => ids.toSet());
|
||||
}
|
||||
|
||||
@Riverpod()
|
||||
Stream<Map<String, DateTime>> watchTabTimestamps(Ref ref) {
|
||||
final db = ref.watch(tabDatabaseProvider);
|
||||
return db.tabDao.getTabTimestamps().watch().map(Map.fromEntries);
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Stream<Map<String, String>> watchTabOrderKeys(Ref ref) {
|
||||
final db = ref.watch(tabDatabaseProvider);
|
||||
return db.tabDao.getTabOrderKeys().watch().map(Map.fromEntries);
|
||||
}
|
||||
|
||||
@Riverpod()
|
||||
Stream<ContainerData?> watchContainerData(Ref ref, String containerId) {
|
||||
final db = ref.watch(tabDatabaseProvider);
|
||||
|
||||
@@ -525,6 +525,128 @@ final class WatchContainerTabsDataFamily extends $Family
|
||||
String toString() => r'watchContainerTabsDataProvider';
|
||||
}
|
||||
|
||||
@ProviderFor(watchPinnedTabIds)
|
||||
final watchPinnedTabIdsProvider = WatchPinnedTabIdsProvider._();
|
||||
|
||||
final class WatchPinnedTabIdsProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<Set<String>>,
|
||||
Set<String>,
|
||||
Stream<Set<String>>
|
||||
>
|
||||
with $FutureModifier<Set<String>>, $StreamProvider<Set<String>> {
|
||||
WatchPinnedTabIdsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'watchPinnedTabIdsProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$watchPinnedTabIdsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$StreamProviderElement<Set<String>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $StreamProviderElement(pointer);
|
||||
|
||||
@override
|
||||
Stream<Set<String>> create(Ref ref) {
|
||||
return watchPinnedTabIds(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$watchPinnedTabIdsHash() => r'5623faf1a4d90185c718654f4172092e28dd1e54';
|
||||
|
||||
@ProviderFor(watchTabTimestamps)
|
||||
final watchTabTimestampsProvider = WatchTabTimestampsProvider._();
|
||||
|
||||
final class WatchTabTimestampsProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<Map<String, DateTime>>,
|
||||
Map<String, DateTime>,
|
||||
Stream<Map<String, DateTime>>
|
||||
>
|
||||
with
|
||||
$FutureModifier<Map<String, DateTime>>,
|
||||
$StreamProvider<Map<String, DateTime>> {
|
||||
WatchTabTimestampsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'watchTabTimestampsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$watchTabTimestampsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$StreamProviderElement<Map<String, DateTime>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $StreamProviderElement(pointer);
|
||||
|
||||
@override
|
||||
Stream<Map<String, DateTime>> create(Ref ref) {
|
||||
return watchTabTimestamps(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$watchTabTimestampsHash() =>
|
||||
r'8b3eea3dded71795f80c607117978bbec1de7cff';
|
||||
|
||||
@ProviderFor(watchTabOrderKeys)
|
||||
final watchTabOrderKeysProvider = WatchTabOrderKeysProvider._();
|
||||
|
||||
final class WatchTabOrderKeysProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<Map<String, String>>,
|
||||
Map<String, String>,
|
||||
Stream<Map<String, String>>
|
||||
>
|
||||
with
|
||||
$FutureModifier<Map<String, String>>,
|
||||
$StreamProvider<Map<String, String>> {
|
||||
WatchTabOrderKeysProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'watchTabOrderKeysProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$watchTabOrderKeysHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$StreamProviderElement<Map<String, String>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $StreamProviderElement(pointer);
|
||||
|
||||
@override
|
||||
Stream<Map<String, String>> create(Ref ref) {
|
||||
return watchTabOrderKeys(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$watchTabOrderKeysHash() => r'675ef0d1b1c5445face8d6ab727507ca1a5e2c44';
|
||||
|
||||
@ProviderFor(watchContainerData)
|
||||
final watchContainerDataProvider = WatchContainerDataFamily._();
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import 'package:weblibre/features/geckoview/domain/entities/tab_container_select
|
||||
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.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/browser/presentation/controllers/tab_view_controllers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||
@@ -114,6 +115,13 @@ class TabDataRepository extends _$TabDataRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setPinned(String tabId, {required bool pinned}) {
|
||||
return ref
|
||||
.read(tabDatabaseProvider)
|
||||
.tabDao
|
||||
.setPinned(tabId, pinned: pinned);
|
||||
}
|
||||
|
||||
Future<void> assignOrderKey(String tabId, String orderKey) {
|
||||
return ref
|
||||
.read(tabDatabaseProvider)
|
||||
@@ -235,6 +243,36 @@ class TabDataRepository extends _$TabDataRepository {
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<String>> getFilteredTabIds(String? containerId) async {
|
||||
final db = ref.read(tabDatabaseProvider);
|
||||
final filterOptions = ref.read(tabViewFilterControllerProvider);
|
||||
final tabStates = ref.read(tabStatesProvider);
|
||||
|
||||
// Get all tab IDs in this container from DB
|
||||
final containerTabIds = await db.containerDao
|
||||
.getContainerTabIds(containerId)
|
||||
.get();
|
||||
|
||||
// Only keep tabs that exist in the engine
|
||||
final candidateIds = containerTabIds
|
||||
.where((id) => tabStates.containsKey(id))
|
||||
.toList();
|
||||
|
||||
if (candidateIds.isEmpty) return const [];
|
||||
|
||||
if (!filterOptions.hasActiveFilter) return candidateIds;
|
||||
|
||||
// Only fetch timestamps when date filtering is active
|
||||
final timestamps = filterOptions.effectiveDateRange != null
|
||||
? Map.fromEntries(await db.tabDao.getTabTimestamps().get())
|
||||
: null;
|
||||
|
||||
return candidateIds.where((id) {
|
||||
final state = tabStates[id];
|
||||
return filterOptions.matchesTab(state?.tabMode, timestamps?[id]);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Future<int> deleteUnassignedTabsOlderThan(DateTime threshold) async {
|
||||
final tabIds = await ref
|
||||
.read(tabDatabaseProvider)
|
||||
|
||||
@@ -41,7 +41,7 @@ final class TabDataRepositoryProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$tabDataRepositoryHash() => r'41ca88333684db71f809e35874b0e5bdddde0f60';
|
||||
String _$tabDataRepositoryHash() => r'c64a44c7d9de566617798a0a3bdcad51bf436658';
|
||||
|
||||
abstract class _$TabDataRepository extends $Notifier<void> {
|
||||
void build();
|
||||
|
||||
@@ -109,7 +109,9 @@ class _ContainerSuggestionsChip extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tabSuggestionsEnabled = ref.watch(persistedBoolProvider(PersistedBoolKey.tabSuggestions));
|
||||
final tabSuggestionsEnabled = ref.watch(
|
||||
persistedBoolProvider(PersistedBoolKey.tabSuggestions),
|
||||
);
|
||||
final enableAiFeatures = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(settings) => settings.enableLocalAiFeatures,
|
||||
@@ -306,7 +308,7 @@ class ContainerChips extends HookConsumerWidget {
|
||||
),
|
||||
if (displayMenu)
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
// visualDensity: VisualDensity.compact,
|
||||
onPressed: () async {
|
||||
await const ContainerListRoute().push(context);
|
||||
},
|
||||
|
||||
@@ -508,7 +508,6 @@ class _PullToRefreshTile extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _DoubleBackCloseTabTile extends HookConsumerWidget {
|
||||
const _DoubleBackCloseTabTile();
|
||||
|
||||
|
||||
@@ -67,9 +67,11 @@ void showInfoMessage(
|
||||
String message, {
|
||||
Duration duration = const Duration(seconds: 4),
|
||||
bool persist = false,
|
||||
SnackBarAction? action,
|
||||
}) {
|
||||
final snackBar = _createFloatingSnackBar(
|
||||
content: Text(message),
|
||||
action: action,
|
||||
duration: duration,
|
||||
persist: persist,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user