Refine iOS navigation, media controls, and background sync
Build APK / build (push) Successful in 5m50s

This commit is contained in:
Ayushya Amitabh
2026-10-06 23:18:50 -04:00
parent c5b46fd493
commit ce2273c9a6
39 changed files with 2309 additions and 647 deletions
+622 -240
View File
@@ -1,3 +1,4 @@
import 'dart:ui' show ImageFilter, lerpDouble;
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart';
@@ -6,53 +7,83 @@ 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 '../views/login_view.dart';
import 'noo/core/noo_avatar.dart';
import 'noo/core/noo_badge.dart';
import 'noo/nav/noo_bottom_bar.dart';
import 'noo/noo_layout.dart';
import 'noo/lists/noo_settings_row.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) {
return showGeneralDialog<void>(
/// The shell supplies the expanding-card presentation. Standalone top bars
/// retain a dialog fallback (for screens outside the shell).
Future<void> showAvatarMenu(BuildContext context) async {
final scope = AvatarNavigationScope.maybeOf(context);
if (scope != null) {
scope._host.openFrom(context);
return;
}
await showGeneralDialog<void>(
context: context,
barrierColor: Colors.transparent,
barrierDismissible: true,
barrierLabel: 'Close menu',
transitionDuration: NooMotion.fast,
pageBuilder: (context, _, _) => const _AvatarMenuContent(),
transitionDuration: NooMotion.base,
pageBuilder: (context, _, _) => Align(
alignment: Alignment.topCenter,
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
child: _StandaloneAvatarCard(),
),
),
),
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)),
scale: Tween(begin: .88, end: 1.0).animate(
CurvedAnimation(parent: animation, curve: Curves.easeOutCubic),
),
child: child,
),
),
);
}
class _AvatarMenuContent extends StatefulWidget {
const _AvatarMenuContent();
class _StandaloneAvatarCard extends StatefulWidget {
@override
State<_AvatarMenuContent> createState() => _AvatarMenuContentState();
State<_StandaloneAvatarCard> createState() => _StandaloneAvatarCardState();
}
class _AvatarMenuContentState extends State<_AvatarMenuContent> {
bool _expanded = false;
class _StandaloneAvatarCardState extends State<_StandaloneAvatarCard> {
bool expanded = false;
@override
Widget build(BuildContext context) => AvatarMenuCard(
expanded: expanded,
onExpand: () => setState(() => expanded = !expanded),
onClose: () => Navigator.pop(context),
);
}
/// Account controls and hidden-tab navigation, shared by both card positions.
class AvatarMenuCard extends StatelessWidget {
final bool bottom;
final bool expanded;
final bool movingAvatar;
final bool showHeaderAvatar;
final VoidCallback onExpand;
final VoidCallback onClose;
const AvatarMenuCard({
super.key,
this.bottom = false,
this.movingAvatar = false,
this.showHeaderAvatar = true,
required this.expanded,
required this.onExpand,
required this.onClose,
});
@override
Widget build(BuildContext context) {
@@ -60,226 +91,310 @@ class _AvatarMenuContentState extends State<_AvatarMenuContent> {
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();
void closeAndOpenSettings() {
Navigator.pop(context);
void settingsTap() {
onClose();
openSettings(context);
}
return Align(
alignment: Alignment.topCenter,
// Just the status-bar inset, not the top bar's own height on top of
// it - the card covers the top bar (title included) rather than
// sitting below it, so opening it reads as the avatar growing into
// this instead of a separate element appearing underneath the row
// it came from.
child: SafeArea(
bottom: false,
child: Padding(
// Same side inset as the Files list's rows (`NooSpace.sm`), so the
// card is as wide as the list beneath it.
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
child: SizedBox(
width: double.infinity,
child: Container(
// No `color`/`clipBehavior` here - a `Container` with both a
// `border` and `clipBehavior` set paints the border as part
// of its *outer* decoration, then paints its (clipped) child
// on top right up to that same boundary with no gap for the
// border's own stroke width to show through. An opaque child
// touching that edge - every row below has its own full-bleed
// `Material` fill - then paints straight over the inner half
// of the border, which read as the border going missing
// specifically wherever an opaque row sits (every corner but
// the two by the header, which has no opaque fill of its
// own). The 1px `Padding` + inset `ClipRRect` below keeps the
// clipped, filled content entirely inside the border's own
// stroke instead of racing it for the same pixels.
decoration: BoxDecoration(
border: Border.all(color: colors.line),
borderRadius: BorderRadius.circular(NooRadii.card),
// `nooDialogShadow` alone is a wide, soft, fairly faint
// shadow - built for a desktop dialog with plenty of room
// to fall off into. On a small card over a dark theme's
// near-black `bg`, that falloff is too gradual to read as
// elevation at all (a dark shadow needs real density close
// to the edge to be visible against an already-dark
// backdrop). A second, tighter, more opaque contact shadow
// underneath it gives an immediate value-step right at the
// card's edge in both themes, with the soft one still
// doing the wider ambient falloff on top.
boxShadow: const [
BoxShadow(
color: Color(0x40000000),
blurRadius: 12,
offset: Offset(0, 4),
),
nooDialogShadow,
],
),
child: Padding(
padding: const EdgeInsets.all(1),
child: ClipRRect(
borderRadius: BorderRadius.circular(NooRadii.card - 1),
child: Material(
color: colors.surface,
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: () => setState(() => _expanded = !_expanded),
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,
),
const SizedBox(width: 8),
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colors.fg1.withValues(alpha: 0.08),
),
child: Icon(
_expanded
? LucideIcons.chevronUp
: LucideIcons.chevronDown,
size: 20,
color: colors.fg2,
),
),
],
),
),
),
AnimatedSize(
duration: NooMotion.fast,
curve: NooMotion.ease,
alignment: Alignment.topCenter,
child: _expanded
? Column(
children: [
for (final account in session.accounts)
if (account.id != session.activeAccountId)
_OtherAccountRow(
name: account.username,
host: serverHost(account.serverUrl),
onTap: () {
Navigator.pop(context);
session.switchAccount(account.id);
},
),
Padding(
padding: const EdgeInsets.fromLTRB(
NooSpace.md,
0,
NooSpace.md,
NooSpace.md,
),
child: Row(
spacing: 8,
children: [
Expanded(
child: _AccountButton(
icon: LucideIcons.userPlus,
label: 'Add Account',
onTap: () {
final nav = Navigator.of(
context,
);
nav.pop();
nav.push(
MaterialPageRoute(
builder: (_) =>
const LoginView(
isAddingAccount: true,
),
),
);
},
),
),
Expanded(
child: _AccountButton(
icon: LucideIcons.users,
label: 'Manage Accounts',
onTap: closeAndOpenSettings,
),
),
],
),
),
],
)
: const SizedBox(width: double.infinity),
),
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,
),
],
final header = InkWell(
onTap: onExpand,
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),
),
],
),
),
Icon(
expanded ? LucideIcons.chevronUp : LucideIcons.chevronDown,
size: 20,
color: colors.fg2,
),
if (showHeaderAvatar) const SizedBox(width: 12),
if (showHeaderAvatar)
movingAvatar
? const SizedBox.square(dimension: 32)
: NooAvatar(
initials: accountInitial(session.username),
current: true,
size: 32,
),
],
),
),
);
final attached = bottom && !showHeaderAvatar;
final accountSection = AnimatedSize(
duration: NooMotion.base,
curve: Curves.easeOutCubic,
alignment: bottom ? Alignment.bottomCenter : Alignment.topCenter,
child: expanded
? Column(
children: [
for (final account in session.accounts)
if (account.id != session.activeAccountId)
_OtherAccountRow(
name: account.username,
host: serverHost(account.serverUrl),
onTap: () {
onClose();
session.switchAccount(account.id);
},
),
Padding(
padding: const EdgeInsets.all(NooSpace.md),
child: Row(
spacing: 8,
children: [
Expanded(
child: _AccountButton(
icon: LucideIcons.userPlus,
label: 'Add Account',
onTap: () {
final nav = Navigator.of(context);
onClose();
nav.push(
MaterialPageRoute(
builder: (_) =>
const LoginView(isAddingAccount: true),
),
);
},
),
),
Expanded(
child: _AccountButton(
icon: LucideIcons.users,
label: 'Manage Accounts',
onTap: settingsTap,
),
),
],
),
),
],
)
: const SizedBox(width: double.infinity),
);
final rows = Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!attached) accountSection,
for (final tab in settings.tabOrder.where(settings.hiddenTabs.contains))
NooSettingsRow(
backgroundColor: Colors.transparent,
icon: tab.icon,
label: Text(tab.label),
trailing: tab == AppTab.trash && trashCount > 0
? NooBadge(
tone: NooBadgeTone.accent,
child: Text('$trashCount'),
)
: null,
onTap: () {
onClose();
settings.requestTab(tab);
},
),
Divider(height: 1, color: colors.line),
NooSettingsRow(
backgroundColor: Colors.transparent,
icon: LucideIcons.settings,
label: const Text('Settings'),
onTap: settingsTap,
),
if (attached) ...[header, accountSection],
],
);
final content = SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.all(1),
child: ClipRRect(
borderRadius: BorderRadius.circular(NooRadii.card - 1),
child: Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!bottom) header,
Flexible(
fit: FlexFit.loose,
child: SingleChildScrollView(child: rows),
),
if (bottom && !attached) header,
],
),
),
),
),
);
return movingAvatar
? content
: _AvatarSurface(
radius: BorderRadius.circular(NooRadii.card),
child: content,
);
}
}
class _AvatarSurface extends StatelessWidget {
final BorderRadius radius;
final Widget child;
final bool connected;
const _AvatarSurface({
required this.radius,
required this.child,
this.connected = false,
});
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsController>();
final colors = context.nooColors;
final frosted = settings.bottomBarFrosted;
final surface = DecoratedBox(
decoration: BoxDecoration(
color: frosted
? colors.surface.withValues(alpha: settings.bottomBarFrostedOpacity)
: colors.surface,
border: connected
? null
: Border.all(color: colors.line),
borderRadius: radius,
),
child: Padding(padding: const EdgeInsets.all(1), child: child),
);
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: radius,
boxShadow: frosted || connected ? null : const [nooDialogShadow],
),
child: ClipRRect(
borderRadius: radius,
child: frosted
? BackdropFilter(
filter: ImageFilter.blur(
sigmaX: settings.bottomBarFrostedBlur,
sigmaY: settings.bottomBarFrostedBlur,
),
child: surface,
)
: surface,
),
);
}
}
class AvatarNavigationScope extends InheritedNotifier<AnimationController> {
final _AvatarNavigationHostState _host;
AvatarNavigationScope._({
required _AvatarNavigationHostState host,
required super.child,
}) : _host = host,
super(notifier: host.animation);
static AvatarNavigationScope? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<AvatarNavigationScope>();
double get progress =>
Curves.easeInOutCubicEmphasized.transform(_host.animation.value);
bool get bottom => _host.bottom;
void close() => _host.close();
@override
bool updateShouldNotify(AvatarNavigationScope oldWidget) => true;
}
/// Keeps the top avatar's layout slot while the single animated avatar moves
/// into the card. Its own context gives us the real screen-space origin.
class AvatarNavigationAnchor extends StatelessWidget {
final Widget child;
const AvatarNavigationAnchor({super.key, required this.child});
@override
Widget build(BuildContext context) {
final scope = AvatarNavigationScope.maybeOf(context);
return IgnorePointer(
ignoring: scope != null && scope.progress > 0,
child: ClipRect(
child: Align(
alignment: Alignment.centerRight,
widthFactor: 1 - (scope?.progress ?? 0),
child: Opacity(
opacity: scope != null && scope.progress > 0 ? 0 : 1,
child: child,
),
),
),
);
}
}
/// Only the content pane moves; the bottom navigation stays anchored.
class AvatarNavigationBody extends StatelessWidget {
final Widget child;
const AvatarNavigationBody({super.key, required this.child});
@override
Widget build(BuildContext context) {
final scope = AvatarNavigationScope.maybeOf(context);
return Padding(
padding: EdgeInsets.only(
top: scope == null || scope.bottom
? 0
: (scope._host.cardHeight + 12) * scope.progress,
),
child: child,
);
}
}
class AvatarNavigationSatellite extends StatelessWidget {
final double size;
const AvatarNavigationSatellite({super.key, required this.size});
@override
Widget build(BuildContext context) {
final scope = AvatarNavigationScope.maybeOf(context);
if (context.watch<SettingsController>().bottomBarStyle ==
NooBottomBarStyle.attached) {
return Builder(
builder: (anchorContext) => ShellAvatarButton(
hitBox: size,
label: 'Menu',
onTap: () => showAvatarMenu(anchorContext),
),
);
}
return IgnorePointer(
ignoring: scope != null && scope.progress > 0,
child: Opacity(
opacity: scope != null && scope.progress > 0 ? 0 : 1,
child: SizedBox(
width: size,
height: size,
child: _AvatarSurface(
radius: BorderRadius.circular(size / 2),
child: Builder(
builder: (anchorContext) => ShellAvatarButton(
hitBox: size,
label: 'Menu',
onTap: () => showAvatarMenu(anchorContext),
),
),
),
@@ -289,6 +404,273 @@ class _AvatarMenuContentState extends State<_AvatarMenuContent> {
}
}
class AvatarNavigationHost extends StatefulWidget {
final Widget child;
const AvatarNavigationHost({super.key, required this.child});
@override
State<AvatarNavigationHost> createState() => _AvatarNavigationHostState();
}
class _AvatarNavigationHostState extends State<AvatarNavigationHost>
with SingleTickerProviderStateMixin {
late final AnimationController animation = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 420),
reverseDuration: const Duration(milliseconds: 320),
);
final GlobalKey measureKey = GlobalKey();
Rect? origin;
bool bottom = false;
bool expanded = false;
double cardHeight = 150;
bool measuring = false;
void measure() {
if (measuring) return;
measuring = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
measuring = false;
if (!mounted) return;
final box = measureKey.currentContext?.findRenderObject() as RenderBox?;
if (box != null && (box.size.height - cardHeight).abs() > .5) {
setState(() => cardHeight = box.size.height);
}
});
}
void openFrom(BuildContext context) {
if (animation.value > 0) {
close();
return;
}
final settings = context.read<SettingsController>();
final box = context.findRenderObject() as RenderBox?;
if (box == null) return;
final hostBox = this.context.findRenderObject() as RenderBox;
final center = hostBox.globalToLocal(
box.localToGlobal(box.size.center(Offset.zero)),
);
setState(() {
bottom = settings.avatarPosition == AvatarPosition.bottom;
final diameter = bottom
? NooBottomBar.rowHeight(
NooLayout.navStyle(context),
settings.bottomBarStyle,
)
: 32.0;
origin = Rect.fromCenter(
center: center,
width: diameter,
height: diameter,
);
expanded = false;
});
animation.duration = MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 420);
animation.forward();
}
void close() {
animation.reverseDuration = MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 320);
animation.reverse();
}
@override
void dispose() {
animation.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsController>();
final session = context.watch<SessionController>();
final enabled = settings.navMenuStyle == NooNavMenuStyle.avatarMenu;
return AvatarNavigationScope._(
host: this,
child: AnimatedBuilder(
animation: animation,
builder: (context, _) {
final progress = Curves.easeInOutCubicEmphasized.transform(
animation.value,
);
return PopScope(
canPop: animation.value == 0,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) close();
},
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final height = constraints.maxHeight;
final safeTop = MediaQuery.viewPaddingOf(context).top;
final attached =
bottom &&
settings.bottomBarStyle == NooBottomBarStyle.attached;
final bottomEdge = bottom && origin != null
? origin!.center.dy -
NooBottomBar.rowHeight(
NooLayout.navStyle(context),
settings.bottomBarStyle,
) /
2 -
(attached ? 0 : 12)
: height;
final maxHeight = (bottomEdge - safeTop - 24).clamp(
72.0,
height * .8,
);
measure();
Widget card() => ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight),
child: AvatarMenuCard(
bottom: bottom,
movingAvatar: true,
showHeaderAvatar: !attached,
expanded: expanded,
onExpand: () => setState(() => expanded = !expanded),
onClose: close,
),
);
final target = Rect.fromLTWH(
attached ? 0 : 12,
bottom ? bottomEdge - cardHeight : safeTop + 8,
attached ? width : width - 24,
cardHeight,
);
final rect = Rect.lerp(
attached
? Rect.fromLTWH(0, bottomEdge, width, 0)
: origin ?? target,
target,
progress,
)!;
final radius = attached
? BorderRadius.vertical(
top: Radius.circular(NooRadii.card * progress),
)
: BorderRadius.circular(
lerpDouble(
(origin?.width ?? 32) / 2,
NooRadii.card,
progress,
)!,
);
final avatarTarget = Rect.fromLTWH(
target.right - 49,
bottom ? target.bottom - 49 : target.top + 17,
32,
32,
);
final avatar = Rect.lerp(
origin == null
? avatarTarget
: Rect.fromCenter(
center: origin!.center,
width: 32,
height: 32,
),
avatarTarget,
progress,
)!;
return Stack(
children: [
widget.child,
if (enabled)
Positioned(
left: attached ? 0 : 12,
right: attached ? 0 : 12,
top: safeTop,
child: Offstage(
child:
NotificationListener<
SizeChangedLayoutNotification
>(
onNotification: (_) {
measure();
return false;
},
child: SizeChangedLayoutNotifier(
child: KeyedSubtree(
key: measureKey,
child: card(),
),
),
),
),
),
if (enabled && progress > 0 && origin != null) ...[
Positioned.fill(
bottom: bottom ? height - bottomEdge : 0,
child: Semantics(
label: 'Close menu',
button: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: close,
child: const ColoredBox(color: Colors.transparent),
),
),
),
Positioned.fromRect(
rect: rect,
child: _AvatarSurface(
radius: radius,
connected: attached,
child: ClipRRect(
borderRadius: radius,
child: OverflowBox(
alignment: bottom
? Alignment.bottomRight
: Alignment.topRight,
minWidth: target.width,
maxWidth: target.width,
minHeight: target.height,
maxHeight: target.height,
child: IgnorePointer(
ignoring: progress < .95,
child: Opacity(
opacity: ((progress - .3) / .7).clamp(
0.0,
1.0,
),
child: card(),
),
),
),
),
),
),
if (!attached)
Positioned.fromRect(
rect: avatar,
child: Semantics(
label: 'Close menu',
button: true,
child: GestureDetector(
onTap: close,
child: NooAvatar(
initials: accountInitial(session.username),
current: true,
size: 32,
),
),
),
),
],
],
);
},
),
);
},
),
);
}
}
class _OtherAccountRow extends StatelessWidget {
final String name;
final String host;
@@ -333,7 +715,7 @@ class _OtherAccountRow extends StatelessWidget {
),
),
const SizedBox(width: 12),
NooAvatar(initials: accountInitial(name), current: false, size: 40),
NooAvatar(initials: accountInitial(name), current: false, size: 32),
],
),
),