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: [
|
||||
|
||||
+8
@@ -178,6 +178,7 @@ class GeckoTabsApiImpl : GeckoTabsApi {
|
||||
override fun syncEvents(
|
||||
onSelectedTabChange: Boolean,
|
||||
onTabListChange: Boolean,
|
||||
onRestoreComplete: Boolean,
|
||||
onTabContentStateChange: Boolean,
|
||||
onIconChange: Boolean,
|
||||
onSecurityInfoStateChange: Boolean,
|
||||
@@ -208,6 +209,13 @@ class GeckoTabsApiImpl : GeckoTabsApi {
|
||||
tabs.map { it.id }) { }
|
||||
}
|
||||
|
||||
if (onRestoreComplete) {
|
||||
components.flutterEvents.onRestoreCompleteChange(
|
||||
EventSequence.next(),
|
||||
components.core.store.state.restoreComplete
|
||||
) { }
|
||||
}
|
||||
|
||||
tabs.forEach { tab ->
|
||||
if (onTabContentStateChange) {
|
||||
components.flutterEvents.onTabContentStateChange(
|
||||
|
||||
+11
@@ -64,6 +64,17 @@ class Events(
|
||||
}
|
||||
}
|
||||
|
||||
stateFlow.flowScoped(dispatcher = Dispatchers.Main) { flow ->
|
||||
flow.map { state -> state.restoreComplete }
|
||||
.distinctUntilChanged()
|
||||
.collect { restoreComplete ->
|
||||
flutterEvents.onRestoreCompleteChange(
|
||||
EventSequence.next(),
|
||||
restoreComplete
|
||||
) { _ -> }
|
||||
}
|
||||
}
|
||||
|
||||
stateFlow.flowScoped(dispatcher = Dispatchers.Main) { flow ->
|
||||
var previousTabs = emptySet<String>()
|
||||
flow.mapNotNull { state -> state.tabs.map { tab -> tab.id } }
|
||||
|
||||
+36
-14
@@ -7829,7 +7829,7 @@ interface GeckoSessionApi {
|
||||
}
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface GeckoTabsApi {
|
||||
fun syncEvents(onSelectedTabChange: Boolean, onTabListChange: Boolean, onTabContentStateChange: Boolean, onIconChange: Boolean, onSecurityInfoStateChange: Boolean, onReaderableStateChange: Boolean, onHistoryStateChange: Boolean, onFindResults: Boolean, onThumbnailChange: Boolean, onBrowserExtensionsChange: Boolean, onPageExtensionsChange: Boolean, onBrowserExtensionIcons: Boolean, onPageExtensionIcons: Boolean, onTranslationStateChange: Boolean)
|
||||
fun syncEvents(onSelectedTabChange: Boolean, onTabListChange: Boolean, onRestoreComplete: Boolean, onTabContentStateChange: Boolean, onIconChange: Boolean, onSecurityInfoStateChange: Boolean, onReaderableStateChange: Boolean, onHistoryStateChange: Boolean, onFindResults: Boolean, onThumbnailChange: Boolean, onBrowserExtensionsChange: Boolean, onPageExtensionsChange: Boolean, onBrowserExtensionIcons: Boolean, onPageExtensionIcons: Boolean, onTranslationStateChange: Boolean)
|
||||
fun selectTab(tabId: String)
|
||||
fun removeTab(tabId: String)
|
||||
fun addTab(url: String, selectTab: Boolean, startLoading: Boolean, parentId: String?, flags: LoadUrlFlagsValue, contextId: String?, source: SourceValue, private: Boolean, historyMetadata: HistoryMetadataKey?, additionalHeaders: Map<String, String>?): String
|
||||
@@ -7867,20 +7867,21 @@ interface GeckoTabsApi {
|
||||
val args = message as List<Any?>
|
||||
val onSelectedTabChangeArg = args[0] as Boolean
|
||||
val onTabListChangeArg = args[1] as Boolean
|
||||
val onTabContentStateChangeArg = args[2] as Boolean
|
||||
val onIconChangeArg = args[3] as Boolean
|
||||
val onSecurityInfoStateChangeArg = args[4] as Boolean
|
||||
val onReaderableStateChangeArg = args[5] as Boolean
|
||||
val onHistoryStateChangeArg = args[6] as Boolean
|
||||
val onFindResultsArg = args[7] as Boolean
|
||||
val onThumbnailChangeArg = args[8] as Boolean
|
||||
val onBrowserExtensionsChangeArg = args[9] as Boolean
|
||||
val onPageExtensionsChangeArg = args[10] as Boolean
|
||||
val onBrowserExtensionIconsArg = args[11] as Boolean
|
||||
val onPageExtensionIconsArg = args[12] as Boolean
|
||||
val onTranslationStateChangeArg = args[13] as Boolean
|
||||
val onRestoreCompleteArg = args[2] as Boolean
|
||||
val onTabContentStateChangeArg = args[3] as Boolean
|
||||
val onIconChangeArg = args[4] as Boolean
|
||||
val onSecurityInfoStateChangeArg = args[5] as Boolean
|
||||
val onReaderableStateChangeArg = args[6] as Boolean
|
||||
val onHistoryStateChangeArg = args[7] as Boolean
|
||||
val onFindResultsArg = args[8] as Boolean
|
||||
val onThumbnailChangeArg = args[9] as Boolean
|
||||
val onBrowserExtensionsChangeArg = args[10] as Boolean
|
||||
val onPageExtensionsChangeArg = args[11] as Boolean
|
||||
val onBrowserExtensionIconsArg = args[12] as Boolean
|
||||
val onPageExtensionIconsArg = args[13] as Boolean
|
||||
val onTranslationStateChangeArg = args[14] as Boolean
|
||||
val wrapped: List<Any?> = try {
|
||||
api.syncEvents(onSelectedTabChangeArg, onTabListChangeArg, onTabContentStateChangeArg, onIconChangeArg, onSecurityInfoStateChangeArg, onReaderableStateChangeArg, onHistoryStateChangeArg, onFindResultsArg, onThumbnailChangeArg, onBrowserExtensionsChangeArg, onPageExtensionsChangeArg, onBrowserExtensionIconsArg, onPageExtensionIconsArg, onTranslationStateChangeArg)
|
||||
api.syncEvents(onSelectedTabChangeArg, onTabListChangeArg, onRestoreCompleteArg, onTabContentStateChangeArg, onIconChangeArg, onSecurityInfoStateChangeArg, onReaderableStateChangeArg, onHistoryStateChangeArg, onFindResultsArg, onThumbnailChangeArg, onBrowserExtensionsChangeArg, onPageExtensionsChangeArg, onBrowserExtensionIconsArg, onPageExtensionIconsArg, onTranslationStateChangeArg)
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
GeckoPigeonUtils.wrapError(exception)
|
||||
@@ -9004,6 +9005,27 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Mirrors `BrowserState.restoreComplete`: false until the native session
|
||||
* restore has dispatched all persisted tabs into the store.
|
||||
*/
|
||||
fun onRestoreCompleteChange(sequenceArg: Long, restoreCompleteArg: Boolean, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onRestoreCompleteChange$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(sequenceArg, restoreCompleteArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
} else {
|
||||
callback(Result.success(Unit))
|
||||
}
|
||||
} else {
|
||||
callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName)))
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onTabContentStateChange(sequenceArg: Long, stateArg: TabContentState, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
|
||||
@@ -31,6 +31,7 @@ class GeckoEventService extends GeckoStateEvents {
|
||||
final _engineStateSubject = BehaviorSubject.seeded(false);
|
||||
final _tabListSubject = BehaviorSubject<List<String>>();
|
||||
final _selectedTabSubject = BehaviorSubject<String?>();
|
||||
final _restoreCompleteSubject = BehaviorSubject.seeded(false);
|
||||
|
||||
final _tabContentSubject = ReplaySubject<TabContentState>();
|
||||
final _historySubject = ReplaySubject<HistoryEvent>();
|
||||
@@ -59,6 +60,8 @@ class GeckoEventService extends GeckoStateEvents {
|
||||
ValueStream<bool> get engineReadyStateEvents => _engineStateSubject.stream;
|
||||
ValueStream<List<String>> get tabListEvents => _tabListSubject.stream;
|
||||
ValueStream<String?> get selectedTabEvents => _selectedTabSubject.stream;
|
||||
ValueStream<bool> get restoreCompleteEvents =>
|
||||
_restoreCompleteSubject.stream;
|
||||
|
||||
Stream<TabContentState> get tabContentEvents => _tabContentSubject.stream;
|
||||
Stream<HistoryEvent> get historyEvents => _historySubject.stream;
|
||||
@@ -109,6 +112,11 @@ class GeckoEventService extends GeckoStateEvents {
|
||||
_selectedTabSubject.addWhenMoreRecent(sequence, id, id);
|
||||
}
|
||||
|
||||
@override
|
||||
void onRestoreCompleteChange(int sequence, bool restoreComplete) {
|
||||
_restoreCompleteSubject.addWhenMoreRecent(sequence, null, restoreComplete);
|
||||
}
|
||||
|
||||
@override
|
||||
void onTabContentStateChange(int sequence, TabContentState state) {
|
||||
_tabContentSubject.addWhenMoreRecent(sequence, state.id, state);
|
||||
|
||||
@@ -19,6 +19,7 @@ class GeckoTabService {
|
||||
Future<void> syncEvents({
|
||||
bool onSelectedTabChange = false,
|
||||
bool onTabListChange = false,
|
||||
bool onRestoreComplete = false,
|
||||
bool onTabContentStateChange = false,
|
||||
bool onIconChange = false,
|
||||
bool onSecurityInfoStateChange = false,
|
||||
@@ -34,6 +35,7 @@ class GeckoTabService {
|
||||
return _api.syncEvents(
|
||||
onSelectedTabChange: onSelectedTabChange,
|
||||
onTabListChange: onTabListChange,
|
||||
onRestoreComplete: onRestoreComplete,
|
||||
onTabContentStateChange: onTabContentStateChange,
|
||||
onIconChange: onIconChange,
|
||||
onSecurityInfoStateChange: onSecurityInfoStateChange,
|
||||
|
||||
@@ -7740,14 +7740,14 @@ class GeckoTabsApi {
|
||||
|
||||
final String pigeonVar_messageChannelSuffix;
|
||||
|
||||
Future<void> syncEvents({required bool onSelectedTabChange, required bool onTabListChange, required bool onTabContentStateChange, required bool onIconChange, required bool onSecurityInfoStateChange, required bool onReaderableStateChange, required bool onHistoryStateChange, required bool onFindResults, required bool onThumbnailChange, required bool onBrowserExtensionsChange, required bool onPageExtensionsChange, required bool onBrowserExtensionIcons, required bool onPageExtensionIcons, required bool onTranslationStateChange, }) async {
|
||||
Future<void> syncEvents({required bool onSelectedTabChange, required bool onTabListChange, required bool onRestoreComplete, required bool onTabContentStateChange, required bool onIconChange, required bool onSecurityInfoStateChange, required bool onReaderableStateChange, required bool onHistoryStateChange, required bool onFindResults, required bool onThumbnailChange, required bool onBrowserExtensionsChange, required bool onPageExtensionsChange, required bool onBrowserExtensionIcons, required bool onPageExtensionIcons, required bool onTranslationStateChange, }) async {
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoTabsApi.syncEvents$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[onSelectedTabChange, onTabListChange, onTabContentStateChange, onIconChange, onSecurityInfoStateChange, onReaderableStateChange, onHistoryStateChange, onFindResults, onThumbnailChange, onBrowserExtensionsChange, onPageExtensionsChange, onBrowserExtensionIcons, onPageExtensionIcons, onTranslationStateChange]);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[onSelectedTabChange, onTabListChange, onRestoreComplete, onTabContentStateChange, onIconChange, onSecurityInfoStateChange, onReaderableStateChange, onHistoryStateChange, onFindResults, onThumbnailChange, onBrowserExtensionsChange, onPageExtensionsChange, onBrowserExtensionIcons, onPageExtensionIcons, onTranslationStateChange]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
_extractReplyValueOrThrow(
|
||||
@@ -8700,6 +8700,10 @@ abstract class GeckoStateEvents {
|
||||
|
||||
void onSelectedTabChange(int sequence, String? id);
|
||||
|
||||
/// Mirrors `BrowserState.restoreComplete`: false until the native session
|
||||
/// restore has dispatched all persisted tabs into the store.
|
||||
void onRestoreCompleteChange(int sequence, bool restoreComplete);
|
||||
|
||||
void onTabContentStateChange(int sequence, TabContentState state);
|
||||
|
||||
void onHistoryStateChange(int sequence, String id, HistoryState state);
|
||||
@@ -8867,6 +8871,28 @@ abstract class GeckoStateEvents {
|
||||
});
|
||||
}
|
||||
}
|
||||
{
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
'dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onRestoreCompleteChange$messageChannelSuffix', pigeonChannelCodec,
|
||||
binaryMessenger: binaryMessenger);
|
||||
if (api == null) {
|
||||
pigeonVar_channel.setMessageHandler(null);
|
||||
} else {
|
||||
pigeonVar_channel.setMessageHandler((Object? message) async {
|
||||
final List<Object?> args = message! as List<Object?>;
|
||||
final int arg_sequence = args[0]! as int;
|
||||
final bool arg_restoreComplete = args[1]! as bool;
|
||||
try {
|
||||
api.onRestoreCompleteChange(arg_sequence, arg_restoreComplete);
|
||||
return wrapResponse(empty: true);
|
||||
} on PlatformException catch (e) {
|
||||
return wrapResponse(error: e);
|
||||
} catch (e) {
|
||||
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
{
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
'dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onTabContentStateChange$messageChannelSuffix', pigeonChannelCodec,
|
||||
|
||||
@@ -1661,6 +1661,7 @@ abstract class GeckoTabsApi {
|
||||
void syncEvents({
|
||||
required bool onSelectedTabChange,
|
||||
required bool onTabListChange,
|
||||
required bool onRestoreComplete,
|
||||
required bool onTabContentStateChange,
|
||||
required bool onIconChange,
|
||||
required bool onSecurityInfoStateChange,
|
||||
@@ -1997,6 +1998,10 @@ abstract class GeckoStateEvents {
|
||||
void onTabListChange(int sequence, List<String> tabIds);
|
||||
void onSelectedTabChange(int sequence, String? id);
|
||||
|
||||
/// Mirrors `BrowserState.restoreComplete`: false until the native session
|
||||
/// restore has dispatched all persisted tabs into the store.
|
||||
void onRestoreCompleteChange(int sequence, bool restoreComplete);
|
||||
|
||||
void onTabContentStateChange(int sequence, TabContentState state);
|
||||
void onHistoryStateChange(int sequence, String id, HistoryState state);
|
||||
void onReaderableStateChange(int sequence, String id, ReaderableState state);
|
||||
|
||||
Reference in New Issue
Block a user