mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
129
mobile/lib/shared/identity_transfer/native_community_entry.dart
Normal file
129
mobile/lib/shared/identity_transfer/native_community_entry.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user