From 5a46751834cf258ffd0945b3ce913baf084eb501 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Mon, 22 Dec 2025 21:00:02 +0100 Subject: [PATCH] app bar positioning --- .../geckoview/domain/providers/tab_state.dart | 18 +- .../domain/providers/tab_state.g.dart | 69 ---- .../browser/presentation/screens/browser.dart | 186 ++++++++--- .../browser_modules/bottom_app_bar.dart | 311 +++++++++++++----- .../widgets/browser_modules/browser_view.dart | 154 +++++---- .../presentation/widgets/sheets/view_tab.dart | 4 +- .../screens/general_settings.dart | 49 +++ .../user/data/models/general_settings.dart | 9 +- .../user/data/models/general_settings.g.dart | 24 ++ .../domain/repositories/general_settings.dart | 4 + .../repositories/general_settings.g.dart | 2 +- 11 files changed, 540 insertions(+), 290 deletions(-) diff --git a/app/lib/features/geckoview/domain/providers/tab_state.dart b/app/lib/features/geckoview/domain/providers/tab_state.dart index 7a70f8d4..4a804a4f 100644 --- a/app/lib/features/geckoview/domain/providers/tab_state.dart +++ b/app/lib/features/geckoview/domain/providers/tab_state.dart @@ -300,13 +300,13 @@ AsyncValue selectedTabContainerId(Ref ref) { return const AsyncData(null); } -@Riverpod() -Stream tabScrollY(Ref ref, String? tabId, Duration sampleTime) { - final eventService = ref.watch(eventServiceProvider); +// @Riverpod() +// Stream tabScrollY(Ref ref, String? tabId, Duration sampleTime) { +// final eventService = ref.watch(eventServiceProvider); - return eventService.scrollEvent - .where((event) => event.tabId == tabId) - .sampleTime(sampleTime) - .map((event) => event.scrollY) - .asBroadcastStream(); -} +// return eventService.scrollEvent +// .where((event) => event.tabId == tabId) +// .sampleTime(sampleTime) +// .map((event) => event.scrollY) +// .asBroadcastStream(); +// } diff --git a/app/lib/features/geckoview/domain/providers/tab_state.g.dart b/app/lib/features/geckoview/domain/providers/tab_state.g.dart index db817ed3..4a21879c 100644 --- a/app/lib/features/geckoview/domain/providers/tab_state.g.dart +++ b/app/lib/features/geckoview/domain/providers/tab_state.g.dart @@ -337,72 +337,3 @@ final class SelectedTabContainerIdProvider String _$selectedTabContainerIdHash() => r'07899d29f69654d3b314d0da945ad402b1003b41'; - -@ProviderFor(tabScrollY) -const tabScrollYProvider = TabScrollYFamily._(); - -final class TabScrollYProvider - extends $FunctionalProvider, int, Stream> - with $FutureModifier, $StreamProvider { - const TabScrollYProvider._({ - required TabScrollYFamily super.from, - required (String?, Duration) super.argument, - }) : super( - retry: null, - name: r'tabScrollYProvider', - isAutoDispose: true, - dependencies: null, - $allTransitiveDependencies: null, - ); - - @override - String debugGetCreateSourceHash() => _$tabScrollYHash(); - - @override - String toString() { - return r'tabScrollYProvider' - '' - '$argument'; - } - - @$internal - @override - $StreamProviderElement $createElement($ProviderPointer pointer) => - $StreamProviderElement(pointer); - - @override - Stream create(Ref ref) { - final argument = this.argument as (String?, Duration); - return tabScrollY(ref, argument.$1, argument.$2); - } - - @override - bool operator ==(Object other) { - return other is TabScrollYProvider && other.argument == argument; - } - - @override - int get hashCode { - return argument.hashCode; - } -} - -String _$tabScrollYHash() => r'dcb1e82b42bab98c4fc2ee4c7a22f37a28b8ce9e'; - -final class TabScrollYFamily extends $Family - with $FunctionalFamilyOverride, (String?, Duration)> { - const TabScrollYFamily._() - : super( - retry: null, - name: r'tabScrollYProvider', - dependencies: null, - $allTransitiveDependencies: null, - isAutoDispose: true, - ); - - TabScrollYProvider call(String? tabId, Duration sampleTime) => - TabScrollYProvider._(argument: (tabId, sampleTime), from: this); - - @override - String toString() => r'tabScrollYProvider'; -} diff --git a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart index fa209eb1..abb58d1d 100644 --- a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart +++ b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart @@ -50,20 +50,27 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/widget import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart'; import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart'; +import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/utils/ui_helper.dart' as ui_helper; class _TabBar extends HookConsumerWidget { + final bool showMainToolbar; final bool showContextualToolbar; final bool showQuickTabSwitcherBar; - final ValueNotifier showAppBar; + final ValueNotifier displayAppBar; final ValueNotifier sheetController; + final Stream? pointerMoveEvents; + final TabBarPosition tabBarPosition; const _TabBar({ + required this.showMainToolbar, required this.showContextualToolbar, required this.showQuickTabSwitcherBar, - required this.showAppBar, + required this.displayAppBar, required this.sheetController, + required this.tabBarPosition, + required this.pointerMoveEvents, }); @override @@ -75,29 +82,43 @@ class _TabBar extends HookConsumerWidget { selectedTabStateProvider.select((value) => value?.isFullScreen ?? false), ); - final autoHideTabBar = ref.watch( - generalSettingsWithDefaultsProvider.select( - (value) => value.autoHideTabBar, + final autoHideTabBar = switch (tabBarPosition) { + TabBarPosition.top => false, + TabBarPosition.bottom => ref.watch( + generalSettingsWithDefaultsProvider.select( + (value) => value.autoHideTabBar, + ), ), - ); + }; + + final appBarVisible = tabBarPosition == TabBarPosition.top + ? !ref.watch(tabBarDismissableControllerProvider) + : useValueListenable(displayAppBar); if (!autoHideTabBar) { return Visibility( - visible: !tabInFullScreen, - child: BrowserBottomAppBar( - displayedSheet: displayedSheet, - showContextualToolbar: showContextualToolbar, - showQuickTabSwitcherBar: showQuickTabSwitcherBar, - ), + visible: !tabInFullScreen && appBarVisible, + child: switch (tabBarPosition) { + TabBarPosition.top => BrowserTopAppBar( + showMainToolbar: showMainToolbar, + showContextualToolbar: showContextualToolbar, + showQuickTabSwitcherBar: showQuickTabSwitcherBar, + ), + TabBarPosition.bottom => BrowserBottomAppBar( + displayedSheet: displayedSheet, + showMainToolbar: showMainToolbar, + showContextualToolbar: showContextualToolbar, + showQuickTabSwitcherBar: showQuickTabSwitcherBar, + ), + }, ); } - final appBarVisible = useValueListenable(showAppBar); final diffAcc = useRef(0.0); void resetHiddenState() { if (!ref.read(tabBarDismissableControllerProvider)) { - showAppBar.value = true; + displayAppBar.value = true; } diffAcc.value = 0.0; @@ -137,12 +158,10 @@ class _TabBar extends HookConsumerWidget { } }); - ref.listen(tabScrollYProvider(tabId, const Duration(milliseconds: 50)), ( - previous, - next, - ) { - if (previous?.value != null && next.value != null) { - final diff = previous!.value! - next.value!; + useOnStreamChange( + pointerMoveEvents, + onData: (event) { + final diff = event.dy; if (diff < 0) { if (diffAcc.value > 0) { diffAcc.value = 0.0; @@ -150,7 +169,7 @@ class _TabBar extends HookConsumerWidget { diffAcc.value += diff; if (diffAcc.value.abs() > kToolbarHeight * 1.5) { - showAppBar.value = false; + displayAppBar.value = false; } } else if (diff > 0) { if (diffAcc.value < 0) { @@ -162,18 +181,26 @@ class _TabBar extends HookConsumerWidget { resetHiddenState(); } } - } - }); + }, + ); return Visibility( visible: sheetController.value != null || (!tabInFullScreen && appBarVisible), maintainState: true, - child: BrowserBottomAppBar( - displayedSheet: displayedSheet, - showContextualToolbar: showContextualToolbar, - showQuickTabSwitcherBar: showQuickTabSwitcherBar, - ), + child: switch (tabBarPosition) { + TabBarPosition.top => BrowserTopAppBar( + showMainToolbar: showMainToolbar, + showContextualToolbar: showContextualToolbar, + showQuickTabSwitcherBar: showQuickTabSwitcherBar, + ), + TabBarPosition.bottom => BrowserBottomAppBar( + showMainToolbar: showMainToolbar, + displayedSheet: displayedSheet, + showContextualToolbar: showContextualToolbar, + showQuickTabSwitcherBar: showQuickTabSwitcherBar, + ), + }, ); } } @@ -191,7 +218,13 @@ class BrowserScreen extends HookConsumerWidget { final overlayController = useOverlayPortalController(); - final showContextualAppBar = ref.watch( + final tabBarPosition = ref.watch( + generalSettingsWithDefaultsProvider.select( + (value) => value.tabBarPosition, + ), + ); + + final showContextualToolbar = ref.watch( generalSettingsWithDefaultsProvider.select( (value) => value.tabBarShowContextualBar, ), @@ -203,10 +236,14 @@ class BrowserScreen extends HookConsumerWidget { ), ); - final showAppBar = useValueNotifier(true); + final displayAppBar = useValueNotifier(true); + final removeTopAppBar = useState(false); ref.listen(tabBarDismissableControllerProvider, (previous, next) { - showAppBar.value = !next; + displayAppBar.value = !next; + if (tabBarPosition == TabBarPosition.bottom) { + removeTopAppBar.value = next; + } }); ref.listen(overlayControllerProvider, (previous, next) { @@ -244,6 +281,8 @@ class BrowserScreen extends HookConsumerWidget { final sheetController = useState(null); + final pointerMoveEventsController = useStreamController(); + return PopScope( //We need this for BackButtonListener to work downstream //No direct pop result will be handled here @@ -256,23 +295,50 @@ class BrowserScreen extends HookConsumerWidget { //This causes issues with a non dismissable barrier pushed, we ahve our own barrier and this does seem to have issues when dismissing, so disable it completely return null; }, - body: Column( - children: [ - _TabBar( - showAppBar: showAppBar, - sheetController: sheetController, - showContextualToolbar: showContextualAppBar, - showQuickTabSwitcherBar: showQuickTabSwitcherBar, - ), - Expanded( - child: _Browser( - overlayController: overlayController, - sheetController: sheetController, - showAppBar: showAppBar, - tabInFullScreen: tabInFullScreen, - ), - ), - ], + appBar: (tabBarPosition == TabBarPosition.top) + ? PreferredSize( + preferredSize: BrowserTopAppBar( + showMainToolbar: true, + showContextualToolbar: showContextualToolbar, + showQuickTabSwitcherBar: showQuickTabSwitcherBar, + ).preferredSize, + child: _TabBar( + tabBarPosition: TabBarPosition.top, + showMainToolbar: true, + displayAppBar: displayAppBar, + sheetController: sheetController, + showContextualToolbar: showContextualToolbar, + showQuickTabSwitcherBar: showQuickTabSwitcherBar, + pointerMoveEvents: null, + ), + ) + : removeTopAppBar.value + ? const PreferredSize( + preferredSize: Size.zero, + child: SizedBox.shrink(), + ) + : null, + bottomNavigationBar: _TabBar( + tabBarPosition: TabBarPosition.bottom, + displayAppBar: displayAppBar, + sheetController: sheetController, + showMainToolbar: tabBarPosition == TabBarPosition.bottom, + showContextualToolbar: showContextualToolbar, + showQuickTabSwitcherBar: showQuickTabSwitcherBar, + pointerMoveEvents: pointerMoveEventsController.stream, + ), + body: _Browser( + overlayController: overlayController, + sheetController: sheetController, + displayAppBar: displayAppBar, + tabInFullScreen: tabInFullScreen, + pointerMoveEventSink: pointerMoveEventsController.sink, + bottomAppBarSize: BrowserBottomAppBar( + showMainToolbar: false, + showContextualToolbar: showContextualToolbar, + showQuickTabSwitcherBar: showQuickTabSwitcherBar, + displayedSheet: null, + ).preferredSize, ), floatingActionButton: BrowserFab(), ), @@ -286,15 +352,19 @@ class _Browser extends HookConsumerWidget { final OverlayPortalController overlayController; final ValueNotifier sheetController; - final ValueNotifier showAppBar; + final ValueNotifier displayAppBar; + final StreamSink pointerMoveEventSink; + final Size bottomAppBarSize; final bool tabInFullScreen; const _Browser({ required this.overlayController, required this.sheetController, - required this.showAppBar, + required this.displayAppBar, required this.tabInFullScreen, + required this.pointerMoveEventSink, + required this.bottomAppBarSize, }); @override @@ -363,6 +433,7 @@ class _Browser extends HookConsumerWidget { child: _ViewUrlSheet( initialTabState: parameter.tabState, maxChildSize: relativeSafeArea, + bottomAppBarSize: bottomAppBarSize, ), ), }; @@ -453,7 +524,7 @@ class _Browser extends HookConsumerWidget { //Make sure app bar is visible if (!ref.read(tabBarDismissableControllerProvider)) { - showAppBar.value = true; + displayAppBar.value = true; } if (tabState?.isLoading == true) { @@ -530,6 +601,7 @@ class _Browser extends HookConsumerWidget { child: _BrowserView( sheetDisplayed: sheetController.value != null, isFullscreen: tabInFullScreen, + pointerMoveEventSink: pointerMoveEventSink, ), ), ), @@ -542,10 +614,12 @@ class _Browser extends HookConsumerWidget { class _BrowserView extends StatelessWidget { final bool sheetDisplayed; final bool isFullscreen; + final StreamSink? pointerMoveEventSink; const _BrowserView({ required this.sheetDisplayed, required this.isFullscreen, + this.pointerMoveEventSink, }); @override @@ -559,7 +633,7 @@ class _BrowserView extends StatelessWidget { left: !isFullscreen, child: Stack( children: [ - const BrowserView(), + BrowserView(pointerMoveEventSink: pointerMoveEventSink), Positioned( bottom: 0, left: 0, @@ -627,8 +701,13 @@ class _BrowserView extends StatelessWidget { class _ViewUrlSheet extends HookConsumerWidget { final double maxChildSize; final TabState initialTabState; + final Size bottomAppBarSize; - const _ViewUrlSheet({required this.initialTabState, this.maxChildSize = 1.0}); + const _ViewUrlSheet({ + required this.initialTabState, + required this.bottomAppBarSize, + this.maxChildSize = 1.0, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -666,6 +745,7 @@ class _ViewUrlSheet extends HookConsumerWidget { } }, initialHeight: initialHeight, + bottomAppBarHeight: bottomAppBarSize.height, ), ); }, 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 27d32bf8..d43c38e0 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 @@ -44,35 +44,114 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/widget 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'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tabs_action_button.dart'; +import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.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/tor/domain/services/tor_proxy.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/domain/providers.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/presentation/hooks/cached_future.dart'; import 'package:weblibre/presentation/hooks/menu_controller.dart'; import 'package:weblibre/presentation/icons/tor_icons.dart'; import 'package:weblibre/presentation/widgets/selectable_chips.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart'; import 'package:weblibre/utils/ui_helper.dart' as ui_helper; -class BrowserBottomAppBar extends HookConsumerWidget { +class BrowserTopAppBar extends HookConsumerWidget { + final bool showMainToolbar; final bool showContextualToolbar; final bool showQuickTabSwitcherBar; - const BrowserBottomAppBar({ + late final BrowserTabBar _tabBar; + late final _size = Size.fromHeight(_tabBar.getToolbarHeight()); + + BrowserTopAppBar({ + required this.showMainToolbar, + required this.showContextualToolbar, + required this.showQuickTabSwitcherBar, + }) { + _tabBar = BrowserTabBar( + showMainToolbar: showMainToolbar, + displayedSheet: null, + showContextualToolbar: false, + showQuickTabSwitcherBar: false, + showMainToolbarNavigationButton: !showContextualToolbar, + showMainToolbarTabsCount: !showContextualToolbar, + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return SafeArea( + child: SizedBox(height: preferredSize.height, child: _tabBar), + ); + } + + Size get preferredSize => _size; +} + +class BrowserBottomAppBar extends HookConsumerWidget { + final bool showMainToolbar; + final bool showContextualToolbar; + final bool showQuickTabSwitcherBar; + final Sheet? displayedSheet; + + late final BrowserTabBar _tabBar; + late final _size = Size.fromHeight(_tabBar.getToolbarHeight()); + + BrowserBottomAppBar({ + required this.showMainToolbar, required this.displayedSheet, required this.showContextualToolbar, required this.showQuickTabSwitcherBar, - }); + }) { + _tabBar = BrowserTabBar( + displayedSheet: displayedSheet, + showMainToolbar: showMainToolbar, + showContextualToolbar: showContextualToolbar, + showQuickTabSwitcherBar: showQuickTabSwitcherBar, + showMainToolbarNavigationButton: !showContextualToolbar, + showMainToolbarTabsCount: !showContextualToolbar, + ); + } + @override + Widget build(BuildContext context, WidgetRef ref) { + return BottomAppBar( + height: _size.height, + padding: EdgeInsets.zero, + child: _tabBar, + ); + } + + Size get preferredSize => _size; +} + +class BrowserTabBar extends HookConsumerWidget { + final bool showMainToolbar; + final bool showContextualToolbar; + final bool showQuickTabSwitcherBar; final Sheet? displayedSheet; + final bool showMainToolbarTabsCount; + final bool showMainToolbarNavigationButton; + + const BrowserTabBar({ + required this.showMainToolbar, + required this.displayedSheet, + required this.showContextualToolbar, + required this.showQuickTabSwitcherBar, + required this.showMainToolbarTabsCount, + required this.showMainToolbarNavigationButton, + }); + static const contextualToolabarHeight = 54.0; static const quickTabSwitcherHeight = 48.0; bool get displayAppBar => - !showContextualToolbar || displayedSheet is! ViewTabsSheet; + showMainToolbar && + (!showContextualToolbar || displayedSheet is! ViewTabsSheet); bool get displayQuickTabSwitcher => showQuickTabSwitcherBar && displayedSheet is! ViewTabsSheet; @@ -113,67 +192,68 @@ class BrowserBottomAppBar extends HookConsumerWidget { final dragStartPosition = useRef(Offset.zero); - return BottomAppBar( - height: getToolbarHeight(), - padding: EdgeInsets.zero, - child: GestureDetector( - onTap: () { - if (displayedSheet case EditUrlSheet()) { - ref.read(bottomSheetControllerProvider.notifier).requestDismiss(); - return; - } + final toolbarHeight = useMemoized(() => getToolbarHeight()); - final tabState = ref.read(selectedTabStateProvider); + return GestureDetector( + onTap: () { + if (displayedSheet case EditUrlSheet()) { + ref.read(bottomSheetControllerProvider.notifier).requestDismiss(); + return; + } - if (tabState != null) { - ref - .read(bottomSheetControllerProvider.notifier) - .show(EditUrlSheet(tabState: tabState)); - } - }, - onHorizontalDragStart: (details) { - dragStartPosition.value = details.globalPosition; - }, - onHorizontalDragEnd: (details) async { - final distance = dragStartPosition.value - details.globalPosition; + final tabState = ref.read(selectedTabStateProvider); - if (distance.dx.abs() > 50 && distance.dy.abs() < 20) { - final selectedTab = ref.read(selectedTabProvider); - final setting = await ref - .read(generalSettingsRepositoryProvider.notifier) - .fetchSettings(); + if (tabState != null) { + ref + .read(bottomSheetControllerProvider.notifier) + .show(EditUrlSheet(tabState: tabState)); + } + }, + onHorizontalDragStart: (details) { + dragStartPosition.value = details.globalPosition; + }, + onHorizontalDragEnd: (details) async { + final distance = dragStartPosition.value - details.globalPosition; - if (selectedTab != null) { - switch (setting.tabBarSwipeAction) { - case TabBarSwipeAction.switchLastOpened: + if (distance.dx.abs() > 50 && distance.dy.abs() < 20) { + final selectedTab = ref.read(selectedTabProvider); + final setting = await ref + .read(generalSettingsRepositoryProvider.notifier) + .fetchSettings(); + + if (selectedTab != null) { + switch (setting.tabBarSwipeAction) { + case TabBarSwipeAction.switchLastOpened: + await ref + .read(tabRepositoryProvider.notifier) + .selectPreviouslyOpenedTab(selectedTab); + case TabBarSwipeAction.navigateOrderedTabs: + if (distance.dx < 0) { await ref .read(tabRepositoryProvider.notifier) - .selectPreviouslyOpenedTab(selectedTab); - case TabBarSwipeAction.navigateOrderedTabs: - if (distance.dx < 0) { - await ref - .read(tabRepositoryProvider.notifier) - .selectPreviousTab(selectedTab); - } else { - await ref - .read(tabRepositoryProvider.notifier) - .selectNextTab(selectedTab); - } - } + .selectPreviousTab(selectedTab); + } else { + await ref + .read(tabRepositoryProvider.notifier) + .selectNextTab(selectedTab); + } } - } else if (distance.dy < 20 && distance.dx.abs() < 15) { - ref.read(tabBarDismissableControllerProvider.notifier).dismiss(); } - }, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (showQuickTabSwitcherBar) - Visibility( - visible: displayQuickTabSwitcher, - maintainState: true, - child: QuickTabSwitcher(), - ), + } else if (distance.dy < (toolbarHeight / 3) && + distance.dx.abs() < 15) { + ref.read(tabBarDismissableControllerProvider.notifier).dismiss(); + } + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (showQuickTabSwitcherBar) + Visibility( + visible: displayQuickTabSwitcher, + maintainState: true, + child: QuickTabSwitcher(), + ), + if (showMainToolbar) Visibility( visible: displayAppBar, maintainState: true, @@ -269,29 +349,40 @@ class BrowserBottomAppBar extends HookConsumerWidget { ), ), ), - if (!showContextualToolbar) + if (showMainToolbarTabsCount) TabsCountButton( selectedTabId: selectedTabId, displayedSheet: displayedSheet, showLongPressMenu: true, ), - if (!showContextualToolbar) - NavigationMenuButton(selectedTabId: selectedTabId), + if (showMainToolbarNavigationButton) + NavigationMenuButton( + selectedTabId: selectedTabId, + showNavigationButtons: true, + ), ], ), ), - if (showContextualToolbar) - ContextualToolbar( - selectedTabId: selectedTabId, - displayedSheet: displayedSheet, - ), - ], - ), + if (showContextualToolbar) + ContextualToolbar( + selectedTabId: selectedTabId, + displayedSheet: displayedSheet, + ), + ], ), ); } } +typedef _QuickTabItem = ({ + Color? color, + String id, + bool isPrivate, + bool isHistory, + String title, + Uri url, +}); + class ContextualToolbar extends HookConsumerWidget { const ContextualToolbar({ super.key, @@ -333,7 +424,10 @@ class ContextualToolbar extends HookConsumerWidget { displayedSheet: displayedSheet, showLongPressMenu: false, ), - NavigationMenuButton(selectedTabId: selectedTabId), + NavigationMenuButton( + selectedTabId: selectedTabId, + showNavigationButtons: false, + ), ], ); } @@ -342,7 +436,16 @@ class ContextualToolbar extends HookConsumerWidget { class QuickTabSwitcher extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final tabStates = ref.watch(fifoTabStatesProvider).value; + final tabStates = ref.watch(fifoTabStatesProvider); + final historyAsync = useCachedFuture(() { + if (tabStates.value.length <= 1) { + return ref + .read(historyRepositoryProvider.notifier) + .getVisitsPaginated(count: 25); + } + + return Future.value([]); + }, [tabStates.value.length <= 1]); final chipScrollController = useScrollController(); @@ -351,19 +454,19 @@ class QuickTabSwitcher extends HookConsumerWidget { child: SizedBox( height: 48, width: double.maxFinite, - child: SelectableChips( + child: SelectableChips<_QuickTabItem, _QuickTabItem, String>( enableDelete: false, scrollController: chipScrollController, - itemId: (item) => item.$1.id, + itemId: (item) => item.id, itemLabel: (item) { return Row( mainAxisSize: MainAxisSize.min, children: [ ConstrainedBox( constraints: const BoxConstraints(maxWidth: 64), - child: Text(item.$1.titleOrAuthority), + child: Text(item.title), ), - if (item.$1.isPrivate) + if (item.isPrivate) const Padding( padding: EdgeInsets.only(left: 8.0), child: Icon( @@ -372,23 +475,57 @@ class QuickTabSwitcher extends HookConsumerWidget { size: 20, ), ), + if (item.isHistory) + const Padding( + padding: EdgeInsets.only(left: 8.0), + child: Icon(MdiIcons.history, size: 20), + ), ], ); }, - itemAvatar: (item) => UrlIcon([item.$1.url], iconSize: 16), - itemBackgroundColor: (item) => item.$2?.color.withValues(alpha: 0.5), + itemAvatar: (item) => UrlIcon([item.url], iconSize: 16), + itemBackgroundColor: (item) => item.color?.withValues(alpha: 0.5), onSelected: (item) async { final animation = chipScrollController.animateTo( 0, duration: const Duration(milliseconds: 200), curve: Curves.easeOutBack, ); - await ref - .read(tabRepositoryProvider.notifier) - .selectTab(item.$1.id); + if (item.isHistory) { + await ref + .read(tabRepositoryProvider.notifier) + .addTab(url: item.url, private: false); + } else { + await ref.read(tabRepositoryProvider.notifier).selectTab(item.id); + } await animation; }, - availableItems: tabStates.skip(1), + availableItems: tabStates.value + .map( + (state) => ( + id: state.$1.id, + title: state.$1.titleOrAuthority, + isPrivate: state.$1.isPrivate, + isHistory: false, + url: state.$1.url, + color: state.$2?.color, + ), + ) + .skip(1) + .followedBy( + (historyAsync.data ?? []).map((state) { + final url = Uri.parse(state.url); + + return ( + id: state.url, + title: state.title ?? url.authority, + isPrivate: false, + isHistory: true, + url: url, + color: null, + ); + }), + ), ), ), ); @@ -430,18 +567,18 @@ class ShareMenuButton extends HookConsumerWidget { } class NavigationMenuButton extends HookConsumerWidget { - const NavigationMenuButton({super.key, required this.selectedTabId}); - final String? selectedTabId; + final bool showNavigationButtons; + + const NavigationMenuButton({ + super.key, + required this.selectedTabId, + required this.showNavigationButtons, + }); @override Widget build(BuildContext context, WidgetRef ref) { final addonService = ref.watch(addonServiceProvider); - final showContextualAppBar = ref.watch( - generalSettingsWithDefaultsProvider.select( - (value) => value.tabBarShowContextualBar, - ), - ); final hamburgerMenuController = useMenuController(); @@ -674,7 +811,7 @@ class NavigationMenuButton extends HookConsumerWidget { child: const Text('Reload'), ), if (selectedTabId != null) const Divider(), - if (selectedTabId != null && !showContextualAppBar) + if (selectedTabId != null && showNavigationButtons) Consumer( builder: (context, ref, child) { final history = ref.watch( diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart index 6e7ff847..e74b9fa6 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart @@ -44,6 +44,7 @@ import 'package:weblibre/features/geckoview/features/browser/domain/providers/li import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_data.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/services/engine_settings_replication.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart'; +import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart'; import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart'; import 'package:weblibre/features/share_intent/domain/entities/shared_content.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; @@ -59,11 +60,13 @@ class BrowserView extends StatefulHookConsumerWidget { final Duration screenshotPeriod; final Duration suggestionTimeout; final Future Function()? postInitializationStep; + final StreamSink? pointerMoveEventSink; const BrowserView({ this.screenshotPeriod = const Duration(seconds: 10), this.suggestionTimeout = const Duration(seconds: 30), this.postInitializationStep, + this.pointerMoveEventSink, }); @override @@ -106,9 +109,9 @@ class _BrowserViewState extends ConsumerState if (settings.historyAutoCleanInterval > Duration.zero) { await GeckoHistoryService().deleteVisitsBetween( - DateTime(0), - DateTime.now().subtract(settings.historyAutoCleanInterval), - ); + DateTime(0), + DateTime.now().subtract(settings.historyAutoCleanInterval), + ); } }); }); @@ -216,74 +219,87 @@ class _BrowserViewState extends ConsumerState }, ); - return Stack( - children: [ - GeckoView( - preInitializationStep: () async { - await ref - .read(eventServiceProvider) - .viewReadyStateEvents - .firstWhere((state) => state == true) - .timeout( - const Duration(seconds: 3), - onTimeout: () { - logger.e( - 'Browser fragement not reported ready, trying to intitialize anyways', - ); - return true; - }, - ); - }, - postInitializationStep: () async { - await widget.postInitializationStep?.call(); - - if (!initializationCompleter.isCompleted) { - const quickActions = QuickActions(); - - //Debounce: https://github.com/flutter/flutter/issues/131121 - DateTime? lastAction; - await quickActions.initialize((type) async { - if (lastAction == null || - DateTime.now().difference(lastAction!) > - const Duration(seconds: 5)) { - if (type == 'new_tab') { - lastAction = DateTime.now(); - - final router = await ref.read(routerProvider.future); - const route = SearchRoute(tabType: TabType.regular); - - await router.push(route.location); - } else if (type == 'new_private_tab') { - lastAction = DateTime.now(); - - final router = await ref.read(routerProvider.future); - const route = SearchRoute(tabType: TabType.private); - - await router.push(route.location); - } else { - throw UnimplementedError(); - } - } - }); - - await quickActions.setShortcutItems([ - //TODO: add icons - const ShortcutItem(type: 'new_tab', localizedTitle: 'New Tab'), - const ShortcutItem( - type: 'new_private_tab', - localizedTitle: 'New Private Tab', - ), - ]); - - initializationCompleter.complete(); + return Listener( + behavior: HitTestBehavior.translucent, + onPointerMove: (widget.pointerMoveEventSink != null) + ? (event) { + if (event.down) { + widget.pointerMoveEventSink!.add(event.localDelta); + } } - }, - ), - if (!hasTab) - Positioned.fill( - child: SizedBox.expand(child: Container(color: Colors.grey[800])), + : null, + child: Stack( + children: [ + GeckoView( + preInitializationStep: () async { + await ref + .read(eventServiceProvider) + .viewReadyStateEvents + .firstWhere((state) => state == true) + .timeout( + const Duration(seconds: 3), + onTimeout: () { + logger.e( + 'Browser fragement not reported ready, trying to intitialize anyways', + ); + return true; + }, + ); + }, + postInitializationStep: () async { + await widget.postInitializationStep?.call(); + + if (!initializationCompleter.isCompleted) { + const quickActions = QuickActions(); + + //Debounce: https://github.com/flutter/flutter/issues/131121 + DateTime? lastAction; + await quickActions.initialize((type) async { + if (lastAction == null || + DateTime.now().difference(lastAction!) > + const Duration(seconds: 5)) { + if (type == 'new_tab') { + lastAction = DateTime.now(); + + final router = await ref.read(routerProvider.future); + const route = SearchRoute(tabType: TabType.regular); + + await router.push(route.location); + } else if (type == 'new_private_tab') { + lastAction = DateTime.now(); + + final router = await ref.read(routerProvider.future); + const route = SearchRoute(tabType: TabType.private); + + await router.push(route.location); + } else { + throw UnimplementedError(); + } + } + }); + + await quickActions.setShortcutItems([ + //TODO: add icons + const ShortcutItem( + type: 'new_tab', + localizedTitle: 'New Tab', + ), + const ShortcutItem( + type: 'new_private_tab', + localizedTitle: 'New Private Tab', + ), + ]); + + initializationCompleter.complete(); + } + }, ), - ], + if (!hasTab) + Positioned.fill( + child: SizedBox.expand(child: Container(color: Colors.grey[800])), + ), + ], + ), ); } diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart index e7d72324..dc39e079 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart @@ -48,6 +48,7 @@ class ViewTabSheetWidget extends HookConsumerWidget { final DraggableScrollableController draggableScrollableController; final VoidCallback onClose; final double initialHeight; + final double bottomAppBarHeight; const ViewTabSheetWidget({ required this.initialTabState, @@ -55,6 +56,7 @@ class ViewTabSheetWidget extends HookConsumerWidget { required this.draggableScrollableController, required this.onClose, required this.initialHeight, + required this.bottomAppBarHeight, }); @override @@ -79,7 +81,7 @@ class ViewTabSheetWidget extends HookConsumerWidget { final totalHeight = headerBox.size.height + textBox.size.height + - kToolbarHeight + + bottomAppBarHeight + MediaQuery.of(context).viewInsets.bottom; final relative = (totalHeight / MediaQuery.of(context).size.height) diff --git a/app/lib/features/settings/presentation/screens/general_settings.dart b/app/lib/features/settings/presentation/screens/general_settings.dart index b5223507..92916ebc 100644 --- a/app/lib/features/settings/presentation/screens/general_settings.dart +++ b/app/lib/features/settings/presentation/screens/general_settings.dart @@ -362,6 +362,55 @@ class GeneralSettingsScreen extends HookConsumerWidget { ); }, ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ListTile( + title: Text('Tab Bar Position'), + leading: Icon(MdiIcons.dockWindow), + contentPadding: EdgeInsets.zero, + ), + RadioGroup( + groupValue: generalSettings.tabBarPosition, + onChanged: (value) async { + if (value != null) { + await ref + .read( + saveGeneralSettingsControllerProvider.notifier, + ) + .save( + (currentSettings) => currentSettings.copyWith + .tabBarPosition(value), + ); + } + }, + child: const Column( + children: [ + RadioListTile.adaptive( + value: TabBarPosition.top, + title: Text('Top'), + subtitle: Text( + 'Persistent tab bar without auto-hide', + ), + ), + RadioListTile.adaptive( + value: TabBarPosition.bottom, + title: Text('Bottom'), + subtitle: Text('Tab bar with auto-hide support'), + ), + ], + ), + ), + ], + ), + ), + SwitchListTile.adaptive( title: const Text('Show Contextual Tab Bar'), subtitle: const Text( diff --git a/app/lib/features/user/data/models/general_settings.dart b/app/lib/features/user/data/models/general_settings.dart index 35cd79cf..727d7136 100644 --- a/app/lib/features/user/data/models/general_settings.dart +++ b/app/lib/features/user/data/models/general_settings.dart @@ -38,6 +38,8 @@ enum TabBarSwipeAction { switchLastOpened, navigateOrderedTabs } enum TabIntentOpenSetting { regular, private, ask } +enum TabBarPosition { top, bottom } + enum DeleteBrowsingDataType { tabs('Open tabs'), history('Browsing history'), @@ -74,6 +76,7 @@ class GeneralSettings with FastEquatable { final bool tabBarReaderView; final bool tabBarShowContextualBar; final bool tabBarShowQuickTabSwitcherBar; + final TabBarPosition tabBarPosition; GeneralSettings({ required this.themeMode, @@ -94,6 +97,7 @@ class GeneralSettings with FastEquatable { required this.tabBarReaderView, required this.tabBarShowContextualBar, required this.tabBarShowQuickTabSwitcherBar, + required this.tabBarPosition, }); GeneralSettings.withDefaults({ @@ -115,6 +119,7 @@ class GeneralSettings with FastEquatable { bool? tabBarReaderView, bool? tabBarShowContextualBar, bool? tabBarShowQuickTabSwitcherBar, + TabBarPosition? tabBarPosition, }) : themeMode = themeMode ?? ThemeMode.dark, enableReadability = enableReadability ?? true, enforceReadability = enforceReadability ?? false, @@ -134,7 +139,8 @@ class GeneralSettings with FastEquatable { tabViewBottomSheet = tabViewBottomSheet ?? false, tabBarReaderView = tabBarReaderView ?? false, tabBarShowContextualBar = tabBarShowContextualBar ?? true, - tabBarShowQuickTabSwitcherBar = tabBarShowQuickTabSwitcherBar ?? true; + tabBarShowQuickTabSwitcherBar = tabBarShowQuickTabSwitcherBar ?? true, + tabBarPosition = tabBarPosition ?? TabBarPosition.bottom; factory GeneralSettings.fromJson(Map json) => _$GeneralSettingsFromJson(json); @@ -161,5 +167,6 @@ class GeneralSettings with FastEquatable { tabBarReaderView, tabBarShowContextualBar, tabBarShowQuickTabSwitcherBar, + tabBarPosition, ]; } diff --git a/app/lib/features/user/data/models/general_settings.g.dart b/app/lib/features/user/data/models/general_settings.g.dart index 15512747..a77f7c9e 100644 --- a/app/lib/features/user/data/models/general_settings.g.dart +++ b/app/lib/features/user/data/models/general_settings.g.dart @@ -51,6 +51,8 @@ abstract class _$GeneralSettingsCWProxy { bool tabBarShowQuickTabSwitcherBar, ); + GeneralSettings tabBarPosition(TabBarPosition tabBarPosition); + /// Creates a new instance with the provided field values. /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`. /// @@ -77,6 +79,7 @@ abstract class _$GeneralSettingsCWProxy { bool tabBarReaderView, bool tabBarShowContextualBar, bool tabBarShowQuickTabSwitcherBar, + TabBarPosition tabBarPosition, }); } @@ -162,6 +165,10 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { bool tabBarShowQuickTabSwitcherBar, ) => call(tabBarShowQuickTabSwitcherBar: tabBarShowQuickTabSwitcherBar); + @override + GeneralSettings tabBarPosition(TabBarPosition tabBarPosition) => + call(tabBarPosition: tabBarPosition); + @override /// Creates a new instance with the provided field values. /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`. @@ -189,6 +196,7 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { Object? tabBarReaderView = const $CopyWithPlaceholder(), Object? tabBarShowContextualBar = const $CopyWithPlaceholder(), Object? tabBarShowQuickTabSwitcherBar = const $CopyWithPlaceholder(), + Object? tabBarPosition = const $CopyWithPlaceholder(), }) { return GeneralSettings( themeMode: themeMode == const $CopyWithPlaceholder() || themeMode == null @@ -295,6 +303,12 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { ? _value.tabBarShowQuickTabSwitcherBar // ignore: cast_nullable_to_non_nullable : tabBarShowQuickTabSwitcherBar as bool, + tabBarPosition: + tabBarPosition == const $CopyWithPlaceholder() || + tabBarPosition == null + ? _value.tabBarPosition + // ignore: cast_nullable_to_non_nullable + : tabBarPosition as TabBarPosition, ); } } @@ -351,6 +365,10 @@ GeneralSettings _$GeneralSettingsFromJson( tabBarReaderView: json['tabBarReaderView'] as bool?, tabBarShowContextualBar: json['tabBarShowContextualBar'] as bool?, tabBarShowQuickTabSwitcherBar: json['tabBarShowQuickTabSwitcherBar'] as bool?, + tabBarPosition: $enumDecodeNullable( + _$TabBarPositionEnumMap, + json['tabBarPosition'], + ), ); Map _$GeneralSettingsToJson( @@ -381,6 +399,7 @@ Map _$GeneralSettingsToJson( 'tabBarReaderView': instance.tabBarReaderView, 'tabBarShowContextualBar': instance.tabBarShowContextualBar, 'tabBarShowQuickTabSwitcherBar': instance.tabBarShowQuickTabSwitcherBar, + 'tabBarPosition': _$TabBarPositionEnumMap[instance.tabBarPosition]!, }; const _$ThemeModeEnumMap = { @@ -422,3 +441,8 @@ const _$TabBarSwipeActionEnumMap = { TabBarSwipeAction.switchLastOpened: 'switchLastOpened', TabBarSwipeAction.navigateOrderedTabs: 'navigateOrderedTabs', }; + +const _$TabBarPositionEnumMap = { + TabBarPosition.top: 'top', + TabBarPosition.bottom: 'bottom', +}; diff --git a/app/lib/features/user/domain/repositories/general_settings.dart b/app/lib/features/user/domain/repositories/general_settings.dart index 199da009..5b1e69de 100644 --- a/app/lib/features/user/domain/repositories/general_settings.dart +++ b/app/lib/features/user/domain/repositories/general_settings.dart @@ -114,6 +114,10 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository { ), 'tabBarShowQuickTabSwitcherBar': settings['tabBarShowQuickTabSwitcherBar'] ?.readAs(DriftSqlType.bool, db.typeMapping), + 'tabBarPosition': settings['tabBarPosition']?.readAs( + DriftSqlType.string, + db.typeMapping, + ), }); } diff --git a/app/lib/features/user/domain/repositories/general_settings.g.dart b/app/lib/features/user/domain/repositories/general_settings.g.dart index cfabf09a..efefc8ab 100644 --- a/app/lib/features/user/domain/repositories/general_settings.g.dart +++ b/app/lib/features/user/domain/repositories/general_settings.g.dart @@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider } String _$generalSettingsRepositoryHash() => - r'4014986c9510d6d7210c5c16d814f6b018a1f93c'; + r'09542387844db25334384af115c538348b0ad33f'; abstract class _$GeneralSettingsRepository extends $StreamNotifier {