refactor consumers into widgets

This commit is contained in:
Fabian Freund
2026-07-30 04:50:00 +02:00
parent 4bc267969b
commit f969055b21
4 changed files with 1038 additions and 680 deletions
@@ -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<Offset> 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<Offset> 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<Offset> 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,
),
],
),
@@ -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<ContainerSelectionResult?>(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<ContainerSelectionResult?>(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);
},
),
),
],
),
@@ -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<bool> 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,
),
),
],
);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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);
}
}