customize quick tab switcher buttons

This commit is contained in:
Fabian Freund
2026-07-06 17:48:58 +02:00
parent 10052ed3a3
commit 1b1c33dee5
30 changed files with 4763 additions and 133 deletions
@@ -41,7 +41,7 @@ final class TabRepositoryProvider
}
}
String _$tabRepositoryHash() => r'dd2f9c776be1d5437141e94dbd0b7ef382093b63';
String _$tabRepositoryHash() => r'749b131347685933ac36a3afcdd49f7def7155a9';
abstract class _$TabRepository extends $Notifier<void> {
void build();
@@ -21,14 +21,17 @@
import 'package:fast_equatable/fast_equatable.dart';
import 'package:lexo_rank/lexo_rank.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/data/repositories/contextual_toolbar_config_repository.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/data/repositories/toolbar_button_config_repository.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_spec.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_config_location.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
show ToolbarButtonConfig;
part 'toolbar_button_configs.g.dart';
List<ToolbarButtonConfig> _buildDefaultToolbarButtonConfigs() {
List<ToolbarButtonConfig> _buildDefaultToolbarButtonConfigs({
required bool allHidden,
}) {
String? lastKey;
return toolbarButtonSpecs.map((spec) {
@@ -41,19 +44,38 @@ List<ToolbarButtonConfig> _buildDefaultToolbarButtonConfigs() {
return ToolbarButtonConfig(
buttonId: spec.id.name,
orderKey: key,
isVisible: spec.defaultVisible,
fallbackId: spec.defaultFallback?.name,
isVisible: allHidden ? false : spec.defaultVisible,
fallbackId: allHidden ? null : spec.defaultFallback?.name,
);
}).toList();
}
final defaultToolbarButtonConfigs = EquatableValue(
_buildDefaultToolbarButtonConfigs(),
final _contextualDefaultConfigs = EquatableValue(
_buildDefaultToolbarButtonConfigs(allHidden: false),
);
final _quickSwitcherDefaultConfigs = EquatableValue(
_buildDefaultToolbarButtonConfigs(allHidden: true),
);
/// Default configuration set for [location], used as the reset target and as the
/// fallback while the persisted set is still loading. The quick switcher starts
/// with every button hidden.
EquatableValue<List<ToolbarButtonConfig>> defaultToolbarButtonConfigsFor(
ToolbarConfigLocation location,
) {
return switch (location) {
ToolbarConfigLocation.contextual => _contextualDefaultConfigs,
ToolbarConfigLocation.quickSwitcher => _quickSwitcherDefaultConfigs,
};
}
@Riverpod(keepAlive: true)
Stream<List<ToolbarButtonConfig>> toolbarButtonConfigs(Ref ref) async* {
final repository = ref.watch(contextualToolbarConfigRepositoryProvider);
Stream<List<ToolbarButtonConfig>> toolbarButtonConfigs(
Ref ref,
ToolbarConfigLocation location,
) async* {
final repository = ref.watch(toolbarConfigRepositoryProvider(location));
await repository.seedMissingDefaults();
yield* repository.watchAll();
}
@@ -61,8 +83,9 @@ Stream<List<ToolbarButtonConfig>> toolbarButtonConfigs(Ref ref) async* {
@Riverpod(keepAlive: true)
EquatableValue<List<ToolbarButtonConfig>> effectiveToolbarButtonConfigs(
Ref ref,
ToolbarConfigLocation location,
) {
final configsAsync = ref.watch(toolbarButtonConfigsProvider);
final configsAsync = ref.watch(toolbarButtonConfigsProvider(location));
return configsAsync.when(
data: (configs) {
@@ -71,12 +94,12 @@ EquatableValue<List<ToolbarButtonConfig>> effectiveToolbarButtonConfigs(
.toList();
if (filtered.isEmpty) {
return defaultToolbarButtonConfigs;
return defaultToolbarButtonConfigsFor(location);
}
return EquatableValue(filtered);
},
loading: () => defaultToolbarButtonConfigs,
error: (_, _) => defaultToolbarButtonConfigs,
loading: () => defaultToolbarButtonConfigsFor(location),
error: (_, _) => defaultToolbarButtonConfigsFor(location),
);
}
@@ -10,7 +10,7 @@ part of 'toolbar_button_configs.dart';
// ignore_for_file: type=lint, type=warning
@ProviderFor(toolbarButtonConfigs)
final toolbarButtonConfigsProvider = ToolbarButtonConfigsProvider._();
final toolbarButtonConfigsProvider = ToolbarButtonConfigsFamily._();
final class ToolbarButtonConfigsProvider
extends
@@ -22,20 +22,27 @@ final class ToolbarButtonConfigsProvider
with
$FutureModifier<List<ToolbarButtonConfig>>,
$StreamProvider<List<ToolbarButtonConfig>> {
ToolbarButtonConfigsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'toolbarButtonConfigsProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
ToolbarButtonConfigsProvider._({
required ToolbarButtonConfigsFamily super.from,
required ToolbarConfigLocation super.argument,
}) : super(
retry: null,
name: r'toolbarButtonConfigsProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$toolbarButtonConfigsHash();
@override
String toString() {
return r'toolbarButtonConfigsProvider'
''
'($argument)';
}
@$internal
@override
$StreamProviderElement<List<ToolbarButtonConfig>> $createElement(
@@ -44,16 +51,49 @@ final class ToolbarButtonConfigsProvider
@override
Stream<List<ToolbarButtonConfig>> create(Ref ref) {
return toolbarButtonConfigs(ref);
final argument = this.argument as ToolbarConfigLocation;
return toolbarButtonConfigs(ref, argument);
}
@override
bool operator ==(Object other) {
return other is ToolbarButtonConfigsProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$toolbarButtonConfigsHash() =>
r'02f79c2087a3a5413fe879405c05f4a4d1eaffeb';
r'913d2c8ca3b8b3f39f2247cafac068dc26abc566';
final class ToolbarButtonConfigsFamily extends $Family
with
$FunctionalFamilyOverride<
Stream<List<ToolbarButtonConfig>>,
ToolbarConfigLocation
> {
ToolbarButtonConfigsFamily._()
: super(
retry: null,
name: r'toolbarButtonConfigsProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: false,
);
ToolbarButtonConfigsProvider call(ToolbarConfigLocation location) =>
ToolbarButtonConfigsProvider._(argument: location, from: this);
@override
String toString() => r'toolbarButtonConfigsProvider';
}
@ProviderFor(effectiveToolbarButtonConfigs)
final effectiveToolbarButtonConfigsProvider =
EffectiveToolbarButtonConfigsProvider._();
EffectiveToolbarButtonConfigsFamily._();
final class EffectiveToolbarButtonConfigsProvider
extends
@@ -63,20 +103,27 @@ final class EffectiveToolbarButtonConfigsProvider
EquatableValue<List<ToolbarButtonConfig>>
>
with $Provider<EquatableValue<List<ToolbarButtonConfig>>> {
EffectiveToolbarButtonConfigsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'effectiveToolbarButtonConfigsProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
EffectiveToolbarButtonConfigsProvider._({
required EffectiveToolbarButtonConfigsFamily super.from,
required ToolbarConfigLocation super.argument,
}) : super(
retry: null,
name: r'effectiveToolbarButtonConfigsProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$effectiveToolbarButtonConfigsHash();
@override
String toString() {
return r'effectiveToolbarButtonConfigsProvider'
''
'($argument)';
}
@$internal
@override
$ProviderElement<EquatableValue<List<ToolbarButtonConfig>>> $createElement(
@@ -85,7 +132,8 @@ final class EffectiveToolbarButtonConfigsProvider
@override
EquatableValue<List<ToolbarButtonConfig>> create(Ref ref) {
return effectiveToolbarButtonConfigs(ref);
final argument = this.argument as ToolbarConfigLocation;
return effectiveToolbarButtonConfigs(ref, argument);
}
/// {@macro riverpod.override_with_value}
@@ -96,7 +144,40 @@ final class EffectiveToolbarButtonConfigsProvider
$SyncValueProvider<EquatableValue<List<ToolbarButtonConfig>>>(value),
);
}
@override
bool operator ==(Object other) {
return other is EffectiveToolbarButtonConfigsProvider &&
other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$effectiveToolbarButtonConfigsHash() =>
r'dd876160f586c64237a42a29eb63cb119f962b02';
r'6cd77c8af6df8943db6778b5222bb6ae9fb40025';
final class EffectiveToolbarButtonConfigsFamily extends $Family
with
$FunctionalFamilyOverride<
EquatableValue<List<ToolbarButtonConfig>>,
ToolbarConfigLocation
> {
EffectiveToolbarButtonConfigsFamily._()
: super(
retry: null,
name: r'effectiveToolbarButtonConfigsProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: false,
);
EffectiveToolbarButtonConfigsProvider call(ToolbarConfigLocation location) =>
EffectiveToolbarButtonConfigsProvider._(argument: location, from: this);
@override
String toString() => r'effectiveToolbarButtonConfigsProvider';
}
@@ -19,6 +19,7 @@
*/
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/data/repositories/toolbar_button_config_repository.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_spec.dart';
import 'package:weblibre/features/user/data/database/daos/toolbar_button_config.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
@@ -27,37 +28,46 @@ import 'package:weblibre/features/user/data/providers.dart';
part 'contextual_toolbar_config_repository.g.dart';
class ContextualToolbarConfigRepository {
class ContextualToolbarConfigRepository
implements ToolbarButtonConfigRepository {
ContextualToolbarConfigRepository(this._dao);
final ToolbarButtonConfigDao _dao;
@override
Stream<List<ToolbarButtonConfig>> watchAll() => _dao.watchAll();
@override
Future<void> replaceAll(List<ToolbarButtonConfig> configs) {
return _dao.replaceAll(configs);
}
@override
Future<void> assignOrderKey(String buttonId, {required String orderKey}) {
return _dao.assignOrderKey(buttonId, orderKey: orderKey);
}
@override
Future<void> assignVisibility(String buttonId, {required bool visible}) {
return _dao.assignVisibility(buttonId, visible: visible);
}
@override
Future<void> assignFallback(String buttonId, String? fallbackId) {
return _dao.assignFallback(buttonId, fallbackId);
}
@override
Future<String> generateLeadingOrderKey({required bool isVisible}) {
return _dao.generateLeadingOrderKey(isVisible: isVisible).getSingle();
}
@override
Future<String> generateTrailingOrderKey({required bool isVisible}) {
return _dao.generateTrailingOrderKey(isVisible: isVisible).getSingle();
}
@override
Future<String?> generateOrderKeyAfterButtonId(
String buttonId, {
required bool isVisible,
@@ -67,6 +77,7 @@ class ContextualToolbarConfigRepository {
.getSingleOrNull();
}
@override
Future<String> generateOrderKeyBeforeButtonId(
String buttonId, {
required bool isVisible,
@@ -76,6 +87,7 @@ class ContextualToolbarConfigRepository {
.getSingle();
}
@override
Future<void> seedMissingDefaults() {
return _dao.seedMissing(
toolbarButtonSpecs
@@ -0,0 +1,135 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/data/repositories/toolbar_button_config_repository.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_spec.dart';
import 'package:weblibre/features/user/data/database/daos/quick_switcher_button_config.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
show QuickSwitcherButtonConfig, ToolbarButtonConfig;
import 'package:weblibre/features/user/data/providers.dart';
part 'quick_switcher_toolbar_config_repository.g.dart';
/// Quick tab switcher counterpart to [ContextualToolbarConfigRepository]. Backed
/// by the separate `quick_switcher_button_configs` table and mapping its row
/// type to the shared [ToolbarButtonConfig] transport type.
class QuickSwitcherToolbarConfigRepository
implements ToolbarButtonConfigRepository {
QuickSwitcherToolbarConfigRepository(this._dao);
final QuickSwitcherButtonConfigDao _dao;
ToolbarButtonConfig _toShared(QuickSwitcherButtonConfig config) =>
ToolbarButtonConfig(
buttonId: config.buttonId,
orderKey: config.orderKey,
isVisible: config.isVisible,
fallbackId: config.fallbackId,
);
QuickSwitcherButtonConfig _fromShared(ToolbarButtonConfig config) =>
QuickSwitcherButtonConfig(
buttonId: config.buttonId,
orderKey: config.orderKey,
isVisible: config.isVisible,
fallbackId: config.fallbackId,
);
@override
Stream<List<ToolbarButtonConfig>> watchAll() =>
_dao.watchAll().map((rows) => rows.map(_toShared).toList());
@override
Future<void> replaceAll(List<ToolbarButtonConfig> configs) {
return _dao.replaceAll(configs.map(_fromShared).toList());
}
@override
Future<void> assignOrderKey(String buttonId, {required String orderKey}) {
return _dao.assignOrderKey(buttonId, orderKey: orderKey);
}
@override
Future<void> assignVisibility(String buttonId, {required bool visible}) {
return _dao.assignVisibility(buttonId, visible: visible);
}
@override
Future<void> assignFallback(String buttonId, String? fallbackId) {
return _dao.assignFallback(buttonId, fallbackId);
}
@override
Future<String> generateLeadingOrderKey({required bool isVisible}) {
return _dao.generateLeadingOrderKey(isVisible: isVisible).getSingle();
}
@override
Future<String> generateTrailingOrderKey({required bool isVisible}) {
return _dao.generateTrailingOrderKey(isVisible: isVisible).getSingle();
}
@override
Future<String?> generateOrderKeyAfterButtonId(
String buttonId, {
required bool isVisible,
}) {
return _dao
.generateOrderKeyAfterButtonId(buttonId, isVisible: isVisible)
.getSingleOrNull();
}
@override
Future<String> generateOrderKeyBeforeButtonId(
String buttonId, {
required bool isVisible,
}) {
return _dao
.generateOrderKeyBeforeButtonId(buttonId, isVisible: isVisible)
.getSingle();
}
@override
Future<void> seedMissingDefaults() {
// The quick switcher cluster starts empty: every known button is seeded
// hidden with no fallback, so nothing renders until the user enables it.
return _dao.seedMissing(
toolbarButtonSpecs
.map(
(spec) => (
buttonId: spec.id.name,
defaultVisible: false,
defaultFallback: null,
),
)
.toList(),
);
}
}
@Riverpod(keepAlive: true)
QuickSwitcherToolbarConfigRepository quickSwitcherToolbarConfigRepository(
Ref ref,
) {
return QuickSwitcherToolbarConfigRepository(
ref.watch(userDatabaseProvider).quickSwitcherButtonConfigDao,
);
}
@@ -0,0 +1,61 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'quick_switcher_toolbar_config_repository.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(quickSwitcherToolbarConfigRepository)
final quickSwitcherToolbarConfigRepositoryProvider =
QuickSwitcherToolbarConfigRepositoryProvider._();
final class QuickSwitcherToolbarConfigRepositoryProvider
extends
$FunctionalProvider<
QuickSwitcherToolbarConfigRepository,
QuickSwitcherToolbarConfigRepository,
QuickSwitcherToolbarConfigRepository
>
with $Provider<QuickSwitcherToolbarConfigRepository> {
QuickSwitcherToolbarConfigRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'quickSwitcherToolbarConfigRepositoryProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() =>
_$quickSwitcherToolbarConfigRepositoryHash();
@$internal
@override
$ProviderElement<QuickSwitcherToolbarConfigRepository> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
QuickSwitcherToolbarConfigRepository create(Ref ref) {
return quickSwitcherToolbarConfigRepository(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(QuickSwitcherToolbarConfigRepository value) {
return $ProviderOverride(
origin: this,
providerOverride:
$SyncValueProvider<QuickSwitcherToolbarConfigRepository>(value),
);
}
}
String _$quickSwitcherToolbarConfigRepositoryHash() =>
r'5459350ebde214186e882d8316e66d438c6913d8';
@@ -0,0 +1,77 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/data/repositories/contextual_toolbar_config_repository.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/data/repositories/quick_switcher_toolbar_config_repository.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_config_location.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
show ToolbarButtonConfig;
part 'toolbar_button_config_repository.g.dart';
/// Shared contract for a single toolbar button configuration set. Both the
/// contextual toolbar and the quick tab switcher cluster persist to their own
/// table but expose this same interface, so the resolution logic, providers and
/// customization UI are location-agnostic. [ToolbarButtonConfig] (the contextual
/// drift row) is used as the shared transport type; the quick switcher
/// repository maps its own row type at the boundary.
abstract interface class ToolbarButtonConfigRepository {
Stream<List<ToolbarButtonConfig>> watchAll();
Future<void> replaceAll(List<ToolbarButtonConfig> configs);
Future<void> assignOrderKey(String buttonId, {required String orderKey});
Future<void> assignVisibility(String buttonId, {required bool visible});
Future<void> assignFallback(String buttonId, String? fallbackId);
Future<String> generateLeadingOrderKey({required bool isVisible});
Future<String> generateTrailingOrderKey({required bool isVisible});
Future<String?> generateOrderKeyAfterButtonId(
String buttonId, {
required bool isVisible,
});
Future<String> generateOrderKeyBeforeButtonId(
String buttonId, {
required bool isVisible,
});
Future<void> seedMissingDefaults();
}
@Riverpod(keepAlive: true)
ToolbarButtonConfigRepository toolbarConfigRepository(
Ref ref,
ToolbarConfigLocation location,
) {
return switch (location) {
ToolbarConfigLocation.contextual => ref.watch(
contextualToolbarConfigRepositoryProvider,
),
ToolbarConfigLocation.quickSwitcher => ref.watch(
quickSwitcherToolbarConfigRepositoryProvider,
),
};
}
@@ -0,0 +1,101 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'toolbar_button_config_repository.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(toolbarConfigRepository)
final toolbarConfigRepositoryProvider = ToolbarConfigRepositoryFamily._();
final class ToolbarConfigRepositoryProvider
extends
$FunctionalProvider<
ToolbarButtonConfigRepository,
ToolbarButtonConfigRepository,
ToolbarButtonConfigRepository
>
with $Provider<ToolbarButtonConfigRepository> {
ToolbarConfigRepositoryProvider._({
required ToolbarConfigRepositoryFamily super.from,
required ToolbarConfigLocation super.argument,
}) : super(
retry: null,
name: r'toolbarConfigRepositoryProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$toolbarConfigRepositoryHash();
@override
String toString() {
return r'toolbarConfigRepositoryProvider'
''
'($argument)';
}
@$internal
@override
$ProviderElement<ToolbarButtonConfigRepository> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
ToolbarButtonConfigRepository create(Ref ref) {
final argument = this.argument as ToolbarConfigLocation;
return toolbarConfigRepository(ref, argument);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ToolbarButtonConfigRepository value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ToolbarButtonConfigRepository>(
value,
),
);
}
@override
bool operator ==(Object other) {
return other is ToolbarConfigRepositoryProvider &&
other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$toolbarConfigRepositoryHash() =>
r'47129b30f01db6ea73d553a8fd1b4f6e8a7b4a3c';
final class ToolbarConfigRepositoryFamily extends $Family
with
$FunctionalFamilyOverride<
ToolbarButtonConfigRepository,
ToolbarConfigLocation
> {
ToolbarConfigRepositoryFamily._()
: super(
retry: null,
name: r'toolbarConfigRepositoryProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: false,
);
ToolbarConfigRepositoryProvider call(ToolbarConfigLocation location) =>
ToolbarConfigRepositoryProvider._(argument: location, from: this);
@override
String toString() => r'toolbarConfigRepositoryProvider';
}
@@ -0,0 +1,31 @@
/*
* 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/>.
*/
/// Identifies which independently-configured toolbar a button configuration
/// belongs to. Each location persists its own enabled set, ordering and
/// fallbacks (backed by a separate drift table) while sharing the button
/// registry, resolution logic and customization UI.
enum ToolbarConfigLocation {
/// The contextual toolbar row that sits below the URL bar.
contextual,
/// The trailing fixed button cluster on the quick tab switcher bar.
quickSwitcher,
}
@@ -20,6 +20,7 @@
import 'package:fast_equatable/fast_equatable.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_config_location.dart';
class ContextualToolbarScope with FastEquatable {
final String? selectedTabId;
@@ -27,11 +28,17 @@ class ContextualToolbarScope with FastEquatable {
final TabState? tabState;
final bool isPreview;
/// Which configuration set this scope renders for, so shared registry
/// builders that read sibling button config (e.g. the back button's
/// stop-loading fallback) consult the correct location.
final ToolbarConfigLocation location;
ContextualToolbarScope({
required this.selectedTabId,
required this.displayedSheet,
required this.tabState,
required this.isPreview,
this.location = ToolbarConfigLocation.contextual,
});
@override
@@ -40,5 +47,6 @@ class ContextualToolbarScope with FastEquatable {
displayedSheet,
tabState,
isPreview,
location,
];
}
@@ -37,6 +37,7 @@ import 'package:weblibre/features/geckoview/features/browser/domain/entities/fon
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_id.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_spec.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_config_location.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/models/contextual_toolbar_scope.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_bar_buttons.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/utils/tab_close_confirmation.dart';
@@ -85,12 +86,12 @@ class ToolbarButtonDefinition {
});
}
/// Whether a reload button is currently configured visible in the contextual
/// Whether a reload button is currently configured visible in [location]'s
/// toolbar. Used to decide whether the back button should fall back to acting
/// as a stop-loading control (see issue #351).
bool _isReloadButtonVisible(WidgetRef ref) {
bool _isReloadButtonVisible(WidgetRef ref, ToolbarConfigLocation location) {
return ref
.read(effectiveToolbarButtonConfigsProvider)
.read(effectiveToolbarButtonConfigsProvider(location))
.value
.any(
(config) =>
@@ -108,7 +109,8 @@ final List<ToolbarButtonDefinition> toolbarButtonRegistry = [
final isLoading = scope.tabState?.isLoading == true;
// The back button only doubles as a stop-loading control when no
// dedicated reload button is present to take over that role.
return canGoBack || (isLoading && !_isReloadButtonVisible(ref));
return canGoBack ||
(isLoading && !_isReloadButtonVisible(ref, scope.location));
},
longPressActions: ['History Menu (Previous pages)'],
builder: (scope, context, ref) {
@@ -123,7 +125,7 @@ final List<ToolbarButtonDefinition> toolbarButtonRegistry = [
return NavigateBackButton(
selectedTabId: scope.selectedTabId,
isLoading: scope.tabState?.isLoading ?? false,
stopLoadingFallback: !_isReloadButtonVisible(ref),
stopLoadingFallback: !_isReloadButtonVisible(ref, scope.location),
);
},
),
@@ -25,6 +25,7 @@ import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_spec.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_config_location.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/services/toolbar_button_resolution.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/models/contextual_toolbar_scope.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart';
@@ -47,7 +48,9 @@ class ContextualToolbar extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabState = ref.watch(tabStateProvider(selectedTabId));
final configs = ref.watch(effectiveToolbarButtonConfigsProvider);
final configs = ref.watch(
effectiveToolbarButtonConfigsProvider(ToolbarConfigLocation.contextual),
);
final scope = ContextualToolbarScope(
selectedTabId: selectedTabId,
@@ -143,10 +146,7 @@ class ContextualToolbarView extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
for (final button in buttons)
UnconstrainedBox(
constrainedAxis: Axis.vertical,
child: button,
),
UnconstrainedBox(constrainedAxis: Axis.vertical, child: button),
],
);
},
@@ -0,0 +1,143 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_spec.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_config_location.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/services/toolbar_button_resolution.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/models/contextual_toolbar_scope.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart';
/// The switcher bar's cross-axis extent (height when horizontal, width on the
/// side rail). Mirrors `BrowserTabBar.quickTabSwitcherHeight`; kept as a local
/// const to avoid a circular import with the bar widget that hosts this row.
const double _switcherExtent = 48.0;
/// Trailing fixed button cluster for the quick tab switcher bar. Reuses the
/// contextual toolbar's button registry and resolution logic against the
/// independently-persisted [ToolbarConfigLocation.quickSwitcher] configuration.
///
/// Pins to the trailing end of the bar while the tab chips keep the remaining
/// scrollable space. Each button is scaled to fit the bar's cross-axis extent
/// (reused [ToolbarButton]s are taller than the 48px bar), and the cluster
/// scrolls along the bar axis so enabling many buttons can never overflow — the
/// hosting bar additionally caps how much room the cluster may claim. Renders
/// nothing when no buttons are enabled.
class QuickSwitcherButtonRow extends HookConsumerWidget {
const QuickSwitcherButtonRow({
super.key,
required this.selectedTabId,
required this.displayedSheet,
this.axis = Axis.horizontal,
});
final String? selectedTabId;
final Sheet? displayedSheet;
/// Layout direction of the switcher bar; vertical for the side rail.
final Axis axis;
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabState = ref.watch(tabStateProvider(selectedTabId));
final configs = ref.watch(
effectiveToolbarButtonConfigsProvider(
ToolbarConfigLocation.quickSwitcher,
),
);
final scope = ContextualToolbarScope(
selectedTabId: selectedTabId,
displayedSheet: displayedSheet,
tabState: tabState,
isPreview: false,
location: ToolbarConfigLocation.quickSwitcher,
);
final resolvedButtons = useMemoized(
() => resolveVisibleContextualToolbarButtons(
configs: configs.value,
knownButtonIds: knownToolbarButtonIds,
isPrimaryAvailable: (buttonId) {
final def = toolbarButtonRegistryById[buttonId];
return def?.isPrimaryAvailable?.call(scope, ref) ?? true;
},
),
[configs, scope],
);
if (resolvedButtons.isEmpty) {
return const SizedBox.shrink();
}
final isVertical = axis == Axis.vertical;
final buttons = resolvedButtons
.map((button) => _buildButton(scope, context, ref, button))
.toList();
// Bound each button to the bar's cross-axis extent and scale down (never
// clip) so a taller [ToolbarButton] can't overflow the switcher bar.
final fittedButtons = [
for (final button in buttons)
SizedBox(
height: isVertical ? null : _switcherExtent,
width: isVertical ? _switcherExtent : null,
child: FittedBox(fit: BoxFit.scaleDown, child: button),
),
];
// Scroll along the bar axis: the host caps the cluster's main-axis extent,
// and anything beyond that scrolls instead of stealing the chips' space.
return SizedBox(
height: isVertical ? null : _switcherExtent,
width: isVertical ? _switcherExtent : null,
child: SingleChildScrollView(
scrollDirection: axis,
child: isVertical
? Column(mainAxisSize: MainAxisSize.min, children: fittedButtons)
: Row(mainAxisSize: MainAxisSize.min, children: fittedButtons),
),
);
}
Widget _buildButton(
ContextualToolbarScope scope,
BuildContext context,
WidgetRef ref,
ContextualToolbarButtonResolution button,
) {
final def = toolbarButtonRegistryById[button.buttonId];
if (def == null) return const SizedBox.shrink();
final child = def.builder(scope, context, ref);
if (button.isEnabled) {
return child;
}
return Opacity(opacity: 0.38, child: IgnorePointer(child: child));
}
}
@@ -36,8 +36,10 @@ import 'package:weblibre/features/geckoview/features/browser/domain/entities/she
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_id.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_config_location.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_bar_buttons.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_toolbar.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/quick_switcher_button_row.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';
@@ -188,7 +190,8 @@ class BrowserSideRail extends ConsumerWidget {
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
isSmallWebMode: isSmallWebMode,
enableGestures: true,
hideMainToolbarButtonsDuplicatedInContextualToolbar: showContextualToolbar,
hideMainToolbarButtonsDuplicatedInContextualToolbar:
showContextualToolbar,
);
}
@@ -303,7 +306,11 @@ class BrowserTabBar extends HookConsumerWidget {
// Determine which buttons are actually visible in the contextual toolbar
// so we only hide them from the main toolbar when they're genuinely present there.
final contextualConfigs = ref
.watch(effectiveToolbarButtonConfigsProvider)
.watch(
effectiveToolbarButtonConfigsProvider(
ToolbarConfigLocation.contextual,
),
)
.value;
final tabsCountInContextual =
@@ -449,55 +456,69 @@ class BrowserTabBar extends HookConsumerWidget {
if (showMainToolbarNavigationButton)
NavigationMenuButton(selectedTabId: selectedTabId),
],
quickTabSwitcher: switch (stackingMode) {
TabBarStackingMode.disabled => const SizedBox.shrink(),
TabBarStackingMode.lastUsedTabs => QuickTabSwitcher(
quickTabSwitcherMode: QuickTabSwitcherMode.lastUsedTabs,
axis: switcherAxis,
),
TabBarStackingMode.containerTabs => QuickTabSwitcher(
quickTabSwitcherMode: QuickTabSwitcherMode.containerTabs,
axis: switcherAxis,
),
TabBarStackingMode.accordion => AccordionQuickTabSwitcher(
axis: switcherAxis,
),
// History fallback only on the MRU row, so empty-state history
// chips don't show twice. On the rail the two rows stack as two
// equal-height vertical lists.
TabBarStackingMode.twoLevel =>
isVertical
? Column(
children: [
Expanded(
child: QuickTabSwitcher(
quickTabSwitcher: _wrapQuickTabSwitcherWithButtonRow(
axis: switcherAxis,
// The button row lives on the switcher bar; when stacking is disabled
// the whole bar is hidden anyway, but skip building it for clarity.
buttonRow: stackingMode == TabBarStackingMode.disabled
? null
: QuickSwitcherButtonRow(
selectedTabId: selectedTabId,
displayedSheet: displayedSheet,
axis: switcherAxis,
),
child: switch (stackingMode) {
TabBarStackingMode.disabled => const SizedBox.shrink(),
TabBarStackingMode.lastUsedTabs => QuickTabSwitcher(
quickTabSwitcherMode: QuickTabSwitcherMode.lastUsedTabs,
axis: switcherAxis,
),
TabBarStackingMode.containerTabs => QuickTabSwitcher(
quickTabSwitcherMode: QuickTabSwitcherMode.containerTabs,
axis: switcherAxis,
),
TabBarStackingMode.accordion => AccordionQuickTabSwitcher(
axis: switcherAxis,
),
// History fallback only on the MRU row, so empty-state history
// chips don't show twice. On the rail the two rows stack as two
// equal-height vertical lists.
TabBarStackingMode.twoLevel =>
isVertical
? Column(
children: [
Expanded(
child: QuickTabSwitcher(
quickTabSwitcherMode:
QuickTabSwitcherMode.containerTabs,
enableHistoryFallback: false,
axis: switcherAxis,
),
),
Expanded(
child: QuickTabSwitcher(
quickTabSwitcherMode:
QuickTabSwitcherMode.lastUsedTabs,
axis: switcherAxis,
),
),
],
)
: const Column(
mainAxisSize: MainAxisSize.min,
children: [
QuickTabSwitcher(
quickTabSwitcherMode:
QuickTabSwitcherMode.containerTabs,
enableHistoryFallback: false,
axis: switcherAxis,
),
),
Expanded(
child: QuickTabSwitcher(
QuickTabSwitcher(
quickTabSwitcherMode: QuickTabSwitcherMode.lastUsedTabs,
axis: switcherAxis,
),
),
],
)
: const Column(
mainAxisSize: MainAxisSize.min,
children: [
QuickTabSwitcher(
quickTabSwitcherMode: QuickTabSwitcherMode.containerTabs,
enableHistoryFallback: false,
),
QuickTabSwitcher(
quickTabSwitcherMode: QuickTabSwitcherMode.lastUsedTabs,
),
],
),
},
],
),
},
),
contextualToolbar: ContextualToolbar(
selectedTabId: selectedTabId,
displayedSheet: displayedSheet,
@@ -707,6 +728,58 @@ class BrowserTabBarView extends StatelessWidget {
}
}
/// Pins [buttonRow] to the trailing end of the quick tab switcher bar (right of
/// the horizontal bar / bottom of the side rail) while [child] (the scrollable
/// chips) fills the remaining space. The cluster is capped to a fraction of the
/// bar so it can never starve the chips; [QuickSwitcherButtonRow] scrolls any
/// overflow beyond that cap. [buttonRow] collapses to nothing when no buttons
/// are enabled, so the default state is unchanged.
Widget _wrapQuickTabSwitcherWithButtonRow({
required Axis axis,
required Widget? buttonRow,
required Widget child,
}) {
if (buttonRow == null) {
return child;
}
// Never let the button cluster take more than this share of the bar; the tab
// chips keep the rest.
const maxClusterFraction = 0.6;
return LayoutBuilder(
builder: (context, constraints) {
if (axis == Axis.vertical) {
final maxExtent = constraints.maxHeight.isFinite
? constraints.maxHeight * maxClusterFraction
: double.infinity;
return Column(
children: [
Expanded(child: child),
ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxExtent),
child: buttonRow,
),
],
);
}
final maxExtent = constraints.maxWidth.isFinite
? constraints.maxWidth * maxClusterFraction
: double.infinity;
return Row(
children: [
Expanded(child: child),
ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxExtent),
child: buttonRow,
),
],
);
},
);
}
class QuickTabSwitcher extends HookConsumerWidget {
final QuickTabSwitcherMode quickTabSwitcherMode;
@@ -1043,7 +1116,9 @@ class QuickTabSwitcherView extends StatelessWidget {
// collapse, since the toolbar height already accounts for both rows.
// On the rail the cross-axis width is fixed and the (vertical) list
// fills the available height.
return _isVertical ? const SizedBox(width: 48) : const SizedBox(height: 48);
return _isVertical
? const SizedBox(width: 48)
: const SizedBox(height: 48);
}
return Padding(
@@ -41,7 +41,7 @@ final class TabDataRepositoryProvider
}
}
String _$tabDataRepositoryHash() => r'5192e4bb2a373bdef4474fb781eaacc64cc35609';
String _$tabDataRepositoryHash() => r'cb923fb0723271cd64d66a720dc3e1aed2d86c71';
abstract class _$TabDataRepository extends $Notifier<void> {
void build();