Files
buzz/mobile/test/shared/widgets/ios_navigation_bar_test.dart
klopez4212 c8ea4fb4eb feat(mobile): polish Home navigation and Solo workspace (#8393)
## Summary
Polish mobile Home and Solo: group Search/Inbox beside a separate
profile avatar, move Inbox and Settings to bottom-up pages with Close
controls, and place tabs opposite the plus/compose action. Morph plus to
compose clockwise when entering Solo and reverse counterclockwise when
returning Home, with a directional quarter-turn of the icon inside a
level button and a subtle scale pulse, respecting reduced motion. Align
Search’s Close control with Inbox, simplify its placeholder, and
initialize selected filter contrast correctly. Collapse the plus menu
when leaving Home. Center Solo’s native-glass New chat empty state and
keep it stationary behind the keyboard.

Standardize Tabler icons and neutral backgrounds, retain SF Back/Close
on iOS, use native Liquid Glass headers, tab container, and actions
(solid grey selected tab, slightly darker on iOS) on iOS and black
actions on Android, and smooth the expanded menu corners. Repair native
header safe-area/return layout and compose movement after cancellation.

### Related issue
None found for this UI refinement; follows #8337.

### Testing
- Formatting, analysis, full mobile suite: 3,487 passed, 4 skipped; 3
push-configuration tests passed. File-size checks passed.
- Base UI built and installed on Pixel and iPhone in place. Pixel
navigation/New chat cancellation checked. Subsequent glass and rotation
refinements are previewed on iPhone; motion feel awaits human
confirmation.
- Draft pending human confirmation of the final Settings entrance/return
behavior on iPhone.

### Pixel snapshots
| Solo | Expanded actions |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/16dc0f8af971e63d788fee20974997fab925dec9/docs/screenshots/mobile-home-solo/pixel-solo.png"
width="280" alt="Solo empty state with New chat" /> | <img
src="https://raw.githubusercontent.com/block/buzz/16dc0f8af971e63d788fee20974997fab925dec9/docs/screenshots/mobile-home-solo/pixel-actions.png"
width="280" alt="Expanded channel actions with smooth corners" /> |

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-16 12:28:02 -04:00

713 lines
23 KiB
Dart

import 'dart:async';
import 'package:buzz/shared/relay/media_image.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
import 'package:buzz/shared/widgets/frosted_scaffold.dart';
import 'package:buzz/shared/widgets/ios_navigation_bar.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
void main() {
testWidgets(
'native toolbar receives stable safe inset inside a translated route',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
await tester.pumpWidget(
_testApp(
home: MediaQuery(
data: const MediaQueryData(viewPadding: EdgeInsets.only(top: 62)),
child: Transform.translate(
offset: const Offset(0, 600),
child: const SizedBox(
height: 120,
child: IosNavigationBar(title: 'Settings'),
),
),
),
),
);
expect(
tester.widget<UiKitView>(find.byType(UiKitView)).creationParams,
containsPair('safeTop', 62.0),
);
await tester.pumpWidget(const SizedBox());
debugDefaultTargetPlatformOverride = null;
},
);
testWidgets(
'native readiness waits for configuration and layout acknowledgement',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
const channel = MethodChannel('buzz/ios_navigation_bar/844');
final applied = Completer<void>();
final readiness = <bool>[];
final calls = <String>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (
call,
) async {
calls.add(call.method);
if (call.method == 'prepareForReveal') await applied.future;
return null;
});
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
null,
),
);
await tester.pumpWidget(
_testApp(
home: SizedBox(
height: 100,
child: IosNavigationBar(
title: 'Bravo',
onReadyChanged: readiness.add,
),
),
),
);
tester.widget<UiKitView>(find.byType(UiKitView)).onPlatformViewCreated!(
844,
);
await tester.pumpAndSettle();
expect(calls, contains('prepareForReveal'));
expect(readiness.last, false);
applied.complete();
await tester.pumpAndSettle();
await tester.pump();
await tester.pump();
expect(readiness.last, true);
await tester.pumpWidget(const SizedBox());
debugDefaultTargetPlatformOverride = null;
},
);
testWidgets('native landing ignores pending profile artwork', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final pending = Completer<http.Response>();
var requested = false;
final client = MockClient((_) {
requested = true;
return pending.future;
});
addTearDown(client.close);
const channel = MethodChannel('buzz/ios_navigation_bar/846');
final readiness = <bool>[];
final calls = <String>[];
final createdChannels = <MethodChannel>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform_views,
(call) async {
if (call.method == 'create') {
final id = (call.arguments as Map)['id'];
final native = MethodChannel('${IosNavigationBar.viewType}/$id');
createdChannels.add(native);
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
native,
(call) async {
calls.add(call.method);
return null;
},
);
}
return null;
},
);
addTearDown(() {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform_views,
null,
);
for (final native in createdChannels) {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
native,
null,
);
}
});
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (
call,
) async {
calls.add(call.method);
return null;
});
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
null,
),
);
await tester.pumpWidget(
ProviderScope(
overrides: [mediaHttpClientProvider.overrideWithValue(client)],
child: _testApp(
home: IosNavigationBar(
title: 'Bravo',
leading: IosNavigationAction(
label: 'Community settings',
avatarInitial: 'B',
onAvatarBoundsChanged: (_) {},
),
actions: const [
IosNavigationAction(
label: 'Profile',
avatarInitial: 'P',
imageUrl: 'https://slow.example.com/profile.png',
),
],
onReadyChanged: readiness.add,
),
),
),
);
tester.widget<UiKitView>(find.byType(UiKitView)).onPlatformViewCreated!(
846,
);
await tester.pumpAndSettle();
Map leading() =>
(tester.widget<UiKitView>(find.byType(UiKitView)).creationParams
as Map)['leading']
as Map;
await tester.runAsync(() async {
for (
var attempt = 0;
attempt < 100 && leading()['imageData'] == null;
attempt++
) {
await Future<void>.delayed(const Duration(milliseconds: 10));
await tester.pump();
}
});
await tester.pumpAndSettle();
expect(requested, isTrue);
expect(pending.isCompleted, isFalse);
expect(calls, contains('prepareForReveal'));
expect(readiness.last, isTrue);
expect(leading()['imageData'], isNotNull);
pending.complete(http.Response('offline', 503));
await tester.pumpAndSettle();
await tester.pumpWidget(const SizedBox());
debugDefaultTargetPlatformOverride = null;
});
testWidgets(
'native avatar reports global transition bounds and retains a hidden slot',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
const channel = MethodChannel('${IosNavigationBar.viewType}/842');
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
(_) async => null,
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
null,
),
);
Rect? measured;
await tester.pumpWidget(
_testApp(
home: Padding(
padding: const EdgeInsets.only(top: 30),
child: IosNavigationBar(
title: 'Alpha',
leading: IosNavigationAction(
label: 'Community settings',
avatarInitial: 'A',
avatarHidden: true,
onAvatarBoundsChanged: (bounds) => measured = bounds,
onPressed: () {},
),
),
),
),
);
final view = tester.widget<UiKitView>(find.byType(UiKitView));
final leading = (view.creationParams as Map)['leading'] as Map;
expect(leading['tracksAvatarBounds'], isTrue);
expect(leading['avatarHidden'], isTrue);
view.onPlatformViewCreated!(842);
await tester.pump();
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
channel.name,
channel.codec.encodeMethodCall(
const MethodCall('avatarBounds', {
'id': 'leading',
'x': 16.0,
'y': 4.0,
'width': 36.0,
'height': 36.0,
}),
),
(_) {},
);
expect(measured, const Rect.fromLTWH(16, 34, 36, 36));
await tester.pumpWidget(const SizedBox());
debugDefaultTargetPlatformOverride = null;
},
);
for (final reverse in [false, true]) {
testWidgets('initial material tracks content above reverse=$reverse', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final controller = ScrollController();
addTearDown(controller.dispose);
ValueListenable<double>? offset;
Widget page(int count) => _testApp(
home: FrostedScaffold(
appBar: const FrostedAppBar(title: Text('Conversation')),
body: Builder(
builder: (context) {
offset = IosNavigationScrollScope.maybeOf(context);
return ListView(
controller: controller,
reverse: reverse,
children: [
for (var i = 0; i < count; i++)
SizedBox(height: 60, child: Text('Message $i')),
],
);
},
),
),
);
await tester.pumpWidget(page(40));
await tester.pumpAndSettle();
expect(offset!.value, reverse ? greaterThan(0) : 0);
controller.jumpTo(controller.position.maxScrollExtent);
await tester.pumpAndSettle();
expect(offset!.value, reverse ? 0 : greaterThan(0));
await tester.pumpWidget(page(1));
await tester.pumpAndSettle();
expect(offset!.value, 0);
await tester.pumpWidget(const SizedBox());
debugDefaultTargetPlatformOverride = null;
});
}
testWidgets('native material follows live theme surface changes', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
const channel = MethodChannel('buzz/ios_navigation_bar/845');
final configurations = <Map<Object?, Object?>>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (
call,
) async {
if (call.method == 'configure') configurations.add(call.arguments as Map);
return null;
});
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
null,
),
);
Widget app(Color surface) => ProviderScope(
child: MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: surface,
).copyWith(surface: surface),
),
home: const Scaffold(
body: IosNavigationBar(title: 'general', subtitle: '3 members'),
),
),
);
await tester.pumpWidget(app(const Color(0xFFFFEEDD)));
final view = tester.widget<UiKitView>(find.byType(UiKitView));
expect(view.creationParams, containsPair('background', 0xFFFFEEDD));
view.onPlatformViewCreated!(845);
await tester.pump();
await tester.pumpWidget(app(const Color(0xFF223344)));
await tester.pumpAndSettle();
expect(configurations.last['background'], 0xFF223344);
await tester.pumpWidget(const SizedBox());
debugDefaultTargetPlatformOverride = null;
});
testWidgets('larger native metrics keep a deeply scrolled title collapsed', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
const channel = MethodChannel('buzz/ios_navigation_bar/843');
final calls = <MethodCall>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (
call,
) async {
calls.add(call);
return null;
});
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
null,
),
);
await tester.pumpWidget(
_testApp(
home: FrostedScaffold(
nativePinnedBody: true,
appBar: const FrostedAppBar(
title: Text('Search'),
nativeLargeTitle: true,
),
body: ListView(
children: [
for (var i = 0; i < 40; i++)
SizedBox(height: 60, child: Text('Row $i')),
],
),
),
),
);
tester.widget<UiKitView>(find.byType(UiKitView)).onPlatformViewCreated!(
843,
);
await tester.pump();
await tester.drag(find.byType(ListView), const Offset(0, -250));
await tester.pumpAndSettle();
expect(
calls.where((call) => call.method == 'scroll').last.arguments,
greaterThan(52),
);
final bodyTop = tester.getTopLeft(find.byType(ListView)).dy;
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
channel.name,
channel.codec.encodeMethodCall(
const MethodCall('metrics', {
'compactHeight': 44.0,
'expandedHeight': 124.0,
}),
),
(_) {},
);
await tester.pump();
await tester.pump();
expect(
calls.where((call) => call.method == 'scroll').last.arguments,
greaterThan(80),
);
expect(tester.getSize(find.byType(UiKitView)).height, 44);
expect(tester.getTopLeft(find.byType(ListView)).dy, bodyTop);
await tester.pumpWidget(const SizedBox());
debugDefaultTargetPlatformOverride = null;
});
testWidgets(
'iOS routes use UIKit and route native menus to current callbacks',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
const channel = MethodChannel('${IosNavigationBar.viewType}/42');
final calls = <MethodCall>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (
call,
) async {
calls.add(call);
return null;
});
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
null,
),
);
var selected = 0;
var callbackVersion = 1;
Widget page() {
final version = callbackVersion;
return _testApp(
theme: AppTheme.light(),
home: FrostedScaffold(
appBar: FrostedAppBar(
nativeTitle: 'Activity',
nativeLargeTitle: true,
nativeActions: [
IosNavigationAction(
label: 'Filter',
symbol: 'line.3.horizontal.decrease',
children: [
IosNavigationAction(
label: 'Mentions',
onPressed: () => selected = version,
),
],
),
],
),
body: ListView(
children: [
for (var i = 0; i < 40; i++)
SizedBox(height: 60, child: Text('Item $i')),
],
),
),
);
}
await tester.pumpWidget(page());
final view = tester.widget<UiKitView>(find.byType(UiKitView));
expect(view.viewType, IosNavigationBar.viewType);
expect(view.creationParams, containsPair('largeTitle', true));
view.onPlatformViewCreated!(42);
await tester.pump();
callbackVersion = 2;
await tester.pumpWidget(page());
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
channel.name,
channel.codec.encodeMethodCall(const MethodCall('action', '0.0')),
(_) {},
);
expect(selected, 2);
await tester.drag(find.byType(ListView), const Offset(0, -180));
await tester.pumpAndSettle();
expect(
calls.where((call) => call.method == 'scroll').last.arguments,
greaterThan(52),
);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
debugDefaultTargetPlatformOverride = null;
},
);
testWidgets('native back button respects the Flutter route stack', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final navigator = GlobalKey<NavigatorState>();
await tester.pumpWidget(
_testApp(
navigatorKey: navigator,
home: const Scaffold(body: Text('Root')),
),
);
navigator.currentState!.push(
MaterialPageRoute<void>(
builder: (_) => const FrostedScaffold(
appBar: FrostedAppBar(title: Text('Theme')),
body: SizedBox.expand(),
),
),
);
await tester.pumpAndSettle();
final native = tester.widget<IosNavigationBar>(
find.byType(IosNavigationBar),
);
expect(native.title, 'Theme');
expect(native.onBack, isNotNull);
native.onBack!();
await tester.pumpAndSettle();
expect(find.text('Root'), findsOneWidget);
expect(find.byType(UiKitView), findsNothing);
debugDefaultTargetPlatformOverride = null;
});
testWidgets('UIKit measurements resize the bar and page spacing together', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
const channel = MethodChannel('${IosNavigationBar.viewType}/91');
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
(_) async => null,
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
null,
),
);
await tester.pumpWidget(
_testApp(
home: Builder(
builder: (context) => FrostedScaffold(
appBar: const FrostedAppBar(
title: Text('Home'),
nativeLargeTitle: true,
),
body: ListView(
children: [
SizedBox(
key: const ValueKey('body-inset'),
height: frostedAppBarHeight(context, nativeLargeTitle: true),
),
],
),
),
),
),
);
final view = tester.widget<UiKitView>(find.byType(UiKitView));
view.onPlatformViewCreated!(91);
await tester.pump();
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
channel.name,
channel.codec.encodeMethodCall(
const MethodCall('metrics', {
'compactHeight': 56.0,
'expandedHeight': 112.0,
}),
),
(_) {},
);
await tester.pump();
expect(tester.getSize(find.byType(UiKitView)).height, 112);
expect(
tester.getSize(find.byKey(const ValueKey('body-inset'))).height,
112,
);
await tester.pumpWidget(const SizedBox());
debugDefaultTargetPlatformOverride = null;
});
testWidgets('loading avatars use initials instead of navigation symbols', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
await tester.pumpWidget(
_testApp(
home: const IosNavigationBar(
title: 'Home',
leading: IosNavigationAction(
label: 'Community',
symbol: 'building.2',
avatarInitial: 'K',
avatarIdentity: 'community-one',
),
actions: [
IosNavigationAction(
label: 'Profile',
symbol: 'person.crop.circle',
avatarInitial: 'A',
avatarIdentity: 'profile-one',
),
],
),
),
);
final params =
tester.widget<UiKitView>(find.byType(UiKitView)).creationParams! as Map;
expect((params['leading'] as Map)['symbol'], isNull);
expect((params['leading'] as Map)['avatarInitial'], 'K');
final profile = (params['actions'] as List).single as Map;
expect(profile['symbol'], isNull);
expect(profile['avatarInitial'], 'A');
await tester.pumpWidget(const SizedBox());
debugDefaultTargetPlatformOverride = null;
});
testWidgets(
'retains decoded avatars on theme refresh but isolates identities',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final messenger = tester.binding.defaultBinaryMessenger;
final channels = <MethodChannel>[];
messenger.setMockMethodCallHandler(SystemChannels.platform_views, (
call,
) async {
if (call.method == 'create') {
final id = (call.arguments as Map)['id'];
final channel = MethodChannel('${IosNavigationBar.viewType}/$id');
channels.add(channel);
messenger.setMockMethodCallHandler(channel, (_) async => null);
}
return null;
});
addTearDown(() {
messenger.setMockMethodCallHandler(SystemChannels.platform_views, null);
for (final channel in channels) {
messenger.setMockMethodCallHandler(channel, null);
}
});
Widget page(String identity, ThemeData theme) => _testApp(
theme: theme,
home: IosNavigationBar(
title: 'Home',
leading: IosNavigationAction(
label: 'Profile',
avatarInitial: 'K',
avatarIdentity: identity,
),
),
);
Map avatar() =>
(tester.widget<UiKitView>(find.byType(UiKitView)).creationParams!
as Map)['leading']
as Map;
await tester.pumpWidget(page('one', AppTheme.light()));
await tester.runAsync(() async {
for (
var attempt = 0;
attempt < 100 && avatar()['imageData'] == null;
attempt++
) {
await Future<void>.delayed(const Duration(milliseconds: 10));
await tester.pump();
}
});
final image = avatar()['imageData'];
expect(image, isNotNull);
await tester.pumpWidget(page('one', AppTheme.dark()));
expect(avatar()['imageData'], image);
await tester.pumpWidget(page('two', AppTheme.dark()));
expect(avatar()['imageData'], isNull);
expect(avatar()['symbol'], isNull);
await tester.pumpWidget(const SizedBox());
debugDefaultTargetPlatformOverride = null;
},
);
testWidgets('Android retains its Flutter header', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
await tester.pumpWidget(
_testApp(
theme: AppTheme.light(),
home: const FrostedScaffold(
appBar: FrostedAppBar(title: Text('Settings')),
body: SizedBox.expand(),
),
),
);
expect(find.byType(UiKitView), findsNothing);
expect(find.text('Settings'), findsOneWidget);
debugDefaultTargetPlatformOverride = null;
});
}
Widget _testApp({
ThemeData? theme,
required Widget home,
GlobalKey<NavigatorState>? navigatorKey,
}) => ProviderScope(
child: MaterialApp(
theme: theme,
home: home,
navigatorKey: navigatorKey,
builder: (context, child) => IosNavigationMetricsHost(child: child!),
),
);