Files
buzz/mobile/lib/features/pairing/pairing_page.dart
klopez4212 6b5bb6f5a3 feat(mobile): refresh onboarding and smooth QR scanner motion (#8019)
## Summary
Refresh mobile onboarding with light/dark gradients, an animated fuzzy
Buzz wordmark and tagline, and bottom-aligned glass/ghost actions.
Remove the dot grid and idle scroll bounce, and smooth the QR scanner's
continuous corners and spring transitions by deferring camera teardown
until dismissal finishes.

### Related issue
N/A — no matching mobile onboarding issue or PR found.

### Testing
- Mobile formatting, analysis, and full test suite passed at
`0bb357f7a`.
- Regression coverage includes shader animation/reduced motion, keyboard
reachability, scanner interruption, deferred camera disposal, and
rendered error contrast in both themes (mutation-checked).
- Signed iOS release build installed on iPhone Air; Kenneth tried the
onboarding and scanner during iteration and approved the final blur.
- Agent review completed with no correctness blockers. Android device
validation was not performed; hosted checks run separately.

buzz-review-completed

### Preview
Production-widget renders at 420 × 912; before: `5fdb2e536`, after:
`443858a96`.

| Before | After — light | After — dark |
|---|---|---|
|
![Before](https://raw.githubusercontent.com/block/buzz/52fb09e3c7e536f3a365d61401a077ce03ac47d9/pr-8019--buzz-before-light.png)
|
![Light](https://raw.githubusercontent.com/block/buzz/52fb09e3c7e536f3a365d61401a077ce03ac47d9/pr-8019--buzz-blur-only-light.png)
|
![Dark](https://raw.githubusercontent.com/block/buzz/52fb09e3c7e536f3a365d61401a077ce03ac47d9/pr-8019--buzz-blur-only-dark.png)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-01 15:17:04 +01:00

417 lines
15 KiB
Dart

import 'dart:async';
import 'dart:math' show sqrt2;
import 'dart:ui' show ImageFilter;
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 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/security/sensitive_action_authorizer.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/ios_glass_navigation_button.dart';
import 'pairing_page/onboarding_wordmark.dart';
import 'pairing_provider.dart';
import 'pairing_qr_scanner.dart';
part 'pairing_page/onboarding_background.dart';
part 'pairing_page/onboarding_colors.dart';
part 'pairing_page/onboarding_glass_button.dart';
part 'pairing_page/pairing_welcome_view.dart';
class PairingPage extends HookConsumerWidget {
/// When true, the pairing page is being used to add a new community
/// (user is already authenticated with at least one community).
final bool addingCommunity;
final bool identityRecoveryOnly;
const PairingPage({
super.key,
this.addingCommunity = false,
this.identityRecoveryOnly = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pairingState = ref.watch(pairingProvider);
final enrolledBiometrics = ref.watch(enrolledBiometricsProvider);
final codeController = useTextEditingController();
final fallbackScannerVisible = useState(false);
final pairingCodeExpanded = useState(false);
final isBusy =
pairingState.status == PairingStatus.connecting ||
pairingState.status == PairingStatus.transferring ||
pairingState.status == PairingStatus.storing;
// When adding a community and pairing succeeds, pop back.
if (addingCommunity && pairingState.status == PairingStatus.success) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
ref.read(pairingProvider.notifier).reset();
Navigator.of(context).pop();
}
});
}
Future<void> handleScannerResult(String? code) async {
if (code != null && context.mounted) {
if (identityRecoveryOnly &&
Uri.tryParse(code)?.queryParameters['mode'] != 'recover') {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Scan a desktop recovery code.')),
);
return;
}
await ref.read(pairingProvider.notifier).pair(code);
}
}
Future<void> openScanner() async {
final usesDynamicIslandPortal = await usesDynamicIslandQrScannerPortal();
if (!context.mounted) {
return;
}
if (!usesDynamicIslandPortal) {
fallbackScannerVisible.value = true;
return;
}
final code = await showDynamicIslandPairingQrScanner(context);
await handleScannerResult(code);
}
final isVerifyingSas = pairingState.status == PairingStatus.confirmingSas;
final onboardingSystemOverlayStyle =
(context._onboardingIsDark
? SystemUiOverlayStyle.light
: SystemUiOverlayStyle.dark)
.copyWith(statusBarColor: Colors.transparent);
final pairingAppBar = addingCommunity
? AppBar(
foregroundColor: context._onboardingInk,
systemOverlayStyle: onboardingSystemOverlayStyle,
leadingWidth: Theme.of(context).platform == TargetPlatform.iOS
? Grid.quarter + iosGlassChannelHeaderLeadingWidth
: null,
leading: Theme.of(context).platform == TargetPlatform.iOS
? Padding(
padding: const EdgeInsets.only(left: Grid.quarter),
child: IosGlassNavigationButton(
key: const ValueKey('pairing-ios-glass-back'),
icon: IosGlassNavigationIcon.back,
semanticLabel: 'Back',
onPressed: () => Navigator.of(context).maybePop(),
width: iosGlassChannelHeaderLeadingWidth,
buttonCenterX: iosGlassChannelHeaderButtonCenterX,
foregroundColor: context._onboardingInk,
),
)
: IconButton(
icon: const Icon(LucideIcons.arrowLeft),
tooltip: 'Back',
onPressed: () => Navigator.of(context).pop(),
),
title: Text(
identityRecoveryOnly ? 'Send to Desktop' : 'Add Community',
style: context.textTheme.titleMedium?.copyWith(
color: context._onboardingInk,
),
),
)
: null;
final pairingScaffold = isVerifyingSas
? AnnotatedRegion<SystemUiOverlayStyle>(
key: const Key('pairing-sas-system-overlay'),
value: onboardingSystemOverlayStyle,
child: _OnboardingBackground(
child: Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.sm),
child: _SasVerificationView(
sasCode: pairingState.sasCode ?? '------',
confirmed: pairingState.userConfirmedSas,
sendsIdentityToDesktop:
pairingState.sendsIdentityToDesktop,
protectSensitiveActions:
pairingState.protectSensitiveActions,
biometricLabel: biometricProtectionLabel(
defaultTargetPlatform,
enrolledBiometrics.value ?? const [],
),
errorMessage: pairingState.errorMessage,
onProtectionChanged: (value) => ref
.read(pairingProvider.notifier)
.setProtectSensitiveActions(value),
onConfirm: () =>
ref.read(pairingProvider.notifier).confirmSas(),
onDeny: () =>
ref.read(pairingProvider.notifier).denySas(),
),
),
),
),
),
)
: AnnotatedRegion<SystemUiOverlayStyle>(
key: const Key('pairing-onboarding-system-overlay'),
value: onboardingSystemOverlayStyle,
child: _OnboardingBackground(
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: pairingAppBar,
body: SafeArea(
child: _PairingWelcomeView(
codeController: codeController,
isBusy: isBusy,
pairingCodeExpanded: pairingCodeExpanded.value,
errorMessage: pairingState.status == PairingStatus.error
? pairingState.errorMessage
: null,
onScan: openScanner,
onTogglePairingCode: () {
pairingCodeExpanded.value = !pairingCodeExpanded.value;
},
onConnect: () {
final code = codeController.text.trim();
if (code.isNotEmpty) {
unawaited(handleScannerResult(code));
}
},
),
),
),
),
);
final appSurface = PopScope(
key: const Key('pairing-pop-scope'),
onPopInvokedWithResult: (didPop, _) {
if (didPop) {
ref.read(pairingProvider.notifier).reset();
}
},
child: pairingScaffold,
);
if (!fallbackScannerVisible.value) {
return appSurface;
}
return FallbackPairingQrScanner(
appSurface: appSurface,
onClosed: (code) {
fallbackScannerVisible.value = false;
unawaited(handleScannerResult(code));
},
);
}
}
/// SAS verification screen shown during NIP-AB pairing.
class _SasVerificationView extends StatelessWidget {
static const _digitSize = 54.0;
static const _digitGap = 6.0;
static const _digitGroupGap = 14.0;
final String sasCode;
final bool confirmed;
final bool sendsIdentityToDesktop;
final bool protectSensitiveActions;
final String biometricLabel;
final String? errorMessage;
final ValueChanged<bool> onProtectionChanged;
final VoidCallback onConfirm;
final VoidCallback onDeny;
const _SasVerificationView({
required this.sasCode,
required this.confirmed,
required this.sendsIdentityToDesktop,
required this.protectSensitiveActions,
required this.biometricLabel,
required this.errorMessage,
required this.onProtectionChanged,
required this.onConfirm,
required this.onDeny,
});
@override
Widget build(BuildContext context) {
final verificationContent = Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Confirm desktop code',
textAlign: TextAlign.center,
style: context.textTheme.headlineSmall?.copyWith(
color: context._onboardingInk,
fontWeight: FontWeight.w600,
letterSpacing: -0.4,
),
),
const SizedBox(height: Grid.xxs),
Text(
sendsIdentityToDesktop
? 'Make sure the six-digit code matches on both devices. Your full Buzz identity will transfer to the desktop and grant it permanent access. Only continue if you started this recovery.'
: 'Make sure the six-digit code matches on both devices. Your Buzz identity will transfer to this device. Only continue if you started this pairing from your desktop.',
textAlign: TextAlign.center,
style: context.textTheme.bodyMedium?.copyWith(
color: context._onboardingMutedInk,
),
),
const SizedBox(height: Grid.md),
Semantics(
label:
'Confirmation code ${sasCode.substring(0, 3)} ${sasCode.substring(3)}',
child: ExcludeSemantics(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var index = 0; index < sasCode.length; index++) ...[
if (index > 0)
SizedBox(width: index == 3 ? _digitGroupGap : _digitGap),
Container(
key: Key('pairing-sas-code-digit-${index + 1}'),
width: _digitSize,
padding: const EdgeInsets.symmetric(vertical: Grid.xs),
alignment: Alignment.center,
decoration: BoxDecoration(
color: context._onboardingInputSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: context.colors.primary.withValues(alpha: 0.15),
),
),
child: Text(
sasCode[index],
style: context.textTheme.displaySmall?.copyWith(
fontWeight: FontWeight.w600,
color: context._onboardingInk,
),
),
),
],
],
),
),
),
),
const SizedBox(height: Grid.sm),
if (!sendsIdentityToDesktop)
CheckboxListTile(
key: const Key('protect-sensitive-actions-checkbox'),
value: protectSensitiveActions,
onChanged: confirmed
? null
: (value) => onProtectionChanged(value ?? false),
activeColor: context._onboardingInk,
checkColor: context._onboardingCtaLabel,
side: BorderSide(color: context._onboardingInk),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
title: Text(
biometricLabel,
style: context.textTheme.bodyMedium?.copyWith(
color: context._onboardingInk,
fontWeight: FontWeight.w600,
),
),
subtitle: Text(
'For secure actions',
style: context.textTheme.bodySmall?.copyWith(
color: context._onboardingMutedInk,
),
),
),
if (errorMessage != null) ...[
const SizedBox(height: Grid.xs),
Text(
errorMessage!,
textAlign: TextAlign.center,
style: context.textTheme.bodySmall?.copyWith(
color: context._onboardingErrorInk,
),
),
],
],
);
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: [
FilledButton.icon(
style: context._onboardingButtonStyle,
onPressed: onConfirm,
icon: const Icon(LucideIcons.check),
label: const Text('Codes match'),
),
const SizedBox(height: Grid.xxs),
TextButton(
style: context._onboardingSecondaryButtonStyle.copyWith(
minimumSize: const WidgetStatePropertyAll(
Size.fromHeight(48),
),
),
onPressed: onDeny,
child: const Text('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),
],
);
}
}