Files
buzz/mobile/test/shared/widgets/ios_navigation_bar_test.dart
klopez4212 d2da104d94 feat(mobile): add animated community switcher (#8076)
## Summary

Replace the community switch sheet with a two-column picker. Selection
enlarges the avatar, waits with the voice-note loader for destination
channel content and layout to settle, then follows a curved path into
the header.

Cache raster icons at a maximum 512-pixel size and accept only
resource-free emoji SVGs. Cancel timed-out or disposed downloads without
delaying content readiness. Animate Edit badges in place, and open Add
Community on the pairing welcome page. Community removal lives only in
the switcher’s Edit mode. Keep the flow shared across Android/iOS, with
native iOS controls and removal confirmation, and clear navigation
backgrounds at rest.

### Related issue

Related to #8070 (community icon refresh/caching).

### Testing

- 122 focused Flutter regression tests and seven native iOS tests passed
after merging the latest navigation changes from `main`. Earlier feature
build installed and launched on a physical iPhone.
- Full mobile pre-push checks passed; regression coverage includes
gradient rendering, rejected/oversized icons, bounded persisted raster
decoding, SVG resource rejection and cache migration, timeout/disposal
cancellation, disconnected destination channels, degraded unread-history
settlement, and switching while icons refresh. Full `just ci` stopped
during Rust checks for low disk space. Hosted CI remains required.
- [Grid and Edit
previews](https://github.com/block/buzz/pull/8076#issuecomment-5977838957).

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-04 14:11:39 +01:00

676 lines
22 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 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, 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, 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, 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!),
),
);