From f969055b21a250fbd13ce7d374f2e2d1b855a4dd Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Thu, 30 Jul 2026 04:50:00 +0200 Subject: [PATCH] refactor consumers into widgets --- .../browser/presentation/screens/browser.dart | 930 ++++++++++++------ .../widgets/browser_menu_sheet.dart | 191 ++-- .../presentation/widgets/tab_menu.dart | 510 +++++----- .../container_relation_visibility.dart | 87 ++ 4 files changed, 1038 insertions(+), 680 deletions(-) create mode 100644 apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_relation_visibility.dart diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart index 5bd112bd..d956d4ed 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart @@ -424,6 +424,534 @@ class _BrowserScaffoldTheme extends ConsumerWidget { } } +/// Computes auto-hide toolbar visibility for [selectedTabId] and animates +/// [child] in/out via [_AnimatedToolbar]. Shared by the bottom, top and side +/// rail toolbar layers so the (identical) visibility logic isn't duplicated +/// across ad-hoc `Consumer` closures. +class _ToolbarVisibilityAnimator extends ConsumerWidget { + final TabBarPosition position; + final String? selectedTabId; + final bool sheetDisplayed; + final bool tabInFullScreen; + final Widget child; + + const _ToolbarVisibilityAnimator({ + required this.position, + required this.selectedTabId, + required this.sheetDisplayed, + required this.tabInFullScreen, + required this.child, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final toolbarState = ref.watch( + toolbarVisibilityControllerProvider(selectedTabId), + ); + final visible = + sheetDisplayed || + (!tabInFullScreen && toolbarState == ToolbarVisibility.visible); + + return _AnimatedToolbar(position: position, visible: visible, child: child); + } +} + +/// Layer 0: browser content, positioned around the (possibly auto-hidden) +/// toolbars/rail. +class _BrowserContentPositioned extends ConsumerWidget { + final OverlayPortalController overlayController; + final StreamController pointerMoveEventsController; + final String? selectedTabId; + final bool sheetDisplayed; + final bool tabInFullScreen; + final TabBarPosition tabBarPosition; + final bool autoHideTabBar; + final bool isRail; + final bool isSmallWebActive; + final double sideRailTotalWidth; + final double topAppBarTotalHeight; + final double bottomAppBarTotalHeight; + + const _BrowserContentPositioned({ + required this.overlayController, + required this.pointerMoveEventsController, + required this.selectedTabId, + required this.sheetDisplayed, + required this.tabInFullScreen, + required this.tabBarPosition, + required this.autoHideTabBar, + required this.isRail, + required this.isSmallWebActive, + required this.sideRailTotalWidth, + required this.topAppBarTotalHeight, + required this.bottomAppBarTotalHeight, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final toolbarState = ref.watch( + toolbarVisibilityControllerProvider(selectedTabId), + ); + final toolbarVisible = + sheetDisplayed || + (!tabInFullScreen && toolbarState == ToolbarVisibility.visible); + + // When auto-hide is disabled, constrain browser above toolbar + // (unless toolbar is manually dismissed via swipe gesture). + // Gated to horizontal positions so the rail (which forces + // auto-hide off) doesn't reserve a phantom bottom inset. + final bottomOffset = + (tabBarPosition.isHorizontal && !autoHideTabBar && toolbarVisible) + ? bottomAppBarTotalHeight + : 0.0; + + // For top bar: constrain browser below toolbar when visible + // to ensure top-of-page content is always accessible + final topOffset = (tabBarPosition == TabBarPosition.top && toolbarVisible) + ? topAppBarTotalHeight + : 0.0; + + // Side rail: inset the browser by the rail width on the docked + // edge whenever the rail is visible (no auto-hide, so this is + // a plain content offset — the rail slides out on dismiss). + final leftOffset = (tabBarPosition == TabBarPosition.left && toolbarVisible) + ? sideRailTotalWidth + : 0.0; + final rightOffset = + (tabBarPosition == TabBarPosition.right && toolbarVisible) + ? sideRailTotalWidth + : 0.0; + + // Only fall back to the system bottom inset when the bottom + // toolbar was explicitly dismissed. The normal auto-hide + // hidden state is still handled by GeckoView's dynamic + // toolbar/clipping logic. On the rail there is never a bottom + // bar, so the browser always needs the bottom safe inset. + final applyBottomSafeArea = isRail + ? (!tabInFullScreen && !isSmallWebActive && !sheetDisplayed) + : (!tabInFullScreen && + !isSmallWebActive && + !sheetDisplayed && + bottomOffset == 0 && + toolbarState == ToolbarVisibility.dismissed); + + return Positioned( + left: leftOffset, + right: rightOffset, + top: topOffset, + bottom: bottomOffset, + child: _Browser( + overlayController: overlayController, + tabInFullScreen: tabInFullScreen, + pointerMoveEventSink: autoHideTabBar + ? pointerMoveEventsController.sink + : null, + sheetDisplayed: sheetDisplayed, + hasTopBarOffset: topOffset > 0, + hasLeftBarOffset: leftOffset > 0, + hasRightBarOffset: rightOffset > 0, + applyBottomSafeArea: applyBottomSafeArea, + ), + ); + } +} + +/// Layer 2: bottom toolbar (or the small-web discovery overlay in its place). +class _BottomToolbarLayer extends StatelessWidget { + final bool sheetDisplayed; + final bool tabInFullScreen; + final bool isSmallWebActive; + final TabBarPosition tabBarPosition; + final bool showContextualToolbar; + final int quickTabSwitcherRowCount; + final String? selectedTabId; + final StreamController pointerMoveEventsController; + + const _BottomToolbarLayer({ + required this.sheetDisplayed, + required this.tabInFullScreen, + required this.isSmallWebActive, + required this.tabBarPosition, + required this.showContextualToolbar, + required this.quickTabSwitcherRowCount, + required this.selectedTabId, + required this.pointerMoveEventsController, + }); + + @override + Widget build(BuildContext context) { + if (isSmallWebActive) { + return _AnimatedToolbar( + position: TabBarPosition.bottom, + visible: !tabInFullScreen, + child: Material( + color: Theme.of(context).colorScheme.surfaceContainer, + elevation: 3, + child: const SmallWebBrowserOverlay(), + ), + ); + } + + // _TabBar is passed via `child` so it is built once and reused across + // toolbar show/hide toggles inside _ToolbarVisibilityAnimator. + return _ToolbarVisibilityAnimator( + position: TabBarPosition.bottom, + selectedTabId: selectedTabId, + sheetDisplayed: sheetDisplayed, + tabInFullScreen: tabInFullScreen, + child: _TabBar( + tabBarPosition: TabBarPosition.bottom, + showMainToolbar: tabBarPosition == TabBarPosition.bottom, + showContextualToolbar: showContextualToolbar, + quickTabSwitcherRowCount: quickTabSwitcherRowCount, + isSmallWebMode: false, + pointerMoveEvents: tabBarPosition == TabBarPosition.bottom + ? pointerMoveEventsController.stream + : null, + ), + ); + } +} + +/// Layer 3: top toolbar (only rendered when the tab bar position is top). +class _TopToolbarLayer extends StatelessWidget { + final bool sheetDisplayed; + final bool tabInFullScreen; + final bool isSmallWebActive; + final bool showContextualToolbar; + final int quickTabSwitcherRowCount; + final String? selectedTabId; + final StreamController pointerMoveEventsController; + + const _TopToolbarLayer({ + required this.sheetDisplayed, + required this.tabInFullScreen, + required this.isSmallWebActive, + required this.showContextualToolbar, + required this.quickTabSwitcherRowCount, + required this.selectedTabId, + required this.pointerMoveEventsController, + }); + + @override + Widget build(BuildContext context) { + return _ToolbarVisibilityAnimator( + position: TabBarPosition.top, + selectedTabId: selectedTabId, + sheetDisplayed: sheetDisplayed, + tabInFullScreen: tabInFullScreen, + child: _TabBar( + tabBarPosition: TabBarPosition.top, + showMainToolbar: true, + showContextualToolbar: showContextualToolbar, + quickTabSwitcherRowCount: quickTabSwitcherRowCount, + isSmallWebMode: isSmallWebActive, + enableGestures: !isSmallWebActive, + pointerMoveEvents: isSmallWebActive + ? null + : pointerMoveEventsController.stream, + ), + ); + } +} + +/// Layer 3b: vertical side rail (left/right). +class _SideRailToolbarLayer extends StatelessWidget { + final bool sheetDisplayed; + final bool tabInFullScreen; + final TabBarPosition tabBarPosition; + final bool showContextualToolbar; + final int quickTabSwitcherRowCount; + final String? selectedTabId; + + const _SideRailToolbarLayer({ + required this.sheetDisplayed, + required this.tabInFullScreen, + required this.tabBarPosition, + required this.showContextualToolbar, + required this.quickTabSwitcherRowCount, + required this.selectedTabId, + }); + + @override + Widget build(BuildContext context) { + return _ToolbarVisibilityAnimator( + position: tabBarPosition, + selectedTabId: selectedTabId, + sheetDisplayed: sheetDisplayed, + tabInFullScreen: tabInFullScreen, + child: BrowserSideRail( + position: tabBarPosition, + showContextualToolbar: showContextualToolbar, + quickTabSwitcherRowCount: quickTabSwitcherRowCount, + isSmallWebMode: false, + ), + ); + } +} + +/// Layer 4: draggable FAB, positioned clear of the toolbar/rail. +class _FabPositioner extends ConsumerWidget { + final String? selectedTabId; + final bool sheetDisplayed; + final bool tabInFullScreen; + final TabBarPosition tabBarPosition; + final double bottomAppBarTotalHeight; + final double bottomSafeArea; + final double sideRailTotalWidth; + final Widget child; + + const _FabPositioner({ + required this.selectedTabId, + required this.sheetDisplayed, + required this.tabInFullScreen, + required this.tabBarPosition, + required this.bottomAppBarTotalHeight, + required this.bottomSafeArea, + required this.sideRailTotalWidth, + required this.child, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final toolbarState = ref.watch( + toolbarVisibilityControllerProvider(selectedTabId), + ); + final visible = + sheetDisplayed || + (!tabInFullScreen && toolbarState == ToolbarVisibility.visible); + + return DraggableFab( + bottomToolbarVisible: visible, + bottomAppBarHeight: bottomAppBarTotalHeight, + bottomSafeArea: bottomSafeArea, + leftReservedWidth: (tabBarPosition == TabBarPosition.left && visible) + ? sideRailTotalWidth + : 0.0, + rightReservedWidth: (tabBarPosition == TabBarPosition.right && visible) + ? sideRailTotalWidth + : 0.0, + child: child, + ); + } +} + +/// Layer 5: page load progress indicator, animated with toolbar visibility. +/// Split into a positioner (reacts to toolbar visibility) and a content +/// widget (reacts to load progress) so the two independent watches don't +/// force each other to rebuild. +class _ProgressIndicatorPositioner extends ConsumerWidget { + final String? selectedTabId; + final bool sheetDisplayed; + final bool tabInFullScreen; + final TabBarPosition tabBarPosition; + final bool isRail; + final double sideRailTotalWidth; + final double topSafeArea; + final double topAppBarTotalHeight; + final double bottomAppBarTotalHeight; + final Widget child; + + const _ProgressIndicatorPositioner({ + required this.selectedTabId, + required this.sheetDisplayed, + required this.tabInFullScreen, + required this.tabBarPosition, + required this.isRail, + required this.sideRailTotalWidth, + required this.topSafeArea, + required this.topAppBarTotalHeight, + required this.bottomAppBarTotalHeight, + required this.child, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final disableAnimations = MediaQuery.disableAnimationsOf(context); + final toolbarState = ref.watch( + toolbarVisibilityControllerProvider(selectedTabId), + ); + final visible = + sheetDisplayed || + (!tabInFullScreen && toolbarState == ToolbarVisibility.visible); + + return AnimatedPositioned( + duration: disableAnimations + ? Duration.zero + : _AnimatedToolbar._kAnimationDuration, + curve: Curves.easeInOutQuart, + // Rail insets track the rail's visibility so overlays + // reclaim the space when it is swiped away. + left: (tabBarPosition == TabBarPosition.left && visible) + ? sideRailTotalWidth + : 0.0, + right: (tabBarPosition == TabBarPosition.right && visible) + ? sideRailTotalWidth + : 0.0, + // On the rail there is no top/bottom bar; pin the progress + // line to the top of the content area. + top: isRail + ? topSafeArea + : tabBarPosition == TabBarPosition.top && visible + ? topAppBarTotalHeight + : null, + bottom: isRail + ? null + : tabBarPosition == TabBarPosition.bottom && visible + ? bottomAppBarTotalHeight + : tabBarPosition == TabBarPosition.bottom + ? 0 + : null, + child: child, + ); + } +} + +class _ProgressIndicatorBar extends ConsumerWidget { + const _ProgressIndicatorBar(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final value = ref.watch( + selectedTabStateProvider.select((state) { + if (state?.isLoading == true) { + return state?.progress ?? 100; + } + + //When not loading we assumed finished + return 100; + }), + ); + + return Visibility( + visible: value < 100, + child: LinearProgressIndicator(value: value / 100), + ); + } +} + +/// Layer 6: find-in-page widget, positioned above the toolbar/keyboard. +/// Same positioner/content split rationale as the progress indicator above. +class _FindInPagePositioner extends ConsumerWidget { + final String? selectedTabId; + final bool sheetDisplayed; + final bool tabInFullScreen; + final TabBarPosition tabBarPosition; + final bool isRail; + final double sideRailTotalWidth; + final double bottomSafeArea; + final double bottomAppBarTotalHeight; + final Widget child; + + const _FindInPagePositioner({ + required this.selectedTabId, + required this.sheetDisplayed, + required this.tabInFullScreen, + required this.tabBarPosition, + required this.isRail, + required this.sideRailTotalWidth, + required this.bottomSafeArea, + required this.bottomAppBarTotalHeight, + required this.child, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final disableAnimations = MediaQuery.disableAnimationsOf(context); + final toolbarState = ref.watch( + toolbarVisibilityControllerProvider(selectedTabId), + ); + final visible = + sheetDisplayed || + (!tabInFullScreen && toolbarState == ToolbarVisibility.visible); + + return AnimatedPositioned( + duration: disableAnimations + ? Duration.zero + : _AnimatedToolbar._kAnimationDuration, + curve: Curves.easeInOutQuart, + left: (tabBarPosition == TabBarPosition.left && visible) + ? sideRailTotalWidth + : 0.0, + right: (tabBarPosition == TabBarPosition.right && visible) + ? sideRailTotalWidth + : 0.0, + bottom: math.max( + isRail + ? bottomSafeArea + : (visible ? bottomAppBarTotalHeight : bottomSafeArea), + MediaQuery.viewInsetsOf(context).bottom, + ), + child: child, + ); + } +} + +class _FindInPageContent extends ConsumerWidget { + const _FindInPageContent(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tabId = ref.watch(selectedTabProvider); + if (tabId == null) { + return const SizedBox.shrink(); + } + return FindInPageWidget(key: ValueKey(tabId), tabId: tabId); + } +} + +/// Layer 7: app-link prompt banner (§2.6). Anchored above the bottom app +/// bar / keyboard exactly like find-in-page, so it is never hidden behind +/// the toolbar. Custom Tab sessions are prompted natively instead; this is +/// the browser-tab surface only. +class _AppLinkPromptLayer extends ConsumerWidget { + final String? selectedTabId; + final bool sheetDisplayed; + final bool tabInFullScreen; + final TabBarPosition tabBarPosition; + final bool isRail; + final double sideRailTotalWidth; + final double bottomSafeArea; + final double bottomAppBarTotalHeight; + + const _AppLinkPromptLayer({ + required this.selectedTabId, + required this.sheetDisplayed, + required this.tabInFullScreen, + required this.tabBarPosition, + required this.isRail, + required this.sideRailTotalWidth, + required this.bottomSafeArea, + required this.bottomAppBarTotalHeight, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final toolbarState = ref.watch( + toolbarVisibilityControllerProvider(selectedTabId), + ); + final visible = + sheetDisplayed || + (!tabInFullScreen && toolbarState == ToolbarVisibility.visible); + + return Positioned( + left: (tabBarPosition == TabBarPosition.left && visible) + ? sideRailTotalWidth + : 0.0, + right: (tabBarPosition == TabBarPosition.right && visible) + ? sideRailTotalWidth + : 0.0, + bottom: math.max( + isRail + ? bottomSafeArea + : (visible ? bottomAppBarTotalHeight : bottomSafeArea), + MediaQuery.viewInsetsOf(context).bottom, + ), + child: const AppLinkPromptHost(), + ); + } +} + class BrowserScreen extends HookConsumerWidget { const BrowserScreen({super.key}); @@ -908,80 +1436,19 @@ class BrowserScreen extends HookConsumerWidget { // Layer 0: Browser content // Position changes instantly (no animation) to avoid jarring native view resize // The toolbar itself animates, providing visual continuity - Consumer( - builder: (context, ref, child) { - final toolbarState = ref.watch( - toolbarVisibilityControllerProvider(selectedTabId), - ); - final toolbarVisible = - sheetDisplayed || - (!tabInFullScreen && - toolbarState == ToolbarVisibility.visible); - - // When auto-hide is disabled, constrain browser above toolbar - // (unless toolbar is manually dismissed via swipe gesture). - // Gated to horizontal positions so the rail (which forces - // auto-hide off) doesn't reserve a phantom bottom inset. - final bottomOffset = - (tabBarPosition.isHorizontal && - !autoHideTabBar && - toolbarVisible) - ? bottomAppBarTotalHeight - : 0.0; - - // For top bar: constrain browser below toolbar when visible - // to ensure top-of-page content is always accessible - final topOffset = - (tabBarPosition == TabBarPosition.top && toolbarVisible) - ? topAppBarTotalHeight - : 0.0; - - // Side rail: inset the browser by the rail width on the docked - // edge whenever the rail is visible (no auto-hide, so this is - // a plain content offset — the rail slides out on dismiss). - final leftOffset = - (tabBarPosition == TabBarPosition.left && toolbarVisible) - ? sideRailTotalWidth - : 0.0; - final rightOffset = - (tabBarPosition == TabBarPosition.right && toolbarVisible) - ? sideRailTotalWidth - : 0.0; - - // Only fall back to the system bottom inset when the bottom - // toolbar was explicitly dismissed. The normal auto-hide - // hidden state is still handled by GeckoView's dynamic - // toolbar/clipping logic. On the rail there is never a bottom - // bar, so the browser always needs the bottom safe inset. - final applyBottomSafeArea = isRail - ? (!tabInFullScreen && - !isSmallWebActive && - !sheetDisplayed) - : (!tabInFullScreen && - !isSmallWebActive && - !sheetDisplayed && - bottomOffset == 0 && - toolbarState == ToolbarVisibility.dismissed); - - return Positioned( - left: leftOffset, - right: rightOffset, - top: topOffset, - bottom: bottomOffset, - child: _Browser( - overlayController: overlayController, - tabInFullScreen: tabInFullScreen, - pointerMoveEventSink: autoHideTabBar - ? pointerMoveEventsController.sink - : null, - sheetDisplayed: sheetDisplayed, - hasTopBarOffset: topOffset > 0, - hasLeftBarOffset: leftOffset > 0, - hasRightBarOffset: rightOffset > 0, - applyBottomSafeArea: applyBottomSafeArea, - ), - ); - }, + _BrowserContentPositioned( + overlayController: overlayController, + pointerMoveEventsController: pointerMoveEventsController, + selectedTabId: selectedTabId, + sheetDisplayed: sheetDisplayed, + tabInFullScreen: tabInFullScreen, + tabBarPosition: tabBarPosition, + autoHideTabBar: autoHideTabBar, + isRail: isRail, + isSmallWebActive: isSmallWebActive, + sideRailTotalWidth: sideRailTotalWidth, + topAppBarTotalHeight: topAppBarTotalHeight, + bottomAppBarTotalHeight: bottomAppBarTotalHeight, ), // Layer 0.5: System bar tint — fills the status-bar/nav-bar @@ -1021,52 +1488,16 @@ class BrowserScreen extends HookConsumerWidget { left: 0, right: 0, bottom: 0, - child: isSmallWebActive - ? _AnimatedToolbar( - position: TabBarPosition.bottom, - visible: !tabInFullScreen, - child: Material( - color: Theme.of( - context, - ).colorScheme.surfaceContainer, - elevation: 3, - child: const SmallWebBrowserOverlay(), - ), - ) - : Consumer( - // _TabBar is passed via `child` so it is built once and - // reused across toolbar show/hide toggles; only the - // `visible` flag fed to _AnimatedToolbar depends on the - // watched provider. - builder: (context, ref, child) { - final toolbarState = ref.watch( - toolbarVisibilityControllerProvider( - selectedTabId, - ), - ); - final visible = - sheetDisplayed || - (!tabInFullScreen && - toolbarState == ToolbarVisibility.visible); - return _AnimatedToolbar( - position: TabBarPosition.bottom, - visible: visible, - child: child!, - ); - }, - child: _TabBar( - tabBarPosition: TabBarPosition.bottom, - showMainToolbar: - tabBarPosition == TabBarPosition.bottom, - showContextualToolbar: showContextualToolbar, - quickTabSwitcherRowCount: quickTabSwitcherRowCount, - isSmallWebMode: false, - pointerMoveEvents: - tabBarPosition == TabBarPosition.bottom - ? pointerMoveEventsController.stream - : null, - ), - ), + child: _BottomToolbarLayer( + sheetDisplayed: sheetDisplayed, + tabInFullScreen: tabInFullScreen, + isSmallWebActive: isSmallWebActive, + tabBarPosition: tabBarPosition, + showContextualToolbar: showContextualToolbar, + quickTabSwitcherRowCount: quickTabSwitcherRowCount, + selectedTabId: selectedTabId, + pointerMoveEventsController: pointerMoveEventsController, + ), ), // Layer 3: Top Toolbar (overlay, slides in/out) - only when position is top @@ -1075,36 +1506,14 @@ class BrowserScreen extends HookConsumerWidget { left: 0, right: 0, top: 0, - child: Consumer( - // _TabBar is passed via `child` so it is built once and - // reused across toolbar show/hide toggles; only the - // `visible` flag fed to _AnimatedToolbar depends on the - // watched provider. - builder: (context, ref, child) { - final toolbarState = ref.watch( - toolbarVisibilityControllerProvider(selectedTabId), - ); - final visible = - sheetDisplayed || - (!tabInFullScreen && - toolbarState == ToolbarVisibility.visible); - return _AnimatedToolbar( - position: TabBarPosition.top, - visible: visible, - child: child!, - ); - }, - child: _TabBar( - tabBarPosition: TabBarPosition.top, - showMainToolbar: true, - showContextualToolbar: showContextualToolbar, - quickTabSwitcherRowCount: quickTabSwitcherRowCount, - isSmallWebMode: isSmallWebActive, - enableGestures: !isSmallWebActive, - pointerMoveEvents: isSmallWebActive - ? null - : pointerMoveEventsController.stream, - ), + child: _TopToolbarLayer( + sheetDisplayed: sheetDisplayed, + tabInFullScreen: tabInFullScreen, + isSmallWebActive: isSmallWebActive, + showContextualToolbar: showContextualToolbar, + quickTabSwitcherRowCount: quickTabSwitcherRowCount, + selectedTabId: selectedTabId, + pointerMoveEventsController: pointerMoveEventsController, ), ), @@ -1116,199 +1525,68 @@ class BrowserScreen extends HookConsumerWidget { bottom: 0, left: tabBarPosition == TabBarPosition.left ? 0 : null, right: tabBarPosition == TabBarPosition.right ? 0 : null, - child: Consumer( - builder: (context, ref, child) { - final toolbarState = ref.watch( - toolbarVisibilityControllerProvider(selectedTabId), - ); - final visible = - sheetDisplayed || - (!tabInFullScreen && - toolbarState == ToolbarVisibility.visible); - return _AnimatedToolbar( - position: tabBarPosition, - visible: visible, - child: child!, - ); - }, - child: BrowserSideRail( - position: tabBarPosition, - showContextualToolbar: showContextualToolbar, - quickTabSwitcherRowCount: quickTabSwitcherRowCount, - isSmallWebMode: false, - ), + child: _SideRailToolbarLayer( + sheetDisplayed: sheetDisplayed, + tabInFullScreen: tabInFullScreen, + tabBarPosition: tabBarPosition, + showContextualToolbar: showContextualToolbar, + quickTabSwitcherRowCount: quickTabSwitcherRowCount, + selectedTabId: selectedTabId, ), ), // Layer 4: FAB (draggable via long press) - Consumer( - builder: (context, ref, child) { - final toolbarState = ref.watch( - toolbarVisibilityControllerProvider(selectedTabId), - ); - final visible = - sheetDisplayed || - (!tabInFullScreen && - toolbarState == ToolbarVisibility.visible); - return DraggableFab( - bottomToolbarVisible: visible, - bottomAppBarHeight: bottomAppBarTotalHeight, - bottomSafeArea: bottomSafeArea, - leftReservedWidth: - (tabBarPosition == TabBarPosition.left && visible) - ? sideRailTotalWidth - : 0.0, - rightReservedWidth: - (tabBarPosition == TabBarPosition.right && visible) - ? sideRailTotalWidth - : 0.0, - child: child!, - ); - }, + _FabPositioner( + selectedTabId: selectedTabId, + sheetDisplayed: sheetDisplayed, + tabInFullScreen: tabInFullScreen, + tabBarPosition: tabBarPosition, + bottomAppBarTotalHeight: bottomAppBarTotalHeight, + bottomSafeArea: bottomSafeArea, + sideRailTotalWidth: sideRailTotalWidth, child: const BrowserFab(), ), // Layer 5: Page load progress indicator (animates with toolbar visibility) - Consumer( - builder: (context, ref, child) { - final disableAnimations = MediaQuery.disableAnimationsOf( - context, - ); - final toolbarState = ref.watch( - toolbarVisibilityControllerProvider(selectedTabId), - ); - final visible = - sheetDisplayed || - (!tabInFullScreen && - toolbarState == ToolbarVisibility.visible); - return AnimatedPositioned( - duration: disableAnimations - ? Duration.zero - : _AnimatedToolbar._kAnimationDuration, - curve: Curves.easeInOutQuart, - // Rail insets track the rail's visibility so overlays - // reclaim the space when it is swiped away. - left: (tabBarPosition == TabBarPosition.left && visible) - ? sideRailTotalWidth - : 0.0, - right: (tabBarPosition == TabBarPosition.right && visible) - ? sideRailTotalWidth - : 0.0, - // On the rail there is no top/bottom bar; pin the progress - // line to the top of the content area. - top: isRail - ? topSafeArea - : tabBarPosition == TabBarPosition.top && visible - ? topAppBarTotalHeight - : null, - bottom: isRail - ? null - : tabBarPosition == TabBarPosition.bottom && visible - ? bottomAppBarTotalHeight - : tabBarPosition == TabBarPosition.bottom - ? 0 - : null, - child: child!, - ); - }, - child: Consumer( - builder: (context, ref, child) { - final value = ref.watch( - selectedTabStateProvider.select((state) { - if (state?.isLoading == true) { - return state?.progress ?? 100; - } - - //When not loading we assumed finished - return 100; - }), - ); - - return Visibility( - visible: value < 100, - child: LinearProgressIndicator(value: value / 100), - ); - }, - ), + _ProgressIndicatorPositioner( + selectedTabId: selectedTabId, + sheetDisplayed: sheetDisplayed, + tabInFullScreen: tabInFullScreen, + tabBarPosition: tabBarPosition, + isRail: isRail, + sideRailTotalWidth: sideRailTotalWidth, + topSafeArea: topSafeArea, + topAppBarTotalHeight: topAppBarTotalHeight, + bottomAppBarTotalHeight: bottomAppBarTotalHeight, + child: const _ProgressIndicatorBar(), ), // Layer 6: Find in Page widget (above toolbar or keyboard, whichever is higher) - Consumer( - builder: (context, ref, child) { - final disableAnimations = MediaQuery.disableAnimationsOf( - context, - ); - final toolbarState = ref.watch( - toolbarVisibilityControllerProvider(selectedTabId), - ); - final visible = - sheetDisplayed || - (!tabInFullScreen && - toolbarState == ToolbarVisibility.visible); - return AnimatedPositioned( - duration: disableAnimations - ? Duration.zero - : _AnimatedToolbar._kAnimationDuration, - curve: Curves.easeInOutQuart, - left: (tabBarPosition == TabBarPosition.left && visible) - ? sideRailTotalWidth - : 0.0, - right: (tabBarPosition == TabBarPosition.right && visible) - ? sideRailTotalWidth - : 0.0, - bottom: math.max( - isRail - ? bottomSafeArea - : (visible - ? bottomAppBarTotalHeight - : bottomSafeArea), - MediaQuery.viewInsetsOf(context).bottom, - ), - child: child!, - ); - }, - child: Consumer( - builder: (context, ref, child) { - final tabId = ref.watch(selectedTabProvider); - if (tabId == null) { - return const SizedBox.shrink(); - } - return FindInPageWidget(key: ValueKey(tabId), tabId: tabId); - }, - ), + _FindInPagePositioner( + selectedTabId: selectedTabId, + sheetDisplayed: sheetDisplayed, + tabInFullScreen: tabInFullScreen, + tabBarPosition: tabBarPosition, + isRail: isRail, + sideRailTotalWidth: sideRailTotalWidth, + bottomSafeArea: bottomSafeArea, + bottomAppBarTotalHeight: bottomAppBarTotalHeight, + child: const _FindInPageContent(), ), // Layer 7: App-link prompt banner (§2.6). Anchored above the bottom app // bar / keyboard exactly like find-in-page, so it is never hidden behind // the toolbar. Custom Tab sessions are prompted natively instead; this is // the browser-tab surface only. - Consumer( - builder: (context, ref, child) { - final toolbarState = ref.watch( - toolbarVisibilityControllerProvider(selectedTabId), - ); - final visible = - sheetDisplayed || - (!tabInFullScreen && - toolbarState == ToolbarVisibility.visible); - return Positioned( - left: (tabBarPosition == TabBarPosition.left && visible) - ? sideRailTotalWidth - : 0.0, - right: (tabBarPosition == TabBarPosition.right && visible) - ? sideRailTotalWidth - : 0.0, - bottom: math.max( - isRail - ? bottomSafeArea - : (visible - ? bottomAppBarTotalHeight - : bottomSafeArea), - MediaQuery.viewInsetsOf(context).bottom, - ), - child: const AppLinkPromptHost(), - ); - }, + _AppLinkPromptLayer( + selectedTabId: selectedTabId, + sheetDisplayed: sheetDisplayed, + tabInFullScreen: tabInFullScreen, + tabBarPosition: tabBarPosition, + isRail: isRail, + sideRailTotalWidth: sideRailTotalWidth, + bottomSafeArea: bottomSafeArea, + bottomAppBarTotalHeight: bottomAppBarTotalHeight, ), ], ), diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart index 689e28d1..5b69e0b1 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart @@ -66,9 +66,9 @@ import 'package:weblibre/features/geckoview/features/readerview/presentation/con import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/entities/container_selection_result.dart'; -import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_relation_visibility.dart'; import 'package:weblibre/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart'; import 'package:weblibre/features/gestures/data/models/gesture_settings.dart'; import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart'; @@ -1271,134 +1271,101 @@ class _ContainerExpansion extends ConsumerWidget { ), // URL relation (conditional) - Consumer( - builder: (context, ref, child) { - final isSiteAssigned = ref.watch( - watchIsCurrentSiteAssignedToContainerProvider, - ); + ContainerRelationUnassignedVisibility( + child: _buildSubTile( + 'Assign URL to Container', + icon: MdiIcons.webPlus, + onTap: () async { + final selection = await const ContainerSelectionRoute() + .push(context); - if (!isSiteAssigned.hasValue || isSiteAssigned.requireValue) { - return const SizedBox.shrink(); - } + if (selection case ContainerSelectionSelected( + :final containerId, + )) { + final containerData = await ref + .read(containerRepositoryProvider.notifier) + .getContainerData(containerId); - return _buildSubTile( - 'Assign URL to Container', - icon: MdiIcons.webPlus, - onTap: () async { - final selection = await const ContainerSelectionRoute() - .push(context); + if (containerData != null) { + final tabState = ref.read(tabStateProvider(selectedTabId)); + final origin = tabState?.url.origin.mapNotNull(Uri.parse); - if (selection case ContainerSelectionSelected( - :final containerId, - )) { + if (origin != null) { + await ref + .read(containerRepositoryProvider.notifier) + .replaceContainer( + containerData.copyWith.metadata( + containerData.metadata.copyWith.assignedSites([ + ...?containerData.metadata.assignedSites, + origin, + ]), + ), + ); + } + } + } + + if (context.mounted) Navigator.pop(context); + }, + ), + ), + + // Unassign URL relation (conditional) + ContainerRelationAssignedVisibility( + child: _buildSubTile( + 'Unassign URL from Container', + icon: MdiIcons.webMinus, + onTap: () async { + final tabState = ref.read(tabStateProvider(selectedTabId)); + final origin = tabState?.url.origin.mapNotNull(Uri.parse); + + if (origin != null) { + final containerId = await ref + .read(containerRepositoryProvider.notifier) + .siteAssignedContainerId(origin); + + if (containerId != null) { final containerData = await ref .read(containerRepositoryProvider.notifier) .getContainerData(containerId); if (containerData != null) { - final tabState = ref.read( - tabStateProvider(selectedTabId), - ); - final origin = tabState?.url.origin.mapNotNull(Uri.parse); + final updatedSites = containerData.metadata.assignedSites + ?.where((site) => site != origin) + .toList(); - if (origin != null) { - await ref - .read(containerRepositoryProvider.notifier) - .replaceContainer( - containerData.copyWith.metadata( - containerData.metadata.copyWith.assignedSites([ - ...?containerData.metadata.assignedSites, - origin, - ]), - ), - ); - } - } - } - - if (context.mounted) Navigator.pop(context); - }, - ); - }, - ), - - // Unassign URL relation (conditional) - Consumer( - builder: (context, ref, child) { - final isSiteAssigned = ref.watch( - watchIsCurrentSiteAssignedToContainerProvider, - ); - - if (!isSiteAssigned.hasValue || !isSiteAssigned.requireValue) { - return const SizedBox.shrink(); - } - - return _buildSubTile( - 'Unassign URL from Container', - icon: MdiIcons.webMinus, - onTap: () async { - final tabState = ref.read(tabStateProvider(selectedTabId)); - final origin = tabState?.url.origin.mapNotNull(Uri.parse); - - if (origin != null) { - final containerId = await ref - .read(containerRepositoryProvider.notifier) - .siteAssignedContainerId(origin); - - if (containerId != null) { - final containerData = await ref + await ref .read(containerRepositoryProvider.notifier) - .getContainerData(containerId); - - if (containerData != null) { - final updatedSites = containerData - .metadata - .assignedSites - ?.where((site) => site != origin) - .toList(); - - await ref - .read(containerRepositoryProvider.notifier) - .replaceContainer( - containerData.copyWith.metadata( - containerData.metadata.copyWith.assignedSites( - updatedSites, - ), + .replaceContainer( + containerData.copyWith.metadata( + containerData.metadata.copyWith.assignedSites( + updatedSites, ), - ); - } + ), + ); } } + } - if (context.mounted) Navigator.pop(context); - }, - ); - }, + if (context.mounted) Navigator.pop(context); + }, + ), ), // Unassign Container (conditional) - Consumer( - builder: (context, ref, child) { - final containerId = ref.watch( - watchContainerTabIdProvider( - selectedTabId, - ).select((value) => value.value), - ); - - if (containerId == null) return const SizedBox.shrink(); - - return _buildSubTile( - 'Unassign Container', - icon: MdiIcons.folderCancelOutline, - onTap: () async { - final tabState = ref.read(tabStateProvider(selectedTabId))!; - await ref - .read(tabDataRepositoryProvider.notifier) - .unassignContainer(tabState.id); - if (context.mounted) Navigator.pop(context); - }, - ); - }, + ContainerAssignedVisibility( + tabId: selectedTabId, + child: _buildSubTile( + 'Unassign Container', + icon: MdiIcons.folderCancelOutline, + onTap: () async { + final tabState = ref.read(tabStateProvider(selectedTabId))!; + await ref + .read(tabDataRepositoryProvider.notifier) + .unassignContainer(tabState.id); + if (context.mounted) Navigator.pop(context); + }, + ), ), ], ), diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart index 59df05ad..2febd6f4 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart @@ -52,6 +52,7 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/entities/contai import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_relation_visibility.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart'; @@ -155,32 +156,16 @@ class TabMenu extends HookConsumerWidget { ), ), if (enableDesktopMode) - Consumer( - builder: (context, childRef, child) { - final enabled = childRef.watch( - desktopModeProvider(selectedTabId), - ); - - return MenuItemButton( - onPressed: () { - ref - .read(desktopModeProvider(selectedTabId).notifier) - .toggle(); - }, - leadingIcon: const Icon(MdiIcons.monitor), - trailingIcon: Checkbox( - value: enabled, - onChanged: (value) { - if (value != null) { - ref - .read(desktopModeProvider(selectedTabId).notifier) - .enabled(value); - controller.close(); - } - }, - ), - child: const Text('Desktop Mode'), - ); + _DesktopModeMenuItem( + selectedTabId: selectedTabId, + controller: controller, + onToggle: () { + ref.read(desktopModeProvider(selectedTabId).notifier).toggle(); + }, + onEnabledChanged: (value) { + ref + .read(desktopModeProvider(selectedTabId).notifier) + .enabled(value); }, ), if (enableFindInPage || enableReaderMode || enableDesktopMode) @@ -220,35 +205,7 @@ class TabMenu extends HookConsumerWidget { ).push(context); }, ), - if (enableAddToHomeScreen) - Consumer( - builder: (context, ref, child) { - final isInstallable = ref.watch(isCurrentTabInstallableProvider); - final isShortcutable = ref.watch( - isCurrentTabShortcutableProvider, - ); - - return Visibility( - visible: isInstallable || isShortcutable, - child: MenuItemButton( - closeOnActivate: false, - leadingIcon: const Icon(Icons.add_to_home_screen), - child: const Text('Add to Home Screen'), - onPressed: () async { - if (isInstallable) { - await showPwaInstallDialog(context, ref); - } else { - await showShortcutInstallDialog(context, ref); - } - - if (context.mounted) { - MenuController.maybeOf(context)?.close(); - } - }, - ), - ); - }, - ), + if (enableAddToHomeScreen) const _AddToHomeScreenMenuItem(), if (enableCloneTab) SubmenuButton( menuChildren: [ @@ -434,7 +391,7 @@ class TabMenu extends HookConsumerWidget { } }, ), - Consumer( + ContainerRelationUnassignedVisibility( child: MenuItemButton( leadingIcon: const Icon(MdiIcons.webPlus), child: const Text('URL relation'), @@ -475,19 +432,8 @@ class TabMenu extends HookConsumerWidget { } }, ), - builder: (context, ref, child) { - final isSiteAssigned = ref.watch( - watchIsCurrentSiteAssignedToContainerProvider, - ); - - return Visibility( - visible: - isSiteAssigned.hasValue && !isSiteAssigned.requireValue, - child: child!, - ); - }, ), - Consumer( + ContainerRelationAssignedVisibility( child: MenuItemButton( leadingIcon: const Icon(MdiIcons.webMinus), child: const Text('Unassign URL relation'), @@ -526,19 +472,9 @@ class TabMenu extends HookConsumerWidget { } }, ), - builder: (context, ref, child) { - final isSiteAssigned = ref.watch( - watchIsCurrentSiteAssignedToContainerProvider, - ); - - return Visibility( - visible: - isSiteAssigned.hasValue && isSiteAssigned.requireValue, - child: child!, - ); - }, ), - Consumer( + ContainerAssignedVisibility( + tabId: selectedTabId, child: MenuItemButton( leadingIcon: const Icon(MdiIcons.folderCancelOutline), child: const Text('Unassign Container'), @@ -550,75 +486,27 @@ class TabMenu extends HookConsumerWidget { .unassignContainer(tabState.id); }, ), - builder: (context, ref, child) { - final containerId = ref.watch( - watchContainerTabIdProvider( - selectedTabId, - ).select((value) => value.value), - ); - - return Visibility( - visible: containerId != null, - child: child!, - ); - }, ), ], leadingIcon: const Icon(MdiIcons.folder), child: const Text('Container'), ), if (enableHierarchy) - Consumer( - builder: (childContext, childRef, child) { - // `MenuItemButton.onPressed` is dispatched as a post-frame - // callback by Flutter's menu_anchor — by the time it fires, - // this `Consumer` element (and any context/ref captured from - // its builder params) has been deactivated as the menu - // overlay tears down. So: - // - use `childContext` / `childRef` only synchronously - // inside this builder (the `watch` below), - // - inside `onPressed`, use the outer `context` and `ref` - // from TabMenu.build, which live above the menu overlay - // and stay mounted with the trigger button. - final movingTab = childRef.watch( - watchTabDbDataProvider(selectedTabId), - ); - final tabData = movingTab.value; - final hasParent = tabData?.parentId != null; - - final repo = ref.read(tabDataRepositoryProvider.notifier); - - return SubmenuButton( - leadingIcon: const Icon(MdiIcons.fileTree), - menuChildren: [ - MenuItemButton( - leadingIcon: const Icon(MdiIcons.swapHorizontal), - onPressed: () async { - controller.close(); - await showTabParentPicker( - context: context, - ref: ref, - tabId: selectedTabId, - ); - }, - child: const Text('Change parent…'), - ), - MenuItemButton( - leadingIcon: const Icon(MdiIcons.fileTreeOutline), - onPressed: hasParent - ? () async { - await repo.setTabParent( - tabId: selectedTabId, - newParentId: null, - ); - } - : null, - child: const Text('Detach from parent'), - ), - ], - child: const Text('Hierarchy'), + _TabHierarchySubmenu( + selectedTabId: selectedTabId, + controller: controller, + onChangeParent: () async { + await showTabParentPicker( + context: context, + ref: ref, + tabId: selectedTabId, ); }, + onDetachFromParent: () async { + await ref + .read(tabDataRepositoryProvider.notifier) + .setTabParent(tabId: selectedTabId, newParentId: null); + }, ), if (enableReorder) SubmenuButton( @@ -708,73 +596,11 @@ class TabMenu extends HookConsumerWidget { leadingIcon: const Icon(MdiIcons.fileExport), child: const Text('Export'), ), - Consumer( - builder: (context, ref, child) { - final engineState = ref.watch(translationEngineStateProvider); - final translationState = ref.watch( - tabStateProvider( - selectedTabId, - ).select((s) => s?.translationState), - ); - final readerActive = ref.watch( - tabStateProvider( - selectedTabId, - ).select((s) => s?.readerableState.active ?? false), - ); - - // Hide when reader mode is active (Fenix-aligned) - if (readerActive || engineState?.isEngineSupported != true) { - return const SizedBox.shrink(); - } - - final isTranslated = translationState?.isTranslated ?? false; - - return MenuItemButton( - closeOnActivate: false, - leadingIcon: Icon( - Icons.translate, - color: isTranslated - ? Theme.of(context).colorScheme.primary - : null, - ), - onPressed: () async { - controller.close(); - if (context.mounted) { - await showTranslationBottomSheet( - context, - selectedTabId: selectedTabId, - ); - } - }, - child: Text(isTranslated ? 'Translated' : 'Translate Page'), - ); - }, + _TranslatePageMenuItem( + selectedTabId: selectedTabId, + controller: controller, ), - if (enablePinTab) - Consumer( - builder: (context, childRef, child) { - final isPinned = childRef.watch( - watchPinnedTabIdsProvider.select( - (v) => v.value?.contains(selectedTabId) ?? false, - ), - ); - - return MenuItemButton( - closeOnActivate: false, - onPressed: () async { - await childRef - .read(tabDataRepositoryProvider.notifier) - .setPinned(selectedTabId, pinned: !isPinned); - - if (context.mounted) { - MenuController.maybeOf(context)?.close(); - } - }, - leadingIcon: Icon(isPinned ? MdiIcons.pinOff : MdiIcons.pin), - child: Text(isPinned ? 'Unpin tab' : 'Pin tab'), - ); - }, - ), + if (enablePinTab) _PinTabMenuItem(selectedTabId: selectedTabId), if (enableCloseTab) MenuItemButton( onPressed: () => @@ -797,43 +623,243 @@ class TabMenu extends HookConsumerWidget { child: const Text('Reload'), ), if (enableNavigationButtons) - Consumer( - builder: (context, ref, child) { - final history = ref.watch( - tabStateProvider( - selectedTabId, - ).select((value) => value?.historyState), - ); - - final isLoading = ref.watch( - selectedTabStateProvider.select( - (state) => state?.isLoading ?? false, - ), - ); - - return Row( - children: [ - Expanded( - child: NavigateBackButton( - selectedTabId: selectedTabId, - isLoading: isLoading, - menuControllerToClose: controller, - canGoBack: history?.canGoBack == true, - ), - ), - const SizedBox(height: 48, child: VerticalDivider()), - Expanded( - child: NavigateForwardButton( - selectedTabId: selectedTabId, - menuControllerToClose: controller, - canGoForward: history?.canGoForward == true, - ), - ), - ], - ); - }, + _NavigationButtonsRow( + selectedTabId: selectedTabId, + controller: controller, ), ], ); } } + +class _DesktopModeMenuItem extends ConsumerWidget { + final String selectedTabId; + final MenuController controller; + final VoidCallback onToggle; + final ValueChanged onEnabledChanged; + + const _DesktopModeMenuItem({ + required this.selectedTabId, + required this.controller, + required this.onToggle, + required this.onEnabledChanged, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final enabled = ref.watch(desktopModeProvider(selectedTabId)); + + return MenuItemButton( + onPressed: onToggle, + leadingIcon: const Icon(MdiIcons.monitor), + trailingIcon: Checkbox( + value: enabled, + onChanged: (value) { + if (value != null) { + onEnabledChanged(value); + controller.close(); + } + }, + ), + child: const Text('Desktop Mode'), + ); + } +} + +class _AddToHomeScreenMenuItem extends ConsumerWidget { + const _AddToHomeScreenMenuItem(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isInstallable = ref.watch(isCurrentTabInstallableProvider); + final isShortcutable = ref.watch(isCurrentTabShortcutableProvider); + + return Visibility( + visible: isInstallable || isShortcutable, + child: MenuItemButton( + closeOnActivate: false, + leadingIcon: const Icon(Icons.add_to_home_screen), + child: const Text('Add to Home Screen'), + onPressed: () async { + if (isInstallable) { + await showPwaInstallDialog(context, ref); + } else { + await showShortcutInstallDialog(context, ref); + } + + if (context.mounted) { + MenuController.maybeOf(context)?.close(); + } + }, + ), + ); + } +} + +/// `MenuItemButton.onPressed` is dispatched as a post-frame callback by +/// Flutter's menu_anchor — by the time it fires, this widget (and any +/// context/ref it could watch) has been deactivated as the menu overlay +/// tears down. So the actual navigation/mutation ([onChangeParent] / +/// [onDetachFromParent]) is passed in from TabMenu.build, closing over +/// TabMenu's own context/ref, which live above the menu overlay and stay +/// mounted with the trigger button. +class _TabHierarchySubmenu extends ConsumerWidget { + final String selectedTabId; + final MenuController controller; + final VoidCallback onChangeParent; + final VoidCallback onDetachFromParent; + + const _TabHierarchySubmenu({ + required this.selectedTabId, + required this.controller, + required this.onChangeParent, + required this.onDetachFromParent, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final movingTab = ref.watch(watchTabDbDataProvider(selectedTabId)); + final tabData = movingTab.value; + final hasParent = tabData?.parentId != null; + + return SubmenuButton( + leadingIcon: const Icon(MdiIcons.fileTree), + menuChildren: [ + MenuItemButton( + leadingIcon: const Icon(MdiIcons.swapHorizontal), + onPressed: () { + controller.close(); + onChangeParent(); + }, + child: const Text('Change parent…'), + ), + MenuItemButton( + leadingIcon: const Icon(MdiIcons.fileTreeOutline), + onPressed: hasParent ? onDetachFromParent : null, + child: const Text('Detach from parent'), + ), + ], + child: const Text('Hierarchy'), + ); + } +} + +class _TranslatePageMenuItem extends ConsumerWidget { + final String selectedTabId; + final MenuController controller; + + const _TranslatePageMenuItem({ + required this.selectedTabId, + required this.controller, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final engineState = ref.watch(translationEngineStateProvider); + final translationState = ref.watch( + tabStateProvider(selectedTabId).select((s) => s?.translationState), + ); + final readerActive = ref.watch( + tabStateProvider( + selectedTabId, + ).select((s) => s?.readerableState.active ?? false), + ); + + // Hide when reader mode is active (Fenix-aligned) + if (readerActive || engineState?.isEngineSupported != true) { + return const SizedBox.shrink(); + } + + final isTranslated = translationState?.isTranslated ?? false; + + return MenuItemButton( + closeOnActivate: false, + leadingIcon: Icon( + Icons.translate, + color: isTranslated ? Theme.of(context).colorScheme.primary : null, + ), + onPressed: () async { + controller.close(); + if (context.mounted) { + await showTranslationBottomSheet( + context, + selectedTabId: selectedTabId, + ); + } + }, + child: Text(isTranslated ? 'Translated' : 'Translate Page'), + ); + } +} + +class _PinTabMenuItem extends ConsumerWidget { + final String selectedTabId; + + const _PinTabMenuItem({required this.selectedTabId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isPinned = ref.watch( + watchPinnedTabIdsProvider.select( + (v) => v.value?.contains(selectedTabId) ?? false, + ), + ); + + return MenuItemButton( + closeOnActivate: false, + onPressed: () async { + await ref + .read(tabDataRepositoryProvider.notifier) + .setPinned(selectedTabId, pinned: !isPinned); + + if (context.mounted) { + MenuController.maybeOf(context)?.close(); + } + }, + leadingIcon: Icon(isPinned ? MdiIcons.pinOff : MdiIcons.pin), + child: Text(isPinned ? 'Unpin tab' : 'Pin tab'), + ); + } +} + +class _NavigationButtonsRow extends ConsumerWidget { + final String selectedTabId; + final MenuController controller; + + const _NavigationButtonsRow({ + required this.selectedTabId, + required this.controller, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final history = ref.watch( + tabStateProvider(selectedTabId).select((value) => value?.historyState), + ); + + final isLoading = ref.watch( + selectedTabStateProvider.select((state) => state?.isLoading ?? false), + ); + + return Row( + children: [ + Expanded( + child: NavigateBackButton( + selectedTabId: selectedTabId, + isLoading: isLoading, + menuControllerToClose: controller, + canGoBack: history?.canGoBack == true, + ), + ), + const SizedBox(height: 48, child: VerticalDivider()), + Expanded( + child: NavigateForwardButton( + selectedTabId: selectedTabId, + menuControllerToClose: controller, + canGoForward: history?.canGoForward == true, + ), + ), + ], + ); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_relation_visibility.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_relation_visibility.dart new file mode 100644 index 00000000..d8e859da --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_relation_visibility.dart @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; + +/// Shows [child] only once [watchIsCurrentSiteAssignedToContainerProvider] +/// has resolved and the current tab's site is not yet assigned to a +/// container. Shared between TabMenu and the browser menu bottom sheet so +/// the "assign URL to container" affordance stays in sync in both surfaces. +class ContainerRelationUnassignedVisibility extends ConsumerWidget { + final Widget child; + + const ContainerRelationUnassignedVisibility({super.key, required this.child}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isSiteAssigned = ref.watch( + watchIsCurrentSiteAssignedToContainerProvider, + ); + + return Visibility( + visible: isSiteAssigned.hasValue && !isSiteAssigned.requireValue, + child: child, + ); + } +} + +/// Shows [child] only once [watchIsCurrentSiteAssignedToContainerProvider] +/// has resolved and the current tab's site is already assigned to a +/// container. Shared between TabMenu and the browser menu bottom sheet. +class ContainerRelationAssignedVisibility extends ConsumerWidget { + final Widget child; + + const ContainerRelationAssignedVisibility({super.key, required this.child}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isSiteAssigned = ref.watch( + watchIsCurrentSiteAssignedToContainerProvider, + ); + + return Visibility( + visible: isSiteAssigned.hasValue && isSiteAssigned.requireValue, + child: child, + ); + } +} + +/// Shows [child] only when [tabId] currently has a container assigned. +/// Shared between TabMenu and the browser menu bottom sheet. +class ContainerAssignedVisibility extends ConsumerWidget { + final String tabId; + final Widget child; + + const ContainerAssignedVisibility({ + super.key, + required this.tabId, + required this.child, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final containerId = ref.watch( + watchContainerTabIdProvider(tabId).select((value) => value.value), + ); + + return Visibility(visible: containerId != null, child: child); + } +}