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:
2026-09-30 16:30:14 -04:00
co-authored by Claude Sonnet 5
parent 5f4caa2a3b
commit 5e5acc3fc6
18 changed files with 361 additions and 55 deletions
+1
View File
@@ -502,6 +502,7 @@ class _MainShellViewState extends State<MainShellView> {
pickRequest == null && navStyle == NooNavStyle.android
? NooFab(
collapsed: !canUpload,
barStyle: bottomBarStyle,
onTap: () => showCreateMenu(context),
)
: null,
+3 -2
View File
@@ -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,
+1 -1
View File
@@ -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(
+2 -1
View File
@@ -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(
+11
View File
@@ -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(
+5 -3
View File
@@ -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(
+1 -1
View File
@@ -74,7 +74,7 @@ class _RecentViewState extends State<RecentView> {
_buildDesktopTable(context, groups)
else
_buildMobileGroups(context, groups),
...tabBottomInsetSlivers,
...tabBottomInsetSlivers(context),
];
return ColoredBox(
+1 -1
View File
@@ -120,7 +120,7 @@ class _SharesViewState extends State<SharesView> {
_buildDesktopTable(context, shares)
else
_buildMobileList(context, shares),
...tabBottomInsetSlivers,
...tabBottomInsetSlivers(context),
];
return ColoredBox(
+1 -1
View File
@@ -89,7 +89,7 @@ class _TrashViewState extends State<TrashView> {
_buildDesktopTable(context, trash)
else
_buildMobileList(context, trash),
...tabBottomInsetSlivers,
...tabBottomInsetSlivers(context),
];
return ColoredBox(
+17 -2
View File
@@ -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,
+61 -8
View File
@@ -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,
+39 -14
View File
@@ -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(
+8 -5
View File
@@ -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
+27 -3
View File
@@ -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()),
];