Files
buzz/mobile/lib/features/profile/profile_setup_page.dart
klopez4212 f3a7bab098 Clarify mobile community onboarding and saved identity selection (#8729)
## Summary

Make community onboarding easier to scan: shorten hosted hostname
fallbacks, clarify Add Community instructions, and replace raw saved-key
buttons with identity cards. Use white pages, grey cards, ghost
secondary actions, standard page transitions, and a “Getting ready”
screen during joining. Native profile setup keeps a horizontal modal
transition. Identity cards load signed name/avatar metadata from saved
communities without switching accounts; unavailable profiles use a
neutral fallback. Cards show only the avatar, a 16px medium-weight name,
and muted npub; community lists are omitted. Retiring profile setup
closes its avatar editor too.

### Related issue

Follow-up to #8687 and #8599; no duplicate PR found.

### Testing

- Formatting and analysis passed. Full suite with hosted-auth defines:
4,019 passed, 5 skipped; includes profile-fetch and cancellation
regressions.
- Prior iOS device preview and native profile tests passed; hosted CI
native Swift checks passed on the preceding head. Latest Dart fixes are
not yet reinstalled on the phone.
- Simulator walkthrough and owner-tested iPhone preview use sample
identities and simulated admission.

Sample-data previews; the identity snapshot shows the
unavailable-profile fallback rendered by Flutter. Media is attached here
only.

| Community picker | Add Community | Saved identity |
| --- | --- | --- |
| ![Shortened community
name](https://github.com/user-attachments/assets/cf0af6aa-3a3d-4ffe-b469-e26f9bb92c30)
| ![White Add Community
page](https://github.com/user-attachments/assets/b2f71c77-1aa0-465a-b302-7eb5f1e92819)
| ![Saved identity card and ghost import
action](https://github.com/user-attachments/assets/47f20f34-f047-4ce5-befa-f56834096e95)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-30 19:00:42 +00:00

337 lines
12 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/hosted/hosted_flow.dart';
import '../../shared/emoji/emoji_avatar.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
// Retain the named entry point used by the native sheet's editor engine.
// ignore: unused_import
import 'profile_avatar_sheet_app.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../../shared/widgets/onboarding_step_page.dart';
import '../../shared/widgets/onboarding_route.dart';
import 'profile_avatar_draft.dart';
import '../../shared/widgets/playing_avatar_image.dart';
import 'profile_setup_avatar_editor.dart';
/// Profile setup for onboarding and existing-community entry, with a failure exit.
class ProfileSetupPage extends HookConsumerWidget {
const ProfileSetupPage({super.key, required this.flow});
final HostedFlow flow;
static const _channel = MethodChannel('buzz/profile_setup');
static int _nextSession = 0;
@override
Widget build(BuildContext context, WidgetRef ref) {
useListenable(flow);
final theme = Theme.of(context);
final appearance = <String, Object>{
'dark': theme.brightness == Brightness.dark,
'pageBackgroundArgb': theme.colorScheme.surface.toARGB32(),
'containerBackgroundArgb': theme.colorScheme.surfaceContainerHighest
.toARGB32(),
'containerCornerRadius': Radii.container,
'primaryArgb': theme.colorScheme.primary.toARGB32(),
'onPrimaryArgb': theme.colorScheme.onPrimary.toARGB32(),
'subtitleArgb': theme.colorScheme.onSurfaceVariant.toARGB32(),
};
final currentAppearance = useRef(appearance)..value = appearance;
final nativeSession = useRef<int?>(null);
useEffect(() {
final session = ++_nextSession;
nativeSession.value = session;
var active = true;
var name = '';
final avatar = ValueNotifier<ProfileAvatarDraft?>(null);
ModalRoute<dynamic>? profileRoute;
ModalRoute<dynamic>? editorRoute;
bool current() =>
active && context.mounted && flow.stage == HostedFlowStage.profile;
Future<void> save({bool skipAvatar = false}) async {
if (!current()) return;
final draft = skipAvatar ? null : avatar.value;
final service = draft == null
? null
: ref.read(mediaUploadServiceProvider);
await flow.saveProfileDraft!(
name.trim(),
draft == null ? null : () => draft.upload(service!),
);
}
Future<void> editAvatar() async {
if (!current()) return;
final result = await showBuzzModalBottomSheet<ProfileAvatarDraft>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
useSafeArea: true,
showCloseButton: false,
padBottom: false,
isDismissible: false,
enableDrag: false,
builder: (sheetContext) {
editorRoute = ModalRoute.of(sheetContext);
return SizedBox(
height: MediaQuery.sizeOf(sheetContext).height * .94,
child: ProfileSetupAvatarEditor(initialDraft: avatar.value),
);
},
);
if (!current()) return;
editorRoute = null;
if (result != null) avatar.value = result;
}
Future<void> fallback() async {
if (!current()) return;
await Navigator.of(context, rootNavigator: true).push<void>(
onboardingRoute<void>(
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,
),
);
},
),
);
if (!current()) return;
profileRoute = null;
if (current()) flow.finishProfile();
}
Future<void> present() async {
if (defaultTargetPlatform != TargetPlatform.iOS) {
await fallback();
return;
}
_channel.setMethodCallHandler((call) async {
if (!current() || (call.arguments as Map?)?['session'] != session) {
return null;
}
switch (call.method) {
case 'avatarDraft':
avatar.value = decodeProfileAvatarDraft(
(call.arguments as Map)['draft'] as Map?,
);
final emoji = avatar.value is ProfileUrlAvatarDraft
? parseEmojiAvatarDataUrl(
(avatar.value as ProfileUrlAvatarDraft).url,
)
: null;
return {
'avatar': switch (avatar.value) {
ProfileImageAvatarDraft(:final bytes) => bytes,
ProfileAnimatedAvatarDraft(:final poster) => poster,
_ => null,
},
'emoji': emoji?.emoji,
'emojiColor': emoji?.colorValue,
};
case 'save':
name = ((call.arguments as Map)['name'] as String? ?? '').trim();
if (name.isEmpty || name.characters.length > 100) {
return {'saved': false};
}
try {
await save(
skipAvatar: (call.arguments as Map)['skipAvatar'] == true,
);
return {'saved': true};
} catch (_) {
return {'saved': false};
}
}
return null;
});
try {
while (current()) {
final Uint8List? bytes = switch (avatar.value) {
ProfileImageAvatarDraft(:final bytes) => bytes,
ProfileAnimatedAvatarDraft(:final poster) => poster,
_ => null,
};
final emoji = avatar.value is ProfileUrlAvatarDraft
? parseEmojiAvatarDataUrl(
(avatar.value as ProfileUrlAvatarDraft).url,
)
: null;
final action = await _channel.invokeMethod<String>('present', {
'session': session,
'name': name,
'avatar': bytes,
'emoji': emoji?.emoji,
'emojiColor': emoji?.colorValue,
...currentAppearance.value,
});
if (!current()) return;
if (action != 'saved') flow.finishProfile();
return;
}
} on MissingPluginException {
await fallback();
} on PlatformException {
await fallback();
} finally {
if (active) _channel.setMethodCallHandler(null);
}
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (current()) unawaited(present());
});
return () {
active = false;
if (nativeSession.value == session) nativeSession.value = null;
if (defaultTargetPlatform == TargetPlatform.iOS) {
_channel.setMethodCallHandler(null);
unawaited(
_channel
.invokeMethod<void>('dismiss', {'session': session})
.catchError((Object _) {}),
);
}
final routes = [editorRoute, profileRoute];
avatar.dispose();
WidgetsBinding.instance.addPostFrameCallback((_) {
for (final route in routes) {
if (route != null && route.isActive) {
route.navigator?.removeRoute(route);
}
}
});
};
}, [flow, flow.stage == HostedFlowStage.profile]);
useEffect(() {
if (defaultTargetPlatform != TargetPlatform.iOS) return null;
WidgetsBinding.instance.addPostFrameCallback((_) {
final session = nativeSession.value;
if (!context.mounted || session == null) return;
unawaited(
_channel
.invokeMethod<void>('appearance', {
'session': session,
...currentAppearance.value,
})
.catchError((Object _) {}),
);
});
return null;
}, [theme]);
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'),
),
],
),
),
);
}
}