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
9 changed files with 280 additions and 46 deletions
Showing only changes of commit a70be19f80 - Show all commits
@@ -273,6 +273,7 @@ Mobile always has **5 pinned tabs in the bottom bar**. The rest go in the drawer
| 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 |
| 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. |
| Upload | `plus` in the nav bar | Extended FAB |
| 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. |
+6 -1
View File
@@ -191,7 +191,12 @@ blocks are noted where they matter:
opacity/blur knob - flat surfaces per the design system), grid tiles are
`NooGridCard`, swipe actions are `NooSwipeAction`, and per-item sync
status is `NooFileRow`/`NooFileTableRow`'s built-in `NooStatusIcon` list
instead of a corner badge.
instead of a corner badge. `NooBottomBar` later gained its own, unrelated
`NooBottomBarStyle.floating` (Settings → Appearance → "Bottom bar") -
don't confuse the two: this one is still flat/non-blurred, just inset
with a `line` border instead of edge to edge (no opacity/blur knob
either). The host `Scaffold` needs `extendBody: true` while it's active
(`main.dart` already wires this off `SettingsController.bottomBarStyle`).
- [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) —
the pull-to-sync `CustomScrollView` header with the persistent sync-status
chip and pull-to-refresh gesture/spinner. Every screen (including
+6
View File
@@ -472,6 +472,7 @@ class _MainShellViewState extends State<MainShellView> {
);
} else {
final navStyle = NooLayout.navStyle(context);
final bottomBarStyle = settings.bottomBarStyle;
scaffold = Scaffold(
backgroundColor: colors.bg,
drawerScrimColor: colors.scrim,
@@ -479,6 +480,10 @@ class _MainShellViewState extends State<MainShellView> {
? AppTopBar(style: navStyle, tab: selectedTab)
: null,
drawer: pickRequest == null ? const AppDrawer() : null,
// Floating needs the body to draw behind the bar's own transparent
// margin (see NooBottomBarStyle's doc comment) instead of stopping
// short of it like attached does.
extendBody: bottomBarStyle == NooBottomBarStyle.floating,
// Android-only extended Upload FAB - iOS uses the top bar's `plus`
// instead (see AppTopBar). Stays mounted across every tab (picking
// aside) and collapses to an icon-only circle off Files/Photos,
@@ -494,6 +499,7 @@ class _MainShellViewState extends State<MainShellView> {
body: tabStack,
bottomNavigationBar: BottomNavBar(
style: navStyle,
barStyle: bottomBarStyle,
tabs: pinnedTabs,
selectedIndex: pinnedIndex,
onDestinationSelected: (index) {
+18
View File
@@ -3,6 +3,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../models/app_tab.dart';
import '../models/selection_action.dart';
import '../theme/app_theme.dart';
import '../widgets/noo/nav/noo_nav_style.dart';
/// What swiping a Files list-view item left/right does, user-configurable
/// in Settings.
@@ -30,6 +31,7 @@ class SettingsController extends ChangeNotifier {
static const _prefDefaultTab = 'ui_default_tab';
static const _prefSwipeLeftAction = 'ui_swipe_left_action';
static const _prefSwipeRightAction = 'ui_swipe_right_action';
static const _prefBottomBarStyle = 'ui_bottom_bar_style';
static const _prefAmoledDark = 'ui_amoled_dark';
static const _prefMediaProgressBarStyle = 'ui_media_progress_bar_style';
static const _prefTapTabToScrollTop = 'ui_tap_tab_to_scroll_top';
@@ -39,6 +41,7 @@ class SettingsController extends ChangeNotifier {
Color _seedColor = AppTheme.defaultAccent;
ThemeMode _themeMode = ThemeMode.system;
NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached;
bool _useDynamicColor = true;
bool _amoledDark = false;
MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy;
@@ -80,6 +83,7 @@ class SettingsController extends ChangeNotifier {
Color get seedColor => _seedColor;
ThemeMode get themeMode => _themeMode;
NooBottomBarStyle get bottomBarStyle => _bottomBarStyle;
bool get useDynamicColor => _useDynamicColor;
bool get amoledDark => _amoledDark;
MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle;
@@ -115,6 +119,13 @@ class SettingsController extends ChangeNotifier {
orElse: () => ThemeMode.system,
);
}
final bottomBarStyleName = prefs.getString(_prefBottomBarStyle);
if (bottomBarStyleName != null) {
_bottomBarStyle = NooBottomBarStyle.values.firstWhere(
(s) => s.name == bottomBarStyleName,
orElse: () => _bottomBarStyle,
);
}
_useDynamicColor =
prefs.getBool(_prefUseDynamicColor) ?? _useDynamicColor;
_amoledDark = prefs.getBool(_prefAmoledDark) ?? _amoledDark;
@@ -256,6 +267,13 @@ class SettingsController extends ChangeNotifier {
_prefsFuture.then((p) => p.setString(_prefThemeMode, mode.name));
}
void setBottomBarStyle(NooBottomBarStyle style) {
if (_bottomBarStyle == style) return;
_bottomBarStyle = style;
notifyListeners();
_prefsFuture.then((p) => p.setString(_prefBottomBarStyle, style.name));
}
void setTabOrder(List<AppTab> order) {
_tabOrder = order;
notifyListeners();
+3
View File
@@ -9,6 +9,7 @@ import 'noo/nav/noo_bottom_bar.dart';
/// drawer's "More" list that isn't one of the pinned [tabs].
class BottomNavBar extends StatelessWidget {
final NooNavStyle style;
final NooBottomBarStyle barStyle;
final List<AppTab> tabs;
final int selectedIndex;
final ValueChanged<int> onDestinationSelected;
@@ -16,6 +17,7 @@ class BottomNavBar extends StatelessWidget {
const BottomNavBar({
super.key,
required this.style,
this.barStyle = NooBottomBarStyle.attached,
required this.tabs,
required this.selectedIndex,
required this.onDestinationSelected,
@@ -25,6 +27,7 @@ class BottomNavBar extends StatelessWidget {
Widget build(BuildContext context) {
return NooBottomBar(
style: style,
barStyle: barStyle,
destinations: [
for (final tab in tabs)
NooNavDestination(icon: tab.icon, label: tab.label),
+96 -44
View File
@@ -5,8 +5,11 @@ import 'noo_nav_style.dart';
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.
const double _kAndroidPillTop = 14;
/// 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)
/// 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 _kAndroidPillWidth = 56;
const double _kAndroidPillHeight = 32;
@@ -23,11 +26,21 @@ const double _kAndroidPillHeight = 32;
/// 56x32 accent-soft pill that slides between tabs as selection moves,
/// rather than popping in/out on the destination item itself.
///
/// [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`.
/// right on every device. Meant for `Scaffold.bottomNavigationBar` - the
/// host `Scaffold` needs `extendBody: true` while floating.
class NooBottomBar extends StatelessWidget {
final NooNavStyle style;
final NooBottomBarStyle barStyle;
final List<NooNavDestination> destinations;
final int selectedIndex;
final ValueChanged<int> onSelected;
@@ -35,6 +48,7 @@ class NooBottomBar extends StatelessWidget {
const NooBottomBar({
super.key,
required this.style,
this.barStyle = NooBottomBarStyle.attached,
required this.destinations,
required this.selectedIndex,
required this.onSelected,
@@ -44,8 +58,31 @@ class NooBottomBar extends StatelessWidget {
Widget build(BuildContext context) {
final colors = context.nooColors;
final ios = style == NooNavStyle.ios;
final floating = barStyle == NooBottomBarStyle.floating;
final row = ios ? _buildIosRow() : _buildAndroidRow(colors);
final row = ios
? _buildIosRow()
: _buildAndroidRow(colors, floating: floating);
final barHeight = ios ? (floating ? 64.0 : 50.0) : (floating ? 72.0 : 80.0);
if (floating) {
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Container(
height: barHeight,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: colors.surface,
border: Border.all(color: colors.line),
borderRadius: BorderRadius.circular(28),
),
child: row,
),
),
);
}
return Container(
decoration: BoxDecoration(
@@ -54,7 +91,7 @@ class NooBottomBar extends StatelessWidget {
),
child: SafeArea(
top: false,
child: SizedBox(height: ios ? 50 : 80, child: row),
child: SizedBox(height: barHeight, child: row),
),
);
}
@@ -75,7 +112,8 @@ class NooBottomBar extends StatelessWidget {
);
}
Widget _buildAndroidRow(NooColors colors) {
Widget _buildAndroidRow(NooColors colors, {required bool floating}) {
final pillTop = floating ? _kFloatingPillTop : _kAttachedPillTop;
return LayoutBuilder(
builder: (context, constraints) {
final itemWidth = constraints.maxWidth / destinations.length;
@@ -84,7 +122,7 @@ class NooBottomBar extends StatelessWidget {
AnimatedPositioned(
duration: NooMotion.base,
curve: NooMotion.ease,
top: _kAndroidPillTop,
top: pillTop,
left:
itemWidth * selectedIndex +
(itemWidth - _kAndroidPillWidth) / 2,
@@ -105,6 +143,7 @@ class NooBottomBar extends StatelessWidget {
child: _AndroidItem(
destination: destinations[i],
selected: i == selectedIndex,
floating: floating,
onTap: () => onSelected(i),
),
),
@@ -165,24 +204,69 @@ class _IosItem extends StatelessWidget {
}
/// Icon (over the shared sliding pill, drawn separately by [NooBottomBar])
/// with its label always below it - the label's space is reserved
/// whether or not it's showing (only its opacity changes), so the icon
/// never shifts vertically as selection changes, and stays aligned with
/// the pill's fixed [_kAndroidPillTop]/[_kAndroidPillHeight].
/// with its label below it. Attached always reserves the label's space
/// (just invisible when idle, per [_kAttachedPillTop]'s doc comment on
/// [NooBottomBar]) so the icon never shifts vertically as selection
/// changes. Floating drops that reserved space when idle instead: there's
/// no label to leave room for, so the icon centers in the whole button and
/// renders a touch bigger.
class _AndroidItem extends StatelessWidget {
final NooNavDestination destination;
final bool selected;
final bool floating;
final VoidCallback onTap;
const _AndroidItem({
required this.destination,
required this.selected,
required this.floating,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final iconColor = selected ? colors.accentText : colors.fg2;
final Widget content;
if (floating && !selected) {
content = Center(
child: Icon(destination.icon, size: 27, color: iconColor),
);
} else {
content = Padding(
padding: EdgeInsets.only(
top: floating ? _kFloatingPillTop : _kAttachedPillTop,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
SizedBox(
height: _kAndroidPillHeight,
child: Center(
child: Icon(destination.icon, size: 24, color: iconColor),
),
),
const SizedBox(height: 4),
AnimatedOpacity(
duration: NooMotion.base,
curve: NooMotion.ease,
opacity: selected ? 1 : 0,
child: Text(
destination.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.navLabelActive.copyWith(
height: 1,
color: colors.fg1,
),
),
),
],
),
);
}
return Semantics(
button: true,
selected: selected,
@@ -190,39 +274,7 @@ class _AndroidItem extends StatelessWidget {
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.only(top: _kAndroidPillTop),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
SizedBox(
height: _kAndroidPillHeight,
child: Center(
child: Icon(
destination.icon,
size: 24,
color: selected ? colors.accentText : colors.fg2,
),
),
),
const SizedBox(height: 4),
AnimatedOpacity(
duration: NooMotion.base,
curve: NooMotion.ease,
opacity: selected ? 1 : 0,
child: Text(
destination.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.navLabelActive.copyWith(
height: 1,
color: colors.fg1,
),
),
),
],
),
),
child: content,
),
);
}
+12
View File
@@ -17,6 +17,18 @@ enum NooNavStyle {
};
}
/// How [NooBottomBar] sits on the screen - user-configurable in Settings
/// (Appearance). [attached] is the original edge-to-edge bar with no
/// 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.
enum NooBottomBarStyle { attached, floating }
/// One destination in a [NooBottomBar]. Data-agnostic on purpose - the app
/// maps its own tab model (e.g. `AppTab`) onto these.
@immutable
+63 -1
View File
@@ -7,6 +7,7 @@ import '../../theme/design_tokens.dart';
import '../noo/core/noo_segmented_control.dart';
import '../noo/core/noo_toggle.dart';
import '../noo/lists/noo_settings_row.dart';
import '../noo/nav/noo_nav_style.dart';
import '../noo/noo_layout.dart';
import '../noo/overlays/noo_dialog.dart';
import '../noo/overlays/noo_sheet.dart';
@@ -47,7 +48,10 @@ class SettingsAppearanceSection extends StatelessWidget {
children: [
SettingsSection(
title: 'Appearance',
children: [_ThemeRow(settings: settings)],
children: [
_ThemeRow(settings: settings),
_BottomBarStyleRow(settings: settings),
],
),
const SizedBox(height: NooSpace.xl),
SettingsSection(
@@ -156,6 +160,64 @@ class _ThemeRow extends StatelessWidget {
}
}
/// The "Bottom bar" row: same "label, then a full-width segmented control
/// below it" shape as [_ThemeRow] right above it. Attached is the original
/// edge-to-edge bar; floating insets it from both side/bottom edges with
/// rounded corners - see [NooBottomBarStyle]'s own doc comment for the
/// full visual rationale.
class _BottomBarStyleRow extends StatelessWidget {
final SettingsController settings;
const _BottomBarStyleRow({required this.settings});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Material(
color: colors.surface,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: NooSpace.md,
vertical: 12,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.panelBottom, size: 20, color: colors.fg2),
const SizedBox(width: 14),
Text(
'Bottom bar',
style: NooText.bodyL.copyWith(color: colors.fg1),
),
],
),
const SizedBox(height: 12),
NooSegmentedControl<NooBottomBarStyle>(
fill: true,
value: settings.bottomBarStyle,
onChanged: settings.setBottomBarStyle,
options: const [
NooSegmentOption(
value: NooBottomBarStyle.attached,
icon: LucideIcons.panelBottom,
label: 'Attached',
),
NooSegmentOption(
value: NooBottomBarStyle.floating,
icon: LucideIcons.panelBottomOpen,
label: 'Floating',
),
],
),
],
),
),
);
}
}
/// The current accent-color choice, shown as a small filled circle in the
/// "Accent color" row - a wallpaper icon when following the OS's dynamic
/// color instead of a fixed swatch.
+75
View File
@@ -137,5 +137,80 @@ void main() {
final labelCenter = tester.getCenter(find.text('Photos'));
expect(labelCenter.dy, greaterThan(iconCenter.dy));
});
testNooWidgets('floating: inset, rounded, bordered - not edge to edge', (
tester,
theme,
c,
) async {
await pumpNoo(
tester,
SizedBox(
width: 360,
height: 96,
child: NooBottomBar(
style: NooNavStyle.android,
barStyle: NooBottomBarStyle.floating,
destinations: _destinations,
selectedIndex: 0,
onSelected: (_) {},
),
),
theme: theme,
);
final pillFinder = find.descendant(
of: find.byType(NooBottomBar),
matching: find.byWidgetPredicate(
(w) => w is Container && w.decoration != null,
),
);
final container = tester.widget<Container>(pillFinder);
final decoration = container.decoration as BoxDecoration;
expect(decoration.borderRadius, BorderRadius.circular(28));
expect(decoration.border, Border.all(color: c.line));
// Inset from both side edges of the bar's own host box, not flush
// against it - both rects are in the same (global) coordinate space,
// so comparing them directly still holds regardless of where
// `pumpNoo`'s own harness centers that host box on screen.
final hostRect = tester.getRect(find.byType(NooBottomBar));
final rect = tester.getRect(pillFinder);
expect(rect.left, greaterThan(hostRect.left));
expect(rect.right, lessThan(hostRect.right));
});
testNooWidgets(
'floating android: idle icon has no reserved label space and is '
'bigger than the active one',
(tester, theme, c) async {
await pumpNoo(
tester,
SizedBox(
width: 360,
height: 96,
child: NooBottomBar(
style: NooNavStyle.android,
barStyle: NooBottomBarStyle.floating,
destinations: _destinations,
selectedIndex: 0,
onSelected: (_) {},
),
),
theme: theme,
);
// Unlike attached, an idle floating tab has no label at all - not
// just an invisible one - so there's nothing to find here.
expect(find.text('Photos'), findsNothing);
expect(
find.ancestor(
of: find.byIcon(LucideIcons.images),
matching: find.byType(AnimatedOpacity),
),
findsNothing,
);
final activeIcon = tester.widget<Icon>(find.byIcon(LucideIcons.folder));
final idleIcon = tester.widget<Icon>(find.byIcon(LucideIcons.images));
expect(idleIcon.size, greaterThan(activeIcon.size!));
},
);
});
}