Refine iOS navigation, media controls, and background sync
Build APK / build (push) Successful in 5m50s
Build APK / build (push) Successful in 5m50s
This commit is contained in:
+622
-240
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user