Noo design system rebuild #1
@@ -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. |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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!));
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user