Fix bottom avatar popup account ordering on Android

This commit is contained in:
Ayushya Amitabh
2026-10-07 03:14:14 -04:00
parent ce2273c9a6
commit d1e920fed1
3 changed files with 110 additions and 20 deletions
+1 -1
View File
@@ -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
+31 -16
View File
@@ -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(
? 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<AvatarNavigationHost>
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<AvatarNavigationHost>
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<AvatarNavigationHost>
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<AvatarNavigationHost>
);
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<AvatarNavigationHost>
child: SizeChangedLayoutNotifier(
child: KeyedSubtree(
key: measureKey,
child: card(),
child: card(measurement: true),
),
),
),
@@ -643,7 +658,7 @@ class _AvatarNavigationHostState extends State<AvatarNavigationHost>
),
),
),
if (!attached)
if (!attached && progress < 1)
Positioned.fromRect(
rect: avatar,
child: Semantics(
+75
View File
@@ -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 {