add long press actions on contextual buttons; move extension shortcut into contextual; disable reader view button in tab bar;

This commit is contained in:
Fabian Freund
2026-03-12 08:25:56 +01:00
parent efb0aac5e2
commit bf4b8a31f4
15 changed files with 301 additions and 243 deletions
@@ -44,8 +44,8 @@ class TabSession extends _$TabSession {
return _sessionService.stopLoading();
}
Future<void> reload() {
return _sessionService.reload();
Future<void> reload({LoadUrlFlags flags = LoadUrlFlags.NONE}) {
return _sessionService.reload(flags: flags);
}
Future<void> goBack() {
@@ -57,7 +57,7 @@ final class TabSessionProvider extends $NotifierProvider<TabSession, void> {
}
}
String _$tabSessionHash() => r'b9381c334672fd3f733a4cc86ce696fb7de6fa5d';
String _$tabSessionHash() => r'be44598ad023c2fb23e48d3fa4bf805c4640c599';
final class TabSessionFamily extends $Family
with $ClassFamilyOverride<TabSession, void, void, void, String?> {
@@ -40,4 +40,5 @@ enum ToolbarButtonId {
pageUp,
pageDown,
font,
extensionShortcut,
}
@@ -147,6 +147,12 @@ const fontToolbarButtonSpec = ToolbarButtonSpec(
defaultVisible: false,
);
const extensionShortcutToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.extensionShortcut,
defaultVisible: false,
canBeFallbackTarget: false,
);
const toolbarButtonSpecs = [
backToolbarButtonSpec,
forwardToolbarButtonSpec,
@@ -169,6 +175,7 @@ const toolbarButtonSpecs = [
pageUpToolbarButtonSpec,
pageDownToolbarButtonSpec,
fontToolbarButtonSpec,
extensionShortcutToolbarButtonSpec,
];
final Map<String, ToolbarButtonSpec> toolbarButtonSpecsById = {
@@ -18,6 +18,7 @@
* 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: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';
@@ -27,11 +28,15 @@ import 'package:weblibre/features/geckoview/domain/providers.dart';
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/providers/web_extensions_state.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/font_size_constants.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/extension_shortcut_menu.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/font_size_bottom_sheet.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';
@@ -112,18 +117,7 @@ final List<ToolbarButtonDefinition> toolbarButtonRegistry = [
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),
);
},
builder: (scope, context, ref) => _BookmarkToolbarButton(scope: scope),
),
ToolbarButtonDefinition(
spec: shareToolbarButtonSpec,
@@ -177,21 +171,7 @@ final List<ToolbarButtonDefinition> toolbarButtonRegistry = [
spec: reloadToolbarButtonSpec,
label: 'Reload',
icon: Icons.refresh,
builder: (scope, context, ref) {
return IconButton(
onPressed: scope.isPreview
? () {}
: () async {
final tabId = scope.selectedTabId;
if (tabId != null) {
await ref
.read(tabSessionProvider(tabId: tabId).notifier)
.reload();
}
},
icon: const Icon(Icons.refresh),
);
},
builder: (scope, context, ref) => _ReloadToolbarButton(scope: scope),
),
ToolbarButtonDefinition(
spec: readerModeToolbarButtonSpec,
@@ -276,14 +256,7 @@ final List<ToolbarButtonDefinition> toolbarButtonRegistry = [
spec: closeTabToolbarButtonSpec,
label: 'Close Tab',
icon: MdiIcons.tabMinus,
builder: (scope, context, ref) {
return IconButton(
onPressed: scope.isPreview
? () {}
: () => _closeTab(context, ref, scope.selectedTabId),
icon: const Icon(MdiIcons.tabMinus),
);
},
builder: (scope, context, ref) => _CloseTabToolbarButton(scope: scope),
),
ToolbarButtonDefinition(
spec: inputUrlToolbarButtonSpec,
@@ -417,6 +390,24 @@ final List<ToolbarButtonDefinition> toolbarButtonRegistry = [
return _FontToolbarButton(selectedTabId: scope.selectedTabId);
},
),
ToolbarButtonDefinition(
spec: extensionShortcutToolbarButtonSpec,
label: 'Extensions',
icon: MdiIcons.puzzle,
isPrimaryAvailable: (scope, ref) => ref
.read(
webExtensionsStateProvider(
WebExtensionActionType.browser,
).select((value) => value.values),
)
.isNotEmpty,
builder: (scope, context, ref) {
if (scope.isPreview) {
return IconButton(onPressed: () {}, icon: const Icon(MdiIcons.puzzle));
}
return const _ExtensionShortcutToolbarButton();
},
),
];
final Map<String, ToolbarButtonDefinition> toolbarButtonRegistryById = {
@@ -452,6 +443,224 @@ Future<void> _closeTab(
}
}
class _ExtensionShortcutToolbarButton extends HookConsumerWidget {
const _ExtensionShortcutToolbarButton();
@override
Widget build(BuildContext context, WidgetRef ref) {
final menuController = useMemoized(MenuController.new);
return ExtensionShortcutMenu(
controller: menuController,
child: IconButton(
onPressed: menuController.open,
icon: const Icon(MdiIcons.puzzle),
),
);
}
}
class _ReloadToolbarButton extends HookConsumerWidget {
final ContextualToolbarScope scope;
const _ReloadToolbarButton({required this.scope});
@override
Widget build(BuildContext context, WidgetRef ref) {
final menuController = useMemoized(MenuController.new);
return MenuAnchor(
controller: menuController,
builder: (context, controller, child) => child!,
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(Icons.refresh),
onPressed: () async {
final tabId = scope.selectedTabId;
if (tabId != null) {
await ref
.read(tabSessionProvider(tabId: tabId).notifier)
.reload(flags: LoadUrlFlags.BYPASS_CACHE);
}
},
child: const Text('Hard Refresh'),
),
],
child: IconButton(
onPressed: scope.isPreview
? () {}
: () async {
final tabId = scope.selectedTabId;
if (tabId != null) {
await ref
.read(tabSessionProvider(tabId: tabId).notifier)
.reload();
}
},
onLongPress: scope.isPreview ? null : menuController.open,
icon: const Icon(Icons.refresh),
),
);
}
}
class _CloseTabToolbarButton extends HookConsumerWidget {
final ContextualToolbarScope scope;
const _CloseTabToolbarButton({required this.scope});
@override
Widget build(BuildContext context, WidgetRef ref) {
final menuController = useMemoized(MenuController.new);
final host = ref.watch(
tabStateProvider(scope.selectedTabId).select((s) => s?.url.host),
);
return MenuAnchor(
controller: menuController,
builder: (context, controller, child) => child!,
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(Icons.tab),
onPressed: () async {
final tabStates = ref.read(tabStatesProvider);
final otherIds = tabStates.keys
.where((id) => id != scope.selectedTabId)
.toList();
if (otherIds.isNotEmpty) {
await ref
.read(tabRepositoryProvider.notifier)
.closeTabs(otherIds);
}
},
child: const Text('Close Others'),
),
if (host != null && host.isNotEmpty)
MenuItemButton(
leadingIcon: const Icon(Icons.language),
onPressed: () async {
final tabStates = ref.read(tabStatesProvider);
final sameHostIds = tabStates.entries
.where((e) => e.value.url.host == host)
.map((e) => e.key)
.toList();
if (sameHostIds.isNotEmpty) {
await ref
.read(tabRepositoryProvider.notifier)
.closeTabs(sameHostIds);
}
},
child: const Text('Close from Same Host'),
),
],
child: IconButton(
onPressed: scope.isPreview
? () {}
: () => _closeTab(context, ref, scope.selectedTabId),
onLongPress: scope.isPreview ? null : menuController.open,
icon: const Icon(MdiIcons.tabMinus),
),
);
}
}
class _BookmarkToolbarButton extends HookConsumerWidget {
final ContextualToolbarScope scope;
const _BookmarkToolbarButton({required this.scope});
@override
Widget build(BuildContext context, WidgetRef ref) {
final menuController = useMemoized(MenuController.new);
final tabUrl = scope.tabState?.url;
final bookmarkable = tabUrl != null && !scope.isPreview;
// Walk the in-memory bookmark tree to find GUIDs for the current URL.
final existingGuids = ref.watch(
bookmarksRepositoryProvider.select((async) {
final result = <String>[];
if (!bookmarkable) return const <String>[];
final root = async.value;
if (root == null) return const <String>[];
void collect(BookmarkItem item) {
if (item is BookmarkEntry && item.url == tabUrl) {
result.add(item.guid);
}
if (item is BookmarkFolder) {
for (final child in item.children ?? const <BookmarkItem>[]) {
collect(child);
}
}
}
collect(root);
return result;
}),
);
final isBookmarked = existingGuids.isNotEmpty;
return MenuAnchor(
controller: menuController,
builder: (context, controller, child) => child!,
menuChildren: [
if (isBookmarked)
MenuItemButton(
leadingIcon: const Icon(MdiIcons.bookmarkRemove),
onPressed: () async {
for (final guid in existingGuids) {
await ref
.read(bookmarksRepositoryProvider.notifier)
.delete(guid);
}
if (context.mounted) {
ui_helper.showInfoMessage(context, 'Bookmark removed');
}
},
child: const Text('Remove Bookmark'),
)
else
MenuItemButton(
leadingIcon: const Icon(MdiIcons.bookmarkPlus),
onPressed: !bookmarkable
? null
: () async {
await ref
.read(bookmarksRepositoryProvider.notifier)
.addBookmark(
parentGuid: BookmarkRoot.mobile.id,
url: tabUrl,
title: scope.tabState!.titleOrAuthority,
);
if (context.mounted) {
ui_helper.showInfoMessage(context, 'Bookmark added');
}
},
child: const Text('Add Bookmark'),
),
],
child: IconButton(
onPressed: scope.isPreview
? () {}
: () async {
await BookmarkListRoute(
entryGuid: BookmarkRoot.root.id,
).push(context);
},
onLongPress: scope.isPreview ? null : menuController.open,
icon: const Icon(MdiIcons.bookmarkMultiple),
),
);
}
}
Future<void> _adjustFontSize(
BuildContext context,
WidgetRef ref, {
@@ -28,28 +28,23 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/design/app_colors.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';
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/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/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_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/tab_icon.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_menu.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';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/hooks/menu_controller.dart';
import 'package:weblibre/presentation/widgets/selectable_chips.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
@@ -72,8 +67,6 @@ class BrowserTopAppBar extends StatelessWidget {
displayedSheet: null,
showContextualToolbar: false,
showQuickTabSwitcherBar: false,
showMainToolbarNavigationButton: !showContextualToolbar,
showMainToolbarTabsCount: !showContextualToolbar,
);
}
@@ -108,8 +101,6 @@ class BrowserBottomAppBar extends StatelessWidget {
showMainToolbar: showMainToolbar,
showContextualToolbar: showContextualToolbar,
showQuickTabSwitcherBar: showQuickTabSwitcherBar,
showMainToolbarNavigationButton: !showContextualToolbar,
showMainToolbarTabsCount: !showContextualToolbar,
);
}
@@ -137,17 +128,12 @@ class BrowserTabBar extends HookConsumerWidget {
final bool showQuickTabSwitcherBar;
final Sheet? displayedSheet;
final bool showMainToolbarTabsCount;
final bool showMainToolbarNavigationButton;
const BrowserTabBar({
super.key,
required this.showMainToolbar,
required this.displayedSheet,
required this.showContextualToolbar,
required this.showQuickTabSwitcherBar,
required this.showMainToolbarTabsCount,
required this.showMainToolbarNavigationButton,
});
static const contextualToolabarHeight = 54.0;
@@ -180,18 +166,37 @@ class BrowserTabBar extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final extensionMenuController = useMenuController();
final selectedTabId = ref.watch(selectedTabProvider);
final settings = ref.watch(generalSettingsWithDefaultsProvider);
// 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)
.value;
final tabsCountInContextual =
showContextualToolbar &&
contextualConfigs.any(
(c) => c.buttonId == ToolbarButtonId.tabsCount.name && c.isVisible,
);
final menuInContextual =
showContextualToolbar &&
contextualConfigs.any(
(c) =>
c.buttonId == ToolbarButtonId.navigationMenu.name && c.isVisible,
);
final showMainToolbarTabsCount = !tabsCountInContextual;
final showMainToolbarNavigationButton = !menuInContextual;
final containerColor = ref.watch(
watchTabContainerDataProvider(
selectedTabId,
).select((data) => data.value?.color),
);
final showExtensionShortcut = settings.showExtensionShortcut;
final quickTabSwitcherMode = settings.effectiveUiQuickTabSwitcherMode();
final tabBarPosition = settings.tabBarPosition;
@@ -221,58 +226,6 @@ class BrowserTabBar extends HookConsumerWidget {
: const AppBarTitle()
: null,
actions: [
if (showTabTitle)
Consumer(
builder: (context, ref, child) {
final tabBarReaderView = ref.watch(
generalSettingsWithDefaultsProvider.select(
(value) => value.tabBarReaderView,
),
);
final readerabilityStateActive = ref.watch(
selectedTabStateProvider.select(
(state) =>
(state?.readerableState ?? ReaderableState.$default())
.active,
),
);
return Visibility(
visible: tabBarReaderView || readerabilityStateActive,
child: ReaderButton(
buttonBuilder: (isLoading, readerActive, icon) =>
ToolbarButton(
onTap: isLoading
? null
: () async {
await ref
.read(
readerableScreenControllerProvider
.notifier,
)
.toggleReaderView(!readerActive);
},
child: icon,
),
),
);
},
),
if (showExtensionShortcut)
ExtensionShortcutMenu(
controller: extensionMenuController,
child: ToolbarButton(
onTap: () {
if (extensionMenuController.isOpen) {
extensionMenuController.close();
} else {
extensionMenuController.open();
}
},
child: const Icon(MdiIcons.puzzle),
),
),
if (showMainToolbarTabsCount)
TabsCountButton(
selectedTabId: selectedTabId,
@@ -234,8 +234,8 @@ class GridTabPreview extends HookConsumerWidget {
onPressed: () {
onDeleteAll?.call(tabState.url.host);
},
leadingIcon: const Icon(MdiIcons.closeBoxMultiple),
child: Text('Close all from ${tabState.url.host}'),
leadingIcon: const Icon(Icons.language),
child: const Text('Close from Same Host'),
),
],
child: SizedBox(
@@ -567,8 +567,8 @@ class ListTabPreview extends HookConsumerWidget {
onPressed: () {
onDeleteAll?.call(tabState.url.host);
},
leadingIcon: const Icon(MdiIcons.closeBoxMultiple),
child: Text('Close all from ${tabState.url.host}'),
leadingIcon: const Icon(Icons.language),
child: const Text('Close from Same Host'),
),
],
child: IconButton(