Noo design system rebuild #1
@@ -280,10 +280,12 @@ Mobile always has **5 pinned tabs in the bottom bar**. The rest go in the drawer
|
||||
| Element | iOS | Android |
|
||||
|---|---|---|
|
||||
| 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, avatar - see "Search" below for what replaces `search` there, and "Navigation menu" for `menu` |
|
||||
| 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. 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. |
|
||||
| Search (top bar, when not in the bottom bar) | A real inline search field (`NooSearchField`, non-editable launcher) below the large title | A search-field-styled launcher takes the title's own flexible slot instead of a bare `search` icon beside it - no second row to put it under the way iOS has |
|
||||
| Navigation menu (Settings → Appearance → "Navigation menu", both platforms) | `drawer` (default): the `menu` icon opens the left `Drawer` (hidden tabs + Settings), unchanged. `avatarMenu`: no `menu` icon at all - the avatar button opens a dropdown instead (`showAvatarMenu`), anchored under the top bar, full width minus the same 16px gutter every content row uses, with the same hidden-tabs + Settings content the drawer holds (not its storage bar - this is a quick menu, not the drawer's full account summary). The avatar sits in the exact same (right-end) position either way, and the dropdown's own header repeats it there too, so the open menu reads as having grown out of the button that opened it. Not a mandatory replacement: this halves the top-corner surface a thumb has to reach (from `menu` + avatar down to avatar alone) without costing any permanent screen width the way an earlier side-rail exploration would have. |
|
||||
| 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` |
|
||||
| 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. |
|
||||
|
||||
@@ -221,6 +221,26 @@ blocks are noted where they matter:
|
||||
`onSearchTap`) and lowers `SettingsController.maxVisibleTabs` by one -
|
||||
use that getter, not `defaultMaxVisibleTabs` from `models/app_tab.dart`,
|
||||
anywhere that needs the *current* cap on regular tabs.
|
||||
- `SettingsController.navMenuStyle` (`NooNavMenuStyle.drawer`/`avatarMenu`,
|
||||
Settings → Appearance → "Navigation menu") picks what opens hidden tabs +
|
||||
Settings on mobile: the original hamburger-opens-`AppDrawer` pattern, or
|
||||
the avatar button opens [`showAvatarMenu`](../../lib/widgets/avatar_menu.dart)
|
||||
instead. Wired through `AppTopBar`→`NooTopBar.onMenu` (null in `avatarMenu`
|
||||
mode - no menu icon renders at all, see `NooTopBar`'s `lead` logic) and
|
||||
`ShellAvatarButton`'s new `onTap`/`label` overrides (`shell_common.dart`) -
|
||||
a caller passing a custom `onTap` *must* also pass a matching `label`, or
|
||||
the tooltip/semantics still say "Accounts" for a button that no longer
|
||||
opens the account switcher. `showAvatarMenu` is this app's first use of
|
||||
`showGeneralDialog` directly (`barrierColor: Colors.transparent` +
|
||||
`barrierDismissible: true` for a non-dimming click-outside-to-close menu,
|
||||
not a modal flow) - there's no existing anchored-popup primitive here
|
||||
(`PopupMenuButton`'s own width doesn't stretch to a full content column),
|
||||
so don't reach for `showNooSheet`/`showNooDialog` for something shaped
|
||||
like this. Also added `NooTopBar.androidTitleReplacement`: Android has no
|
||||
large title to put a second search row under the way iOS's `search:`
|
||||
slot does, so an inline search bar (`AppTopBar` passes a plain
|
||||
`ShellSearchLauncher()` when search isn't in the bottom bar) replaces the
|
||||
title in its flexible slot instead of sitting beside it as a bare icon.
|
||||
- [`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
|
||||
|
||||
@@ -423,6 +423,7 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
style: navStyle,
|
||||
tab: tab,
|
||||
searchInBottomBar: showBottomBarSearch,
|
||||
navMenuStyle: settings.navMenuStyle,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -32,6 +32,7 @@ class SettingsController extends ChangeNotifier {
|
||||
static const _prefSwipeLeftAction = 'ui_swipe_left_action';
|
||||
static const _prefSwipeRightAction = 'ui_swipe_right_action';
|
||||
static const _prefBottomBarStyle = 'ui_bottom_bar_style';
|
||||
static const _prefNavMenuStyle = 'ui_nav_menu_style';
|
||||
static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar';
|
||||
static const _prefAmoledDark = 'ui_amoled_dark';
|
||||
static const _prefMediaProgressBarStyle = 'ui_media_progress_bar_style';
|
||||
@@ -43,6 +44,7 @@ class SettingsController extends ChangeNotifier {
|
||||
Color _seedColor = AppTheme.defaultAccent;
|
||||
ThemeMode _themeMode = ThemeMode.system;
|
||||
NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached;
|
||||
NooNavMenuStyle _navMenuStyle = NooNavMenuStyle.drawer;
|
||||
bool _searchInBottomBar = false;
|
||||
bool _useDynamicColor = true;
|
||||
bool _amoledDark = false;
|
||||
@@ -86,6 +88,7 @@ class SettingsController extends ChangeNotifier {
|
||||
Color get seedColor => _seedColor;
|
||||
ThemeMode get themeMode => _themeMode;
|
||||
NooBottomBarStyle get bottomBarStyle => _bottomBarStyle;
|
||||
NooNavMenuStyle get navMenuStyle => _navMenuStyle;
|
||||
bool get searchInBottomBar => _searchInBottomBar;
|
||||
bool get useDynamicColor => _useDynamicColor;
|
||||
bool get amoledDark => _amoledDark;
|
||||
@@ -137,6 +140,13 @@ class SettingsController extends ChangeNotifier {
|
||||
orElse: () => _bottomBarStyle,
|
||||
);
|
||||
}
|
||||
final navMenuStyleName = prefs.getString(_prefNavMenuStyle);
|
||||
if (navMenuStyleName != null) {
|
||||
_navMenuStyle = NooNavMenuStyle.values.firstWhere(
|
||||
(s) => s.name == navMenuStyleName,
|
||||
orElse: () => _navMenuStyle,
|
||||
);
|
||||
}
|
||||
_searchInBottomBar =
|
||||
prefs.getBool(_prefSearchInBottomBar) ?? _searchInBottomBar;
|
||||
_useDynamicColor =
|
||||
@@ -287,6 +297,13 @@ class SettingsController extends ChangeNotifier {
|
||||
_prefsFuture.then((p) => p.setString(_prefBottomBarStyle, style.name));
|
||||
}
|
||||
|
||||
void setNavMenuStyle(NooNavMenuStyle style) {
|
||||
if (_navMenuStyle == style) return;
|
||||
_navMenuStyle = style;
|
||||
notifyListeners();
|
||||
_prefsFuture.then((p) => p.setString(_prefNavMenuStyle, style.name));
|
||||
}
|
||||
|
||||
/// Turning this on lowers [maxVisibleTabs] by one, so re-enforces the cap
|
||||
/// immediately in case the user already has a full 5 regular tabs pinned
|
||||
/// - same cleanup [_enforceMaxVisibleTabs] already does for a fresh
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../models/app_tab.dart';
|
||||
import 'avatar_menu.dart';
|
||||
import 'create_menu.dart';
|
||||
import 'noo/nav/noo_top_bar.dart';
|
||||
import 'shell/shell_common.dart';
|
||||
@@ -13,8 +14,11 @@ import 'shell/shell_common.dart';
|
||||
/// - iOS: `menu` leading, `plus` (Files only - no tab besides Files has a
|
||||
/// create/upload flow) and the account avatar trailing, then the large
|
||||
/// title, then an inline search field that opens [SearchView].
|
||||
/// - Android: `menu`, title, `search` and avatar actions. Upload has no
|
||||
/// top-bar icon here - it's the [NooFab] `MainShellView` shows instead.
|
||||
/// - Android: `menu`, title, avatar actions. Upload has no top-bar icon
|
||||
/// here - it's the [NooFab] `MainShellView` shows instead. When search
|
||||
/// isn't in the bottom bar, the title itself is replaced by an inline
|
||||
/// search-field-styled launcher (`androidTitleReplacement`) rather than
|
||||
/// a bare search icon sitting beside it.
|
||||
///
|
||||
/// [style] is resolved by the caller (`NooLayout.navStyle`) rather than
|
||||
/// read from context here, since [preferredSize] is a plain getter with no
|
||||
@@ -22,17 +26,24 @@ import 'shell/shell_common.dart';
|
||||
/// [searchInBottomBar] is [SettingsController.searchInBottomBar], passed in
|
||||
/// for the same reason: it hides this bar's own search field/icon so
|
||||
/// there's only ever one search entry point on screen once the bottom bar
|
||||
/// has taken it over.
|
||||
/// has taken it over. [navMenuStyle] is
|
||||
/// [SettingsController.navMenuStyle]: [NooNavMenuStyle.drawer] wires the
|
||||
/// `menu` icon to `Scaffold.of(context).openDrawer()` as before;
|
||||
/// [NooNavMenuStyle.avatarMenu] drops that icon (`onMenu: null`) and makes
|
||||
/// the avatar button open `showAvatarMenu` instead of the account
|
||||
/// switcher.
|
||||
class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
final NooNavStyle style;
|
||||
final AppTab tab;
|
||||
final bool searchInBottomBar;
|
||||
final NooNavMenuStyle navMenuStyle;
|
||||
|
||||
const AppTopBar({
|
||||
super.key,
|
||||
required this.style,
|
||||
required this.tab,
|
||||
this.searchInBottomBar = false,
|
||||
this.navMenuStyle = NooNavMenuStyle.drawer,
|
||||
});
|
||||
|
||||
bool get _ios => style == NooNavStyle.ios;
|
||||
@@ -53,21 +64,31 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
return NooTopBar(
|
||||
style: style,
|
||||
title: tab.label,
|
||||
onMenu: () => Scaffold.of(context).openDrawer(),
|
||||
onMenu: navMenuStyle == NooNavMenuStyle.drawer
|
||||
? () => Scaffold.of(context).openDrawer()
|
||||
: null,
|
||||
// Android has no large title to put a second search row under the
|
||||
// way iOS does (`search:` below) - an inline bar replaces the title
|
||||
// itself instead of sitting beside it as a plain icon action.
|
||||
androidTitleReplacement: !_ios && _showSearch
|
||||
? const ShellSearchLauncher()
|
||||
: null,
|
||||
actions: [
|
||||
if (!_ios && _showSearch)
|
||||
NooTopBarButton(
|
||||
icon: LucideIcons.search,
|
||||
tooltip: 'Search',
|
||||
onTap: () => openSearch(context),
|
||||
),
|
||||
if (_ios && tab == AppTab.files)
|
||||
NooTopBarButton(
|
||||
icon: LucideIcons.plus,
|
||||
tooltip: 'Create',
|
||||
onTap: () => showCreateMenu(context),
|
||||
),
|
||||
ShellAvatarButton(hitBox: _ios ? 44 : 48),
|
||||
ShellAvatarButton(
|
||||
hitBox: _ios ? 44 : 48,
|
||||
onTap: navMenuStyle == NooNavMenuStyle.avatarMenu
|
||||
? () => showAvatarMenu(context)
|
||||
: null,
|
||||
label: navMenuStyle == NooNavMenuStyle.avatarMenu
|
||||
? 'Menu'
|
||||
: 'Accounts',
|
||||
),
|
||||
],
|
||||
search: _ios && _showSearch ? const ShellSearchLauncher(ios: true) : null,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/app_tab.dart';
|
||||
import '../providers/session_controller.dart';
|
||||
import '../providers/settings_controller.dart';
|
||||
import '../providers/trash_controller.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import 'noo/core/noo_avatar.dart';
|
||||
import 'noo/core/noo_badge.dart';
|
||||
import 'noo/lists/noo_settings_row.dart';
|
||||
import 'noo/nav/noo_top_bar.dart';
|
||||
import 'noo/noo_layout.dart';
|
||||
import 'shell/shell_common.dart';
|
||||
|
||||
/// The dropdown [ShellAvatarButton] opens when
|
||||
/// [SettingsController.navMenuStyle] is [NooNavMenuStyle.avatarMenu] -
|
||||
/// everything [AppDrawer] holds beyond its storage bar (hidden tabs,
|
||||
/// Settings), reached from the avatar instead of a hamburger-triggered
|
||||
/// left `Drawer`. Built with `showGeneralDialog` rather than a `Drawer`/
|
||||
/// `showNooSheet`/`showNooDialog` - this needs a transparent (non-dimming)
|
||||
/// barrier and a card anchored under the top bar rather than a modal
|
||||
/// sheet/dialog, and there's no existing anchored-popup primitive in this
|
||||
/// app to reuse (`PopupMenuButton`'s own width doesn't stretch to the full
|
||||
/// content column the way this needs to).
|
||||
Future<void> showAvatarMenu(BuildContext context) {
|
||||
final navStyle = NooLayout.navStyle(context);
|
||||
return showGeneralDialog<void>(
|
||||
context: context,
|
||||
barrierColor: Colors.transparent,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: 'Close menu',
|
||||
transitionDuration: NooMotion.fast,
|
||||
pageBuilder: (context, _, _) => _AvatarMenuContent(navStyle: navStyle),
|
||||
transitionBuilder: (context, animation, _, child) => FadeTransition(
|
||||
opacity: animation,
|
||||
child: ScaleTransition(
|
||||
alignment: Alignment.topRight,
|
||||
scale: Tween<double>(
|
||||
begin: 0.96,
|
||||
end: 1,
|
||||
).animate(CurvedAnimation(parent: animation, curve: NooMotion.ease)),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _AvatarMenuContent extends StatelessWidget {
|
||||
final NooNavStyle navStyle;
|
||||
|
||||
const _AvatarMenuContent({required this.navStyle});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
final session = context.watch<SessionController>();
|
||||
final settings = context.watch<SettingsController>();
|
||||
final trashCount = context.watch<TrashController>().items.length;
|
||||
final hiddenTabs = settings.tabOrder
|
||||
.where((t) => settings.hiddenTabs.contains(t))
|
||||
.toList();
|
||||
// The row/avatar it opens from is always this tall + the status bar
|
||||
// above it (the button that opens this can't be tapped while its own
|
||||
// top bar is scrolled away, so it's always on-screen at this exact
|
||||
// position when that happens) - matches `NooTopBar.preferredSize`
|
||||
// exactly rather than a guessed constant.
|
||||
final topBarHeight = NooTopBar(
|
||||
style: navStyle,
|
||||
title: '',
|
||||
).preferredSize.height;
|
||||
|
||||
void closeAndOpenSettings() {
|
||||
Navigator.pop(context);
|
||||
openSettings(context);
|
||||
}
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
NooSpace.md,
|
||||
topBarHeight,
|
||||
NooSpace.md,
|
||||
0,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
border: Border.all(color: colors.line),
|
||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||
boxShadow: const [nooDialogShadow],
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Avatar on the right, name/host on the left - mirrors the
|
||||
// top bar's own right-aligned avatar exactly, so this
|
||||
// header reads as a continuation of the button that
|
||||
// opened it rather than a disconnected card.
|
||||
InkWell(
|
||||
onTap: closeAndOpenSettings,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(NooSpace.md),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
session.username,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.bodyL.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colors.fg1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
serverHost(session.serverUrl),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.meta.copyWith(
|
||||
color: colors.fg3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
NooAvatar(
|
||||
initials: accountInitial(session.username),
|
||||
current: true,
|
||||
size: 40,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hiddenTabs.isNotEmpty) ...[
|
||||
Divider(height: 1, color: colors.line),
|
||||
for (final tab in hiddenTabs)
|
||||
NooSettingsRow(
|
||||
icon: tab.icon,
|
||||
label: Text(tab.label),
|
||||
trailing: tab == AppTab.trash && trashCount > 0
|
||||
? NooBadge(
|
||||
tone: NooBadgeTone.accent,
|
||||
child: Text('$trashCount'),
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
settings.requestTab(tab);
|
||||
},
|
||||
),
|
||||
],
|
||||
Divider(height: 1, color: colors.line),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.settings,
|
||||
label: const Text('Settings'),
|
||||
onTap: closeAndOpenSettings,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,20 @@ enum NooNavStyle {
|
||||
/// trailing padding to clear the bar (see `NooBottomBar.rowHeight`).
|
||||
enum NooBottomBarStyle { attached, floating }
|
||||
|
||||
/// Which widget opens the shell's navigation menu (hidden tabs + Settings)
|
||||
/// on mobile - user-configurable in Settings (Appearance), same precedent
|
||||
/// as [NooBottomBarStyle]. [drawer] is the original pattern: a `menu`
|
||||
/// icon, top-left of the top bar, opens a left-edge `Drawer`. [avatarMenu]
|
||||
/// drops that icon entirely and repurposes the avatar button - already
|
||||
/// sitting top-right, already a tap target every user already knows - as
|
||||
/// the one entry point instead, opening a dropdown anchored below it (see
|
||||
/// `showAvatarMenu` in `widgets/avatar_menu.dart`) rather than a drawer
|
||||
/// sliding from the opposite edge. Applies on both platforms, since
|
||||
/// [NooTopBar]'s `onMenu`/avatar wiring is shared chrome, not
|
||||
/// Android-specific - only [AppDrawer]'s left-`Drawer` convention is being
|
||||
/// offered an alternative, not anything platform-only.
|
||||
enum NooNavMenuStyle { drawer, avatarMenu }
|
||||
|
||||
/// One destination in a [NooBottomBar]. Data-agnostic on purpose - the app
|
||||
/// maps its own tab model (e.g. `AppTab`) onto these.
|
||||
@immutable
|
||||
|
||||
@@ -56,6 +56,15 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
/// iOS only: the search field shown below the large title.
|
||||
final Widget? search;
|
||||
|
||||
/// Android only: replaces the plain title text with an arbitrary widget
|
||||
/// (e.g. a search-field-styled launcher) in that same flexible middle
|
||||
/// slot, between [leading]/the menu button and [actions]. iOS already
|
||||
/// has a dedicated [search] slot below its large title for this; Android
|
||||
/// has no large title to put a second row under, so a caller that wants
|
||||
/// an inline search bar there replaces the title with one instead of
|
||||
/// adding a row.
|
||||
final Widget? androidTitleReplacement;
|
||||
|
||||
/// Defaults to `bg` - the top bar sits on the screen background.
|
||||
final Color? backgroundColor;
|
||||
|
||||
@@ -67,6 +76,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
this.onMenu,
|
||||
this.actions = const [],
|
||||
this.search,
|
||||
this.androidTitleReplacement,
|
||||
this.backgroundColor,
|
||||
});
|
||||
|
||||
@@ -179,7 +189,9 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
children: [
|
||||
?lead,
|
||||
Expanded(
|
||||
child: Text(
|
||||
child:
|
||||
androidTitleReplacement ??
|
||||
Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
||||
@@ -51,6 +51,7 @@ class SettingsAppearanceSection extends StatelessWidget {
|
||||
children: [
|
||||
_ThemeRow(settings: settings),
|
||||
_BottomBarStyleRow(settings: settings),
|
||||
_NavMenuStyleRow(settings: settings),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.search,
|
||||
label: const Text('Search in bottom bar'),
|
||||
@@ -230,6 +231,66 @@ class _BottomBarStyleRow extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// "Navigation menu" (Settings → Appearance): which widget opens hidden
|
||||
/// tabs + Settings - the original `menu` icon that opens a left `Drawer`,
|
||||
/// or the avatar button instead (opens `showAvatarMenu`, a dropdown
|
||||
/// anchored under it) - see [NooNavMenuStyle]'s own doc comment for the
|
||||
/// full reachability rationale (dropping the hamburger removes one of the
|
||||
/// two top-corner targets the shell asks a thumb to reach, rather than
|
||||
/// adding a third kind of chrome like a side rail would).
|
||||
class _NavMenuStyleRow extends StatelessWidget {
|
||||
final SettingsController settings;
|
||||
|
||||
const _NavMenuStyleRow({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.panelLeft, size: 20, color: colors.fg2),
|
||||
const SizedBox(width: 14),
|
||||
Text(
|
||||
'Navigation menu',
|
||||
style: NooText.bodyL.copyWith(color: colors.fg1),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
NooSegmentedControl<NooNavMenuStyle>(
|
||||
fill: true,
|
||||
value: settings.navMenuStyle,
|
||||
onChanged: settings.setNavMenuStyle,
|
||||
options: const [
|
||||
NooSegmentOption(
|
||||
value: NooNavMenuStyle.drawer,
|
||||
icon: LucideIcons.menu,
|
||||
label: 'Hamburger',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: NooNavMenuStyle.avatarMenu,
|
||||
icon: LucideIcons.userRound,
|
||||
label: 'Avatar',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -212,13 +212,31 @@ class _AccountSwitchRow extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// The current user's avatar in the mobile top bar. Tap opens the accounts
|
||||
/// sheet ([showAccountSwitcher]); swiping up/down on it cycles to the
|
||||
/// next/previous saved account immediately, with no confirmation - a
|
||||
/// shortcut alongside the full switcher.
|
||||
/// sheet ([showAccountSwitcher]) by default - or [onTap], when the caller
|
||||
/// overrides it (`AppTopBar` does this for
|
||||
/// `SettingsController.navMenuStyle`'s `avatarMenu` option, which
|
||||
/// repurposes this same button to open `showAvatarMenu` instead; avoids
|
||||
/// this file importing `widgets/avatar_menu.dart`, which itself imports
|
||||
/// this file for `openSettings`/`accountInitial`/`serverHost`). Swiping
|
||||
/// up/down on it cycles to the next/previous saved account immediately,
|
||||
/// with no confirmation, regardless of [onTap] - a shortcut that stays
|
||||
/// available either way.
|
||||
class ShellAvatarButton extends StatelessWidget {
|
||||
final double hitBox;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const ShellAvatarButton({super.key, this.hitBox = 48});
|
||||
/// Describes whatever [onTap] actually does - defaults to "Accounts"
|
||||
/// (the account switcher), but a caller overriding [onTap] should
|
||||
/// override this too so the tooltip/semantics describe the real action
|
||||
/// instead of a stale default.
|
||||
final String label;
|
||||
|
||||
const ShellAvatarButton({
|
||||
super.key,
|
||||
this.hitBox = 48,
|
||||
this.onTap,
|
||||
this.label = 'Accounts',
|
||||
});
|
||||
|
||||
Future<void> _handleVerticalSwipe(
|
||||
BuildContext context,
|
||||
@@ -246,17 +264,17 @@ class ShellAvatarButton extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final session = context.watch<SessionController>();
|
||||
return Tooltip(
|
||||
message: 'Accounts',
|
||||
message: label,
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: 'Accounts',
|
||||
label: label,
|
||||
child: GestureDetector(
|
||||
onVerticalDragEnd: (details) =>
|
||||
_handleVerticalSwipe(context, details),
|
||||
child: SizedBox.square(
|
||||
dimension: hitBox,
|
||||
child: InkResponse(
|
||||
onTap: () => showAccountSwitcher(context),
|
||||
onTap: onTap ?? () => showAccountSwitcher(context),
|
||||
radius: hitBox / 2,
|
||||
child: Center(
|
||||
child: NooAvatar(
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:noo/models/app_tab.dart';
|
||||
import 'package:noo/models/saved_account.dart';
|
||||
import 'package:noo/providers/connectivity_controller.dart';
|
||||
import 'package:noo/providers/files_controller.dart';
|
||||
import 'package:noo/providers/session_controller.dart';
|
||||
import 'package:noo/providers/settings_controller.dart';
|
||||
import 'package:noo/providers/sync_status_controller.dart';
|
||||
import 'package:noo/providers/trash_controller.dart';
|
||||
import 'package:noo/theme/app_theme.dart';
|
||||
import 'package:noo/widgets/app_top_bar.dart';
|
||||
import 'package:noo/widgets/noo/nav/noo_top_bar.dart';
|
||||
|
||||
/// Covers `SettingsController.navMenuStyle`'s two options as wired through
|
||||
/// `AppTopBar`/`ShellAvatarButton`: [NooNavMenuStyle.drawer] (today's
|
||||
/// hamburger-opens-a-left-Drawer pattern, unchanged) vs
|
||||
/// [NooNavMenuStyle.avatarMenu] (no hamburger at all - the avatar opens
|
||||
/// `showAvatarMenu`'s dropdown instead of the account switcher).
|
||||
void main() {
|
||||
const secureStorageChannel = MethodChannel(
|
||||
'plugins.it_nomads.com/flutter_secure_storage',
|
||||
);
|
||||
const connectivityChannel = MethodChannel(
|
||||
'dev.fluttercommunity.plus/connectivity',
|
||||
);
|
||||
const connectivityStatusChannel = EventChannel(
|
||||
'dev.fluttercommunity.plus/connectivity_status',
|
||||
);
|
||||
|
||||
const accountId = 'server_example_com__alice';
|
||||
const passwordKey = 'nc_app_password_$accountId';
|
||||
const password = 'app-password';
|
||||
|
||||
setUpAll(() {
|
||||
GoogleFonts.config.allowRuntimeFetching = false;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(secureStorageChannel, (call) async {
|
||||
if (call.method == 'readAll') {
|
||||
return <String, String>{passwordKey: password};
|
||||
}
|
||||
if (call.method == 'read') {
|
||||
final key = (call.arguments as Map)['key'] as String?;
|
||||
return key == passwordKey ? password : null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
// Offline, same as `account_view_test.dart`'s setup: keeps
|
||||
// SessionController in a provisional login and stops
|
||||
// FilesController/SyncStatusController/TrashController's network/timer
|
||||
// machinery from hanging the test.
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(connectivityChannel, (call) async {
|
||||
if (call.method == 'check') return <String>['none'];
|
||||
return null;
|
||||
});
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockStreamHandler(
|
||||
connectivityStatusChannel,
|
||||
MockStreamHandler.inline(onListen: (arguments, events) {}),
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'account_migration_v1_done': true,
|
||||
'accounts_list': jsonEncode([
|
||||
const SavedAccount(
|
||||
id: accountId,
|
||||
serverUrl: 'https://server.example.com',
|
||||
username: 'alice',
|
||||
).toJson(),
|
||||
]),
|
||||
'active_account_id': accountId,
|
||||
});
|
||||
});
|
||||
|
||||
Future<void> pumpTopBar(
|
||||
WidgetTester tester, {
|
||||
required NooNavMenuStyle navMenuStyle,
|
||||
}) async {
|
||||
await tester.binding.setSurfaceSize(const Size(400, 800));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider(create: (_) => ConnectivityController()),
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => SessionController(context.read()),
|
||||
),
|
||||
ChangeNotifierProvider(create: (_) => SettingsController()),
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => FilesController(context.read()),
|
||||
),
|
||||
ChangeNotifierProvider(
|
||||
create: (context) =>
|
||||
SyncStatusController(context.read(), context.read()),
|
||||
),
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => TrashController(context.read()),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(AppTheme.defaultAccent, useDynamicColor: false),
|
||||
home: Scaffold(
|
||||
drawer: const Drawer(child: Text('drawer open')),
|
||||
appBar: AppTopBar(
|
||||
style: NooNavStyle.android,
|
||||
tab: AppTab.files,
|
||||
navMenuStyle: navMenuStyle,
|
||||
),
|
||||
body: const SizedBox(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Not `pumpAndSettle` - the same reasoning as `account_view_test.dart`:
|
||||
// the active account's dependent controllers start periodic
|
||||
// timers/futures that would spin it forever.
|
||||
for (var i = 0; i < 30; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 10));
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('avatarMenu style: no hamburger, avatar opens the dropdown', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpTopBar(tester, navMenuStyle: NooNavMenuStyle.avatarMenu);
|
||||
|
||||
expect(find.byTooltip('Menu'), findsOneWidget);
|
||||
expect(find.byIcon(LucideIcons.menu), findsNothing);
|
||||
|
||||
await tester.tap(find.byTooltip('Menu'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
// The dropdown's own header + Settings row - not the account
|
||||
// switcher's "Add account"/"Manage accounts" rows.
|
||||
expect(find.text('alice'), findsOneWidget);
|
||||
expect(find.text('Settings'), findsOneWidget);
|
||||
expect(find.text('Add account'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('drawer style (default): hamburger opens the real drawer', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpTopBar(tester, navMenuStyle: NooNavMenuStyle.drawer);
|
||||
|
||||
expect(find.byTooltip('Menu'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Menu'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('drawer open'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'drawer style (default): avatar still opens the account switcher',
|
||||
(tester) async {
|
||||
await pumpTopBar(tester, navMenuStyle: NooNavMenuStyle.drawer);
|
||||
|
||||
expect(find.byTooltip('Accounts'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Accounts'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(find.text('Add account'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user