Files
buzz/mobile/lib/shared/identity_transfer/nsec_import_sheet.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

227 lines
7.3 KiB
Dart

import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import '../hosted/account_identity_store.dart';
import '../theme/theme.dart';
import '../utils/string_utils.dart';
import '../widgets/modal_presentation.dart';
import '../widgets/onboarding_step_page.dart';
import 'nsec_transfer.dart';
/// Lets the user inspect the public identity before explicitly importing it.
/// [onImport] is the secure persistence boundary, not an observable UI store.
Future<bool> showNsecImportSheet(
BuildContext context, {
required Future<void> Function(String nsec) onImport,
String? mismatchMessage,
}) async =>
await showBuzzModalBottomSheet<bool>(
context: context,
title: 'Import identity',
isScrollControlled: true,
builder: (_) =>
NsecImportSheet(onImport: onImport, mismatchMessage: mismatchMessage),
) ??
false;
/// Transient secret input; only public preview and safe errors are displayed.
class NsecImportSheet extends HookWidget {
const NsecImportSheet({
super.key,
required this.onImport,
this.pickFile,
this.mismatchMessage,
this.autofocus = true,
this.fullScreen = false,
this.onBack,
});
final Future<void> Function(String nsec) onImport;
final Future<String?> Function()? pickFile;
final String? mismatchMessage;
/// Allows a containing sheet to finish expanding before opening the keyboard.
final bool autofocus;
final bool fullScreen;
final VoidCallback? onBack;
@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);
final cancelled = useRef(false);
Future<void> chooseFile() async {
busy.value = true;
error.value = null;
try {
final text = await (pickFile ?? NsecTransfer.pickFile)();
if (!context.mounted || cancelled.value || text == null) return;
final preview = NsecTransfer.preview(text);
input.text = text.trim();
publicKey.value = preview;
} catch (_) {
if (context.mounted && !cancelled.value) {
error.value = 'Choose a text file containing one valid nsec.';
}
} finally {
if (context.mounted && !cancelled.value) busy.value = false;
}
}
Future<void> submit() async {
error.value = null;
try {
final preview = NsecTransfer.preview(input.text);
if (publicKey.value != preview) {
publicKey.value = preview;
return;
}
busy.value = true;
await onImport(input.text.trim());
if (!context.mounted || cancelled.value) return;
input.clear();
Navigator.of(context).pop(true);
} catch (failure) {
if (!context.mounted || cancelled.value) return;
error.value = switch (failure) {
AccountIdentityFailure.mismatch =>
mismatchMessage ??
'This device already has a different identity. It has not been replaced.',
AccountIdentityFailure.occupied =>
'This device already has a different identity. It has not been replaced.',
AccountIdentityFailure.invalidKey =>
'Enter a valid nsec private key.',
_ =>
'Could not finish importing this identity. Try again with the same key.',
};
} finally {
if (context.mounted && !cancelled.value) busy.value = false;
}
}
final children = <Widget>[
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),
TextButton(
style: TextButton.styleFrom(
minimumSize: const Size.fromHeight(52),
shape: const StadiumBorder(),
),
onPressed: busy.value ? null : chooseFile,
child: const Text('Choose nsec file'),
),
if (publicKey.value != null) ...[
const Text('Confirm this is the public identity you want to use:'),
const SizedBox(height: Grid.xs),
SelectableText(fullNpub(publicKey.value!)!),
const SizedBox(height: Grid.sm),
],
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
? 'Importing…'
: publicKey.value == null
? 'Preview identity'
: 'Import this identity',
),
),
];
if (fullScreen) {
return OnboardingStepPage(
title: 'Import identity',
subtitle:
'Bring your Buzz identity from another device. Check the public identity before continuing.',
onBack: () {
if (cancelled.value || (onBack == null && busy.value)) return;
cancelled.value = true;
if (onBack != null) {
onBack!();
} else {
Navigator.of(context).pop(false);
}
},
content: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children.sublist(0, children.length - 1),
),
actions: [children.last],
);
}
return SafeArea(
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
Grid.gutter,
Grid.xs,
Grid.gutter,
Grid.gutter + MediaQuery.viewInsetsOf(context).bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
),
),
);
}
}