mirror of
https://github.com/block/buzz.git
synced 2026-11-14 13:21:02 +00:00
On iOS, `FrostedAppBar` built its native `IosNavigationBar` inside the `ValueListenableBuilder` for the page's scroll offset, so every scrolled frame rebuilt the bar. Each rebuild `jsonEncode`s the whole native payload to see whether it changed, and that payload carries the header's avatars: base64 image data, and the data URLs of people whose profile picture is one (100 KB or more each). The bar already follows the offset itself, through its own listener that sends `scroll` to UIKit, so none of that work was needed. The bar is now built once per `FrostedAppBar` build, outside the offset builder. Scrolling still rebuilds the positioned frame around it (its height follows the large title), but passes the same bar widget, so Flutter skips it. ### Validation New test: scrolling a `FrostedScaffold` page leaves the same `IosNavigationBar` instance in the tree. It fails without this change. Full mobile test suite, `flutter analyze` and the file-size check pass. On the iPhone 12 mini (profile build, Builderlab), opening a busy channel whose header shows member avatars, and scrolling history with 10 swipes (about 30 seconds): | | `IosNavigationBar.build` (CPU samples) | BUILD total | LAYOUT total | UI frame time total | | --- | --- | --- | --- | --- | | Before | 1,581 ms | 3,304 ms | 3,514 ms | 7,013 ms | | After | 0 ms (no samples) | 591 ms | 748 ms | 4,718 ms | The remaining long frames (up to 130 ms) are older history loading in, and are the same before and after. The automated run covered a cold start, search, opening the channel from a search result, and scrolling its history. ### Regressions None expected. The bar's inputs don't depend on the offset; only the frame's height and animation duration do, and those still update per frame. Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
828 lines
27 KiB
Dart
828 lines
27 KiB
Dart
import 'package:buzz/shared/widgets/dm_header_portrait.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(
|
|
'animated DM portrait opens the same settings as the native title',
|
|
(tester) async {
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
|
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
|
var settingsOpened = 0;
|
|
await tester.pumpWidget(
|
|
_testApp(
|
|
home: MediaQuery(
|
|
data: const MediaQueryData(
|
|
viewPadding: EdgeInsets.only(top: 62),
|
|
disableAnimations: true,
|
|
),
|
|
child: SizedBox(
|
|
width: 393,
|
|
height: 166,
|
|
child: IosNavigationBar(
|
|
title: 'Agent',
|
|
onTitlePressed: () => settingsOpened++,
|
|
subtitle: 'Online',
|
|
titleAvatars: const [
|
|
IosNavigationAction(
|
|
label: 'Agent',
|
|
avatarInitial: 'A',
|
|
avatarIsAgent: true,
|
|
imageUrl:
|
|
'https://dwwgwmfqqjotj.cloudfront.net/avatars/20260925T154625166Z/poster/gloopies/gloopies-13.png',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
final params =
|
|
tester.widget<UiKitView>(find.byType(UiKitView)).creationParams!
|
|
as Map;
|
|
expect((params['titleAvatars'] as List).length, 1);
|
|
expect(params['titleHasAnimatedPortrait'], isTrue);
|
|
final portrait = find.byType(DmHeaderPortrait);
|
|
expect(tester.getSize(portrait), const Size(64, 64));
|
|
expect(
|
|
tester.getTopLeft(portrait).dy -
|
|
tester.getTopLeft(find.byType(UiKitView)).dy,
|
|
58,
|
|
);
|
|
await tester.tap(
|
|
find.byKey(const ValueKey('navigation-header-avatar-settings')),
|
|
);
|
|
expect(settingsOpened, 1);
|
|
await tester.pumpWidget(
|
|
_testApp(
|
|
home: const MediaQuery(
|
|
data: MediaQueryData(),
|
|
child: SizedBox(
|
|
width: 951,
|
|
height: 128,
|
|
child: IosNavigationBar(
|
|
title: 'Alice',
|
|
centerTitleInPane: true,
|
|
titleAvatars: [IosNavigationAction(label: 'Alice')],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
final paneParams =
|
|
tester.widget<UiKitView>(find.byType(UiKitView)).creationParams!
|
|
as Map;
|
|
expect(paneParams['portraitTopPadding'], 12);
|
|
expect(
|
|
tester.getTopLeft(find.byType(DmHeaderPortrait)).dy -
|
|
tester.getTopLeft(find.byType(UiKitView)).dy,
|
|
12,
|
|
);
|
|
await tester.pumpWidget(const SizedBox());
|
|
debugDefaultTargetPlatformOverride = null;
|
|
},
|
|
);
|
|
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('scrolling does not rebuild the native bar', (tester) async {
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
|
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
|
final controller = ScrollController();
|
|
addTearDown(controller.dispose);
|
|
await tester.pumpWidget(
|
|
_testApp(
|
|
home: FrostedScaffold(
|
|
appBar: const FrostedAppBar(title: Text('Conversation')),
|
|
body: ListView(
|
|
controller: controller,
|
|
children: [
|
|
for (var i = 0; i < 40; i++)
|
|
SizedBox(height: 60, child: Text('Message $i')),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
final before = tester.widget<IosNavigationBar>(
|
|
find.byType(IosNavigationBar),
|
|
);
|
|
controller.jumpTo(300);
|
|
await tester.pumpAndSettle();
|
|
// The same widget instance is not rebuilt, so its native payload is not
|
|
// encoded again for every scrolled frame.
|
|
expect(
|
|
tester.widget<IosNavigationBar>(find.byType(IosNavigationBar)),
|
|
same(before),
|
|
);
|
|
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!),
|
|
),
|
|
);
|