mirror of
https://github.com/block/buzz.git
synced 2026-11-02 15:31:27 +00:00
## 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 | | --- | --- | --- | |  |  |  | --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
337 lines
12 KiB
Dart
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'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|