added feature to configure contextual tab bar buttons

This commit is contained in:
Fabian Freund
2026-03-11 10:44:56 +01:00
parent 7121b1c236
commit 4f2145dfc2
34 changed files with 4331 additions and 313 deletions
@@ -0,0 +1,82 @@
/*
* 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: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/domain/entities/toolbar_button_spec.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
show ToolbarButtonConfig;
part 'toolbar_button_configs.g.dart';
List<ToolbarButtonConfig> _buildDefaultToolbarButtonConfigs() {
String? lastKey;
return toolbarButtonSpecs.map((spec) {
final key = lastKey == null
? LexoRank.middle().value
: LexoRank.parse(lastKey!).genNext().value;
lastKey = key;
return ToolbarButtonConfig(
buttonId: spec.id.name,
orderKey: key,
isVisible: spec.defaultVisible,
fallbackId: spec.defaultFallback?.name,
);
}).toList();
}
final defaultToolbarButtonConfigs = EquatableValue(
_buildDefaultToolbarButtonConfigs(),
);
@Riverpod(keepAlive: true)
Stream<List<ToolbarButtonConfig>> toolbarButtonConfigs(Ref ref) async* {
final repository = ref.watch(contextualToolbarConfigRepositoryProvider);
await repository.seedMissingDefaults();
yield* repository.watchAll();
}
@Riverpod(keepAlive: true)
EquatableValue<List<ToolbarButtonConfig>> effectiveToolbarButtonConfigs(
Ref ref,
) {
final configsAsync = ref.watch(toolbarButtonConfigsProvider);
return configsAsync.when(
data: (configs) {
final filtered = configs
.where((config) => knownToolbarButtonIds.contains(config.buttonId))
.toList();
if (filtered.isEmpty) {
return defaultToolbarButtonConfigs;
}
return EquatableValue(filtered);
},
loading: () => defaultToolbarButtonConfigs,
error: (_, _) => defaultToolbarButtonConfigs,
);
}
@@ -0,0 +1,102 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'toolbar_button_configs.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(toolbarButtonConfigs)
final toolbarButtonConfigsProvider = ToolbarButtonConfigsProvider._();
final class ToolbarButtonConfigsProvider
extends
$FunctionalProvider<
AsyncValue<List<ToolbarButtonConfig>>,
List<ToolbarButtonConfig>,
Stream<List<ToolbarButtonConfig>>
>
with
$FutureModifier<List<ToolbarButtonConfig>>,
$StreamProvider<List<ToolbarButtonConfig>> {
ToolbarButtonConfigsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'toolbarButtonConfigsProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$toolbarButtonConfigsHash();
@$internal
@override
$StreamProviderElement<List<ToolbarButtonConfig>> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<List<ToolbarButtonConfig>> create(Ref ref) {
return toolbarButtonConfigs(ref);
}
}
String _$toolbarButtonConfigsHash() =>
r'02f79c2087a3a5413fe879405c05f4a4d1eaffeb';
@ProviderFor(effectiveToolbarButtonConfigs)
final effectiveToolbarButtonConfigsProvider =
EffectiveToolbarButtonConfigsProvider._();
final class EffectiveToolbarButtonConfigsProvider
extends
$FunctionalProvider<
EquatableValue<List<ToolbarButtonConfig>>,
EquatableValue<List<ToolbarButtonConfig>>,
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,
);
@override
String debugGetCreateSourceHash() => _$effectiveToolbarButtonConfigsHash();
@$internal
@override
$ProviderElement<EquatableValue<List<ToolbarButtonConfig>>> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
EquatableValue<List<ToolbarButtonConfig>> create(Ref ref) {
return effectiveToolbarButtonConfigs(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(EquatableValue<List<ToolbarButtonConfig>> value) {
return $ProviderOverride(
origin: this,
providerOverride:
$SyncValueProvider<EquatableValue<List<ToolbarButtonConfig>>>(value),
);
}
}
String _$effectiveToolbarButtonConfigsHash() =>
r'dd876160f586c64237a42a29eb63cb119f962b02';
@@ -0,0 +1,89 @@
/*
* 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/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'
show ToolbarButtonConfig;
import 'package:weblibre/features/user/data/providers.dart';
part 'contextual_toolbar_config_repository.g.dart';
class ContextualToolbarConfigRepository {
ContextualToolbarConfigRepository(this._dao);
final ToolbarButtonConfigDao _dao;
Stream<List<ToolbarButtonConfig>> watchAll() => _dao.watchAll();
Future<void> replaceAll(List<ToolbarButtonConfig> configs) {
return _dao.replaceAll(configs);
}
Future<void> assignOrderKey(String buttonId, {required String orderKey}) {
return _dao.assignOrderKey(buttonId, orderKey: orderKey);
}
Future<void> assignVisibility(String buttonId, {required bool visible}) {
return _dao.assignVisibility(buttonId, visible: visible);
}
Future<void> assignFallback(String buttonId, String? fallbackId) {
return _dao.assignFallback(buttonId, fallbackId);
}
Future<String> generateLeadingOrderKey() {
return _dao.generateLeadingOrderKey().getSingle();
}
Future<String> generateTrailingOrderKey() {
return _dao.generateTrailingOrderKey().getSingle();
}
Future<String?> generateOrderKeyAfterButtonId(String buttonId) {
return _dao.generateOrderKeyAfterButtonId(buttonId).getSingleOrNull();
}
Future<String> generateOrderKeyBeforeButtonId(String buttonId) {
return _dao.generateOrderKeyBeforeButtonId(buttonId).getSingle();
}
Future<void> seedMissingDefaults() {
return _dao.seedMissing(
toolbarButtonSpecs
.map(
(spec) => (
buttonId: spec.id.name,
defaultVisible: spec.defaultVisible,
defaultFallback: spec.defaultFallback?.name,
),
)
.toList(),
);
}
}
@Riverpod(keepAlive: true)
ContextualToolbarConfigRepository contextualToolbarConfigRepository(Ref ref) {
return ContextualToolbarConfigRepository(
ref.watch(userDatabaseProvider).toolbarButtonConfigDao,
);
}
@@ -0,0 +1,62 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'contextual_toolbar_config_repository.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(contextualToolbarConfigRepository)
final contextualToolbarConfigRepositoryProvider =
ContextualToolbarConfigRepositoryProvider._();
final class ContextualToolbarConfigRepositoryProvider
extends
$FunctionalProvider<
ContextualToolbarConfigRepository,
ContextualToolbarConfigRepository,
ContextualToolbarConfigRepository
>
with $Provider<ContextualToolbarConfigRepository> {
ContextualToolbarConfigRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'contextualToolbarConfigRepositoryProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() =>
_$contextualToolbarConfigRepositoryHash();
@$internal
@override
$ProviderElement<ContextualToolbarConfigRepository> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
ContextualToolbarConfigRepository create(Ref ref) {
return contextualToolbarConfigRepository(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ContextualToolbarConfigRepository value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ContextualToolbarConfigRepository>(
value,
),
);
}
}
String _$contextualToolbarConfigRepositoryHash() =>
r'96bb0c00019e076ef65d3e901ba850de0fccbb1b';
@@ -0,0 +1,29 @@
/*
* 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/>.
*/
enum ToolbarButtonId {
back,
forward,
bookmarks,
share,
addTab,
tabsCount,
navigationMenu,
}
@@ -0,0 +1,88 @@
/*
* 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:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_id.dart';
class ToolbarButtonSpec {
final ToolbarButtonId id;
final bool defaultVisible;
final ToolbarButtonId? defaultFallback;
final bool canBeFallbackTarget;
const ToolbarButtonSpec({
required this.id,
required this.defaultVisible,
this.defaultFallback,
this.canBeFallbackTarget = true,
});
}
const backToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.back,
defaultVisible: true,
defaultFallback: ToolbarButtonId.bookmarks,
);
const forwardToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.forward,
defaultVisible: true,
defaultFallback: ToolbarButtonId.share,
);
const bookmarksToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.bookmarks,
defaultVisible: false,
);
const shareToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.share,
defaultVisible: false,
);
const addTabToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.addTab,
defaultVisible: true,
);
const tabsCountToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.tabsCount,
defaultVisible: true,
);
const navigationMenuToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.navigationMenu,
defaultVisible: true,
);
const toolbarButtonSpecs = [
backToolbarButtonSpec,
forwardToolbarButtonSpec,
bookmarksToolbarButtonSpec,
shareToolbarButtonSpec,
addTabToolbarButtonSpec,
tabsCountToolbarButtonSpec,
navigationMenuToolbarButtonSpec,
];
final Map<String, ToolbarButtonSpec> toolbarButtonSpecsById = {
for (final spec in toolbarButtonSpecs) spec.id.name: spec,
};
final Set<String> knownToolbarButtonIds = toolbarButtonSpecsById.keys.toSet();
@@ -0,0 +1,57 @@
/*
* 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:fast_equatable/fast_equatable.dart';
sealed class ToolbarFallbackChoice with FastEquatable {
ToolbarFallbackChoice();
factory ToolbarFallbackChoice.fromStored(String? storedFallbackId) {
if (storedFallbackId != null) {
return ToolbarFallbackButton(buttonId: storedFallbackId);
}
return ToolbarFallbackNone();
}
String? resolveRuntimeFallbackId();
String? toStoredFallbackId() => resolveRuntimeFallbackId();
}
class ToolbarFallbackButton extends ToolbarFallbackChoice {
final String buttonId;
ToolbarFallbackButton({required this.buttonId});
@override
List<Object?> get hashParameters => [buttonId];
@override
String resolveRuntimeFallbackId() => buttonId;
}
class ToolbarFallbackNone extends ToolbarFallbackChoice {
ToolbarFallbackNone();
@override
List<Object?> get hashParameters => [null];
@override
String? resolveRuntimeFallbackId() => null;
}
@@ -0,0 +1,116 @@
/*
* 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:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_fallback_choice.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart';
class ContextualToolbarButtonResolution {
const ContextualToolbarButtonResolution({
required this.buttonId,
required this.isEnabled,
});
final String buttonId;
final bool isEnabled;
}
List<ContextualToolbarButtonResolution> resolveVisibleContextualToolbarButtons({
required List<ToolbarButtonConfig> configs,
required Set<String> knownButtonIds,
required bool Function(String buttonId) isPrimaryAvailable,
}) {
final configById = {for (final config in configs) config.buttonId: config};
final resolvedButtons = <ContextualToolbarButtonResolution>[];
final seenIds = <String>{};
for (final config in configs.where((config) => config.isVisible)) {
final resolvedButton = resolveContextualToolbarButton(
config: config,
configById: configById,
knownButtonIds: knownButtonIds,
isPrimaryAvailable: isPrimaryAvailable,
);
if (resolvedButton == null) {
continue;
}
if (seenIds.add(resolvedButton.buttonId)) {
resolvedButtons.add(resolvedButton);
}
}
return resolvedButtons;
}
ContextualToolbarButtonResolution? resolveContextualToolbarButton({
required ToolbarButtonConfig config,
required Map<String, ToolbarButtonConfig> configById,
required Set<String> knownButtonIds,
required bool Function(String buttonId) isPrimaryAvailable,
Set<String> visited = const {},
}) {
if (visited.contains(config.buttonId)) {
return null;
}
if (!knownButtonIds.contains(config.buttonId)) {
return ContextualToolbarButtonResolution(
buttonId: config.buttonId,
isEnabled: true,
);
}
if (isPrimaryAvailable(config.buttonId)) {
return ContextualToolbarButtonResolution(
buttonId: config.buttonId,
isEnabled: true,
);
}
final fallbackId = ToolbarFallbackChoice.fromStored(
config.fallbackId,
).resolveRuntimeFallbackId();
if (fallbackId == null) {
return ContextualToolbarButtonResolution(
buttonId: config.buttonId,
isEnabled: false,
);
}
final fallbackConfig = configById[fallbackId];
if (fallbackConfig == null) {
return knownButtonIds.contains(fallbackId)
? ContextualToolbarButtonResolution(
buttonId: fallbackId,
isEnabled: true,
)
: ContextualToolbarButtonResolution(
buttonId: config.buttonId,
isEnabled: false,
);
}
return resolveContextualToolbarButton(
config: fallbackConfig,
configById: configById,
knownButtonIds: knownButtonIds,
isPrimaryAvailable: isPrimaryAvailable,
visited: {...visited, config.buttonId},
);
}
@@ -0,0 +1,44 @@
/*
* 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: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';
class ContextualToolbarScope with FastEquatable {
final String? selectedTabId;
final Sheet? displayedSheet;
final TabState? tabState;
final bool isPreview;
ContextualToolbarScope({
required this.selectedTabId,
required this.displayedSheet,
required this.tabState,
required this.isPreview,
});
@override
List<Object?> get hashParameters => [
selectedTabId,
displayedSheet,
tabState,
isPreview,
];
}
@@ -0,0 +1,162 @@
/*
* 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_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.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/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/widgets/navigation_buttons.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tabs_action_button.dart';
class ToolbarButtonDefinition {
final ToolbarButtonSpec spec;
final String label;
final IconData icon;
final bool Function(ContextualToolbarScope scope, WidgetRef ref)?
isPrimaryAvailable;
final Widget Function(
ContextualToolbarScope scope,
BuildContext context,
WidgetRef ref,
)
builder;
const ToolbarButtonDefinition({
required this.spec,
required this.label,
required this.icon,
this.isPrimaryAvailable,
required this.builder,
});
}
final List<ToolbarButtonDefinition> toolbarButtonRegistry = [
ToolbarButtonDefinition(
spec: backToolbarButtonSpec,
label: 'Back',
icon: Icons.arrow_back,
isPrimaryAvailable: (scope, ref) =>
scope.tabState?.historyState.canGoBack == true ||
scope.tabState?.isLoading == true,
builder: (scope, context, ref) {
if (scope.isPreview) {
return NavigateBackButtonView(
canGoBack: true,
isLoading: false,
onPressed: () {},
onLongPress: () {},
);
}
return NavigateBackButton(
selectedTabId: scope.selectedTabId,
isLoading: scope.tabState?.isLoading ?? false,
);
},
),
ToolbarButtonDefinition(
spec: forwardToolbarButtonSpec,
label: 'Forward',
icon: Icons.arrow_forward,
isPrimaryAvailable: (scope, ref) =>
scope.tabState?.historyState.canGoForward == true,
builder: (scope, context, ref) {
if (scope.isPreview) {
return NavigateForwardButtonView(
canGoForward: true,
onPressed: () {},
onLongPress: () {},
);
}
return NavigateForwardButton(selectedTabId: scope.selectedTabId);
},
),
ToolbarButtonDefinition(
spec: bookmarksToolbarButtonSpec,
label: 'Bookmarks',
icon: MdiIcons.bookmarkMultiple,
builder: (scope, context, ref) {
return IconButton(
onPressed: scope.isPreview
? () {}
: () async {
await BookmarkListRoute(
entryGuid: BookmarkRoot.root.id,
).push(context);
},
icon: const Icon(MdiIcons.bookmarkMultiple),
);
},
),
ToolbarButtonDefinition(
spec: shareToolbarButtonSpec,
label: 'Share',
icon: Icons.share,
builder: (scope, context, ref) => scope.isPreview
? ShareMenuButtonView(onPressed: () {})
: ShareMenuButton(selectedTabId: scope.selectedTabId),
),
ToolbarButtonDefinition(
spec: addTabToolbarButtonSpec,
label: 'New Tab',
icon: MdiIcons.tabPlus,
builder: (scope, context, ref) => scope.isPreview
? AddTabButtonView(onPressed: () {}, onLongPress: () {})
: const AddTabButton(),
),
ToolbarButtonDefinition(
spec: tabsCountToolbarButtonSpec,
label: 'Tabs',
icon: MdiIcons.tab,
builder: (scope, context, ref) => scope.isPreview
? TabsCountButtonView(
isActive: false,
onTap: () {},
onLongPress: () {},
buttonBuilder: (isActive, onTap, onLongPress) {
return TabsActionButtonView(
isActive: isActive,
tabCountText: '5',
onTap: onTap,
onLongPress: onLongPress,
);
},
)
: TabsCountButton(
selectedTabId: scope.selectedTabId,
displayedSheet: scope.displayedSheet,
showLongPressMenu: false,
),
),
ToolbarButtonDefinition(
spec: navigationMenuToolbarButtonSpec,
label: 'Menu',
icon: Icons.more_vert,
builder: (scope, context, ref) => scope.isPreview
? NavigationMenuButtonView(onTap: () {})
: NavigationMenuButton(selectedTabId: scope.selectedTabId),
),
];
final Map<String, ToolbarButtonDefinition> toolbarButtonRegistryById = {
for (final def in toolbarButtonRegistry) def.spec.id.name: def,
};
@@ -0,0 +1,221 @@
/*
* 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_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.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/presentation/widgets/browser_menu_sheet.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_creation_menu.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tabs_action_button.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/toolbar_button.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/hooks/menu_controller.dart';
class ShareMenuButton extends StatelessWidget {
final String? selectedTabId;
const ShareMenuButton({super.key, required this.selectedTabId});
@override
Widget build(BuildContext context) {
return ShareMenuButtonView(
onPressed: () async {
final tabId = selectedTabId;
if (tabId != null) {
await showShareBottomSheet(context, selectedTabId: tabId);
}
},
);
}
}
class ShareMenuButtonView extends StatelessWidget {
const ShareMenuButtonView({super.key, this.onPressed});
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return IconButton(onPressed: onPressed, icon: const Icon(Icons.share));
}
}
class NavigationMenuButton extends StatelessWidget {
final String? selectedTabId;
const NavigationMenuButton({super.key, required this.selectedTabId});
@override
Widget build(BuildContext context) {
return NavigationMenuButtonView(
onTap: () async {
await showBrowserMenuSheet(context);
},
);
}
}
class NavigationMenuButtonView extends StatelessWidget {
const NavigationMenuButtonView({super.key, this.onTap});
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return ToolbarButton(onTap: onTap, child: const Icon(Icons.more_vert));
}
}
class AddTabButton extends HookConsumerWidget {
const AddTabButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabMenuController = useMenuController();
return TabCreationMenu(
controller: tabMenuController,
child: AddTabButtonView(
onPressed: () async {
final settings = ref.read(generalSettingsWithDefaultsProvider);
await SearchRoute(
tabType:
ref.read(selectedTabTypeProvider) ??
settings.effectiveDefaultCreateTabType,
).push(context);
if (context.mounted) {
const BrowserRoute().go(context);
}
},
onLongPress: () {
if (tabMenuController.isOpen) {
tabMenuController.close();
} else {
tabMenuController.open();
}
},
),
);
}
}
class AddTabButtonView extends StatelessWidget {
const AddTabButtonView({super.key, this.onPressed, this.onLongPress});
final VoidCallback? onPressed;
final VoidCallback? onLongPress;
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: onPressed,
icon: const Icon(MdiIcons.tabPlus),
onLongPress: onLongPress,
);
}
}
class TabsCountButtonView extends StatelessWidget {
const TabsCountButtonView({
super.key,
required this.isActive,
required this.onTap,
this.onLongPress,
this.buttonBuilder,
});
final bool isActive;
final VoidCallback onTap;
final VoidCallback? onLongPress;
final Widget Function(
bool isActive,
VoidCallback onTap,
VoidCallback? onLongPress,
)?
buttonBuilder;
@override
Widget build(BuildContext context) {
return (buttonBuilder != null)
? buttonBuilder!(isActive, onTap, onLongPress)
: TabsActionButton(
isActive: isActive,
onTap: onTap,
onLongPress: onLongPress,
);
}
}
class TabsCountButton extends HookConsumerWidget {
const TabsCountButton({
super.key,
required this.selectedTabId,
required this.displayedSheet,
required this.showLongPressMenu,
});
final String? selectedTabId;
final Sheet? displayedSheet;
final bool showLongPressMenu;
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabMenuController = useMenuController();
return TabCreationMenu(
controller: tabMenuController,
child: TabsCountButtonView(
isActive: displayedSheet is ViewTabsSheet,
onTap: () async {
final tabViewBottomSheet = ref
.read(generalSettingsWithDefaultsProvider)
.tabViewBottomSheet;
if (tabViewBottomSheet) {
if (displayedSheet case ViewTabsSheet()) {
ref.read(bottomSheetControllerProvider.notifier).requestDismiss();
} else {
ref
.read(bottomSheetControllerProvider.notifier)
.show(ViewTabsSheet());
}
} else {
await const TabViewRoute().push(context);
}
},
onLongPress: showLongPressMenu
? () {
if (tabMenuController.isOpen) {
tabMenuController.close();
} else {
tabMenuController.open();
}
}
: null,
),
);
}
}
@@ -0,0 +1,128 @@
/*
* 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/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';
class ContextualToolbar extends HookConsumerWidget {
const ContextualToolbar({
super.key,
required this.selectedTabId,
required this.displayedSheet,
});
final String? selectedTabId;
final Sheet? displayedSheet;
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabState = ref.watch(tabStateProvider(selectedTabId));
final configs = ref.watch(effectiveToolbarButtonConfigsProvider);
final scope = ContextualToolbarScope(
selectedTabId: selectedTabId,
displayedSheet: displayedSheet,
tabState: tabState,
isPreview: false,
);
final resolvedButtons = useMemoized(
() => resolveVisibleContextualToolbarButtons(
configs: configs.value,
knownButtonIds: knownToolbarButtonIds,
isPrimaryAvailable: (buttonId) {
final def = toolbarButtonRegistryById[buttonId];
return def?.isPrimaryAvailable?.call(scope, ref) ?? true;
},
),
[configs, scope],
);
final buttons = resolvedButtons
.map((button) => _buildButton(scope, context, ref, button))
.toList();
return ContextualToolbarView(buttons: buttons);
}
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));
}
}
class ContextualToolbarView extends StatelessWidget {
const ContextualToolbarView({super.key, required this.buttons});
final List<Widget> buttons;
static const _minButtonWidth = 48.0;
@override
Widget build(BuildContext context) {
if (buttons.isEmpty) return const SizedBox.shrink();
return LayoutBuilder(
builder: (context, constraints) {
final fitsEvenly =
constraints.maxWidth >= _minButtonWidth * buttons.length;
if (fitsEvenly) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: buttons,
);
}
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: buttons
.map(
(button) => SizedBox(width: _minButtonWidth, child: button),
)
.toList(),
),
);
},
);
}
}
@@ -25,10 +25,8 @@ import 'package:flutter/material.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:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
@@ -36,16 +34,13 @@ 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';
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/presentation/controllers/toolbar_visibility.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.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/extension_shortcut_menu.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/navigation_buttons.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_creation_menu.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/tabs_action_button.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/toolbar_button.dart';
import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart';
import 'package:weblibre/features/geckoview/features/readerview/presentation/widgets/reader_button.dart';
@@ -460,90 +455,6 @@ class QuickTabSwitcherItem with FastEquatable {
];
}
class ContextualToolbar extends HookConsumerWidget {
const ContextualToolbar({
super.key,
required this.selectedTabId,
required this.displayedSheet,
});
final String? selectedTabId;
final Sheet? displayedSheet;
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabState = ref.watch(tabStateProvider(selectedTabId));
return ContextualToolbarView(
canGoBack:
tabState?.historyState.canGoBack == true ||
tabState?.isLoading == true,
canGoForward: tabState?.historyState.canGoForward == true,
onBookmarksTap: () async {
await BookmarkListRoute(entryGuid: BookmarkRoot.root.id).push(context);
},
backButton: NavigateBackButton(
selectedTabId: selectedTabId,
isLoading: tabState?.isLoading ?? false,
),
forwardButton: NavigateForwardButton(selectedTabId: selectedTabId),
shareButton: ShareMenuButton(selectedTabId: selectedTabId),
addTabButton: const AddTabButton(),
tabsCountButton: TabsCountButton(
selectedTabId: selectedTabId,
displayedSheet: displayedSheet,
showLongPressMenu: false,
),
navigationButton: NavigationMenuButton(selectedTabId: selectedTabId),
);
}
}
class ContextualToolbarView extends StatelessWidget {
const ContextualToolbarView({
super.key,
required this.canGoBack,
required this.canGoForward,
required this.onBookmarksTap,
required this.backButton,
required this.forwardButton,
required this.shareButton,
required this.addTabButton,
required this.tabsCountButton,
required this.navigationButton,
});
final bool canGoBack;
final bool canGoForward;
final VoidCallback onBookmarksTap;
final Widget backButton;
final Widget forwardButton;
final Widget shareButton;
final Widget addTabButton;
final Widget tabsCountButton;
final Widget navigationButton;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
if (canGoBack)
backButton
else
IconButton(
onPressed: onBookmarksTap,
icon: const Icon(MdiIcons.bookmarkMultiple),
),
if (canGoForward) forwardButton else shareButton,
addTabButton,
tabsCountButton,
navigationButton,
],
);
}
}
class QuickTabSwitcher extends HookConsumerWidget {
final QuickTabSwitcherMode quickTabSwitcherMode;
@@ -760,191 +671,3 @@ class QuickTabSwitcherView extends StatelessWidget {
);
}
}
class ShareMenuButton extends StatelessWidget {
final String? selectedTabId;
const ShareMenuButton({super.key, required this.selectedTabId});
@override
Widget build(BuildContext context) {
return ShareMenuButtonView(
onPressed: () async {
final tabId = selectedTabId;
if (tabId != null) {
await showShareBottomSheet(context, selectedTabId: tabId);
}
},
);
}
}
class ShareMenuButtonView extends StatelessWidget {
const ShareMenuButtonView({super.key, this.onPressed});
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return IconButton(onPressed: onPressed, icon: const Icon(Icons.share));
}
}
class NavigationMenuButton extends StatelessWidget {
final String? selectedTabId;
const NavigationMenuButton({super.key, required this.selectedTabId});
@override
Widget build(BuildContext context) {
return NavigationMenuButtonView(
onTap: () async {
await showBrowserMenuSheet(context);
},
);
}
}
class NavigationMenuButtonView extends StatelessWidget {
const NavigationMenuButtonView({super.key, this.onTap});
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return ToolbarButton(onTap: onTap, child: const Icon(Icons.more_vert));
}
}
class AddTabButton extends HookConsumerWidget {
const AddTabButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabMenuController = useMenuController();
return TabCreationMenu(
controller: tabMenuController,
child: AddTabButtonView(
onPressed: () async {
final settings = ref.read(generalSettingsWithDefaultsProvider);
await SearchRoute(
tabType:
ref.read(selectedTabTypeProvider) ??
settings.effectiveDefaultCreateTabType,
).push(context);
if (context.mounted) {
const BrowserRoute().go(context);
}
},
onLongPress: () {
if (tabMenuController.isOpen) {
tabMenuController.close();
} else {
tabMenuController.open();
}
},
),
);
}
}
class AddTabButtonView extends StatelessWidget {
const AddTabButtonView({super.key, this.onPressed, this.onLongPress});
final VoidCallback? onPressed;
final VoidCallback? onLongPress;
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: onPressed,
icon: const Icon(MdiIcons.tabPlus),
onLongPress: onLongPress,
);
}
}
class TabsCountButtonView extends StatelessWidget {
const TabsCountButtonView({
super.key,
required this.isActive,
required this.onTap,
this.onLongPress,
this.buttonBuilder,
});
final bool isActive;
final VoidCallback onTap;
final VoidCallback? onLongPress;
final Widget Function(
bool isActive,
VoidCallback onTap,
VoidCallback? onLongPress,
)?
buttonBuilder;
@override
Widget build(BuildContext context) {
return (buttonBuilder != null)
? buttonBuilder!(isActive, onTap, onLongPress)
: TabsActionButton(
isActive: isActive,
onTap: onTap,
onLongPress: onLongPress,
);
}
}
class TabsCountButton extends HookConsumerWidget {
const TabsCountButton({
super.key,
required this.selectedTabId,
required this.displayedSheet,
required this.showLongPressMenu,
});
final String? selectedTabId;
final Sheet? displayedSheet;
final bool showLongPressMenu;
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabMenuController = useMenuController();
return TabCreationMenu(
controller: tabMenuController,
child: TabsCountButtonView(
isActive: displayedSheet is ViewTabsSheet,
onTap: () async {
final tabViewBottomSheet = ref
.read(generalSettingsWithDefaultsProvider)
.tabViewBottomSheet;
if (tabViewBottomSheet) {
if (displayedSheet case ViewTabsSheet()) {
ref.read(bottomSheetControllerProvider.notifier).requestDismiss();
} else {
ref
.read(bottomSheetControllerProvider.notifier)
.show(ViewTabsSheet());
}
} else {
await const TabViewRoute().push(context);
}
},
onLongPress: showLongPressMenu
? () {
if (tabMenuController.isOpen) {
tabMenuController.close();
} else {
tabMenuController.open();
}
}
: null,
),
);
}
}
@@ -45,7 +45,7 @@ final class StartupPreferenceEnforcementServiceProvider
}
String _$startupPreferenceEnforcementServiceHash() =>
r'107cdcf7550b7977b53a3cf7419861cdcac0fe3a';
r'5cfe5aec33a9e9e077704ff519c10b0398c5085d';
abstract class _$StartupPreferenceEnforcementService extends $Notifier<void> {
void build();