Polish floating bottom bar: elevation, height, overlap, and FAB shape
- Give the floating bar and search satellite the app's one shadow (nooDialogShadow) plus a smaller, tighter height on both platforms. - Let every tab's scrollable list compute its own bottom clearance (bottomBarClearance) instead of a flat 100px, so the floating bar no longer overlaps the last row/card. - Square the Android upload FAB off to a rounded-square radius when the bottom bar is attached, keeping it fully round only when floating. - Let a swipe on a file row trigger its action directly once dragged past 1.8x the reveal width, instead of requiring a second tap. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -502,6 +502,7 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
pickRequest == null && navStyle == NooNavStyle.android
|
||||
? NooFab(
|
||||
collapsed: !canUpload,
|
||||
barStyle: bottomBarStyle,
|
||||
onTap: () => showCreateMenu(context),
|
||||
)
|
||||
: null,
|
||||
|
||||
@@ -356,8 +356,9 @@ class NooMotion {
|
||||
}
|
||||
|
||||
/// The one shadow the design system allows, from `tokens/spacing.css`'s
|
||||
/// `--shadow-dialog` - desktop dialogs only. Nothing else in the app should
|
||||
/// use a `BoxShadow`.
|
||||
/// `--shadow-dialog` - desktop dialogs, and [NooBottomBarStyle.floating]'s
|
||||
/// bar/search satellite (the only other thing in the app that's genuinely
|
||||
/// floating above other content). Nothing else should use a `BoxShadow`.
|
||||
const nooDialogShadow = BoxShadow(
|
||||
color: Color(0x241E002F), // rgba(30,0,47,.14)
|
||||
blurRadius: 48,
|
||||
|
||||
@@ -92,7 +92,7 @@ class ActivityView extends StatelessWidget {
|
||||
final List<Widget> contentSlivers = [
|
||||
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)),
|
||||
feed,
|
||||
...tabBottomInsetSlivers,
|
||||
...tabBottomInsetSlivers(context),
|
||||
];
|
||||
|
||||
final scrollView = CustomScrollView(
|
||||
|
||||
@@ -29,6 +29,7 @@ import '../widgets/noo/overlays/noo_sheet.dart';
|
||||
import '../widgets/noo/core/noo_button.dart';
|
||||
import '../widgets/share_sheet.dart';
|
||||
import '../widgets/sticky_header_delegate.dart';
|
||||
import '../widgets/tabs/tab_state_slivers.dart';
|
||||
import '../widgets/synced_header_scaffold.dart' show formatBytes;
|
||||
import 'file_viewer_screen.dart';
|
||||
import 'move_copy_destination_picker.dart';
|
||||
@@ -638,7 +639,7 @@ class _FavoritesViewState extends State<FavoritesView> {
|
||||
}, childCount: favorites.length),
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 100)),
|
||||
SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))),
|
||||
];
|
||||
|
||||
return PopScope(
|
||||
|
||||
@@ -39,11 +39,13 @@ import '../widgets/noo/lists/noo_selection_bar.dart';
|
||||
import '../widgets/noo/lists/noo_settings_row.dart';
|
||||
import '../widgets/noo/lists/noo_summary_card.dart';
|
||||
import '../widgets/noo/media/noo_grid_card.dart';
|
||||
import '../widgets/noo/nav/noo_bottom_bar.dart';
|
||||
import '../widgets/noo/noo_layout.dart';
|
||||
import '../widgets/noo/overlays/noo_sheet.dart';
|
||||
import '../widgets/share_sheet.dart';
|
||||
import '../widgets/sticky_header_delegate.dart';
|
||||
import '../widgets/synced_header_scaffold.dart' show formatBytes;
|
||||
import '../widgets/tabs/tab_state_slivers.dart';
|
||||
import 'file_viewer_screen.dart';
|
||||
import 'move_copy_destination_picker.dart';
|
||||
|
||||
@@ -815,6 +817,15 @@ class _FilesViewState extends State<FilesView> {
|
||||
}, childCount: browser.items.length),
|
||||
),
|
||||
),
|
||||
// List/grid/table above only pad NooSpace.lg at the bottom - plenty
|
||||
// once Scaffold shrinks the body above an attached bar, but floating
|
||||
// draws the body behind the bar instead, so it needs the bar's own
|
||||
// footprint added on top or the last row ends up under it.
|
||||
if (context.watch<SettingsController>().bottomBarStyle ==
|
||||
NooBottomBarStyle.floating)
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: bottomBarClearance(context)),
|
||||
),
|
||||
];
|
||||
|
||||
return PopScope(
|
||||
|
||||
@@ -30,6 +30,7 @@ import '../widgets/noo/overlays/noo_sheet.dart';
|
||||
import '../widgets/share_sheet.dart';
|
||||
import '../widgets/sort_menu_button.dart' show sortFieldLabel;
|
||||
import '../widgets/sticky_header_delegate.dart';
|
||||
import '../widgets/tabs/tab_state_slivers.dart';
|
||||
import 'file_viewer_screen.dart';
|
||||
import 'move_copy_destination_picker.dart';
|
||||
|
||||
@@ -346,9 +347,10 @@ class _PhotosViewState extends State<PhotosView> {
|
||||
),
|
||||
],
|
||||
|
||||
// Fixed clearance so the last row isn't hidden behind the floating
|
||||
// nav bar, regardless of grid length.
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 100)),
|
||||
// Clearance so the last row isn't hidden behind the nav bar,
|
||||
// regardless of grid length - see `bottomBarClearance`'s own doc
|
||||
// comment for why this has to be dynamic rather than a flat 100.
|
||||
SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))),
|
||||
];
|
||||
|
||||
return PopScope(
|
||||
|
||||
@@ -74,7 +74,7 @@ class _RecentViewState extends State<RecentView> {
|
||||
_buildDesktopTable(context, groups)
|
||||
else
|
||||
_buildMobileGroups(context, groups),
|
||||
...tabBottomInsetSlivers,
|
||||
...tabBottomInsetSlivers(context),
|
||||
];
|
||||
|
||||
return ColoredBox(
|
||||
|
||||
@@ -120,7 +120,7 @@ class _SharesViewState extends State<SharesView> {
|
||||
_buildDesktopTable(context, shares)
|
||||
else
|
||||
_buildMobileList(context, shares),
|
||||
...tabBottomInsetSlivers,
|
||||
...tabBottomInsetSlivers(context),
|
||||
];
|
||||
|
||||
return ColoredBox(
|
||||
|
||||
@@ -89,7 +89,7 @@ class _TrashViewState extends State<TrashView> {
|
||||
_buildDesktopTable(context, trash)
|
||||
else
|
||||
_buildMobileList(context, trash),
|
||||
...tabBottomInsetSlivers,
|
||||
...tabBottomInsetSlivers(context),
|
||||
];
|
||||
|
||||
return ColoredBox(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../../../theme/design_tokens.dart';
|
||||
import '../nav/noo_nav_style.dart';
|
||||
|
||||
/// Android-only extended Upload pill, bottom-right of Files and Photos (Noo
|
||||
/// Design System project, `components/core/FAB.jsx`). 56px tall, accent
|
||||
@@ -10,11 +11,21 @@ import '../../../theme/design_tokens.dart';
|
||||
/// don't support upload, so the button stays in place and resizes as the
|
||||
/// user switches tabs instead of the Scaffold popping it fully in/out -
|
||||
/// the label's width animates away rather than the FAB disappearing.
|
||||
///
|
||||
/// [barStyle] shapes the corners to match the bottom bar it sits above
|
||||
/// (Settings > Appearance). With [NooBottomBarStyle.floating] the FAB stays
|
||||
/// fully round (`NooRadii.pill`) - consistent with that mode's other
|
||||
/// elements, which are already rounder (the bar itself at 28px, its search
|
||||
/// satellite a full circle). With [NooBottomBarStyle.attached] the FAB
|
||||
/// squares off to `NooRadii.card`, a rounded square that fits attached's
|
||||
/// flatter, edge-to-edge aesthetic; this applies whether [collapsed] or not,
|
||||
/// so the FAB keeps one consistent identity per bar style.
|
||||
class NooFab extends StatelessWidget {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final VoidCallback? onTap;
|
||||
final bool collapsed;
|
||||
final NooBottomBarStyle barStyle;
|
||||
|
||||
const NooFab({
|
||||
super.key,
|
||||
@@ -22,17 +33,21 @@ class NooFab extends StatelessWidget {
|
||||
this.icon = LucideIcons.plus,
|
||||
this.onTap,
|
||||
this.collapsed = false,
|
||||
required this.barStyle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
final radius = BorderRadius.circular(
|
||||
barStyle == NooBottomBarStyle.attached ? NooRadii.card : NooRadii.pill,
|
||||
);
|
||||
return Material(
|
||||
color: colors.accent,
|
||||
borderRadius: BorderRadius.circular(NooRadii.pill),
|
||||
borderRadius: radius,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(NooRadii.pill),
|
||||
borderRadius: radius,
|
||||
child: AnimatedContainer(
|
||||
duration: NooMotion.base,
|
||||
curve: NooMotion.ease,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../../../theme/design_tokens.dart';
|
||||
|
||||
@@ -34,6 +35,13 @@ class NooSwipeActionSpec {
|
||||
/// springs shut - both on [NooMotion.base] / [NooMotion.ease], no bounce.
|
||||
/// Tapping the block fires the action and closes it; tapping the row while
|
||||
/// open just closes it.
|
||||
///
|
||||
/// Dragging further still - past [_kTriggerExtent] of the block's own
|
||||
/// width, the block growing to fill the extra reveal so there's no gap -
|
||||
/// arms the action (a [HapticFeedback.mediumImpact] marks the crossing);
|
||||
/// releasing while armed fires it immediately instead of just leaving it
|
||||
/// open, so a single swipe-through gesture can do the whole thing without a
|
||||
/// second tap.
|
||||
class NooSwipeAction extends StatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
@@ -59,12 +67,24 @@ class NooSwipeAction extends StatefulWidget {
|
||||
|
||||
class _NooSwipeActionState extends State<NooSwipeAction>
|
||||
with SingleTickerProviderStateMixin {
|
||||
/// How far past the fully-revealed block (1.0) a drag has to go before
|
||||
/// releasing fires the action outright rather than just snapping open -
|
||||
/// "swipe through" in one gesture instead of open-then-tap.
|
||||
static const double _kTriggerExtent = 1.8;
|
||||
|
||||
/// Hard ceiling on the controller's value - past [_kTriggerExtent] there's
|
||||
/// nothing more for further drag to *do*, but a little extra travel still
|
||||
/// gives the gesture room to keep moving under the finger instead of
|
||||
/// hitting a dead stop right at the trigger point.
|
||||
static const double _kMaxDrag = 2.4;
|
||||
|
||||
/// -1 = end action fully revealed, 0 = closed, 1 = start action revealed
|
||||
/// (in visual left/right terms after [_dir] is applied).
|
||||
/// (in visual left/right terms after [_dir] is applied) - and on past
|
||||
/// either bound up to [_kMaxDrag] while armed for [_kTriggerExtent].
|
||||
late final AnimationController _ctrl = AnimationController(
|
||||
vsync: this,
|
||||
lowerBound: -1,
|
||||
upperBound: 1,
|
||||
lowerBound: -_kMaxDrag,
|
||||
upperBound: _kMaxDrag,
|
||||
value: 0,
|
||||
);
|
||||
|
||||
@@ -85,13 +105,21 @@ class _NooSwipeActionState extends State<NooSwipeAction>
|
||||
void _onDragUpdate(DragUpdateDetails d) {
|
||||
// Positive = revealing the start action.
|
||||
final delta = d.primaryDelta! * _dir / NooSwipeAction.actionWidth;
|
||||
final min = widget.endAction != null ? -1.0 : 0.0;
|
||||
final max = widget.startAction != null ? 1.0 : 0.0;
|
||||
_ctrl.value = (_ctrl.value + delta).clamp(min, max);
|
||||
final min = widget.endAction != null ? -_kMaxDrag : 0.0;
|
||||
final max = widget.startAction != null ? _kMaxDrag : 0.0;
|
||||
final next = (_ctrl.value + delta).clamp(min, max);
|
||||
final wasArmed = _ctrl.value.abs() >= _kTriggerExtent;
|
||||
final nowArmed = next.abs() >= _kTriggerExtent;
|
||||
if (nowArmed && !wasArmed) HapticFeedback.mediumImpact();
|
||||
_ctrl.value = next;
|
||||
}
|
||||
|
||||
void _onDragEnd(DragEndDetails d) {
|
||||
final v = _ctrl.value;
|
||||
if (v.abs() >= _kTriggerExtent) {
|
||||
_trigger(v > 0 ? widget.startAction! : widget.endAction!);
|
||||
return;
|
||||
}
|
||||
final velocity = d.primaryVelocity! * _dir;
|
||||
double target;
|
||||
if (velocity.abs() > 700) {
|
||||
@@ -141,6 +169,13 @@ class _NooSwipeActionState extends State<NooSwipeAction>
|
||||
child: _ActionBlock(
|
||||
spec: showing,
|
||||
colors: colors,
|
||||
// Grows past its own min width to fill the extra
|
||||
// reveal once dragged further than a plain "open"
|
||||
// - otherwise the row's translated edge would pull
|
||||
// away from the block and expose bare space behind
|
||||
// it.
|
||||
width: v.abs() * NooSwipeAction.actionWidth,
|
||||
armed: v.abs() >= _kTriggerExtent,
|
||||
onTap: () => _trigger(showing),
|
||||
),
|
||||
),
|
||||
@@ -196,11 +231,24 @@ class _OpenAwareChild extends AnimatedWidget {
|
||||
class _ActionBlock extends StatelessWidget {
|
||||
final NooSwipeActionSpec spec;
|
||||
final NooColors colors;
|
||||
|
||||
/// The block's own width - grows past [NooSwipeAction.actionWidth] once
|
||||
/// dragged further than a plain reveal, so it always fills exactly what's
|
||||
/// exposed behind the row.
|
||||
final double width;
|
||||
|
||||
/// True once the drag has gone far enough that releasing now fires the
|
||||
/// action - bumps the icon up a touch as a "you're past the point of no
|
||||
/// return" cue, on top of the haptic tick that fired at the same moment.
|
||||
final bool armed;
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ActionBlock({
|
||||
required this.spec,
|
||||
required this.colors,
|
||||
required this.width,
|
||||
required this.armed,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@@ -216,13 +264,18 @@ class _ActionBlock extends StatelessWidget {
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: NooSwipeAction.actionWidth,
|
||||
width: width.clamp(NooSwipeAction.actionWidth, double.infinity),
|
||||
color: bg,
|
||||
alignment: Alignment.center,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 20, color: Colors.white),
|
||||
AnimatedScale(
|
||||
scale: armed ? 1.15 : 1,
|
||||
duration: NooMotion.fast,
|
||||
curve: NooMotion.ease,
|
||||
child: Icon(icon, size: 20, color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
label,
|
||||
|
||||
@@ -6,10 +6,10 @@ export 'noo_nav_style.dart';
|
||||
|
||||
/// Android indicator geometry, shared between the sliding pill and each
|
||||
/// item's own icon box so they line up exactly. Floating's own top offset
|
||||
/// is shorter than attached's - its row is 8px shorter overall (72 vs 80)
|
||||
/// is shorter than attached's - its row is 16px shorter overall (64 vs 80)
|
||||
/// and there's no edge-to-edge safe-area strip inside it eating into that.
|
||||
const double _kAttachedPillTop = 14;
|
||||
const double _kFloatingPillTop = 10;
|
||||
const double _kFloatingPillTop = 8;
|
||||
const double _kAndroidPillWidth = 56;
|
||||
const double _kAndroidPillHeight = 32;
|
||||
|
||||
@@ -29,15 +29,21 @@ const double _kAndroidPillHeight = 32;
|
||||
/// [barStyle] (user-configurable in Settings, Appearance) picks between
|
||||
/// that edge-to-edge [NooBottomBarStyle.attached] bar and
|
||||
/// [NooBottomBarStyle.floating] - inset 16px from both side edges, 28px
|
||||
/// corners, a 1px `line` border instead of a shadow (see that enum's own
|
||||
/// doc comment for why). Floating's Android row is 8px shorter (72 vs 80)
|
||||
/// and drops its idle tabs' reserved label space - with no label to leave
|
||||
/// room for, an idle icon just centers in the whole button and renders a
|
||||
/// touch bigger (27 vs 24px) instead of sitting high with a gap under it.
|
||||
/// The home indicator / gesture bar area below the row comes from the
|
||||
/// bottom safe-area inset rather than a fixed 34/20px spacer, so it's
|
||||
/// right on every device. Meant for `Scaffold.bottomNavigationBar` - the
|
||||
/// host `Scaffold` needs `extendBody: true` while floating.
|
||||
/// corners, a 1px `line` border plus [nooDialogShadow] (the one shadow the
|
||||
/// rest of the app allows itself, see that constant's own doc comment) so
|
||||
/// it actually reads as floating above the content scrolling behind it.
|
||||
/// Floating's Android row is both shorter (64 vs 80) and drops its idle
|
||||
/// tabs' reserved label space - with no label to leave room for, an idle
|
||||
/// icon just centers in the whole button, rather than sitting high with a
|
||||
/// gap under it. The home indicator / gesture bar area below the row comes
|
||||
/// from the bottom safe-area inset rather than a fixed 34/20px spacer, so
|
||||
/// it's right on every device. Meant for `Scaffold.bottomNavigationBar` -
|
||||
/// the host `Scaffold` needs `extendBody: true` while floating, and a
|
||||
/// scrollable body needs enough trailing padding to clear the bar's own
|
||||
/// footprint ([NooBottomBar.rowHeight] + [NooBottomBar.floatingBottomMargin]
|
||||
/// + the bottom safe area) since nothing does that automatically once the
|
||||
/// body draws behind it - see `tab_state_slivers.dart`'s
|
||||
/// `bottomBarClearance`.
|
||||
///
|
||||
/// [searchDestination]/[onSearchTap] (set together, from Settings'
|
||||
/// "Search in bottom bar" - see `DESIGN_SYSTEM.md`'s floating bottom bar
|
||||
@@ -65,6 +71,18 @@ class NooBottomBar extends StatelessWidget {
|
||||
this.onSearchTap,
|
||||
});
|
||||
|
||||
/// The row height [build] draws for [style]/[barStyle] - exposed so a
|
||||
/// scrollable body sharing the same `Scaffold` can reserve exactly this
|
||||
/// much clearance (see the class doc comment) instead of guessing.
|
||||
static double rowHeight(NooNavStyle style, NooBottomBarStyle barStyle) {
|
||||
if (style == NooNavStyle.ios) return 50;
|
||||
return barStyle == NooBottomBarStyle.floating ? 64 : 80;
|
||||
}
|
||||
|
||||
/// Gap between the floating bar's bottom edge and the safe area below it
|
||||
/// (itself inside the [SafeArea] that consumes the actual device inset).
|
||||
static const double floatingBottomMargin = 12;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
@@ -83,7 +101,7 @@ class NooBottomBar extends StatelessWidget {
|
||||
floating: floating,
|
||||
trailingSearch: rowSearch,
|
||||
);
|
||||
final barHeight = ios ? (floating ? 64.0 : 50.0) : (floating ? 72.0 : 80.0);
|
||||
final barHeight = NooBottomBar.rowHeight(style, barStyle);
|
||||
|
||||
if (floating) {
|
||||
final pill = Container(
|
||||
@@ -93,13 +111,19 @@ class NooBottomBar extends StatelessWidget {
|
||||
color: colors.surface,
|
||||
border: Border.all(color: colors.line),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
boxShadow: const [nooDialogShadow],
|
||||
),
|
||||
child: row,
|
||||
);
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
0,
|
||||
16,
|
||||
NooBottomBar.floatingBottomMargin,
|
||||
),
|
||||
child: hasSearch
|
||||
// A fixed-height SizedBox, not just a Row with
|
||||
// crossAxisAlignment.stretch - the bottomNavigationBar slot
|
||||
@@ -255,6 +279,7 @@ class _SearchSatellite extends StatelessWidget {
|
||||
color: colors.surface,
|
||||
border: Border.all(color: colors.line),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: const [nooDialogShadow],
|
||||
),
|
||||
child: Icon(destination.icon, size: 24, color: colors.fg1),
|
||||
),
|
||||
@@ -338,7 +363,7 @@ class _AndroidItem extends StatelessWidget {
|
||||
final Widget content;
|
||||
if (floating && !selected) {
|
||||
content = Center(
|
||||
child: Icon(destination.icon, size: 27, color: iconColor),
|
||||
child: Icon(destination.icon, size: 25, color: iconColor),
|
||||
);
|
||||
} else {
|
||||
content = Padding(
|
||||
|
||||
@@ -22,11 +22,14 @@ enum NooNavStyle {
|
||||
/// side/bottom margin. [floating] insets it 16px from both side edges and
|
||||
/// clear of the bottom safe area, rounded (28px - the same radius the
|
||||
/// sheet top/drawer edge use) rather than square, with a 1px `line` border
|
||||
/// standing in for elevation instead of a shadow (product UI stays flat -
|
||||
/// DESIGN_SYSTEM.md 1.4 - so this is the one place that border does that
|
||||
/// job). The host `Scaffold` needs `extendBody: true` while floating, so
|
||||
/// its body scrolls behind the bar's transparent margin instead of
|
||||
/// stopping short of it like [attached] does.
|
||||
/// plus `nooDialogShadow` - product UI otherwise stays flat (DESIGN_SYSTEM.md
|
||||
/// 1.4), but a bar that's genuinely floating above scrolling content reads
|
||||
/// as attached without some elevation, so it borrows the one shadow the
|
||||
/// rest of the app allows itself rather than inventing a second one. The
|
||||
/// host `Scaffold` needs `extendBody: true` while floating, so its body
|
||||
/// scrolls behind the bar's transparent margin instead of stopping short of
|
||||
/// it like [attached] does - which also means that body needs its own
|
||||
/// trailing padding to clear the bar (see `NooBottomBar.rowHeight`).
|
||||
enum NooBottomBarStyle { attached, floating }
|
||||
|
||||
/// One destination in a [NooBottomBar]. Data-agnostic on purpose - the app
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../providers/settings_controller.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../noo/core/noo_button.dart';
|
||||
import '../noo/nav/noo_bottom_bar.dart';
|
||||
import '../noo/noo_layout.dart';
|
||||
|
||||
/// Shared loading/error/empty slivers for Recent/Activity/Trash/Shares (and
|
||||
/// any future tab shaped like them: a `CustomScrollView` over one flat
|
||||
@@ -88,9 +92,29 @@ Widget tabEmptySliver(
|
||||
);
|
||||
}
|
||||
|
||||
/// How much bottom padding a tab's scrollable list needs to clear
|
||||
/// [NooBottomBar] and not just butt up against it. [NooBottomBarStyle.
|
||||
/// attached] bars aren't drawn behind (no `Scaffold.extendBody`), so
|
||||
/// Scaffold already shrinks the body above them - this is then pure
|
||||
/// breathing room, not overlap prevention. [NooBottomBarStyle.floating]
|
||||
/// bars draw over an extended body instead, so nothing reserves space for
|
||||
/// them automatically: the clearance has to cover the bar's own footprint
|
||||
/// (see [NooBottomBar.rowHeight]/[NooBottomBar.floatingBottomMargin]) plus
|
||||
/// the safe-area inset below it, or the last row ends up hidden under it.
|
||||
double bottomBarClearance(BuildContext context) {
|
||||
final settings = context.watch<SettingsController>();
|
||||
if (settings.bottomBarStyle == NooBottomBarStyle.attached) return 100;
|
||||
final barHeight = NooBottomBar.rowHeight(
|
||||
NooLayout.navStyle(context),
|
||||
NooBottomBarStyle.floating,
|
||||
);
|
||||
final safeBottom = MediaQuery.paddingOf(context).bottom;
|
||||
return barHeight + NooBottomBar.floatingBottomMargin + safeBottom + 24;
|
||||
}
|
||||
|
||||
/// Bottom padding sliver so the last row/card clears the bottom nav/FAB -
|
||||
/// same trailing pair every rebuilt tab list ends on.
|
||||
const List<Widget> tabBottomInsetSlivers = [
|
||||
SliverToBoxAdapter(child: SizedBox(height: 100)),
|
||||
SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||
List<Widget> tabBottomInsetSlivers(BuildContext context) => [
|
||||
SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))),
|
||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user