Noo design system rebuild #1

Merged
ayushya merged 31 commits from noo-design-system-rebuild into main 2026-10-01 09:23:58 -04:00
18 changed files with 361 additions and 55 deletions
Showing only changes of commit 5e5acc3fc6 - Show all commits
+15 -6
View File
@@ -109,10 +109,18 @@ Use **Lucide** (pinned to `lucide-static@0.460.0`) at a 1.8 stroke. Sizes are 14
- An extended pill: 56px tall, accent fill, labelled "Upload". - An extended pill: 56px tall, accent fill, labelled "Upload".
- Sits 16px from the right and bottom edges of the content area. - Sits 16px from the right and bottom edges of the content area.
- Stays mounted on every tab; it's only the extended label that's tied to - Stays mounted on every tab; it's only the extended label that's tied to
Files/Photos. Elsewhere it collapses to an icon-only 56px circle (still Files/Photos. Elsewhere it collapses to an icon-only 56px circle or
tappable - it always targets the Files tab's current folder), animating rounded square (see below) - still tappable, it always targets the Files
the width/label change rather than the button popping fully in/out as tab's current folder - animating the width/label change rather than the
the Scaffold's default FAB transition would on every tab switch. button popping fully in/out as the Scaffold's default FAB transition
would on every tab switch.
- Corner radius follows the bottom bar style (Settings → Appearance):
fully round (`pill`, 999) when the bar is floating, matching that mode's
other already-rounder elements (the bar itself at 28px, its search
satellite a full circle); a rounded square (`card`, 20) when the bar is
attached, to suit that mode's flatter, edge-to-edge corners. Same radius
whether the FAB is collapsed to a circle/square or expanded to the full
pill shape.
**Chip** **Chip**
- A pill, 32–34px tall, 12px horizontal padding, Label 13–14. - A pill, 32–34px tall, 12px horizontal padding, Label 13–14.
@@ -162,6 +170,7 @@ Use **Lucide** (pinned to `lucide-static@0.460.0`) at a 1.8 stroke. Sizes are 14
**Swipe action** **Swipe action**
- The row slides and uncovers a 96px action block. Delete is white on danger-fill; Favorite is white on accent. - The row slides and uncovers a 96px action block. Delete is white on danger-fill; Favorite is white on accent.
- The block has a 20px icon above a 12/600 label. - The block has a 20px icon above a 12/600 label.
- Releasing past half the block snaps it open; dragging further still (past 1.8× the block's width, a haptic tick marks the crossing and the icon bumps up 15%) arms the action, and releasing while armed fires it immediately - one swipe-through gesture instead of open-then-tap. The block grows to fill however far it's dragged, so there's never a gap behind it.
- Swipe actions are set in Settings → Swipe on a file. - Swipe actions are set in Settings → Swipe on a file.
**Selection action bar** (Files, Photos, Favorites - anywhere with **Selection action bar** (Files, Photos, Favorites - anywhere with
@@ -273,9 +282,9 @@ Mobile always has **5 pinned tabs in the bottom bar**. The rest go in the drawer
| Status bar | 54px, Dynamic Island | 40px | | Status bar | 54px, Dynamic Island | 40px |
| Top bar | 44px row: `menu` on the left; `plus` and avatar on the right (all accent-text). Then a 34px large title, then the search field. | 64px: `menu`, 22px title, `search`, avatar | | Top bar | 44px row: `menu` on the left; `plus` and avatar on the right (all accent-text). Then a 34px large title, then the search field. | 64px: `menu`, 22px title, `search`, avatar |
| Bottom bar | Surface fill, 1px top line. Icon 24 above a 10px label. Active: accent-text. Idle: fg-3. 34px home indicator below. | Surface fill, 80px. Icon 24 above a 12px label (label space is always reserved, just invisible when idle, so nothing shifts on selection). The active icon sits inside a 56×32 accent-soft pill that slides between tabs as one shared indicator, rather than popping in/out per tab. 20px gesture bar below. | | Bottom bar | Surface fill, 1px top line. Icon 24 above a 10px label. Active: accent-text. Idle: fg-3. 34px home indicator below. | Surface fill, 80px. Icon 24 above a 12px label (label space is always reserved, just invisible when idle, so nothing shifts on selection). The active icon sits inside a 56×32 accent-soft pill that slides between tabs as one shared indicator, rather than popping in/out per tab. 20px gesture bar below. |
| Bottom bar - floating (Settings → Appearance → "Bottom bar", both platforms) | Same content, inset 16px from both side edges and clear of the bottom safe area instead of edge to edge, radius 28 (the sheet-top/drawer-edge radius) rather than square corners. No shadow (product UI stays flat - see 1.4); a 1px `line` border stands in for elevation instead. Row height drops slightly (64/72 vs 50/80) to suit. Android's idle tabs drop their reserved label space in this mode - the icon just centers in the button and renders a touch bigger (27 vs 24px) - rather than sitting high with a gap held open under it. `Scaffold.extendBody: true` while floating, so the body scrolls behind the bar's transparent margin instead of stopping short of it. | | Bottom bar - floating (Settings → Appearance → "Bottom bar", both platforms) | Same content, inset 16px from both side edges and clear of the bottom safe area instead of edge to edge, radius 28 (the sheet-top/drawer-edge radius) rather than square corners. Product UI otherwise stays flat (see 1.4), but this is the one other place - besides desktop dialogs - that gets the app's one shadow: a 1px `line` border plus the dialog shadow, since a bar that's genuinely floating above scrolling content needs to read as elevated. Row height drops (iOS 50, same as attached; Android 64 vs 80) to suit. Android's idle tabs drop their reserved label space in this mode - the icon just centers in the button and renders a touch bigger (25 vs 24px) - rather than sitting high with a gap held open under it. `Scaffold.extendBody: true` while floating, so the body scrolls behind the bar's transparent margin instead of stopping short of it - which means every tab's scrollable list has to pad its own bottom enough to clear the bar's footprint, since nothing does that for it automatically once the body draws behind the bar. The Android FAB stays fully round in this mode (see "Upload" below), consistent with the bar's own rounder shape. |
| Bottom bar - "Search in bottom bar" (Settings → Appearance, either bar style) | Adds a Search entry that's never highlighted (tapping it pushes Search, same as the top bar's own search action, which this replaces so there's only one entry point on screen) - the row's last item when attached (same styling as a real tab, just permanently idle), or its own separate, always fully round satellite circle beside the bar when floating. Costs one regular tab: `SettingsController.maxVisibleTabs` drops from 5 to 4 while this is on. | | Bottom bar - "Search in bottom bar" (Settings → Appearance, either bar style) | Adds a Search entry that's never highlighted (tapping it pushes Search, same as the top bar's own search action, which this replaces so there's only one entry point on screen) - the row's last item when attached (same styling as a real tab, just permanently idle), or its own separate, always fully round satellite circle beside the bar when floating. Costs one regular tab: `SettingsController.maxVisibleTabs` drops from 5 to 4 while this is on. |
| Upload | `plus` in the nav bar | Extended FAB | | Upload | `plus` in the nav bar | Extended FAB - fully round (pill) when the bottom bar is floating, rounded square (`card` radius) when it's attached |
| Overflow icon | `ellipsis` | `ellipsis-vertical` | | Overflow icon | `ellipsis` | `ellipsis-vertical` |
| Settings | Pushed screen with a "‹ Files" back button and a large title. No bottom bar. | Pushed screen with a back arrow and a title. No bottom bar. | | Settings | Pushed screen with a "‹ Files" back button and a large title. No bottom bar. | Pushed screen with a back arrow and a title. No bottom bar. |
| Biometric label | "Lock with Face ID" | "Lock with fingerprint" | | Biometric label | "Lock with Face ID" | "Lock with fingerprint" |
+13 -5
View File
@@ -92,8 +92,10 @@ Gotchas:
do). Its `collapsible`/`initiallyExpanded` params (off by default) make do). Its `collapsible`/`initiallyExpanded` params (off by default) make
`label` a tap target that shows/hides the card - `SettingsSection` is the `label` a tap target that shows/hides the card - `SettingsSection` is the
only caller that opts in, for Settings' mobile sections. only caller that opts in, for Settings' mobile sections.
- `NooSwipeAction` only reveals its action. The user has to tap the block to - `NooSwipeAction` reveals its action on a normal swipe (tap the block to
trigger it; a full swipe never deletes. trigger it) but also fires it directly if the drag goes far enough (past
1.8x the block's width) before release - a full swipe-through does delete
in one gesture, it's not tap-only anymore.
- Window chrome (macOS traffic lights, the Windows 40px title bar) isn't - Window chrome (macOS traffic lights, the Windows 40px title bar) isn't
built yet. `NooSidebar.windowControls` is the slot for it. built yet. `NooSidebar.windowControls` is the slot for it.
- Always read colors through `context.nooColors`, never - Always read colors through `context.nooColors`, never
@@ -193,10 +195,16 @@ blocks are noted where they matter:
status is `NooFileRow`/`NooFileTableRow`'s built-in `NooStatusIcon` list status is `NooFileRow`/`NooFileTableRow`'s built-in `NooStatusIcon` list
instead of a corner badge. `NooBottomBar` later gained its own, unrelated instead of a corner badge. `NooBottomBar` later gained its own, unrelated
`NooBottomBarStyle.floating` (Settings → Appearance → "Bottom bar") - `NooBottomBarStyle.floating` (Settings → Appearance → "Bottom bar") -
don't confuse the two: this one is still flat/non-blurred, just inset don't confuse the two: this one is still non-blurred, just inset with a
with a `line` border instead of edge to edge (no opacity/blur knob `line` border and `nooDialogShadow` (the app's one other shadow user - see
that constant's doc comment) instead of edge to edge (no opacity/blur knob
either). The host `Scaffold` needs `extendBody: true` while it's active either). The host `Scaffold` needs `extendBody: true` while it's active
(`main.dart` already wires this off `SettingsController.bottomBarStyle`). (`main.dart` already wires this off `SettingsController.bottomBarStyle`),
which also means every tab's own scrollable list has to reserve enough
bottom padding to clear the bar - nothing does that automatically once the
body draws behind it. Use `bottomBarClearance(context)`
(`tab_state_slivers.dart`) for that rather than a flat `100`; it already
accounts for both bar styles.
Settings' separate "Search in bottom bar" toggle Settings' separate "Search in bottom bar" toggle
(`SettingsController.searchInBottomBar`) adds a never-highlighted Search (`SettingsController.searchInBottomBar`) adds a never-highlighted Search
entry to either bar style (`NooBottomBar`'s `searchDestination`/ entry to either bar style (`NooBottomBar`'s `searchDestination`/
+1
View File
@@ -502,6 +502,7 @@ class _MainShellViewState extends State<MainShellView> {
pickRequest == null && navStyle == NooNavStyle.android pickRequest == null && navStyle == NooNavStyle.android
? NooFab( ? NooFab(
collapsed: !canUpload, collapsed: !canUpload,
barStyle: bottomBarStyle,
onTap: () => showCreateMenu(context), onTap: () => showCreateMenu(context),
) )
: null, : null,
+3 -2
View File
@@ -356,8 +356,9 @@ class NooMotion {
} }
/// The one shadow the design system allows, from `tokens/spacing.css`'s /// The one shadow the design system allows, from `tokens/spacing.css`'s
/// `--shadow-dialog` - desktop dialogs only. Nothing else in the app should /// `--shadow-dialog` - desktop dialogs, and [NooBottomBarStyle.floating]'s
/// use a `BoxShadow`. /// 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( const nooDialogShadow = BoxShadow(
color: Color(0x241E002F), // rgba(30,0,47,.14) color: Color(0x241E002F), // rgba(30,0,47,.14)
blurRadius: 48, blurRadius: 48,
+1 -1
View File
@@ -92,7 +92,7 @@ class ActivityView extends StatelessWidget {
final List<Widget> contentSlivers = [ final List<Widget> contentSlivers = [
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)), const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)),
feed, feed,
...tabBottomInsetSlivers, ...tabBottomInsetSlivers(context),
]; ];
final scrollView = CustomScrollView( 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/noo/core/noo_button.dart';
import '../widgets/share_sheet.dart'; import '../widgets/share_sheet.dart';
import '../widgets/sticky_header_delegate.dart'; import '../widgets/sticky_header_delegate.dart';
import '../widgets/tabs/tab_state_slivers.dart';
import '../widgets/synced_header_scaffold.dart' show formatBytes; import '../widgets/synced_header_scaffold.dart' show formatBytes;
import 'file_viewer_screen.dart'; import 'file_viewer_screen.dart';
import 'move_copy_destination_picker.dart'; import 'move_copy_destination_picker.dart';
@@ -638,7 +639,7 @@ class _FavoritesViewState extends State<FavoritesView> {
}, childCount: favorites.length), }, childCount: favorites.length),
), ),
), ),
const SliverToBoxAdapter(child: SizedBox(height: 100)), SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))),
]; ];
return PopScope( 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_settings_row.dart';
import '../widgets/noo/lists/noo_summary_card.dart'; import '../widgets/noo/lists/noo_summary_card.dart';
import '../widgets/noo/media/noo_grid_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/noo_layout.dart';
import '../widgets/noo/overlays/noo_sheet.dart'; import '../widgets/noo/overlays/noo_sheet.dart';
import '../widgets/share_sheet.dart'; import '../widgets/share_sheet.dart';
import '../widgets/sticky_header_delegate.dart'; import '../widgets/sticky_header_delegate.dart';
import '../widgets/synced_header_scaffold.dart' show formatBytes; import '../widgets/synced_header_scaffold.dart' show formatBytes;
import '../widgets/tabs/tab_state_slivers.dart';
import 'file_viewer_screen.dart'; import 'file_viewer_screen.dart';
import 'move_copy_destination_picker.dart'; import 'move_copy_destination_picker.dart';
@@ -815,6 +817,15 @@ class _FilesViewState extends State<FilesView> {
}, childCount: browser.items.length), }, 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( return PopScope(
+5 -3
View File
@@ -30,6 +30,7 @@ import '../widgets/noo/overlays/noo_sheet.dart';
import '../widgets/share_sheet.dart'; import '../widgets/share_sheet.dart';
import '../widgets/sort_menu_button.dart' show sortFieldLabel; import '../widgets/sort_menu_button.dart' show sortFieldLabel;
import '../widgets/sticky_header_delegate.dart'; import '../widgets/sticky_header_delegate.dart';
import '../widgets/tabs/tab_state_slivers.dart';
import 'file_viewer_screen.dart'; import 'file_viewer_screen.dart';
import 'move_copy_destination_picker.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 // Clearance so the last row isn't hidden behind the nav bar,
// nav bar, regardless of grid length. // regardless of grid length - see `bottomBarClearance`'s own doc
const SliverToBoxAdapter(child: SizedBox(height: 100)), // comment for why this has to be dynamic rather than a flat 100.
SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))),
]; ];
return PopScope( return PopScope(
+1 -1
View File
@@ -74,7 +74,7 @@ class _RecentViewState extends State<RecentView> {
_buildDesktopTable(context, groups) _buildDesktopTable(context, groups)
else else
_buildMobileGroups(context, groups), _buildMobileGroups(context, groups),
...tabBottomInsetSlivers, ...tabBottomInsetSlivers(context),
]; ];
return ColoredBox( return ColoredBox(
+1 -1
View File
@@ -120,7 +120,7 @@ class _SharesViewState extends State<SharesView> {
_buildDesktopTable(context, shares) _buildDesktopTable(context, shares)
else else
_buildMobileList(context, shares), _buildMobileList(context, shares),
...tabBottomInsetSlivers, ...tabBottomInsetSlivers(context),
]; ];
return ColoredBox( return ColoredBox(
+1 -1
View File
@@ -89,7 +89,7 @@ class _TrashViewState extends State<TrashView> {
_buildDesktopTable(context, trash) _buildDesktopTable(context, trash)
else else
_buildMobileList(context, trash), _buildMobileList(context, trash),
...tabBottomInsetSlivers, ...tabBottomInsetSlivers(context),
]; ];
return ColoredBox( return ColoredBox(
+17 -2
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart'; import '../../../theme/design_tokens.dart';
import '../nav/noo_nav_style.dart';
/// Android-only extended Upload pill, bottom-right of Files and Photos (Noo /// Android-only extended Upload pill, bottom-right of Files and Photos (Noo
/// Design System project, `components/core/FAB.jsx`). 56px tall, accent /// 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 /// 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 - /// user switches tabs instead of the Scaffold popping it fully in/out -
/// the label's width animates away rather than the FAB disappearing. /// 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 { class NooFab extends StatelessWidget {
final String label; final String label;
final IconData icon; final IconData icon;
final VoidCallback? onTap; final VoidCallback? onTap;
final bool collapsed; final bool collapsed;
final NooBottomBarStyle barStyle;
const NooFab({ const NooFab({
super.key, super.key,
@@ -22,17 +33,21 @@ class NooFab extends StatelessWidget {
this.icon = LucideIcons.plus, this.icon = LucideIcons.plus,
this.onTap, this.onTap,
this.collapsed = false, this.collapsed = false,
required this.barStyle,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.nooColors; final colors = context.nooColors;
final radius = BorderRadius.circular(
barStyle == NooBottomBarStyle.attached ? NooRadii.card : NooRadii.pill,
);
return Material( return Material(
color: colors.accent, color: colors.accent,
borderRadius: BorderRadius.circular(NooRadii.pill), borderRadius: radius,
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(NooRadii.pill), borderRadius: radius,
child: AnimatedContainer( child: AnimatedContainer(
duration: NooMotion.base, duration: NooMotion.base,
curve: NooMotion.ease, curve: NooMotion.ease,
+61 -8
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart'; import '../../../theme/design_tokens.dart';
@@ -34,6 +35,13 @@ class NooSwipeActionSpec {
/// springs shut - both on [NooMotion.base] / [NooMotion.ease], no bounce. /// springs shut - both on [NooMotion.base] / [NooMotion.ease], no bounce.
/// Tapping the block fires the action and closes it; tapping the row while /// Tapping the block fires the action and closes it; tapping the row while
/// open just closes it. /// 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 { class NooSwipeAction extends StatefulWidget {
final Widget child; final Widget child;
@@ -59,12 +67,24 @@ class NooSwipeAction extends StatefulWidget {
class _NooSwipeActionState extends State<NooSwipeAction> class _NooSwipeActionState extends State<NooSwipeAction>
with SingleTickerProviderStateMixin { 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 /// -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( late final AnimationController _ctrl = AnimationController(
vsync: this, vsync: this,
lowerBound: -1, lowerBound: -_kMaxDrag,
upperBound: 1, upperBound: _kMaxDrag,
value: 0, value: 0,
); );
@@ -85,13 +105,21 @@ class _NooSwipeActionState extends State<NooSwipeAction>
void _onDragUpdate(DragUpdateDetails d) { void _onDragUpdate(DragUpdateDetails d) {
// Positive = revealing the start action. // Positive = revealing the start action.
final delta = d.primaryDelta! * _dir / NooSwipeAction.actionWidth; final delta = d.primaryDelta! * _dir / NooSwipeAction.actionWidth;
final min = widget.endAction != null ? -1.0 : 0.0; final min = widget.endAction != null ? -_kMaxDrag : 0.0;
final max = widget.startAction != null ? 1.0 : 0.0; final max = widget.startAction != null ? _kMaxDrag : 0.0;
_ctrl.value = (_ctrl.value + delta).clamp(min, max); 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) { void _onDragEnd(DragEndDetails d) {
final v = _ctrl.value; final v = _ctrl.value;
if (v.abs() >= _kTriggerExtent) {
_trigger(v > 0 ? widget.startAction! : widget.endAction!);
return;
}
final velocity = d.primaryVelocity! * _dir; final velocity = d.primaryVelocity! * _dir;
double target; double target;
if (velocity.abs() > 700) { if (velocity.abs() > 700) {
@@ -141,6 +169,13 @@ class _NooSwipeActionState extends State<NooSwipeAction>
child: _ActionBlock( child: _ActionBlock(
spec: showing, spec: showing,
colors: colors, 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), onTap: () => _trigger(showing),
), ),
), ),
@@ -196,11 +231,24 @@ class _OpenAwareChild extends AnimatedWidget {
class _ActionBlock extends StatelessWidget { class _ActionBlock extends StatelessWidget {
final NooSwipeActionSpec spec; final NooSwipeActionSpec spec;
final NooColors colors; 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; final VoidCallback onTap;
const _ActionBlock({ const _ActionBlock({
required this.spec, required this.spec,
required this.colors, required this.colors,
required this.width,
required this.armed,
required this.onTap, required this.onTap,
}); });
@@ -216,13 +264,18 @@ class _ActionBlock extends StatelessWidget {
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: onTap, onTap: onTap,
child: Container( child: Container(
width: NooSwipeAction.actionWidth, width: width.clamp(NooSwipeAction.actionWidth, double.infinity),
color: bg, color: bg,
alignment: Alignment.center, alignment: Alignment.center,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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), const SizedBox(height: 6),
Text( Text(
label, label,
+39 -14
View File
@@ -6,10 +6,10 @@ export 'noo_nav_style.dart';
/// Android indicator geometry, shared between the sliding pill and each /// 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 /// 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. /// and there's no edge-to-edge safe-area strip inside it eating into that.
const double _kAttachedPillTop = 14; const double _kAttachedPillTop = 14;
const double _kFloatingPillTop = 10; const double _kFloatingPillTop = 8;
const double _kAndroidPillWidth = 56; const double _kAndroidPillWidth = 56;
const double _kAndroidPillHeight = 32; const double _kAndroidPillHeight = 32;
@@ -29,15 +29,21 @@ const double _kAndroidPillHeight = 32;
/// [barStyle] (user-configurable in Settings, Appearance) picks between /// [barStyle] (user-configurable in Settings, Appearance) picks between
/// that edge-to-edge [NooBottomBarStyle.attached] bar and /// that edge-to-edge [NooBottomBarStyle.attached] bar and
/// [NooBottomBarStyle.floating] - inset 16px from both side edges, 28px /// [NooBottomBarStyle.floating] - inset 16px from both side edges, 28px
/// corners, a 1px `line` border instead of a shadow (see that enum's own /// corners, a 1px `line` border plus [nooDialogShadow] (the one shadow the
/// doc comment for why). Floating's Android row is 8px shorter (72 vs 80) /// rest of the app allows itself, see that constant's own doc comment) so
/// and drops its idle tabs' reserved label space - with no label to leave /// it actually reads as floating above the content scrolling behind it.
/// room for, an idle icon just centers in the whole button and renders a /// Floating's Android row is both shorter (64 vs 80) and drops its idle
/// touch bigger (27 vs 24px) instead of sitting high with a gap under it. /// tabs' reserved label space - with no label to leave room for, an idle
/// The home indicator / gesture bar area below the row comes from the /// icon just centers in the whole button, rather than sitting high with a
/// bottom safe-area inset rather than a fixed 34/20px spacer, so it's /// gap under it. The home indicator / gesture bar area below the row comes
/// right on every device. Meant for `Scaffold.bottomNavigationBar` - the /// from the bottom safe-area inset rather than a fixed 34/20px spacer, so
/// host `Scaffold` needs `extendBody: true` while floating. /// 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' /// [searchDestination]/[onSearchTap] (set together, from Settings'
/// "Search in bottom bar" - see `DESIGN_SYSTEM.md`'s floating bottom bar /// "Search in bottom bar" - see `DESIGN_SYSTEM.md`'s floating bottom bar
@@ -65,6 +71,18 @@ class NooBottomBar extends StatelessWidget {
this.onSearchTap, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.nooColors; final colors = context.nooColors;
@@ -83,7 +101,7 @@ class NooBottomBar extends StatelessWidget {
floating: floating, floating: floating,
trailingSearch: rowSearch, trailingSearch: rowSearch,
); );
final barHeight = ios ? (floating ? 64.0 : 50.0) : (floating ? 72.0 : 80.0); final barHeight = NooBottomBar.rowHeight(style, barStyle);
if (floating) { if (floating) {
final pill = Container( final pill = Container(
@@ -93,13 +111,19 @@ class NooBottomBar extends StatelessWidget {
color: colors.surface, color: colors.surface,
border: Border.all(color: colors.line), border: Border.all(color: colors.line),
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(28),
boxShadow: const [nooDialogShadow],
), ),
child: row, child: row,
); );
return SafeArea( return SafeArea(
top: false, top: false,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), padding: EdgeInsets.fromLTRB(
16,
0,
16,
NooBottomBar.floatingBottomMargin,
),
child: hasSearch child: hasSearch
// A fixed-height SizedBox, not just a Row with // A fixed-height SizedBox, not just a Row with
// crossAxisAlignment.stretch - the bottomNavigationBar slot // crossAxisAlignment.stretch - the bottomNavigationBar slot
@@ -255,6 +279,7 @@ class _SearchSatellite extends StatelessWidget {
color: colors.surface, color: colors.surface,
border: Border.all(color: colors.line), border: Border.all(color: colors.line),
shape: BoxShape.circle, shape: BoxShape.circle,
boxShadow: const [nooDialogShadow],
), ),
child: Icon(destination.icon, size: 24, color: colors.fg1), child: Icon(destination.icon, size: 24, color: colors.fg1),
), ),
@@ -338,7 +363,7 @@ class _AndroidItem extends StatelessWidget {
final Widget content; final Widget content;
if (floating && !selected) { if (floating && !selected) {
content = Center( content = Center(
child: Icon(destination.icon, size: 27, color: iconColor), child: Icon(destination.icon, size: 25, color: iconColor),
); );
} else { } else {
content = Padding( content = Padding(
+8 -5
View File
@@ -22,11 +22,14 @@ enum NooNavStyle {
/// side/bottom margin. [floating] insets it 16px from both side edges and /// side/bottom margin. [floating] insets it 16px from both side edges and
/// clear of the bottom safe area, rounded (28px - the same radius the /// 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 /// 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 - /// plus `nooDialogShadow` - product UI otherwise stays flat (DESIGN_SYSTEM.md
/// DESIGN_SYSTEM.md 1.4 - so this is the one place that border does that /// 1.4), but a bar that's genuinely floating above scrolling content reads
/// job). The host `Scaffold` needs `extendBody: true` while floating, so /// as attached without some elevation, so it borrows the one shadow the
/// its body scrolls behind the bar's transparent margin instead of /// rest of the app allows itself rather than inventing a second one. The
/// stopping short of it like [attached] does. /// 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 } enum NooBottomBarStyle { attached, floating }
/// One destination in a [NooBottomBar]. Data-agnostic on purpose - the app /// 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:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.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 '../../theme/design_tokens.dart';
import '../noo/core/noo_button.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 /// Shared loading/error/empty slivers for Recent/Activity/Trash/Shares (and
/// any future tab shaped like them: a `CustomScrollView` over one flat /// 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 - /// Bottom padding sliver so the last row/card clears the bottom nav/FAB -
/// same trailing pair every rebuilt tab list ends on. /// same trailing pair every rebuilt tab list ends on.
const List<Widget> tabBottomInsetSlivers = [ List<Widget> tabBottomInsetSlivers(BuildContext context) => [
SliverToBoxAdapter(child: SizedBox(height: 100)), SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))),
SliverFillRemaining(hasScrollBody: false, child: SizedBox()), const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
]; ];
+49 -2
View File
@@ -12,6 +12,7 @@ import 'package:noo/widgets/noo/core/noo_progress_bar.dart';
import 'package:noo/widgets/noo/core/noo_search_field.dart'; import 'package:noo/widgets/noo/core/noo_search_field.dart';
import 'package:noo/widgets/noo/core/noo_segmented_control.dart'; import 'package:noo/widgets/noo/core/noo_segmented_control.dart';
import 'package:noo/widgets/noo/core/noo_toggle.dart'; import 'package:noo/widgets/noo/core/noo_toggle.dart';
import 'package:noo/widgets/noo/nav/noo_nav_style.dart';
import 'noo_test_utils.dart'; import 'noo_test_utils.dart';
@@ -247,7 +248,11 @@ void main() {
group('NooFab', () { group('NooFab', () {
testNooWidgets('shows label and fires onTap', (tester, theme, c) async { testNooWidgets('shows label and fires onTap', (tester, theme, c) async {
var taps = 0; var taps = 0;
await pumpNoo(tester, NooFab(onTap: () => taps++), theme: theme); await pumpNoo(
tester,
NooFab(barStyle: NooBottomBarStyle.floating, onTap: () => taps++),
theme: theme,
);
expect(find.text('Upload'), findsOneWidget); expect(find.text('Upload'), findsOneWidget);
expect(tester.getSize(find.byType(NooFab)).height, 56); expect(tester.getSize(find.byType(NooFab)).height, 56);
expect(tester.getSize(find.byType(NooFab)).width, lessThan(200)); expect(tester.getSize(find.byType(NooFab)).width, lessThan(200));
@@ -263,7 +268,11 @@ void main() {
var taps = 0; var taps = 0;
await pumpNoo( await pumpNoo(
tester, tester,
NooFab(collapsed: true, onTap: () => taps++), NooFab(
collapsed: true,
barStyle: NooBottomBarStyle.floating,
onTap: () => taps++,
),
theme: theme, theme: theme,
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -274,6 +283,44 @@ void main() {
await tester.tap(find.byType(NooFab)); await tester.tap(find.byType(NooFab));
expect(taps, 1); expect(taps, 1);
}); });
testNooWidgets('floating bar style stays fully round', (
tester,
theme,
c,
) async {
await pumpNoo(
tester,
const NooFab(barStyle: NooBottomBarStyle.floating),
theme: theme,
);
final material = tester.widget<Material>(
find.descendant(
of: find.byType(NooFab),
matching: find.byType(Material),
),
);
expect((material.borderRadius as BorderRadius).topLeft.x, NooRadii.pill);
});
testNooWidgets('attached bar style squares off to a rounded square', (
tester,
theme,
c,
) async {
await pumpNoo(
tester,
const NooFab(barStyle: NooBottomBarStyle.attached),
theme: theme,
);
final material = tester.widget<Material>(
find.descendant(
of: find.byType(NooFab),
matching: find.byType(Material),
),
);
expect((material.borderRadius as BorderRadius).topLeft.x, NooRadii.card);
});
}); });
group('NooProgressBar', () { group('NooProgressBar', () {
+106
View File
@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:noo/widgets/noo/files/noo_swipe_action.dart';
import 'noo_test_utils.dart';
void main() {
setUpNooTests();
group('NooSwipeAction', () {
testNooWidgets('a moderate swipe opens the action without triggering it', (
tester,
theme,
c,
) async {
var triggered = 0;
await pumpNoo(
tester,
SizedBox(
width: 360,
height: 64,
child: NooSwipeAction(
endAction: NooSwipeActionSpec(
kind: NooSwipeActionKind.delete,
onTriggered: () => triggered++,
),
child: const ColoredBox(
color: Colors.white,
child: SizedBox(width: 360, height: 64),
),
),
),
theme: theme,
);
// Half the block's own width (96) is well past the "snap open"
// threshold but nowhere near the 1.8x trigger extent.
await tester.drag(find.byType(NooSwipeAction), const Offset(-60, 0));
await tester.pumpAndSettle();
expect(triggered, 0);
expect(find.text('Delete'), findsOneWidget);
});
testNooWidgets(
'swiping past the trigger extent fires the action on release - no '
'second tap needed',
(tester, theme, c) async {
var triggered = 0;
await pumpNoo(
tester,
SizedBox(
width: 360,
height: 64,
child: NooSwipeAction(
endAction: NooSwipeActionSpec(
kind: NooSwipeActionKind.delete,
onTriggered: () => triggered++,
),
child: const ColoredBox(
color: Colors.white,
child: SizedBox(width: 360, height: 64),
),
),
),
theme: theme,
);
// 300px of drag on a 96px-wide block clears the 1.8x (~173px)
// trigger extent well past the clamp ceiling.
await tester.drag(find.byType(NooSwipeAction), const Offset(-300, 0));
await tester.pump();
expect(triggered, 1);
},
);
testNooWidgets('tapping the revealed block still triggers the action too', (
tester,
theme,
c,
) async {
var triggered = 0;
await pumpNoo(
tester,
SizedBox(
width: 360,
height: 64,
child: NooSwipeAction(
startAction: NooSwipeActionSpec(
kind: NooSwipeActionKind.favorite,
onTriggered: () => triggered++,
),
child: const ColoredBox(
color: Colors.white,
child: SizedBox(width: 360, height: 64),
),
),
),
theme: theme,
);
await tester.drag(find.byType(NooSwipeAction), const Offset(60, 0));
await tester.pumpAndSettle();
expect(triggered, 0);
await tester.tap(find.text('Favorite'));
await tester.pump();
expect(triggered, 1);
});
});
}