add tab view filter and sort options

This commit is contained in:
Fabian Freund
2026-03-02 16:30:18 +01:00
parent 51b53d900b
commit 8898f8a317
35 changed files with 4519 additions and 692 deletions
+23
View File
@@ -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 }
@@ -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();
@@ -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);
}
@@ -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
@@ -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() {
@@ -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;
@@ -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) ...[
@@ -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 {
@@ -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,
),
);
}
}
@@ -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,
@@ -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;
},
);
},
@@ -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),
),
),
),
);
@@ -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);
@@ -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) {
@@ -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();
+2
View File
@@ -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,
);