Polish mobile onboarding and profile setup (#8438)

Originally stacked on #8375, now merged. Rebased onto main.

Polishes the onboarding landing page, community-link entry, and recovery
sheets. Profile setup uses a consistent sheet with a larger avatar,
inline display name, and draft photo/emoji/animated editing; iOS uses
native controls and coordinated avatar/sheet transitions.

Validation: mobile analysis, full tests, and normal pre-push checks
passed after rebase. Prior iOS build and device walkthrough passed;
native validation was not repeated after this rebase. Snapshots omitted
as requested.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
klopez4212
2026-10-22 10:27:38 -07:00
committed by GitHub
parent 8260b6ef4c
commit f7708d0694
45 changed files with 3749 additions and 172 deletions

View File

@@ -2863,7 +2863,8 @@ void main() {
expect(find.byType(PairingPage), findsOneWidget);
expect(find.byType(FallbackPairingQrScanner), findsNothing);
expect(find.text('Scan a QR code'), findsOneWidget);
expect(find.text('Use pairing code'), findsOneWidget);
expect(find.text('Use pairing code'), findsNothing);
expect(find.byKey(const Key('developer-pairing-trigger')), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();

View File

@@ -8,6 +8,8 @@ import 'package:buzz/shared/hosted/hosted_flow.dart';
import 'package:buzz/shared/hosted/builderlab_account.dart';
import 'package:buzz/shared/hosted/hosted_community_connection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -18,6 +20,155 @@ void main() {
testWidgets(
'cancel sign-in and resolve batch conflicts without replacing saved identities',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
// Keep secure-storage operations in the same test zone.
for (final reduced in [false, true]) {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(320, 640);
FlutterSecureStorage.setMockInitialValues({});
PendingDeepLinkNotifier.debugUriStreamOverride =
const Stream<Uri>.empty();
final f = HostedCommunitiesFixture();
await f.initialize(widget: true);
f.expired = true;
await tester.pumpWidget(
WidgetHelpers.testable(
disableAnimations: reduced,
child: const HostedAppGate(
authenticated: false,
child: OnboardingAppEntry(),
),
overrides: [hostedFlowProvider.overrideWithValue(f.flow)],
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Other options'));
await tester.pumpAndSettle();
tester.view.physicalSize = const Size(420, 900);
await tester.pumpAndSettle();
final linkCard = find.ancestor(
of: find.text('Enter a community link').hitTestable(),
matching: find.byType(InkWell),
);
expect(tester.getBottomRight(linkCard).dy, closeTo(876, 1));
final scanCard = find.ancestor(
of: find.text('Scan a QR code').hitTestable(),
matching: find.byType(InkWell),
);
final subtitle = find
.text('Scan a QR code or enter a community link to continue.')
.hitTestable();
expect(
tester.getTopLeft(scanCard).dy - tester.getBottomLeft(subtitle).dy,
closeTo(24, 1),
);
tester.view.physicalSize = const Size(320, 640);
await tester.pumpAndSettle();
final optionTop = tester
.getTopLeft(find.text('Enter a community link').hitTestable())
.dy;
final scanTop = tester.getTopLeft(scanCard).dy;
await tester.tap(find.text('Enter a community link').hitTestable());
await tester.pump();
await tester.pump(const Duration(milliseconds: 30));
if (!reduced) {
// Keyboard setup must finish before any heading/surface movement.
expect(
tester
.getTopLeft(find.byKey(const Key('connection-options-motion')))
.dy,
scanTop,
);
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
'buzz/community_keyboard',
const StandardMethodCodec().encodeMethodCall(
const MethodCall('willShow'),
),
(_) {},
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 30));
final scanLayer = find.byKey(const Key('connection-options-motion'));
expect(tester.getTopLeft(scanLayer).dy, greaterThan(scanTop));
final formOpacity = tester.widget<Opacity>(
find
.descendant(
of: find.byKey(const Key('community-form-motion')),
matching: find.byType(Opacity),
)
.first,
);
expect(formOpacity.opacity, greaterThan(0));
final surface = find.byWidgetPredicate(
(widget) => widget is LayoutId && widget.id == 'surface',
);
final action = find.byWidgetPredicate(
(widget) => widget is LayoutId && widget.id == 'action',
);
expect(tester.getRect(surface), tester.getRect(action));
}
await tester.pump(const Duration(milliseconds: 50));
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(tester.testTextInput.isVisible, isFalse);
expect(find.text('Scan a QR code').hitTestable(), findsOneWidget);
await tester.tap(find.text('Enter a community link').hitTestable());
await tester.pump();
// A hardware keyboard produces no software-keyboard notification.
await tester.pump(const Duration(milliseconds: 350));
await tester.pumpAndSettle();
expect(
tester
.getTopLeft(find.text('Enter a community link').hitTestable())
.dy,
lessThan(optionTop - 100),
);
expect(tester.testTextInput.isVisible, isTrue);
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
await tester.pumpAndSettle();
final field = find.byKey(const Key('community-link-input'));
await tester.ensureVisible(field);
expect(field.hitTestable(), findsOneWidget);
expect(
tester
.widget<EditableText>(
find.descendant(of: field, matching: find.byType(EditableText)),
)
.focusNode
.hasFocus,
isTrue,
);
expect(tester.takeException(), isNull);
tester.view.viewInsets = const FakeViewPadding();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(tester.testTextInput.isVisible, isFalse);
expect(find.text('Use pairing code'), findsNothing);
final developerTrigger = find
.byKey(const Key('developer-pairing-trigger'))
.hitTestable();
await tester.tap(developerTrigger);
await tester.pump(const Duration(milliseconds: 50));
await tester.tap(developerTrigger);
await tester.pumpAndSettle();
expect(
find
.text('Paste the pairing code from your desktop app.')
.hitTestable(),
findsOneWidget,
);
await tester.tap(find.text('Back').hitTestable());
await tester.pumpAndSettle();
expect(find.text('Scan a QR code').hitTestable(), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
await f.dispose();
tester.view.reset();
}
debugDefaultTargetPlatformOverride = null;
await tester.binding.setSurfaceSize(const Size(440, 956));
addTearDown(() => tester.binding.setSurfaceSize(null));
// Keep the process-wide secure-store queue in one widget-test zone.
@@ -28,6 +179,7 @@ void main() {
final f = HostedCommunitiesFixture();
await f.initialize(widget: true);
f.rows = [f.row('one'), f.row('two')];
f.expired = true;
addTearDown(() async {
PendingDeepLinkNotifier.debugUriStreamOverride = null;
await f.dispose();
@@ -43,17 +195,61 @@ void main() {
),
);
await tester.pumpAndSettle();
expect(
find.text('Sign in with builderlab').hitTestable(),
findsOneWidget,
);
expect(find.text('Enter a community link').hitTestable(), findsNothing);
expect(find.text('Scan a QR code'), findsNothing);
expect(find.text('Use pairing code'), findsNothing);
await tester.tap(find.text('Other options'));
await tester.pumpAndSettle();
expect(
find.text('Enter a community link').hitTestable(),
findsOneWidget,
);
expect(find.text('Scan a QR code').hitTestable(), findsOneWidget);
expect(find.text('Use pairing code').hitTestable(), findsNothing);
await tester.tap(find.byType(BackButton));
await tester.pumpAndSettle();
expect(find.text('Enter a community link').hitTestable(), findsNothing);
await tester.tap(find.text('Other options'));
await tester.pumpAndSettle();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('Other options').hitTestable(), findsOneWidget);
expect(find.text('Enter a community link').hitTestable(), findsNothing);
await tester.tap(find.text('Sign in with builderlab'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(f.browser.startedIntent, {'type': 'account'});
expect(
find.bySemanticsLabel('Cancel sign-in').hitTestable(),
findsOneWidget,
);
await tester.tap(find.bySemanticsLabel('Cancel sign-in').hitTestable());
await tester.pumpAndSettle();
expect(
find.text('Sign in with builderlab').hitTestable(),
findsOneWidget,
);
f.expired = false;
await f.flow.signInAccount(openBrowser: false);
await tester.pumpAndSettle();
f.expired = true;
await tester.tap(find.text('Continue with Builderlab'));
await tester.pump();
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 300));
expect(f.flow.stage, HostedFlowStage.signIn);
expect(f.browser.startedIntent, {'type': 'account'});
expect(find.byType(HostedConnectPage), findsNothing);
expect(find.text('Cancel sign-in').hitTestable(), findsOneWidget);
expect(find.text('Enter a community link'), findsOneWidget);
expect(
find.bySemanticsLabel('Cancel sign-in').hitTestable(),
findsOneWidget,
);
expect(find.text('Enter a community link').hitTestable(), findsNothing);
f.expired = false;
await tester.tap(find.text('Cancel sign-in').hitTestable());
await tester.tap(find.bySemanticsLabel('Cancel sign-in').hitTestable());
await tester.pumpAndSettle();
expect(f.flow.stage, HostedFlowStage.idle);
await tester.pumpAndSettle();

View File

@@ -4,7 +4,12 @@ import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart';
import 'dart:async';
import 'dart:convert';
import 'package:buzz/features/onboarding/hosted_profile_setup.dart';
import 'package:buzz/features/profile/profile_setup_page.dart';
import 'package:buzz/features/profile/profile_setup_avatar_editor.dart';
import 'package:buzz/features/profile/profile_avatar_draft.dart';
import 'package:buzz/shared/emoji/emoji_avatar.dart';
import 'package:buzz/shared/widgets/playing_avatar_image.dart';
import 'package:buzz/features/pairing/pairing_page.dart';
import 'package:buzz/onboarding_app_entry.dart';
import 'package:buzz/shared/auth/auth.dart';
import 'package:buzz/shared/hosted/builderlab_account.dart';
@@ -52,6 +57,73 @@ void main() {
);
}
journey('system Back leaves community creation', (tester, f) async {
await f.initialize();
await f.mount(tester);
await f.tap(tester, 'Create a community');
expect(
tester.widget<PairingPage>(find.byType(PairingPage)).creatingCommunity,
isTrue,
);
await tester.binding.handlePopRoute();
await f.settle(tester);
expect(
tester.widget<PairingPage>(find.byType(PairingPage)).creatingCommunity,
isFalse,
);
expect(find.text('Create a community').hitTestable(), findsOneWidget);
});
journey('retiring profile setup removes both profile and avatar routes', (
tester,
f,
) async {
await f.initialize();
f.rows.add(f.row('one'));
await f.mount(tester);
await f.tap(tester, 'Continue with Builderlab');
await tester.tap(find.byTooltip('Add profile photo'));
await f.settle(tester);
expect(find.byType(ProfileSetupAvatarEditor), findsOneWidget);
await f.link(tester, 'two');
expect(
find.byType(ProfileSetupAvatarEditor, skipOffstage: false),
findsNothing,
);
expect(find.text('Set up your profile', skipOffstage: false), findsNothing);
expect(f.profileWrites, isEmpty);
});
journey('fallback profile previews the accepted draft before saving', (
tester,
f,
) async {
await f.initialize();
f.rows.add(f.row('one'));
await f.mount(tester);
await f.tap(tester, 'Continue with Builderlab');
await tester.tap(find.byTooltip('Add profile photo'));
await f.settle(tester);
final url = emojiAvatarDataUrl('🐝', 0xFFFFE75C);
Navigator.of(
tester.element(find.byType(ProfileSetupAvatarEditor)),
).pop(ProfileUrlAvatarDraft(url));
await f.settle(tester);
expect(
tester
.widget<PlayingAvatarImage>(find.byType(PlayingAvatarImage))
.imageUrl,
url,
);
await tester.enterText(
find.byType(TextField).hitTestable(),
'Draft Person',
);
await f.tap(tester, 'Continue');
expect(f.profileWrites, ['Draft Person']);
expect(f.profileAvatars, [url]);
});
journey(
'QR credentials enter without an account when domain is not required',
(tester, f) async {
@@ -71,7 +143,7 @@ void main() {
expect(find.text('Create a community'), findsNothing);
expect(find.text('Enter a community link'), findsOneWidget);
expect(find.text('Scan a QR code'), findsOneWidget);
expect(find.text('Use pairing code'), findsOneWidget);
expect(find.text('Use pairing code'), findsNothing);
await f.tap(tester, 'Enter a community link');
expect(
find.byKey(const Key('community-link-input')).hitTestable(),
@@ -384,7 +456,17 @@ void main() {
expect((await f.storage.loadAll()).single.starterSetupIncomplete, isTrue);
expect(find.text('Try again').hitTestable(), findsOneWidget);
await f.tap(tester, 'Try again');
expect(find.byType(HostedProfileSetup), findsOneWidget);
expect(
find.byType(ProfileSetupPage, skipOffstage: false),
findsOneWidget,
);
expect(
find.ancestor(
of: find.byType(TextField).hitTestable(),
matching: find.byType(BottomSheet),
),
findsOneWidget,
);
expect(f.sent('/claim'), hasLength(1));
expect(f.recoveryScopes, hasLength(3));
expect(
@@ -477,17 +559,20 @@ void main() {
findsOneWidget,
);
expect(find.text('Scan a QR code'), findsOneWidget);
expect(find.text('Use pairing code'), findsOneWidget);
expect(find.text('Use pairing code'), findsNothing);
f.rows.addAll([f.row('one'), f.row('two')]);
await f.tap(tester, 'Continue with Builderlab');
// Joined communities are added automatically after choosing the identity.
expect(find.byType(CheckboxListTile).hitTestable(), findsNothing);
expect(find.byType(HostedProfileSetup), findsOneWidget);
expect(
find.byType(ProfileSetupPage, skipOffstage: false),
findsOneWidget,
);
final firstSaved = (await f.storage.loadAll()).firstWhere(
(c) => c.name == 'one',
);
Navigator.of(
tester.element(find.byType(HostedProfileSetup)),
tester.element(find.byType(ProfileSetupPage, skipOffstage: false)),
rootNavigator: true,
).pop();
await f.settle(tester);
@@ -528,7 +613,10 @@ void main() {
await f.tap(tester, 'Continue with Builderlab');
// One discovered community is admitted automatically.
expect(f.profileWrites, isEmpty);
expect(find.byType(HostedProfileSetup), findsNothing);
expect(
find.byType(ProfileSetupPage, skipOffstage: false),
findsNothing,
);
final saved = (await f.storage.loadAll()).single;
expect(saved.nsec, f.keys.nsec);
expect(saved.pubkey, f.keys.public);
@@ -728,7 +816,10 @@ void main() {
expect(await f.storage.loadAll(), isEmpty);
await f.restart(tester);
await f.tap(tester, 'Start login again');
expect(find.byType(HostedProfileSetup), findsOneWidget);
expect(
find.byType(ProfileSetupPage, skipOffstage: false),
findsOneWidget,
);
await tester.enterText(find.byType(TextField).hitTestable(), 'Creator');
await f.tap(tester, 'Continue');
final saved = (await f.storage.loadAll()).single;

View File

@@ -0,0 +1,58 @@
import 'package:buzz/features/onboarding/self_hosted_entry_sheet.dart';
import 'package:buzz/shared/hosted/device_identity_source.dart';
import 'package:buzz/shared/hosted/self_hosted_entry.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/widget_helpers.dart';
class _AvailableEntry extends SelfHostedEntry {
@override
Future<({List<DeviceIdentityChoice> choices, bool complete})>
choices() async => (choices: const <DeviceIdentityChoice>[], complete: true);
}
void main() {
testWidgets(
'native presentation failure opens the usable Flutter join sheet',
(tester) async {
FlutterSecureStorage.setMockInitialValues({});
const channel = MethodChannel('buzz/community_entry');
final messenger = tester.binding.defaultBinaryMessenger;
var presentations = 0;
messenger.setMockMethodCallHandler(channel, (_) async {
presentations++;
throw PlatformException(code: 'presentation_failed');
});
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
selfHostedEntryProvider.overrideWithValue(_AvailableEntry()),
],
child: Builder(
builder: (context) => TextButton(
onPressed: () => showSelfHostedEntry(
context,
relay: 'wss://example.com',
name: 'Example',
),
child: const Text('Open join'),
),
),
),
);
await tester.tap(find.text('Open join'));
await tester.pumpAndSettle();
expect(presentations, 1);
expect(tester.takeException(), isNull);
expect(find.byType(SelfHostedEntrySheet), findsOneWidget);
expect(find.text('Import a Nostr key').hitTestable(), findsOneWidget);
Navigator.of(tester.element(find.byType(SelfHostedEntrySheet))).pop();
await tester.pumpAndSettle();
},
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
);
}

View File

@@ -10,6 +10,7 @@ import 'package:buzz/hosted_app_gate.dart';
import 'package:buzz/shared/hosted/hosted_flow_provider.dart';
import 'package:buzz/onboarding_app_entry.dart';
import 'package:buzz/shared/auth/auth.dart';
import 'package:buzz/shared/identity_transfer/nsec_import_sheet.dart';
import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart';
import 'package:buzz/shared/hosted/builderlab_account.dart';
import 'package:buzz/shared/hosted/hosted_flow.dart';
@@ -46,6 +47,7 @@ void main() {
});
var probes = 0;
final inventoryStorage = _EnumerationStorage();
Completer<void>? pendingProbe;
await tester.pumpWidget(
WidgetHelpers.testable(
disableAnimations: true,
@@ -57,6 +59,7 @@ void main() {
accountStore: AccountIdentityStore(storage: inventoryStorage),
probe: (_) async {
probes++;
await pendingProbe?.future;
},
),
),
@@ -92,7 +95,12 @@ void main() {
}
Future<void> pair() async {
await tester.tap(find.text('Use pairing code').hitTestable());
final trigger = find
.byKey(const Key('developer-pairing-trigger'))
.hitTestable();
await tester.tap(trigger);
await tester.pump(const Duration(milliseconds: 50));
await tester.tap(trigger);
await tester.pumpAndSettle();
await tester.enterText(
find.byType(TextField).hitTestable(),
@@ -225,9 +233,68 @@ void main() {
await tester.tap(find.text('Continue').hitTestable());
await tester.pumpAndSettle();
expect(find.text('Import a Nostr key').hitTestable(), findsOneWidget);
await tester.tap(find.text('Previous device identity').hitTestable());
final joinRoute = ModalRoute.of(
tester.element(find.byType(SelfHostedEntrySheet)),
);
final joinHeight = tester.getSize(find.byType(SelfHostedEntrySheet)).height;
await tester.tap(find.text('Import a Nostr key').hitTestable());
await tester.pumpAndSettle();
expect(find.byType(BuzzSheetHeader), findsOneWidget);
expect(find.text('Import identity'), findsOneWidget);
expect(
ModalRoute.of(tester.element(find.byType(NsecImportSheet))),
same(joinRoute),
);
expect(
tester.getSize(find.byType(SelfHostedEntrySheet)).height,
greaterThan(joinHeight),
);
await tester.tap(find.byTooltip('Close sheet'));
await tester.pumpAndSettle();
expect(find.byType(SelfHostedEntrySheet), findsOneWidget);
expect(find.byType(NsecImportSheet), findsNothing);
expect(find.text('Join community'), findsOneWidget);
expect(
tester.getSize(find.byType(SelfHostedEntrySheet)).height,
joinHeight,
);
await tester.tap(find.text('Import a Nostr key').hitTestable());
await tester.pumpAndSettle();
await tester.enterText(
find.descendant(
of: find.byType(NsecImportSheet),
matching: find.byType(TextField),
),
f.keys.nsec,
);
await tester.tap(find.text('Preview identity'));
await tester.pumpAndSettle();
expect(probes, 0);
pendingProbe = Completer<void>();
await tester.tap(find.text('Import this identity'));
await tester.pumpAndSettle();
expect(probes, 1);
await tester.tap(find.byTooltip('Close sheet'));
await tester.pumpAndSettle();
pendingProbe.complete();
await tester.pumpAndSettle();
expect(find.text('Join community'), findsOneWidget);
expect(await container.read(communityStorageProvider).loadAll(), isEmpty);
pendingProbe = null;
await tester.tap(find.text('Import a Nostr key').hitTestable());
await tester.pumpAndSettle();
await tester.enterText(
find.descendant(
of: find.byType(NsecImportSheet),
matching: find.byType(TextField),
),
f.keys.nsec,
);
await tester.tap(find.text('Preview identity'));
await tester.pumpAndSettle();
await tester.tap(find.text('Import this identity'));
await tester.pumpAndSettle();
expect(probes, 2);
expect(find.byType(SelfHostedEntrySheet), findsNothing);
expect(find.byType(OnboardingAppEntry), findsNothing);
expect(find.text('Add community').hitTestable(), findsOneWidget);
@@ -251,7 +318,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.byType(OnboardingAppEntry), findsNothing);
expect(find.text('Add community').hitTestable(), findsOneWidget);
expect(probes, 1);
expect(probes, 2);
expect(
await container.read(communityStorageProvider).loadActiveId(),
saved.single.id,

View File

@@ -689,8 +689,8 @@ void main() {
expect(confirmTopLeft.dx, Grid.sm);
expect(tester.getSize(confirmFinder).width, scaffoldWidth - Grid.sm * 2);
expect(tester.getSize(cancelFinder).width, scaffoldWidth - Grid.sm * 2);
expect(tester.getSize(confirmFinder).height, 48);
expect(tester.getSize(cancelFinder).height, 48);
expect(tester.getSize(confirmFinder).height, 52);
expect(tester.getSize(cancelFinder).height, 52);
expect(
find.textContaining(
'Only continue if you started this pairing from your desktop.',

View File

@@ -0,0 +1,100 @@
import 'package:buzz/features/profile/profile_setup_avatar_editor.dart';
import 'package:buzz/features/profile/profile_avatar_draft.dart';
import 'package:buzz/shared/emoji/emoji_avatar.dart';
import 'dart:typed_data';
import 'package:buzz/features/profile/profile_avatar_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
void main() {
testWidgets(
'accepted animated draft can reopen and finish without recapture',
(tester) async {
final draft = ProfileAnimatedAvatarDraft(
animation: Uint8List(0),
poster: Uint8List(0),
);
ProfileAvatarDraft? accepted;
Rect? nativePreview;
Future<void> open(ProfileAvatarDraft? initial) async {
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
home: ProfileSetupAvatarEditor(
key: UniqueKey(),
initialDraft: initial,
onPreviewReady: (rect) async {
nativePreview = rect;
},
onFinished: (value) => accepted = value,
),
),
),
);
await tester.pump();
}
await open(null);
tester
.widget<ProfileAvatarEditor>(find.byType(ProfileAvatarEditor))
.onModeChanged(ProfileAvatarMode.animated);
await tester.pump();
tester
.widget<ProfileAvatarEditor>(find.byType(ProfileAvatarEditor))
.onAnimatedPrepareChanged
.call(() async => draft);
await tester.pump();
await tester.tap(find.text('Done'));
await tester.pump();
expect(accepted, same(draft));
nativePreview = null;
await open(accepted);
expect(nativePreview, isNotNull);
expect(nativePreview!.width, greaterThan(0));
accepted = null;
expect(find.byTooltip('Back to profile'), findsOneWidget);
await tester.tap(find.text('Done'));
await tester.pump();
expect(accepted, same(draft));
tester
.widget<ProfileAvatarEditor>(find.byType(ProfileAvatarEditor))
.onModeChanged(ProfileAvatarMode.image);
await tester.pump();
expect(
tester
.widget<TextButton>(find.widgetWithText(TextButton, 'Done'))
.onPressed,
isNull,
);
await tester.pumpWidget(const SizedBox.shrink());
},
);
testWidgets('avatar draft editor lays out within a full height sheet', (
tester,
) async {
Rect? previewRect;
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
home: ProfileSetupAvatarEditor(
onPreviewReady: (rect) async {
previewRect = rect;
},
initialDraft: ProfileUrlAvatarDraft(
emojiAvatarDataUrl('🐝', 0xFFFFE75C),
),
),
),
),
);
await tester.pump();
expect(find.text('Done'), findsOneWidget);
expect(previewRect, isNotNull);
expect(previewRect!.width, greaterThan(0));
expect(previewRect!.height, greaterThan(0));
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
});
}

View File

@@ -0,0 +1,67 @@
import 'dart:async';
import 'package:buzz/features/profile/profile_setup_page.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/hosted_communities_fixture.dart';
import '../../helpers/widget_helpers.dart';
void main() {
testWidgets('native profile saves 100 graphemes and rejects 101', (
tester,
) async {
FlutterSecureStorage.setMockInitialValues({});
final fixture = HostedCommunitiesFixture();
await fixture.initialize(widget: true);
addTearDown(fixture.dispose);
for (final grapheme in ['😀', 'e\u0301', '👨‍👩‍👧‍👦']) {
for (final count in [100, 101]) {
const channel = MethodChannel('buzz/profile_setup');
final messenger = tester.binding.defaultBinaryMessenger;
final presented = Completer<Map<dynamic, dynamic>>();
final dismissed = Completer<String>();
messenger.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'present') {
presented.complete(call.arguments as Map);
return dismissed.future;
}
return null;
});
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
final writes = <String>[];
final pending = fixture.flow.requestProfile(
(name) async => writes.add(name),
);
await tester.pumpWidget(
WidgetHelpers.testable(child: ProfileSetupPage(flow: fixture.flow)),
);
await tester.pumpAndSettle();
final configuration = await presented.future;
final name = List.filled(count, grapheme).join();
final response = Completer<Map<dynamic, dynamic>>();
messenger.handlePlatformMessage(
channel.name,
const StandardMethodCodec().encodeMethodCall(
MethodCall('save', {
'session': configuration['session'],
'name': name,
}),
),
(data) => response.complete(
const StandardMethodCodec().decodeEnvelope(data!) as Map,
),
);
expect(await response.future, {'saved': count == 100});
expect(writes, count == 100 ? [name] : isEmpty);
dismissed.complete(count == 100 ? 'saved' : 'skip');
await tester.pumpAndSettle();
await pending;
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
}
}
}, variant: TargetPlatformVariant.only(TargetPlatform.iOS));
}