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 <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-10-31 17:08:39 +00:00
committed by GitHub
parent 20742a236d
commit a2540fbe3d
5 changed files with 249 additions and 143 deletions

View File

@@ -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<ProfileAvatarDraft?> avatar;
final ValueChanged<String> onName;
final Future<void> Function() editAvatar;
final Future<void> 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'),
),
],
),
),
);
}
}

View File

@@ -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<ProfileAvatarDraft?> avatar;
final ValueChanged<String> onName;
final Future<void> Function() editAvatar;
final Future<void> 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<void> 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'),
),
],
);
}
}

View File

@@ -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<PlayingAvatarImage>(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<PlayingAvatarImage>(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<TextField>(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);

View File

@@ -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<FilledButton>(find.byType(FilledButton)).onPressed,
isNull,
);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(
tester.widget<TextField>(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);

View File

@@ -71,6 +71,7 @@ class HostedJourneyFixture {
final socketHeaders = <Map<String, String>>[];
bool offerProfile = true;
bool profileUnavailable = false;
int profileSaveFailures = 0;
bool rejectList = false;
int saveFailures = 0;
final knownSaved = <String>{};
@@ -518,11 +519,19 @@ class _Profile extends ProfileNotifier {
@override
Future<void> updateDisplayName(String name) async {
if (fixture.profileSaveFailures > 0) {
fixture.profileSaveFailures--;
throw StateError('profile save unavailable');
}
fixture.profileWrites.add(name);
}
@override
Future<void> 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);
}