improved quick tab switcher & general tab design/logic;
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* 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:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/restore_complete.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
||||
|
||||
part 'pending_tab_selection.g.dart';
|
||||
|
||||
/// Queues the selection of a tab that exists in the local DB but has not
|
||||
/// been delivered by the native session restore yet (placeholder chips at
|
||||
/// cold start). The queued tab is selected as soon as its native state
|
||||
/// arrives; the queue clears itself if restore finishes without it.
|
||||
@Riverpod(keepAlive: true)
|
||||
class PendingTabSelection extends _$PendingTabSelection {
|
||||
void queue(String tabId) {
|
||||
if (state != tabId) {
|
||||
state = tabId;
|
||||
}
|
||||
}
|
||||
|
||||
void clear() {
|
||||
state = null;
|
||||
}
|
||||
|
||||
@override
|
||||
String? build() {
|
||||
ref.listen(
|
||||
tabStatesProvider,
|
||||
(previous, next) {
|
||||
final pendingTabId = state;
|
||||
if (pendingTabId != null && next.containsKey(pendingTabId)) {
|
||||
state = null;
|
||||
unawaited(
|
||||
ref.read(tabRepositoryProvider.notifier).selectTab(pendingTabId),
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (error, stackTrace) {
|
||||
logger.e(
|
||||
'Error listening to tabStatesProvider',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Stale-tab safety: when restore finishes and the queued id never
|
||||
// appeared, the tab no longer exists.
|
||||
ref.listen(browserRestoreCompleteProvider, (previous, next) {
|
||||
if (next &&
|
||||
state != null &&
|
||||
!ref.read(tabStatesProvider).containsKey(state)) {
|
||||
state = null;
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'pending_tab_selection.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Queues the selection of a tab that exists in the local DB but has not
|
||||
/// been delivered by the native session restore yet (placeholder chips at
|
||||
/// cold start). The queued tab is selected as soon as its native state
|
||||
/// arrives; the queue clears itself if restore finishes without it.
|
||||
|
||||
@ProviderFor(PendingTabSelection)
|
||||
final pendingTabSelectionProvider = PendingTabSelectionProvider._();
|
||||
|
||||
/// Queues the selection of a tab that exists in the local DB but has not
|
||||
/// been delivered by the native session restore yet (placeholder chips at
|
||||
/// cold start). The queued tab is selected as soon as its native state
|
||||
/// arrives; the queue clears itself if restore finishes without it.
|
||||
final class PendingTabSelectionProvider
|
||||
extends $NotifierProvider<PendingTabSelection, String?> {
|
||||
/// Queues the selection of a tab that exists in the local DB but has not
|
||||
/// been delivered by the native session restore yet (placeholder chips at
|
||||
/// cold start). The queued tab is selected as soon as its native state
|
||||
/// arrives; the queue clears itself if restore finishes without it.
|
||||
PendingTabSelectionProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'pendingTabSelectionProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$pendingTabSelectionHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
PendingTabSelection create() => PendingTabSelection();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(String? value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<String?>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$pendingTabSelectionHash() =>
|
||||
r'761d5814b40b1003b59db09a0bb7ef370530b317';
|
||||
|
||||
/// Queues the selection of a tab that exists in the local DB but has not
|
||||
/// been delivered by the native session restore yet (placeholder chips at
|
||||
/// cold start). The queued tab is selected as soon as its native state
|
||||
/// arrives; the queue clears itself if restore finishes without it.
|
||||
|
||||
abstract class _$PendingTabSelection extends $Notifier<String?> {
|
||||
String? build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<String?, String?>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<String?, String?>,
|
||||
String?,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
||||
|
||||
part 'restore_complete.g.dart';
|
||||
|
||||
/// Mirrors the native `BrowserState.restoreComplete` flag: false until the
|
||||
/// session restore has dispatched all persisted tabs into the BrowserStore.
|
||||
/// While false, DB-cached tabs without a native state are rendered as
|
||||
/// placeholders and the destructive tab DB sync is deferred.
|
||||
@Riverpod(keepAlive: true)
|
||||
class BrowserRestoreComplete extends _$BrowserRestoreComplete {
|
||||
@override
|
||||
bool build() {
|
||||
final eventService = ref.watch(eventServiceProvider);
|
||||
|
||||
ref.listen(
|
||||
fireImmediately: true,
|
||||
engineReadyStateProvider,
|
||||
(previous, next) async {
|
||||
if (next) {
|
||||
await GeckoTabService().syncEvents(onRestoreComplete: true);
|
||||
}
|
||||
},
|
||||
onError: (error, stackTrace) {
|
||||
logger.e(
|
||||
'Error listening to engineReadyStateProvider',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final restoreCompleteSub = eventService.restoreCompleteEvents.listen(
|
||||
(restoreComplete) {
|
||||
if (restoreComplete != state) {
|
||||
state = restoreComplete;
|
||||
}
|
||||
},
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
logger.e(
|
||||
'Error in restore complete events',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ref.onDispose(() async {
|
||||
await restoreCompleteSub.cancel();
|
||||
});
|
||||
|
||||
return eventService.restoreCompleteEvents.valueOrNull ?? false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'restore_complete.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Mirrors the native `BrowserState.restoreComplete` flag: false until the
|
||||
/// session restore has dispatched all persisted tabs into the BrowserStore.
|
||||
/// While false, DB-cached tabs without a native state are rendered as
|
||||
/// placeholders and the destructive tab DB sync is deferred.
|
||||
|
||||
@ProviderFor(BrowserRestoreComplete)
|
||||
final browserRestoreCompleteProvider = BrowserRestoreCompleteProvider._();
|
||||
|
||||
/// Mirrors the native `BrowserState.restoreComplete` flag: false until the
|
||||
/// session restore has dispatched all persisted tabs into the BrowserStore.
|
||||
/// While false, DB-cached tabs without a native state are rendered as
|
||||
/// placeholders and the destructive tab DB sync is deferred.
|
||||
final class BrowserRestoreCompleteProvider
|
||||
extends $NotifierProvider<BrowserRestoreComplete, bool> {
|
||||
/// Mirrors the native `BrowserState.restoreComplete` flag: false until the
|
||||
/// session restore has dispatched all persisted tabs into the BrowserStore.
|
||||
/// While false, DB-cached tabs without a native state are rendered as
|
||||
/// placeholders and the destructive tab DB sync is deferred.
|
||||
BrowserRestoreCompleteProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'browserRestoreCompleteProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$browserRestoreCompleteHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
BrowserRestoreComplete create() => BrowserRestoreComplete();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(bool value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<bool>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$browserRestoreCompleteHash() =>
|
||||
r'971ce7722781b3cb581cfa9ada0884e91f723b85';
|
||||
|
||||
/// Mirrors the native `BrowserState.restoreComplete` flag: false until the
|
||||
/// session restore has dispatched all persisted tabs into the BrowserStore.
|
||||
/// While false, DB-cached tabs without a native state are rendered as
|
||||
/// placeholders and the destructive tab DB sync is deferred.
|
||||
|
||||
abstract class _$BrowserRestoreComplete extends $Notifier<bool> {
|
||||
bool build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<bool, bool>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<bool, bool>,
|
||||
bool,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ import 'package:weblibre/extensions/uri.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/pending_tab_selection.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/restore_complete.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';
|
||||
@@ -456,6 +458,14 @@ class TabRepository extends _$TabRepository {
|
||||
}
|
||||
|
||||
Future<bool> selectTab(String tabId) async {
|
||||
// The tab is still a pre-restore placeholder (known to the DB but not to
|
||||
// the engine yet): queue the selection until the native state arrives.
|
||||
if (!ref.read(browserRestoreCompleteProvider) &&
|
||||
!ref.read(tabStatesProvider).containsKey(tabId)) {
|
||||
ref.read(pendingTabSelectionProvider.notifier).queue(tabId);
|
||||
return true;
|
||||
}
|
||||
|
||||
final containerData = await ref
|
||||
.read(tabDataRepositoryProvider.notifier)
|
||||
.getTabContainerData(tabId);
|
||||
@@ -768,6 +778,18 @@ class TabRepository extends _$TabRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _drainPendingIsolationCleanup() async {
|
||||
if (_pendingIsolationCleanup.isEmpty) return;
|
||||
|
||||
final pending = Set<String>.of(_pendingIsolationCleanup);
|
||||
_pendingIsolationCleanup.clear();
|
||||
|
||||
for (final contextId in pending) {
|
||||
if (!ref.mounted) break;
|
||||
await _cleanupIsolationContextIfEmpty(contextId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> undoClose() {
|
||||
// Suppress the next reclose pass: undo can resurrect a tab whose
|
||||
// tombstone is still on disk (from a previous session); without this
|
||||
@@ -1037,9 +1059,14 @@ class TabRepository extends _$TabRepository {
|
||||
return;
|
||||
}
|
||||
|
||||
//Only sync tabs if there has been a previous value or is not empty
|
||||
//Only sync tabs if there has been a previous value or is not empty.
|
||||
//Additionally require the native session restore to have completed:
|
||||
//a partial pre-restore list (e.g. a share-intent tab arriving first)
|
||||
//must not delete the cached rows of tabs that are still being
|
||||
//restored.
|
||||
final shouldSyncTabs =
|
||||
next.value.isNotEmpty || (previous?.value.isNotEmpty ?? false);
|
||||
ref.read(browserRestoreCompleteProvider) &&
|
||||
(next.value.isNotEmpty || (previous?.value.isNotEmpty ?? false));
|
||||
|
||||
if (shouldSyncTabs) {
|
||||
final syncTabsResult = await db.tabDao.syncTabs(
|
||||
@@ -1054,14 +1081,7 @@ class TabRepository extends _$TabRepository {
|
||||
|
||||
// Process pending isolation context cleanups after syncTabs
|
||||
// has deleted the rows, so the count check is accurate.
|
||||
if (_pendingIsolationCleanup.isNotEmpty) {
|
||||
final pending = Set<String>.of(_pendingIsolationCleanup);
|
||||
_pendingIsolationCleanup.clear();
|
||||
for (final contextId in pending) {
|
||||
if (!ref.mounted) break;
|
||||
await _cleanupIsolationContextIfEmpty(contextId);
|
||||
}
|
||||
}
|
||||
await _drainPendingIsolationCleanup();
|
||||
|
||||
// One-shot orphan cleanup after tab list stabilizes (5s debounce).
|
||||
// Also runs for DB-only contexts whose rows were already deleted
|
||||
@@ -1084,6 +1104,26 @@ class TabRepository extends _$TabRepository {
|
||||
},
|
||||
);
|
||||
|
||||
// Catch up on the tab list emissions skipped while the restore-complete
|
||||
// gate above was closed: reconcile the DB once against the current list.
|
||||
ref.listen(browserRestoreCompleteProvider, (
|
||||
previous,
|
||||
restoreComplete,
|
||||
) async {
|
||||
if (restoreComplete && !(previous ?? false)) {
|
||||
final currentTabs = ref.read(tabListProvider).value;
|
||||
if (currentTabs.isNotEmpty) {
|
||||
final syncTabsResult = await db.tabDao.syncTabs(
|
||||
retainTabIds: currentTabs,
|
||||
);
|
||||
_pendingIsolationCleanup.addAll(
|
||||
syncTabsResult.deletedIsolationContextIds,
|
||||
);
|
||||
await _drainPendingIsolationCleanup();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
final tabStateDebouncer = Debouncer(const Duration(seconds: 1));
|
||||
Map<String, TabState>? debounceStartValue;
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ final class TabRepositoryProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$tabRepositoryHash() => r'd7b67460388c264f477325266a3299fe6df2df07';
|
||||
String _$tabRepositoryHash() => r'982b80b8ea7c694958bc10cc7e8e15310af6a238';
|
||||
|
||||
abstract class _$TabRepository extends $Notifier<void> {
|
||||
void build();
|
||||
|
||||
@@ -28,6 +28,7 @@ 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';
|
||||
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/restore_complete.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';
|
||||
@@ -38,6 +39,7 @@ import 'package:weblibre/features/geckoview/features/search/domain/entities/tab_
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/container_filter.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_entity.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';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
|
||||
@@ -176,6 +178,51 @@ EquatableValue<Map<String, TabState>> containerTabStates(
|
||||
});
|
||||
}
|
||||
|
||||
/// Synthesizes a placeholder [TabState] from a cached DB row for tabs the
|
||||
/// native session restore hasn't delivered yet. `TabIcon` falls back to the
|
||||
/// URL-cached favicon when [TabState.icon] is null, so placeholder chips
|
||||
/// still render with proper icons and titles.
|
||||
TabState _placeholderTabState(TabData tab) {
|
||||
return TabState.$default(tab.id).copyWith(
|
||||
url: tab.url ?? TabState.defaultUrl,
|
||||
title: tab.title ?? '',
|
||||
parentId: tab.parentId,
|
||||
tabMode: TabMode.fromDbValue(
|
||||
tab.tabMode,
|
||||
isolationContextId: tab.isolationContextId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether [tab] may be shown as a pre-restore placeholder. Private tabs are
|
||||
/// never session-restored, so their rows must not produce dangling chips.
|
||||
bool _canShowAsPlaceholder(TabData tab) =>
|
||||
tab.tabMode != TabModeDbValue.private;
|
||||
|
||||
/// Ids of DB-cached tabs whose native state hasn't arrived yet. Empty once
|
||||
/// the session restore completed (afterwards a missing native state means
|
||||
/// the tab is gone, not pending).
|
||||
@Riverpod()
|
||||
EquatableValue<Set<String>> pendingRestoreTabIds(Ref ref) {
|
||||
final restoreComplete = ref.watch(browserRestoreCompleteProvider);
|
||||
if (restoreComplete) {
|
||||
return EquatableValue(const {});
|
||||
}
|
||||
|
||||
final nativeTabIds = ref.watch(
|
||||
tabStatesProvider.select((states) => EquatableValue(states.keys.toSet())),
|
||||
);
|
||||
final dbTabs =
|
||||
ref.watch(watchTabsFifoProvider.select((value) => value.value)) ??
|
||||
const <TabData>[];
|
||||
|
||||
return EquatableValue({
|
||||
for (final tab in dbTabs)
|
||||
if (_canShowAsPlaceholder(tab) && !nativeTabIds.value.contains(tab.id))
|
||||
tab.id,
|
||||
});
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
EquatableValue<List<TabStateWithContainer>> fifoTabStates(Ref ref) {
|
||||
final containerData = ref
|
||||
@@ -189,13 +236,20 @@ EquatableValue<List<TabStateWithContainer>> fifoTabStates(Ref ref) {
|
||||
);
|
||||
|
||||
final tabStates = ref.watch(tabStatesProvider);
|
||||
final placeholdersActive = !ref.watch(browserRestoreCompleteProvider);
|
||||
|
||||
TabState? stateFor(TabData tab) =>
|
||||
tabStates[tab.id] ??
|
||||
(placeholdersActive && _canShowAsPlaceholder(tab)
|
||||
? _placeholderTabState(tab)
|
||||
: null);
|
||||
|
||||
return EquatableValue([
|
||||
if (sortedTabs != null)
|
||||
for (final tab in sortedTabs)
|
||||
if (tabStates.containsKey(tab.id))
|
||||
if (stateFor(tab) case final state?)
|
||||
(
|
||||
tabStates[tab.id]!,
|
||||
state,
|
||||
tab.containerId.mapNotNull(
|
||||
(containerId) => containerData?[containerId],
|
||||
),
|
||||
@@ -218,11 +272,43 @@ selectedContainerTabStatesWithContainer(Ref ref) {
|
||||
(value) => Map.fromEntries(value.map((c) => MapEntry(c.id, c))),
|
||||
);
|
||||
|
||||
final sortedTabs = ref.watch(
|
||||
containerTabEntitiesProvider(filter).select((value) => value.value),
|
||||
);
|
||||
|
||||
final tabStates = ref.watch(tabStatesProvider);
|
||||
final placeholdersActive = !ref.watch(browserRestoreCompleteProvider);
|
||||
final selectedContainerTabsData = placeholdersActive
|
||||
? ref.watch(
|
||||
watchContainerTabsDataProvider(
|
||||
filter.containerId,
|
||||
).select((value) => value.value),
|
||||
) ??
|
||||
const <TabData>[]
|
||||
: const <TabData>[];
|
||||
final sortedTabs = placeholdersActive
|
||||
? [
|
||||
for (final tab in selectedContainerTabsData)
|
||||
DefaultTabEntity(
|
||||
tabId: tab.id,
|
||||
orderKey: tab.orderKey,
|
||||
containerId: tab.containerId,
|
||||
),
|
||||
]
|
||||
: ref.watch(
|
||||
containerTabEntitiesProvider(filter).select((value) => value.value),
|
||||
);
|
||||
final tabDataById = placeholdersActive
|
||||
? {for (final tab in selectedContainerTabsData) tab.id: tab}
|
||||
: const <String, TabData>{};
|
||||
|
||||
TabState? stateForEntity(String tabId) {
|
||||
final state = tabStates[tabId];
|
||||
if (state != null) {
|
||||
return state;
|
||||
}
|
||||
final tabData = tabDataById[tabId];
|
||||
if (tabData != null && _canShowAsPlaceholder(tabData)) {
|
||||
return _placeholderTabState(tabData);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
final groupedItems = ref
|
||||
.watch(groupedTabListItemsProvider(containerId: filter.containerId))
|
||||
@@ -294,12 +380,15 @@ selectedContainerTabStatesWithContainer(Ref ref) {
|
||||
final groupedOrder = {
|
||||
for (var i = 0; i < orderedItems.length; i++) orderedItems[i].tabId: i,
|
||||
};
|
||||
final orderKeyById = {
|
||||
for (final tabEntity in sortedTabs) tabEntity.tabId: tabEntity.orderKey,
|
||||
};
|
||||
|
||||
var items = [
|
||||
for (final tabEntity in sortedTabs)
|
||||
if (tabStates.containsKey(tabEntity.tabId))
|
||||
if (stateForEntity(tabEntity.tabId) case final state?)
|
||||
(
|
||||
tabStates[tabEntity.tabId]!,
|
||||
state,
|
||||
tabEntity.containerId.mapNotNull(
|
||||
(containerId) => containerData?[containerId],
|
||||
),
|
||||
@@ -307,9 +396,15 @@ selectedContainerTabStatesWithContainer(Ref ref) {
|
||||
];
|
||||
|
||||
items.sort((a, b) {
|
||||
final aIndex = groupedOrder[a.$1.id] ?? orderedItems.length;
|
||||
final bIndex = groupedOrder[b.$1.id] ?? orderedItems.length;
|
||||
return aIndex.compareTo(bIndex);
|
||||
final aIndex = groupedOrder[a.$1.id];
|
||||
final bIndex = groupedOrder[b.$1.id];
|
||||
if (aIndex != null && bIndex != null) {
|
||||
return aIndex.compareTo(bIndex);
|
||||
}
|
||||
if (aIndex != null) return -1;
|
||||
if (bIndex != null) return 1;
|
||||
|
||||
return (orderKeyById[a.$1.id] ?? '').compareTo(orderKeyById[b.$1.id] ?? '');
|
||||
});
|
||||
|
||||
// Flat pinned-first: move all pinned tabs before unpinned regardless of
|
||||
@@ -330,14 +425,9 @@ EquatableValue<List<TabStateWithContainer>> quickTabSwitcherTabStates(
|
||||
Ref ref,
|
||||
QuickTabSwitcherMode mode,
|
||||
) {
|
||||
final effectiveMode = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(settings) => settings.effectiveUiQuickTabSwitcherMode(),
|
||||
),
|
||||
);
|
||||
final selectedTabId = ref.watch(selectedTabProvider);
|
||||
|
||||
final tabStates = switch (effectiveMode) {
|
||||
final tabStates = switch (mode) {
|
||||
QuickTabSwitcherMode.lastUsedTabs => ref.watch(fifoTabStatesProvider).value,
|
||||
QuickTabSwitcherMode.containerTabs =>
|
||||
ref.watch(selectedContainerTabStatesWithContainerProvider).value,
|
||||
@@ -352,7 +442,7 @@ EquatableValue<List<TabStateWithContainer>> quickTabSwitcherTabStates(
|
||||
tabViewFilterControllerProvider.select((v) => v.sortPinnedFirst),
|
||||
);
|
||||
|
||||
return EquatableValue(switch (effectiveMode) {
|
||||
return EquatableValue(switch (mode) {
|
||||
QuickTabSwitcherMode.lastUsedTabs => () {
|
||||
final filtered = tabStates
|
||||
.where((state) => state.$1.id != selectedTabId)
|
||||
@@ -378,11 +468,6 @@ Future<List<VisitInfo>> quickTabSwitcherHistorySuggestions(
|
||||
Ref ref,
|
||||
QuickTabSwitcherMode mode,
|
||||
) async {
|
||||
final effectiveMode = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(settings) => settings.effectiveUiQuickTabSwitcherMode(),
|
||||
),
|
||||
);
|
||||
final showHistorySuggestions = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(settings) => settings.quickTabSwitcherShowHistorySuggestions,
|
||||
@@ -395,7 +480,7 @@ Future<List<VisitInfo>> quickTabSwitcherHistorySuggestions(
|
||||
|
||||
final hasTabStates = ref.watch(
|
||||
quickTabSwitcherTabStatesProvider(
|
||||
effectiveMode,
|
||||
mode,
|
||||
).select((value) => value.value.isNotEmpty),
|
||||
);
|
||||
if (hasTabStates) {
|
||||
@@ -407,28 +492,15 @@ Future<List<VisitInfo>> quickTabSwitcherHistorySuggestions(
|
||||
.getVisitsPaginated(count: 25);
|
||||
}
|
||||
|
||||
@Riverpod()
|
||||
AsyncValue<bool> quickTabSwitcherHasResults(
|
||||
/// Whether a single switcher row of [mode] has anything to render
|
||||
/// (open tabs, or history suggestions as fallback).
|
||||
AsyncValue<bool> _quickTabSwitcherRowHasResults(
|
||||
Ref ref,
|
||||
QuickTabSwitcherMode mode,
|
||||
) {
|
||||
final effectiveMode = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(settings) => settings.effectiveUiQuickTabSwitcherMode(),
|
||||
),
|
||||
);
|
||||
final showQuickTabSwitcherBar = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(settings) => settings.tabBarShowQuickTabSwitcherBar,
|
||||
),
|
||||
);
|
||||
if (!showQuickTabSwitcherBar) {
|
||||
return const AsyncValue.data(false);
|
||||
}
|
||||
|
||||
final hasResults = ref.watch(
|
||||
quickTabSwitcherTabStatesProvider(
|
||||
effectiveMode,
|
||||
mode,
|
||||
).select((value) => value.value.isNotEmpty),
|
||||
);
|
||||
|
||||
@@ -437,10 +509,64 @@ AsyncValue<bool> quickTabSwitcherHasResults(
|
||||
}
|
||||
|
||||
return ref
|
||||
.watch(quickTabSwitcherHistorySuggestionsProvider(effectiveMode))
|
||||
.watch(quickTabSwitcherHistorySuggestionsProvider(mode))
|
||||
.whenData((visits) => visits.isNotEmpty);
|
||||
}
|
||||
|
||||
/// Number of 48px rows the quick tab switcher bar currently occupies.
|
||||
/// 0 hides the bar; feeds the toolbar height / GeckoView viewport math.
|
||||
@Riverpod()
|
||||
AsyncValue<int> quickTabSwitcherRowCount(Ref ref) {
|
||||
final stackingMode = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(settings) => settings.effectiveTabBarStackingMode(),
|
||||
),
|
||||
);
|
||||
|
||||
switch (stackingMode) {
|
||||
case TabBarStackingMode.disabled:
|
||||
return const AsyncValue.data(0);
|
||||
case TabBarStackingMode.lastUsedTabs:
|
||||
return _quickTabSwitcherRowHasResults(
|
||||
ref,
|
||||
QuickTabSwitcherMode.lastUsedTabs,
|
||||
).whenData((hasResults) => hasResults ? 1 : 0);
|
||||
case TabBarStackingMode.containerTabs:
|
||||
return _quickTabSwitcherRowHasResults(
|
||||
ref,
|
||||
QuickTabSwitcherMode.containerTabs,
|
||||
).whenData((hasResults) => hasResults ? 1 : 0);
|
||||
case TabBarStackingMode.accordion:
|
||||
final hasContainers = ref.watch(
|
||||
watchContainersWithCountProvider.select(
|
||||
(value) => value.value?.isNotEmpty ?? false,
|
||||
),
|
||||
);
|
||||
if (hasContainers) {
|
||||
return const AsyncValue.data(1);
|
||||
}
|
||||
return _quickTabSwitcherRowHasResults(
|
||||
ref,
|
||||
QuickTabSwitcherMode.containerTabs,
|
||||
).whenData((hasResults) => hasResults ? 1 : 0);
|
||||
case TabBarStackingMode.twoLevel:
|
||||
final containerRow = _quickTabSwitcherRowHasResults(
|
||||
ref,
|
||||
QuickTabSwitcherMode.containerTabs,
|
||||
);
|
||||
final mruRow = _quickTabSwitcherRowHasResults(
|
||||
ref,
|
||||
QuickTabSwitcherMode.lastUsedTabs,
|
||||
);
|
||||
// The bar shows both rows whenever either has content; an empty row
|
||||
// renders blank within its slot.
|
||||
return containerRow.whenData(
|
||||
(hasContainerTabs) =>
|
||||
(hasContainerTabs || (mruRow.value ?? false)) ? 2 : 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod()
|
||||
EquatableValue<List<TabEntity>> suggestedTabEntities(
|
||||
Ref ref,
|
||||
|
||||
@@ -424,6 +424,65 @@ final class ContainerTabStatesFamily extends $Family
|
||||
String toString() => r'containerTabStatesProvider';
|
||||
}
|
||||
|
||||
/// Ids of DB-cached tabs whose native state hasn't arrived yet. Empty once
|
||||
/// the session restore completed (afterwards a missing native state means
|
||||
/// the tab is gone, not pending).
|
||||
|
||||
@ProviderFor(pendingRestoreTabIds)
|
||||
final pendingRestoreTabIdsProvider = PendingRestoreTabIdsProvider._();
|
||||
|
||||
/// Ids of DB-cached tabs whose native state hasn't arrived yet. Empty once
|
||||
/// the session restore completed (afterwards a missing native state means
|
||||
/// the tab is gone, not pending).
|
||||
|
||||
final class PendingRestoreTabIdsProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
EquatableValue<Set<String>>,
|
||||
EquatableValue<Set<String>>,
|
||||
EquatableValue<Set<String>>
|
||||
>
|
||||
with $Provider<EquatableValue<Set<String>>> {
|
||||
/// Ids of DB-cached tabs whose native state hasn't arrived yet. Empty once
|
||||
/// the session restore completed (afterwards a missing native state means
|
||||
/// the tab is gone, not pending).
|
||||
PendingRestoreTabIdsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'pendingRestoreTabIdsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$pendingRestoreTabIdsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<EquatableValue<Set<String>>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
EquatableValue<Set<String>> create(Ref ref) {
|
||||
return pendingRestoreTabIds(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(EquatableValue<Set<String>> value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<EquatableValue<Set<String>>>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$pendingRestoreTabIdsHash() =>
|
||||
r'a7c1639478ebed7d887d9b1d1296757d829dcc6b';
|
||||
|
||||
@ProviderFor(fifoTabStates)
|
||||
final fifoTabStatesProvider = FifoTabStatesProvider._();
|
||||
|
||||
@@ -474,7 +533,7 @@ final class FifoTabStatesProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$fifoTabStatesHash() => r'f8dc69382d307bc83193a3b839a16313e9c1ab69';
|
||||
String _$fifoTabStatesHash() => r'a512f96119561096b9cc7d6920e82da6d460ce68';
|
||||
|
||||
@ProviderFor(selectedContainerTabStatesWithContainer)
|
||||
final selectedContainerTabStatesWithContainerProvider =
|
||||
@@ -529,7 +588,7 @@ final class SelectedContainerTabStatesWithContainerProvider
|
||||
}
|
||||
|
||||
String _$selectedContainerTabStatesWithContainerHash() =>
|
||||
r'e6fd0e1e0ca4cfb0a0a46e8ff4e63d41032bfad9';
|
||||
r'6e215f983140f7980d84e3f3d05a8afe95e6f1e7';
|
||||
|
||||
@ProviderFor(quickTabSwitcherTabStates)
|
||||
final quickTabSwitcherTabStatesProvider = QuickTabSwitcherTabStatesFamily._();
|
||||
@@ -601,7 +660,7 @@ final class QuickTabSwitcherTabStatesProvider
|
||||
}
|
||||
|
||||
String _$quickTabSwitcherTabStatesHash() =>
|
||||
r'213c9c492055175de8fc684e0c9fb9a3530fbb6f';
|
||||
r'f61847bd68385e799384adfa5bfe735544fedbf5';
|
||||
|
||||
final class QuickTabSwitcherTabStatesFamily extends $Family
|
||||
with
|
||||
@@ -684,7 +743,7 @@ final class QuickTabSwitcherHistorySuggestionsProvider
|
||||
}
|
||||
|
||||
String _$quickTabSwitcherHistorySuggestionsHash() =>
|
||||
r'2e689ce72aac6b5b5b41e0390b168b3fdf5d4251';
|
||||
r'3446741dfedf6fda9ab61421c1b74370c7f43f5b';
|
||||
|
||||
final class QuickTabSwitcherHistorySuggestionsFamily extends $Family
|
||||
with
|
||||
@@ -708,89 +767,56 @@ final class QuickTabSwitcherHistorySuggestionsFamily extends $Family
|
||||
String toString() => r'quickTabSwitcherHistorySuggestionsProvider';
|
||||
}
|
||||
|
||||
@ProviderFor(quickTabSwitcherHasResults)
|
||||
final quickTabSwitcherHasResultsProvider = QuickTabSwitcherHasResultsFamily._();
|
||||
/// Number of 48px rows the quick tab switcher bar currently occupies.
|
||||
/// 0 hides the bar; feeds the toolbar height / GeckoView viewport math.
|
||||
|
||||
final class QuickTabSwitcherHasResultsProvider
|
||||
@ProviderFor(quickTabSwitcherRowCount)
|
||||
final quickTabSwitcherRowCountProvider = QuickTabSwitcherRowCountProvider._();
|
||||
|
||||
/// Number of 48px rows the quick tab switcher bar currently occupies.
|
||||
/// 0 hides the bar; feeds the toolbar height / GeckoView viewport math.
|
||||
|
||||
final class QuickTabSwitcherRowCountProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<bool>,
|
||||
AsyncValue<bool>,
|
||||
AsyncValue<bool>
|
||||
>
|
||||
with $Provider<AsyncValue<bool>> {
|
||||
QuickTabSwitcherHasResultsProvider._({
|
||||
required QuickTabSwitcherHasResultsFamily super.from,
|
||||
required QuickTabSwitcherMode super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'quickTabSwitcherHasResultsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
$FunctionalProvider<AsyncValue<int>, AsyncValue<int>, AsyncValue<int>>
|
||||
with $Provider<AsyncValue<int>> {
|
||||
/// Number of 48px rows the quick tab switcher bar currently occupies.
|
||||
/// 0 hides the bar; feeds the toolbar height / GeckoView viewport math.
|
||||
QuickTabSwitcherRowCountProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'quickTabSwitcherRowCountProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$quickTabSwitcherHasResultsHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'quickTabSwitcherHasResultsProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
String debugGetCreateSourceHash() => _$quickTabSwitcherRowCountHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<AsyncValue<bool>> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement<AsyncValue<int>> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
AsyncValue<bool> create(Ref ref) {
|
||||
final argument = this.argument as QuickTabSwitcherMode;
|
||||
return quickTabSwitcherHasResults(ref, argument);
|
||||
AsyncValue<int> create(Ref ref) {
|
||||
return quickTabSwitcherRowCount(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(AsyncValue<bool> value) {
|
||||
Override overrideWithValue(AsyncValue<int> value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<AsyncValue<bool>>(value),
|
||||
providerOverride: $SyncValueProvider<AsyncValue<int>>(value),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is QuickTabSwitcherHasResultsProvider &&
|
||||
other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$quickTabSwitcherHasResultsHash() =>
|
||||
r'5c488c9933372350c9e87ca1b83145176823dc88';
|
||||
|
||||
final class QuickTabSwitcherHasResultsFamily extends $Family
|
||||
with $FunctionalFamilyOverride<AsyncValue<bool>, QuickTabSwitcherMode> {
|
||||
QuickTabSwitcherHasResultsFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'quickTabSwitcherHasResultsProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
QuickTabSwitcherHasResultsProvider call(QuickTabSwitcherMode mode) =>
|
||||
QuickTabSwitcherHasResultsProvider._(argument: mode, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'quickTabSwitcherHasResultsProvider';
|
||||
}
|
||||
String _$quickTabSwitcherRowCountHash() =>
|
||||
r'81a2bfbdc4dc88fdf2c66dd9f5bfd6d1ce73a073';
|
||||
|
||||
@ProviderFor(suggestedTabEntities)
|
||||
final suggestedTabEntitiesProvider = SuggestedTabEntitiesFamily._();
|
||||
|
||||
+33
-26
@@ -47,6 +47,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/provid
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/addon_popup_bottom_sheet.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_fab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_system_bars.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/draggable_fab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart';
|
||||
@@ -115,7 +116,13 @@ class _AnimatedToolbar extends HookWidget {
|
||||
);
|
||||
}, [position]);
|
||||
|
||||
return SlideTransition(position: slideAnimation, child: child);
|
||||
// RepaintBoundary so the (heavy) toolbar content is rasterized once and
|
||||
// merely re-composited at a new offset for each frame of the slide, instead
|
||||
// of repainting the whole chip row + favicons + menus every animation tick.
|
||||
return SlideTransition(
|
||||
position: slideAnimation,
|
||||
child: RepaintBoundary(child: child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +131,7 @@ class _AnimatedToolbar extends HookWidget {
|
||||
class _TabBar extends HookConsumerWidget {
|
||||
final bool showMainToolbar;
|
||||
final bool showContextualToolbar;
|
||||
final bool showQuickTabSwitcherBar;
|
||||
final int quickTabSwitcherRowCount;
|
||||
final Stream<Offset>? pointerMoveEvents;
|
||||
final TabBarPosition tabBarPosition;
|
||||
final bool isSmallWebMode;
|
||||
@@ -133,7 +140,7 @@ class _TabBar extends HookConsumerWidget {
|
||||
const _TabBar({
|
||||
required this.showMainToolbar,
|
||||
required this.showContextualToolbar,
|
||||
required this.showQuickTabSwitcherBar,
|
||||
required this.quickTabSwitcherRowCount,
|
||||
required this.tabBarPosition,
|
||||
required this.pointerMoveEvents,
|
||||
required this.isSmallWebMode,
|
||||
@@ -220,7 +227,7 @@ class _TabBar extends HookConsumerWidget {
|
||||
TabBarPosition.top => BrowserTopAppBar(
|
||||
showMainToolbar: showMainToolbar,
|
||||
showContextualToolbar: showContextualToolbar,
|
||||
showQuickTabSwitcherBar: showQuickTabSwitcherBar,
|
||||
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
|
||||
isSmallWebMode: isSmallWebMode,
|
||||
enableGestures: enableGestures,
|
||||
),
|
||||
@@ -228,7 +235,7 @@ class _TabBar extends HookConsumerWidget {
|
||||
displayedSheet: displayedSheet,
|
||||
showMainToolbar: showMainToolbar,
|
||||
showContextualToolbar: showContextualToolbar,
|
||||
showQuickTabSwitcherBar: showQuickTabSwitcherBar,
|
||||
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
|
||||
isSmallWebMode: isSmallWebMode,
|
||||
),
|
||||
};
|
||||
@@ -527,23 +534,9 @@ class BrowserScreen extends HookConsumerWidget {
|
||||
),
|
||||
);
|
||||
|
||||
final showQuickTabSwitcherBar =
|
||||
!isSmallWebActive &&
|
||||
ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(value) => value.tabBarShowQuickTabSwitcherBar,
|
||||
),
|
||||
);
|
||||
final quickTabSwitcherMode = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(value) => value.effectiveUiQuickTabSwitcherMode(),
|
||||
),
|
||||
);
|
||||
final quickTabSwitcherHasResults = ref.watch(
|
||||
quickTabSwitcherHasResultsProvider(quickTabSwitcherMode),
|
||||
);
|
||||
final displayQuickTabSwitcherBar =
|
||||
showQuickTabSwitcherBar && (quickTabSwitcherHasResults.value ?? false);
|
||||
final quickTabSwitcherRowCount = isSmallWebActive
|
||||
? 0
|
||||
: ref.watch(quickTabSwitcherRowCountProvider).value ?? 0;
|
||||
|
||||
final autoHideTabBar =
|
||||
!isSmallWebActive &&
|
||||
@@ -652,7 +645,7 @@ class BrowserScreen extends HookConsumerWidget {
|
||||
bottomAppBarContentSize = BrowserBottomAppBar(
|
||||
showMainToolbar: tabBarPosition == TabBarPosition.bottom,
|
||||
showContextualToolbar: showContextualToolbar,
|
||||
showQuickTabSwitcherBar: displayQuickTabSwitcherBar,
|
||||
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
|
||||
isSmallWebMode: false,
|
||||
displayedSheet: displayedSheet,
|
||||
).preferredSize;
|
||||
@@ -666,7 +659,7 @@ class BrowserScreen extends HookConsumerWidget {
|
||||
final topAppBarContentSize = BrowserTopAppBar(
|
||||
showMainToolbar: tabBarPosition == TabBarPosition.top,
|
||||
showContextualToolbar: showContextualToolbar,
|
||||
showQuickTabSwitcherBar: displayQuickTabSwitcherBar,
|
||||
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
|
||||
isSmallWebMode: isSmallWebActive,
|
||||
enableGestures: !isSmallWebActive,
|
||||
).preferredSize;
|
||||
@@ -919,6 +912,20 @@ class BrowserScreen extends HookConsumerWidget {
|
||||
},
|
||||
),
|
||||
|
||||
// Layer 0.5: System bar tint — fills the status-bar/nav-bar
|
||||
// inset regions with the active container color (or the tab bar
|
||||
// surface fallback) and drives the system bar icon brightness.
|
||||
// Sits above the browser content but below the toolbars, so the
|
||||
// tab bar's transparent safe-area padding reveals the bottom
|
||||
// strip and the top toolbar's SafeArea reveals the top strip.
|
||||
if (!tabInFullScreen)
|
||||
Positioned.fill(
|
||||
child: BrowserSystemBars(
|
||||
topInset: topSafeArea,
|
||||
bottomInset: bottomSafeArea,
|
||||
),
|
||||
),
|
||||
|
||||
// Layer 1: Sheet (when displayed) - positioned above toolbar
|
||||
if (sheetDisplayed)
|
||||
Positioned(
|
||||
@@ -973,7 +980,7 @@ class BrowserScreen extends HookConsumerWidget {
|
||||
showMainToolbar:
|
||||
tabBarPosition == TabBarPosition.bottom,
|
||||
showContextualToolbar: showContextualToolbar,
|
||||
showQuickTabSwitcherBar: displayQuickTabSwitcherBar,
|
||||
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
|
||||
isSmallWebMode: false,
|
||||
pointerMoveEvents:
|
||||
tabBarPosition == TabBarPosition.bottom
|
||||
@@ -1012,7 +1019,7 @@ class BrowserScreen extends HookConsumerWidget {
|
||||
tabBarPosition: TabBarPosition.top,
|
||||
showMainToolbar: true,
|
||||
showContextualToolbar: showContextualToolbar,
|
||||
showQuickTabSwitcherBar: displayQuickTabSwitcherBar,
|
||||
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
|
||||
isSmallWebMode: isSmallWebActive,
|
||||
enableGestures: !isSmallWebActive,
|
||||
pointerMoveEvents: isSmallWebActive
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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/tabs/data/entities/tab_mode.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
|
||||
|
||||
/// Closes [tabId] with the shared UX contract: confirm before closing the
|
||||
/// last tab of an isolation group, then offer undo via snackbar.
|
||||
Future<void> closeTabWithConfirmationAndUndo(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String tabId,
|
||||
) async {
|
||||
// Confirm before closing the last tab in an isolation group
|
||||
final tabState = ref.read(tabStateProvider(tabId));
|
||||
if (tabState != null && tabState.tabMode is IsolatedTabMode) {
|
||||
final allStates = ref.read(tabStatesProvider);
|
||||
final groupCount = allStates.values
|
||||
.where((s) => s.isolationContextId == tabState.isolationContextId)
|
||||
.length;
|
||||
if (groupCount <= 1 && context.mounted) {
|
||||
final confirmed = await ui_helper.confirmIsolatedTabClose(context);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
}
|
||||
|
||||
await ref.read(tabRepositoryProvider.notifier).closeTab(tabId);
|
||||
|
||||
if (context.mounted) {
|
||||
ui_helper.showTabUndoClose(
|
||||
context,
|
||||
ref.read(tabRepositoryProvider.notifier).undoClose,
|
||||
);
|
||||
}
|
||||
}
|
||||
+159
-391
@@ -25,13 +25,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:weblibre/core/design/app_colors.dart';
|
||||
import 'package:weblibre/features/addons/presentation/widgets/pinned_addon_bar.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/restore_complete.dart';
|
||||
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/domain/entities/sheet.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
|
||||
@@ -41,12 +40,12 @@ import 'package:weblibre/features/geckoview/features/browser/features/contextual
|
||||
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_toolbar.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/utils/close_tab_helper.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/utils/tab_view_reorder.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_icon.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_menu.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/quick_tab_switcher_accordion.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/quick_tab_switcher_chip.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_depth_indicator.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_item.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/toolbar_button.dart';
|
||||
import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart';
|
||||
@@ -60,14 +59,17 @@ import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors
|
||||
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
import 'package:weblibre/presentation/hooks/scroll_to_active_chip.dart';
|
||||
import 'package:weblibre/presentation/widgets/selectable_chips.dart';
|
||||
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
|
||||
|
||||
export 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/quick_tab_switcher_chip.dart'
|
||||
show QuickTabSwitcherItem;
|
||||
|
||||
class BrowserTopAppBar extends StatelessWidget {
|
||||
final bool showMainToolbar;
|
||||
final bool showContextualToolbar;
|
||||
final bool showQuickTabSwitcherBar;
|
||||
final int quickTabSwitcherRowCount;
|
||||
final bool isSmallWebMode;
|
||||
final bool enableGestures;
|
||||
|
||||
@@ -78,7 +80,7 @@ class BrowserTopAppBar extends StatelessWidget {
|
||||
super.key,
|
||||
required this.showMainToolbar,
|
||||
required this.showContextualToolbar,
|
||||
required this.showQuickTabSwitcherBar,
|
||||
required this.quickTabSwitcherRowCount,
|
||||
required this.isSmallWebMode,
|
||||
this.enableGestures = true,
|
||||
}) {
|
||||
@@ -86,7 +88,7 @@ class BrowserTopAppBar extends StatelessWidget {
|
||||
showMainToolbar: showMainToolbar,
|
||||
displayedSheet: null,
|
||||
showContextualToolbar: false,
|
||||
showQuickTabSwitcherBar: false,
|
||||
quickTabSwitcherRowCount: 0,
|
||||
isSmallWebMode: isSmallWebMode,
|
||||
enableGestures: enableGestures,
|
||||
hideMainToolbarButtonsDuplicatedInContextualToolbar:
|
||||
@@ -107,7 +109,7 @@ class BrowserTopAppBar extends StatelessWidget {
|
||||
class BrowserBottomAppBar extends StatelessWidget {
|
||||
final bool showMainToolbar;
|
||||
final bool showContextualToolbar;
|
||||
final bool showQuickTabSwitcherBar;
|
||||
final int quickTabSwitcherRowCount;
|
||||
final bool isSmallWebMode;
|
||||
final Sheet? displayedSheet;
|
||||
final bool enableGestures;
|
||||
@@ -120,7 +122,7 @@ class BrowserBottomAppBar extends StatelessWidget {
|
||||
required this.showMainToolbar,
|
||||
required this.displayedSheet,
|
||||
required this.showContextualToolbar,
|
||||
required this.showQuickTabSwitcherBar,
|
||||
required this.quickTabSwitcherRowCount,
|
||||
required this.isSmallWebMode,
|
||||
this.enableGestures = true,
|
||||
}) {
|
||||
@@ -128,7 +130,7 @@ class BrowserBottomAppBar extends StatelessWidget {
|
||||
displayedSheet: displayedSheet,
|
||||
showMainToolbar: showMainToolbar,
|
||||
showContextualToolbar: showContextualToolbar,
|
||||
showQuickTabSwitcherBar: showQuickTabSwitcherBar,
|
||||
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
|
||||
isSmallWebMode: isSmallWebMode,
|
||||
enableGestures: enableGestures,
|
||||
hideMainToolbarButtonsDuplicatedInContextualToolbar:
|
||||
@@ -139,12 +141,14 @@ class BrowserBottomAppBar extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Material(
|
||||
shadowColor: Colors.transparent,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
color: colorScheme.surfaceContainer,
|
||||
// Transparent so the navigation-bar inset region behind this padding is
|
||||
// filled by the BrowserSystemBars tint strip (matching the active
|
||||
// container color), instead of a fixed surfaceContainer fill.
|
||||
color: Colors.transparent,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: bottomPadding),
|
||||
child: SizedBox(height: _size.height, child: _tabBar),
|
||||
@@ -158,7 +162,7 @@ class BrowserBottomAppBar extends StatelessWidget {
|
||||
class BrowserTabBar extends HookConsumerWidget {
|
||||
final bool showMainToolbar;
|
||||
final bool showContextualToolbar;
|
||||
final bool showQuickTabSwitcherBar;
|
||||
final int quickTabSwitcherRowCount;
|
||||
final Sheet? displayedSheet;
|
||||
final bool hideMainToolbarButtonsDuplicatedInContextualToolbar;
|
||||
final bool isSmallWebMode;
|
||||
@@ -169,7 +173,7 @@ class BrowserTabBar extends HookConsumerWidget {
|
||||
required this.showMainToolbar,
|
||||
required this.displayedSheet,
|
||||
required this.showContextualToolbar,
|
||||
required this.showQuickTabSwitcherBar,
|
||||
required this.quickTabSwitcherRowCount,
|
||||
required this.isSmallWebMode,
|
||||
required this.enableGestures,
|
||||
this.hideMainToolbarButtonsDuplicatedInContextualToolbar = false,
|
||||
@@ -183,7 +187,7 @@ class BrowserTabBar extends HookConsumerWidget {
|
||||
(!showContextualToolbar || displayedSheet is! ViewTabsSheet);
|
||||
|
||||
bool get displayQuickTabSwitcher =>
|
||||
showQuickTabSwitcherBar && displayedSheet is! ViewTabsSheet;
|
||||
quickTabSwitcherRowCount > 0 && displayedSheet is! ViewTabsSheet;
|
||||
|
||||
double getToolbarHeight() {
|
||||
var height = 0.0;
|
||||
@@ -197,7 +201,7 @@ class BrowserTabBar extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
if (displayQuickTabSwitcher) {
|
||||
height += quickTabSwitcherHeight;
|
||||
height += quickTabSwitcherHeight * quickTabSwitcherRowCount;
|
||||
}
|
||||
|
||||
return height;
|
||||
@@ -242,7 +246,7 @@ class BrowserTabBar extends HookConsumerWidget {
|
||||
).select((data) => data.value?.metadata.useCustomColor ?? false),
|
||||
);
|
||||
|
||||
final quickTabSwitcherMode = settings.effectiveUiQuickTabSwitcherMode();
|
||||
final stackingMode = settings.effectiveTabBarStackingMode();
|
||||
|
||||
final tabBarPosition = settings.tabBarPosition;
|
||||
|
||||
@@ -269,7 +273,7 @@ class BrowserTabBar extends HookConsumerWidget {
|
||||
return BrowserTabBarView(
|
||||
showMainToolbar: showMainToolbar,
|
||||
showContextualToolbar: showContextualToolbar,
|
||||
showQuickTabSwitcherBar: showQuickTabSwitcherBar,
|
||||
showQuickTabSwitcherBar: quickTabSwitcherRowCount > 0,
|
||||
displayAppBar: displayAppBar,
|
||||
displayQuickTabSwitcher: displayQuickTabSwitcher,
|
||||
backgroundColor: effectiveContainerPalette?.surfaceColor,
|
||||
@@ -308,9 +312,30 @@ class BrowserTabBar extends HookConsumerWidget {
|
||||
if (showMainToolbarNavigationButton)
|
||||
NavigationMenuButton(selectedTabId: selectedTabId),
|
||||
],
|
||||
quickTabSwitcher: QuickTabSwitcher(
|
||||
quickTabSwitcherMode: quickTabSwitcherMode,
|
||||
),
|
||||
quickTabSwitcher: switch (stackingMode) {
|
||||
TabBarStackingMode.disabled => const SizedBox.shrink(),
|
||||
TabBarStackingMode.lastUsedTabs => const QuickTabSwitcher(
|
||||
quickTabSwitcherMode: QuickTabSwitcherMode.lastUsedTabs,
|
||||
),
|
||||
TabBarStackingMode.containerTabs => const QuickTabSwitcher(
|
||||
quickTabSwitcherMode: QuickTabSwitcherMode.containerTabs,
|
||||
),
|
||||
TabBarStackingMode.accordion => const AccordionQuickTabSwitcher(),
|
||||
// History fallback only on the MRU row, so empty-state history
|
||||
// chips don't show twice.
|
||||
TabBarStackingMode.twoLevel => const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
QuickTabSwitcher(
|
||||
quickTabSwitcherMode: QuickTabSwitcherMode.containerTabs,
|
||||
enableHistoryFallback: false,
|
||||
),
|
||||
QuickTabSwitcher(
|
||||
quickTabSwitcherMode: QuickTabSwitcherMode.lastUsedTabs,
|
||||
),
|
||||
],
|
||||
),
|
||||
},
|
||||
contextualToolbar: ContextualToolbar(
|
||||
selectedTabId: selectedTabId,
|
||||
displayedSheet: displayedSheet,
|
||||
@@ -472,107 +497,19 @@ class BrowserTabBarView extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class QuickTabSwitcherItem with FastEquatable {
|
||||
final Color? color;
|
||||
final bool useCustomColor;
|
||||
final String id;
|
||||
final bool isActive;
|
||||
final TabMode tabMode;
|
||||
final bool isHistory;
|
||||
final bool isPinned;
|
||||
final bool isSandbox;
|
||||
final int depth;
|
||||
final String title;
|
||||
final Uri url;
|
||||
final Widget avatar;
|
||||
|
||||
QuickTabSwitcherItem({
|
||||
required this.color,
|
||||
required this.id,
|
||||
required this.isActive,
|
||||
required this.tabMode,
|
||||
required this.isHistory,
|
||||
required this.isPinned,
|
||||
required this.title,
|
||||
required this.url,
|
||||
required this.avatar,
|
||||
this.useCustomColor = false,
|
||||
this.isSandbox = false,
|
||||
this.depth = 0,
|
||||
});
|
||||
|
||||
/// Builds a switcher entry for an open tab. [sandboxSourceUri] is the
|
||||
/// canonical source URL when the tab is a sandbox capture (otherwise null),
|
||||
/// so the bar shows the real site instead of the loopback capture URL.
|
||||
factory QuickTabSwitcherItem.tab(
|
||||
TabStateWithContainer state, {
|
||||
required String? selectedTabId,
|
||||
required Set<String> pinnedTabIds,
|
||||
required Map<String, int> tabDepthById,
|
||||
required Uri? sandboxSourceUri,
|
||||
}) {
|
||||
final (tab, container) = state;
|
||||
|
||||
return QuickTabSwitcherItem(
|
||||
color: container?.color,
|
||||
useCustomColor: container?.metadata.useCustomColor ?? false,
|
||||
id: tab.id,
|
||||
isActive: tab.id == selectedTabId,
|
||||
title: sandboxSourceUri != null && tab.title.isEmpty
|
||||
? sandboxSourceUri.authority
|
||||
: tab.titleOrAuthority,
|
||||
tabMode: tab.tabMode,
|
||||
isHistory: false,
|
||||
isPinned: pinnedTabIds.contains(tab.id),
|
||||
isSandbox: sandboxSourceUri != null,
|
||||
depth: tabDepthById[tab.id] ?? 0,
|
||||
url: sandboxSourceUri ?? tab.url,
|
||||
avatar: TabIcon(tabState: tab, iconSize: 20),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a switcher entry for a history suggestion (shown only when there
|
||||
/// are no open tabs in the active mode).
|
||||
factory QuickTabSwitcherItem.history({
|
||||
required String url,
|
||||
required String? title,
|
||||
}) {
|
||||
final parsedUrl = Uri.parse(url);
|
||||
|
||||
return QuickTabSwitcherItem(
|
||||
color: null,
|
||||
id: url,
|
||||
isActive: false,
|
||||
title: title ?? parsedUrl.authority,
|
||||
tabMode: TabMode.regular,
|
||||
isHistory: true,
|
||||
isPinned: false,
|
||||
url: parsedUrl,
|
||||
avatar: UrlIcon([parsedUrl], iconSize: 20),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [
|
||||
color,
|
||||
useCustomColor,
|
||||
id,
|
||||
isActive,
|
||||
tabMode,
|
||||
isHistory,
|
||||
isPinned,
|
||||
isSandbox,
|
||||
depth,
|
||||
title,
|
||||
url,
|
||||
avatar,
|
||||
];
|
||||
}
|
||||
|
||||
class QuickTabSwitcher extends HookConsumerWidget {
|
||||
final QuickTabSwitcherMode quickTabSwitcherMode;
|
||||
|
||||
const QuickTabSwitcher({super.key, required this.quickTabSwitcherMode});
|
||||
/// Whether the row falls back to history suggestion chips when it has no
|
||||
/// open tabs. Disabled for the top row in two-level stacking so history
|
||||
/// chips don't show twice.
|
||||
final bool enableHistoryFallback;
|
||||
|
||||
const QuickTabSwitcher({
|
||||
super.key,
|
||||
required this.quickTabSwitcherMode,
|
||||
this.enableHistoryFallback = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -584,9 +521,14 @@ class QuickTabSwitcher extends HookConsumerWidget {
|
||||
(s) => s.quickTabSwitcherShowTitles,
|
||||
),
|
||||
);
|
||||
final effectiveMode = ref.watch(
|
||||
final titleMaxWidth = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(settings) => settings.effectiveUiQuickTabSwitcherMode(),
|
||||
(s) => s.quickTabSwitcherTitleWidth,
|
||||
),
|
||||
);
|
||||
final showCloseButtonOnAllTabs = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(s) => s.quickTabSwitcherShowCloseButtonOnAllTabs,
|
||||
),
|
||||
);
|
||||
final tabBarDirection = ref.watch(
|
||||
@@ -596,9 +538,15 @@ class QuickTabSwitcher extends HookConsumerWidget {
|
||||
quickTabSwitcherTabStatesProvider(quickTabSwitcherMode),
|
||||
);
|
||||
final selectedTabId = ref.watch(selectedTabProvider);
|
||||
final historySuggestions = ref
|
||||
.watch(quickTabSwitcherHistorySuggestionsProvider(quickTabSwitcherMode))
|
||||
.value;
|
||||
final historySuggestions = enableHistoryFallback
|
||||
? ref
|
||||
.watch(
|
||||
quickTabSwitcherHistorySuggestionsProvider(
|
||||
quickTabSwitcherMode,
|
||||
),
|
||||
)
|
||||
.value
|
||||
: null;
|
||||
final sandboxCaptureMap =
|
||||
ref.watch(sandboxCaptureMapProvider).value ?? const {};
|
||||
// Reorder is only meaningful when the bar renders the user's actual tab
|
||||
@@ -616,7 +564,7 @@ class QuickTabSwitcher extends HookConsumerWidget {
|
||||
final showHierarchicalTabs = hierarchyGlyphs > 0;
|
||||
final selectedContainerId = ref.watch(selectedContainerProvider);
|
||||
final hierarchyContainerId =
|
||||
effectiveMode == QuickTabSwitcherMode.containerTabs
|
||||
quickTabSwitcherMode == QuickTabSwitcherMode.containerTabs
|
||||
? selectedContainerId
|
||||
: null;
|
||||
|
||||
@@ -639,8 +587,14 @@ class QuickTabSwitcher extends HookConsumerWidget {
|
||||
(value) => value.value ?? const <String>{},
|
||||
),
|
||||
);
|
||||
final reorderEnabled =
|
||||
effectiveMode == QuickTabSwitcherMode.containerTabs && canManualReorder;
|
||||
final restoreComplete = ref.watch(browserRestoreCompleteProvider);
|
||||
final nativeTabIds = ref
|
||||
.watch(
|
||||
tabStatesProvider.select(
|
||||
(states) => EquatableValue(states.keys.toSet()),
|
||||
),
|
||||
)
|
||||
.value;
|
||||
final tabItems = tabStates.value
|
||||
.map(
|
||||
(state) => QuickTabSwitcherItem.tab(
|
||||
@@ -651,9 +605,17 @@ class QuickTabSwitcher extends HookConsumerWidget {
|
||||
sandboxSourceUri: parseSandboxSource(
|
||||
sandboxCaptureMap[state.$1.id],
|
||||
),
|
||||
isPlaceholder:
|
||||
!restoreComplete && !nativeTabIds.contains(state.$1.id),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
// Reorder is disabled while placeholders are present: the engine doesn't
|
||||
// know those tabs yet, so a reorder couldn't be applied consistently.
|
||||
final reorderEnabled =
|
||||
quickTabSwitcherMode == QuickTabSwitcherMode.containerTabs &&
|
||||
canManualReorder &&
|
||||
!tabItems.any((item) => item.isPlaceholder);
|
||||
final historyItems = (historySuggestions ?? [])
|
||||
.map(
|
||||
(visit) =>
|
||||
@@ -673,7 +635,6 @@ class QuickTabSwitcher extends HookConsumerWidget {
|
||||
final activeItemKey = useRef(GlobalKey());
|
||||
final isUserScrolling = useRef(false);
|
||||
final userScrollTimer = useRef<Timer?>(null);
|
||||
final didRunInitialAutoScroll = useRef(false);
|
||||
final scrollKey = PageStorageKey(
|
||||
'quick_tab_switcher_${quickTabSwitcherMode.name}',
|
||||
);
|
||||
@@ -682,61 +643,15 @@ class QuickTabSwitcher extends HookConsumerWidget {
|
||||
return userScrollTimer.value?.cancel;
|
||||
}, []);
|
||||
|
||||
useEffect(() {
|
||||
if (isUserScrolling.value) return null;
|
||||
|
||||
final isInitialAutoScroll = !didRunInitialAutoScroll.value;
|
||||
didRunInitialAutoScroll.value = true;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (isInitialAutoScroll &&
|
||||
chipScrollController.hasClients &&
|
||||
chipScrollController.offset != 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
final context = activeItemKey.value.currentContext;
|
||||
if (context != null) {
|
||||
unawaited(
|
||||
Scrollable.ensureVisible(
|
||||
context,
|
||||
alignment: 0.5,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
),
|
||||
);
|
||||
} else if (chipScrollController.hasClients) {
|
||||
final activeIndex = availableItems.indexWhere(
|
||||
(item) => item.id == selectedTabId,
|
||||
);
|
||||
if (activeIndex < 0) return;
|
||||
|
||||
final totalItems = availableItems.length;
|
||||
final maxExtent = chipScrollController.position.maxScrollExtent;
|
||||
if (totalItems > 0 && maxExtent > 0) {
|
||||
chipScrollController.jumpTo(
|
||||
(activeIndex / totalItems * maxExtent).clamp(0.0, maxExtent),
|
||||
);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final retryContext = activeItemKey.value.currentContext;
|
||||
if (retryContext != null) {
|
||||
unawaited(
|
||||
Scrollable.ensureVisible(
|
||||
retryContext,
|
||||
alignment: 0.5,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
}, [selectedTabId]);
|
||||
// Keep the active chip centered when the selection or ordering changes,
|
||||
// even if it is far outside the lazily-built range.
|
||||
useScrollToActiveChip<String>(
|
||||
controller: chipScrollController,
|
||||
activeChipKey: activeItemKey.value,
|
||||
activeId: (activeItem?.isActive ?? false) ? activeItem?.id : null,
|
||||
orderedIds: [for (final item in availableItems) item.id],
|
||||
isUserScrolling: () => isUserScrolling.value,
|
||||
);
|
||||
|
||||
return NotificationListener<UserScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
@@ -765,7 +680,12 @@ class QuickTabSwitcher extends HookConsumerWidget {
|
||||
showTitles: showTitles,
|
||||
showIsolatedTabUi: showIsolatedTabUi,
|
||||
hierarchyGlyphs: hierarchyGlyphs,
|
||||
enablePinTabInMenu: effectiveMode == QuickTabSwitcherMode.containerTabs,
|
||||
titleMaxWidth: titleMaxWidth,
|
||||
showCloseButtonOnAllTabs: showCloseButtonOnAllTabs,
|
||||
enablePinTabInMenu:
|
||||
quickTabSwitcherMode == QuickTabSwitcherMode.containerTabs,
|
||||
onCloseItem: (item) =>
|
||||
closeTabWithConfirmationAndUndo(context, ref, item.id),
|
||||
onSelected: (item) async {
|
||||
if (!item.isHistory && item.isActive) {
|
||||
return;
|
||||
@@ -837,8 +757,11 @@ class QuickTabSwitcherView extends StatelessWidget {
|
||||
required this.showTitles,
|
||||
required this.showIsolatedTabUi,
|
||||
this.hierarchyGlyphs = defaultQuickTabSwitcherHierarchyGlyphs,
|
||||
this.titleMaxWidth = defaultQuickTabSwitcherTitleWidth,
|
||||
this.showCloseButtonOnAllTabs = false,
|
||||
required this.enablePinTabInMenu,
|
||||
required this.onSelected,
|
||||
this.onCloseItem,
|
||||
this.onReorderItem,
|
||||
this.reorderableItemCount = 0,
|
||||
});
|
||||
@@ -855,9 +778,20 @@ class QuickTabSwitcherView extends StatelessWidget {
|
||||
/// into an icon + count badge. A value of 0 hides the indicator entirely.
|
||||
final int hierarchyGlyphs;
|
||||
|
||||
/// Max width of a chip's title text.
|
||||
final double titleMaxWidth;
|
||||
|
||||
/// Whether every tab chip shows a close button. The active tab's chip
|
||||
/// always shows one when [onCloseItem] is set.
|
||||
final bool showCloseButtonOnAllTabs;
|
||||
|
||||
final bool enablePinTabInMenu;
|
||||
final Future<void> Function(QuickTabSwitcherItem item) onSelected;
|
||||
|
||||
/// Close handler backing the chips' close buttons. When null no close
|
||||
/// buttons are shown at all.
|
||||
final Future<void> Function(QuickTabSwitcherItem item)? onCloseItem;
|
||||
|
||||
/// When non-null, the first [reorderableItemCount] items are rendered as a
|
||||
/// horizontal `ReorderableListView` driven by this callback. Otherwise the
|
||||
/// view falls back to the non-reorderable `SelectableChips` layout.
|
||||
@@ -869,10 +803,19 @@ class QuickTabSwitcherView extends StatelessWidget {
|
||||
|
||||
bool get _reorderEnabled => onReorderItem != null && reorderableItemCount > 0;
|
||||
|
||||
/// Whether [item]'s chip shows a close button.
|
||||
bool _canShowCloseButton(QuickTabSwitcherItem item) =>
|
||||
onCloseItem != null &&
|
||||
!item.isHistory &&
|
||||
!item.isPlaceholder &&
|
||||
(showCloseButtonOnAllTabs || item.isActive);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (availableItems.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
// Hold the 48px row slot: in two-level stacking an empty row must not
|
||||
// collapse, since the toolbar height already accounts for both rows.
|
||||
return const SizedBox(height: 48);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
@@ -889,7 +832,7 @@ class QuickTabSwitcherView extends StatelessWidget {
|
||||
|
||||
Widget _buildSelectableChips(BuildContext context) {
|
||||
return SelectableChips<QuickTabSwitcherItem, QuickTabSwitcherItem, String>(
|
||||
enableDelete: false,
|
||||
enableDelete: onCloseItem != null,
|
||||
sortSelectedFirst: false,
|
||||
maxCount: null,
|
||||
scrollController: scrollController,
|
||||
@@ -902,8 +845,11 @@ class QuickTabSwitcherView extends StatelessWidget {
|
||||
decoration: _chipDecoration(context),
|
||||
itemLabel: (item) => _chipLabel(context, item, activeItem?.id == item.id),
|
||||
onSelected: onSelected,
|
||||
onDeleted: (item) {
|
||||
unawaited(onCloseItem?.call(item));
|
||||
},
|
||||
itemWrap: (child, item) =>
|
||||
item.isHistory ? child : _wrapWithMenu(itemId: item.id, child: child),
|
||||
item.isHistory ? child : _wrapWithMenu(item: item, child: child),
|
||||
availableItems: availableItems,
|
||||
);
|
||||
}
|
||||
@@ -928,15 +874,14 @@ class QuickTabSwitcherView extends StatelessWidget {
|
||||
itemBuilder: (context, index) {
|
||||
final item = availableItems[index];
|
||||
final isSelected = activeItem?.id == item.id;
|
||||
final chip = _ReorderableSwitcherChip(
|
||||
final chip = QuickTabSwitcherChip(
|
||||
item: item,
|
||||
isSelected: isSelected,
|
||||
showTitles: showTitles,
|
||||
showIsolatedTabUi: showIsolatedTabUi,
|
||||
selectedBorderColor: Theme.of(context).colorScheme.primary,
|
||||
decoration: _chipDecoration(context),
|
||||
label: _chipLabel(context, item, isSelected),
|
||||
onTap: () => onSelected(item),
|
||||
onDelete: _canShowCloseButton(item) ? () => onCloseItem!(item) : null,
|
||||
);
|
||||
final keyedForActive = isSelected && activeItemKey != null
|
||||
? KeyedSubtree(key: activeItemKey, child: chip)
|
||||
@@ -956,72 +901,26 @@ class QuickTabSwitcherView extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _wrapWithMenu({required String itemId, required Widget child}) {
|
||||
return TabMenu(
|
||||
selectedTabId: itemId,
|
||||
enableFindInPage: false,
|
||||
enableFetchFeeds: false,
|
||||
enableDesktopMode: false,
|
||||
enableReaderMode: false,
|
||||
enableReloadButton: false,
|
||||
enableNavigationButtons: false,
|
||||
enableAddToHomeScreen: false,
|
||||
Widget _wrapWithMenu({
|
||||
required QuickTabSwitcherItem item,
|
||||
required Widget child,
|
||||
}) {
|
||||
return wrapQuickTabSwitcherChipWithMenu(
|
||||
itemId: item.id,
|
||||
enabled: !item.isPlaceholder,
|
||||
enablePinTab: enablePinTabInMenu,
|
||||
builder: (context, controller, _) {
|
||||
return InkWell(
|
||||
onLongPress: () {
|
||||
if (controller.isOpen) {
|
||||
controller.close();
|
||||
} else {
|
||||
controller.open();
|
||||
}
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
SelectableChipDecoration<QuickTabSwitcherItem> _chipDecoration(
|
||||
BuildContext context,
|
||||
) {
|
||||
return SelectableChipDecoration(
|
||||
color: (item, isSelected) => switch (item.color) {
|
||||
final color? when isSelected => ContainerColors.palette(
|
||||
context,
|
||||
color,
|
||||
useCustomColor: item.useCustomColor,
|
||||
).selectedBackgroundColor,
|
||||
final color? => ContainerColors.palette(
|
||||
context,
|
||||
color,
|
||||
useCustomColor: item.useCustomColor,
|
||||
).backgroundColor,
|
||||
null => null,
|
||||
},
|
||||
side: (item, isSelected) => switch (item.color) {
|
||||
final color? when isSelected => ContainerColors.palette(
|
||||
context,
|
||||
color,
|
||||
useCustomColor: item.useCustomColor,
|
||||
).selectedBorderSide,
|
||||
final color? => ContainerColors.palette(
|
||||
context,
|
||||
color,
|
||||
useCustomColor: item.useCustomColor,
|
||||
).borderSide,
|
||||
null => null,
|
||||
},
|
||||
labelPadding: (item) =>
|
||||
(!showTitles &&
|
||||
!item.isHistory &&
|
||||
!item.isPinned &&
|
||||
!item.isSandbox &&
|
||||
(item.depth == 0 || hierarchyGlyphs == 0) &&
|
||||
item.tabMode is! PrivateTabMode &&
|
||||
item.tabMode is! IsolatedTabMode)
|
||||
? EdgeInsets.zero
|
||||
: null,
|
||||
return buildQuickTabSwitcherChipDecoration(
|
||||
context,
|
||||
showTitles: showTitles,
|
||||
hierarchyGlyphs: hierarchyGlyphs,
|
||||
canDelete: _canShowCloseButton,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1030,145 +929,14 @@ class QuickTabSwitcherView extends StatelessWidget {
|
||||
QuickTabSwitcherItem item,
|
||||
bool isSelected,
|
||||
) {
|
||||
final appColors = AppColors.of(context);
|
||||
final hasTitle = item.isHistory || showTitles;
|
||||
final row = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (item.depth > 0 && hierarchyGlyphs > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 6.0),
|
||||
child: TabDepthIndicator(
|
||||
depth: item.depth,
|
||||
height: 24.0,
|
||||
iconSize: 14.0,
|
||||
horizontalPadding: 4.0,
|
||||
maxInlineGlyphs: hierarchyGlyphs,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(right: hasTitle ? 6.0 : 0.0),
|
||||
child: item.avatar,
|
||||
),
|
||||
if (hasTitle)
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 64),
|
||||
child: Text(item.title),
|
||||
),
|
||||
if (showIsolatedTabUi && item.tabMode is IsolatedTabMode)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Icon(
|
||||
MdiIcons.snowflake,
|
||||
color: appColors.isolatedTabTeal,
|
||||
size: 20,
|
||||
),
|
||||
)
|
||||
else if (item.tabMode is PrivateTabMode)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Icon(
|
||||
MdiIcons.dominoMask,
|
||||
color: appColors.privateTabPurple,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
if (item.isSandbox)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Icon(
|
||||
MdiIcons.archiveLockOutline,
|
||||
color: Theme.of(context).colorScheme.tertiary,
|
||||
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),
|
||||
child: Icon(MdiIcons.history, size: 20),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return item.color.mapNotNull(
|
||||
(color) => DefaultTextStyle.merge(
|
||||
style: TextStyle(
|
||||
color: isSelected
|
||||
? ContainerColors.palette(
|
||||
context,
|
||||
color,
|
||||
useCustomColor: item.useCustomColor,
|
||||
).selectedForegroundColor
|
||||
: ContainerColors.palette(
|
||||
context,
|
||||
color,
|
||||
useCustomColor: item.useCustomColor,
|
||||
).foregroundColor,
|
||||
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
|
||||
),
|
||||
child: row,
|
||||
),
|
||||
) ??
|
||||
row;
|
||||
}
|
||||
}
|
||||
|
||||
/// FilterChip matching `SelectableChips`' visual contract, used in the
|
||||
/// reorderable render path. Stateless wrapper so the parent
|
||||
/// `ReorderableListView` can attach its drag-handle gesture recognizer.
|
||||
class _ReorderableSwitcherChip extends StatelessWidget {
|
||||
final QuickTabSwitcherItem item;
|
||||
final bool isSelected;
|
||||
final bool showTitles;
|
||||
final bool showIsolatedTabUi;
|
||||
final Color selectedBorderColor;
|
||||
final SelectableChipDecoration<QuickTabSwitcherItem> decoration;
|
||||
final Widget label;
|
||||
final Future<void> Function() onTap;
|
||||
|
||||
const _ReorderableSwitcherChip({
|
||||
required this.item,
|
||||
required this.isSelected,
|
||||
required this.showTitles,
|
||||
required this.showIsolatedTabUi,
|
||||
required this.selectedBorderColor,
|
||||
required this.decoration,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final itemColor = decoration.color?.call(item, isSelected);
|
||||
final side =
|
||||
decoration.side?.call(item, isSelected) ??
|
||||
(isSelected
|
||||
? BorderSide(color: selectedBorderColor, width: 2.0)
|
||||
: null);
|
||||
final labelPadding = decoration.labelPadding?.call(item);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0, top: 4.0),
|
||||
child: FilterChip(
|
||||
color: itemColor != null ? WidgetStatePropertyAll(itemColor) : null,
|
||||
selected: false,
|
||||
showCheckmark: false,
|
||||
labelPadding: labelPadding,
|
||||
onSelected: (_) {
|
||||
unawaited(onTap());
|
||||
},
|
||||
label: label,
|
||||
side: side,
|
||||
),
|
||||
return buildQuickTabSwitcherChipLabel(
|
||||
context,
|
||||
item,
|
||||
isSelected: isSelected,
|
||||
showTitles: showTitles,
|
||||
showIsolatedTabUi: showIsolatedTabUi,
|
||||
hierarchyGlyphs: hierarchyGlyphs,
|
||||
titleMaxWidth: titleMaxWidth,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
|
||||
/// Tints the status-bar (top) and navigation-bar (bottom) inset regions with
|
||||
/// the active container's surface color, mirroring how the tab bar
|
||||
/// ([BrowserBottomAppBar]) tints itself, and drives the system bar icon
|
||||
/// brightness so the icons stay legible against the tint.
|
||||
///
|
||||
/// The app runs edge-to-edge, so on modern Android the native
|
||||
/// `statusBarColor` / `navigationBarColor` window attributes are ignored — the
|
||||
/// system bars are transparent and content draws behind them. The inset
|
||||
/// regions are therefore filled in Flutter, and only the icon brightness is
|
||||
/// forwarded to the platform through [SystemUiOverlayStyle].
|
||||
///
|
||||
/// Designed to be placed as a full-bleed ([Positioned.fill]) layer in the
|
||||
/// browser [Stack], above the browser content but below the toolbars. The tab
|
||||
/// bar's safe-area padding is transparent, so the bottom strip shows through
|
||||
/// behind a visible bottom toolbar and remains visible when it is hidden.
|
||||
class BrowserSystemBars extends HookConsumerWidget {
|
||||
final double topInset;
|
||||
final double bottomInset;
|
||||
|
||||
const BrowserSystemBars({
|
||||
super.key,
|
||||
required this.topInset,
|
||||
required this.bottomInset,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final selectedTabId = ref.watch(selectedTabProvider);
|
||||
|
||||
final showContainerUi = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select((s) => s.showContainerUi),
|
||||
);
|
||||
final containerColor = ref.watch(
|
||||
watchTabContainerDataProvider(
|
||||
selectedTabId,
|
||||
).select((data) => data.value?.color),
|
||||
);
|
||||
final useCustomColor = ref.watch(
|
||||
watchTabContainerDataProvider(
|
||||
selectedTabId,
|
||||
).select((data) => data.value?.metadata.useCustomColor ?? false),
|
||||
);
|
||||
|
||||
final effectiveContainerColor = (showContainerUi && containerColor != null)
|
||||
? containerColor
|
||||
: null;
|
||||
final palette = effectiveContainerColor != null
|
||||
? ContainerColors.palette(
|
||||
context,
|
||||
effectiveContainerColor,
|
||||
useCustomColor: useCustomColor,
|
||||
)
|
||||
: null;
|
||||
|
||||
// Mirror the tab bar's surfaceContainer fallback so the inset regions stay
|
||||
// visually attached to the toolbar even when no container is active.
|
||||
final tintColor = palette?.surfaceColor ?? colorScheme.surfaceContainer;
|
||||
|
||||
// Icons must contrast with whatever fills the inset region.
|
||||
final iconBrightness =
|
||||
ThemeData.estimateBrightnessForColor(tintColor) == Brightness.dark
|
||||
? Brightness.light
|
||||
: Brightness.dark;
|
||||
|
||||
return IgnorePointer(
|
||||
child: AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: SystemUiOverlayStyle(
|
||||
statusBarIconBrightness: iconBrightness,
|
||||
systemNavigationBarIconBrightness: iconBrightness,
|
||||
// iOS reports the bar's own brightness rather than the icons'.
|
||||
statusBarBrightness: iconBrightness == Brightness.light
|
||||
? Brightness.dark
|
||||
: Brightness.light,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: topInset,
|
||||
child: ColoredBox(color: tintColor),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: bottomInset,
|
||||
child: ColoredBox(color: tintColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+555
@@ -0,0 +1,555 @@
|
||||
/*
|
||||
* 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:fading_scroll/fading_scroll.dart';
|
||||
import 'package:fast_equatable/fast_equatable.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/restore_complete.dart';
|
||||
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/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/utils/close_tab_helper.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/quick_tab_switcher_chip.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/container_filter.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_entity.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chip_content.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
import 'package:weblibre/presentation/hooks/scroll_to_active_chip.dart';
|
||||
import 'package:weblibre/presentation/widgets/inline_count_badge.dart';
|
||||
|
||||
/// Accordion stacking mode for the quick tab switcher bar: every available
|
||||
/// container renders as a header chip and the selected container is
|
||||
/// "expanded" — its tabs appear inline right after its header. Tapping
|
||||
/// another header selects that container, collapsing the previous group.
|
||||
class AccordionQuickTabSwitcher extends HookConsumerWidget {
|
||||
const AccordionQuickTabSwitcher({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scrollController = useScrollController();
|
||||
final activeChipKey = useRef(GlobalKey());
|
||||
final isUserScrolling = useRef(false);
|
||||
final userScrollTimer = useRef<Timer?>(null);
|
||||
|
||||
useEffect(() {
|
||||
return userScrollTimer.value?.cancel;
|
||||
}, []);
|
||||
|
||||
final showTitles = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(s) => s.quickTabSwitcherShowTitles,
|
||||
),
|
||||
);
|
||||
final showIsolatedTabUi = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select((s) => s.showIsolatedTabUi),
|
||||
);
|
||||
final hierarchyGlyphs = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(s) => s.quickTabSwitcherHierarchyGlyphs,
|
||||
),
|
||||
);
|
||||
final titleMaxWidth = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(s) => s.quickTabSwitcherTitleWidth,
|
||||
),
|
||||
);
|
||||
final showCloseButtonOnAllTabs = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(s) => s.quickTabSwitcherShowCloseButtonOnAllTabs,
|
||||
),
|
||||
);
|
||||
|
||||
final containers =
|
||||
ref.watch(
|
||||
watchContainersWithCountProvider.select((value) => value.value),
|
||||
) ??
|
||||
const <ContainerDataWithCount>[];
|
||||
final selectedContainerId = ref.watch(selectedContainerProvider);
|
||||
final selectedTabId = ref.watch(selectedTabProvider);
|
||||
|
||||
final unassignedTabCount = ref.watch(
|
||||
containerTabCountProvider(
|
||||
// ignore: provider_parameters
|
||||
ContainerFilterById(containerId: null),
|
||||
).select((value) => value.value ?? 0),
|
||||
);
|
||||
|
||||
final expandedTabStates = ref.watch(
|
||||
selectedContainerTabStatesWithContainerProvider,
|
||||
);
|
||||
final pinnedTabIds = ref.watch(
|
||||
watchPinnedTabIdsProvider.select(
|
||||
(value) => value.value ?? const <String>{},
|
||||
),
|
||||
);
|
||||
final sandboxCaptureMap =
|
||||
ref.watch(sandboxCaptureMapProvider).value ?? const {};
|
||||
final restoreComplete = ref.watch(browserRestoreCompleteProvider);
|
||||
final nativeTabIds = ref
|
||||
.watch(
|
||||
tabStatesProvider.select(
|
||||
(states) => EquatableValue(states.keys.toSet()),
|
||||
),
|
||||
)
|
||||
.value;
|
||||
final tabDepthById = ref
|
||||
.watch(
|
||||
groupedTabListItemsProvider(containerId: selectedContainerId).select((
|
||||
value,
|
||||
) {
|
||||
return EquatableValue(<String, int>{
|
||||
if (hierarchyGlyphs > 0)
|
||||
for (final item in value.value)
|
||||
if (item is TabListChildItem) item.tabId: item.depth,
|
||||
});
|
||||
}),
|
||||
)
|
||||
.value;
|
||||
|
||||
final expandedItems = expandedTabStates.value
|
||||
.map(
|
||||
(state) => QuickTabSwitcherItem.tab(
|
||||
state,
|
||||
selectedTabId: selectedTabId,
|
||||
pinnedTabIds: pinnedTabIds,
|
||||
tabDepthById: tabDepthById,
|
||||
sandboxSourceUri: parseSandboxSource(
|
||||
sandboxCaptureMap[state.$1.id],
|
||||
),
|
||||
isPlaceholder:
|
||||
!restoreComplete && !nativeTabIds.contains(state.$1.id),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
final decoration = buildQuickTabSwitcherChipDecoration(
|
||||
context,
|
||||
showTitles: showTitles,
|
||||
hierarchyGlyphs: hierarchyGlyphs,
|
||||
// The tray is painted in the container color, so the active tab's normal
|
||||
// (transparent) selected border would blend in; give it a thicker border
|
||||
// in the container's outline color instead.
|
||||
thickContainerSelectedBorder: true,
|
||||
);
|
||||
|
||||
Future<void> selectContainer(String? containerId) async {
|
||||
if (containerId != null) {
|
||||
await ref
|
||||
.read(selectedContainerProvider.notifier)
|
||||
.setContainerId(containerId);
|
||||
} else {
|
||||
ref.read(selectedContainerProvider.notifier).clearContainer();
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildTabChip(QuickTabSwitcherItem item) {
|
||||
final isSelected = item.isActive;
|
||||
final canClose =
|
||||
!item.isPlaceholder && (showCloseButtonOnAllTabs || item.isActive);
|
||||
|
||||
final chip = QuickTabSwitcherChip(
|
||||
item: item,
|
||||
isSelected: isSelected,
|
||||
selectedBorderColor: Theme.of(context).colorScheme.primary,
|
||||
decoration: decoration,
|
||||
label: buildQuickTabSwitcherChipLabel(
|
||||
context,
|
||||
item,
|
||||
isSelected: isSelected,
|
||||
showTitles: showTitles,
|
||||
showIsolatedTabUi: showIsolatedTabUi,
|
||||
hierarchyGlyphs: hierarchyGlyphs,
|
||||
titleMaxWidth: titleMaxWidth,
|
||||
),
|
||||
// Spacing inside the expanded group is owned by the surrounding tray
|
||||
// slice so the slices abut into one continuous background.
|
||||
padding: EdgeInsets.zero,
|
||||
onTap: () async {
|
||||
if (item.isActive) {
|
||||
return;
|
||||
}
|
||||
await ref.read(tabRepositoryProvider.notifier).selectTab(item.id);
|
||||
},
|
||||
onDelete: canClose
|
||||
? () => closeTabWithConfirmationAndUndo(context, ref, item.id)
|
||||
: null,
|
||||
);
|
||||
|
||||
return wrapQuickTabSwitcherChipWithMenu(
|
||||
itemId: item.id,
|
||||
enabled: !item.isPlaceholder,
|
||||
enablePinTab: true,
|
||||
child: chip,
|
||||
);
|
||||
}
|
||||
|
||||
final showUnassignedGroup =
|
||||
unassignedTabCount > 0 || selectedContainerId == null;
|
||||
|
||||
final entries = <_AccordionEntry>[
|
||||
if (showUnassignedGroup)
|
||||
_AccordionEntry.header(
|
||||
container: null,
|
||||
tabCount: unassignedTabCount,
|
||||
isExpanded: selectedContainerId == null,
|
||||
),
|
||||
if (selectedContainerId == null)
|
||||
...expandedItems.map(_AccordionEntry.tab),
|
||||
for (final container in containers) ...[
|
||||
_AccordionEntry.header(
|
||||
container: container,
|
||||
tabCount: container.tabCount ?? 0,
|
||||
isExpanded: container.id == selectedContainerId,
|
||||
),
|
||||
if (container.id == selectedContainerId)
|
||||
...expandedItems.map(_AccordionEntry.tab),
|
||||
],
|
||||
];
|
||||
|
||||
// The expanded container header plus its tabs form one contiguous run that
|
||||
// is wrapped in a shared "tray" background so the group reads as a unit and
|
||||
// its members are visually distinct from the standalone container headers.
|
||||
// Each entry only knows which slice of that tray it paints; the slices abut
|
||||
// into one continuous rounded surface.
|
||||
final trayPositions = <_TrayPosition>[
|
||||
for (var i = 0; i < entries.length; i++)
|
||||
switch (entries[i]) {
|
||||
_AccordionHeaderEntry(:final isExpanded) =>
|
||||
!isExpanded
|
||||
? _TrayPosition.none
|
||||
: (i + 1 < entries.length &&
|
||||
entries[i + 1] is _AccordionTabEntry
|
||||
? _TrayPosition.start
|
||||
: _TrayPosition.solo),
|
||||
_AccordionTabEntry() =>
|
||||
(i + 1 >= entries.length || entries[i + 1] is _AccordionHeaderEntry)
|
||||
? _TrayPosition.end
|
||||
: _TrayPosition.middle,
|
||||
},
|
||||
];
|
||||
|
||||
// The expanded group's tray is filled with the container's color so the
|
||||
// whole group reads as "this container". The unassigned group has no color
|
||||
// and falls back to a neutral surface.
|
||||
ContainerDataWithCount? expandedContainer;
|
||||
for (final container in containers) {
|
||||
if (container.id == selectedContainerId) {
|
||||
expandedContainer = container;
|
||||
break;
|
||||
}
|
||||
}
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final trayPalette = expandedContainer != null
|
||||
? ContainerColors.palette(
|
||||
context,
|
||||
expandedContainer.color,
|
||||
useCustomColor: expandedContainer.metadata.useCustomColor,
|
||||
)
|
||||
: null;
|
||||
final trayFill = trayPalette?.containerColor ?? scheme.surfaceContainerHigh;
|
||||
|
||||
// The chip to keep centered: the active tab when it is part of the
|
||||
// expanded group, otherwise the expanded container header as a fallback.
|
||||
final activeTabEntryId = 'tab-$selectedTabId';
|
||||
final hasActiveTab = entries.any((entry) => entry.id == activeTabEntryId);
|
||||
String? expandedHeaderId;
|
||||
for (final entry in entries) {
|
||||
if (entry is _AccordionHeaderEntry && entry.isExpanded) {
|
||||
expandedHeaderId = entry.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
final activeEntryId = hasActiveTab ? activeTabEntryId : expandedHeaderId;
|
||||
|
||||
// Keep the active chip (or expanded header fallback) centered when the
|
||||
// selection or ordering changes, unless the user is scrolling themselves.
|
||||
useScrollToActiveChip<String>(
|
||||
controller: scrollController,
|
||||
activeChipKey: activeChipKey.value,
|
||||
activeId: activeEntryId,
|
||||
orderedIds: [for (final entry in entries) entry.id],
|
||||
isUserScrolling: () => isUserScrolling.value,
|
||||
);
|
||||
|
||||
if (entries.isEmpty) {
|
||||
// Hold the 48px row slot; the bar visibility is decided upstream by
|
||||
// quickTabSwitcherRowCountProvider.
|
||||
return const SizedBox(height: 48);
|
||||
}
|
||||
|
||||
return NotificationListener<UserScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
userScrollTimer.value?.cancel();
|
||||
isUserScrolling.value = true;
|
||||
userScrollTimer.value = Timer(const Duration(milliseconds: 1500), () {
|
||||
isUserScrolling.value = false;
|
||||
});
|
||||
return false;
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
width: double.maxFinite,
|
||||
child: FadingScroll(
|
||||
controller: scrollController,
|
||||
fadingSize: 15,
|
||||
builder: (context, controller) {
|
||||
return ListView.builder(
|
||||
key: const PageStorageKey('quick_tab_switcher_accordion'),
|
||||
controller: controller,
|
||||
scrollDirection: Axis.horizontal,
|
||||
scrollCacheExtent: const ScrollCacheExtent.pixels(500),
|
||||
itemCount: entries.length,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
final child = switch (entry) {
|
||||
_AccordionHeaderEntry() => _AccordionHeaderChip(
|
||||
entry: entry,
|
||||
onSelected: () => selectContainer(entry.container?.id),
|
||||
),
|
||||
_AccordionTabEntry(:final item) => buildTabChip(item),
|
||||
};
|
||||
|
||||
return KeyedSubtree(
|
||||
key: entry.id == activeEntryId
|
||||
? activeChipKey.value
|
||||
: ValueKey(entry.id),
|
||||
child: _TraySlice(
|
||||
position: trayPositions[index],
|
||||
fill: trayFill,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
sealed class _AccordionEntry {
|
||||
const _AccordionEntry();
|
||||
|
||||
factory _AccordionEntry.header({
|
||||
required ContainerDataWithCount? container,
|
||||
required int tabCount,
|
||||
required bool isExpanded,
|
||||
}) = _AccordionHeaderEntry;
|
||||
|
||||
factory _AccordionEntry.tab(QuickTabSwitcherItem item) = _AccordionTabEntry;
|
||||
|
||||
String get id;
|
||||
}
|
||||
|
||||
/// A container group header chip; [container] is null for the pseudo-group
|
||||
/// of tabs without a container.
|
||||
class _AccordionHeaderEntry extends _AccordionEntry {
|
||||
final ContainerDataWithCount? container;
|
||||
final int tabCount;
|
||||
final bool isExpanded;
|
||||
|
||||
const _AccordionHeaderEntry({
|
||||
required this.container,
|
||||
required this.tabCount,
|
||||
required this.isExpanded,
|
||||
});
|
||||
|
||||
@override
|
||||
String get id => 'container-${container?.id}';
|
||||
}
|
||||
|
||||
class _AccordionTabEntry extends _AccordionEntry {
|
||||
final QuickTabSwitcherItem item;
|
||||
|
||||
const _AccordionTabEntry(this.item);
|
||||
|
||||
@override
|
||||
String get id => 'tab-${item.id}';
|
||||
}
|
||||
|
||||
/// Container group header, rendered as a solid container-colored box. The fill
|
||||
/// is the same whether the container is selected (expanded) or not — selection
|
||||
/// only adds the surrounding tray and the inline tabs, it never recolors the
|
||||
/// header chip itself.
|
||||
class _AccordionHeaderChip extends StatelessWidget {
|
||||
final _AccordionHeaderEntry entry;
|
||||
final VoidCallback onSelected;
|
||||
|
||||
const _AccordionHeaderChip({required this.entry, required this.onSelected});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final container = entry.container;
|
||||
|
||||
// The header content sits on the container color, so it always uses the
|
||||
// on-container foreground.
|
||||
final Color fill;
|
||||
final Color nullForeground;
|
||||
final Color badgeBackground;
|
||||
final Color badgeForeground;
|
||||
|
||||
if (container != null) {
|
||||
final palette = ContainerColors.palette(
|
||||
context,
|
||||
container.color,
|
||||
useCustomColor: container.metadata.useCustomColor,
|
||||
);
|
||||
fill = palette.containerColor;
|
||||
nullForeground = palette.onContainerColor;
|
||||
badgeBackground = palette.badgeBackgroundColor;
|
||||
badgeForeground = palette.badgeForegroundColor;
|
||||
} else {
|
||||
fill = scheme.surfaceContainerHigh;
|
||||
nullForeground = scheme.onSurfaceVariant;
|
||||
badgeBackground = scheme.secondaryContainer;
|
||||
badgeForeground = scheme.onSecondaryContainer;
|
||||
}
|
||||
|
||||
final countBadge = entry.tabCount > 0
|
||||
? InlineCountBadge(
|
||||
count: entry.tabCount,
|
||||
backgroundColor: badgeBackground,
|
||||
foregroundColor: badgeForeground,
|
||||
)
|
||||
: null;
|
||||
|
||||
return FilterChip(
|
||||
avatar: container != null
|
||||
? buildContainerChipAvatar(context, container, true)
|
||||
: Icon(MdiIcons.folderHidden, color: nullForeground),
|
||||
label: container != null
|
||||
? buildContainerChipLabel(
|
||||
context,
|
||||
container,
|
||||
true,
|
||||
trailing: countBadge,
|
||||
)
|
||||
: SizedBox(
|
||||
height: 20,
|
||||
child: Center(
|
||||
child:
|
||||
countBadge ??
|
||||
DefaultTextStyle.merge(
|
||||
style: TextStyle(color: nullForeground),
|
||||
child: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Same fill regardless of selection — the tray (added when expanded)
|
||||
// is what signals the active container, not a header recolor.
|
||||
color: WidgetStatePropertyAll(fill),
|
||||
selected: false,
|
||||
showCheckmark: false,
|
||||
onSelected: (value) {
|
||||
if (value) {
|
||||
onSelected();
|
||||
}
|
||||
},
|
||||
side: BorderSide(width: 2, color: fill),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
side: BorderSide(width: 2, color: fill),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a chip sits within the expanded container's tray, controlling which
|
||||
/// rounded corners and edge padding its [_TraySlice] paints. [none] is a
|
||||
/// standalone (collapsed) header that carries no tray.
|
||||
enum _TrayPosition { none, solo, start, middle, end }
|
||||
|
||||
/// One slice of the shared tray behind an expanded group's chips. Adjacent
|
||||
/// slices abut with matching height and seam padding so their fill merges into
|
||||
/// a single continuous rounded, container-colored surface spanning the header
|
||||
/// and its tabs.
|
||||
class _TraySlice extends StatelessWidget {
|
||||
final _TrayPosition position;
|
||||
final Color fill;
|
||||
final Widget child;
|
||||
|
||||
const _TraySlice({
|
||||
required this.position,
|
||||
required this.fill,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
/// Corner radius of the chips, matched by the tray so it hugs the first and
|
||||
/// last chip's edges exactly.
|
||||
static const Radius _radius = Radius.circular(8.0);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (position == _TrayPosition.none) {
|
||||
// Standalone container header: regular inter-chip spacing, vertically
|
||||
// centered to line up with the tray slices.
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0.0, 2.0, 8.0, 2.0),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
final borderRadius = switch (position) {
|
||||
_TrayPosition.solo => const BorderRadius.all(_radius),
|
||||
_TrayPosition.start => const BorderRadius.horizontal(left: _radius),
|
||||
_TrayPosition.end => const BorderRadius.horizontal(right: _radius),
|
||||
_TrayPosition.middle || _TrayPosition.none => BorderRadius.zero,
|
||||
};
|
||||
|
||||
final isRightEdge =
|
||||
position == _TrayPosition.end || position == _TrayPosition.solo;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: 2.0,
|
||||
bottom: 2.0,
|
||||
// Transparent gap after the tray so a following standalone header
|
||||
// doesn't butt up against the rounded right edge.
|
||||
right: isRightEdge ? 8.0 : 0.0,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 44.0,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: fill, borderRadius: borderRadius),
|
||||
// A small inset on every side so the first/last chip get the same
|
||||
// breathing room from the tray edge as the inter-chip seam gaps.
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: Center(child: child),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+404
@@ -0,0 +1,404 @@
|
||||
/*
|
||||
* 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:fast_equatable/fast_equatable.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:weblibre/core/design/app_colors.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_icon.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_menu.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_depth_indicator.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
|
||||
import 'package:weblibre/presentation/widgets/selectable_chips.dart';
|
||||
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
||||
|
||||
class QuickTabSwitcherItem with FastEquatable {
|
||||
final Color? color;
|
||||
final bool useCustomColor;
|
||||
final String id;
|
||||
final bool isActive;
|
||||
final TabMode tabMode;
|
||||
final bool isHistory;
|
||||
final bool isPinned;
|
||||
final bool isSandbox;
|
||||
final int depth;
|
||||
final String title;
|
||||
final Uri url;
|
||||
final Widget avatar;
|
||||
|
||||
/// True while the tab is only known from the local DB cache and the native
|
||||
/// session restore hasn't delivered its state yet. Placeholders cannot be
|
||||
/// closed or reordered.
|
||||
final bool isPlaceholder;
|
||||
|
||||
QuickTabSwitcherItem({
|
||||
required this.color,
|
||||
required this.id,
|
||||
required this.isActive,
|
||||
required this.tabMode,
|
||||
required this.isHistory,
|
||||
required this.isPinned,
|
||||
required this.title,
|
||||
required this.url,
|
||||
required this.avatar,
|
||||
this.useCustomColor = false,
|
||||
this.isSandbox = false,
|
||||
this.depth = 0,
|
||||
this.isPlaceholder = false,
|
||||
});
|
||||
|
||||
/// Builds a switcher entry for an open tab. [sandboxSourceUri] is the
|
||||
/// canonical source URL when the tab is a sandbox capture (otherwise null),
|
||||
/// so the bar shows the real site instead of the loopback capture URL.
|
||||
factory QuickTabSwitcherItem.tab(
|
||||
TabStateWithContainer state, {
|
||||
required String? selectedTabId,
|
||||
required Set<String> pinnedTabIds,
|
||||
required Map<String, int> tabDepthById,
|
||||
required Uri? sandboxSourceUri,
|
||||
bool isPlaceholder = false,
|
||||
}) {
|
||||
final (tab, container) = state;
|
||||
|
||||
return QuickTabSwitcherItem(
|
||||
color: container?.color,
|
||||
useCustomColor: container?.metadata.useCustomColor ?? false,
|
||||
id: tab.id,
|
||||
isActive: tab.id == selectedTabId,
|
||||
title: sandboxSourceUri != null && tab.title.isEmpty
|
||||
? sandboxSourceUri.authority
|
||||
: tab.titleOrAuthority,
|
||||
tabMode: tab.tabMode,
|
||||
isHistory: false,
|
||||
isPinned: pinnedTabIds.contains(tab.id),
|
||||
isSandbox: sandboxSourceUri != null,
|
||||
depth: tabDepthById[tab.id] ?? 0,
|
||||
url: sandboxSourceUri ?? tab.url,
|
||||
avatar: TabIcon(tabState: tab, iconSize: 20),
|
||||
isPlaceholder: isPlaceholder,
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a switcher entry for a history suggestion (shown only when there
|
||||
/// are no open tabs in the active mode).
|
||||
factory QuickTabSwitcherItem.history({
|
||||
required String url,
|
||||
required String? title,
|
||||
}) {
|
||||
final parsedUrl = Uri.parse(url);
|
||||
|
||||
return QuickTabSwitcherItem(
|
||||
color: null,
|
||||
id: url,
|
||||
isActive: false,
|
||||
title: title ?? parsedUrl.authority,
|
||||
tabMode: TabMode.regular,
|
||||
isHistory: true,
|
||||
isPinned: false,
|
||||
url: parsedUrl,
|
||||
avatar: UrlIcon([parsedUrl], iconSize: 20),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [
|
||||
color,
|
||||
useCustomColor,
|
||||
id,
|
||||
isActive,
|
||||
tabMode,
|
||||
isHistory,
|
||||
isPinned,
|
||||
isSandbox,
|
||||
depth,
|
||||
title,
|
||||
url,
|
||||
avatar,
|
||||
isPlaceholder,
|
||||
];
|
||||
}
|
||||
|
||||
/// Visual contract shared by every quick tab switcher render path
|
||||
/// ([SelectableChips], the reorderable list, and the accordion view).
|
||||
SelectableChipDecoration<QuickTabSwitcherItem>
|
||||
buildQuickTabSwitcherChipDecoration(
|
||||
BuildContext context, {
|
||||
required bool showTitles,
|
||||
required int hierarchyGlyphs,
|
||||
bool Function(QuickTabSwitcherItem item)? canDelete,
|
||||
// When true, the selected item gets a thicker border in its container's
|
||||
// outline color (instead of the usual transparent selected border). The
|
||||
// accordion uses this so the active tab stays visible against its
|
||||
// container-colored tray while matching the unselected chips' border color.
|
||||
bool thickContainerSelectedBorder = false,
|
||||
}) {
|
||||
return SelectableChipDecoration(
|
||||
color: (item, isSelected) => switch (item.color) {
|
||||
final color? when isSelected => ContainerColors.palette(
|
||||
context,
|
||||
color,
|
||||
useCustomColor: item.useCustomColor,
|
||||
).selectedBackgroundColor,
|
||||
final color? => ContainerColors.palette(
|
||||
context,
|
||||
color,
|
||||
useCustomColor: item.useCustomColor,
|
||||
).backgroundColor,
|
||||
null => null,
|
||||
},
|
||||
side: (item, isSelected) => switch (item.color) {
|
||||
final color? when isSelected => thickContainerSelectedBorder
|
||||
? BorderSide(
|
||||
color: ContainerColors.palette(
|
||||
context,
|
||||
color,
|
||||
useCustomColor: item.useCustomColor,
|
||||
).outlineColor,
|
||||
width: 2.0,
|
||||
)
|
||||
: ContainerColors.palette(
|
||||
context,
|
||||
color,
|
||||
useCustomColor: item.useCustomColor,
|
||||
).selectedBorderSide,
|
||||
final color? => ContainerColors.palette(
|
||||
context,
|
||||
color,
|
||||
useCustomColor: item.useCustomColor,
|
||||
).borderSide,
|
||||
null => null,
|
||||
},
|
||||
labelPadding: (item) =>
|
||||
(!showTitles &&
|
||||
!item.isHistory &&
|
||||
!item.isPinned &&
|
||||
!item.isSandbox &&
|
||||
(item.depth == 0 || hierarchyGlyphs == 0) &&
|
||||
item.tabMode is! PrivateTabMode &&
|
||||
item.tabMode is! IsolatedTabMode)
|
||||
? EdgeInsets.zero
|
||||
: null,
|
||||
canDelete: canDelete,
|
||||
deleteIcon: (_) => const Icon(Icons.close, size: 18),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildQuickTabSwitcherChipLabel(
|
||||
BuildContext context,
|
||||
QuickTabSwitcherItem item, {
|
||||
required bool isSelected,
|
||||
required bool showTitles,
|
||||
required bool showIsolatedTabUi,
|
||||
required int hierarchyGlyphs,
|
||||
required double titleMaxWidth,
|
||||
}) {
|
||||
final appColors = AppColors.of(context);
|
||||
final hasTitle = item.isHistory || showTitles;
|
||||
final row = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (item.depth > 0 && hierarchyGlyphs > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 6.0),
|
||||
child: TabDepthIndicator(
|
||||
depth: item.depth,
|
||||
height: 20.0,
|
||||
iconSize: 14.0,
|
||||
horizontalPadding: 4.0,
|
||||
maxInlineGlyphs: hierarchyGlyphs,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(right: hasTitle ? 6.0 : 0.0),
|
||||
child: item.avatar,
|
||||
),
|
||||
if (hasTitle)
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: titleMaxWidth),
|
||||
child: Text(item.title),
|
||||
),
|
||||
if (showIsolatedTabUi && item.tabMode is IsolatedTabMode)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Icon(
|
||||
MdiIcons.snowflake,
|
||||
color: appColors.isolatedTabTeal,
|
||||
size: 20,
|
||||
),
|
||||
)
|
||||
else if (item.tabMode is PrivateTabMode)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Icon(
|
||||
MdiIcons.dominoMask,
|
||||
color: appColors.privateTabPurple,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
if (item.isSandbox)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Icon(
|
||||
MdiIcons.archiveLockOutline,
|
||||
color: Theme.of(context).colorScheme.tertiary,
|
||||
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),
|
||||
child: Icon(MdiIcons.history, size: 20),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return item.color.mapNotNull(
|
||||
(color) => DefaultTextStyle.merge(
|
||||
style: TextStyle(
|
||||
color: isSelected
|
||||
? ContainerColors.palette(
|
||||
context,
|
||||
color,
|
||||
useCustomColor: item.useCustomColor,
|
||||
).selectedForegroundColor
|
||||
: ContainerColors.palette(
|
||||
context,
|
||||
color,
|
||||
useCustomColor: item.useCustomColor,
|
||||
).foregroundColor,
|
||||
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
|
||||
),
|
||||
child: row,
|
||||
),
|
||||
) ??
|
||||
row;
|
||||
}
|
||||
|
||||
/// FilterChip matching [SelectableChips]' visual contract, used by render
|
||||
/// paths that can't go through [SelectableChips] itself (the reorderable
|
||||
/// list and the accordion view). Stateless wrapper so a parent
|
||||
/// `ReorderableListView` can attach its drag-handle gesture recognizer.
|
||||
class QuickTabSwitcherChip extends StatelessWidget {
|
||||
final QuickTabSwitcherItem item;
|
||||
final bool isSelected;
|
||||
final Color selectedBorderColor;
|
||||
final SelectableChipDecoration<QuickTabSwitcherItem> decoration;
|
||||
final Widget label;
|
||||
final Future<void> Function() onTap;
|
||||
final Future<void> Function()? onDelete;
|
||||
|
||||
/// Outer spacing around the chip. Defaults to the standard inter-chip gap;
|
||||
/// render paths that manage their own spacing (e.g. the accordion tray)
|
||||
/// pass [EdgeInsets.zero].
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
const QuickTabSwitcherChip({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.isSelected,
|
||||
required this.selectedBorderColor,
|
||||
required this.decoration,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.onDelete,
|
||||
this.padding = const EdgeInsets.only(right: 8.0, top: 4.0),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final itemColor = decoration.color?.call(item, isSelected);
|
||||
final side =
|
||||
decoration.side?.call(item, isSelected) ??
|
||||
(isSelected
|
||||
? BorderSide(color: selectedBorderColor, width: 2.0)
|
||||
: null);
|
||||
final labelPadding = decoration.labelPadding?.call(item);
|
||||
|
||||
return Padding(
|
||||
padding: padding,
|
||||
child: FilterChip(
|
||||
color: itemColor != null ? WidgetStatePropertyAll(itemColor) : null,
|
||||
selected: false,
|
||||
showCheckmark: false,
|
||||
labelPadding: labelPadding,
|
||||
onSelected: (_) {
|
||||
unawaited(onTap());
|
||||
},
|
||||
deleteIcon: decoration.deleteIcon?.call(item),
|
||||
onDeleted: onDelete != null
|
||||
? () {
|
||||
unawaited(onDelete!());
|
||||
}
|
||||
: null,
|
||||
label: label,
|
||||
side: side,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps a tab chip in the long-press [TabMenu] used across switcher views.
|
||||
Widget wrapQuickTabSwitcherChipWithMenu({
|
||||
required String itemId,
|
||||
required bool enabled,
|
||||
required bool enablePinTab,
|
||||
required Widget child,
|
||||
}) {
|
||||
if (!enabled) {
|
||||
return child;
|
||||
}
|
||||
|
||||
return TabMenu(
|
||||
selectedTabId: itemId,
|
||||
enableFindInPage: false,
|
||||
enableFetchFeeds: false,
|
||||
enableDesktopMode: false,
|
||||
enableReaderMode: false,
|
||||
enableReloadButton: false,
|
||||
enableNavigationButtons: false,
|
||||
enableAddToHomeScreen: false,
|
||||
enablePinTab: enablePinTab,
|
||||
builder: (context, controller, _) {
|
||||
return InkWell(
|
||||
onLongPress: () {
|
||||
if (controller.isOpen) {
|
||||
controller.close();
|
||||
} else {
|
||||
controller.open();
|
||||
}
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
+26
-16
@@ -60,23 +60,33 @@ class TabIcon extends HookConsumerWidget {
|
||||
return UrlIcon([sandboxSourceUri], iconSize: iconSize, cacheOnly: true);
|
||||
}
|
||||
|
||||
return Skeletonizer(
|
||||
enabled: icon.connectionState != ConnectionState.done,
|
||||
child: Skeleton.replace(
|
||||
replacement: Bone.icon(size: iconSize),
|
||||
child: RepaintBoundary(
|
||||
child:
|
||||
icon.data.mapNotNull(
|
||||
(image) => SafeRawImage(
|
||||
image: image,
|
||||
height: iconSize,
|
||||
width: iconSize,
|
||||
fallback: Icon(MdiIcons.web, size: iconSize),
|
||||
),
|
||||
) ??
|
||||
Icon(MdiIcons.web, size: iconSize),
|
||||
// While the icon future is still resolving, show the skeleton bone. This is
|
||||
// the only state that needs the (comparatively expensive) Skeletonizer +
|
||||
// Skeleton.replace machinery.
|
||||
if (icon.connectionState != ConnectionState.done) {
|
||||
return Skeletonizer(
|
||||
enabled: true,
|
||||
child: Skeleton.replace(
|
||||
replacement: Bone.icon(size: iconSize),
|
||||
child: SizedBox.square(dimension: iconSize),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Resolved case (hit on virtually every rebuild once the favicon is known):
|
||||
// render the image directly without wrapping it in Skeletonizer, which the
|
||||
// rebuild profiler flagged as the most-rebuilt widget in the tab bar.
|
||||
return RepaintBoundary(
|
||||
child:
|
||||
icon.data.mapNotNull(
|
||||
(image) => SafeRawImage(
|
||||
image: image,
|
||||
height: iconSize,
|
||||
width: iconSize,
|
||||
fallback: Icon(MdiIcons.web, size: iconSize),
|
||||
),
|
||||
) ??
|
||||
Icon(MdiIcons.web, size: iconSize),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-30
@@ -36,6 +36,7 @@ import 'package:weblibre/features/geckoview/domain/providers/desktop_mode.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/tab_session.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/utils/close_tab_helper.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/navigation_buttons.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/dialogs/tab_parent_picker.dart';
|
||||
@@ -764,36 +765,8 @@ class TabMenu extends HookConsumerWidget {
|
||||
),
|
||||
if (enableCloseTab)
|
||||
MenuItemButton(
|
||||
onPressed: () async {
|
||||
// Confirm before closing the last tab in an isolation group
|
||||
final tabState = ref.read(tabStateProvider(selectedTabId));
|
||||
if (tabState != null && tabState.tabMode is IsolatedTabMode) {
|
||||
final allStates = ref.read(tabStatesProvider);
|
||||
final groupCount = allStates.values
|
||||
.where(
|
||||
(s) =>
|
||||
s.isolationContextId == tabState.isolationContextId,
|
||||
)
|
||||
.length;
|
||||
if (groupCount <= 1 && context.mounted) {
|
||||
final confirmed = await ui_helper.confirmIsolatedTabClose(
|
||||
context,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.closeTab(selectedTabId);
|
||||
|
||||
if (context.mounted) {
|
||||
ui_helper.showTabUndoClose(
|
||||
context,
|
||||
ref.read(tabRepositoryProvider.notifier).undoClose,
|
||||
);
|
||||
}
|
||||
},
|
||||
onPressed: () =>
|
||||
closeTabWithConfirmationAndUndo(context, ref, selectedTabId),
|
||||
leadingIcon: const Icon(MdiIcons.tabMinus),
|
||||
child: const Text('Close Tab'),
|
||||
),
|
||||
|
||||
+1
@@ -67,6 +67,7 @@ class RecentTabsSection extends ConsumerWidget {
|
||||
iconSize: UrlListTile.iconSize,
|
||||
),
|
||||
containerColor: containerData?.color,
|
||||
containerIcon: containerData?.metadata.iconData,
|
||||
useCustomColor:
|
||||
containerData?.metadata.useCustomColor ?? false,
|
||||
onTap: () => onTabSelected(tabState.id),
|
||||
|
||||
@@ -568,10 +568,13 @@ class _ShowContainerUiTile extends HookConsumerWidget {
|
||||
) {
|
||||
var updated = currentSettings.copyWith.showContainerUi(value);
|
||||
if (!value &&
|
||||
updated.quickTabSwitcherMode ==
|
||||
QuickTabSwitcherMode.containerTabs) {
|
||||
updated = updated.copyWith.quickTabSwitcherMode(
|
||||
QuickTabSwitcherMode.lastUsedTabs,
|
||||
const {
|
||||
TabBarStackingMode.containerTabs,
|
||||
TabBarStackingMode.accordion,
|
||||
TabBarStackingMode.twoLevel,
|
||||
}.contains(updated.tabBarStackingMode)) {
|
||||
updated = updated.copyWith.tabBarStackingMode(
|
||||
TabBarStackingMode.lastUsedTabs,
|
||||
);
|
||||
}
|
||||
return updated;
|
||||
|
||||
+204
-57
@@ -78,16 +78,25 @@ const List<SettingsSectionDefinition> toolbarLayoutSettingsSections = [
|
||||
title: 'Quick Tab Switcher',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Show Quick Tab Switcher Bar',
|
||||
subtitle: 'Show a bar for switching to recent tabs',
|
||||
keywords: ['recent tabs'],
|
||||
child: _ShowQuickTabSwitcherBarTile(),
|
||||
title: 'Tab Stacking',
|
||||
subtitle: 'Choose how the quick tab switcher bar arranges tabs',
|
||||
keywords: [
|
||||
'recent tabs',
|
||||
'recently used',
|
||||
'container tabs',
|
||||
'accordion',
|
||||
'two level',
|
||||
'rows',
|
||||
'stacking',
|
||||
'disabled',
|
||||
],
|
||||
child: _TabBarStackingModeSection(),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'Quick Tab Switcher Mode',
|
||||
subtitle: 'Choose how the switcher orders and groups tabs',
|
||||
keywords: ['recently used', 'container tabs'],
|
||||
child: _QuickTabSwitcherModeSection(),
|
||||
title: 'Close Buttons on All Tabs',
|
||||
subtitle: 'Show a close button on every switcher chip',
|
||||
keywords: ['close', 'x button'],
|
||||
child: _QuickTabSwitcherCloseButtonsTile(),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'History Fallback in Quick Tab Switcher',
|
||||
@@ -101,6 +110,12 @@ const List<SettingsSectionDefinition> toolbarLayoutSettingsSections = [
|
||||
keywords: ['page titles'],
|
||||
child: _QuickTabSwitcherShowTitlesTile(),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'Title Width in Quick Tab Switcher',
|
||||
subtitle: 'Maximum width of tab titles on switcher chips',
|
||||
keywords: ['width', 'title', 'chip', 'length'],
|
||||
child: _QuickTabSwitcherTitleWidthTile(),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'Hierarchy Depth in Quick Tab Switcher',
|
||||
subtitle: 'How many nesting chevrons to show on switcher chips',
|
||||
@@ -303,43 +318,13 @@ class _CustomizeToolbarButtonsTile extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ShowQuickTabSwitcherBarTile extends HookConsumerWidget {
|
||||
const _ShowQuickTabSwitcherBarTile();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tabBarShowQuickTabSwitcherBar = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(s) => s.tabBarShowQuickTabSwitcherBar,
|
||||
),
|
||||
);
|
||||
|
||||
return SwitchListTile.adaptive(
|
||||
title: const Text('Show Quick Tab Switcher Bar'),
|
||||
subtitle: const Text(
|
||||
'Show additional toolbar to quickly switch to recently used tabs',
|
||||
),
|
||||
secondary: const Icon(MdiIcons.dockBottom),
|
||||
value: tabBarShowQuickTabSwitcherBar,
|
||||
onChanged: (value) async {
|
||||
await ref
|
||||
.read(saveGeneralSettingsControllerProvider.notifier)
|
||||
.save(
|
||||
(currentSettings) =>
|
||||
currentSettings.copyWith.tabBarShowQuickTabSwitcherBar(value),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuickTabSwitcherModeSection extends HookConsumerWidget {
|
||||
const _QuickTabSwitcherModeSection();
|
||||
class _TabBarStackingModeSection extends HookConsumerWidget {
|
||||
const _TabBarStackingModeSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(generalSettingsWithDefaultsProvider);
|
||||
final quickTabSwitcherMode = settings.effectiveUiQuickTabSwitcherMode();
|
||||
final stackingMode = settings.effectiveTabBarStackingMode();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
|
||||
@@ -348,35 +333,60 @@ class _QuickTabSwitcherModeSection extends HookConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const ListTile(
|
||||
title: Text('Quick Tab Switcher Mode'),
|
||||
title: Text('Tab Stacking'),
|
||||
subtitle: Text(
|
||||
'How the quick tab switcher bar arranges its tabs',
|
||||
),
|
||||
leading: Icon(MdiIcons.folderSettings),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
RadioGroup(
|
||||
groupValue: quickTabSwitcherMode,
|
||||
groupValue: stackingMode,
|
||||
onChanged: (value) async {
|
||||
if (value != null) {
|
||||
await ref
|
||||
.read(saveGeneralSettingsControllerProvider.notifier)
|
||||
.save(
|
||||
(currentSettings) =>
|
||||
currentSettings.copyWith.quickTabSwitcherMode(value),
|
||||
currentSettings.copyWith.tabBarStackingMode(value),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
const RadioListTile.adaptive(
|
||||
value: QuickTabSwitcherMode.lastUsedTabs,
|
||||
value: TabBarStackingMode.lastUsedTabs,
|
||||
title: Text('Recently Used Tabs'),
|
||||
subtitle: Text('Recently used tabs across all containers'),
|
||||
),
|
||||
if (settings.showContainerUi)
|
||||
const RadioListTile.adaptive(
|
||||
value: QuickTabSwitcherMode.containerTabs,
|
||||
if (settings.showContainerUi) ...const [
|
||||
RadioListTile.adaptive(
|
||||
value: TabBarStackingMode.containerTabs,
|
||||
title: Text('Container Tabs'),
|
||||
subtitle: Text('Ordered tabs of the selected container'),
|
||||
),
|
||||
RadioListTile.adaptive(
|
||||
value: TabBarStackingMode.accordion,
|
||||
title: Text('Accordion'),
|
||||
subtitle: Text(
|
||||
"All containers as chips, with the selected "
|
||||
"container's tabs expanded inline",
|
||||
),
|
||||
),
|
||||
RadioListTile.adaptive(
|
||||
value: TabBarStackingMode.twoLevel,
|
||||
title: Text('Two Rows'),
|
||||
subtitle: Text(
|
||||
'Tabs of the selected container on top, recently used '
|
||||
'tabs below',
|
||||
),
|
||||
),
|
||||
],
|
||||
const RadioListTile.adaptive(
|
||||
value: TabBarStackingMode.disabled,
|
||||
title: Text('Disabled'),
|
||||
subtitle: Text('Hide the quick tab switcher bar'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -386,6 +396,143 @@ class _QuickTabSwitcherModeSection extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _QuickTabSwitcherCloseButtonsTile extends HookConsumerWidget {
|
||||
const _QuickTabSwitcherCloseButtonsTile();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final showCloseButtonOnAllTabs = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(s) => s.quickTabSwitcherShowCloseButtonOnAllTabs,
|
||||
),
|
||||
);
|
||||
final switcherEnabled = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(s) => s.effectiveTabBarStackingMode() != TabBarStackingMode.disabled,
|
||||
),
|
||||
);
|
||||
|
||||
return SwitchListTile.adaptive(
|
||||
title: const Text('Close Buttons on All Tabs'),
|
||||
subtitle: const Text(
|
||||
"Show a close button on every switcher chip; the active tab's chip "
|
||||
"always has one",
|
||||
),
|
||||
secondary: const Icon(MdiIcons.closeCircleOutline),
|
||||
value: showCloseButtonOnAllTabs,
|
||||
onChanged: switcherEnabled
|
||||
? (value) async {
|
||||
await ref
|
||||
.read(saveGeneralSettingsControllerProvider.notifier)
|
||||
.save(
|
||||
(currentSettings) => currentSettings.copyWith
|
||||
.quickTabSwitcherShowCloseButtonOnAllTabs(value),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuickTabSwitcherTitleWidthTile extends HookConsumerWidget {
|
||||
const _QuickTabSwitcherTitleWidthTile();
|
||||
|
||||
static final _divisions =
|
||||
((maxQuickTabSwitcherTitleWidth - minQuickTabSwitcherTitleWidth) /
|
||||
quickTabSwitcherTitleWidthStep)
|
||||
.round();
|
||||
|
||||
static String _label(double width) => '${width.round()} px';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final titleWidth = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(s) => s.quickTabSwitcherTitleWidth,
|
||||
),
|
||||
);
|
||||
final showTitles = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(s) => s.quickTabSwitcherShowTitles,
|
||||
),
|
||||
);
|
||||
final switcherEnabled = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(s) => s.effectiveTabBarStackingMode() != TabBarStackingMode.disabled,
|
||||
),
|
||||
);
|
||||
|
||||
final sliderValue = useState(titleWidth);
|
||||
useEffect(() {
|
||||
sliderValue.value = titleWidth;
|
||||
return null;
|
||||
}, [titleWidth]);
|
||||
|
||||
final enabled = switcherEnabled && showTitles;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
title: const Text('Title Width in Quick Tab Switcher'),
|
||||
subtitle: const Text(
|
||||
'Maximum width of tab titles on switcher chips',
|
||||
),
|
||||
leading: const Icon(MdiIcons.arrowExpandHorizontal),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
enabled: enabled,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Slider(
|
||||
min: minQuickTabSwitcherTitleWidth,
|
||||
max: maxQuickTabSwitcherTitleWidth,
|
||||
divisions: _divisions,
|
||||
label: _label(sliderValue.value),
|
||||
value: sliderValue.value.clamp(
|
||||
minQuickTabSwitcherTitleWidth,
|
||||
maxQuickTabSwitcherTitleWidth,
|
||||
),
|
||||
onChanged: enabled
|
||||
? (value) {
|
||||
sliderValue.value = value;
|
||||
}
|
||||
: null,
|
||||
onChangeEnd: enabled
|
||||
? (value) async {
|
||||
final normalized =
|
||||
(value / quickTabSwitcherTitleWidthStep)
|
||||
.round() *
|
||||
quickTabSwitcherTitleWidthStep;
|
||||
sliderValue.value = normalized;
|
||||
await ref
|
||||
.read(
|
||||
saveGeneralSettingsControllerProvider.notifier,
|
||||
)
|
||||
.save(
|
||||
(currentSettings) => currentSettings.copyWith
|
||||
.quickTabSwitcherTitleWidth(normalized),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_label(sliderValue.value),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuickTabSwitcherHistorySuggestionsTile extends HookConsumerWidget {
|
||||
const _QuickTabSwitcherHistorySuggestionsTile();
|
||||
|
||||
@@ -396,9 +543,9 @@ class _QuickTabSwitcherHistorySuggestionsTile extends HookConsumerWidget {
|
||||
(s) => s.quickTabSwitcherShowHistorySuggestions,
|
||||
),
|
||||
);
|
||||
final tabBarShowQuickTabSwitcherBar = ref.watch(
|
||||
final switcherEnabled = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(s) => s.tabBarShowQuickTabSwitcherBar,
|
||||
(s) => s.effectiveTabBarStackingMode() != TabBarStackingMode.disabled,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -409,7 +556,7 @@ class _QuickTabSwitcherHistorySuggestionsTile extends HookConsumerWidget {
|
||||
),
|
||||
secondary: const Icon(MdiIcons.history),
|
||||
value: showHistorySuggestions,
|
||||
onChanged: tabBarShowQuickTabSwitcherBar
|
||||
onChanged: switcherEnabled
|
||||
? (value) async {
|
||||
await ref
|
||||
.read(saveGeneralSettingsControllerProvider.notifier)
|
||||
@@ -433,9 +580,9 @@ class _QuickTabSwitcherShowTitlesTile extends HookConsumerWidget {
|
||||
(s) => s.quickTabSwitcherShowTitles,
|
||||
),
|
||||
);
|
||||
final tabBarShowQuickTabSwitcherBar = ref.watch(
|
||||
final switcherEnabled = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(s) => s.tabBarShowQuickTabSwitcherBar,
|
||||
(s) => s.effectiveTabBarStackingMode() != TabBarStackingMode.disabled,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -446,7 +593,7 @@ class _QuickTabSwitcherShowTitlesTile extends HookConsumerWidget {
|
||||
),
|
||||
secondary: const Icon(MdiIcons.textRecognition),
|
||||
value: quickTabSwitcherShowTitles,
|
||||
onChanged: tabBarShowQuickTabSwitcherBar
|
||||
onChanged: switcherEnabled
|
||||
? (value) async {
|
||||
await ref
|
||||
.read(saveGeneralSettingsControllerProvider.notifier)
|
||||
@@ -476,9 +623,9 @@ class _QuickTabSwitcherHierarchyGlyphsTile extends HookConsumerWidget {
|
||||
(s) => s.quickTabSwitcherHierarchyGlyphs,
|
||||
),
|
||||
);
|
||||
final tabBarShowQuickTabSwitcherBar = ref.watch(
|
||||
final switcherEnabled = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(s) => s.tabBarShowQuickTabSwitcherBar,
|
||||
(s) => s.effectiveTabBarStackingMode() != TabBarStackingMode.disabled,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -489,7 +636,7 @@ class _QuickTabSwitcherHierarchyGlyphsTile extends HookConsumerWidget {
|
||||
}, [hierarchyGlyphs]);
|
||||
|
||||
final currentGlyphs = sliderValue.value.round();
|
||||
final enabled = tabBarShowQuickTabSwitcherBar;
|
||||
final enabled = switcherEnabled;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
|
||||
|
||||
@@ -57,11 +57,15 @@ class TabBarPreviewHeaderDelegate extends SliverPersistentHeaderDelegate {
|
||||
height += BrowserTabBar.contextualToolabarHeight;
|
||||
}
|
||||
|
||||
if (settings.tabBarShowQuickTabSwitcherBar) {
|
||||
height += BrowserTabBar.quickTabSwitcherHeight;
|
||||
}
|
||||
final quickTabSwitcherRows = switch (settings
|
||||
.effectiveTabBarStackingMode()) {
|
||||
TabBarStackingMode.disabled => 0,
|
||||
TabBarStackingMode.twoLevel => 2,
|
||||
_ => 1,
|
||||
};
|
||||
|
||||
return height;
|
||||
return height +
|
||||
BrowserTabBar.quickTabSwitcherHeight * quickTabSwitcherRows;
|
||||
}
|
||||
|
||||
double get _baseHeight =>
|
||||
@@ -114,6 +118,7 @@ class TabBarPreviewCard extends HookWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final quickTabsController = useScrollController();
|
||||
final quickTabsSecondRowController = useScrollController();
|
||||
final showMainToolbarActionButtons = !settings.tabBarShowContextualBar;
|
||||
|
||||
final previewTabState = TabState.$default('preview-tab').copyWith(
|
||||
@@ -134,8 +139,8 @@ class TabBarPreviewCard extends HookWidget {
|
||||
tabMode: TabMode.regular,
|
||||
isHistory: false,
|
||||
isPinned:
|
||||
settings.effectiveUiQuickTabSwitcherMode() ==
|
||||
QuickTabSwitcherMode.containerTabs,
|
||||
settings.effectiveTabBarStackingMode() ==
|
||||
TabBarStackingMode.containerTabs,
|
||||
url: Uri.parse('https://example.com/news'),
|
||||
color: settings.showContainerUi ? colorScheme.primary : null,
|
||||
avatar: const Icon(MdiIcons.web, size: 20),
|
||||
@@ -196,19 +201,39 @@ class TabBarPreviewCard extends HookWidget {
|
||||
},
|
||||
);
|
||||
|
||||
Widget buildQuickTabSwitcher() {
|
||||
Widget buildQuickTabSwitcherRow(ScrollController scrollController) {
|
||||
return QuickTabSwitcherView(
|
||||
availableItems: previewQuickItems,
|
||||
activeItem: previewQuickItems.firstWhere((item) => item.isActive),
|
||||
scrollController: quickTabsController,
|
||||
scrollController: scrollController,
|
||||
showTitles: settings.quickTabSwitcherShowTitles,
|
||||
showIsolatedTabUi: settings.showIsolatedTabUi,
|
||||
hierarchyGlyphs: settings.quickTabSwitcherHierarchyGlyphs,
|
||||
titleMaxWidth: settings.quickTabSwitcherTitleWidth,
|
||||
showCloseButtonOnAllTabs:
|
||||
settings.quickTabSwitcherShowCloseButtonOnAllTabs,
|
||||
enablePinTabInMenu: false,
|
||||
onSelected: (_) async {},
|
||||
onCloseItem: (_) async {},
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildQuickTabSwitcher() {
|
||||
// The accordion preview reuses the single-row layout; container header
|
||||
// chips need live container data that the static preview doesn't have.
|
||||
if (settings.effectiveTabBarStackingMode() ==
|
||||
TabBarStackingMode.twoLevel) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
buildQuickTabSwitcherRow(quickTabsController),
|
||||
buildQuickTabSwitcherRow(quickTabsSecondRowController),
|
||||
],
|
||||
);
|
||||
}
|
||||
return buildQuickTabSwitcherRow(quickTabsController);
|
||||
}
|
||||
|
||||
Widget buildContextualToolbar() {
|
||||
return ContextualToolbarView(
|
||||
buttons: [
|
||||
@@ -235,10 +260,13 @@ class TabBarPreviewCard extends HookWidget {
|
||||
if (showMainToolbarActionButtons) NavigationMenuButtonView(onTap: () {}),
|
||||
];
|
||||
|
||||
final showQuickTabSwitcherBar =
|
||||
settings.effectiveTabBarStackingMode() != TabBarStackingMode.disabled;
|
||||
|
||||
final bottomCombinedToolbar = BrowserTabBarView(
|
||||
showMainToolbar: true,
|
||||
showContextualToolbar: settings.tabBarShowContextualBar,
|
||||
showQuickTabSwitcherBar: settings.tabBarShowQuickTabSwitcherBar,
|
||||
showQuickTabSwitcherBar: showQuickTabSwitcherBar,
|
||||
displayAppBar: true,
|
||||
displayQuickTabSwitcher: true,
|
||||
backgroundColor:
|
||||
@@ -270,7 +298,7 @@ class TabBarPreviewCard extends HookWidget {
|
||||
final topBottomToolbar = BrowserTabBarView(
|
||||
showMainToolbar: false,
|
||||
showContextualToolbar: settings.tabBarShowContextualBar,
|
||||
showQuickTabSwitcherBar: settings.tabBarShowQuickTabSwitcherBar,
|
||||
showQuickTabSwitcherBar: showQuickTabSwitcherBar,
|
||||
displayAppBar: false,
|
||||
displayQuickTabSwitcher: true,
|
||||
backgroundColor: colorScheme.surfaceContainer,
|
||||
|
||||
@@ -47,6 +47,13 @@ const defaultQuickTabSwitcherHierarchyGlyphs = 2;
|
||||
const minQuickTabSwitcherHierarchyGlyphs = 0;
|
||||
const maxQuickTabSwitcherHierarchyGlyphs = 4;
|
||||
|
||||
/// Max width (logical px) of the title text on a quick tab switcher chip.
|
||||
/// The default of 64 sits at the 1/3 position of the slider scale.
|
||||
const defaultQuickTabSwitcherTitleWidth = 64.0;
|
||||
const minQuickTabSwitcherTitleWidth = 32.0;
|
||||
const maxQuickTabSwitcherTitleWidth = 128.0;
|
||||
const quickTabSwitcherTitleWidthStep = 8.0;
|
||||
|
||||
/// Controls the Android display refresh rate the app requests at startup.
|
||||
///
|
||||
/// Flutter does not request a high refresh rate by default, so on many devices
|
||||
@@ -58,8 +65,24 @@ enum RefreshRateMode { system, high, low }
|
||||
|
||||
enum TabBarSwipeAction { switchLastOpened, navigateOrderedTabs }
|
||||
|
||||
/// Row kind rendered inside the quick tab switcher bar. [TabBarStackingMode]
|
||||
/// decides which row(s) are shown; this enum identifies a single row.
|
||||
enum QuickTabSwitcherMode { lastUsedTabs, containerTabs }
|
||||
|
||||
/// Layout of the quick tab switcher bar. Merges the former
|
||||
/// "show tab switcher bar" toggle and [QuickTabSwitcherMode] selection.
|
||||
///
|
||||
/// [accordion] renders all containers as header chips with the selected
|
||||
/// container's tabs expanded inline. [twoLevel] stacks a container-tabs row
|
||||
/// on top of a recently-used row. [disabled] hides the bar entirely.
|
||||
enum TabBarStackingMode {
|
||||
lastUsedTabs,
|
||||
containerTabs,
|
||||
accordion,
|
||||
twoLevel,
|
||||
disabled,
|
||||
}
|
||||
|
||||
enum TabIntentOpenSetting { regular, private, isolated, ask }
|
||||
|
||||
enum TabDirection { newestFirst, oldestFirst }
|
||||
@@ -118,10 +141,9 @@ class GeneralSettings with FastEquatable {
|
||||
final Duration historyAutoCleanInterval;
|
||||
final bool tabViewBottomSheet;
|
||||
final bool tabBarShowContextualBar;
|
||||
final bool tabBarShowQuickTabSwitcherBar;
|
||||
final TabBarPosition tabBarPosition;
|
||||
final TabBarLayout tabBarLayout;
|
||||
final QuickTabSwitcherMode quickTabSwitcherMode;
|
||||
final TabBarStackingMode tabBarStackingMode;
|
||||
final bool pullToRefreshEnabled;
|
||||
final bool useExternalDownloadManager;
|
||||
final bool doubleBackCloseTab;
|
||||
@@ -132,6 +154,13 @@ class GeneralSettings with FastEquatable {
|
||||
final bool quickTabSwitcherShowTitles;
|
||||
final int quickTabSwitcherHierarchyGlyphs;
|
||||
final bool quickTabSwitcherShowHistorySuggestions;
|
||||
|
||||
/// Max width (logical px) for chip titles in the quick tab switcher.
|
||||
final double quickTabSwitcherTitleWidth;
|
||||
|
||||
/// Whether every tab chip in the quick tab switcher shows a close button.
|
||||
/// The selected tab's chip always shows one regardless of this setting.
|
||||
final bool quickTabSwitcherShowCloseButtonOnAllTabs;
|
||||
final String syncServerOverride;
|
||||
final String syncTokenServerOverride;
|
||||
final bool urlCleanerEnabled;
|
||||
@@ -202,10 +231,9 @@ class GeneralSettings with FastEquatable {
|
||||
required this.historyAutoCleanInterval,
|
||||
required this.tabViewBottomSheet,
|
||||
required this.tabBarShowContextualBar,
|
||||
required this.tabBarShowQuickTabSwitcherBar,
|
||||
required this.tabBarPosition,
|
||||
required this.tabBarLayout,
|
||||
required this.quickTabSwitcherMode,
|
||||
required this.tabBarStackingMode,
|
||||
required this.pullToRefreshEnabled,
|
||||
required this.useExternalDownloadManager,
|
||||
required this.doubleBackCloseTab,
|
||||
@@ -216,6 +244,8 @@ class GeneralSettings with FastEquatable {
|
||||
required this.quickTabSwitcherShowTitles,
|
||||
required this.quickTabSwitcherHierarchyGlyphs,
|
||||
required this.quickTabSwitcherShowHistorySuggestions,
|
||||
required this.quickTabSwitcherTitleWidth,
|
||||
required this.quickTabSwitcherShowCloseButtonOnAllTabs,
|
||||
required this.syncServerOverride,
|
||||
required this.syncTokenServerOverride,
|
||||
required this.urlCleanerEnabled,
|
||||
@@ -267,10 +297,9 @@ class GeneralSettings with FastEquatable {
|
||||
Duration? historyAutoCleanInterval,
|
||||
bool? tabViewBottomSheet,
|
||||
bool? tabBarShowContextualBar,
|
||||
bool? tabBarShowQuickTabSwitcherBar,
|
||||
TabBarPosition? tabBarPosition,
|
||||
TabBarLayout? tabBarLayout,
|
||||
QuickTabSwitcherMode? quickTabSwitcherMode,
|
||||
TabBarStackingMode? tabBarStackingMode,
|
||||
bool? pullToRefreshEnabled,
|
||||
bool? useExternalDownloadManager,
|
||||
bool? doubleBackCloseTab,
|
||||
@@ -281,6 +310,8 @@ class GeneralSettings with FastEquatable {
|
||||
bool? quickTabSwitcherShowTitles,
|
||||
int? quickTabSwitcherHierarchyGlyphs,
|
||||
bool? quickTabSwitcherShowHistorySuggestions,
|
||||
double? quickTabSwitcherTitleWidth,
|
||||
bool? quickTabSwitcherShowCloseButtonOnAllTabs,
|
||||
String? syncServerOverride,
|
||||
String? syncTokenServerOverride,
|
||||
bool? urlCleanerEnabled,
|
||||
@@ -332,11 +363,9 @@ class GeneralSettings with FastEquatable {
|
||||
historyAutoCleanInterval ?? const Duration(days: 90),
|
||||
tabViewBottomSheet = tabViewBottomSheet ?? false,
|
||||
tabBarShowContextualBar = tabBarShowContextualBar ?? true,
|
||||
tabBarShowQuickTabSwitcherBar = tabBarShowQuickTabSwitcherBar ?? true,
|
||||
tabBarPosition = tabBarPosition ?? TabBarPosition.bottom,
|
||||
tabBarLayout = tabBarLayout ?? TabBarLayout.compact,
|
||||
quickTabSwitcherMode =
|
||||
quickTabSwitcherMode ?? QuickTabSwitcherMode.lastUsedTabs,
|
||||
tabBarStackingMode = tabBarStackingMode ?? TabBarStackingMode.accordion,
|
||||
pullToRefreshEnabled = pullToRefreshEnabled ?? true,
|
||||
useExternalDownloadManager = useExternalDownloadManager ?? false,
|
||||
doubleBackCloseTab = doubleBackCloseTab ?? true,
|
||||
@@ -351,6 +380,10 @@ class GeneralSettings with FastEquatable {
|
||||
defaultQuickTabSwitcherHierarchyGlyphs,
|
||||
quickTabSwitcherShowHistorySuggestions =
|
||||
quickTabSwitcherShowHistorySuggestions ?? true,
|
||||
quickTabSwitcherTitleWidth =
|
||||
quickTabSwitcherTitleWidth ?? defaultQuickTabSwitcherTitleWidth,
|
||||
quickTabSwitcherShowCloseButtonOnAllTabs =
|
||||
quickTabSwitcherShowCloseButtonOnAllTabs ?? false,
|
||||
syncServerOverride = syncServerOverride ?? '',
|
||||
syncTokenServerOverride = syncTokenServerOverride ?? '',
|
||||
urlCleanerEnabled = urlCleanerEnabled ?? true,
|
||||
@@ -394,6 +427,24 @@ class GeneralSettings with FastEquatable {
|
||||
json.putIfAbsent('tabListDirection', () => mapped);
|
||||
json.putIfAbsent('tabBarDirection', () => mapped);
|
||||
}
|
||||
|
||||
// Migrate the legacy `tabBarShowQuickTabSwitcherBar` toggle and
|
||||
// `quickTabSwitcherMode` selection to the merged `tabBarStackingMode`.
|
||||
// The legacy mode names are a subset of the new enum's, so values map
|
||||
// verbatim.
|
||||
// TODO: Drop this fallback (and the legacy rows in the user settings DB)
|
||||
// once enough releases have shipped that rolling back to a version
|
||||
// without `tabBarStackingMode` is no longer a concern.
|
||||
final legacyShowSwitcherBar = json['tabBarShowQuickTabSwitcherBar'];
|
||||
final legacySwitcherMode = json['quickTabSwitcherMode'];
|
||||
if (json['tabBarStackingMode'] == null) {
|
||||
if (legacyShowSwitcherBar == false) {
|
||||
json['tabBarStackingMode'] = 'disabled';
|
||||
} else if (legacySwitcherMode != null) {
|
||||
json['tabBarStackingMode'] = legacySwitcherMode;
|
||||
}
|
||||
}
|
||||
|
||||
return _$GeneralSettingsFromJson(json);
|
||||
}
|
||||
|
||||
@@ -421,12 +472,18 @@ class GeneralSettings with FastEquatable {
|
||||
return tabIntentOpenSetting;
|
||||
}
|
||||
|
||||
QuickTabSwitcherMode effectiveUiQuickTabSwitcherMode() {
|
||||
/// Container-dependent stacking modes degrade to a single recently-used
|
||||
/// row when the container UI is disabled.
|
||||
TabBarStackingMode effectiveTabBarStackingMode() {
|
||||
if (!showContainerUi &&
|
||||
quickTabSwitcherMode == QuickTabSwitcherMode.containerTabs) {
|
||||
return QuickTabSwitcherMode.lastUsedTabs;
|
||||
const {
|
||||
TabBarStackingMode.containerTabs,
|
||||
TabBarStackingMode.accordion,
|
||||
TabBarStackingMode.twoLevel,
|
||||
}.contains(tabBarStackingMode)) {
|
||||
return TabBarStackingMode.lastUsedTabs;
|
||||
}
|
||||
return quickTabSwitcherMode;
|
||||
return tabBarStackingMode;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -456,10 +513,9 @@ class GeneralSettings with FastEquatable {
|
||||
historyAutoCleanInterval,
|
||||
tabViewBottomSheet,
|
||||
tabBarShowContextualBar,
|
||||
tabBarShowQuickTabSwitcherBar,
|
||||
tabBarPosition,
|
||||
tabBarLayout,
|
||||
quickTabSwitcherMode,
|
||||
tabBarStackingMode,
|
||||
pullToRefreshEnabled,
|
||||
useExternalDownloadManager,
|
||||
doubleBackCloseTab,
|
||||
@@ -470,6 +526,8 @@ class GeneralSettings with FastEquatable {
|
||||
quickTabSwitcherShowTitles,
|
||||
quickTabSwitcherHierarchyGlyphs,
|
||||
quickTabSwitcherShowHistorySuggestions,
|
||||
quickTabSwitcherTitleWidth,
|
||||
quickTabSwitcherShowCloseButtonOnAllTabs,
|
||||
syncServerOverride,
|
||||
syncTokenServerOverride,
|
||||
urlCleanerEnabled,
|
||||
|
||||
@@ -65,17 +65,11 @@ abstract class _$GeneralSettingsCWProxy {
|
||||
|
||||
GeneralSettings tabBarShowContextualBar(bool tabBarShowContextualBar);
|
||||
|
||||
GeneralSettings tabBarShowQuickTabSwitcherBar(
|
||||
bool tabBarShowQuickTabSwitcherBar,
|
||||
);
|
||||
|
||||
GeneralSettings tabBarPosition(TabBarPosition tabBarPosition);
|
||||
|
||||
GeneralSettings tabBarLayout(TabBarLayout tabBarLayout);
|
||||
|
||||
GeneralSettings quickTabSwitcherMode(
|
||||
QuickTabSwitcherMode quickTabSwitcherMode,
|
||||
);
|
||||
GeneralSettings tabBarStackingMode(TabBarStackingMode tabBarStackingMode);
|
||||
|
||||
GeneralSettings pullToRefreshEnabled(bool pullToRefreshEnabled);
|
||||
|
||||
@@ -103,6 +97,12 @@ abstract class _$GeneralSettingsCWProxy {
|
||||
bool quickTabSwitcherShowHistorySuggestions,
|
||||
);
|
||||
|
||||
GeneralSettings quickTabSwitcherTitleWidth(double quickTabSwitcherTitleWidth);
|
||||
|
||||
GeneralSettings quickTabSwitcherShowCloseButtonOnAllTabs(
|
||||
bool quickTabSwitcherShowCloseButtonOnAllTabs,
|
||||
);
|
||||
|
||||
GeneralSettings syncServerOverride(String syncServerOverride);
|
||||
|
||||
GeneralSettings syncTokenServerOverride(String syncTokenServerOverride);
|
||||
@@ -186,10 +186,9 @@ abstract class _$GeneralSettingsCWProxy {
|
||||
Duration historyAutoCleanInterval,
|
||||
bool tabViewBottomSheet,
|
||||
bool tabBarShowContextualBar,
|
||||
bool tabBarShowQuickTabSwitcherBar,
|
||||
TabBarPosition tabBarPosition,
|
||||
TabBarLayout tabBarLayout,
|
||||
QuickTabSwitcherMode quickTabSwitcherMode,
|
||||
TabBarStackingMode tabBarStackingMode,
|
||||
bool pullToRefreshEnabled,
|
||||
bool useExternalDownloadManager,
|
||||
bool doubleBackCloseTab,
|
||||
@@ -200,6 +199,8 @@ abstract class _$GeneralSettingsCWProxy {
|
||||
bool quickTabSwitcherShowTitles,
|
||||
int quickTabSwitcherHierarchyGlyphs,
|
||||
bool quickTabSwitcherShowHistorySuggestions,
|
||||
double quickTabSwitcherTitleWidth,
|
||||
bool quickTabSwitcherShowCloseButtonOnAllTabs,
|
||||
String syncServerOverride,
|
||||
String syncTokenServerOverride,
|
||||
bool urlCleanerEnabled,
|
||||
@@ -337,11 +338,6 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
||||
GeneralSettings tabBarShowContextualBar(bool tabBarShowContextualBar) =>
|
||||
call(tabBarShowContextualBar: tabBarShowContextualBar);
|
||||
|
||||
@override
|
||||
GeneralSettings tabBarShowQuickTabSwitcherBar(
|
||||
bool tabBarShowQuickTabSwitcherBar,
|
||||
) => call(tabBarShowQuickTabSwitcherBar: tabBarShowQuickTabSwitcherBar);
|
||||
|
||||
@override
|
||||
GeneralSettings tabBarPosition(TabBarPosition tabBarPosition) =>
|
||||
call(tabBarPosition: tabBarPosition);
|
||||
@@ -351,9 +347,8 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
||||
call(tabBarLayout: tabBarLayout);
|
||||
|
||||
@override
|
||||
GeneralSettings quickTabSwitcherMode(
|
||||
QuickTabSwitcherMode quickTabSwitcherMode,
|
||||
) => call(quickTabSwitcherMode: quickTabSwitcherMode);
|
||||
GeneralSettings tabBarStackingMode(TabBarStackingMode tabBarStackingMode) =>
|
||||
call(tabBarStackingMode: tabBarStackingMode);
|
||||
|
||||
@override
|
||||
GeneralSettings pullToRefreshEnabled(bool pullToRefreshEnabled) =>
|
||||
@@ -401,6 +396,19 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
||||
quickTabSwitcherShowHistorySuggestions,
|
||||
);
|
||||
|
||||
@override
|
||||
GeneralSettings quickTabSwitcherTitleWidth(
|
||||
double quickTabSwitcherTitleWidth,
|
||||
) => call(quickTabSwitcherTitleWidth: quickTabSwitcherTitleWidth);
|
||||
|
||||
@override
|
||||
GeneralSettings quickTabSwitcherShowCloseButtonOnAllTabs(
|
||||
bool quickTabSwitcherShowCloseButtonOnAllTabs,
|
||||
) => call(
|
||||
quickTabSwitcherShowCloseButtonOnAllTabs:
|
||||
quickTabSwitcherShowCloseButtonOnAllTabs,
|
||||
);
|
||||
|
||||
@override
|
||||
GeneralSettings syncServerOverride(String syncServerOverride) =>
|
||||
call(syncServerOverride: syncServerOverride);
|
||||
@@ -529,10 +537,9 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
||||
Object? historyAutoCleanInterval = const $CopyWithPlaceholder(),
|
||||
Object? tabViewBottomSheet = const $CopyWithPlaceholder(),
|
||||
Object? tabBarShowContextualBar = const $CopyWithPlaceholder(),
|
||||
Object? tabBarShowQuickTabSwitcherBar = const $CopyWithPlaceholder(),
|
||||
Object? tabBarPosition = const $CopyWithPlaceholder(),
|
||||
Object? tabBarLayout = const $CopyWithPlaceholder(),
|
||||
Object? quickTabSwitcherMode = const $CopyWithPlaceholder(),
|
||||
Object? tabBarStackingMode = const $CopyWithPlaceholder(),
|
||||
Object? pullToRefreshEnabled = const $CopyWithPlaceholder(),
|
||||
Object? useExternalDownloadManager = const $CopyWithPlaceholder(),
|
||||
Object? doubleBackCloseTab = const $CopyWithPlaceholder(),
|
||||
@@ -544,6 +551,9 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
||||
Object? quickTabSwitcherHierarchyGlyphs = const $CopyWithPlaceholder(),
|
||||
Object? quickTabSwitcherShowHistorySuggestions =
|
||||
const $CopyWithPlaceholder(),
|
||||
Object? quickTabSwitcherTitleWidth = const $CopyWithPlaceholder(),
|
||||
Object? quickTabSwitcherShowCloseButtonOnAllTabs =
|
||||
const $CopyWithPlaceholder(),
|
||||
Object? syncServerOverride = const $CopyWithPlaceholder(),
|
||||
Object? syncTokenServerOverride = const $CopyWithPlaceholder(),
|
||||
Object? urlCleanerEnabled = const $CopyWithPlaceholder(),
|
||||
@@ -714,12 +724,6 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
||||
? _value.tabBarShowContextualBar
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: tabBarShowContextualBar as bool,
|
||||
tabBarShowQuickTabSwitcherBar:
|
||||
tabBarShowQuickTabSwitcherBar == const $CopyWithPlaceholder() ||
|
||||
tabBarShowQuickTabSwitcherBar == null
|
||||
? _value.tabBarShowQuickTabSwitcherBar
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: tabBarShowQuickTabSwitcherBar as bool,
|
||||
tabBarPosition:
|
||||
tabBarPosition == const $CopyWithPlaceholder() ||
|
||||
tabBarPosition == null
|
||||
@@ -731,12 +735,12 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
||||
? _value.tabBarLayout
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: tabBarLayout as TabBarLayout,
|
||||
quickTabSwitcherMode:
|
||||
quickTabSwitcherMode == const $CopyWithPlaceholder() ||
|
||||
quickTabSwitcherMode == null
|
||||
? _value.quickTabSwitcherMode
|
||||
tabBarStackingMode:
|
||||
tabBarStackingMode == const $CopyWithPlaceholder() ||
|
||||
tabBarStackingMode == null
|
||||
? _value.tabBarStackingMode
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: quickTabSwitcherMode as QuickTabSwitcherMode,
|
||||
: tabBarStackingMode as TabBarStackingMode,
|
||||
pullToRefreshEnabled:
|
||||
pullToRefreshEnabled == const $CopyWithPlaceholder() ||
|
||||
pullToRefreshEnabled == null
|
||||
@@ -798,6 +802,19 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
||||
? _value.quickTabSwitcherShowHistorySuggestions
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: quickTabSwitcherShowHistorySuggestions as bool,
|
||||
quickTabSwitcherTitleWidth:
|
||||
quickTabSwitcherTitleWidth == const $CopyWithPlaceholder() ||
|
||||
quickTabSwitcherTitleWidth == null
|
||||
? _value.quickTabSwitcherTitleWidth
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: quickTabSwitcherTitleWidth as double,
|
||||
quickTabSwitcherShowCloseButtonOnAllTabs:
|
||||
quickTabSwitcherShowCloseButtonOnAllTabs ==
|
||||
const $CopyWithPlaceholder() ||
|
||||
quickTabSwitcherShowCloseButtonOnAllTabs == null
|
||||
? _value.quickTabSwitcherShowCloseButtonOnAllTabs
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: quickTabSwitcherShowCloseButtonOnAllTabs as bool,
|
||||
syncServerOverride:
|
||||
syncServerOverride == const $CopyWithPlaceholder() ||
|
||||
syncServerOverride == null
|
||||
@@ -1005,7 +1022,6 @@ GeneralSettings _$GeneralSettingsFromJson(
|
||||
),
|
||||
tabViewBottomSheet: json['tabViewBottomSheet'] as bool?,
|
||||
tabBarShowContextualBar: json['tabBarShowContextualBar'] as bool?,
|
||||
tabBarShowQuickTabSwitcherBar: json['tabBarShowQuickTabSwitcherBar'] as bool?,
|
||||
tabBarPosition: $enumDecodeNullable(
|
||||
_$TabBarPositionEnumMap,
|
||||
json['tabBarPosition'],
|
||||
@@ -1014,9 +1030,9 @@ GeneralSettings _$GeneralSettingsFromJson(
|
||||
_$TabBarLayoutEnumMap,
|
||||
json['tabBarLayout'],
|
||||
),
|
||||
quickTabSwitcherMode: $enumDecodeNullable(
|
||||
_$QuickTabSwitcherModeEnumMap,
|
||||
json['quickTabSwitcherMode'],
|
||||
tabBarStackingMode: $enumDecodeNullable(
|
||||
_$TabBarStackingModeEnumMap,
|
||||
json['tabBarStackingMode'],
|
||||
),
|
||||
pullToRefreshEnabled: json['pullToRefreshEnabled'] as bool?,
|
||||
useExternalDownloadManager: json['useExternalDownloadManager'] as bool?,
|
||||
@@ -1036,6 +1052,10 @@ GeneralSettings _$GeneralSettingsFromJson(
|
||||
(json['quickTabSwitcherHierarchyGlyphs'] as num?)?.toInt(),
|
||||
quickTabSwitcherShowHistorySuggestions:
|
||||
json['quickTabSwitcherShowHistorySuggestions'] as bool?,
|
||||
quickTabSwitcherTitleWidth: (json['quickTabSwitcherTitleWidth'] as num?)
|
||||
?.toDouble(),
|
||||
quickTabSwitcherShowCloseButtonOnAllTabs:
|
||||
json['quickTabSwitcherShowCloseButtonOnAllTabs'] as bool?,
|
||||
syncServerOverride: json['syncServerOverride'] as String?,
|
||||
syncTokenServerOverride: json['syncTokenServerOverride'] as String?,
|
||||
urlCleanerEnabled: json['urlCleanerEnabled'] as bool?,
|
||||
@@ -1107,11 +1127,10 @@ Map<String, dynamic> _$GeneralSettingsToJson(
|
||||
'historyAutoCleanInterval': instance.historyAutoCleanInterval.inMicroseconds,
|
||||
'tabViewBottomSheet': instance.tabViewBottomSheet,
|
||||
'tabBarShowContextualBar': instance.tabBarShowContextualBar,
|
||||
'tabBarShowQuickTabSwitcherBar': instance.tabBarShowQuickTabSwitcherBar,
|
||||
'tabBarPosition': _$TabBarPositionEnumMap[instance.tabBarPosition]!,
|
||||
'tabBarLayout': _$TabBarLayoutEnumMap[instance.tabBarLayout]!,
|
||||
'quickTabSwitcherMode':
|
||||
_$QuickTabSwitcherModeEnumMap[instance.quickTabSwitcherMode]!,
|
||||
'tabBarStackingMode':
|
||||
_$TabBarStackingModeEnumMap[instance.tabBarStackingMode]!,
|
||||
'pullToRefreshEnabled': instance.pullToRefreshEnabled,
|
||||
'useExternalDownloadManager': instance.useExternalDownloadManager,
|
||||
'doubleBackCloseTab': instance.doubleBackCloseTab,
|
||||
@@ -1124,6 +1143,9 @@ Map<String, dynamic> _$GeneralSettingsToJson(
|
||||
'quickTabSwitcherHierarchyGlyphs': instance.quickTabSwitcherHierarchyGlyphs,
|
||||
'quickTabSwitcherShowHistorySuggestions':
|
||||
instance.quickTabSwitcherShowHistorySuggestions,
|
||||
'quickTabSwitcherTitleWidth': instance.quickTabSwitcherTitleWidth,
|
||||
'quickTabSwitcherShowCloseButtonOnAllTabs':
|
||||
instance.quickTabSwitcherShowCloseButtonOnAllTabs,
|
||||
'syncServerOverride': instance.syncServerOverride,
|
||||
'syncTokenServerOverride': instance.syncTokenServerOverride,
|
||||
'urlCleanerEnabled': instance.urlCleanerEnabled,
|
||||
@@ -1214,9 +1236,12 @@ const _$TabBarLayoutEnumMap = {
|
||||
TabBarLayout.compact: 'compact',
|
||||
};
|
||||
|
||||
const _$QuickTabSwitcherModeEnumMap = {
|
||||
QuickTabSwitcherMode.lastUsedTabs: 'lastUsedTabs',
|
||||
QuickTabSwitcherMode.containerTabs: 'containerTabs',
|
||||
const _$TabBarStackingModeEnumMap = {
|
||||
TabBarStackingMode.lastUsedTabs: 'lastUsedTabs',
|
||||
TabBarStackingMode.containerTabs: 'containerTabs',
|
||||
TabBarStackingMode.accordion: 'accordion',
|
||||
TabBarStackingMode.twoLevel: 'twoLevel',
|
||||
TabBarStackingMode.disabled: 'disabled',
|
||||
};
|
||||
|
||||
const _$IntentSourcePolicyEnumMap = {
|
||||
|
||||
@@ -156,6 +156,10 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository {
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'tabBarStackingMode': settings['tabBarStackingMode']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'pullToRefreshEnabled': settings['pullToRefreshEnabled']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
@@ -195,6 +199,13 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository {
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'quickTabSwitcherTitleWidth': settings['quickTabSwitcherTitleWidth']
|
||||
?.readAs(DriftSqlType.double, db.typeMapping),
|
||||
'quickTabSwitcherShowCloseButtonOnAllTabs':
|
||||
settings['quickTabSwitcherShowCloseButtonOnAllTabs']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'syncServerOverride': settings['syncServerOverride']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
|
||||
@@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider
|
||||
}
|
||||
|
||||
String _$generalSettingsRepositoryHash() =>
|
||||
r'eb369cd699e336cd5ca51709f116883ae8c6dbc4';
|
||||
r'ad70d95bd7f57b9ba06b0500a651b4e41ade572a';
|
||||
|
||||
abstract class _$GeneralSettingsRepository
|
||||
extends $StreamNotifier<GeneralSettings> {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
*/
|
||||
import 'package:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:weblibre/features/web_feed/data/models/feed_category.dart';
|
||||
|
||||
@@ -61,10 +62,9 @@ class TagsHorizontalList extends StatelessWidget {
|
||||
fadingSize: 15,
|
||||
builder: (context, controller) {
|
||||
return ListView.builder(
|
||||
scrollCacheExtent: const ScrollCacheExtent.pixels(0),
|
||||
itemCount: _tags.length,
|
||||
controller: controller,
|
||||
//Improve list performance by not rendering outside screen at all
|
||||
cacheExtent: 0,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (context, index) => _tags[index],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* 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/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
/// Keeps the "active" chip of a horizontally-scrolling chip row centered in
|
||||
/// its viewport, reliably even when that chip is far outside the currently
|
||||
/// built (lazy) range.
|
||||
///
|
||||
/// A plain [Scrollable.ensureVisible] is not enough for these rows: the chips
|
||||
/// live in a horizontal `ListView.builder`, so an active chip outside the
|
||||
/// build/cache range has no `BuildContext` and there is nothing to scroll to.
|
||||
/// Their widths also vary (titles, badges, hierarchy glyphs), so a single
|
||||
/// index-proportional jump usually lands the target close but not exactly.
|
||||
///
|
||||
/// This hook runs a short, bounded converge loop instead. Each pass either:
|
||||
/// * centers the chip precisely with [Scrollable.ensureVisible] (when the
|
||||
/// chip is built and therefore has a context), finishing the loop; or
|
||||
/// * jumps to an index-proportional estimate of the chip's centered offset
|
||||
/// to pull it into the build/cache range, then retries on the next frame.
|
||||
///
|
||||
/// When the estimate is too far off for the chip to build (most likely in the
|
||||
/// accordion, whose rows mix wide container headers with narrow tab chips),
|
||||
/// later passes fan out from the estimate in alternating viewport-sized steps
|
||||
/// so the search window sweeps across the error instead of stalling. The
|
||||
/// target enters the cache range within a few passes and the final
|
||||
/// `ensureVisible` snaps it to the exact center — handling the "calculation is
|
||||
/// a bit off" (variable widths), "doesn't work at all" (metrics not ready on
|
||||
/// the first frame, e.g. two stacked rows), and far-off-estimate cases.
|
||||
///
|
||||
/// The routine re-runs whenever [activeId] changes or its position within
|
||||
/// [orderedIds] changes (reorders, insertions, container switches), and is
|
||||
/// suppressed while [isUserScrolling] returns true so it never fights a manual
|
||||
/// scroll.
|
||||
void useScrollToActiveChip<K>({
|
||||
required ScrollController controller,
|
||||
required GlobalKey activeChipKey,
|
||||
required K? activeId,
|
||||
required List<K> orderedIds,
|
||||
required bool Function() isUserScrolling,
|
||||
Duration animationDuration = const Duration(milliseconds: 200),
|
||||
int maxPasses = 8,
|
||||
}) {
|
||||
// Index of the active chip drives both the estimate and the effect's
|
||||
// re-run trigger: a selection change, a reorder, or an insertion that
|
||||
// shifts the active chip all change this value.
|
||||
final activeIndex = activeId == null ? -1 : orderedIds.indexOf(activeId);
|
||||
final totalCount = orderedIds.length;
|
||||
|
||||
useEffect(() {
|
||||
if (activeIndex < 0) return null;
|
||||
if (isUserScrolling()) return null;
|
||||
|
||||
var cancelled = false;
|
||||
|
||||
void runPass(int pass) {
|
||||
if (cancelled || isUserScrolling()) return;
|
||||
|
||||
void scheduleNext() {
|
||||
if (pass >= maxPasses) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => runPass(pass + 1));
|
||||
}
|
||||
|
||||
if (!controller.hasClients) {
|
||||
// Viewport not attached yet (common on the first frame, and worse for
|
||||
// two stacked rows); wait for it to come up.
|
||||
scheduleNext();
|
||||
return;
|
||||
}
|
||||
|
||||
final chipContext = activeChipKey.currentContext;
|
||||
if (chipContext != null) {
|
||||
// The chip is built: snap it to the exact center and stop. Any late
|
||||
// layout shift (image/badge resize) is small enough to ignore.
|
||||
unawaited(
|
||||
Scrollable.ensureVisible(
|
||||
chipContext,
|
||||
alignment: 0.5,
|
||||
duration: animationDuration,
|
||||
curve: Curves.easeInOut,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// The chip is outside the build/cache range, so there is no context to
|
||||
// center yet. Jump to an estimate of its offset to pull it into range,
|
||||
// then refine on the next pass once it has been built.
|
||||
final position = controller.position;
|
||||
final maxExtent = position.maxScrollExtent;
|
||||
final viewport = position.viewportDimension;
|
||||
if (maxExtent <= 0 || totalCount <= 0) {
|
||||
// Metrics not ready (or everything fits): retry until they settle.
|
||||
scheduleNext();
|
||||
return;
|
||||
}
|
||||
|
||||
// Index-proportional estimate of the chip's centered offset, assuming
|
||||
// roughly uniform widths. Total content spans [0, maxExtent + viewport].
|
||||
final contentExtent = maxExtent + viewport;
|
||||
final estimatedCenter = (activeIndex + 0.5) / totalCount * contentExtent;
|
||||
final estimate = estimatedCenter - viewport / 2;
|
||||
|
||||
// Uniform widths are only an approximation — worst for the accordion,
|
||||
// whose rows mix wide container headers with tab chips. When the first
|
||||
// estimate doesn't surface the chip, fan out from it in alternating
|
||||
// directions (pass 2: +1 viewport, pass 3: -1, pass 4: +2, ...) so the
|
||||
// search window sweeps across the mis-estimate instead of stalling on a
|
||||
// repeated identical jump. Each step overlaps the lazy cache, so the
|
||||
// chip is guaranteed to build within a few passes.
|
||||
final step = pass ~/ 2;
|
||||
final direction = pass.isOdd ? 1 : -1;
|
||||
final target = (estimate + direction * step * viewport).clamp(
|
||||
0.0,
|
||||
maxExtent,
|
||||
);
|
||||
|
||||
controller.jumpTo(target);
|
||||
scheduleNext();
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => runPass(1));
|
||||
|
||||
return () => cancelled = true;
|
||||
}, [activeId, activeIndex, totalCount]);
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_icons.dart';
|
||||
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
|
||||
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
||||
|
||||
@@ -28,6 +29,7 @@ class UrlListTile extends StatelessWidget {
|
||||
final Widget? leading;
|
||||
final Widget? trailing;
|
||||
final Color? containerColor;
|
||||
final IconData? containerIcon;
|
||||
final bool useCustomColor;
|
||||
final bool showHttpScheme;
|
||||
final VoidCallback? onTap;
|
||||
@@ -39,12 +41,14 @@ class UrlListTile extends StatelessWidget {
|
||||
this.leading,
|
||||
this.trailing,
|
||||
this.containerColor,
|
||||
this.containerIcon,
|
||||
this.useCustomColor = false,
|
||||
this.showHttpScheme = true,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
static const iconSize = 32.0;
|
||||
static const _badgeWidth = 56.0;
|
||||
static const _borderRadius = BorderRadius.all(Radius.circular(12.0));
|
||||
|
||||
@override
|
||||
@@ -75,51 +79,91 @@ class UrlListTile extends StatelessWidget {
|
||||
child: InkWell(
|
||||
borderRadius: _borderRadius,
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 12.0,
|
||||
top: 10.0,
|
||||
bottom: 10.0,
|
||||
right: 12.0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
leading ??
|
||||
RepaintBoundary(child: UrlIcon([uri], iconSize: iconSize)),
|
||||
const SizedBox(width: 14.0),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3.0),
|
||||
UriBreadcrumb(
|
||||
uri: uri,
|
||||
showHttpScheme: showHttpScheme,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
child: Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 12.0,
|
||||
top: 10.0,
|
||||
bottom: 10.0,
|
||||
// Reserve room for the trailing badge so content never
|
||||
// slides underneath it.
|
||||
right: containerPalette != null ? _badgeWidth + 12.0 : 12.0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
leading ??
|
||||
RepaintBoundary(
|
||||
child: UrlIcon([uri], iconSize: iconSize),
|
||||
),
|
||||
const SizedBox(width: 14.0),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3.0),
|
||||
UriBreadcrumb(
|
||||
uri: uri,
|
||||
showHttpScheme: showHttpScheme,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[
|
||||
const SizedBox(width: 8.0),
|
||||
trailing!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
// Stretches to the card height set by the content above without
|
||||
// the extra layout pass an IntrinsicHeight Row would cost.
|
||||
if (containerPalette != null)
|
||||
Positioned(
|
||||
top: 0.0,
|
||||
bottom: 0.0,
|
||||
right: 0.0,
|
||||
width: _badgeWidth,
|
||||
child: _ContainerBadge(
|
||||
palette: containerPalette,
|
||||
icon: resolveContainerIcon(containerIcon),
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[
|
||||
const SizedBox(width: 8.0),
|
||||
trailing!,
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Trailing accent-coloured strip flush against the card's right edge that
|
||||
/// surfaces the owning container's identity (its colour and icon).
|
||||
class _ContainerBadge extends StatelessWidget {
|
||||
final ContainerColorPalette palette;
|
||||
final IconData icon;
|
||||
|
||||
const _ContainerBadge({required this.palette, required this.icon});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: UrlListTile._badgeWidth,
|
||||
alignment: Alignment.center,
|
||||
color: palette.accentColor,
|
||||
child: Icon(icon, size: 22.0, color: palette.onAccentColor),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,12 +92,12 @@ void main() {
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: MaterialApp(
|
||||
child: const MaterialApp(
|
||||
home: SettingsDetailScaffold(
|
||||
title: 'Appearance',
|
||||
subtitle: 'Configure app appearance',
|
||||
icon: Icons.palette,
|
||||
sections: const [
|
||||
sections: [
|
||||
SettingsSectionDefinition(
|
||||
title: 'Display',
|
||||
entries: [
|
||||
|
||||
Reference in New Issue
Block a user