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';
|
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart';
|
||||||
|
|
||||||
class PinnedAddonBar extends ConsumerWidget {
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -45,11 +49,9 @@ class PinnedAddonBar extends ConsumerWidget {
|
|||||||
.toList();
|
.toList();
|
||||||
if (pinned.isEmpty) return const SizedBox.shrink();
|
if (pinned.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
return Padding(
|
final isVertical = axis == Axis.vertical;
|
||||||
padding: const EdgeInsets.only(right: 6.0),
|
|
||||||
child: Row(
|
final items = [
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
for (final extension in pinned)
|
for (final extension in pinned)
|
||||||
InkResponse(
|
InkResponse(
|
||||||
radius: 22,
|
radius: 22,
|
||||||
@@ -67,15 +69,23 @@ class PinnedAddonBar extends ConsumerWidget {
|
|||||||
).push<void>(context);
|
).push<void>(context);
|
||||||
},
|
},
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 8.0),
|
||||||
horizontal: 6.0,
|
|
||||||
vertical: 8.0,
|
|
||||||
),
|
|
||||||
child: ExtensionBadgeIcon(extension),
|
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: items),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ final class BrowserDataServiceProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$browserDataServiceHash() =>
|
String _$browserDataServiceHash() =>
|
||||||
r'a10ac863a4c80e3ed882c3d2a2c1b8535a257ac0';
|
r'a317f0d56d1bfc61dc99af1e60080aaa248d6eed';
|
||||||
|
|
||||||
abstract class _$BrowserDataService extends $Notifier<void> {
|
abstract class _$BrowserDataService extends $Notifier<void> {
|
||||||
void build();
|
void build();
|
||||||
|
|||||||
+55
-2
@@ -34,11 +34,16 @@ class ContextualToolbar extends HookConsumerWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.selectedTabId,
|
required this.selectedTabId,
|
||||||
required this.displayedSheet,
|
required this.displayedSheet,
|
||||||
|
this.axis = Axis.horizontal,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String? selectedTabId;
|
final String? selectedTabId;
|
||||||
final Sheet? displayedSheet;
|
final Sheet? displayedSheet;
|
||||||
|
|
||||||
|
/// Layout direction, forwarded to [ContextualToolbarView]. Vertical for the
|
||||||
|
/// side rail.
|
||||||
|
final Axis axis;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final tabState = ref.watch(tabStateProvider(selectedTabId));
|
final tabState = ref.watch(tabStateProvider(selectedTabId));
|
||||||
@@ -67,7 +72,7 @@ class ContextualToolbar extends HookConsumerWidget {
|
|||||||
.map((button) => _buildButton(scope, context, ref, button))
|
.map((button) => _buildButton(scope, context, ref, button))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
return ContextualToolbarView(buttons: buttons);
|
return ContextualToolbarView(buttons: buttons, axis: axis);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildButton(
|
Widget _buildButton(
|
||||||
@@ -90,16 +95,64 @@ class ContextualToolbar extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ContextualToolbarView extends StatelessWidget {
|
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;
|
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;
|
static const _minButtonWidth = 48.0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (buttons.isEmpty) return const SizedBox.shrink();
|
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(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final fitsEvenly =
|
final fitsEvenly =
|
||||||
|
|||||||
+167
-27
@@ -108,9 +108,12 @@ class _AnimatedToolbar extends HookWidget {
|
|||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
final slideAnimation = useMemoized(() {
|
final slideAnimation = useMemoized(() {
|
||||||
final begin = position == TabBarPosition.top
|
final begin = switch (position) {
|
||||||
? const Offset(0, -1)
|
TabBarPosition.top => const Offset(0, -1),
|
||||||
: 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(
|
return Tween<Offset>(begin: begin, end: Offset.zero).animate(
|
||||||
CurvedAnimation(parent: controller, curve: Curves.easeInOutQuart),
|
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) {
|
return switch (tabBarPosition) {
|
||||||
TabBarPosition.top => BrowserTopAppBar(
|
TabBarPosition.top => BrowserTopAppBar(
|
||||||
showMainToolbar: showMainToolbar,
|
showMainToolbar: showMainToolbar,
|
||||||
@@ -239,6 +244,12 @@ class _TabBar extends HookConsumerWidget {
|
|||||||
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
|
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
|
||||||
isSmallWebMode: isSmallWebMode,
|
isSmallWebMode: isSmallWebMode,
|
||||||
),
|
),
|
||||||
|
TabBarPosition.left || TabBarPosition.right => BrowserSideRail(
|
||||||
|
position: tabBarPosition,
|
||||||
|
showContextualToolbar: showContextualToolbar,
|
||||||
|
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
|
||||||
|
isSmallWebMode: isSmallWebMode,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -539,8 +550,13 @@ class BrowserScreen extends HookConsumerWidget {
|
|||||||
? 0
|
? 0
|
||||||
: ref.watch(quickTabSwitcherRowCountProvider).value ?? 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 =
|
final autoHideTabBar =
|
||||||
!isSmallWebActive &&
|
!isSmallWebActive &&
|
||||||
|
tabBarPosition.isHorizontal &&
|
||||||
ref.watch(
|
ref.watch(
|
||||||
generalSettingsWithDefaultsProvider.select(
|
generalSettingsWithDefaultsProvider.select(
|
||||||
(value) => value.autoHideTabBar,
|
(value) => value.autoHideTabBar,
|
||||||
@@ -641,6 +657,9 @@ class BrowserScreen extends HookConsumerWidget {
|
|||||||
bottomAppBarContentSize = const Size.fromHeight(
|
bottomAppBarContentSize = const Size.fromHeight(
|
||||||
SmallWebBrowserOverlay.barHeight,
|
SmallWebBrowserOverlay.barHeight,
|
||||||
);
|
);
|
||||||
|
} else if (isRail) {
|
||||||
|
// The rail occupies a side, not the bottom; no bottom bar is rendered.
|
||||||
|
bottomAppBarContentSize = Size.zero;
|
||||||
} else {
|
} else {
|
||||||
// Pass actual displayedSheet to get correct height when ViewTabsSheet hides main toolbar
|
// Pass actual displayedSheet to get correct height when ViewTabsSheet hides main toolbar
|
||||||
bottomAppBarContentSize = BrowserBottomAppBar(
|
bottomAppBarContentSize = BrowserBottomAppBar(
|
||||||
@@ -655,6 +674,25 @@ class BrowserScreen extends HookConsumerWidget {
|
|||||||
final bottomAppBarTotalHeight =
|
final bottomAppBarTotalHeight =
|
||||||
bottomAppBarContentSize.height + bottomSafeArea;
|
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
|
// Calculate top toolbar size for browser offset and progress indicator
|
||||||
final topSafeArea = MediaQuery.of(context).padding.top;
|
final topSafeArea = MediaQuery.of(context).padding.top;
|
||||||
final topAppBarContentSize = BrowserTopAppBar(
|
final topAppBarContentSize = BrowserTopAppBar(
|
||||||
@@ -867,8 +905,13 @@ class BrowserScreen extends HookConsumerWidget {
|
|||||||
toolbarState == ToolbarVisibility.visible);
|
toolbarState == ToolbarVisibility.visible);
|
||||||
|
|
||||||
// When auto-hide is disabled, constrain browser above toolbar
|
// When auto-hide is disabled, constrain browser above toolbar
|
||||||
// (unless toolbar is manually dismissed via swipe gesture)
|
// (unless toolbar is manually dismissed via swipe gesture).
|
||||||
final bottomOffset = (!autoHideTabBar && toolbarVisible)
|
// 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
|
? bottomAppBarTotalHeight
|
||||||
: 0.0;
|
: 0.0;
|
||||||
|
|
||||||
@@ -879,20 +922,36 @@ class BrowserScreen extends HookConsumerWidget {
|
|||||||
? topAppBarTotalHeight
|
? topAppBarTotalHeight
|
||||||
: 0.0;
|
: 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
|
// Only fall back to the system bottom inset when the bottom
|
||||||
// toolbar was explicitly dismissed. The normal auto-hide
|
// toolbar was explicitly dismissed. The normal auto-hide
|
||||||
// hidden state is still handled by GeckoView's dynamic
|
// hidden state is still handled by GeckoView's dynamic
|
||||||
// toolbar/clipping logic.
|
// toolbar/clipping logic. On the rail there is never a bottom
|
||||||
final applyBottomSafeArea =
|
// bar, so the browser always needs the bottom safe inset.
|
||||||
!tabInFullScreen &&
|
final applyBottomSafeArea = isRail
|
||||||
|
? (!tabInFullScreen &&
|
||||||
|
!isSmallWebActive &&
|
||||||
|
!sheetDisplayed)
|
||||||
|
: (!tabInFullScreen &&
|
||||||
!isSmallWebActive &&
|
!isSmallWebActive &&
|
||||||
!sheetDisplayed &&
|
!sheetDisplayed &&
|
||||||
bottomOffset == 0 &&
|
bottomOffset == 0 &&
|
||||||
toolbarState == ToolbarVisibility.dismissed;
|
toolbarState == ToolbarVisibility.dismissed);
|
||||||
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
left: 0,
|
left: leftOffset,
|
||||||
right: 0,
|
right: rightOffset,
|
||||||
top: topOffset,
|
top: topOffset,
|
||||||
bottom: bottomOffset,
|
bottom: bottomOffset,
|
||||||
child: _Browser(
|
child: _Browser(
|
||||||
@@ -903,6 +962,8 @@ class BrowserScreen extends HookConsumerWidget {
|
|||||||
: null,
|
: null,
|
||||||
sheetDisplayed: sheetDisplayed,
|
sheetDisplayed: sheetDisplayed,
|
||||||
hasTopBarOffset: topOffset > 0,
|
hasTopBarOffset: topOffset > 0,
|
||||||
|
hasLeftBarOffset: leftOffset > 0,
|
||||||
|
hasRightBarOffset: rightOffset > 0,
|
||||||
applyBottomSafeArea: applyBottomSafeArea,
|
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)
|
if (sheetDisplayed)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: railLeftInset,
|
||||||
right: 0,
|
right: railRightInset,
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: bottomAppBarTotalHeight,
|
bottom: bottomAppBarTotalHeight,
|
||||||
child: _SheetContainer(
|
child: _SheetContainer(
|
||||||
@@ -938,7 +1000,9 @@ class BrowserScreen extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Layer 2: Bottom Toolbar (overlay, slides in/out)
|
// Layer 2: Bottom Toolbar (overlay, slides in/out)
|
||||||
// In small web mode, show the discovery overlay instead
|
// In small web mode, show the discovery overlay instead.
|
||||||
|
// Skipped entirely for the side rail (Layer 3b below).
|
||||||
|
if (!isRail)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
@@ -948,7 +1012,9 @@ class BrowserScreen extends HookConsumerWidget {
|
|||||||
position: TabBarPosition.bottom,
|
position: TabBarPosition.bottom,
|
||||||
visible: !tabInFullScreen,
|
visible: !tabInFullScreen,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainer,
|
||||||
elevation: 3,
|
elevation: 3,
|
||||||
child: const SmallWebBrowserOverlay(),
|
child: const SmallWebBrowserOverlay(),
|
||||||
),
|
),
|
||||||
@@ -960,7 +1026,9 @@ class BrowserScreen extends HookConsumerWidget {
|
|||||||
// watched provider.
|
// watched provider.
|
||||||
builder: (context, ref, child) {
|
builder: (context, ref, child) {
|
||||||
final toolbarState = ref.watch(
|
final toolbarState = ref.watch(
|
||||||
toolbarVisibilityControllerProvider(selectedTabId),
|
toolbarVisibilityControllerProvider(
|
||||||
|
selectedTabId,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
final visible =
|
final visible =
|
||||||
sheetDisplayed ||
|
sheetDisplayed ||
|
||||||
@@ -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)
|
// Layer 4: FAB (draggable via long press)
|
||||||
Consumer(
|
Consumer(
|
||||||
builder: (context, ref, child) {
|
builder: (context, ref, child) {
|
||||||
@@ -1040,6 +1140,14 @@ class BrowserScreen extends HookConsumerWidget {
|
|||||||
bottomToolbarVisible: visible,
|
bottomToolbarVisible: visible,
|
||||||
bottomAppBarHeight: bottomAppBarTotalHeight,
|
bottomAppBarHeight: bottomAppBarTotalHeight,
|
||||||
bottomSafeArea: bottomSafeArea,
|
bottomSafeArea: bottomSafeArea,
|
||||||
|
leftReservedWidth:
|
||||||
|
(tabBarPosition == TabBarPosition.left && visible)
|
||||||
|
? sideRailTotalWidth
|
||||||
|
: 0.0,
|
||||||
|
rightReservedWidth:
|
||||||
|
(tabBarPosition == TabBarPosition.right && visible)
|
||||||
|
? sideRailTotalWidth
|
||||||
|
: 0.0,
|
||||||
child: child!,
|
child: child!,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1064,12 +1172,24 @@ class BrowserScreen extends HookConsumerWidget {
|
|||||||
? Duration.zero
|
? Duration.zero
|
||||||
: _AnimatedToolbar._kAnimationDuration,
|
: _AnimatedToolbar._kAnimationDuration,
|
||||||
curve: Curves.easeInOutQuart,
|
curve: Curves.easeInOutQuart,
|
||||||
left: 0,
|
// Rail insets track the rail's visibility so overlays
|
||||||
right: 0,
|
// reclaim the space when it is swiped away.
|
||||||
top: tabBarPosition == TabBarPosition.top && visible
|
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
|
? topAppBarTotalHeight
|
||||||
: null,
|
: null,
|
||||||
bottom: tabBarPosition == TabBarPosition.bottom && visible
|
bottom: isRail
|
||||||
|
? null
|
||||||
|
: tabBarPosition == TabBarPosition.bottom && visible
|
||||||
? bottomAppBarTotalHeight
|
? bottomAppBarTotalHeight
|
||||||
: tabBarPosition == TabBarPosition.bottom
|
: tabBarPosition == TabBarPosition.bottom
|
||||||
? 0
|
? 0
|
||||||
@@ -1116,10 +1236,18 @@ class BrowserScreen extends HookConsumerWidget {
|
|||||||
? Duration.zero
|
? Duration.zero
|
||||||
: _AnimatedToolbar._kAnimationDuration,
|
: _AnimatedToolbar._kAnimationDuration,
|
||||||
curve: Curves.easeInOutQuart,
|
curve: Curves.easeInOutQuart,
|
||||||
left: 0,
|
left: (tabBarPosition == TabBarPosition.left && visible)
|
||||||
right: 0,
|
? sideRailTotalWidth
|
||||||
|
: 0.0,
|
||||||
|
right: (tabBarPosition == TabBarPosition.right && visible)
|
||||||
|
? sideRailTotalWidth
|
||||||
|
: 0.0,
|
||||||
bottom: math.max(
|
bottom: math.max(
|
||||||
visible ? bottomAppBarTotalHeight : bottomSafeArea,
|
isRail
|
||||||
|
? bottomSafeArea
|
||||||
|
: (visible
|
||||||
|
? bottomAppBarTotalHeight
|
||||||
|
: bottomSafeArea),
|
||||||
MediaQuery.viewInsetsOf(context).bottom,
|
MediaQuery.viewInsetsOf(context).bottom,
|
||||||
),
|
),
|
||||||
child: child!,
|
child: child!,
|
||||||
@@ -1213,6 +1341,8 @@ class _Browser extends HookConsumerWidget {
|
|||||||
final bool tabInFullScreen;
|
final bool tabInFullScreen;
|
||||||
final bool sheetDisplayed;
|
final bool sheetDisplayed;
|
||||||
final bool hasTopBarOffset;
|
final bool hasTopBarOffset;
|
||||||
|
final bool hasLeftBarOffset;
|
||||||
|
final bool hasRightBarOffset;
|
||||||
final bool applyBottomSafeArea;
|
final bool applyBottomSafeArea;
|
||||||
|
|
||||||
const _Browser({
|
const _Browser({
|
||||||
@@ -1221,6 +1351,8 @@ class _Browser extends HookConsumerWidget {
|
|||||||
required this.pointerMoveEventSink,
|
required this.pointerMoveEventSink,
|
||||||
required this.sheetDisplayed,
|
required this.sheetDisplayed,
|
||||||
required this.hasTopBarOffset,
|
required this.hasTopBarOffset,
|
||||||
|
required this.hasLeftBarOffset,
|
||||||
|
required this.hasRightBarOffset,
|
||||||
required this.applyBottomSafeArea,
|
required this.applyBottomSafeArea,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1460,6 +1592,8 @@ class _Browser extends HookConsumerWidget {
|
|||||||
isFullscreen: tabInFullScreen,
|
isFullscreen: tabInFullScreen,
|
||||||
pointerMoveEventSink: pointerMoveEventSink,
|
pointerMoveEventSink: pointerMoveEventSink,
|
||||||
hasTopBarOffset: hasTopBarOffset,
|
hasTopBarOffset: hasTopBarOffset,
|
||||||
|
hasLeftBarOffset: hasLeftBarOffset,
|
||||||
|
hasRightBarOffset: hasRightBarOffset,
|
||||||
applyBottomSafeArea: applyBottomSafeArea,
|
applyBottomSafeArea: applyBottomSafeArea,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1474,11 +1608,15 @@ class _BrowserView extends StatelessWidget {
|
|||||||
final bool isFullscreen;
|
final bool isFullscreen;
|
||||||
final StreamSink<Offset>? pointerMoveEventSink;
|
final StreamSink<Offset>? pointerMoveEventSink;
|
||||||
final bool hasTopBarOffset;
|
final bool hasTopBarOffset;
|
||||||
|
final bool hasLeftBarOffset;
|
||||||
|
final bool hasRightBarOffset;
|
||||||
final bool applyBottomSafeArea;
|
final bool applyBottomSafeArea;
|
||||||
|
|
||||||
const _BrowserView({
|
const _BrowserView({
|
||||||
required this.isFullscreen,
|
required this.isFullscreen,
|
||||||
required this.hasTopBarOffset,
|
required this.hasTopBarOffset,
|
||||||
|
required this.hasLeftBarOffset,
|
||||||
|
required this.hasRightBarOffset,
|
||||||
required this.applyBottomSafeArea,
|
required this.applyBottomSafeArea,
|
||||||
this.pointerMoveEventSink,
|
this.pointerMoveEventSink,
|
||||||
});
|
});
|
||||||
@@ -1488,12 +1626,14 @@ class _BrowserView extends StatelessWidget {
|
|||||||
return SafeArea(
|
return SafeArea(
|
||||||
// Disable top SafeArea when top bar handles it (has offset applied)
|
// Disable top SafeArea when top bar handles it (has offset applied)
|
||||||
top: !isFullscreen && !hasTopBarOffset,
|
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
|
// Apply bottom SafeArea only when no toolbar/overlay is rendered at the
|
||||||
// bottom (and not in fullscreen). Otherwise the toolbar handles its own
|
// bottom (and not in fullscreen). Otherwise the toolbar handles its own
|
||||||
// safe-area inset and the platform view extends behind it.
|
// safe-area inset and the platform view extends behind it.
|
||||||
bottom: applyBottomSafeArea,
|
bottom: applyBottomSafeArea,
|
||||||
left: !isFullscreen,
|
left: !isFullscreen && !hasLeftBarOffset,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [BrowserView(pointerMoveEventSink: pointerMoveEventSink)],
|
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 {
|
class _SecurityStatusIcon extends StatelessWidget {
|
||||||
const _SecurityStatusIcon({
|
const _SecurityStatusIcon({
|
||||||
required this.tabState,
|
required this.tabState,
|
||||||
|
|||||||
+282
-49
@@ -159,6 +159,86 @@ class BrowserBottomAppBar extends StatelessWidget {
|
|||||||
Size get preferredSize => _size;
|
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 {
|
class BrowserTabBar extends HookConsumerWidget {
|
||||||
final bool showMainToolbar;
|
final bool showMainToolbar;
|
||||||
final bool showContextualToolbar;
|
final bool showContextualToolbar;
|
||||||
@@ -182,6 +262,14 @@ class BrowserTabBar extends HookConsumerWidget {
|
|||||||
static const contextualToolabarHeight = 54.0;
|
static const contextualToolabarHeight = 54.0;
|
||||||
static const quickTabSwitcherHeight = 48.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 =>
|
bool get displayAppBar =>
|
||||||
showMainToolbar &&
|
showMainToolbar &&
|
||||||
(!showContextualToolbar || displayedSheet is! ViewTabsSheet);
|
(!showContextualToolbar || displayedSheet is! ViewTabsSheet);
|
||||||
@@ -249,9 +337,50 @@ class BrowserTabBar extends HookConsumerWidget {
|
|||||||
final stackingMode = settings.effectiveTabBarStackingMode();
|
final stackingMode = settings.effectiveTabBarStackingMode();
|
||||||
|
|
||||||
final tabBarPosition = settings.tabBarPosition;
|
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);
|
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 showTabTitle = displayedSheet is! ViewTabsSheet;
|
||||||
|
|
||||||
final effectiveContainerColor =
|
final effectiveContainerColor =
|
||||||
@@ -271,6 +400,8 @@ class BrowserTabBar extends HookConsumerWidget {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return BrowserTabBarView(
|
return BrowserTabBarView(
|
||||||
|
axis: switcherAxis,
|
||||||
|
railOnLeft: tabBarPosition == TabBarPosition.left,
|
||||||
showMainToolbar: showMainToolbar,
|
showMainToolbar: showMainToolbar,
|
||||||
showContextualToolbar: showContextualToolbar,
|
showContextualToolbar: showContextualToolbar,
|
||||||
showQuickTabSwitcherBar: quickTabSwitcherRowCount > 0,
|
showQuickTabSwitcherBar: quickTabSwitcherRowCount > 0,
|
||||||
@@ -278,7 +409,13 @@ class BrowserTabBar extends HookConsumerWidget {
|
|||||||
displayQuickTabSwitcher: displayQuickTabSwitcher,
|
displayQuickTabSwitcher: displayQuickTabSwitcher,
|
||||||
backgroundColor: effectiveContainerPalette?.surfaceColor,
|
backgroundColor: effectiveContainerPalette?.surfaceColor,
|
||||||
title: showTabTitle
|
title: showTabTitle
|
||||||
? settings.tabBarLayout == TabBarLayout.compact
|
? isVertical
|
||||||
|
? RailAppBarTitle(
|
||||||
|
quarterTurns: railQuarterTurns,
|
||||||
|
containerColor: effectiveContainerColor,
|
||||||
|
useCustomColor: effectiveUseCustomColor,
|
||||||
|
)
|
||||||
|
: settings.tabBarLayout == TabBarLayout.compact
|
||||||
? CompactAppBarTitle(
|
? CompactAppBarTitle(
|
||||||
containerColor: effectiveContainerColor,
|
containerColor: effectiveContainerColor,
|
||||||
useCustomColor: effectiveUseCustomColor,
|
useCustomColor: effectiveUseCustomColor,
|
||||||
@@ -289,7 +426,7 @@ class BrowserTabBar extends HookConsumerWidget {
|
|||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
actions: [
|
actions: [
|
||||||
const PinnedAddonBar(),
|
PinnedAddonBar(axis: switcherAxis),
|
||||||
if (isSmallWebMode)
|
if (isSmallWebMode)
|
||||||
ReaderButton(
|
ReaderButton(
|
||||||
buttonBuilder: (isLoading, readerActive, icon) => ToolbarButton(
|
buttonBuilder: (isLoading, readerActive, icon) => ToolbarButton(
|
||||||
@@ -314,16 +451,41 @@ class BrowserTabBar extends HookConsumerWidget {
|
|||||||
],
|
],
|
||||||
quickTabSwitcher: switch (stackingMode) {
|
quickTabSwitcher: switch (stackingMode) {
|
||||||
TabBarStackingMode.disabled => const SizedBox.shrink(),
|
TabBarStackingMode.disabled => const SizedBox.shrink(),
|
||||||
TabBarStackingMode.lastUsedTabs => const QuickTabSwitcher(
|
TabBarStackingMode.lastUsedTabs => QuickTabSwitcher(
|
||||||
quickTabSwitcherMode: QuickTabSwitcherMode.lastUsedTabs,
|
quickTabSwitcherMode: QuickTabSwitcherMode.lastUsedTabs,
|
||||||
|
axis: switcherAxis,
|
||||||
),
|
),
|
||||||
TabBarStackingMode.containerTabs => const QuickTabSwitcher(
|
TabBarStackingMode.containerTabs => QuickTabSwitcher(
|
||||||
quickTabSwitcherMode: QuickTabSwitcherMode.containerTabs,
|
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
|
// History fallback only on the MRU row, so empty-state history
|
||||||
// chips don't show twice.
|
// chips don't show twice. On the rail the two rows stack as two
|
||||||
TabBarStackingMode.twoLevel => const Column(
|
// 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,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
QuickTabSwitcher(
|
QuickTabSwitcher(
|
||||||
@@ -339,6 +501,7 @@ class BrowserTabBar extends HookConsumerWidget {
|
|||||||
contextualToolbar: ContextualToolbar(
|
contextualToolbar: ContextualToolbar(
|
||||||
selectedTabId: selectedTabId,
|
selectedTabId: selectedTabId,
|
||||||
displayedSheet: displayedSheet,
|
displayedSheet: displayedSheet,
|
||||||
|
axis: switcherAxis,
|
||||||
),
|
),
|
||||||
onHorizontalDragStart: !enableGestures
|
onHorizontalDragStart: !enableGestures
|
||||||
? null
|
? null
|
||||||
@@ -349,30 +512,21 @@ class BrowserTabBar extends HookConsumerWidget {
|
|||||||
? null
|
? null
|
||||||
: (details) async {
|
: (details) async {
|
||||||
final distance = dragStartPosition.value - details.globalPosition;
|
final distance = dragStartPosition.value - details.globalPosition;
|
||||||
|
const dismissThreshold = kToolbarHeight * 0.5;
|
||||||
|
|
||||||
if (distance.dx.abs() > 50 && distance.dy.abs() < 20) {
|
if (isVertical) {
|
||||||
final selectedTab = ref.read(selectedTabProvider);
|
// Rail: horizontal swipe dismisses toward the docked edge.
|
||||||
final setting = await ref
|
// distance = start - end, so a leftward swipe is positive dx.
|
||||||
.read(generalSettingsRepositoryProvider.notifier)
|
final shouldDismiss = switch (tabBarPosition) {
|
||||||
.fetchSettings();
|
TabBarPosition.left => distance.dx > dismissThreshold,
|
||||||
|
TabBarPosition.right => distance.dx < -dismissThreshold,
|
||||||
if (selectedTab != null) {
|
_ => false,
|
||||||
switch (setting.tabBarSwipeAction) {
|
};
|
||||||
case TabBarSwipeAction.switchLastOpened:
|
if (shouldDismiss) dismissToolbar();
|
||||||
await ref
|
|
||||||
.read(tabRepositoryProvider.notifier)
|
|
||||||
.selectPreviouslyOpenedTab(selectedTab);
|
|
||||||
case TabBarSwipeAction.navigateOrderedTabs:
|
|
||||||
if (distance.dx < 0) {
|
|
||||||
await ref
|
|
||||||
.read(tabRepositoryProvider.notifier)
|
|
||||||
.selectPreviousTab(selectedTab);
|
|
||||||
} else {
|
} else {
|
||||||
await ref
|
// Horizontal bar: horizontal swipe switches tabs.
|
||||||
.read(tabRepositoryProvider.notifier)
|
if (distance.dx.abs() > 50 && distance.dy.abs() < 20) {
|
||||||
.selectNextTab(selectedTab);
|
await switchTabsBy(distance.dx);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -383,10 +537,18 @@ class BrowserTabBar extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
onVerticalDragEnd: !enableGestures
|
onVerticalDragEnd: !enableGestures
|
||||||
? null
|
? null
|
||||||
: (details) {
|
: (details) async {
|
||||||
final distance = dragStartPosition.value - details.globalPosition;
|
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)
|
// - Bottom bar: swipe down to dismiss (positive distance.dy)
|
||||||
// - Top bar: swipe up to dismiss (negative distance.dy)
|
// - Top bar: swipe up to dismiss (negative distance.dy)
|
||||||
const dismissThreshold = kToolbarHeight * 0.5;
|
const dismissThreshold = kToolbarHeight * 0.5;
|
||||||
@@ -397,18 +559,9 @@ class BrowserTabBar extends HookConsumerWidget {
|
|||||||
TabBarPosition.top =>
|
TabBarPosition.top =>
|
||||||
!distance.dy.isNegative &&
|
!distance.dy.isNegative &&
|
||||||
distance.dy.abs() > dismissThreshold,
|
distance.dy.abs() > dismissThreshold,
|
||||||
|
_ => false,
|
||||||
};
|
};
|
||||||
if (shouldDismiss &&
|
if (shouldDismiss) dismissToolbar();
|
||||||
ref.read(bottomSheetControllerProvider) == null) {
|
|
||||||
unawaited(HapticFeedback.lightImpact());
|
|
||||||
ref
|
|
||||||
.read(
|
|
||||||
toolbarVisibilityControllerProvider(
|
|
||||||
selectedTabId,
|
|
||||||
).notifier,
|
|
||||||
)
|
|
||||||
.dismiss();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -427,12 +580,21 @@ class BrowserTabBarView extends StatelessWidget {
|
|||||||
required this.actions,
|
required this.actions,
|
||||||
required this.quickTabSwitcher,
|
required this.quickTabSwitcher,
|
||||||
required this.contextualToolbar,
|
required this.contextualToolbar,
|
||||||
|
this.axis = Axis.horizontal,
|
||||||
|
this.railOnLeft = true,
|
||||||
this.onHorizontalDragStart,
|
this.onHorizontalDragStart,
|
||||||
this.onHorizontalDragEnd,
|
this.onHorizontalDragEnd,
|
||||||
this.onVerticalDragStart,
|
this.onVerticalDragStart,
|
||||||
this.onVerticalDragEnd,
|
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 showMainToolbar;
|
||||||
final bool showContextualToolbar;
|
final bool showContextualToolbar;
|
||||||
final bool showQuickTabSwitcherBar;
|
final bool showQuickTabSwitcherBar;
|
||||||
@@ -454,6 +616,54 @@ class BrowserTabBarView extends StatelessWidget {
|
|||||||
final effectiveBackgroundColor =
|
final effectiveBackgroundColor =
|
||||||
backgroundColor ?? colorScheme.surfaceContainer;
|
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(
|
return GestureDetector(
|
||||||
// Tap handling moved to AppBarTitle for split icon/title behavior
|
// Tap handling moved to AppBarTitle for split icon/title behavior
|
||||||
onHorizontalDragStart: onHorizontalDragStart,
|
onHorizontalDragStart: onHorizontalDragStart,
|
||||||
@@ -505,10 +715,14 @@ class QuickTabSwitcher extends HookConsumerWidget {
|
|||||||
/// chips don't show twice.
|
/// chips don't show twice.
|
||||||
final bool enableHistoryFallback;
|
final bool enableHistoryFallback;
|
||||||
|
|
||||||
|
/// Direction the chips list flows. Vertical for the side rail.
|
||||||
|
final Axis axis;
|
||||||
|
|
||||||
const QuickTabSwitcher({
|
const QuickTabSwitcher({
|
||||||
super.key,
|
super.key,
|
||||||
required this.quickTabSwitcherMode,
|
required this.quickTabSwitcherMode,
|
||||||
this.enableHistoryFallback = true,
|
this.enableHistoryFallback = true,
|
||||||
|
this.axis = Axis.horizontal,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -516,11 +730,13 @@ class QuickTabSwitcher extends HookConsumerWidget {
|
|||||||
final showIsolatedTabUi = ref.watch(
|
final showIsolatedTabUi = ref.watch(
|
||||||
generalSettingsWithDefaultsProvider.select((s) => s.showIsolatedTabUi),
|
generalSettingsWithDefaultsProvider.select((s) => s.showIsolatedTabUi),
|
||||||
);
|
);
|
||||||
final showTitles = ref.watch(
|
final showTitlesSetting = ref.watch(
|
||||||
generalSettingsWithDefaultsProvider.select(
|
generalSettingsWithDefaultsProvider.select(
|
||||||
(s) => s.quickTabSwitcherShowTitles,
|
(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(
|
final titleMaxWidth = ref.watch(
|
||||||
generalSettingsWithDefaultsProvider.select(
|
generalSettingsWithDefaultsProvider.select(
|
||||||
(s) => s.quickTabSwitcherTitleWidth,
|
(s) => s.quickTabSwitcherTitleWidth,
|
||||||
@@ -677,6 +893,7 @@ class QuickTabSwitcher extends HookConsumerWidget {
|
|||||||
scrollController: chipScrollController,
|
scrollController: chipScrollController,
|
||||||
scrollKey: scrollKey,
|
scrollKey: scrollKey,
|
||||||
activeItemKey: activeItemKey.value,
|
activeItemKey: activeItemKey.value,
|
||||||
|
axis: axis,
|
||||||
showTitles: showTitles,
|
showTitles: showTitles,
|
||||||
showIsolatedTabUi: showIsolatedTabUi,
|
showIsolatedTabUi: showIsolatedTabUi,
|
||||||
hierarchyGlyphs: hierarchyGlyphs,
|
hierarchyGlyphs: hierarchyGlyphs,
|
||||||
@@ -764,8 +981,12 @@ class QuickTabSwitcherView extends StatelessWidget {
|
|||||||
this.onCloseItem,
|
this.onCloseItem,
|
||||||
this.onReorderItem,
|
this.onReorderItem,
|
||||||
this.reorderableItemCount = 0,
|
this.reorderableItemCount = 0,
|
||||||
|
this.axis = Axis.horizontal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Direction the chips flow. Vertical for the side rail.
|
||||||
|
final Axis axis;
|
||||||
|
|
||||||
final List<QuickTabSwitcherItem> availableItems;
|
final List<QuickTabSwitcherItem> availableItems;
|
||||||
final QuickTabSwitcherItem? activeItem;
|
final QuickTabSwitcherItem? activeItem;
|
||||||
final ScrollController scrollController;
|
final ScrollController scrollController;
|
||||||
@@ -803,25 +1024,36 @@ class QuickTabSwitcherView extends StatelessWidget {
|
|||||||
|
|
||||||
bool get _reorderEnabled => onReorderItem != null && reorderableItemCount > 0;
|
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) =>
|
bool _canShowCloseButton(QuickTabSwitcherItem item) =>
|
||||||
|
!_isVertical &&
|
||||||
onCloseItem != null &&
|
onCloseItem != null &&
|
||||||
!item.isHistory &&
|
!item.isHistory &&
|
||||||
!item.isPlaceholder &&
|
!item.isPlaceholder &&
|
||||||
(showCloseButtonOnAllTabs || item.isActive);
|
(showCloseButtonOnAllTabs || item.isActive);
|
||||||
|
|
||||||
|
bool get _isVertical => axis == Axis.vertical;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (availableItems.isEmpty) {
|
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.
|
// 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(
|
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(
|
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,
|
width: double.maxFinite,
|
||||||
child: _reorderEnabled
|
child: _reorderEnabled
|
||||||
? _buildReorderableList(context)
|
? _buildReorderableList(context)
|
||||||
@@ -838,6 +1070,7 @@ class QuickTabSwitcherView extends StatelessWidget {
|
|||||||
scrollController: scrollController,
|
scrollController: scrollController,
|
||||||
scrollKey: scrollKey,
|
scrollKey: scrollKey,
|
||||||
activeItemKey: activeItemKey,
|
activeItemKey: activeItemKey,
|
||||||
|
scrollDirection: axis,
|
||||||
cacheExtent: 500,
|
cacheExtent: 500,
|
||||||
itemId: (item) => item.id,
|
itemId: (item) => item.id,
|
||||||
selectedItem: activeItem,
|
selectedItem: activeItem,
|
||||||
@@ -867,7 +1100,7 @@ class QuickTabSwitcherView extends StatelessWidget {
|
|||||||
return ReorderableListView.builder(
|
return ReorderableListView.builder(
|
||||||
key: scrollKey,
|
key: scrollKey,
|
||||||
scrollController: scrollController,
|
scrollController: scrollController,
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: axis,
|
||||||
buildDefaultDragHandles: true,
|
buildDefaultDragHandles: true,
|
||||||
scrollCacheExtent: const ScrollCacheExtent.pixels(500),
|
scrollCacheExtent: const ScrollCacheExtent.pixels(500),
|
||||||
itemCount: reorderableCount,
|
itemCount: reorderableCount,
|
||||||
|
|||||||
+33
-11
@@ -18,6 +18,7 @@
|
|||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -30,6 +31,8 @@ class DraggableFab extends HookConsumerWidget {
|
|||||||
final bool bottomToolbarVisible;
|
final bool bottomToolbarVisible;
|
||||||
final double bottomAppBarHeight;
|
final double bottomAppBarHeight;
|
||||||
final double bottomSafeArea;
|
final double bottomSafeArea;
|
||||||
|
final double leftReservedWidth;
|
||||||
|
final double rightReservedWidth;
|
||||||
|
|
||||||
const DraggableFab({
|
const DraggableFab({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -38,6 +41,8 @@ class DraggableFab extends HookConsumerWidget {
|
|||||||
required this.bottomToolbarVisible,
|
required this.bottomToolbarVisible,
|
||||||
required this.bottomAppBarHeight,
|
required this.bottomAppBarHeight,
|
||||||
required this.bottomSafeArea,
|
required this.bottomSafeArea,
|
||||||
|
this.leftReservedWidth = 0.0,
|
||||||
|
this.rightReservedWidth = 0.0,
|
||||||
});
|
});
|
||||||
|
|
||||||
static const _edgePadding = 16.0;
|
static const _edgePadding = 16.0;
|
||||||
@@ -82,11 +87,20 @@ class DraggableFab extends HookConsumerWidget {
|
|||||||
final defaultBottom = bottomToolbarVisible
|
final defaultBottom = bottomToolbarVisible
|
||||||
? bottomAppBarHeight + _edgePadding
|
? bottomAppBarHeight + _edgePadding
|
||||||
: _edgePadding + bottomSafeArea;
|
: _edgePadding + bottomSafeArea;
|
||||||
const defaultRight = _edgePadding;
|
final defaultRight = _edgePadding + rightReservedWidth;
|
||||||
|
|
||||||
// Current position: custom if set, otherwise default
|
// Current position: custom if set, otherwise default
|
||||||
final currentRight = customOffset.value?.dx ?? defaultRight;
|
final rawRight = customOffset.value?.dx ?? defaultRight;
|
||||||
final currentBottom = customOffset.value?.dy ?? defaultBottom;
|
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(
|
return Positioned(
|
||||||
right: currentRight,
|
right: currentRight,
|
||||||
@@ -145,19 +159,27 @@ class DraggableFab extends HookConsumerWidget {
|
|||||||
required double fabHeight,
|
required double fabHeight,
|
||||||
}) {
|
}) {
|
||||||
const minEdgePadding = 8.0;
|
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
|
// 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)
|
// 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.
|
// FAB on the far edge so it can't be dragged off-screen.
|
||||||
return Offset(
|
return Offset(
|
||||||
offset.dx.clamp(
|
offset.dx.clamp(minRight, maxRight),
|
||||||
padding.right + minEdgePadding,
|
offset.dy.clamp(minBottom, maxBottom),
|
||||||
screenSize.width - fabWidth - padding.left - minEdgePadding,
|
|
||||||
),
|
|
||||||
offset.dy.clamp(
|
|
||||||
padding.bottom + minEdgePadding,
|
|
||||||
screenSize.height - fabHeight - padding.top - minEdgePadding,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+117
-37
@@ -50,10 +50,14 @@ import 'package:weblibre/presentation/widgets/inline_count_badge.dart';
|
|||||||
/// "expanded" — its tabs appear inline right after its header. Tapping
|
/// "expanded" — its tabs appear inline right after its header. Tapping
|
||||||
/// another header selects that container, collapsing the previous group.
|
/// another header selects that container, collapsing the previous group.
|
||||||
class AccordionQuickTabSwitcher extends HookConsumerWidget {
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isVertical = axis == Axis.vertical;
|
||||||
final scrollController = useScrollController();
|
final scrollController = useScrollController();
|
||||||
final activeChipKey = useRef(GlobalKey());
|
final activeChipKey = useRef(GlobalKey());
|
||||||
final isUserScrolling = useRef(false);
|
final isUserScrolling = useRef(false);
|
||||||
@@ -63,11 +67,13 @@ class AccordionQuickTabSwitcher extends HookConsumerWidget {
|
|||||||
return userScrollTimer.value?.cancel;
|
return userScrollTimer.value?.cancel;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
final showTitles = ref.watch(
|
final showTitlesSetting = ref.watch(
|
||||||
generalSettingsWithDefaultsProvider.select(
|
generalSettingsWithDefaultsProvider.select(
|
||||||
(s) => s.quickTabSwitcherShowTitles,
|
(s) => s.quickTabSwitcherShowTitles,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
// Titles can't fit the narrow vertical rail; force icon-only chips there.
|
||||||
|
final showTitles = !isVertical && showTitlesSetting;
|
||||||
final showIsolatedTabUi = ref.watch(
|
final showIsolatedTabUi = ref.watch(
|
||||||
generalSettingsWithDefaultsProvider.select((s) => s.showIsolatedTabUi),
|
generalSettingsWithDefaultsProvider.select((s) => s.showIsolatedTabUi),
|
||||||
);
|
);
|
||||||
@@ -172,8 +178,13 @@ class AccordionQuickTabSwitcher extends HookConsumerWidget {
|
|||||||
|
|
||||||
Widget buildTabChip(QuickTabSwitcherItem item) {
|
Widget buildTabChip(QuickTabSwitcherItem item) {
|
||||||
final isSelected = item.isActive;
|
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 =
|
final canClose =
|
||||||
!item.isPlaceholder && (showCloseButtonOnAllTabs || item.isActive);
|
!isVertical &&
|
||||||
|
!item.isPlaceholder &&
|
||||||
|
(showCloseButtonOnAllTabs || item.isActive);
|
||||||
|
|
||||||
final chip = QuickTabSwitcherChip(
|
final chip = QuickTabSwitcherChip(
|
||||||
item: item,
|
item: item,
|
||||||
@@ -300,9 +311,9 @@ class AccordionQuickTabSwitcher extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (entries.isEmpty) {
|
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.
|
// quickTabSwitcherRowCountProvider.
|
||||||
return const SizedBox(height: 48);
|
return isVertical ? const SizedBox(width: 48) : const SizedBox(height: 48);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NotificationListener<UserScrollNotification>(
|
return NotificationListener<UserScrollNotification>(
|
||||||
@@ -315,10 +326,12 @@ class AccordionQuickTabSwitcher extends HookConsumerWidget {
|
|||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
child: Padding(
|
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(
|
child: SizedBox(
|
||||||
height: 48,
|
height: isVertical ? double.maxFinite : 48,
|
||||||
width: double.maxFinite,
|
width: isVertical ? 48 : double.maxFinite,
|
||||||
child: FadingScroll(
|
child: FadingScroll(
|
||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
fadingSize: 15,
|
fadingSize: 15,
|
||||||
@@ -326,7 +339,7 @@ class AccordionQuickTabSwitcher extends HookConsumerWidget {
|
|||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
key: const PageStorageKey('quick_tab_switcher_accordion'),
|
key: const PageStorageKey('quick_tab_switcher_accordion'),
|
||||||
controller: controller,
|
controller: controller,
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: axis,
|
||||||
scrollCacheExtent: const ScrollCacheExtent.pixels(500),
|
scrollCacheExtent: const ScrollCacheExtent.pixels(500),
|
||||||
itemCount: entries.length,
|
itemCount: entries.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
@@ -334,6 +347,9 @@ class AccordionQuickTabSwitcher extends HookConsumerWidget {
|
|||||||
final child = switch (entry) {
|
final child = switch (entry) {
|
||||||
_AccordionHeaderEntry() => _AccordionHeaderChip(
|
_AccordionHeaderEntry() => _AccordionHeaderChip(
|
||||||
entry: entry,
|
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),
|
onSelected: () => selectContainer(entry.container?.id),
|
||||||
),
|
),
|
||||||
_AccordionTabEntry(:final item) => buildTabChip(item),
|
_AccordionTabEntry(:final item) => buildTabChip(item),
|
||||||
@@ -346,6 +362,7 @@ class AccordionQuickTabSwitcher extends HookConsumerWidget {
|
|||||||
child: _TraySlice(
|
child: _TraySlice(
|
||||||
position: trayPositions[index],
|
position: trayPositions[index],
|
||||||
fill: trayFill,
|
fill: trayFill,
|
||||||
|
axis: axis,
|
||||||
child: child,
|
child: child,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -407,7 +424,15 @@ class _AccordionHeaderChip extends StatelessWidget {
|
|||||||
final _AccordionHeaderEntry entry;
|
final _AccordionHeaderEntry entry;
|
||||||
final VoidCallback onSelected;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -446,10 +471,49 @@ class _AccordionHeaderChip extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return FilterChip(
|
final iconAvatar = container != null
|
||||||
avatar: container != null
|
|
||||||
? buildContainerChipAvatar(context, container, true)
|
? buildContainerChipAvatar(context, container, true)
|
||||||
: Icon(MdiIcons.folderHidden, color: nullForeground),
|
: 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: iconAvatar,
|
||||||
label: container != null
|
label: container != null
|
||||||
? buildContainerChipLabel(
|
? buildContainerChipLabel(
|
||||||
context,
|
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),
|
color: WidgetStatePropertyAll(fill),
|
||||||
selected: false,
|
selected: false,
|
||||||
showCheckmark: false,
|
showCheckmark: false,
|
||||||
@@ -478,11 +540,8 @@ class _AccordionHeaderChip extends StatelessWidget {
|
|||||||
onSelected();
|
onSelected();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
side: BorderSide(width: 2, color: fill),
|
side: side,
|
||||||
shape: RoundedRectangleBorder(
|
shape: shape,
|
||||||
borderRadius: BorderRadius.circular(8.0),
|
|
||||||
side: BorderSide(width: 2, color: fill),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -500,11 +559,13 @@ class _TraySlice extends StatelessWidget {
|
|||||||
final _TrayPosition position;
|
final _TrayPosition position;
|
||||||
final Color fill;
|
final Color fill;
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
final Axis axis;
|
||||||
|
|
||||||
const _TraySlice({
|
const _TraySlice({
|
||||||
required this.position,
|
required this.position,
|
||||||
required this.fill,
|
required this.fill,
|
||||||
required this.child,
|
required this.child,
|
||||||
|
this.axis = Axis.horizontal,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Corner radius of the chips, matched by the tray so it hugs the first and
|
/// Corner radius of the chips, matched by the tray so it hugs the first and
|
||||||
@@ -513,40 +574,59 @@ class _TraySlice extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final isVertical = axis == Axis.vertical;
|
||||||
|
|
||||||
if (position == _TrayPosition.none) {
|
if (position == _TrayPosition.none) {
|
||||||
// Standalone container header: regular inter-chip spacing, vertically
|
// Standalone container header: regular inter-chip spacing, centered
|
||||||
// centered to line up with the tray slices.
|
// on the cross axis to line up with the tray slices.
|
||||||
return Padding(
|
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,
|
child: child,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final borderRadius = switch (position) {
|
final borderRadius = switch ((position, isVertical)) {
|
||||||
_TrayPosition.solo => const BorderRadius.all(_radius),
|
(_TrayPosition.solo, _) => const BorderRadius.all(_radius),
|
||||||
_TrayPosition.start => const BorderRadius.horizontal(left: _radius),
|
(_TrayPosition.start, false) => const BorderRadius.horizontal(
|
||||||
_TrayPosition.end => const BorderRadius.horizontal(right: _radius),
|
left: _radius,
|
||||||
_TrayPosition.middle || _TrayPosition.none => BorderRadius.zero,
|
),
|
||||||
|
(_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;
|
position == _TrayPosition.end || position == _TrayPosition.solo;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.only(
|
// 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,
|
top: 2.0,
|
||||||
bottom: 2.0,
|
bottom: 2.0,
|
||||||
// Transparent gap after the tray so a following standalone header
|
right: isTrailingEdge ? 8.0 : 0.0,
|
||||||
// doesn't butt up against the rounded right edge.
|
|
||||||
right: isRightEdge ? 8.0 : 0.0,
|
|
||||||
),
|
),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 44.0,
|
height: isVertical ? null : 44.0,
|
||||||
|
width: isVertical ? 44.0 : null,
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(color: fill, borderRadius: borderRadius),
|
decoration: BoxDecoration(color: fill, borderRadius: borderRadius),
|
||||||
// A small inset on every side so the first/last chip get the same
|
// A small inset so the first/last chip get the same breathing room
|
||||||
// breathing room from the tray edge as the inter-chip seam gaps.
|
// from the tray edge as the inter-chip seam gaps.
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
padding: isVertical
|
||||||
|
? const EdgeInsets.symmetric(vertical: 4.0)
|
||||||
|
: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||||
child: Center(child: child),
|
child: Center(child: child),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ final class BrowsingDownloadsProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$browsingDownloadsHash() => r'938ae4d26b4e0a3f428d3d530a71cb5d8720f317';
|
String _$browsingDownloadsHash() => r'a526dab6915085e6fb47a33d5acc892c3f8e17b2';
|
||||||
|
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
// JsonGenerator
|
// JsonGenerator
|
||||||
|
|||||||
+1
-1
@@ -49,7 +49,7 @@ final class ContainerHistoryRepositoryProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$containerHistoryRepositoryHash() =>
|
String _$containerHistoryRepositoryHash() =>
|
||||||
r'8872a421664c0ac8d6b0bd3ca76e1300677b0873';
|
r'89bd0a552b47643a8ef8c182901af3811e96c271';
|
||||||
|
|
||||||
/// Mutations that combine the visit→container relation (`visit_container`) with
|
/// Mutations that combine the visit→container relation (`visit_container`) with
|
||||||
/// Mozilla Places, the source of truth for the visits themselves.
|
/// Mozilla Places, the source of truth for the visits themselves.
|
||||||
|
|||||||
+1
-1
@@ -69,7 +69,7 @@ final class VisitContainerRecorderProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$visitContainerRecorderHash() =>
|
String _$visitContainerRecorderHash() =>
|
||||||
r'2d5742816ec08bc37edf2b56a933b6a324e8b038';
|
r'13ed2d5a188efd2a4419585f4989e782263884aa';
|
||||||
|
|
||||||
/// Records the visit→container relation. Mozilla Places owns the visit itself;
|
/// Records the visit→container relation. Mozilla Places owns the visit itself;
|
||||||
/// on each Places visit the native [WebLibreHistoryDelegate] forwards the
|
/// on each Places visit the native [WebLibreHistoryDelegate] forwards the
|
||||||
|
|||||||
@@ -203,6 +203,16 @@ class _TabBarPositionSection extends HookConsumerWidget {
|
|||||||
title: Text('Bottom'),
|
title: Text('Bottom'),
|
||||||
subtitle: Text('Tab bar with auto-hide support'),
|
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;
|
double get _headerHeight => compact ? 0.0 : _kHeaderHeight;
|
||||||
|
|
||||||
@override
|
/// Fixed preview height for the vertical rail (the rail flows along the
|
||||||
double get minExtent =>
|
/// height, so it can't be derived from stacked section heights).
|
||||||
_baseHeight + _toolbarHeight + _headerHeight + padding.vertical;
|
static const _kRailPreviewHeight = 220.0;
|
||||||
|
static const _kCompactRailPreviewHeight = 140.0;
|
||||||
|
|
||||||
|
double get _contentHeight => settings.tabBarPosition.isVertical
|
||||||
|
? (compact ? _kCompactRailPreviewHeight : _kRailPreviewHeight)
|
||||||
|
: _baseHeight + _toolbarHeight;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
double get maxExtent =>
|
double get minExtent => _contentHeight + _headerHeight + padding.vertical;
|
||||||
_baseHeight + _toolbarHeight + _headerHeight + padding.vertical;
|
|
||||||
|
@override
|
||||||
|
double get maxExtent => _contentHeight + _headerHeight + padding.vertical;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(
|
Widget build(
|
||||||
@@ -200,12 +207,17 @@ class TabBarPreviewCard extends HookWidget {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget buildQuickTabSwitcherRow(ScrollController scrollController) {
|
Widget buildQuickTabSwitcherRow(
|
||||||
|
ScrollController scrollController, {
|
||||||
|
Axis axis = Axis.horizontal,
|
||||||
|
}) {
|
||||||
return QuickTabSwitcherView(
|
return QuickTabSwitcherView(
|
||||||
availableItems: previewQuickItems,
|
availableItems: previewQuickItems,
|
||||||
activeItem: previewQuickItems.firstWhere((item) => item.isActive),
|
activeItem: previewQuickItems.firstWhere((item) => item.isActive),
|
||||||
scrollController: scrollController,
|
scrollController: scrollController,
|
||||||
showTitles: settings.quickTabSwitcherShowTitles,
|
axis: axis,
|
||||||
|
showTitles:
|
||||||
|
axis != Axis.vertical && settings.quickTabSwitcherShowTitles,
|
||||||
showIsolatedTabUi: settings.showIsolatedTabUi,
|
showIsolatedTabUi: settings.showIsolatedTabUi,
|
||||||
hierarchyGlyphs: settings.quickTabSwitcherHierarchyGlyphs,
|
hierarchyGlyphs: settings.quickTabSwitcherHierarchyGlyphs,
|
||||||
titleMaxWidth: settings.quickTabSwitcherTitleWidth,
|
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
|
// The accordion preview reuses the single-row layout; container header
|
||||||
// chips need live container data that the static preview doesn't have.
|
// chips need live container data that the static preview doesn't have.
|
||||||
if (settings.effectiveTabBarStackingMode() ==
|
if (settings.effectiveTabBarStackingMode() ==
|
||||||
TabBarStackingMode.twoLevel) {
|
TabBarStackingMode.twoLevel) {
|
||||||
return Column(
|
final rows = [
|
||||||
mainAxisSize: MainAxisSize.min,
|
buildQuickTabSwitcherRow(quickTabsController, axis: axis),
|
||||||
children: [
|
buildQuickTabSwitcherRow(quickTabsSecondRowController, axis: axis),
|
||||||
buildQuickTabSwitcherRow(quickTabsController),
|
];
|
||||||
buildQuickTabSwitcherRow(quickTabsSecondRowController),
|
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(
|
return ContextualToolbarView(
|
||||||
|
axis: axis,
|
||||||
buttons: [
|
buttons: [
|
||||||
NavigateBackButtonView(
|
NavigateBackButtonView(
|
||||||
canGoBack: true,
|
canGoBack: true,
|
||||||
@@ -307,21 +320,30 @@ class TabBarPreviewCard extends HookWidget {
|
|||||||
contextualToolbar: buildContextualToolbar(),
|
contextualToolbar: buildContextualToolbar(),
|
||||||
);
|
);
|
||||||
|
|
||||||
final previewContent = Container(
|
final isRailPreview = settings.tabBarPosition.isVertical;
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
decoration: BoxDecoration(
|
final railToolbar = BrowserTabBarView(
|
||||||
color: compact
|
axis: Axis.vertical,
|
||||||
? colorScheme.surface.withValues(alpha: 0.7)
|
railOnLeft: settings.tabBarPosition == TabBarPosition.left,
|
||||||
: colorScheme.surface,
|
showMainToolbar: true,
|
||||||
border: Border.all(color: colorScheme.outlineVariant),
|
showContextualToolbar: settings.tabBarShowContextualBar,
|
||||||
borderRadius: BorderRadius.circular(4),
|
showQuickTabSwitcherBar: showQuickTabSwitcherBar,
|
||||||
|
displayAppBar: true,
|
||||||
|
displayQuickTabSwitcher: true,
|
||||||
|
backgroundColor:
|
||||||
|
previewContainerPalette?.surfaceColor ?? colorScheme.surfaceContainer,
|
||||||
|
title: _RailPreviewTitle(
|
||||||
|
tabState: previewTabState,
|
||||||
|
quarterTurns: settings.tabBarPosition == TabBarPosition.left ? 3 : 1,
|
||||||
),
|
),
|
||||||
child: Column(
|
actions: mainToolbarActions,
|
||||||
children: [
|
quickTabSwitcher: buildQuickTabSwitcher(axis: Axis.vertical),
|
||||||
if (settings.tabBarPosition == TabBarPosition.top) topMainToolbar,
|
contextualToolbar: buildContextualToolbar(axis: Axis.vertical),
|
||||||
Container(
|
);
|
||||||
height: compact ? 40 : 72,
|
|
||||||
|
final pageContentBox = Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: compact
|
color: compact
|
||||||
@@ -335,7 +357,45 @@ class TabBarPreviewCard extends HookWidget {
|
|||||||
'Page Content',
|
'Page Content',
|
||||||
style: Theme.of(context).textTheme.labelMedium,
|
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)
|
if (settings.tabBarPosition == TabBarPosition.top)
|
||||||
topBottomToolbar
|
topBottomToolbar
|
||||||
else
|
else
|
||||||
@@ -343,6 +403,7 @@ class TabBarPreviewCard extends HookWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
return previewContent;
|
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() {}
|
void _noop() {}
|
||||||
|
|||||||
@@ -87,7 +87,23 @@ enum TabIntentOpenSetting { regular, private, isolated, ask }
|
|||||||
|
|
||||||
enum TabDirection { newestFirst, oldestFirst }
|
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 }
|
enum TabBarLayout { withTitle, compact }
|
||||||
|
|
||||||
|
|||||||
@@ -1246,6 +1246,8 @@ const _$TabBarSwipeActionEnumMap = {
|
|||||||
const _$TabBarPositionEnumMap = {
|
const _$TabBarPositionEnumMap = {
|
||||||
TabBarPosition.top: 'top',
|
TabBarPosition.top: 'top',
|
||||||
TabBarPosition.bottom: 'bottom',
|
TabBarPosition.bottom: 'bottom',
|
||||||
|
TabBarPosition.left: 'left',
|
||||||
|
TabBarPosition.right: 'right',
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$TabBarLayoutEnumMap = {
|
const _$TabBarLayoutEnumMap = {
|
||||||
|
|||||||
@@ -145,6 +145,10 @@ class SelectableChips<T extends S, S, K> extends StatelessWidget {
|
|||||||
final GlobalKey? activeItemKey;
|
final GlobalKey? activeItemKey;
|
||||||
final double? cacheExtent;
|
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
|
/// Key applied to the underlying scroll view. Supply a [PageStorageKey] to
|
||||||
/// preserve the scroll offset across rebuilds/remounts (e.g. when a host
|
/// preserve the scroll offset across rebuilds/remounts (e.g. when a host
|
||||||
/// widget is torn down and recreated by a bottom sheet open/close).
|
/// 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.activeItemKey,
|
||||||
this.cacheExtent = 0,
|
this.cacheExtent = 0,
|
||||||
this.scrollKey,
|
this.scrollKey,
|
||||||
|
this.scrollDirection = Axis.horizontal,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -221,10 +226,14 @@ class SelectableChips<T extends S, S, K> extends StatelessWidget {
|
|||||||
final prefixCount = prefixListItems.length;
|
final prefixCount = prefixListItems.length;
|
||||||
final totalCount = prefixCount + items.length;
|
final totalCount = prefixCount + items.length;
|
||||||
|
|
||||||
|
final isVertical = scrollDirection == Axis.vertical;
|
||||||
|
|
||||||
Widget buildPrefix(int index) {
|
Widget buildPrefix(int index) {
|
||||||
return Padding(
|
return Padding(
|
||||||
key: ValueKey('__selectable_chips_prefix_$index'),
|
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],
|
child: prefixListItems[index],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -238,7 +247,9 @@ class SelectableChips<T extends S, S, K> extends StatelessWidget {
|
|||||||
final canDeleteItem =
|
final canDeleteItem =
|
||||||
enableDelete && (deco?.canDelete?.call(item) ?? true);
|
enableDelete && (deco?.canDelete?.call(item) ?? true);
|
||||||
final child = Padding(
|
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(
|
child: _BadgeWrapper(
|
||||||
count: itemBadgeCount?.call(item),
|
count: itemBadgeCount?.call(item),
|
||||||
child: _GestureWrapper(
|
child: _GestureWrapper(
|
||||||
@@ -307,7 +318,7 @@ class SelectableChips<T extends S, S, K> extends StatelessWidget {
|
|||||||
scrollCacheExtent: cacheExtent.mapNotNull(
|
scrollCacheExtent: cacheExtent.mapNotNull(
|
||||||
(extent) => ScrollCacheExtent.pixels(extent),
|
(extent) => ScrollCacheExtent.pixels(extent),
|
||||||
),
|
),
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: scrollDirection,
|
||||||
itemCount: totalCount,
|
itemCount: totalCount,
|
||||||
itemBuilder: (context, index) => index < prefixCount
|
itemBuilder: (context, index) => index < prefixCount
|
||||||
? buildPrefix(index)
|
? buildPrefix(index)
|
||||||
@@ -320,7 +331,7 @@ class SelectableChips<T extends S, S, K> extends StatelessWidget {
|
|||||||
scrollCacheExtent: cacheExtent.mapNotNull(
|
scrollCacheExtent: cacheExtent.mapNotNull(
|
||||||
(extent) => ScrollCacheExtent.pixels(extent),
|
(extent) => ScrollCacheExtent.pixels(extent),
|
||||||
),
|
),
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: scrollDirection,
|
||||||
buildDefaultDragHandles: false,
|
buildDefaultDragHandles: false,
|
||||||
itemCount: totalCount,
|
itemCount: totalCount,
|
||||||
itemBuilder: (context, index) => index < prefixCount
|
itemBuilder: (context, index) => index < prefixCount
|
||||||
|
|||||||
Reference in New Issue
Block a user