Polish mobile onboarding and profile setup (#8438)

Originally stacked on #8375, now merged. Rebased onto main.

Polishes the onboarding landing page, community-link entry, and recovery
sheets. Profile setup uses a consistent sheet with a larger avatar,
inline display name, and draft photo/emoji/animated editing; iOS uses
native controls and coordinated avatar/sheet transitions.

Validation: mobile analysis, full tests, and normal pre-push checks
passed after rebase. Prior iOS build and device walkthrough passed;
native validation was not repeated after this rebase. Snapshots omitted
as requested.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
klopez4212
2026-10-22 10:27:38 -07:00
committed by GitHub
parent 8260b6ef4c
commit f7708d0694
45 changed files with 3749 additions and 172 deletions

View File

@@ -124,23 +124,35 @@ class HostedFlow extends ChangeNotifier {
Completer<void>? _profileCompletion;
Future<void> Function(String)? saveProfile;
Future<void> requestProfile(Future<void> Function(String) save) async {
Future<void> Function(String, Future<String> Function()?)? saveProfileDraft;
Future<void> requestProfile(
Future<void> Function(String) save, {
Future<void> Function(String, Future<String> Function()?)? saveDraft,
}) async {
if (_disposed) return;
final completion = Completer<void>();
_profileCompletion = completion;
saveProfile = (name) async {
saveProfileDraft = (name, avatar) async {
if (!identical(_profileCompletion, completion) ||
completion.isCompleted) {
throw AccountFailure.unavailable;
}
await save(name);
if (saveDraft != null) {
await saveDraft(name, avatar);
} else {
await save(name);
}
if (identical(_profileCompletion, completion)) finishProfile();
};
final saveCurrentDraft = saveProfileDraft!;
saveProfile = (name) => saveCurrentDraft(name, null);
_set(HostedFlowStage.profile);
await completion.future;
if (identical(_profileCompletion, completion)) {
_profileCompletion = null;
saveProfile = null;
saveProfileDraft = null;
_set(HostedFlowStage.working);
}
}
@@ -154,6 +166,7 @@ class HostedFlow extends ChangeNotifier {
final completion = _profileCompletion;
_profileCompletion = null;
saveProfile = null;
saveProfileDraft = null;
if (completion != null && !completion.isCompleted) completion.complete();
}

View File

@@ -153,10 +153,23 @@ Future<void> offerHostedProfile(
} catch (_) {
return;
}
await flow.requestProfile((name) async {
if (!current()) throw AccountFailure.unavailable;
await ref.read(profileProvider.notifier).updateDisplayName(name);
});
await flow.requestProfile(
(name) async {
if (!current()) throw AccountFailure.unavailable;
await ref.read(profileProvider.notifier).updateDisplayName(name);
},
saveDraft: (name, prepareAvatar) async {
if (!current()) throw AccountFailure.unavailable;
final writer = ref.read(profileProvider.notifier);
final avatar = await prepareAvatar?.call();
if (!current()) throw AccountFailure.unavailable;
if (avatar == null) {
await writer.updateDisplayName(name);
} else {
await writer.updateSetup(name, avatar);
}
},
);
} finally {
subscription.close();
}

View File

@@ -0,0 +1,129 @@
import 'package:flutter/services.dart';
import '../hosted/account_identity_store.dart';
import '../hosted/device_identity_source.dart';
import '../utils/string_utils.dart';
import 'nsec_transfer.dart';
/// UIKit presentation with identity operations kept in the Dart service layer.
abstract final class NativeCommunityEntry {
static const _channel = MethodChannel('buzz/community_entry');
static bool _presenting = false;
static int _session = 0;
/// Returns null when native presentation is unavailable.
static Future<bool?> show({
required String name,
required bool dark,
required int primaryArgb,
required List<DeviceIdentityChoice> choices,
Future<Map<String, dynamic>> Function()? confirmInvite,
required Future<void> Function(
String? pubkey,
String? nsec,
bool create,
bool Function() isCurrent,
)
connect,
}) async {
if (_presenting) return false;
_presenting = true;
final session = ++_session;
var active = true;
var generation = 0;
var busy = false;
String? previewed;
_channel.setMethodCallHandler((call) async {
final args = Map<String, dynamic>.from(call.arguments as Map);
if (!active || args['session'] != session) return {'cancelled': true};
if (call.method == 'cancel') {
generation++;
busy = false;
previewed = null;
return null;
}
if (busy) return {'cancelled': true};
final attempt = ++generation;
bool current() => active && generation == attempt;
busy = true;
try {
switch (call.method) {
case 'preview':
case 'file':
final text = call.method == 'file'
? await NsecTransfer.pickFile()
: args['nsec'] as String?;
if (!current() || text == null) return {'cancelled': true};
previewed = NsecTransfer.preview(text);
return {
'npub': fullNpub(previewed!),
if (call.method == 'file') 'nsec': text,
};
case 'join':
if (confirmInvite != null) return await confirmInvite();
final nsec = args['nsec'] as String?;
final pubkey = args['pubkey'] as String?;
final create = args['create'] == true;
if (nsec != null) {
if (previewed == null ||
NsecTransfer.preview(nsec) != previewed) {
throw AccountIdentityFailure.invalidKey;
}
} else if (create
? choices.isNotEmpty
: !choices.any((choice) => choice.pubkey == pubkey)) {
return {'error': 'Choose an identity to continue.'};
}
await connect(pubkey, nsec, create, current);
return current() ? {'joined': true} : {'cancelled': true};
default:
throw MissingPluginException();
}
} catch (error) {
if (!current()) return {'cancelled': true};
return {
'error': switch (error) {
AccountIdentityFailure.invalidKey =>
'Enter a valid nsec private key.',
AccountIdentityFailure.occupied ||
AccountIdentityFailure.mismatch =>
'This device already has a different identity. It has not been replaced.',
_ => 'Could not finish. Try again. Your saved keys have been kept.',
},
};
} finally {
if (current()) busy = false;
}
});
try {
return await _channel.invokeMethod<bool>('present', {
'session': session,
'name': name,
'dark': dark,
'primaryArgb': primaryArgb,
'invite': confirmInvite != null,
'choices': [
for (final choice in choices)
{
'pubkey': choice.pubkey,
'label': choice.label,
'npub': fullNpub(choice.pubkey) ?? choice.pubkey,
},
],
}) ??
false;
} on MissingPluginException {
return null;
} on PlatformException catch (error) {
// Scene transitions can temporarily leave UIKit without a presenter.
if (error.code == 'presentation_failed') return null;
rethrow;
} finally {
active = false;
generation++;
previewed = null;
_channel.setMethodCallHandler(null);
_presenting = false;
}
}
}

View File

@@ -1,3 +1,5 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@@ -23,14 +25,32 @@ Future<bool> showNsecImportSheet(
/// Transient secret input; only public preview and safe errors are displayed.
class NsecImportSheet extends HookWidget {
const NsecImportSheet({super.key, required this.onImport, this.pickFile});
const NsecImportSheet({
super.key,
required this.onImport,
this.pickFile,
this.autofocus = true,
});
final Future<void> Function(String nsec) onImport;
final Future<String?> Function()? pickFile;
/// Allows a containing sheet to finish expanding before opening the keyboard.
final bool autofocus;
@override
Widget build(BuildContext context) {
final input = useTextEditingController();
final focus = useFocusNode();
final autofocusRef = useRef(autofocus)..value = autofocus;
useEffect(() {
if (autofocus && defaultTargetPlatform == TargetPlatform.iOS) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted && autofocusRef.value) focus.requestFocus();
});
}
return null;
}, [autofocus]);
final publicKey = useState<String?>(null);
final error = useState<String?>(null);
final busy = useState(false);
@@ -93,27 +113,51 @@ class NsecImportSheet extends HookWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Your nsec is your private key. Only import a key you trust. It is never copied to the clipboard.',
),
const SizedBox(height: Grid.sm),
TextField(
controller: input,
maxLength: 1024,
enabled: !busy.value,
obscureText: true,
autocorrect: false,
enableSuggestions: false,
enableIMEPersonalizedLearning: false,
decoration: const InputDecoration(
labelText: 'Private key (nsec)',
if (defaultTargetPlatform == TargetPlatform.iOS)
CupertinoTextField(
controller: input,
placeholder: 'Private key (nsec)',
padding: const EdgeInsets.all(Grid.gutter),
decoration: BoxDecoration(
color: CupertinoColors.secondarySystemGroupedBackground
.resolveFrom(context),
borderRadius: BorderRadius.circular(Radii.container),
),
focusNode: focus,
maxLength: 1024,
enabled: !busy.value,
obscureText: true,
autocorrect: false,
enableSuggestions: false,
enableIMEPersonalizedLearning: false,
onChanged: (_) {
publicKey.value = null;
error.value = null;
},
)
else
TextField(
controller: input,
maxLength: 1024,
enabled: !busy.value,
obscureText: true,
autocorrect: false,
enableSuggestions: false,
enableIMEPersonalizedLearning: false,
decoration: const InputDecoration(
labelText: 'Private key (nsec)',
),
onChanged: (_) {
publicKey.value = null;
error.value = null;
},
),
const SizedBox(height: Grid.xs),
OutlinedButton(
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(52),
shape: const StadiumBorder(),
),
onChanged: (_) {
publicKey.value = null;
error.value = null;
},
),
TextButton(
onPressed: busy.value ? null : chooseFile,
child: const Text('Choose nsec file'),
),
@@ -127,7 +171,12 @@ class NsecImportSheet extends HookWidget {
],
if (error.value != null)
Text(error.value!, style: TextStyle(color: context.colors.error)),
const SizedBox(height: Grid.xs),
FilledButton(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
shape: const StadiumBorder(),
),
onPressed: busy.value ? null : submit,
child: Text(
busy.value

View File

@@ -13,6 +13,9 @@ abstract final class BuzzIcons {
static const edit = IconData(0xea98, fontFamily: 'BuzzTabler');
/// Tabler `qrcode`.
static const qrcode = IconData(0xeb11, fontFamily: 'BuzzTabler');
/// Tabler `text-size`.
static const aLargeSmall = IconData(0xf2b1, fontFamily: 'BuzzTabler');

View File

@@ -16,6 +16,7 @@ class BuzzSheetHeader extends StatelessWidget {
this.titleKey,
this.leading,
this.trailing,
this.onClose,
this.showDragHandle = false,
});
@@ -23,6 +24,9 @@ class BuzzSheetHeader extends StatelessWidget {
final Key? titleKey;
final Widget? leading;
final Widget? trailing;
/// Overrides route dismissal while retaining the standard close control.
final VoidCallback? onClose;
final bool showDragHandle;
@override
@@ -61,7 +65,7 @@ class BuzzSheetHeader extends StatelessWidget {
),
Align(
alignment: Alignment.bottomRight,
child: trailing ?? const _SheetCloseButton(),
child: trailing ?? _SheetCloseButton(onClose: onClose),
),
if (leading case final leading?)
Align(alignment: Alignment.bottomLeft, child: leading),
@@ -73,13 +77,18 @@ class BuzzSheetHeader extends StatelessWidget {
}
class _SheetCloseButton extends StatelessWidget {
const _SheetCloseButton();
const _SheetCloseButton({this.onClose});
final VoidCallback? onClose;
@override
Widget build(BuildContext context) {
void closeSheet() {
unawaited(HapticFeedback.lightImpact());
Navigator.of(context).pop();
if (onClose != null) {
onClose!();
} else {
Navigator.of(context).pop();
}
}
if (Theme.of(context).platform == TargetPlatform.iOS) {

View File

@@ -13,6 +13,7 @@ class BuzzTitledSheetLayout extends HookWidget {
required this.child,
this.leading,
this.trailing,
this.onClose,
this.titleKey,
this.showDragHandle = false,
this.surfaceColor,
@@ -22,6 +23,9 @@ class BuzzTitledSheetLayout extends HookWidget {
final Widget child;
final Widget? leading;
final Widget? trailing;
/// Overrides the header close action; null dismisses the current route.
final VoidCallback? onClose;
final Key? titleKey;
final bool showDragHandle;
final Color? surfaceColor;
@@ -79,6 +83,7 @@ class BuzzTitledSheetLayout extends HookWidget {
titleKey: titleKey,
leading: leading,
trailing: trailing,
onClose: onClose,
showDragHandle: showDragHandle,
),
),

View File

@@ -23,6 +23,7 @@ const buzzModalAnimationStyle = AnimationStyle(
/// uses native concentric corners when available and paints a requested drag
/// handle inside the shared header so its spacing is consistent on every
/// platform.
/// Set [padBottom] to false when the content owns its bottom edge spacing.
Future<T?> showBuzzModalBottomSheet<T>({
required BuildContext context,
required WidgetBuilder builder,
@@ -42,6 +43,7 @@ Future<T?> showBuzzModalBottomSheet<T>({
bool? showDragHandle,
bool showCloseButton = true,
bool useSafeArea = false,
bool padBottom = true,
RouteSettings? routeSettings,
AnimationController? transitionAnimationController,
Offset? anchorPoint,
@@ -83,8 +85,9 @@ Future<T?> showBuzzModalBottomSheet<T>({
top: false,
left: false,
right: false,
bottom: padBottom,
child: Padding(
padding: const EdgeInsets.only(bottom: Grid.half),
padding: EdgeInsets.only(bottom: padBottom ? Grid.half : 0),
child: Builder(builder: builder),
),
),