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
+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()),
];