Files
buzz/mobile/lib/features/pairing/pairing_page/sas_verification_view.dart
klopez4212 f7708d0694 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>
2026-10-22 17:27:38 +00:00

277 lines
9.4 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

part of '../pairing_page.dart';
/// SAS verification screen shown during NIP-AB pairing.
class _SasVerificationView extends HookConsumerWidget {
final String sasCode;
final Future<bool> Function(String)? verifyDesktopCode;
final bool confirmed;
final bool sendsIdentityToDesktop;
final String biometricLabel;
final String? errorMessage;
final ValueChanged<bool> onProtectionChanged;
final VoidCallback onConfirm;
final VoidCallback onDeny;
const _SasVerificationView({
super.key,
required this.sasCode,
this.verifyDesktopCode,
required this.confirmed,
required this.sendsIdentityToDesktop,
required this.biometricLabel,
required this.errorMessage,
required this.onProtectionChanged,
required this.onConfirm,
required this.onDeny,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = useTextEditingController();
final enteredCode = useValueListenable(controller).text;
final mismatch = useState(false);
final checkingCode = useState(false);
final rejectedCode = useRef<String?>(null);
final verificationError = useState<String?>(null);
final shakeRevision = useState(0);
final codeAccepted = useState(false);
final confirmationSent = useRef(false);
useEffect(() {
if (!confirmed) confirmationSent.value = false;
return null;
}, [confirmed, errorMessage]);
void confirm({bool? protection}) {
if (confirmed || confirmationSent.value) return;
confirmationSent.value = true;
if (protection != null) onProtectionChanged(protection);
onConfirm();
}
void checkCode(String value) {
if (confirmed || codeAccepted.value || checkingCode.value) return;
verificationError.value = null;
if (verifyDesktopCode != null) {
mismatch.value = false;
if (value.length != 6) return;
if (value == rejectedCode.value) {
mismatch.value = true;
shakeRevision.value++;
unawaited(errorHaptic());
return;
}
checkingCode.value = true;
unawaited(() async {
try {
final accepted = await verifyDesktopCode!(value);
if (!context.mounted) return;
if (accepted) {
codeAccepted.value = true;
FocusScope.of(context).unfocus();
} else {
rejectedCode.value = value;
mismatch.value = true;
shakeRevision.value++;
unawaited(errorHaptic());
}
} catch (_) {
if (context.mounted) {
verificationError.value = 'Couldn’t check the code. Try again.';
}
} finally {
if (context.mounted) checkingCode.value = false;
}
}());
return;
}
mismatch.value = value.length == 6 && value != sasCode;
if (mismatch.value) {
shakeRevision.value++;
unawaited(errorHaptic());
} else if (value.length == 6) {
codeAccepted.value = true;
FocusScope.of(context).unfocus();
if (sendsIdentityToDesktop) confirm();
}
}
useEffect(() {
if (verifyDesktopCode == null) return null;
var active = true;
// Negotiation can arrive after the user typed a code under legacy SAS
// rules. Re-evaluate that input against the source-only desktop code.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!active || !context.mounted) return;
rejectedCode.value = null;
mismatch.value = false;
codeAccepted.value = false;
checkCode(controller.text);
});
return () => active = false;
}, [verifyDesktopCode != null]);
final showProtection = codeAccepted.value && !sendsIdentityToDesktop;
final verificationContent = Column(
mainAxisSize: MainAxisSize.min,
children: [
if (showProtection) ...[
Icon(
biometricLabel == 'Use Face ID'
? BuzzIcons.scanFace
: BuzzIcons.fingerprint,
size: 64,
color: context._onboardingInk,
),
const SizedBox(height: Grid.sm),
Text(
'Protect your identity',
textAlign: TextAlign.center,
style: context.textTheme.headlineSmall?.copyWith(
color: context._onboardingInk,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: Grid.xxs),
Text(
'$biometricLabel to confirm it’s you before sending your Buzz identity to another device.',
textAlign: TextAlign.center,
style: context.textTheme.bodyMedium?.copyWith(
color: context._onboardingMutedInk,
),
),
] else if (codeAccepted.value) ...[
Icon(BuzzIcons.shieldCheck, size: 64, color: context._onboardingInk),
const SizedBox(height: Grid.sm),
Text('Code confirmed', style: context.textTheme.headlineSmall),
] else ...[
Text(
'Enter pairing code',
textAlign: TextAlign.center,
style: context.textTheme.headlineSmall?.copyWith(
color: context._onboardingInk,
fontWeight: FontWeight.w600,
letterSpacing: -0.4,
),
),
const SizedBox(height: Grid.md),
_PairingErrorShake(
revision: shakeRevision.value,
child: _PairingCodeEntry(
controller: controller,
enabled: !confirmed && !checkingCode.value,
invalid: mismatch.value,
onChanged: checkCode,
onSubmitted: () => checkCode(enteredCode),
onRejectedInput: () => checkCode(controller.text),
),
),
const SizedBox(height: Grid.xxs),
AnimatedOpacity(
key: const Key('pairing-code-error'),
opacity: mismatch.value ? 1 : 0,
duration: const Duration(milliseconds: 280),
curve: Curves.easeOut,
child: ExcludeSemantics(
excluding: !mismatch.value,
child: Semantics(
liveRegion: true,
child: Text(
'Check the desktop code and try again.',
textAlign: TextAlign.center,
style: context.textTheme.bodySmall?.copyWith(
color: context._onboardingInk,
),
),
),
),
),
],
if (errorMessage != null || verificationError.value != null) ...[
const SizedBox(height: Grid.xs),
Text(
errorMessage ?? verificationError.value!,
textAlign: TextAlign.center,
style: context.textTheme.bodySmall?.copyWith(
color: context._onboardingInk,
),
),
],
],
);
final verificationActions = confirmed
? Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
BuzzLoadingIndicator(
size: 24,
color: context._onboardingInk,
semanticLabel: 'Connecting',
),
const SizedBox(width: Grid.twelve),
Text(
'Confirmed — waiting for desktop',
style: context.textTheme.bodySmall?.copyWith(
color: context._onboardingMutedInk,
),
),
],
)
: SizedBox(
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (showProtection ||
(codeAccepted.value && errorMessage != null)) ...[
FilledButton(
style: context._onboardingButtonStyle,
onPressed: () =>
confirm(protection: showProtection ? true : null),
child: Text(showProtection ? biometricLabel : 'Try again'),
),
const SizedBox(height: Grid.xxs),
],
TextButton(
style: context._onboardingSecondaryButtonStyle.copyWith(
minimumSize: const WidgetStatePropertyAll(
Size.fromHeight(52),
),
),
onPressed: showProtection
? () => confirm(protection: false)
: onDeny,
child: Text(showProtection ? 'Skip' : 'Cancel'),
),
],
),
);
return Column(
children: [
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final verticalPadding = Grid.sm * 2;
final minimumContentHeight =
constraints.maxHeight > verticalPadding
? constraints.maxHeight - verticalPadding
: 0.0;
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: Grid.sm),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: minimumContentHeight),
child: Center(child: verificationContent),
),
);
},
),
),
verificationActions,
const SizedBox(height: Grid.sm),
],
);
}
}