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 '../views/login_view.dart'; import 'noo/core/noo_badge.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 showAvatarMenu(BuildContext context) { return showGeneralDialog( context: context, barrierColor: Colors.transparent, barrierDismissible: true, barrierLabel: 'Close menu', transitionDuration: NooMotion.fast, pageBuilder: (context, _, _) => const _AvatarMenuContent(), transitionBuilder: (context, animation, _, child) => FadeTransition( opacity: animation, child: ScaleTransition( alignment: Alignment.topRight, scale: Tween( begin: 0.96, end: 1, ).animate(CurvedAnimation(parent: animation, curve: NooMotion.ease)), child: child, ), ), ); } class _AvatarMenuContent extends StatefulWidget { const _AvatarMenuContent(); @override State<_AvatarMenuContent> createState() => _AvatarMenuContentState(); } class _AvatarMenuContentState extends State<_AvatarMenuContent> { bool _expanded = false; @override Widget build(BuildContext context) { final colors = context.nooColors; final session = context.watch(); final settings = context.watch(); final trashCount = context.watch().items.length; final hiddenTabs = settings.tabOrder .where((t) => settings.hiddenTabs.contains(t)) .toList(); void closeAndOpenSettings() { Navigator.pop(context); 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( padding: const EdgeInsets.symmetric(horizontal: NooSpace.md), 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, ), ], ), ), ), ), ), ), ), ), ); } } class _OtherAccountRow extends StatelessWidget { final String name; final String host; final VoidCallback onTap; const _OtherAccountRow({ required this.name, required this.host, required this.onTap, }); @override Widget build(BuildContext context) { final colors = context.nooColors; return InkWell( onTap: onTap, child: Padding( padding: const EdgeInsets.all(NooSpace.md), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( name, maxLines: 1, overflow: TextOverflow.ellipsis, style: NooText.bodyL.copyWith( fontWeight: FontWeight.w600, color: colors.fg1, ), ), const SizedBox(height: 2), Text( host, maxLines: 1, overflow: TextOverflow.ellipsis, style: NooText.meta.copyWith(color: colors.fg3), ), ], ), ), const SizedBox(width: 12), NooAvatar(initials: accountInitial(name), current: false, size: 40), const SizedBox(width: 44), ], ), ), ); } } class _AccountButton extends StatelessWidget { final IconData icon; final String label; final VoidCallback onTap; const _AccountButton({ required this.icon, required this.label, required this.onTap, }); @override Widget build(BuildContext context) { final colors = context.nooColors; final radius = BorderRadius.circular(NooRadii.input); return Material( color: colors.bg, borderRadius: radius, child: InkWell( borderRadius: radius, onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8), child: Row( mainAxisAlignment: MainAxisAlignment.center, spacing: 6, children: [ Icon(icon, size: 18, color: colors.fg2), Flexible( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: NooText.body.copyWith(color: colors.fg1), ), ), ], ), ), ), ); } }