From d6e02f9d027426a95cd3b6d47fc80f3564da0261 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Wed, 21 Jan 2026 07:13:34 +0100 Subject: [PATCH] long press history navigation --- .../domain/providers/tab_session.dart | 4 + .../domain/providers/tab_session.g.dart | 2 +- .../browser_modules/bottom_app_bar.dart | 99 +++++++++++------ .../presentation/widgets/history_menu.dart | 102 ++++++++++++++++++ .../widgets/history_menu_item.dart | 80 ++++++++++++++ 5 files changed, 251 insertions(+), 36 deletions(-) create mode 100644 app/lib/features/geckoview/features/browser/presentation/widgets/history_menu.dart create mode 100644 app/lib/features/geckoview/features/browser/presentation/widgets/history_menu_item.dart diff --git a/app/lib/features/geckoview/domain/providers/tab_session.dart b/app/lib/features/geckoview/domain/providers/tab_session.dart index 918c644e..98df84b5 100644 --- a/app/lib/features/geckoview/domain/providers/tab_session.dart +++ b/app/lib/features/geckoview/domain/providers/tab_session.dart @@ -56,6 +56,10 @@ class TabSession extends _$TabSession { return _sessionService.goForward(); } + Future goToHistoryIndex({required int index}) { + return _sessionService.goToHistoryIndex(index: index); + } + Future exitFullscreen() { return _sessionService.exitFullscreen(); } diff --git a/app/lib/features/geckoview/domain/providers/tab_session.g.dart b/app/lib/features/geckoview/domain/providers/tab_session.g.dart index a6620c2b..ab9e0f5b 100644 --- a/app/lib/features/geckoview/domain/providers/tab_session.g.dart +++ b/app/lib/features/geckoview/domain/providers/tab_session.g.dart @@ -57,7 +57,7 @@ final class TabSessionProvider extends $NotifierProvider { } } -String _$tabSessionHash() => r'4b7206863e205dfeee6201ba6072e742c43ab6f3'; +String _$tabSessionHash() => r'5be471574bb868889cc1eee37ae7e4570a9e21a4'; final class TabSessionFamily extends $Family with $ClassFamilyOverride { diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart index 76f09a6c..b05c44a1 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart @@ -41,6 +41,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/contro 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_badge_icon.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/extension_shortcut_menu.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/history_menu.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_creation_menu.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_menu.dart'; @@ -924,18 +925,32 @@ class NavigateForwardButton extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return IconButton( - onPressed: canGoForward - ? () async { - final controller = ref.read( - tabSessionProvider(tabId: selectedTabId).notifier, - ); + final historyMenuController = useMenuController(); - await controller.goForward(); - menuControllerToClose?.close(); - } - : null, - icon: const Icon(Icons.arrow_forward), + return HistoryMenu( + selectedTabId: selectedTabId, + controller: historyMenuController, + direction: HistoryMenuDirection.forward, + child: IconButton( + onPressed: canGoForward + ? () async { + final controller = ref.read( + tabSessionProvider(tabId: selectedTabId).notifier, + ); + + await controller.goForward(); + menuControllerToClose?.close(); + } + : null, + onLongPress: canGoForward + ? () { + if (!historyMenuController.isOpen) { + historyMenuController.open(); + } + } + : null, + icon: const Icon(Icons.arrow_forward), + ), ); } } @@ -956,33 +971,47 @@ class NavigateBackButton extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return IconButton( - onPressed: (canGoBack || isLoading) - ? () async { - final controller = ref.read( - tabSessionProvider(tabId: selectedTabId).notifier, - ); + final historyMenuController = useMenuController(); - final isReaderActive = ref.read( - selectedTabStateProvider.select( - (state) => state?.readerableState.active ?? false, - ), - ); + return HistoryMenu( + selectedTabId: selectedTabId, + controller: historyMenuController, + direction: HistoryMenuDirection.back, + child: IconButton( + onPressed: (canGoBack || isLoading) + ? () async { + final controller = ref.read( + tabSessionProvider(tabId: selectedTabId).notifier, + ); - if (isLoading) { - await controller.stopLoading(); - } else if (isReaderActive) { - await ref - .read(readerableScreenControllerProvider.notifier) - .toggleReaderView(false); - } else { - await controller.goBack(); + final isReaderActive = ref.read( + selectedTabStateProvider.select( + (state) => state?.readerableState.active ?? false, + ), + ); + + if (isLoading) { + await controller.stopLoading(); + } else if (isReaderActive) { + await ref + .read(readerableScreenControllerProvider.notifier) + .toggleReaderView(false); + } else { + await controller.goBack(); + } + + menuControllerToClose?.close(); } - - menuControllerToClose?.close(); - } - : null, - icon: isLoading ? const Icon(Icons.close) : const Icon(Icons.arrow_back), + : null, + onLongPress: (canGoBack && !isLoading) + ? () { + if (!historyMenuController.isOpen) { + historyMenuController.open(); + } + } + : null, + icon: isLoading ? const Icon(Icons.close) : const Icon(Icons.arrow_back), + ), ); } } diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/history_menu.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/history_menu.dart new file mode 100644 index 00000000..69ff8f83 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/history_menu.dart @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2024-2025 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 . + */ +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/entities/states/history.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/history_menu_item.dart'; + +enum HistoryMenuDirection { back, forward } + +class HistoryMenu extends HookConsumerWidget { + const HistoryMenu({ + super.key, + required this.selectedTabId, + required this.controller, + required this.direction, + required this.child, + }); + + final String? selectedTabId; + final MenuController controller; + final HistoryMenuDirection direction; + final Widget child; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final historyState = ref.watch( + tabStateProvider(selectedTabId).select((state) => state?.historyState), + ); + + final menuItems = useMemoized(() => _buildMenuItems(historyState), [ + historyState, + ]); + + return MenuAnchor( + controller: controller, + consumeOutsideTap: true, + menuChildren: menuItems, + child: child, + ); + } + + List _buildMenuItems(HistoryState? historyState) { + if (historyState == null || historyState.items.isEmpty) { + return [ + MenuItemButton( + child: Text( + direction == HistoryMenuDirection.back + ? 'No previous pages' + : 'No forward pages', + ), + ), + ]; + } + + final items = historyState.items.indexed; + final currentIndex = historyState.currentIndex; + + final historyItems = switch (direction) { + // Back: oldest at top, newest at bottom (close to button) + HistoryMenuDirection.back => items.where((e) => e.$1 < currentIndex), + // Forward: furthest at top, closest at bottom (close to button) + HistoryMenuDirection.forward => + items.where((e) => e.$1 > currentIndex).toList().reversed, + }; + + if (historyItems.isEmpty) { + final message = direction == HistoryMenuDirection.back + ? 'No previous pages' + : 'No forward pages'; + return [MenuItemButton(child: Text(message))]; + } + + return historyItems + .map( + (e) => HistoryMenuItem( + selectedTabId: selectedTabId, + item: e.$2, + historyIndex: e.$1, + ), + ) + .toList(); + } +} diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/history_menu_item.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/history_menu_item.dart new file mode 100644 index 00000000..d41237c7 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/history_menu_item.dart @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2024-2025 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 . + */ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/history.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; +import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; +import 'package:weblibre/presentation/widgets/url_icon.dart'; + +class HistoryMenuItem extends HookConsumerWidget { + const HistoryMenuItem({ + super.key, + required this.selectedTabId, + required this.item, + required this.historyIndex, + }); + + final String? selectedTabId; + final HistoryItem item; + final int historyIndex; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + return MenuItemButton( + leadingIcon: UrlIcon([item.url], iconSize: 24), + closeOnActivate: false, + onPressed: () async { + await ref + .read(tabSessionProvider(tabId: selectedTabId).notifier) + .goToHistoryIndex(index: historyIndex); + + if (context.mounted) { + MenuController.maybeOf(context)?.close(); + } + }, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + item.title.isNotEmpty ? item.title : item.url.authority, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + UriBreadcrumb( + uri: item.url, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } +}