From a2540fbe3d8a1c934073c28b95d53deeac911327 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 31 Oct 2026 17:08:39 +0000 Subject: [PATCH] Align Android profile setup with iOS steps (#8871) Android profile setup now matches iOS: choose a display name, then an optional avatar using the existing dotted-circle placeholder and Image / Emoji / Animated editor. Back preserves drafts; Skip saves the name without an avatar, and failed saves retain retry/exit options. Validated on Pixel 10 and approved by Kenny; analysis clean, 4,064 mobile tests passed (8 skipped). Device preview uses no-write callbacks; preview-only files are excluded. | Display name | Avatar | | --- | --- | | ![Display name step](https://github.com/user-attachments/assets/516361ea-2c02-4ed5-8847-f716ed5c0272) | ![Avatar step](https://github.com/user-attachments/assets/2a1607e0-59dd-4ff6-9ffa-a1dd13faad3e) | buzz-review-completed Signed-off-by: kenny lopez Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> --- .../features/profile/profile_setup_page.dart | 116 ++------------ .../profile/profile_setup_page/form.dart | 142 ++++++++++++++++++ .../onboarding/hosted_journeys_test.dart | 104 ++++++++----- .../profile/profile_setup_fallback_test.dart | 21 ++- .../test/helpers/hosted_journey_fixture.dart | 9 ++ 5 files changed, 249 insertions(+), 143 deletions(-) create mode 100644 mobile/lib/features/profile/profile_setup_page/form.dart diff --git a/mobile/lib/features/profile/profile_setup_page.dart b/mobile/lib/features/profile/profile_setup_page.dart index 3982c88c04..20e080d9bc 100644 --- a/mobile/lib/features/profile/profile_setup_page.dart +++ b/mobile/lib/features/profile/profile_setup_page.dart @@ -19,6 +19,9 @@ import '../../shared/widgets/onboarding_route.dart'; import 'profile_avatar_draft.dart'; import '../../shared/widgets/playing_avatar_image.dart'; import 'profile_setup_avatar_editor.dart'; +import 'profile_setup_empty_avatar.dart'; + +part 'profile_setup_page/form.dart'; /// Profile setup for onboarding and existing-community entry, with a failure exit. class ProfileSetupPage extends HookConsumerWidget { @@ -96,16 +99,12 @@ class ProfileSetupPage extends HookConsumerWidget { context, builder: (sheetContext) { profileRoute = ModalRoute.of(sheetContext); - return OnboardingStepPage( - title: 'Set up your profile', - onBack: null, - content: _ProfileSetupForm( - avatar: avatar, - onName: (value) => name = value, - editAvatar: editAvatar, - save: save, - notNow: flow.finishProfile, - ), + return _ProfileSetupForm( + avatar: avatar, + onName: (value) => name = value, + editAvatar: editAvatar, + save: save, + notNow: flow.finishProfile, ); }, ), @@ -237,100 +236,3 @@ class ProfileSetupPage extends HookConsumerWidget { return const SizedBox.shrink(); } } - -class _ProfileSetupForm extends HookWidget { - const _ProfileSetupForm({ - required this.avatar, - required this.onName, - required this.editAvatar, - required this.save, - required this.notNow, - }); - final ValueNotifier avatar; - final ValueChanged onName; - final Future Function() editAvatar; - final Future Function() save; - final VoidCallback notNow; - @override - Widget build(BuildContext context) { - final draft = useValueListenable(avatar); - final name = useTextEditingController(); - useListenable(name); - final busy = useState(false); - final error = useState(false); - return PopScope( - canPop: false, - child: Padding( - padding: EdgeInsets.fromLTRB( - 20, - 16, - 20, - MediaQuery.viewInsetsOf(context).bottom + 20, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - IconButton( - onPressed: busy.value ? null : editAvatar, - icon: switch (draft) { - ProfileImageAvatarDraft(:final bytes) => CircleAvatar( - radius: 32, - backgroundImage: MemoryImage(bytes), - ), - ProfileAnimatedAvatarDraft(:final poster) => CircleAvatar( - radius: 32, - backgroundImage: MemoryImage(poster), - ), - ProfileUrlAvatarDraft(:final url) => PlayingAvatarImage( - imageUrl: url, - radius: 32, - fallback: const Icon(Icons.add_a_photo_outlined), - ), - null => const Icon(Icons.add_a_photo_outlined), - }, - iconSize: 64, - tooltip: 'Add profile photo', - ), - TextField( - controller: name, - maxLength: 100, - enabled: !busy.value, - onChanged: onName, - decoration: const InputDecoration(labelText: 'Display name'), - ), - if (error.value) - const Text( - 'Could not save your profile. Try again or choose Not now to continue without saving.', - ), - FilledButton( - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(52), - shape: const StadiumBorder(), - ), - onPressed: busy.value || name.text.trim().isEmpty - ? null - : () async { - busy.value = true; - error.value = false; - try { - await save(); - } catch (_) { - if (context.mounted) error.value = true; - } finally { - if (context.mounted) busy.value = false; - } - }, - child: Text(busy.value ? 'Saving…' : 'Continue'), - ), - if (error.value) - TextButton( - onPressed: busy.value ? null : notNow, - child: const Text('Not now'), - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/features/profile/profile_setup_page/form.dart b/mobile/lib/features/profile/profile_setup_page/form.dart new file mode 100644 index 0000000000..32f75e56ce --- /dev/null +++ b/mobile/lib/features/profile/profile_setup_page/form.dart @@ -0,0 +1,142 @@ +part of '../profile_setup_page.dart'; + +class _ProfileSetupForm extends HookWidget { + const _ProfileSetupForm({ + required this.avatar, + required this.onName, + required this.editAvatar, + required this.save, + required this.notNow, + }); + + final ValueNotifier avatar; + final ValueChanged onName; + final Future Function() editAvatar; + final Future Function({bool skipAvatar}) save; + final VoidCallback notNow; + + @override + Widget build(BuildContext context) { + final draft = useValueListenable(avatar); + final name = useTextEditingController(); + final nameFocus = useFocusNode(); + useListenable(name); + final choosingAvatar = useState(false); + final busy = useState(false); + final error = useState(false); + final validName = + name.text.trim().isNotEmpty && + name.text.trim().characters.length <= 100; + + void back() { + if (busy.value) return; + error.value = false; + choosingAvatar.value = false; + nameFocus.requestFocus(); + } + + Future submit({bool skipAvatar = false}) async { + if (busy.value || !validName) return; + if (!choosingAvatar.value) { + nameFocus.unfocus(); + choosingAvatar.value = true; + return; + } + if (!skipAvatar && draft == null) return; + busy.value = true; + error.value = false; + try { + await save(skipAvatar: skipAvatar); + } catch (_) { + if (context.mounted) error.value = true; + } finally { + if (context.mounted) busy.value = false; + } + } + + final avatarLabel = draft == null + ? 'Add profile photo' + : 'Edit profile photo'; + return OnboardingStepPage( + title: choosingAvatar.value + ? 'Choose your avatar' + : 'Choose a display name', + subtitle: choosingAvatar.value + ? 'Let’s choose an avatar so people know it’s you.' + : 'Your display name is how people see you in Buzz. You can change it any time.', + onBack: choosingAvatar.value && !busy.value ? back : null, + content: choosingAvatar.value + ? Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.lg), + child: Center( + child: SizedBox.square( + dimension: 220, + child: IconButton( + tooltip: avatarLabel, + onPressed: busy.value ? null : editAvatar, + padding: EdgeInsets.zero, + icon: ExcludeSemantics( + child: switch (draft) { + ProfileImageAvatarDraft(:final bytes) => CircleAvatar( + radius: 110, + backgroundImage: MemoryImage(bytes), + ), + ProfileAnimatedAvatarDraft(:final poster) => + CircleAvatar( + radius: 110, + backgroundImage: MemoryImage(poster), + ), + ProfileUrlAvatarDraft(:final url) => PlayingAvatarImage( + imageUrl: url, + radius: 110, + fallback: const ProfileSetupEmptyAvatar(), + ), + null => const ProfileSetupEmptyAvatar(), + }, + ), + ), + ), + ), + ) + : TextField( + controller: name, + focusNode: nameFocus, + autofocus: true, + maxLength: 100, + textCapitalization: TextCapitalization.words, + textInputAction: TextInputAction.next, + onSubmitted: (_) => submit(), + onChanged: onName, + decoration: const InputDecoration(labelText: 'Display name'), + ), + actions: [ + if (error.value) + Semantics( + liveRegion: true, + child: const Text( + 'Could not save your profile. Try again or choose Not now to continue without saving.', + ), + ), + FilledButton( + onPressed: + busy.value || + !validName || + (choosingAvatar.value && draft == null) + ? null + : submit, + child: Text(busy.value ? 'Saving…' : 'Continue'), + ), + if (choosingAvatar.value) + TextButton( + onPressed: busy.value ? null : () => submit(skipAvatar: true), + child: const Text('Skip'), + ), + if (error.value) + TextButton( + onPressed: busy.value ? null : notNow, + child: const Text('Not now'), + ), + ], + ); + } +} diff --git a/mobile/test/features/onboarding/hosted_journeys_test.dart b/mobile/test/features/onboarding/hosted_journeys_test.dart index 22dcada474..7e279e0aaf 100644 --- a/mobile/test/features/onboarding/hosted_journeys_test.dart +++ b/mobile/test/features/onboarding/hosted_journeys_test.dart @@ -281,6 +281,14 @@ void main() { expect(f.probes, isEmpty); await tester.tap(find.byType(CommunityGridOption).first); await f.settle(tester); + await tester.enterText( + find.byType(TextField).hitTestable(), + 'Draft Person', + ); + expect(find.byTooltip('Add profile photo'), findsNothing); + await f.tap(tester, 'Continue'); + expect(find.text('Choose your avatar'), findsOneWidget); + expect(f.profileWrites, isEmpty); await tester.tap(find.byTooltip('Add profile photo')); await f.settle(tester); expect(find.byType(ProfileSetupAvatarEditor), findsOneWidget); @@ -289,45 +297,68 @@ void main() { find.byType(ProfileSetupAvatarEditor, skipOffstage: false), findsNothing, ); - expect(find.text('Set up your profile', skipOffstage: false), findsNothing); + expect(find.text('Choose your avatar', 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); - // Account restoration may resume independently of the Add choices. - await f.flow.signInAccount(); - await f.settle(tester); - expect(f.flow.stage, HostedFlowStage.chooseCommunities); - expect(f.probes, isEmpty); - await tester.tap(find.byType(CommunityGridOption).first); - await f.settle(tester); - 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(find.byType(PlayingAvatarImage)) - .imageUrl, - url, + for (final skipAvatar in [false, true]) { + journey( + 'fallback profile preserves drafts and recovers save failures (skip: $skipAvatar)', + (tester, f) async { + await f.initialize(); + f.rows.add(f.row('one')); + await f.mount(tester); + // Account restoration may resume independently of the Add choices. + await f.flow.signInAccount(); + await f.settle(tester); + expect(f.flow.stage, HostedFlowStage.chooseCommunities); + expect(f.probes, isEmpty); + await tester.tap(find.byType(CommunityGridOption).first); + await f.settle(tester); + await tester.enterText( + find.byType(TextField).hitTestable(), + 'Draft Person', + ); + expect(find.byTooltip('Add profile photo'), findsNothing); + await f.tap(tester, 'Continue'); + expect(find.text('Choose your avatar'), findsOneWidget); + expect(f.profileWrites, isEmpty); + 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(find.byType(PlayingAvatarImage)) + .imageUrl, + url, + ); + await tester.tap(find.byTooltip('Back')); + await f.settle(tester); + expect(find.text('Choose a display name'), findsOneWidget); + expect( + tester + .widget(find.byType(TextField).hitTestable()) + .controller! + .text, + 'Draft Person', + ); + await f.tap(tester, 'Continue'); + expect(find.byTooltip('Edit profile photo'), findsOneWidget); + f.profileSaveFailures = 1; + await f.tap(tester, 'Continue'); + expect(f.profileWrites, isEmpty); + expect(find.text('Not now'), findsOneWidget); + expect(find.byTooltip('Edit profile photo'), findsOneWidget); + await f.tap(tester, skipAvatar ? 'Skip' : 'Continue'); + expect(f.profileWrites, ['Draft Person']); + expect(f.profileAvatars, skipAvatar ? isEmpty : [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', @@ -715,6 +746,8 @@ void main() { 'Journey Person', ); await f.tap(tester, 'Continue'); + expect(f.profileWrites, isEmpty); + await f.tap(tester, 'Skip'); expect(f.profileWrites, ['Journey Person']); final saved = (await f.storage.loadAll()).single; expect(saved.id, pending.id); @@ -1126,6 +1159,7 @@ void main() { ); await tester.enterText(find.byType(TextField).hitTestable(), 'Creator'); await f.tap(tester, 'Continue'); + await f.tap(tester, 'Skip'); final saved = (await f.storage.loadAll()).single; expect(saved.relayUrl, 'wss://new-home.communities.buzz.xyz'); expect(saved.nsec, f.keys.nsec); diff --git a/mobile/test/features/profile/profile_setup_fallback_test.dart b/mobile/test/features/profile/profile_setup_fallback_test.dart index 96909f7658..6d22571bcf 100644 --- a/mobile/test/features/profile/profile_setup_fallback_test.dart +++ b/mobile/test/features/profile/profile_setup_fallback_test.dart @@ -44,6 +44,23 @@ void main() { await tester.pump(); await tester.tap(find.text('Continue')); await tester.pump(); + expect(find.text('Choose your avatar'), findsOneWidget); + expect(attempts, 0); + expect( + tester.widget(find.byType(FilledButton)).onPressed, + isNull, + ); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(TextField)).controller!.text, + 'Kenny', + ); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Skip')); + await tester.tap(find.text('Skip')); + await tester.pump(); expect(find.text('Saving…'), findsOneWidget); await tester.binding.handlePopRoute(); await tester.pump(); @@ -53,7 +70,9 @@ void main() { await tester.pumpAndSettle(); expect(attempts, 1); expect(find.text('Not now'), findsOneWidget); - await tester.tap(find.text('Not now')); + await tester.ensureVisible(find.text('Not now')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Not now').hitTestable()); await tester.pumpAndSettle(); await pending; expect(attempts, 1); diff --git a/mobile/test/helpers/hosted_journey_fixture.dart b/mobile/test/helpers/hosted_journey_fixture.dart index f371ebba0c..25a7d8c30c 100644 --- a/mobile/test/helpers/hosted_journey_fixture.dart +++ b/mobile/test/helpers/hosted_journey_fixture.dart @@ -71,6 +71,7 @@ class HostedJourneyFixture { final socketHeaders = >[]; bool offerProfile = true; bool profileUnavailable = false; + int profileSaveFailures = 0; bool rejectList = false; int saveFailures = 0; final knownSaved = {}; @@ -518,11 +519,19 @@ class _Profile extends ProfileNotifier { @override Future updateDisplayName(String name) async { + if (fixture.profileSaveFailures > 0) { + fixture.profileSaveFailures--; + throw StateError('profile save unavailable'); + } fixture.profileWrites.add(name); } @override Future updateSetup(String name, String? avatarUrl) async { + if (fixture.profileSaveFailures > 0) { + fixture.profileSaveFailures--; + throw StateError('profile save unavailable'); + } fixture.profileWrites.add(name); fixture.profileAvatars.add(avatarUrl); }