mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
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 | | --- | --- | |  |  | 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:
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
142
mobile/lib/features/profile/profile_setup_page/form.dart
Normal file
142
mobile/lib/features/profile/profile_setup_page/form.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user