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 | | 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. |
| Upload | `plus` in the nav bar | Extended FAB | | Upload | `plus` in the nav bar | Extended FAB |
| 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. |
+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 opacity/blur knob - flat surfaces per the design system), grid tiles are
`NooGridCard`, swipe actions are `NooSwipeAction`, and per-item sync `NooGridCard`, swipe actions are `NooSwipeAction`, and per-item sync
status is `NooFileRow`/`NooFileTableRow`'s built-in `NooStatusIcon` list 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) — - [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) —
the pull-to-sync `CustomScrollView` header with the persistent sync-status the pull-to-sync `CustomScrollView` header with the persistent sync-status
chip and pull-to-refresh gesture/spinner. Every screen (including chip and pull-to-refresh gesture/spinner. Every screen (including
+6
View File
@@ -472,6 +472,7 @@ class _MainShellViewState extends State<MainShellView> {
); );
} else { } else {
final navStyle = NooLayout.navStyle(context); final navStyle = NooLayout.navStyle(context);
final bottomBarStyle = settings.bottomBarStyle;
scaffold = Scaffold( scaffold = Scaffold(
backgroundColor: colors.bg, backgroundColor: colors.bg,
drawerScrimColor: colors.scrim, drawerScrimColor: colors.scrim,
@@ -479,6 +480,10 @@ class _MainShellViewState extends State<MainShellView> {
? AppTopBar(style: navStyle, tab: selectedTab) ? AppTopBar(style: navStyle, tab: selectedTab)
: null, : null,
drawer: pickRequest == null ? const AppDrawer() : 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` // Android-only extended Upload FAB - iOS uses the top bar's `plus`
// instead (see AppTopBar). Stays mounted across every tab (picking // instead (see AppTopBar). Stays mounted across every tab (picking
// aside) and collapses to an icon-only circle off Files/Photos, // aside) and collapses to an icon-only circle off Files/Photos,
@@ -494,6 +499,7 @@ class _MainShellViewState extends State<MainShellView> {
body: tabStack, body: tabStack,
bottomNavigationBar: BottomNavBar( bottomNavigationBar: BottomNavBar(
style: navStyle, style: navStyle,
barStyle: bottomBarStyle,
tabs: pinnedTabs, tabs: pinnedTabs,
selectedIndex: pinnedIndex, selectedIndex: pinnedIndex,
onDestinationSelected: (index) { onDestinationSelected: (index) {
+18
View File
@@ -3,6 +3,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../models/app_tab.dart'; import '../models/app_tab.dart';
import '../models/selection_action.dart'; import '../models/selection_action.dart';
import '../theme/app_theme.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 /// What swiping a Files list-view item left/right does, user-configurable
/// in Settings. /// in Settings.
@@ -30,6 +31,7 @@ class SettingsController extends ChangeNotifier {
static const _prefDefaultTab = 'ui_default_tab'; static const _prefDefaultTab = 'ui_default_tab';
static const _prefSwipeLeftAction = 'ui_swipe_left_action'; static const _prefSwipeLeftAction = 'ui_swipe_left_action';
static const _prefSwipeRightAction = 'ui_swipe_right_action'; static const _prefSwipeRightAction = 'ui_swipe_right_action';
static const _prefBottomBarStyle = 'ui_bottom_bar_style';
static const _prefAmoledDark = 'ui_amoled_dark'; static const _prefAmoledDark = 'ui_amoled_dark';
static const _prefMediaProgressBarStyle = 'ui_media_progress_bar_style'; static const _prefMediaProgressBarStyle = 'ui_media_progress_bar_style';
static const _prefTapTabToScrollTop = 'ui_tap_tab_to_scroll_top'; static const _prefTapTabToScrollTop = 'ui_tap_tab_to_scroll_top';
@@ -39,6 +41,7 @@ class SettingsController extends ChangeNotifier {
Color _seedColor = AppTheme.defaultAccent; Color _seedColor = AppTheme.defaultAccent;
ThemeMode _themeMode = ThemeMode.system; ThemeMode _themeMode = ThemeMode.system;
NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached;
bool _useDynamicColor = true; bool _useDynamicColor = true;
bool _amoledDark = false; bool _amoledDark = false;
MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy; MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy;
@@ -80,6 +83,7 @@ class SettingsController extends ChangeNotifier {
Color get seedColor => _seedColor; Color get seedColor => _seedColor;
ThemeMode get themeMode => _themeMode; ThemeMode get themeMode => _themeMode;
NooBottomBarStyle get bottomBarStyle => _bottomBarStyle;
bool get useDynamicColor => _useDynamicColor; bool get useDynamicColor => _useDynamicColor;
bool get amoledDark => _amoledDark; bool get amoledDark => _amoledDark;
MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle; MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle;
@@ -115,6 +119,13 @@ class SettingsController extends ChangeNotifier {
orElse: () => ThemeMode.system, orElse: () => ThemeMode.system,
); );
} }
final bottomBarStyleName = prefs.getString(_prefBottomBarStyle);
if (bottomBarStyleName != null) {
_bottomBarStyle = NooBottomBarStyle.values.firstWhere(
(s) => s.name == bottomBarStyleName,
orElse: () => _bottomBarStyle,
);
}
_useDynamicColor = _useDynamicColor =
prefs.getBool(_prefUseDynamicColor) ?? _useDynamicColor; prefs.getBool(_prefUseDynamicColor) ?? _useDynamicColor;
_amoledDark = prefs.getBool(_prefAmoledDark) ?? _amoledDark; _amoledDark = prefs.getBool(_prefAmoledDark) ?? _amoledDark;
@@ -256,6 +267,13 @@ class SettingsController extends ChangeNotifier {
_prefsFuture.then((p) => p.setString(_prefThemeMode, mode.name)); _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) { void setTabOrder(List<AppTab> order) {
_tabOrder = order; _tabOrder = order;
notifyListeners(); 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]. /// drawer's "More" list that isn't one of the pinned [tabs].
class BottomNavBar extends StatelessWidget { class BottomNavBar extends StatelessWidget {
final NooNavStyle style; final NooNavStyle style;
final NooBottomBarStyle barStyle;
final List<AppTab> tabs; final List<AppTab> tabs;
final int selectedIndex; final int selectedIndex;
final ValueChanged<int> onDestinationSelected; final ValueChanged<int> onDestinationSelected;
@@ -16,6 +17,7 @@ class BottomNavBar extends StatelessWidget {
const BottomNavBar({ const BottomNavBar({
super.key, super.key,
required this.style, required this.style,
this.barStyle = NooBottomBarStyle.attached,
required this.tabs, required this.tabs,
required this.selectedIndex, required this.selectedIndex,
required this.onDestinationSelected, required this.onDestinationSelected,
@@ -25,6 +27,7 @@ class BottomNavBar extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return NooBottomBar( return NooBottomBar(
style: style, style: style,
barStyle: barStyle,
destinations: [ destinations: [
for (final tab in tabs) for (final tab in tabs)
NooNavDestination(icon: tab.icon, label: tab.label), NooNavDestination(icon: tab.icon, label: tab.label),
+78 -26
View File
@@ -5,8 +5,11 @@ import 'noo_nav_style.dart';
export 'noo_nav_style.dart'; 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. /// item's own icon box so they line up exactly. Floating's own top offset
const double _kAndroidPillTop = 14; /// 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 _kAndroidPillWidth = 56;
const double _kAndroidPillHeight = 32; const double _kAndroidPillHeight = 32;
@@ -23,11 +26,21 @@ const double _kAndroidPillHeight = 32;
/// 56x32 accent-soft pill that slides between tabs as selection moves, /// 56x32 accent-soft pill that slides between tabs as selection moves,
/// rather than popping in/out on the destination item itself. /// 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 /// 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 /// 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 { class NooBottomBar extends StatelessWidget {
final NooNavStyle style; final NooNavStyle style;
final NooBottomBarStyle barStyle;
final List<NooNavDestination> destinations; final List<NooNavDestination> destinations;
final int selectedIndex; final int selectedIndex;
final ValueChanged<int> onSelected; final ValueChanged<int> onSelected;
@@ -35,6 +48,7 @@ class NooBottomBar extends StatelessWidget {
const NooBottomBar({ const NooBottomBar({
super.key, super.key,
required this.style, required this.style,
this.barStyle = NooBottomBarStyle.attached,
required this.destinations, required this.destinations,
required this.selectedIndex, required this.selectedIndex,
required this.onSelected, required this.onSelected,
@@ -44,8 +58,31 @@ class NooBottomBar extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.nooColors; final colors = context.nooColors;
final ios = style == NooNavStyle.ios; 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( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -54,7 +91,7 @@ class NooBottomBar extends StatelessWidget {
), ),
child: SafeArea( child: SafeArea(
top: false, 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( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final itemWidth = constraints.maxWidth / destinations.length; final itemWidth = constraints.maxWidth / destinations.length;
@@ -84,7 +122,7 @@ class NooBottomBar extends StatelessWidget {
AnimatedPositioned( AnimatedPositioned(
duration: NooMotion.base, duration: NooMotion.base,
curve: NooMotion.ease, curve: NooMotion.ease,
top: _kAndroidPillTop, top: pillTop,
left: left:
itemWidth * selectedIndex + itemWidth * selectedIndex +
(itemWidth - _kAndroidPillWidth) / 2, (itemWidth - _kAndroidPillWidth) / 2,
@@ -105,6 +143,7 @@ class NooBottomBar extends StatelessWidget {
child: _AndroidItem( child: _AndroidItem(
destination: destinations[i], destination: destinations[i],
selected: i == selectedIndex, selected: i == selectedIndex,
floating: floating,
onTap: () => onSelected(i), onTap: () => onSelected(i),
), ),
), ),
@@ -165,44 +204,47 @@ class _IosItem extends StatelessWidget {
} }
/// Icon (over the shared sliding pill, drawn separately by [NooBottomBar]) /// Icon (over the shared sliding pill, drawn separately by [NooBottomBar])
/// with its label always below it - the label's space is reserved /// with its label below it. Attached always reserves the label's space
/// whether or not it's showing (only its opacity changes), so the icon /// (just invisible when idle, per [_kAttachedPillTop]'s doc comment on
/// never shifts vertically as selection changes, and stays aligned with /// [NooBottomBar]) so the icon never shifts vertically as selection
/// the pill's fixed [_kAndroidPillTop]/[_kAndroidPillHeight]. /// 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 { class _AndroidItem extends StatelessWidget {
final NooNavDestination destination; final NooNavDestination destination;
final bool selected; final bool selected;
final bool floating;
final VoidCallback onTap; final VoidCallback onTap;
const _AndroidItem({ const _AndroidItem({
required this.destination, required this.destination,
required this.selected, required this.selected,
required this.floating,
required this.onTap, required this.onTap,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.nooColors; final colors = context.nooColors;
return Semantics( final iconColor = selected ? colors.accentText : colors.fg2;
button: true,
selected: selected, final Widget content;
label: destination.label, if (floating && !selected) {
child: GestureDetector( content = Center(
behavior: HitTestBehavior.opaque, child: Icon(destination.icon, size: 27, color: iconColor),
onTap: onTap, );
child: Padding( } else {
padding: const EdgeInsets.only(top: _kAndroidPillTop), content = Padding(
padding: EdgeInsets.only(
top: floating ? _kFloatingPillTop : _kAttachedPillTop,
),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
SizedBox( SizedBox(
height: _kAndroidPillHeight, height: _kAndroidPillHeight,
child: Center( child: Center(
child: Icon( child: Icon(destination.icon, size: 24, color: iconColor),
destination.icon,
size: 24,
color: selected ? colors.accentText : colors.fg2,
),
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -222,7 +264,17 @@ class _AndroidItem extends StatelessWidget {
), ),
], ],
), ),
), );
}
return Semantics(
button: true,
selected: selected,
label: destination.label,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
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 /// One destination in a [NooBottomBar]. Data-agnostic on purpose - the app
/// maps its own tab model (e.g. `AppTab`) onto these. /// maps its own tab model (e.g. `AppTab`) onto these.
@immutable @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_segmented_control.dart';
import '../noo/core/noo_toggle.dart'; import '../noo/core/noo_toggle.dart';
import '../noo/lists/noo_settings_row.dart'; import '../noo/lists/noo_settings_row.dart';
import '../noo/nav/noo_nav_style.dart';
import '../noo/noo_layout.dart'; import '../noo/noo_layout.dart';
import '../noo/overlays/noo_dialog.dart'; import '../noo/overlays/noo_dialog.dart';
import '../noo/overlays/noo_sheet.dart'; import '../noo/overlays/noo_sheet.dart';
@@ -47,7 +48,10 @@ class SettingsAppearanceSection extends StatelessWidget {
children: [ children: [
SettingsSection( SettingsSection(
title: 'Appearance', title: 'Appearance',
children: [_ThemeRow(settings: settings)], children: [
_ThemeRow(settings: settings),
_BottomBarStyleRow(settings: settings),
],
), ),
const SizedBox(height: NooSpace.xl), const SizedBox(height: NooSpace.xl),
SettingsSection( 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 /// 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 /// "Accent color" row - a wallpaper icon when following the OS's dynamic
/// color instead of a fixed swatch. /// color instead of a fixed swatch.
+75
View File
@@ -137,5 +137,80 @@ void main() {
final labelCenter = tester.getCenter(find.text('Photos')); final labelCenter = tester.getCenter(find.text('Photos'));
expect(labelCenter.dy, greaterThan(iconCenter.dy)); 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!));
},
);
}); });
} }