From 1fc8cc5fab9a748bd25028b1c933bc38858960ab Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Thu, 5 Mar 2026 07:54:45 +0100 Subject: [PATCH] decouple app bar related widgets from logic --- .../browser_modules/app_bar_title.dart | 235 +++--- .../browser_modules/bottom_app_bar.dart | 674 +++++++++++------- .../widgets/navigation_buttons.dart | 139 ++-- .../widgets/tabs_action_button.dart | 130 ++-- 4 files changed, 721 insertions(+), 457 deletions(-) diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart index 6c7ec183..81d96f4a 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart @@ -18,7 +18,6 @@ * along with this program. If not, see . */ 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:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; @@ -27,6 +26,7 @@ import 'package:weblibre/core/design/app_colors.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/extensions/uri.dart'; import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/tab.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/providers/site_settings_badge_provider.dart'; @@ -35,14 +35,11 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/widget import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; -class CompactAppBarTitle extends HookConsumerWidget { +class CompactAppBarTitle extends ConsumerWidget { const CompactAppBarTitle({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - final appColors = AppColors.of(context); - final tabState = ref.watch(selectedTabStateProvider); final isTabTuneledAsync = ref.watch(isTabTunneledProvider(tabState?.id)); final showSiteSettingsBadge = ref.watch( @@ -53,42 +50,60 @@ class CompactAppBarTitle extends HookConsumerWidget { return const SizedBox.shrink(); } - final icon = useMemoized(() { - if (tabState.url.isHttp) { - return Icon( - MdiIcons.lockOff, - color: Theme.of(context).colorScheme.error, - size: 16, - ); - } else if (tabState.readerableState.active) { - return const Icon(MdiIcons.lockMinus, size: 16); - } else if (!tabState.securityInfoState.secure) { - return Icon( - MdiIcons.lockAlert, - color: Theme.of(context).colorScheme.errorContainer, - size: 16, - ); - } else if (!tabState.isLoading) { - return const Icon(MdiIcons.lock, size: 16); - } else { - return const Icon(MdiIcons.timerSand, size: 16); - } - }, [tabState]); + return CompactAppBarTitleView( + tabState: tabState, + isTabTunneled: + isTabTuneledAsync.hasValue && isTabTuneledAsync.value == true, + showSiteSettingsBadge: showSiteSettingsBadge, + onSiteSettingsTap: () { + ref + .read(bottomSheetControllerProvider.notifier) + .show(SiteSettingsSheet(tabState: tabState)); + }, + onTitleTap: () async { + await SearchRoute( + tabId: tabState.id, + searchText: _searchTextForTab(tabState), + tabType: tabState.tabMode.toTabType(), + ).push(context); + }, + ); + } +} + +class CompactAppBarTitleView extends StatelessWidget { + const CompactAppBarTitleView({ + super.key, + required this.tabState, + required this.isTabTunneled, + required this.showSiteSettingsBadge, + required this.onSiteSettingsTap, + required this.onTitleTap, + this.tabIcon, + }); + + final TabState tabState; + final bool isTabTunneled; + final bool showSiteSettingsBadge; + final VoidCallback onSiteSettingsTap; + final VoidCallback onTitleTap; + final Widget? tabIcon; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final appColors = AppColors.of(context); return Row( children: [ ToolbarButton( - onTap: () { - ref - .read(bottomSheetControllerProvider.notifier) - .show(SiteSettingsSheet(tabState: tabState)); - }, + onTap: onSiteSettingsTap, child: Padding( padding: const EdgeInsets.only(right: 4.0), child: Stack( clipBehavior: Clip.none, children: [ - TabIcon(tabState: tabState, iconSize: 24), + tabIcon ?? TabIcon(tabState: tabState, iconSize: 24), Positioned( top: -4, right: -4, @@ -106,19 +121,7 @@ class CompactAppBarTitle extends HookConsumerWidget { ), Expanded( child: GestureDetector( - onTap: () async { - final searchText = tabState.url.scheme == 'about' - ? '' - : tabState.url.toString(); - - await SearchRoute( - tabId: tabState.id, - searchText: searchText.isEmpty - ? SearchRoute.emptySearchText - : searchText, - tabType: tabState.tabMode.toTabType(), - ).push(context); - }, + onTap: onTitleTap, child: Container( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), decoration: BoxDecoration( @@ -144,12 +147,11 @@ class CompactAppBarTitle extends HookConsumerWidget { ), const SizedBox(width: 4), ], - if (isTabTuneledAsync.hasValue && - isTabTuneledAsync.value == true) ...[ + if (isTabTunneled) ...[ const Icon(MdiIcons.tunnelOutline, size: 16), const SizedBox(width: 4), ], - icon, + _SecurityStatusIcon(tabState: tabState, size: 16), const SizedBox(width: 6), Flexible( child: UriBreadcrumb( @@ -170,14 +172,11 @@ class CompactAppBarTitle extends HookConsumerWidget { } } -class AppBarTitle extends HookConsumerWidget { +class AppBarTitle extends ConsumerWidget { const AppBarTitle({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - final appColors = AppColors.of(context); - final tabState = ref.watch(selectedTabStateProvider); final isTabTuneledAsync = ref.watch(isTabTunneledProvider(tabState?.id)); final showSiteSettingsBadge = ref.watch( @@ -188,42 +187,60 @@ class AppBarTitle extends HookConsumerWidget { return const SizedBox.shrink(); } - final icon = useMemoized(() { - if (tabState.url.isHttp) { - return Icon( - MdiIcons.lockOff, - color: Theme.of(context).colorScheme.error, - size: 14, - ); - } else if (tabState.readerableState.active) { - return const Icon(MdiIcons.lockMinus, size: 14); - } else if (!tabState.securityInfoState.secure) { - return Icon( - MdiIcons.lockAlert, - color: Theme.of(context).colorScheme.errorContainer, - size: 14, - ); - } else if (!tabState.isLoading) { - return const Icon(MdiIcons.lock, size: 14); - } else { - return const Icon(MdiIcons.timerSand, size: 14); - } - }, [tabState]); + return AppBarTitleView( + tabState: tabState, + isTabTunneled: + isTabTuneledAsync.hasValue && isTabTuneledAsync.value == true, + showSiteSettingsBadge: showSiteSettingsBadge, + onSiteSettingsTap: () { + ref + .read(bottomSheetControllerProvider.notifier) + .show(SiteSettingsSheet(tabState: tabState)); + }, + onTitleTap: () async { + await SearchRoute( + tabId: tabState.id, + searchText: _searchTextForTab(tabState), + tabType: tabState.tabMode.toTabType(), + ).push(context); + }, + ); + } +} + +class AppBarTitleView extends StatelessWidget { + const AppBarTitleView({ + super.key, + required this.tabState, + required this.isTabTunneled, + required this.showSiteSettingsBadge, + required this.onSiteSettingsTap, + required this.onTitleTap, + this.tabIcon, + }); + + final TabState tabState; + final bool isTabTunneled; + final bool showSiteSettingsBadge; + final VoidCallback onSiteSettingsTap; + final VoidCallback onTitleTap; + final Widget? tabIcon; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final appColors = AppColors.of(context); return Row( children: [ ToolbarButton( - onTap: () { - ref - .read(bottomSheetControllerProvider.notifier) - .show(SiteSettingsSheet(tabState: tabState)); - }, + onTap: onSiteSettingsTap, child: Padding( padding: const EdgeInsets.only(right: 4.0), child: Stack( clipBehavior: Clip.none, children: [ - TabIcon(tabState: tabState, iconSize: 24), + tabIcon ?? TabIcon(tabState: tabState, iconSize: 24), Positioned( top: -4, right: -4, @@ -239,23 +256,9 @@ class AppBarTitle extends HookConsumerWidget { ), ), ), - // Title/URL tap → opens search screen Expanded( child: GestureDetector( - onTap: () async { - // Don't pre-fill for internal URLs - final searchText = tabState.url.scheme == 'about' - ? '' - : tabState.url.toString(); - - await SearchRoute( - tabId: tabState.id, - searchText: searchText.isEmpty - ? SearchRoute.emptySearchText - : searchText, - tabType: tabState.tabMode.toTabType(), - ).push(context); - }, + onTap: onTitleTap, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, @@ -273,7 +276,6 @@ class AppBarTitle extends HookConsumerWidget { style: theme.textTheme.bodyLarge?.copyWith( color: theme.colorScheme.onSurface, ), - // mode: TextScrollMode.bouncing, velocity: const Velocity(pixelsPerSecond: Offset(75, 0)), delayBefore: const Duration(milliseconds: 500), pauseBetween: const Duration(milliseconds: 5000), @@ -302,12 +304,11 @@ class AppBarTitle extends HookConsumerWidget { ), const SizedBox(width: 4), ], - if (isTabTuneledAsync.hasValue && - isTabTuneledAsync.value == true) ...[ + if (isTabTunneled) ...[ const Icon(MdiIcons.tunnelOutline, size: 14), const SizedBox(width: 4), ], - icon, + _SecurityStatusIcon(tabState: tabState, size: 14), const SizedBox(width: 4), Expanded( child: UriBreadcrumb( @@ -328,3 +329,41 @@ class AppBarTitle extends HookConsumerWidget { ); } } + +class _SecurityStatusIcon extends StatelessWidget { + const _SecurityStatusIcon({required this.tabState, required this.size}); + + final TabState tabState; + final double size; + + @override + Widget build(BuildContext context) { + if (tabState.url.isHttp) { + return Icon( + MdiIcons.lockOff, + color: Theme.of(context).colorScheme.error, + size: size, + ); + } else if (tabState.readerableState.active) { + return Icon(MdiIcons.lockMinus, size: size); + } else if (!tabState.securityInfoState.secure) { + return Icon( + MdiIcons.lockAlert, + color: Theme.of(context).colorScheme.errorContainer, + size: size, + ); + } else if (!tabState.isLoading) { + return Icon(MdiIcons.lock, size: size); + } + + return Icon(MdiIcons.timerSand, size: size); + } +} + +String _searchTextForTab(TabState tabState) { + final searchText = tabState.url.scheme == 'about' + ? '' + : tabState.url.toString(); + + return searchText.isEmpty ? SearchRoute.emptySearchText : searchText; +} 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 9bf51a50..38359c2b 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 @@ -27,12 +27,10 @@ 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:nullability/nullability.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/entities/states/tab.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'; @@ -60,7 +58,7 @@ import 'package:weblibre/presentation/hooks/menu_controller.dart'; import 'package:weblibre/presentation/widgets/selectable_chips.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart'; -class BrowserTopAppBar extends HookConsumerWidget { +class BrowserTopAppBar extends StatelessWidget { final bool showMainToolbar; final bool showContextualToolbar; final bool showQuickTabSwitcherBar; @@ -85,7 +83,7 @@ class BrowserTopAppBar extends HookConsumerWidget { } @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { return SafeArea( child: SizedBox(height: preferredSize.height, child: _tabBar), ); @@ -94,7 +92,7 @@ class BrowserTopAppBar extends HookConsumerWidget { Size get preferredSize => _size; } -class BrowserBottomAppBar extends HookConsumerWidget { +class BrowserBottomAppBar extends StatelessWidget { final bool showMainToolbar; final bool showContextualToolbar; final bool showQuickTabSwitcherBar; @@ -121,7 +119,7 @@ class BrowserBottomAppBar extends HookConsumerWidget { } @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final bottomPadding = MediaQuery.of(context).padding.bottom; return Material( @@ -205,8 +203,97 @@ class BrowserTabBar extends HookConsumerWidget { final dragStartPosition = useRef(Offset.zero); - return GestureDetector( - // Tap handling moved to AppBarTitle for split icon/title behavior + final showTabTitle = + selectedTabId != null && displayedSheet is! ViewTabsSheet; + + final backgroundColor = + (settings.showContainerUi && + containerColor != null && + displayedSheet is! ViewTabsSheet) + ? ContainerColors.forAppBar(containerColor) + : null; + + return BrowserTabBarView( + showMainToolbar: showMainToolbar, + showContextualToolbar: showContextualToolbar, + showQuickTabSwitcherBar: showQuickTabSwitcherBar, + displayAppBar: displayAppBar, + displayQuickTabSwitcher: displayQuickTabSwitcher, + backgroundColor: backgroundColor, + title: showTabTitle + ? settings.tabBarLayout == TabBarLayout.compact + ? const CompactAppBarTitle() + : 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, + displayedSheet: displayedSheet, + showLongPressMenu: true, + ), + if (showMainToolbarNavigationButton) + NavigationMenuButton(selectedTabId: selectedTabId), + ], + quickTabSwitcher: QuickTabSwitcher( + quickTabSwitcherMode: quickTabSwitcherMode, + ), + contextualToolbar: ContextualToolbar( + selectedTabId: selectedTabId, + displayedSheet: displayedSheet, + ), onHorizontalDragStart: (details) { dragStartPosition.value = details.globalPosition; }, @@ -262,6 +349,52 @@ class BrowserTabBar extends HookConsumerWidget { .dismiss(); } }, + ); + } +} + +class BrowserTabBarView extends StatelessWidget { + const BrowserTabBarView({ + super.key, + required this.showMainToolbar, + required this.showContextualToolbar, + required this.showQuickTabSwitcherBar, + required this.displayAppBar, + required this.displayQuickTabSwitcher, + required this.backgroundColor, + required this.title, + required this.actions, + required this.quickTabSwitcher, + required this.contextualToolbar, + this.onHorizontalDragStart, + this.onHorizontalDragEnd, + this.onVerticalDragStart, + this.onVerticalDragEnd, + }); + + final bool showMainToolbar; + final bool showContextualToolbar; + final bool showQuickTabSwitcherBar; + final bool displayAppBar; + final bool displayQuickTabSwitcher; + final Color? backgroundColor; + final Widget? title; + final List actions; + final Widget quickTabSwitcher; + final Widget contextualToolbar; + final GestureDragStartCallback? onHorizontalDragStart; + final GestureDragEndCallback? onHorizontalDragEnd; + final GestureDragStartCallback? onVerticalDragStart; + final GestureDragEndCallback? onVerticalDragEnd; + + @override + Widget build(BuildContext context) { + return GestureDetector( + // Tap handling moved to AppBarTitle for split icon/title behavior + onHorizontalDragStart: onHorizontalDragStart, + onHorizontalDragEnd: onHorizontalDragEnd, + onVerticalDragStart: onVerticalDragStart, + onVerticalDragEnd: onVerticalDragEnd, child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -269,9 +402,7 @@ class BrowserTabBar extends HookConsumerWidget { Visibility( visible: displayQuickTabSwitcher, maintainState: true, - child: QuickTabSwitcher( - quickTabSwitcherMode: quickTabSwitcherMode, - ), + child: quickTabSwitcher, ), if (showMainToolbar) Visibility( @@ -283,95 +414,19 @@ class BrowserTabBar extends HookConsumerWidget { titleSpacing: 0.0, leadingWidth: 40.0, toolbarHeight: kToolbarHeight, - backgroundColor: - (settings.showContainerUi && - containerColor != null && - displayedSheet is! ViewTabsSheet) - ? ContainerColors.forAppBar(containerColor) - : null, - title: - (selectedTabId != null && displayedSheet is! ViewTabsSheet) - ? settings.tabBarLayout == TabBarLayout.compact - ? const CompactAppBarTitle() - : const AppBarTitle() - : null, - actions: [ - if (selectedTabId != null && displayedSheet is! ViewTabsSheet) - 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, - displayedSheet: displayedSheet, - showLongPressMenu: true, - ), - if (showMainToolbarNavigationButton) - NavigationMenuButton(selectedTabId: selectedTabId), - ], + backgroundColor: backgroundColor, + title: title, + actions: actions, ), ), - if (showContextualToolbar) - ContextualToolbar( - selectedTabId: selectedTabId, - displayedSheet: displayedSheet, - ), + if (showContextualToolbar) contextualToolbar, ], ), ); } } -class _QuickTabItem with FastEquatable { +class QuickTabSwitcherItem with FastEquatable { final Color? color; final String id; final TabMode tabMode; @@ -379,9 +434,9 @@ class _QuickTabItem with FastEquatable { final bool isPinned; final String title; final Uri url; - final TabState? tabState; + final Widget avatar; - _QuickTabItem({ + QuickTabSwitcherItem({ required this.color, required this.id, required this.tabMode, @@ -389,7 +444,7 @@ class _QuickTabItem with FastEquatable { required this.isPinned, required this.title, required this.url, - required this.tabState, + required this.avatar, }); @override @@ -401,7 +456,7 @@ class _QuickTabItem with FastEquatable { isPinned, title, url, - tabState, + avatar, ]; } @@ -419,35 +474,71 @@ class ContextualToolbar extends HookConsumerWidget { 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 (tabState?.historyState.canGoBack == true || - tabState?.isLoading == true) - NavigateBackButton( - selectedTabId: selectedTabId, - isLoading: tabState?.isLoading ?? false, - ) + if (canGoBack) + backButton else IconButton( - onPressed: () async { - await BookmarkListRoute( - entryGuid: BookmarkRoot.root.id, - ).push(context); - }, + onPressed: onBookmarksTap, icon: const Icon(MdiIcons.bookmarkMultiple), ), - if (tabState?.historyState.canGoForward == true) - NavigateForwardButton(selectedTabId: selectedTabId) - else - ShareMenuButton(selectedTabId: selectedTabId), - const AddTabButton(), - TabsCountButton( - selectedTabId: selectedTabId, - displayedSheet: displayedSheet, - showLongPressMenu: false, - ), - NavigationMenuButton(selectedTabId: selectedTabId), + if (canGoForward) forwardButton else shareButton, + addTabButton, + tabsCountButton, + navigationButton, ], ); } @@ -460,7 +551,6 @@ class QuickTabSwitcher extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final appColors = AppColors.of(context); final showIsolatedTabUi = ref.watch( generalSettingsWithDefaultsProvider.select((s) => s.showIsolatedTabUi), ); @@ -484,8 +574,8 @@ class QuickTabSwitcher extends HookConsumerWidget { .watch(quickTabSwitcherHistorySuggestionsProvider(quickTabSwitcherMode)) .value; final availableItems = tabStates.value - .map<_QuickTabItem>( - (state) => _QuickTabItem( + .map( + (state) => QuickTabSwitcherItem( id: state.$1.id, title: state.$1.titleOrAuthority, tabMode: state.$1.tabMode, @@ -493,14 +583,14 @@ class QuickTabSwitcher extends HookConsumerWidget { isPinned: pinnedTabIds?.contains(state.$1.id) ?? false, url: state.$1.url, color: state.$2?.color, - tabState: state.$1, + avatar: TabIcon(tabState: state.$1, iconSize: 20), ), ) .followedBy( - (historySuggestions ?? []).map<_QuickTabItem>((state) { + (historySuggestions ?? []).map((state) { final url = Uri.parse(state.url); - return _QuickTabItem( + return QuickTabSwitcherItem( id: state.url, title: state.title ?? url.authority, tabMode: TabMode.regular, @@ -508,136 +598,164 @@ class QuickTabSwitcher extends HookConsumerWidget { isPinned: false, url: url, color: null, - tabState: null, + avatar: UrlIcon([url], iconSize: 20), ); }), ) .toList(); + final chipScrollController = useScrollController(); + + return QuickTabSwitcherView( + availableItems: availableItems, + scrollController: chipScrollController, + showTitles: showTitles, + showIsolatedTabUi: showIsolatedTabUi, + onSelected: (item) async { + final animation = chipScrollController.animateTo( + 0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutBack, + ); + if (item.isHistory) { + await ref + .read(tabRepositoryProvider.notifier) + .addTab(url: item.url, tabMode: TabMode.regular, selectTab: true); + } else { + await ref.read(tabRepositoryProvider.notifier).selectTab(item.id); + } + await animation; + }, + itemWrapBuilder: (child, item) { + if (item.isHistory) { + return child; + } + + return TabMenu( + selectedTabId: item.id, + enableFindInPage: false, + enableFetchFeeds: false, + enableDesktopMode: false, + enableReaderMode: false, + enableReloadButton: false, + enableNavigationButtons: false, + enableAddToHomeScreen: false, + enablePinTab: effectiveMode == QuickTabSwitcherMode.containerTabs, + builder: (context, controller, _) { + return InkWell( + onLongPress: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: child, + ); + }, + ); + }, + ); + } +} + +class QuickTabSwitcherView extends StatelessWidget { + const QuickTabSwitcherView({ + super.key, + required this.availableItems, + required this.scrollController, + required this.showTitles, + required this.showIsolatedTabUi, + required this.onSelected, + required this.itemWrapBuilder, + }); + + final List availableItems; + final ScrollController scrollController; + final bool showTitles; + final bool showIsolatedTabUi; + final Future Function(QuickTabSwitcherItem item) onSelected; + final Widget Function(Widget child, QuickTabSwitcherItem item) + itemWrapBuilder; + + @override + Widget build(BuildContext context) { + final appColors = AppColors.of(context); + if (availableItems.isEmpty) { return const SizedBox.shrink(); } - final chipScrollController = useScrollController(); - return Padding( padding: const EdgeInsets.symmetric(horizontal: 4.0), child: SizedBox( height: 48, width: double.maxFinite, - child: SelectableChips<_QuickTabItem, _QuickTabItem, String>( - enableDelete: false, - scrollController: chipScrollController, - itemId: (item) => item.id, - labelPadding: (item) => - (!showTitles && - !item.isHistory && - !item.isPinned && - item.tabMode is! PrivateTabMode && - item.tabMode is! IsolatedTabMode) - ? EdgeInsets.zero - : null, - itemLabel: (item) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (item.isHistory || showTitles) - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 64), - child: Text(item.title), - ), - if (showIsolatedTabUi && item.tabMode is IsolatedTabMode) - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Icon( - MdiIcons.snowflake, - color: appColors.isolatedTabTeal, - size: 20, - ), - ) - else if (item.tabMode is PrivateTabMode) - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Icon( - MdiIcons.dominoMask, - color: appColors.privateTabPurple, - size: 20, - ), - ), - if (item.isPinned) - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Icon( - MdiIcons.pin, - color: Theme.of(context).colorScheme.primary, - size: 20, - ), - ), - if (item.isHistory) - const Padding( - padding: EdgeInsets.only(left: 8.0), - child: Icon(MdiIcons.history, size: 20), - ), - ], - ); - }, - itemAvatar: (item) => - item.tabState.mapNotNull( - (tabState) => TabIcon(tabState: tabState, iconSize: 20), - ) ?? - UrlIcon([item.url], iconSize: 20), - itemBackgroundColor: (item) => - item.color != null ? ContainerColors.forChip(item.color!) : null, - onSelected: (item) async { - final animation = chipScrollController.animateTo( - 0, - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutBack, - ); - if (item.isHistory) { - await ref - .read(tabRepositoryProvider.notifier) - .addTab( - url: item.url, - tabMode: TabMode.regular, - selectTab: true, - ); - } else { - await ref.read(tabRepositoryProvider.notifier).selectTab(item.id); - } - await animation; - }, - itemWrap: (child, item) { - if (item.isHistory) { - return child; - } - - return TabMenu( - selectedTabId: item.id, - enableFindInPage: false, - enableFetchFeeds: false, - enableDesktopMode: false, - enableReaderMode: false, - enableReloadButton: false, - enableNavigationButtons: false, - enableAddToHomeScreen: false, - enablePinTab: effectiveMode == QuickTabSwitcherMode.containerTabs, - builder: (context, controller, _) { - return InkWell( - onLongPress: () { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); - } - }, - child: child, + child: + SelectableChips( + enableDelete: false, + scrollController: scrollController, + itemId: (item) => item.id, + labelPadding: (item) => + (!showTitles && + !item.isHistory && + !item.isPinned && + item.tabMode is! PrivateTabMode && + item.tabMode is! IsolatedTabMode) + ? EdgeInsets.zero + : null, + itemLabel: (item) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (item.isHistory || showTitles) + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 64), + child: Text(item.title), + ), + if (showIsolatedTabUi && item.tabMode is IsolatedTabMode) + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Icon( + MdiIcons.snowflake, + color: appColors.isolatedTabTeal, + size: 20, + ), + ) + else if (item.tabMode is PrivateTabMode) + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Icon( + MdiIcons.dominoMask, + color: appColors.privateTabPurple, + size: 20, + ), + ), + if (item.isPinned) + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Icon( + MdiIcons.pin, + color: Theme.of(context).colorScheme.primary, + size: 20, + ), + ), + if (item.isHistory) + const Padding( + padding: EdgeInsets.only(left: 8.0), + child: Icon(MdiIcons.history, size: 20), + ), + ], ); }, - ); - }, - availableItems: availableItems, - ), + itemAvatar: (item) => item.avatar, + itemBackgroundColor: (item) => item.color != null + ? ContainerColors.forChip(item.color!) + : null, + onSelected: onSelected, + itemWrap: itemWrapBuilder, + availableItems: availableItems, + ), ), ); } @@ -650,18 +768,28 @@ class ShareMenuButton extends StatelessWidget { @override Widget build(BuildContext context) { - return IconButton( + return ShareMenuButtonView( onPressed: () async { final tabId = selectedTabId; if (tabId != null) { await showShareBottomSheet(context, selectedTabId: tabId); } }, - icon: const Icon(Icons.share), ); } } +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; @@ -669,15 +797,25 @@ class NavigationMenuButton extends StatelessWidget { @override Widget build(BuildContext context) { - return ToolbarButton( + return NavigationMenuButtonView( onTap: () async { await showBrowserMenuSheet(context); }, - child: const Icon(Icons.more_vert), ); } } +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}); @@ -687,7 +825,7 @@ class AddTabButton extends HookConsumerWidget { return TabCreationMenu( controller: tabMenuController, - child: IconButton( + child: AddTabButtonView( onPressed: () async { final settings = ref.read(generalSettingsWithDefaultsProvider); @@ -701,7 +839,6 @@ class AddTabButton extends HookConsumerWidget { const BrowserRoute().go(context); } }, - icon: const Icon(MdiIcons.tabPlus), onLongPress: () { if (tabMenuController.isOpen) { tabMenuController.close(); @@ -714,6 +851,53 @@ class AddTabButton extends HookConsumerWidget { } } +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, @@ -732,7 +916,7 @@ class TabsCountButton extends HookConsumerWidget { return TabCreationMenu( controller: tabMenuController, - child: TabsActionButton( + child: TabsCountButtonView( isActive: displayedSheet is ViewTabsSheet, onTap: () async { final tabViewBottomSheet = ref diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/navigation_buttons.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/navigation_buttons.dart index b276c0aa..9bf1ead0 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/navigation_buttons.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/navigation_buttons.dart @@ -25,6 +25,52 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/widget import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart'; import 'package:weblibre/presentation/hooks/menu_controller.dart'; +class NavigateForwardButtonView extends StatelessWidget { + const NavigateForwardButtonView({ + super.key, + required this.canGoForward, + this.onPressed, + this.onLongPress, + }); + + final bool canGoForward; + final VoidCallback? onPressed; + final VoidCallback? onLongPress; + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: canGoForward ? onPressed : null, + onLongPress: canGoForward ? onLongPress : null, + icon: const Icon(Icons.arrow_forward), + ); + } +} + +class NavigateBackButtonView extends StatelessWidget { + const NavigateBackButtonView({ + super.key, + required this.canGoBack, + required this.isLoading, + this.onPressed, + this.onLongPress, + }); + + final bool canGoBack; + final bool isLoading; + final VoidCallback? onPressed; + final VoidCallback? onLongPress; + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: (canGoBack || isLoading) ? onPressed : null, + onLongPress: (canGoBack && !isLoading) ? onLongPress : null, + icon: isLoading ? const Icon(Icons.close) : const Icon(Icons.arrow_back), + ); + } +} + class NavigateForwardButton extends HookConsumerWidget { const NavigateForwardButton({ super.key, @@ -45,25 +91,21 @@ class NavigateForwardButton extends HookConsumerWidget { selectedTabId: selectedTabId, controller: historyMenuController, direction: HistoryMenuDirection.forward, - child: IconButton( - onPressed: canGoForward - ? () async { - final controller = ref.read( - tabSessionProvider(tabId: selectedTabId).notifier, - ); + child: NavigateForwardButtonView( + canGoForward: canGoForward, + onPressed: () 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), + await controller.goForward(); + menuControllerToClose?.close(); + }, + onLongPress: () { + if (!historyMenuController.isOpen) { + historyMenuController.open(); + } + }, ), ); } @@ -91,42 +133,37 @@ class NavigateBackButton extends HookConsumerWidget { selectedTabId: selectedTabId, controller: historyMenuController, direction: HistoryMenuDirection.back, - child: IconButton( - onPressed: (canGoBack || isLoading) - ? () async { - final controller = ref.read( - tabSessionProvider(tabId: selectedTabId).notifier, - ); + child: NavigateBackButtonView( + canGoBack: canGoBack, + isLoading: isLoading, + onPressed: () async { + final controller = ref.read( + tabSessionProvider(tabId: selectedTabId).notifier, + ); - final isReaderActive = ref.read( - selectedTabStateProvider.select( - (state) => state?.readerableState.active ?? false, - ), - ); + 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(); - } + if (isLoading) { + await controller.stopLoading(); + } else if (isReaderActive) { + await ref + .read(readerableScreenControllerProvider.notifier) + .toggleReaderView(false); + } else { + await controller.goBack(); + } - menuControllerToClose?.close(); - } - : null, - onLongPress: (canGoBack && !isLoading) - ? () { - if (!historyMenuController.isOpen) { - historyMenuController.open(); - } - } - : null, - icon: isLoading - ? const Icon(Icons.close) - : const Icon(Icons.arrow_back), + menuControllerToClose?.close(); + }, + onLongPress: () { + if (!historyMenuController.isOpen) { + historyMenuController.open(); + } + }, ), ); } diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tabs_action_button.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tabs_action_button.dart index 0331257e..61822f47 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tabs_action_button.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tabs_action_button.dart @@ -26,6 +26,56 @@ import 'package:weblibre/features/geckoview/features/tabs/data/entities/containe import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart'; +class TabsActionButtonView extends StatelessWidget { + const TabsActionButtonView({ + super.key, + required this.isActive, + required this.tabCountText, + this.showSkeleton = false, + this.onTap, + this.onDoubleTap, + this.onLongPress, + }); + + final bool isActive; + final String tabCountText; + final bool showSkeleton; + final VoidCallback? onTap; + final VoidCallback? onDoubleTap; + final VoidCallback? onLongPress; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final iconColor = isActive + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant; + + final text = Text( + tabCountText, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14.0, + color: iconColor, + ), + ); + + return ToolbarButton( + onTap: onTap, + onDoubleTap: onDoubleTap, + onLongPress: onLongPress, + child: Container( + decoration: BoxDecoration( + border: Border.all(width: 2.0, color: iconColor), + borderRadius: BorderRadius.circular(5.0), + ), + constraints: const BoxConstraints(minWidth: 25.0), + child: Center(child: showSkeleton ? Skeletonizer(child: text) : text), + ), + ); + } +} + class TabsActionButton extends HookConsumerWidget { final bool isActive; final VoidCallback? onTap; @@ -42,78 +92,32 @@ class TabsActionButton extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - final tabCount = isActive // ignore: provider_parameters ? ref.watch(containerTabCountProvider(ContainerFilterDisabled())) : ref.watch(selectedContainerTabCountProvider); - final iconColor = isActive - ? theme.colorScheme.primary - : theme.colorScheme.onSurfaceVariant; + final tabCountText = tabCount.when( + skipLoadingOnReload: true, + data: (count) => count.toString(), + loading: () => tabCount.hasValue ? tabCount.value.toString() : '0', + error: (error, stackTrace) { + logger.e( + 'Could not determine tab count', + error: error, + stackTrace: stackTrace, + ); + return '-1'; + }, + ); - return ToolbarButton( + return TabsActionButtonView( + isActive: isActive, + tabCountText: tabCountText, + showSkeleton: tabCount.isLoading && !tabCount.hasValue, onTap: onTap, onDoubleTap: onDoubleTap, onLongPress: onLongPress, - child: Container( - decoration: BoxDecoration( - border: Border.all(width: 2.0, color: iconColor), - borderRadius: BorderRadius.circular(5.0), - ), - constraints: const BoxConstraints(minWidth: 25.0), - child: Center( - child: tabCount.when( - skipLoadingOnReload: true, - data: (count) { - return Text( - count.toString(), - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14.0, - color: iconColor, - ), - ); - }, - loading: () => (tabCount.hasValue) - ? Text( - tabCount.value.toString(), - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14.0, - color: iconColor, - ), - ) - : Skeletonizer( - child: Text( - '0', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14.0, - color: iconColor, - ), - ), - ), - error: (error, stackTrace) { - logger.e( - 'Could not determine tab count', - error: error, - stackTrace: stackTrace, - ); - - return Text( - '-1', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14.0, - color: iconColor, - ), - ); - }, - ), - ), - ), ); } }