side rail initial
This commit is contained in:
@@ -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<void>(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<void>(context);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
child: ExtensionBadgeIcon(extension),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: items),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ final class BrowserDataServiceProvider
|
||||
}
|
||||
|
||||
String _$browserDataServiceHash() =>
|
||||
r'a10ac863a4c80e3ed882c3d2a2c1b8535a257ac0';
|
||||
r'a317f0d56d1bfc61dc99af1e60080aaa248d6eed';
|
||||
|
||||
abstract class _$BrowserDataService extends $Notifier<void> {
|
||||
void build();
|
||||
|
||||
+55
-2
@@ -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<Widget> 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 =
|
||||
|
||||
+214
-74
@@ -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<Offset>(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<Offset>? 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)],
|
||||
),
|
||||
|
||||
+263
@@ -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,
|
||||
|
||||
+294
-61
@@ -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<void> 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<QuickTabSwitcherItem> 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,
|
||||
|
||||
+33
-11
@@ -18,6 +18,7 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+120
-40
@@ -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<UserScrollNotification>(
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -203,7 +203,7 @@ final class BrowsingDownloadsProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$browsingDownloadsHash() => r'938ae4d26b4e0a3f428d3d530a71cb5d8720f317';
|
||||
String _$browsingDownloadsHash() => r'a526dab6915085e6fb47a33d5acc892c3f8e17b2';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonGenerator
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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() {}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -1246,6 +1246,8 @@ const _$TabBarSwipeActionEnumMap = {
|
||||
const _$TabBarPositionEnumMap = {
|
||||
TabBarPosition.top: 'top',
|
||||
TabBarPosition.bottom: 'bottom',
|
||||
TabBarPosition.left: 'left',
|
||||
TabBarPosition.right: 'right',
|
||||
};
|
||||
|
||||
const _$TabBarLayoutEnumMap = {
|
||||
|
||||
@@ -145,6 +145,10 @@ class SelectableChips<T extends S, S, K> 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<T extends S, S, K> extends StatelessWidget {
|
||||
this.activeItemKey,
|
||||
this.cacheExtent = 0,
|
||||
this.scrollKey,
|
||||
this.scrollDirection = Axis.horizontal,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -221,10 +226,14 @@ class SelectableChips<T extends S, S, K> 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<T extends S, S, K> 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<T extends S, S, K> 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<T extends S, S, K> extends StatelessWidget {
|
||||
scrollCacheExtent: cacheExtent.mapNotNull(
|
||||
(extent) => ScrollCacheExtent.pixels(extent),
|
||||
),
|
||||
scrollDirection: Axis.horizontal,
|
||||
scrollDirection: scrollDirection,
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: totalCount,
|
||||
itemBuilder: (context, index) => index < prefixCount
|
||||
|
||||
Reference in New Issue
Block a user