From d83487325c19fc501c213a9777410784f3e689a8 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Sun, 5 Jul 2026 08:43:46 +0200 Subject: [PATCH] side rail initial --- .../widgets/pinned_addon_bar.dart | 70 ++-- .../domain/services/browser_data.g.dart | 2 +- .../widgets/contextual_toolbar.dart | 57 ++- .../browser/presentation/screens/browser.dart | 288 ++++++++++---- .../browser_modules/app_bar_title.dart | 263 +++++++++++++ .../browser_modules/bottom_app_bar.dart | 355 +++++++++++++++--- .../browser_modules/draggable_fab.dart | 44 ++- .../quick_tab_switcher_accordion.dart | 160 ++++++-- .../features/history/domain/providers.g.dart | 2 +- .../repositories/container_history.g.dart | 2 +- .../services/visit_container_recorder.g.dart | 2 +- .../widgets/toolbar_layout_content.dart | 10 + .../presentation/widgets/toolbar_preview.dart | 178 ++++++--- .../user/data/models/general_settings.dart | 18 +- .../user/data/models/general_settings.g.dart | 2 + .../widgets/selectable_chips.dart | 19 +- 16 files changed, 1197 insertions(+), 275 deletions(-) diff --git a/apps/weblibre/lib/features/addons/presentation/widgets/pinned_addon_bar.dart b/apps/weblibre/lib/features/addons/presentation/widgets/pinned_addon_bar.dart index fa048d8d..69836ac2 100644 --- a/apps/weblibre/lib/features/addons/presentation/widgets/pinned_addon_bar.dart +++ b/apps/weblibre/lib/features/addons/presentation/widgets/pinned_addon_bar.dart @@ -27,7 +27,11 @@ import 'package:weblibre/features/geckoview/domain/providers/web_extensions_stat import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart'; class PinnedAddonBar extends ConsumerWidget { - const PinnedAddonBar({super.key}); + const PinnedAddonBar({super.key, this.axis = Axis.horizontal}); + + /// Layout direction. Vertical stacks the pinned add-on icons for the side + /// rail; horizontal is the standard top/bottom bar layout. + final Axis axis; @override Widget build(BuildContext context, WidgetRef ref) { @@ -45,37 +49,43 @@ class PinnedAddonBar extends ConsumerWidget { .toList(); if (pinned.isEmpty) return const SizedBox.shrink(); + final isVertical = axis == Axis.vertical; + + final items = [ + for (final extension in pinned) + InkResponse( + radius: 22, + onTap: () async { + await ref + .read(addonServiceProvider) + .invokeAddonAction( + extension.extensionId, + WebExtensionActionType.browser, + ); + }, + onLongPress: () async { + await AddonDetailsRoute( + addonId: extension.extensionId, + ).push(context); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 8.0), + child: ExtensionBadgeIcon(extension), + ), + ), + ]; + + if (isVertical) { + // On the narrow rail, stack the pinned icons and let them scroll if they + // exceed the available height. + return SingleChildScrollView( + child: Column(mainAxisSize: MainAxisSize.min, children: items), + ); + } + return Padding( padding: const EdgeInsets.only(right: 6.0), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (final extension in pinned) - InkResponse( - radius: 22, - onTap: () async { - await ref - .read(addonServiceProvider) - .invokeAddonAction( - extension.extensionId, - WebExtensionActionType.browser, - ); - }, - onLongPress: () async { - await AddonDetailsRoute( - addonId: extension.extensionId, - ).push(context); - }, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 6.0, - vertical: 8.0, - ), - child: ExtensionBadgeIcon(extension), - ), - ), - ], - ), + child: Row(mainAxisSize: MainAxisSize.min, children: items), ); } } diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_data.g.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_data.g.dart index 3d7e8522..d60f77b8 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_data.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_data.g.dart @@ -42,7 +42,7 @@ final class BrowserDataServiceProvider } String _$browserDataServiceHash() => - r'a10ac863a4c80e3ed882c3d2a2c1b8535a257ac0'; + r'a317f0d56d1bfc61dc99af1e60080aaa248d6eed'; abstract class _$BrowserDataService extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_toolbar.dart b/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_toolbar.dart index f6739090..b304c81a 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_toolbar.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_toolbar.dart @@ -34,11 +34,16 @@ class ContextualToolbar extends HookConsumerWidget { super.key, required this.selectedTabId, required this.displayedSheet, + this.axis = Axis.horizontal, }); final String? selectedTabId; final Sheet? displayedSheet; + /// Layout direction, forwarded to [ContextualToolbarView]. Vertical for the + /// side rail. + final Axis axis; + @override Widget build(BuildContext context, WidgetRef ref) { final tabState = ref.watch(tabStateProvider(selectedTabId)); @@ -67,7 +72,7 @@ class ContextualToolbar extends HookConsumerWidget { .map((button) => _buildButton(scope, context, ref, button)) .toList(); - return ContextualToolbarView(buttons: buttons); + return ContextualToolbarView(buttons: buttons, axis: axis); } Widget _buildButton( @@ -90,16 +95,64 @@ class ContextualToolbar extends HookConsumerWidget { } class ContextualToolbarView extends StatelessWidget { - const ContextualToolbarView({super.key, required this.buttons}); + const ContextualToolbarView({ + super.key, + required this.buttons, + this.axis = Axis.horizontal, + }); final List buttons; + /// Layout direction of the contextual button strip. Horizontal for the + /// top/bottom tab bar, vertical for the side rail. + final Axis axis; + static const _minButtonWidth = 48.0; @override Widget build(BuildContext context) { if (buttons.isEmpty) return const SizedBox.shrink(); + if (axis == Axis.vertical) { + return LayoutBuilder( + builder: (context, constraints) { + // spaceEvenly needs a bounded height; in the rail the contextual + // strip usually sits in an intrinsic (unbounded) slot, so fall back + // to a min-sized fixed-height column there. + final fitsEvenly = + constraints.maxHeight.isFinite && + constraints.maxHeight >= _minButtonWidth * buttons.length; + + if (fitsEvenly) { + return Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: buttons, + ); + } + + // Let buttons size to their natural height instead of clamping them + // into fixed _minButtonWidth-tall slots: some buttons (e.g. the + // tab-count box, which carries its own ToolbarButton padding) are + // taller than that, and a short SizedBox + Center would clip them. + // + // UnconstrainedBox frees the horizontal axis so each button + // shrink-wraps its content instead of stretching to the rail width + // (the tab-count box's inner Center would otherwise fill it); the + // Column then centers each on the cross axis. + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final button in buttons) + UnconstrainedBox( + constrainedAxis: Axis.vertical, + child: button, + ), + ], + ); + }, + ); + } + return LayoutBuilder( builder: (context, constraints) { final fitsEvenly = 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 b6fe94ca..857a7564 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 @@ -108,9 +108,12 @@ class _AnimatedToolbar extends HookWidget { }, [visible]); final slideAnimation = useMemoized(() { - final begin = position == TabBarPosition.top - ? const Offset(0, -1) - : const Offset(0, 1); + final begin = switch (position) { + TabBarPosition.top => const Offset(0, -1), + TabBarPosition.bottom => const Offset(0, 1), + TabBarPosition.left => const Offset(-1, 0), + TabBarPosition.right => const Offset(1, 0), + }; return Tween(begin: begin, end: Offset.zero).animate( CurvedAnimation(parent: controller, curve: Curves.easeInOutQuart), @@ -223,7 +226,9 @@ class _TabBar extends HookConsumerWidget { }, ); - // Return the toolbar widget - parent handles animation + // Return the toolbar widget - parent handles animation. + // Rail positions are rendered by a dedicated Stack layer, not _TabBar, but + // are handled here for exhaustiveness/correctness. return switch (tabBarPosition) { TabBarPosition.top => BrowserTopAppBar( showMainToolbar: showMainToolbar, @@ -239,6 +244,12 @@ class _TabBar extends HookConsumerWidget { quickTabSwitcherRowCount: quickTabSwitcherRowCount, isSmallWebMode: isSmallWebMode, ), + TabBarPosition.left || TabBarPosition.right => BrowserSideRail( + position: tabBarPosition, + showContextualToolbar: showContextualToolbar, + quickTabSwitcherRowCount: quickTabSwitcherRowCount, + isSmallWebMode: isSmallWebMode, + ), }; } } @@ -539,8 +550,13 @@ class BrowserScreen extends HookConsumerWidget { ? 0 : ref.watch(quickTabSwitcherRowCountProvider).value ?? 0; + // Vertical side rail (left/right). Auto-hide is not supported on the rail; + // it is reserved via a plain content offset and dismissed only by gesture. + final isRail = tabBarPosition.isVertical; + final autoHideTabBar = !isSmallWebActive && + tabBarPosition.isHorizontal && ref.watch( generalSettingsWithDefaultsProvider.select( (value) => value.autoHideTabBar, @@ -641,6 +657,9 @@ class BrowserScreen extends HookConsumerWidget { bottomAppBarContentSize = const Size.fromHeight( SmallWebBrowserOverlay.barHeight, ); + } else if (isRail) { + // The rail occupies a side, not the bottom; no bottom bar is rendered. + bottomAppBarContentSize = Size.zero; } else { // Pass actual displayedSheet to get correct height when ViewTabsSheet hides main toolbar bottomAppBarContentSize = BrowserBottomAppBar( @@ -655,6 +674,25 @@ class BrowserScreen extends HookConsumerWidget { final bottomAppBarTotalHeight = bottomAppBarContentSize.height + bottomSafeArea; + // Side rail width reservation (vertical positions only): the fixed content + // width plus the system safe-area inset on the rail's outer edge. + final horizontalSafeArea = switch (tabBarPosition) { + TabBarPosition.left => MediaQuery.of(context).padding.left, + TabBarPosition.right => MediaQuery.of(context).padding.right, + _ => 0.0, + }; + final sideRailTotalWidth = isRail + ? BrowserTabBar.sideRailWidth + horizontalSafeArea + : 0.0; + // Horizontal insets used to keep overlays (progress, find-in-page) clear of + // the rail on its docked edge. + final railLeftInset = tabBarPosition == TabBarPosition.left + ? sideRailTotalWidth + : 0.0; + final railRightInset = tabBarPosition == TabBarPosition.right + ? sideRailTotalWidth + : 0.0; + // Calculate top toolbar size for browser offset and progress indicator final topSafeArea = MediaQuery.of(context).padding.top; final topAppBarContentSize = BrowserTopAppBar( @@ -867,8 +905,13 @@ class BrowserScreen extends HookConsumerWidget { toolbarState == ToolbarVisibility.visible); // When auto-hide is disabled, constrain browser above toolbar - // (unless toolbar is manually dismissed via swipe gesture) - final bottomOffset = (!autoHideTabBar && toolbarVisible) + // (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; @@ -879,20 +922,36 @@ class BrowserScreen extends HookConsumerWidget { ? 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. - final applyBottomSafeArea = - !tabInFullScreen && - !isSmallWebActive && - !sheetDisplayed && - bottomOffset == 0 && - toolbarState == ToolbarVisibility.dismissed; + // 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: 0, - right: 0, + left: leftOffset, + right: rightOffset, top: topOffset, bottom: bottomOffset, child: _Browser( @@ -903,6 +962,8 @@ class BrowserScreen extends HookConsumerWidget { : null, sheetDisplayed: sheetDisplayed, hasTopBarOffset: topOffset > 0, + hasLeftBarOffset: leftOffset > 0, + hasRightBarOffset: rightOffset > 0, applyBottomSafeArea: applyBottomSafeArea, ), ); @@ -923,11 +984,12 @@ class BrowserScreen extends HookConsumerWidget { ), ), - // Layer 1: Sheet (when displayed) - positioned above toolbar + // Layer 1: Sheet (when displayed) - positioned above toolbar, + // inset past the rail on its docked edge. if (sheetDisplayed) Positioned( - left: 0, - right: 0, + left: railLeftInset, + right: railRightInset, top: 0, bottom: bottomAppBarTotalHeight, child: _SheetContainer( @@ -938,54 +1000,60 @@ class BrowserScreen extends HookConsumerWidget { ), // Layer 2: Bottom Toolbar (overlay, slides in/out) - // In small web mode, show the discovery overlay instead - Positioned( - 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(), + // In small web mode, show the discovery overlay instead. + // Skipped entirely for the side rail (Layer 3b below). + if (!isRail) + Positioned( + 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, + ), ), - ) - : 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, - ), - ), - ), + ), // Layer 3: Top Toolbar (overlay, slides in/out) - only when position is top if (tabBarPosition == TabBarPosition.top) @@ -1026,6 +1094,38 @@ class BrowserScreen extends HookConsumerWidget { ), ), + // Layer 3b: Side rail (vertical, left/right). No auto-hide; it + // slides horizontally out of view only on manual dismiss. + if (isRail) + Positioned( + top: 0, + 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, + ), + ), + ), + // Layer 4: FAB (draggable via long press) Consumer( builder: (context, ref, child) { @@ -1040,6 +1140,14 @@ class BrowserScreen extends HookConsumerWidget { bottomToolbarVisible: visible, bottomAppBarHeight: bottomAppBarTotalHeight, bottomSafeArea: bottomSafeArea, + leftReservedWidth: + (tabBarPosition == TabBarPosition.left && visible) + ? sideRailTotalWidth + : 0.0, + rightReservedWidth: + (tabBarPosition == TabBarPosition.right && visible) + ? sideRailTotalWidth + : 0.0, child: child!, ); }, @@ -1064,12 +1172,24 @@ class BrowserScreen extends HookConsumerWidget { ? Duration.zero : _AnimatedToolbar._kAnimationDuration, curve: Curves.easeInOutQuart, - left: 0, - right: 0, - top: tabBarPosition == TabBarPosition.top && visible + // 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: tabBarPosition == TabBarPosition.bottom && visible + bottom: isRail + ? null + : tabBarPosition == TabBarPosition.bottom && visible ? bottomAppBarTotalHeight : tabBarPosition == TabBarPosition.bottom ? 0 @@ -1116,10 +1236,18 @@ class BrowserScreen extends HookConsumerWidget { ? Duration.zero : _AnimatedToolbar._kAnimationDuration, curve: Curves.easeInOutQuart, - left: 0, - right: 0, + left: (tabBarPosition == TabBarPosition.left && visible) + ? sideRailTotalWidth + : 0.0, + right: (tabBarPosition == TabBarPosition.right && visible) + ? sideRailTotalWidth + : 0.0, bottom: math.max( - visible ? bottomAppBarTotalHeight : bottomSafeArea, + isRail + ? bottomSafeArea + : (visible + ? bottomAppBarTotalHeight + : bottomSafeArea), MediaQuery.viewInsetsOf(context).bottom, ), child: child!, @@ -1213,6 +1341,8 @@ class _Browser extends HookConsumerWidget { final bool tabInFullScreen; final bool sheetDisplayed; final bool hasTopBarOffset; + final bool hasLeftBarOffset; + final bool hasRightBarOffset; final bool applyBottomSafeArea; const _Browser({ @@ -1221,6 +1351,8 @@ class _Browser extends HookConsumerWidget { required this.pointerMoveEventSink, required this.sheetDisplayed, required this.hasTopBarOffset, + required this.hasLeftBarOffset, + required this.hasRightBarOffset, required this.applyBottomSafeArea, }); @@ -1460,6 +1592,8 @@ class _Browser extends HookConsumerWidget { isFullscreen: tabInFullScreen, pointerMoveEventSink: pointerMoveEventSink, hasTopBarOffset: hasTopBarOffset, + hasLeftBarOffset: hasLeftBarOffset, + hasRightBarOffset: hasRightBarOffset, applyBottomSafeArea: applyBottomSafeArea, ), ), @@ -1474,11 +1608,15 @@ class _BrowserView extends StatelessWidget { final bool isFullscreen; final StreamSink? pointerMoveEventSink; final bool hasTopBarOffset; + final bool hasLeftBarOffset; + final bool hasRightBarOffset; final bool applyBottomSafeArea; const _BrowserView({ required this.isFullscreen, required this.hasTopBarOffset, + required this.hasLeftBarOffset, + required this.hasRightBarOffset, required this.applyBottomSafeArea, this.pointerMoveEventSink, }); @@ -1488,12 +1626,14 @@ class _BrowserView extends StatelessWidget { return SafeArea( // Disable top SafeArea when top bar handles it (has offset applied) top: !isFullscreen && !hasTopBarOffset, - right: !isFullscreen, + // Disable a side's SafeArea when the rail on that edge already consumed + // the inset via the content offset. + right: !isFullscreen && !hasRightBarOffset, // Apply bottom SafeArea only when no toolbar/overlay is rendered at the // bottom (and not in fullscreen). Otherwise the toolbar handles its own // safe-area inset and the platform view extends behind it. bottom: applyBottomSafeArea, - left: !isFullscreen, + left: !isFullscreen && !hasLeftBarOffset, child: Stack( children: [BrowserView(pointerMoveEventSink: pointerMoveEventSink)], ), diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart index 399e7bba..eb4a8f2f 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart @@ -494,6 +494,269 @@ class AppBarTitleView extends StatelessWidget { } } +/// Side-rail variant of the address field: an upright site-settings favicon +/// button stacked above the URL, which is rendered as rotated ("vertical") +/// text like a bookshelf spine. Reuses all the same content/behaviour as +/// [AppBarTitle] — only the layout is rotated. +class RailAppBarTitle extends ConsumerWidget { + const RailAppBarTitle({ + super.key, + required this.quarterTurns, + this.containerColor, + this.useCustomColor = false, + }); + + /// Rotation applied to the URL text. 3 (bottom-to-top) reads best on a left + /// rail; 1 (top-to-bottom) on a right rail. + final int quarterTurns; + final Color? containerColor; + final bool useCustomColor; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tabState = ref.watch(selectedTabStateProvider); + final selectedTabType = ref.watch(selectedTabTypeProvider); + final settings = ref.watch(generalSettingsWithDefaultsProvider); + final isTabTuneledAsync = ref.watch(isTabTunneledProvider(tabState?.id)); + final siteSettingsBadgeState = ref.watch( + showSiteSettingsBadgeProvider.select( + (value) => value.value ?? SiteSettingsBadgeState.hidden, + ), + ); + + if (tabState == null) { + return _EmptyRailAddressField( + quarterTurns: quarterTurns, + onTap: () async { + await SearchRoute( + tabType: selectedTabType ?? settings.effectiveDefaultCreateTabType, + ).push(context); + }, + ); + } + + final sandboxSourceUri = ref.watch( + sandboxSourceUriForTabProvider(tabId: tabState.id), + ); + + return RailAppBarTitleView( + tabState: tabState, + quarterTurns: quarterTurns, + isTabTunneled: + isTabTuneledAsync.hasValue && isTabTuneledAsync.value == true, + siteSettingsBadgeState: siteSettingsBadgeState, + longPressUrlCopy: settings.tabBarLongPressUrlCopy, + containerColor: containerColor, + useCustomColor: useCustomColor, + sandboxSourceUri: sandboxSourceUri, + onSiteSettingsTap: () { + ref + .read(bottomSheetControllerProvider.notifier) + .show(SiteSettingsSheet(tabState: tabState)); + }, + onTitleTap: () async { + await SearchRoute( + tabId: tabState.id, + searchText: searchTextForTab(tabState, sandboxSourceUri), + tabType: tabState.tabMode.toTabType(), + ).push(context); + }, + ); + } +} + +class RailAppBarTitleView extends StatelessWidget { + const RailAppBarTitleView({ + super.key, + required this.tabState, + required this.quarterTurns, + required this.isTabTunneled, + required this.siteSettingsBadgeState, + required this.onSiteSettingsTap, + required this.onTitleTap, + this.tabIcon, + this.longPressUrlCopy = true, + this.containerColor, + this.useCustomColor = false, + this.sandboxSourceUri, + }); + + final TabState tabState; + final int quarterTurns; + final bool isTabTunneled; + final SiteSettingsBadgeState siteSettingsBadgeState; + final VoidCallback onSiteSettingsTap; + final VoidCallback onTitleTap; + final Widget? tabIcon; + final bool longPressUrlCopy; + final Color? containerColor; + final bool useCustomColor; + final Uri? sandboxSourceUri; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final appColors = AppColors.of(context); + final containerColor = this.containerColor; + final containerPalette = containerColor != null + ? ContainerColors.palette( + context, + containerColor, + useCustomColor: useCustomColor, + ) + : null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ToolbarButton( + onTap: onSiteSettingsTap, + child: Stack( + clipBehavior: Clip.none, + children: [ + tabIcon ?? TabIcon(tabState: tabState, iconSize: 24), + if (siteSettingsBadgeState != SiteSettingsBadgeState.hidden) + Positioned( + top: -4, + right: -4, + child: Icon( + siteSettingsBadgeState == SiteSettingsBadgeState.improved + ? MdiIcons.shield + : MdiIcons.shieldAlert, + size: 10, + color: + siteSettingsBadgeState == SiteSettingsBadgeState.improved + ? Colors.green + : appColors.warningAmber, + ), + ), + ], + ), + ), + Expanded( + child: GestureDetector( + onTap: onTitleTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 4), + decoration: BoxDecoration( + color: containerColor != null + ? containerPalette!.surfaceColor + : theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(24), + border: containerPalette != null + ? Border.all(color: containerPalette.outlineColor) + : null, + ), + child: RotatedBox( + quarterTurns: quarterTurns, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (tabState.tabMode is PrivateTabMode) ...[ + Icon( + MdiIcons.dominoMask, + color: appColors.privateTabPurple, + size: 16, + ), + const SizedBox(width: 4), + ] else if (tabState.tabMode is IsolatedTabMode) ...[ + Icon( + MdiIcons.snowflake, + color: appColors.isolatedTabTeal, + size: 16, + ), + const SizedBox(width: 4), + ], + if (isTabTunneled) ...[ + const Icon(MdiIcons.tunnelOutline, size: 16), + const SizedBox(width: 4), + ], + if (sandboxSourceUri != null) ...[ + Icon( + MdiIcons.archiveLockOutline, + color: theme.colorScheme.tertiary, + size: 16, + ), + const SizedBox(width: 4), + ] else + _SecurityStatusIcon( + tabState: tabState, + size: 16, + containerColor: containerPalette?.accentColor, + ), + const SizedBox(width: 6), + Flexible( + child: UriBreadcrumb( + uri: sandboxSourceUri ?? tabState.url, + showHttpScheme: false, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), + onTooltipTriggered: longPressUrlCopy + ? () async { + await Clipboard.setData( + ClipboardData( + text: (sandboxSourceUri ?? tabState.url) + .toString(), + ), + ); + } + : null, + ), + ), + ], + ), + ), + ), + ), + ), + ], + ); + } +} + +class _EmptyRailAddressField extends StatelessWidget { + const _EmptyRailAddressField({ + required this.onTap, + required this.quarterTurns, + }); + + final VoidCallback onTap; + final int quarterTurns; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return GestureDetector( + onTap: onTap, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 4), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(24), + ), + alignment: Alignment.center, + child: RotatedBox( + quarterTurns: quarterTurns, + child: Text( + 'Search or enter URL', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + ), + ), + ); + } +} + class _SecurityStatusIcon extends StatelessWidget { const _SecurityStatusIcon({ required this.tabState, diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart index 2bdcbdb3..4c096d3a 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart @@ -159,6 +159,86 @@ class BrowserBottomAppBar extends StatelessWidget { Size get preferredSize => _size; } +/// Vertical side-rail wrapper (left/right positions). Exposes a fixed content +/// [preferredSize] width; the caller adds the horizontal safe-area inset on the +/// rail's outer edge to compute the browser content offset. +class BrowserSideRail extends ConsumerWidget { + final bool showContextualToolbar; + final int quickTabSwitcherRowCount; + final bool isSmallWebMode; + + /// Which edge the rail is docked to ([TabBarPosition.left] or + /// [TabBarPosition.right]). + final TabBarPosition position; + + late final BrowserTabBar _tabBar; + late final _size = Size.fromWidth(_tabBar.getToolbarWidth()); + + BrowserSideRail({ + super.key, + required this.showContextualToolbar, + required this.quickTabSwitcherRowCount, + required this.isSmallWebMode, + required this.position, + }) { + _tabBar = BrowserTabBar( + displayedSheet: null, + showMainToolbar: true, + showContextualToolbar: showContextualToolbar, + quickTabSwitcherRowCount: quickTabSwitcherRowCount, + isSmallWebMode: isSmallWebMode, + enableGestures: true, + hideMainToolbarButtonsDuplicatedInContextualToolbar: showContextualToolbar, + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isLeft = position == TabBarPosition.left; + + // Tint the outer fill with the active container's surface color (same tint + // the content and BrowserSystemBars use) so the rail's system safe-area + // strips (status/nav bar, docked-edge notch) blend with the rail instead + // of showing a neutral surfaceContainer gap. Falls back to surfaceContainer + // when no container is active. + final selectedTabId = ref.watch(selectedTabProvider); + final showContainerUi = ref.watch( + generalSettingsWithDefaultsProvider.select((s) => s.showContainerUi), + ); + final containerColor = ref.watch( + watchTabContainerDataProvider( + selectedTabId, + ).select((data) => data.value?.color), + ); + final useCustomColor = ref.watch( + watchTabContainerDataProvider( + selectedTabId, + ).select((data) => data.value?.metadata.useCustomColor ?? false), + ); + final effectiveContainerColor = (showContainerUi && containerColor != null) + ? containerColor + : null; + final tintColor = effectiveContainerColor != null + ? ContainerColors.palette( + context, + effectiveContainerColor, + useCustomColor: useCustomColor, + ).surfaceColor + : Theme.of(context).colorScheme.surfaceContainer; + + return ColoredBox( + color: tintColor, + child: SafeArea( + left: isLeft, + right: !isLeft, + child: SizedBox(width: _size.width, child: _tabBar), + ), + ); + } + + Size get preferredSize => _size; +} + class BrowserTabBar extends HookConsumerWidget { final bool showMainToolbar; final bool showContextualToolbar; @@ -182,6 +262,14 @@ class BrowserTabBar extends HookConsumerWidget { static const contextualToolabarHeight = 54.0; static const quickTabSwitcherHeight = 48.0; + /// Content width of the vertical side rail (excludes the system safe-area + /// inset on the rail's outer edge, which is added by the caller). Kept equal + /// to [kToolbarHeight] so the rail reuses the same base sizing as the + /// horizontal bar. + static const sideRailWidth = kToolbarHeight; + + double getToolbarWidth() => sideRailWidth; + bool get displayAppBar => showMainToolbar && (!showContextualToolbar || displayedSheet is! ViewTabsSheet); @@ -249,9 +337,50 @@ class BrowserTabBar extends HookConsumerWidget { final stackingMode = settings.effectiveTabBarStackingMode(); final tabBarPosition = settings.tabBarPosition; + final isVertical = tabBarPosition.isVertical; + final switcherAxis = tabBarPosition.axis; + // Left rail reads bottom-to-top, right rail top-to-bottom. + final railQuarterTurns = tabBarPosition == TabBarPosition.left ? 3 : 1; final dragStartPosition = useRef(Offset.zero); + // Swipe along the primary switch axis moves between tabs. [delta] is + // (dragStart - dragEnd) along that axis; its sign chooses prev/next. + Future switchTabsBy(double delta) async { + final selectedTab = ref.read(selectedTabProvider); + final setting = await ref + .read(generalSettingsRepositoryProvider.notifier) + .fetchSettings(); + + if (selectedTab == null) return; + + switch (setting.tabBarSwipeAction) { + case TabBarSwipeAction.switchLastOpened: + await ref + .read(tabRepositoryProvider.notifier) + .selectPreviouslyOpenedTab(selectedTab); + case TabBarSwipeAction.navigateOrderedTabs: + if (delta < 0) { + await ref + .read(tabRepositoryProvider.notifier) + .selectPreviousTab(selectedTab); + } else { + await ref + .read(tabRepositoryProvider.notifier) + .selectNextTab(selectedTab); + } + } + } + + void dismissToolbar() { + if (ref.read(bottomSheetControllerProvider) == null) { + unawaited(HapticFeedback.lightImpact()); + ref + .read(toolbarVisibilityControllerProvider(selectedTabId).notifier) + .dismiss(); + } + } + final showTabTitle = displayedSheet is! ViewTabsSheet; final effectiveContainerColor = @@ -271,6 +400,8 @@ class BrowserTabBar extends HookConsumerWidget { : null; return BrowserTabBarView( + axis: switcherAxis, + railOnLeft: tabBarPosition == TabBarPosition.left, showMainToolbar: showMainToolbar, showContextualToolbar: showContextualToolbar, showQuickTabSwitcherBar: quickTabSwitcherRowCount > 0, @@ -278,7 +409,13 @@ class BrowserTabBar extends HookConsumerWidget { displayQuickTabSwitcher: displayQuickTabSwitcher, backgroundColor: effectiveContainerPalette?.surfaceColor, title: showTabTitle - ? settings.tabBarLayout == TabBarLayout.compact + ? isVertical + ? RailAppBarTitle( + quarterTurns: railQuarterTurns, + containerColor: effectiveContainerColor, + useCustomColor: effectiveUseCustomColor, + ) + : settings.tabBarLayout == TabBarLayout.compact ? CompactAppBarTitle( containerColor: effectiveContainerColor, useCustomColor: effectiveUseCustomColor, @@ -289,7 +426,7 @@ class BrowserTabBar extends HookConsumerWidget { ) : null, actions: [ - const PinnedAddonBar(), + PinnedAddonBar(axis: switcherAxis), if (isSmallWebMode) ReaderButton( buttonBuilder: (isLoading, readerActive, icon) => ToolbarButton( @@ -314,31 +451,57 @@ class BrowserTabBar extends HookConsumerWidget { ], quickTabSwitcher: switch (stackingMode) { TabBarStackingMode.disabled => const SizedBox.shrink(), - TabBarStackingMode.lastUsedTabs => const QuickTabSwitcher( + TabBarStackingMode.lastUsedTabs => QuickTabSwitcher( quickTabSwitcherMode: QuickTabSwitcherMode.lastUsedTabs, + axis: switcherAxis, ), - TabBarStackingMode.containerTabs => const QuickTabSwitcher( + TabBarStackingMode.containerTabs => QuickTabSwitcher( quickTabSwitcherMode: QuickTabSwitcherMode.containerTabs, + axis: switcherAxis, + ), + TabBarStackingMode.accordion => AccordionQuickTabSwitcher( + axis: switcherAxis, ), - TabBarStackingMode.accordion => const AccordionQuickTabSwitcher(), // History fallback only on the MRU row, so empty-state history - // chips don't show twice. - TabBarStackingMode.twoLevel => const Column( - mainAxisSize: MainAxisSize.min, - children: [ - QuickTabSwitcher( - quickTabSwitcherMode: QuickTabSwitcherMode.containerTabs, - enableHistoryFallback: false, - ), - QuickTabSwitcher( - quickTabSwitcherMode: QuickTabSwitcherMode.lastUsedTabs, - ), - ], - ), + // chips don't show twice. On the rail the two rows stack as two + // equal-height vertical lists. + TabBarStackingMode.twoLevel => + isVertical + ? Column( + children: [ + Expanded( + child: QuickTabSwitcher( + quickTabSwitcherMode: + QuickTabSwitcherMode.containerTabs, + enableHistoryFallback: false, + axis: switcherAxis, + ), + ), + Expanded( + child: QuickTabSwitcher( + quickTabSwitcherMode: QuickTabSwitcherMode.lastUsedTabs, + axis: switcherAxis, + ), + ), + ], + ) + : const Column( + mainAxisSize: MainAxisSize.min, + children: [ + QuickTabSwitcher( + quickTabSwitcherMode: QuickTabSwitcherMode.containerTabs, + enableHistoryFallback: false, + ), + QuickTabSwitcher( + quickTabSwitcherMode: QuickTabSwitcherMode.lastUsedTabs, + ), + ], + ), }, contextualToolbar: ContextualToolbar( selectedTabId: selectedTabId, displayedSheet: displayedSheet, + axis: switcherAxis, ), onHorizontalDragStart: !enableGestures ? null @@ -349,30 +512,21 @@ class BrowserTabBar extends HookConsumerWidget { ? null : (details) async { final distance = dragStartPosition.value - details.globalPosition; + const dismissThreshold = kToolbarHeight * 0.5; - if (distance.dx.abs() > 50 && distance.dy.abs() < 20) { - final selectedTab = ref.read(selectedTabProvider); - final setting = await ref - .read(generalSettingsRepositoryProvider.notifier) - .fetchSettings(); - - if (selectedTab != null) { - switch (setting.tabBarSwipeAction) { - case TabBarSwipeAction.switchLastOpened: - await ref - .read(tabRepositoryProvider.notifier) - .selectPreviouslyOpenedTab(selectedTab); - case TabBarSwipeAction.navigateOrderedTabs: - if (distance.dx < 0) { - await ref - .read(tabRepositoryProvider.notifier) - .selectPreviousTab(selectedTab); - } else { - await ref - .read(tabRepositoryProvider.notifier) - .selectNextTab(selectedTab); - } - } + if (isVertical) { + // Rail: horizontal swipe dismisses toward the docked edge. + // distance = start - end, so a leftward swipe is positive dx. + final shouldDismiss = switch (tabBarPosition) { + TabBarPosition.left => distance.dx > dismissThreshold, + TabBarPosition.right => distance.dx < -dismissThreshold, + _ => false, + }; + if (shouldDismiss) dismissToolbar(); + } else { + // Horizontal bar: horizontal swipe switches tabs. + if (distance.dx.abs() > 50 && distance.dy.abs() < 20) { + await switchTabsBy(distance.dx); } } }, @@ -383,10 +537,18 @@ class BrowserTabBar extends HookConsumerWidget { }, onVerticalDragEnd: !enableGestures ? null - : (details) { + : (details) async { final distance = dragStartPosition.value - details.globalPosition; - // Swipe direction for dismiss depends on toolbar position: + if (isVertical) { + // Rail: vertical swipe switches tabs. + if (distance.dy.abs() > 50 && distance.dx.abs() < 20) { + await switchTabsBy(distance.dy); + } + return; + } + + // Horizontal bar dismiss direction depends on position: // - Bottom bar: swipe down to dismiss (positive distance.dy) // - Top bar: swipe up to dismiss (negative distance.dy) const dismissThreshold = kToolbarHeight * 0.5; @@ -397,18 +559,9 @@ class BrowserTabBar extends HookConsumerWidget { TabBarPosition.top => !distance.dy.isNegative && distance.dy.abs() > dismissThreshold, + _ => false, }; - if (shouldDismiss && - ref.read(bottomSheetControllerProvider) == null) { - unawaited(HapticFeedback.lightImpact()); - ref - .read( - toolbarVisibilityControllerProvider( - selectedTabId, - ).notifier, - ) - .dismiss(); - } + if (shouldDismiss) dismissToolbar(); }, ); } @@ -427,12 +580,21 @@ class BrowserTabBarView extends StatelessWidget { required this.actions, required this.quickTabSwitcher, required this.contextualToolbar, + this.axis = Axis.horizontal, + this.railOnLeft = true, this.onHorizontalDragStart, this.onHorizontalDragEnd, this.onVerticalDragStart, this.onVerticalDragEnd, }); + /// Layout orientation. Vertical renders the side-rail form. + final Axis axis; + + /// For the vertical rail, whether it is docked to the left edge (affects + /// nothing structural here yet; reserved for edge-specific tweaks). + final bool railOnLeft; + final bool showMainToolbar; final bool showContextualToolbar; final bool showQuickTabSwitcherBar; @@ -454,6 +616,54 @@ class BrowserTabBarView extends StatelessWidget { final effectiveBackgroundColor = backgroundColor ?? colorScheme.surfaceContainer; + if (axis == Axis.vertical) { + return GestureDetector( + onHorizontalDragStart: onHorizontalDragStart, + onHorizontalDragEnd: onHorizontalDragEnd, + onVerticalDragStart: onVerticalDragStart, + onVerticalDragEnd: onVerticalDragEnd, + child: ColoredBox( + color: effectiveBackgroundColor, + child: Column( + children: [ + // Literal section order (switcher → URL+actions → contextual); + // the switcher is the flexible scroll region. + if (showQuickTabSwitcherBar) + Expanded( + flex: 3, + child: Visibility( + visible: displayQuickTabSwitcher, + maintainState: true, + child: quickTabSwitcher, + ), + ), + if (showMainToolbar) + Expanded( + flex: 2, + child: Visibility( + visible: displayAppBar, + maintainState: true, + // Horizontal inset so the URL pile and action buttons don't + // sit flush against the rail edges, matching the breathing + // room the horizontal bar's title/actions get. + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Column( + children: [ + Expanded(child: title ?? const SizedBox.shrink()), + ...actions, + ], + ), + ), + ), + ), + if (showContextualToolbar) contextualToolbar, + ], + ), + ), + ); + } + return GestureDetector( // Tap handling moved to AppBarTitle for split icon/title behavior onHorizontalDragStart: onHorizontalDragStart, @@ -505,10 +715,14 @@ class QuickTabSwitcher extends HookConsumerWidget { /// chips don't show twice. final bool enableHistoryFallback; + /// Direction the chips list flows. Vertical for the side rail. + final Axis axis; + const QuickTabSwitcher({ super.key, required this.quickTabSwitcherMode, this.enableHistoryFallback = true, + this.axis = Axis.horizontal, }); @override @@ -516,11 +730,13 @@ class QuickTabSwitcher extends HookConsumerWidget { final showIsolatedTabUi = ref.watch( generalSettingsWithDefaultsProvider.select((s) => s.showIsolatedTabUi), ); - final showTitles = ref.watch( + final showTitlesSetting = ref.watch( generalSettingsWithDefaultsProvider.select( (s) => s.quickTabSwitcherShowTitles, ), ); + // Titles can't fit the narrow vertical rail; force icon-only chips there. + final showTitles = axis != Axis.vertical && showTitlesSetting; final titleMaxWidth = ref.watch( generalSettingsWithDefaultsProvider.select( (s) => s.quickTabSwitcherTitleWidth, @@ -677,6 +893,7 @@ class QuickTabSwitcher extends HookConsumerWidget { scrollController: chipScrollController, scrollKey: scrollKey, activeItemKey: activeItemKey.value, + axis: axis, showTitles: showTitles, showIsolatedTabUi: showIsolatedTabUi, hierarchyGlyphs: hierarchyGlyphs, @@ -764,8 +981,12 @@ class QuickTabSwitcherView extends StatelessWidget { this.onCloseItem, this.onReorderItem, this.reorderableItemCount = 0, + this.axis = Axis.horizontal, }); + /// Direction the chips flow. Vertical for the side rail. + final Axis axis; + final List availableItems; final QuickTabSwitcherItem? activeItem; final ScrollController scrollController; @@ -803,25 +1024,36 @@ class QuickTabSwitcherView extends StatelessWidget { bool get _reorderEnabled => onReorderItem != null && reorderableItemCount > 0; - /// Whether [item]'s chip shows a close button. + /// Whether [item]'s chip shows a close button. Never on the narrow vertical + /// rail: an icon-only chip has no room for a close button beside it (it + /// overflows). Closing stays available via the long-press menu. bool _canShowCloseButton(QuickTabSwitcherItem item) => + !_isVertical && onCloseItem != null && !item.isHistory && !item.isPlaceholder && (showCloseButtonOnAllTabs || item.isActive); + bool get _isVertical => axis == Axis.vertical; + @override Widget build(BuildContext context) { if (availableItems.isEmpty) { - // Hold the 48px row slot: in two-level stacking an empty row must not + // Hold the 48px slot: in two-level stacking an empty row must not // collapse, since the toolbar height already accounts for both rows. - return const SizedBox(height: 48); + // On the rail the cross-axis width is fixed and the (vertical) list + // fills the available height. + return _isVertical ? const SizedBox(width: 48) : const SizedBox(height: 48); } return Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), + padding: _isVertical + ? const EdgeInsets.symmetric(vertical: 4.0) + : const EdgeInsets.symmetric(horizontal: 4.0), child: SizedBox( - height: 48, + // Vertical fills both axes of the rail content column; horizontal keeps + // the fixed 48px row height. + height: _isVertical ? double.maxFinite : 48, width: double.maxFinite, child: _reorderEnabled ? _buildReorderableList(context) @@ -838,6 +1070,7 @@ class QuickTabSwitcherView extends StatelessWidget { scrollController: scrollController, scrollKey: scrollKey, activeItemKey: activeItemKey, + scrollDirection: axis, cacheExtent: 500, itemId: (item) => item.id, selectedItem: activeItem, @@ -867,7 +1100,7 @@ class QuickTabSwitcherView extends StatelessWidget { return ReorderableListView.builder( key: scrollKey, scrollController: scrollController, - scrollDirection: Axis.horizontal, + scrollDirection: axis, buildDefaultDragHandles: true, scrollCacheExtent: const ScrollCacheExtent.pixels(500), itemCount: reorderableCount, diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/draggable_fab.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/draggable_fab.dart index 9ded6db6..ea3ee23e 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/draggable_fab.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/draggable_fab.dart @@ -18,6 +18,7 @@ * along with this program. If not, see . */ import 'dart:async'; +import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -30,6 +31,8 @@ class DraggableFab extends HookConsumerWidget { final bool bottomToolbarVisible; final double bottomAppBarHeight; final double bottomSafeArea; + final double leftReservedWidth; + final double rightReservedWidth; const DraggableFab({ super.key, @@ -38,6 +41,8 @@ class DraggableFab extends HookConsumerWidget { required this.bottomToolbarVisible, required this.bottomAppBarHeight, required this.bottomSafeArea, + this.leftReservedWidth = 0.0, + this.rightReservedWidth = 0.0, }); static const _edgePadding = 16.0; @@ -82,11 +87,20 @@ class DraggableFab extends HookConsumerWidget { final defaultBottom = bottomToolbarVisible ? bottomAppBarHeight + _edgePadding : _edgePadding + bottomSafeArea; - const defaultRight = _edgePadding; + final defaultRight = _edgePadding + rightReservedWidth; // Current position: custom if set, otherwise default - final currentRight = customOffset.value?.dx ?? defaultRight; - final currentBottom = customOffset.value?.dy ?? defaultBottom; + final rawRight = customOffset.value?.dx ?? defaultRight; + final rawBottom = customOffset.value?.dy ?? defaultBottom; + final currentOffset = _clampToBounds( + Offset(rawRight, rawBottom), + screenSize: screenSize, + padding: padding, + fabWidth: fabWidth, + fabHeight: fabHeight, + ); + final currentRight = currentOffset.dx; + final currentBottom = currentOffset.dy; return Positioned( right: currentRight, @@ -145,19 +159,27 @@ class DraggableFab extends HookConsumerWidget { required double fabHeight, }) { const minEdgePadding = 8.0; + final minRight = + math.max(padding.right, rightReservedWidth) + minEdgePadding; + final maxRight = math.max( + minRight, + screenSize.width - + fabWidth - + math.max(padding.left, leftReservedWidth) - + minEdgePadding, + ); + final minBottom = padding.bottom + minEdgePadding; + final maxBottom = math.max( + minBottom, + screenSize.height - fabHeight - padding.top - minEdgePadding, + ); // Distances from the bottom-right corner: keep at least the safe-area inset // plus a margin on the near edge, and leave room for the (possibly stacked) // FAB on the far edge so it can't be dragged off-screen. return Offset( - offset.dx.clamp( - padding.right + minEdgePadding, - screenSize.width - fabWidth - padding.left - minEdgePadding, - ), - offset.dy.clamp( - padding.bottom + minEdgePadding, - screenSize.height - fabHeight - padding.top - minEdgePadding, - ), + offset.dx.clamp(minRight, maxRight), + offset.dy.clamp(minBottom, maxBottom), ); } } diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/quick_tab_switcher_accordion.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/quick_tab_switcher_accordion.dart index bde5b22e..4bcf614f 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/quick_tab_switcher_accordion.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/quick_tab_switcher_accordion.dart @@ -50,10 +50,14 @@ import 'package:weblibre/presentation/widgets/inline_count_badge.dart'; /// "expanded" — its tabs appear inline right after its header. Tapping /// another header selects that container, collapsing the previous group. class AccordionQuickTabSwitcher extends HookConsumerWidget { - const AccordionQuickTabSwitcher({super.key}); + const AccordionQuickTabSwitcher({super.key, this.axis = Axis.horizontal}); + + /// Direction the accordion flows. Vertical for the side rail. + final Axis axis; @override Widget build(BuildContext context, WidgetRef ref) { + final isVertical = axis == Axis.vertical; final scrollController = useScrollController(); final activeChipKey = useRef(GlobalKey()); final isUserScrolling = useRef(false); @@ -63,11 +67,13 @@ class AccordionQuickTabSwitcher extends HookConsumerWidget { return userScrollTimer.value?.cancel; }, []); - final showTitles = ref.watch( + final showTitlesSetting = ref.watch( generalSettingsWithDefaultsProvider.select( (s) => s.quickTabSwitcherShowTitles, ), ); + // Titles can't fit the narrow vertical rail; force icon-only chips there. + final showTitles = !isVertical && showTitlesSetting; final showIsolatedTabUi = ref.watch( generalSettingsWithDefaultsProvider.select((s) => s.showIsolatedTabUi), ); @@ -172,8 +178,13 @@ class AccordionQuickTabSwitcher extends HookConsumerWidget { Widget buildTabChip(QuickTabSwitcherItem item) { final isSelected = item.isActive; + // The narrow rail can't fit a close button beside the icon-only chip; it + // overflows (and the active tab's thick border makes it worse). Closing + // stays available via the long-press menu. final canClose = - !item.isPlaceholder && (showCloseButtonOnAllTabs || item.isActive); + !isVertical && + !item.isPlaceholder && + (showCloseButtonOnAllTabs || item.isActive); final chip = QuickTabSwitcherChip( item: item, @@ -300,9 +311,9 @@ class AccordionQuickTabSwitcher extends HookConsumerWidget { ); if (entries.isEmpty) { - // Hold the 48px row slot; the bar visibility is decided upstream by + // Hold the 48px slot; the bar visibility is decided upstream by // quickTabSwitcherRowCountProvider. - return const SizedBox(height: 48); + return isVertical ? const SizedBox(width: 48) : const SizedBox(height: 48); } return NotificationListener( @@ -315,10 +326,12 @@ class AccordionQuickTabSwitcher extends HookConsumerWidget { return false; }, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), + padding: isVertical + ? const EdgeInsets.symmetric(vertical: 4.0) + : const EdgeInsets.symmetric(horizontal: 4.0), child: SizedBox( - height: 48, - width: double.maxFinite, + height: isVertical ? double.maxFinite : 48, + width: isVertical ? 48 : double.maxFinite, child: FadingScroll( controller: scrollController, fadingSize: 15, @@ -326,7 +339,7 @@ class AccordionQuickTabSwitcher extends HookConsumerWidget { return ListView.builder( key: const PageStorageKey('quick_tab_switcher_accordion'), controller: controller, - scrollDirection: Axis.horizontal, + scrollDirection: axis, scrollCacheExtent: const ScrollCacheExtent.pixels(500), itemCount: entries.length, itemBuilder: (context, index) { @@ -334,6 +347,9 @@ class AccordionQuickTabSwitcher extends HookConsumerWidget { final child = switch (entry) { _AccordionHeaderEntry() => _AccordionHeaderChip( entry: entry, + // The narrow rail can't fit the container title; show the + // container icon avatar + count badge only. + showTitle: !isVertical, onSelected: () => selectContainer(entry.container?.id), ), _AccordionTabEntry(:final item) => buildTabChip(item), @@ -346,6 +362,7 @@ class AccordionQuickTabSwitcher extends HookConsumerWidget { child: _TraySlice( position: trayPositions[index], fill: trayFill, + axis: axis, child: child, ), ); @@ -407,7 +424,15 @@ class _AccordionHeaderChip extends StatelessWidget { final _AccordionHeaderEntry entry; final VoidCallback onSelected; - const _AccordionHeaderChip({required this.entry, required this.onSelected}); + /// When false (e.g. the narrow vertical rail) the container title is hidden + /// and only the icon avatar + count badge are shown, so the chip fits. + final bool showTitle; + + const _AccordionHeaderChip({ + required this.entry, + required this.onSelected, + this.showTitle = true, + }); @override Widget build(BuildContext context) { @@ -446,10 +471,49 @@ class _AccordionHeaderChip extends StatelessWidget { ) : null; + final iconAvatar = container != null + ? buildContainerChipAvatar(context, container, true) + : Icon(MdiIcons.folderHidden, color: nullForeground); + + // Same fill regardless of selection — the tray (added when expanded) + // is what signals the active container, not a header recolor. + final side = BorderSide(width: 2, color: fill); + final shape = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + side: side, + ); + + if (!showTitle) { + // Narrow rail: no room for the avatar slot + title + trailing badge side + // by side (the badge gets clipped). Stack the container icon over the + // count badge inside the label instead, dropping the avatar slot. + return FilterChip( + labelPadding: EdgeInsets.zero, + label: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (iconAvatar != null) iconAvatar, + if (countBadge != null) ...[ + if (iconAvatar != null) const SizedBox(height: 4), + countBadge, + ], + ], + ), + color: WidgetStatePropertyAll(fill), + selected: false, + showCheckmark: false, + onSelected: (value) { + if (value) { + onSelected(); + } + }, + side: side, + shape: shape, + ); + } + return FilterChip( - avatar: container != null - ? buildContainerChipAvatar(context, container, true) - : Icon(MdiIcons.folderHidden, color: nullForeground), + avatar: iconAvatar, label: container != null ? buildContainerChipLabel( context, @@ -468,8 +532,6 @@ class _AccordionHeaderChip extends StatelessWidget { ), ), ), - // Same fill regardless of selection — the tray (added when expanded) - // is what signals the active container, not a header recolor. color: WidgetStatePropertyAll(fill), selected: false, showCheckmark: false, @@ -478,11 +540,8 @@ class _AccordionHeaderChip extends StatelessWidget { onSelected(); } }, - side: BorderSide(width: 2, color: fill), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8.0), - side: BorderSide(width: 2, color: fill), - ), + side: side, + shape: shape, ); } } @@ -500,11 +559,13 @@ class _TraySlice extends StatelessWidget { final _TrayPosition position; final Color fill; final Widget child; + final Axis axis; const _TraySlice({ required this.position, required this.fill, required this.child, + this.axis = Axis.horizontal, }); /// Corner radius of the chips, matched by the tray so it hugs the first and @@ -513,40 +574,59 @@ class _TraySlice extends StatelessWidget { @override Widget build(BuildContext context) { + final isVertical = axis == Axis.vertical; + if (position == _TrayPosition.none) { - // Standalone container header: regular inter-chip spacing, vertically - // centered to line up with the tray slices. + // Standalone container header: regular inter-chip spacing, centered + // on the cross axis to line up with the tray slices. return Padding( - padding: const EdgeInsets.fromLTRB(0.0, 2.0, 8.0, 2.0), + padding: isVertical + ? const EdgeInsets.fromLTRB(0.0, 0.0, 0.0, 8.0) + : const EdgeInsets.fromLTRB(0.0, 2.0, 8.0, 2.0), child: child, ); } - final borderRadius = switch (position) { - _TrayPosition.solo => const BorderRadius.all(_radius), - _TrayPosition.start => const BorderRadius.horizontal(left: _radius), - _TrayPosition.end => const BorderRadius.horizontal(right: _radius), - _TrayPosition.middle || _TrayPosition.none => BorderRadius.zero, + final borderRadius = switch ((position, isVertical)) { + (_TrayPosition.solo, _) => const BorderRadius.all(_radius), + (_TrayPosition.start, false) => const BorderRadius.horizontal( + left: _radius, + ), + (_TrayPosition.end, false) => const BorderRadius.horizontal( + right: _radius, + ), + (_TrayPosition.start, true) => const BorderRadius.vertical(top: _radius), + (_TrayPosition.end, true) => const BorderRadius.vertical(bottom: _radius), + (_TrayPosition.middle, _) || (_TrayPosition.none, _) => BorderRadius.zero, }; - final isRightEdge = + final isTrailingEdge = position == _TrayPosition.end || position == _TrayPosition.solo; return Padding( - padding: EdgeInsets.only( - top: 2.0, - bottom: 2.0, - // Transparent gap after the tray so a following standalone header - // doesn't butt up against the rounded right edge. - right: isRightEdge ? 8.0 : 0.0, - ), + // Transparent gap after the tray so a following standalone header + // doesn't butt up against the rounded trailing edge. + padding: isVertical + ? EdgeInsets.only( + left: 2.0, + right: 2.0, + bottom: isTrailingEdge ? 8.0 : 0.0, + ) + : EdgeInsets.only( + top: 2.0, + bottom: 2.0, + right: isTrailingEdge ? 8.0 : 0.0, + ), child: SizedBox( - height: 44.0, + height: isVertical ? null : 44.0, + width: isVertical ? 44.0 : null, child: Container( decoration: BoxDecoration(color: fill, borderRadius: borderRadius), - // A small inset on every side so the first/last chip get the same - // breathing room from the tray edge as the inter-chip seam gaps. - padding: const EdgeInsets.symmetric(horizontal: 4.0), + // A small inset so the first/last chip get the same breathing room + // from the tray edge as the inter-chip seam gaps. + padding: isVertical + ? const EdgeInsets.symmetric(vertical: 4.0) + : const EdgeInsets.symmetric(horizontal: 4.0), child: Center(child: child), ), ), diff --git a/apps/weblibre/lib/features/geckoview/features/history/domain/providers.g.dart b/apps/weblibre/lib/features/geckoview/features/history/domain/providers.g.dart index 46fac05a..dcfb5f0d 100644 --- a/apps/weblibre/lib/features/geckoview/features/history/domain/providers.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/history/domain/providers.g.dart @@ -203,7 +203,7 @@ final class BrowsingDownloadsProvider } } -String _$browsingDownloadsHash() => r'938ae4d26b4e0a3f428d3d530a71cb5d8720f317'; +String _$browsingDownloadsHash() => r'a526dab6915085e6fb47a33d5acc892c3f8e17b2'; // ************************************************************************** // JsonGenerator diff --git a/apps/weblibre/lib/features/geckoview/features/history/domain/repositories/container_history.g.dart b/apps/weblibre/lib/features/geckoview/features/history/domain/repositories/container_history.g.dart index aa6d3b1c..01e59f29 100644 --- a/apps/weblibre/lib/features/geckoview/features/history/domain/repositories/container_history.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/history/domain/repositories/container_history.g.dart @@ -49,7 +49,7 @@ final class ContainerHistoryRepositoryProvider } String _$containerHistoryRepositoryHash() => - r'8872a421664c0ac8d6b0bd3ca76e1300677b0873'; + r'89bd0a552b47643a8ef8c182901af3811e96c271'; /// Mutations that combine the visit→container relation (`visit_container`) with /// Mozilla Places, the source of truth for the visits themselves. diff --git a/apps/weblibre/lib/features/geckoview/features/history/domain/services/visit_container_recorder.g.dart b/apps/weblibre/lib/features/geckoview/features/history/domain/services/visit_container_recorder.g.dart index 6b950c55..9ee18f98 100644 --- a/apps/weblibre/lib/features/geckoview/features/history/domain/services/visit_container_recorder.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/history/domain/services/visit_container_recorder.g.dart @@ -69,7 +69,7 @@ final class VisitContainerRecorderProvider } String _$visitContainerRecorderHash() => - r'2d5742816ec08bc37edf2b56a933b6a324e8b038'; + r'13ed2d5a188efd2a4419585f4989e782263884aa'; /// Records the visit→container relation. Mozilla Places owns the visit itself; /// on each Places visit the native [WebLibreHistoryDelegate] forwards the diff --git a/apps/weblibre/lib/features/settings/presentation/widgets/toolbar_layout_content.dart b/apps/weblibre/lib/features/settings/presentation/widgets/toolbar_layout_content.dart index 6cc495a4..3e4a5227 100644 --- a/apps/weblibre/lib/features/settings/presentation/widgets/toolbar_layout_content.dart +++ b/apps/weblibre/lib/features/settings/presentation/widgets/toolbar_layout_content.dart @@ -203,6 +203,16 @@ class _TabBarPositionSection extends HookConsumerWidget { title: Text('Bottom'), subtitle: Text('Tab bar with auto-hide support'), ), + RadioListTile.adaptive( + value: TabBarPosition.left, + title: Text('Left'), + subtitle: Text('Vertical side rail, swipe to hide'), + ), + RadioListTile.adaptive( + value: TabBarPosition.right, + title: Text('Right'), + subtitle: Text('Vertical side rail, swipe to hide'), + ), ], ), ), diff --git a/apps/weblibre/lib/features/settings/presentation/widgets/toolbar_preview.dart b/apps/weblibre/lib/features/settings/presentation/widgets/toolbar_preview.dart index 7b0b98a2..8228af07 100644 --- a/apps/weblibre/lib/features/settings/presentation/widgets/toolbar_preview.dart +++ b/apps/weblibre/lib/features/settings/presentation/widgets/toolbar_preview.dart @@ -72,13 +72,20 @@ class TabBarPreviewHeaderDelegate extends SliverPersistentHeaderDelegate { double get _headerHeight => compact ? 0.0 : _kHeaderHeight; - @override - double get minExtent => - _baseHeight + _toolbarHeight + _headerHeight + padding.vertical; + /// Fixed preview height for the vertical rail (the rail flows along the + /// height, so it can't be derived from stacked section heights). + static const _kRailPreviewHeight = 220.0; + static const _kCompactRailPreviewHeight = 140.0; + + double get _contentHeight => settings.tabBarPosition.isVertical + ? (compact ? _kCompactRailPreviewHeight : _kRailPreviewHeight) + : _baseHeight + _toolbarHeight; @override - double get maxExtent => - _baseHeight + _toolbarHeight + _headerHeight + padding.vertical; + double get minExtent => _contentHeight + _headerHeight + padding.vertical; + + @override + double get maxExtent => _contentHeight + _headerHeight + padding.vertical; @override Widget build( @@ -200,12 +207,17 @@ class TabBarPreviewCard extends HookWidget { }, ); - Widget buildQuickTabSwitcherRow(ScrollController scrollController) { + Widget buildQuickTabSwitcherRow( + ScrollController scrollController, { + Axis axis = Axis.horizontal, + }) { return QuickTabSwitcherView( availableItems: previewQuickItems, activeItem: previewQuickItems.firstWhere((item) => item.isActive), scrollController: scrollController, - showTitles: settings.quickTabSwitcherShowTitles, + axis: axis, + showTitles: + axis != Axis.vertical && settings.quickTabSwitcherShowTitles, showIsolatedTabUi: settings.showIsolatedTabUi, hierarchyGlyphs: settings.quickTabSwitcherHierarchyGlyphs, titleMaxWidth: settings.quickTabSwitcherTitleWidth, @@ -217,24 +229,25 @@ class TabBarPreviewCard extends HookWidget { ); } - Widget buildQuickTabSwitcher() { + Widget buildQuickTabSwitcher({Axis axis = Axis.horizontal}) { // The accordion preview reuses the single-row layout; container header // chips need live container data that the static preview doesn't have. if (settings.effectiveTabBarStackingMode() == TabBarStackingMode.twoLevel) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - buildQuickTabSwitcherRow(quickTabsController), - buildQuickTabSwitcherRow(quickTabsSecondRowController), - ], - ); + final rows = [ + buildQuickTabSwitcherRow(quickTabsController, axis: axis), + buildQuickTabSwitcherRow(quickTabsSecondRowController, axis: axis), + ]; + return axis == Axis.vertical + ? Column(children: [for (final row in rows) Expanded(child: row)]) + : Column(mainAxisSize: MainAxisSize.min, children: rows); } - return buildQuickTabSwitcherRow(quickTabsController); + return buildQuickTabSwitcherRow(quickTabsController, axis: axis); } - Widget buildContextualToolbar() { + Widget buildContextualToolbar({Axis axis = Axis.horizontal}) { return ContextualToolbarView( + axis: axis, buttons: [ NavigateBackButtonView( canGoBack: true, @@ -307,43 +320,91 @@ class TabBarPreviewCard extends HookWidget { contextualToolbar: buildContextualToolbar(), ); - final previewContent = Container( - clipBehavior: Clip.antiAlias, + final isRailPreview = settings.tabBarPosition.isVertical; + + final railToolbar = BrowserTabBarView( + axis: Axis.vertical, + railOnLeft: settings.tabBarPosition == TabBarPosition.left, + showMainToolbar: true, + showContextualToolbar: settings.tabBarShowContextualBar, + showQuickTabSwitcherBar: showQuickTabSwitcherBar, + displayAppBar: true, + displayQuickTabSwitcher: true, + backgroundColor: + previewContainerPalette?.surfaceColor ?? colorScheme.surfaceContainer, + title: _RailPreviewTitle( + tabState: previewTabState, + quarterTurns: settings.tabBarPosition == TabBarPosition.left ? 3 : 1, + ), + actions: mainToolbarActions, + quickTabSwitcher: buildQuickTabSwitcher(axis: Axis.vertical), + contextualToolbar: buildContextualToolbar(axis: Axis.vertical), + ); + + final pageContentBox = Container( + width: double.infinity, + height: double.infinity, + alignment: Alignment.center, decoration: BoxDecoration( color: compact - ? colorScheme.surface.withValues(alpha: 0.7) - : colorScheme.surface, - border: Border.all(color: colorScheme.outlineVariant), - borderRadius: BorderRadius.circular(4), + ? colorScheme.surfaceContainerLowest.withValues(alpha: 0.7) + : colorScheme.surfaceContainerLowest, + border: Border.symmetric( + horizontal: BorderSide(color: colorScheme.outlineVariant), + ), ), - child: Column( - children: [ - if (settings.tabBarPosition == TabBarPosition.top) topMainToolbar, - Container( - height: compact ? 40 : 72, - width: double.infinity, - alignment: Alignment.center, - decoration: BoxDecoration( - color: compact - ? colorScheme.surfaceContainerLowest.withValues(alpha: 0.7) - : colorScheme.surfaceContainerLowest, - border: Border.symmetric( - horizontal: BorderSide(color: colorScheme.outlineVariant), - ), - ), - child: Text( - 'Page Content', - style: Theme.of(context).textTheme.labelMedium, - ), - ), - if (settings.tabBarPosition == TabBarPosition.top) - topBottomToolbar - else - bottomCombinedToolbar, - ], + child: Text( + 'Page Content', + style: Theme.of(context).textTheme.labelMedium, ), ); + final Widget previewContent; + if (isRailPreview) { + final rail = SizedBox( + width: BrowserTabBar.sideRailWidth, + child: railToolbar, + ); + final railOnLeft = settings.tabBarPosition == TabBarPosition.left; + previewContent = Container( + clipBehavior: Clip.antiAlias, + height: compact ? 140 : 220, + decoration: BoxDecoration( + color: compact + ? colorScheme.surface.withValues(alpha: 0.7) + : colorScheme.surface, + border: Border.all(color: colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + children: railOnLeft + ? [rail, Expanded(child: pageContentBox)] + : [Expanded(child: pageContentBox), rail], + ), + ); + } else { + previewContent = Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: compact + ? colorScheme.surface.withValues(alpha: 0.7) + : colorScheme.surface, + border: Border.all(color: colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(4), + ), + child: Column( + children: [ + if (settings.tabBarPosition == TabBarPosition.top) topMainToolbar, + SizedBox(height: compact ? 40 : 72, child: pageContentBox), + if (settings.tabBarPosition == TabBarPosition.top) + topBottomToolbar + else + bottomCombinedToolbar, + ], + ), + ); + } + if (compact) { return previewContent; } @@ -409,4 +470,25 @@ class _CompactPreviewTitle extends StatelessWidget { } } +class _RailPreviewTitle extends StatelessWidget { + const _RailPreviewTitle({required this.tabState, required this.quarterTurns}); + + final TabState tabState; + final int quarterTurns; + + @override + Widget build(BuildContext context) { + return RailAppBarTitleView( + tabState: tabState, + quarterTurns: quarterTurns, + isTabTunneled: false, + siteSettingsBadgeState: SiteSettingsBadgeState.hidden, + onSiteSettingsTap: _noop, + onTitleTap: _noop, + tabIcon: const Icon(MdiIcons.web, size: 24), + longPressUrlCopy: false, + ); + } +} + void _noop() {} diff --git a/apps/weblibre/lib/features/user/data/models/general_settings.dart b/apps/weblibre/lib/features/user/data/models/general_settings.dart index 79a95144..90e21e5b 100644 --- a/apps/weblibre/lib/features/user/data/models/general_settings.dart +++ b/apps/weblibre/lib/features/user/data/models/general_settings.dart @@ -87,7 +87,23 @@ enum TabIntentOpenSetting { regular, private, isolated, ask } enum TabDirection { newestFirst, oldestFirst } -enum TabBarPosition { top, bottom } +enum TabBarPosition { + top, + bottom, + left, + right; + + /// Whether the tab bar is rendered as a vertical side rail (left/right) + /// rather than a horizontal bar (top/bottom). + bool get isVertical => + this == TabBarPosition.left || this == TabBarPosition.right; + + /// Whether the tab bar is rendered as a horizontal bar (top/bottom). + bool get isHorizontal => !isVertical; + + /// Main axis along which the bar's content flows. + Axis get axis => isVertical ? Axis.vertical : Axis.horizontal; +} enum TabBarLayout { withTitle, compact } diff --git a/apps/weblibre/lib/features/user/data/models/general_settings.g.dart b/apps/weblibre/lib/features/user/data/models/general_settings.g.dart index 54d5525f..c33b739d 100644 --- a/apps/weblibre/lib/features/user/data/models/general_settings.g.dart +++ b/apps/weblibre/lib/features/user/data/models/general_settings.g.dart @@ -1246,6 +1246,8 @@ const _$TabBarSwipeActionEnumMap = { const _$TabBarPositionEnumMap = { TabBarPosition.top: 'top', TabBarPosition.bottom: 'bottom', + TabBarPosition.left: 'left', + TabBarPosition.right: 'right', }; const _$TabBarLayoutEnumMap = { diff --git a/apps/weblibre/lib/presentation/widgets/selectable_chips.dart b/apps/weblibre/lib/presentation/widgets/selectable_chips.dart index b920ee08..cd491f6f 100644 --- a/apps/weblibre/lib/presentation/widgets/selectable_chips.dart +++ b/apps/weblibre/lib/presentation/widgets/selectable_chips.dart @@ -145,6 +145,10 @@ class SelectableChips extends StatelessWidget { final GlobalKey? activeItemKey; final double? cacheExtent; + /// Direction the chip list scrolls / lays out. Horizontal for the standard + /// top/bottom tab bar; vertical for the side rail. + final Axis scrollDirection; + /// Key applied to the underlying scroll view. Supply a [PageStorageKey] to /// preserve the scroll offset across rebuilds/remounts (e.g. when a host /// widget is torn down and recreated by a bottom sheet open/close). @@ -193,6 +197,7 @@ class SelectableChips extends StatelessWidget { this.activeItemKey, this.cacheExtent = 0, this.scrollKey, + this.scrollDirection = Axis.horizontal, super.key, }); @@ -221,10 +226,14 @@ class SelectableChips extends StatelessWidget { final prefixCount = prefixListItems.length; final totalCount = prefixCount + items.length; + final isVertical = scrollDirection == Axis.vertical; + Widget buildPrefix(int index) { return Padding( key: ValueKey('__selectable_chips_prefix_$index'), - padding: const EdgeInsets.only(top: 4.0, right: 4), + padding: isVertical + ? const EdgeInsets.only(bottom: 4.0) + : const EdgeInsets.only(top: 4.0, right: 4), child: prefixListItems[index], ); } @@ -238,7 +247,9 @@ class SelectableChips extends StatelessWidget { final canDeleteItem = enableDelete && (deco?.canDelete?.call(item) ?? true); final child = Padding( - padding: const EdgeInsets.only(right: 8.0, top: 4.0), + padding: isVertical + ? const EdgeInsets.only(bottom: 8.0) + : const EdgeInsets.only(right: 8.0, top: 4.0), child: _BadgeWrapper( count: itemBadgeCount?.call(item), child: _GestureWrapper( @@ -307,7 +318,7 @@ class SelectableChips extends StatelessWidget { scrollCacheExtent: cacheExtent.mapNotNull( (extent) => ScrollCacheExtent.pixels(extent), ), - scrollDirection: Axis.horizontal, + scrollDirection: scrollDirection, itemCount: totalCount, itemBuilder: (context, index) => index < prefixCount ? buildPrefix(index) @@ -320,7 +331,7 @@ class SelectableChips extends StatelessWidget { scrollCacheExtent: cacheExtent.mapNotNull( (extent) => ScrollCacheExtent.pixels(extent), ), - scrollDirection: Axis.horizontal, + scrollDirection: scrollDirection, buildDefaultDragHandles: false, itemCount: totalCount, itemBuilder: (context, index) => index < prefixCount