Add avatar-menu navigation style and inline Android search bar

- New Settings > Appearance > "Navigation menu" toggle
  (SettingsController.navMenuStyle, NooNavMenuStyle.drawer/avatarMenu):
  the hamburger-opens-a-Drawer pattern stays the default, or the avatar
  button opens a full-width dropdown (showAvatarMenu) instead, holding
  the same hidden-tabs + Settings content the drawer does. Removes one
  of the two top-corner targets the shell asks a thumb to reach, with
  no permanent chrome added.
- NooTopBar gains androidTitleReplacement so Android's top bar can swap
  its title for an inline search-field-styled launcher
  (ShellSearchLauncher) when search isn't in the bottom bar, instead of
  a bare search icon beside it.
- ShellAvatarButton's onTap/label are now overridable so AppTopBar can
  repoint it per the new setting without this file depending on
  avatar_menu.dart (which already depends on it for shared helpers).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 22:25:04 -04:00
co-authored by Claude Sonnet 5
parent 840ec4c301
commit e6d96a6f9f
11 changed files with 550 additions and 25 deletions
+32 -11
View File
@@ -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,
);
+182
View File
@@ -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,
),
],
),
),
),
),
),
),
);
}
}
+14
View File
@@ -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
+18 -6
View File
@@ -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,12 +189,14 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
children: [
?lead,
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.title.copyWith(height: 1, color: colors.fg1),
),
child:
androidTitleReplacement ??
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.title.copyWith(height: 1, color: colors.fg1),
),
),
...actions,
],
@@ -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.
+25 -7
View File
@@ -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(