From f6407c447ecc4fa9e88148c7a25cf68a8bc70bc4 Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Wed, 7 Oct 2026 03:14:14 -0400 Subject: [PATCH] Fix bottom avatar popup account ordering on Android --- .claude/context/styling.md | 2 +- lib/widgets/avatar_menu.dart | 53 +++++++++++++-------- test/widgets/avatar_menu_test.dart | 75 ++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 20 deletions(-) diff --git a/.claude/context/styling.md b/.claude/context/styling.md index 4a45074..1ce3ac2 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -311,7 +311,7 @@ blocks are noted where they matter: full screen width, rounded top corners and no gap above the bar; the avatar stays in the bar, and Search keeps its inline position. The attached popup has no outer border or shadow, blending into the bar. - Attached popup order is navigation (including Settings), current account, + All bottom popups order navigation (including Settings), current account, then expanded secondary accounts and Add/Manage Account controls. The iOS shell places Upload and the top avatar on the large-title row. Upload uses the shared `ui_fab_style` preference: Auto expands on Files diff --git a/lib/widgets/avatar_menu.dart b/lib/widgets/avatar_menu.dart index b7d00c2..5778eb8 100644 --- a/lib/widgets/avatar_menu.dart +++ b/lib/widgets/avatar_menu.dart @@ -73,6 +73,7 @@ class AvatarMenuCard extends StatelessWidget { final bool expanded; final bool movingAvatar; final bool showHeaderAvatar; + final Key? headerAvatarKey; final VoidCallback onExpand; final VoidCallback onClose; const AvatarMenuCard({ @@ -80,6 +81,7 @@ class AvatarMenuCard extends StatelessWidget { this.bottom = false, this.movingAvatar = false, this.showHeaderAvatar = true, + this.headerAvatarKey, required this.expanded, required this.onExpand, required this.onClose, @@ -133,17 +135,20 @@ class AvatarMenuCard extends StatelessWidget { if (showHeaderAvatar) const SizedBox(width: 12), if (showHeaderAvatar) movingAvatar - ? const SizedBox.square(dimension: 32) - : NooAvatar( - initials: accountInitial(session.username), - current: true, - size: 32, + ? SizedBox.square(key: headerAvatarKey, dimension: 32) + : GestureDetector( + onTap: onClose, + child: NooAvatar( + key: headerAvatarKey, + initials: accountInitial(session.username), + current: true, + size: 32, + ), ), ], ), ), ); - final attached = bottom && !showHeaderAvatar; final accountSection = AnimatedSize( duration: NooMotion.base, curve: Curves.easeOutCubic, @@ -199,7 +204,7 @@ class AvatarMenuCard extends StatelessWidget { final rows = Column( mainAxisSize: MainAxisSize.min, children: [ - if (!attached) accountSection, + if (!bottom) accountSection, for (final tab in settings.tabOrder.where(settings.hiddenTabs.contains)) NooSettingsRow( backgroundColor: Colors.transparent, @@ -223,7 +228,7 @@ class AvatarMenuCard extends StatelessWidget { label: const Text('Settings'), onTap: settingsTap, ), - if (attached) ...[header, accountSection], + if (bottom) ...[header, accountSection], ], ); final content = SizedBox( @@ -242,7 +247,6 @@ class AvatarMenuCard extends StatelessWidget { fit: FlexFit.loose, child: SingleChildScrollView(child: rows), ), - if (bottom && !attached) header, ], ), ), @@ -278,9 +282,7 @@ class _AvatarSurface extends StatelessWidget { color: frosted ? colors.surface.withValues(alpha: settings.bottomBarFrostedOpacity) : colors.surface, - border: connected - ? null - : Border.all(color: colors.line), + border: connected ? null : Border.all(color: colors.line), borderRadius: radius, ), child: Padding(padding: const EdgeInsets.all(1), child: child), @@ -419,6 +421,8 @@ class _AvatarNavigationHostState extends State reverseDuration: const Duration(milliseconds: 320), ); final GlobalKey measureKey = GlobalKey(); + final GlobalKey measureAvatarKey = GlobalKey(); + Offset avatarOffset = const Offset(0, 17); Rect? origin; bool bottom = false; bool expanded = false; @@ -431,8 +435,18 @@ class _AvatarNavigationHostState extends State 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); + final avatarBox = + measureAvatarKey.currentContext?.findRenderObject() as RenderBox?; + final offset = box != null && avatarBox != null + ? avatarBox.localToGlobal(Offset.zero, ancestor: box) + : avatarOffset; + if (box != null && + ((box.size.height - cardHeight).abs() > .5 || + (offset - avatarOffset).distance > .5)) { + setState(() { + cardHeight = box.size.height; + avatarOffset = offset; + }); } }); } @@ -523,11 +537,12 @@ class _AvatarNavigationHostState extends State height * .8, ); measure(); - Widget card() => ConstrainedBox( + Widget card({bool measurement = false}) => ConstrainedBox( constraints: BoxConstraints(maxHeight: maxHeight), child: AvatarMenuCard( bottom: bottom, - movingAvatar: true, + movingAvatar: measurement || progress < 1, + headerAvatarKey: measurement ? measureAvatarKey : null, showHeaderAvatar: !attached, expanded: expanded, onExpand: () => setState(() => expanded = !expanded), @@ -560,7 +575,7 @@ class _AvatarNavigationHostState extends State ); final avatarTarget = Rect.fromLTWH( target.right - 49, - bottom ? target.bottom - 49 : target.top + 17, + target.top + avatarOffset.dy, 32, 32, ); @@ -595,7 +610,7 @@ class _AvatarNavigationHostState extends State child: SizeChangedLayoutNotifier( child: KeyedSubtree( key: measureKey, - child: card(), + child: card(measurement: true), ), ), ), @@ -643,7 +658,7 @@ class _AvatarNavigationHostState extends State ), ), ), - if (!attached) + if (!attached && progress < 1) Positioned.fromRect( rect: avatar, child: Semantics( diff --git a/test/widgets/avatar_menu_test.dart b/test/widgets/avatar_menu_test.dart index 6223df9..41c296b 100644 --- a/test/widgets/avatar_menu_test.dart +++ b/test/widgets/avatar_menu_test.dart @@ -550,6 +550,81 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('Android floating popup orders navigation before accounts', ( + tester, + ) async { + final prefs = await SharedPreferences.getInstance(); + final accounts = jsonDecode(prefs.getString('accounts_list')!) as List; + accounts.add( + const SavedAccount( + id: 'other_example_org__bob', + serverUrl: 'https://other.example.org', + username: 'bob', + ).toJson(), + ); + await prefs.setString('accounts_list', jsonEncode(accounts)); + await pumpTopBar( + tester, + navMenuStyle: NooNavMenuStyle.avatarMenu, + withHost: true, + position: AvatarPosition.bottom, + search: true, + style: NooNavStyle.android, + barStyle: NooBottomBarStyle.floating, + ); + final origin = tester.getCenter(find.byTooltip('Menu')); + expect( + find.descendant( + of: find.byType(BottomNavBar), + matching: find.text('Files'), + ), + findsOneWidget, + ); + expect(find.text('Photos'), findsNothing); + await tester.tap(find.byTooltip('Menu')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect( + tester.getBottomLeft(find.byType(AvatarMenuCard)).dy, + origin.dy - 32 - 12 - 1, + ); + expect(tester.getSize(find.byType(AvatarMenuCard)).width, 376); + expect(find.bySemanticsLabel('Search'), findsOneWidget); + await tester.tap(find.text('alice')); + await tester.pump(); + for (var i = 0; i < 8; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + expect( + tester.getCenter(find.text('Settings')).dy, + lessThan(tester.getCenter(find.text('alice')).dy), + ); + expect( + tester.getCenter(find.text('alice')).dy, + lessThan(tester.getCenter(find.text('bob')).dy), + ); + expect( + tester.getCenter(find.text('bob')).dy, + lessThan(tester.getCenter(find.text('Add Account')).dy), + ); + final currentAvatar = find + .descendant( + of: find.byType(AvatarMenuCard), + matching: find.byType(NooAvatar), + ) + .first; + expect( + tester.getCenter(currentAvatar).dy, + lessThan(tester.getCenter(find.text('bob')).dy), + ); + await tester.tap(currentAvatar); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byType(AvatarMenuCard), findsNothing); + expect(tester.getCenter(find.byTooltip('Menu')), origin); + expect(tester.takeException(), isNull); + }); + testWidgets('expanding accounts also grows the top content offset', ( tester, ) async {