import 'dart:async'; 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/theme/theme.dart'; import '../../shared/l10n/l10n.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/tappable_flapping_bee.dart'; import 'pairing_provider.dart'; import 'pairing_qr_scanner.dart'; part 'pairing_page/onboarding_background.dart'; part 'pairing_page/pairing_welcome_view.dart'; const _onboardingChartreuse = Color(0xFFD7D72E); const _onboardingShellBottom = Color(0xFFD7E7F6); const _onboardingCtaLabel = Color(0xFFD7E6F0); const _onboardingInk = Color(0xFF111111); const _onboardingMutedInk = Color(0xB3111111); 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 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 handleScannerResult(String? code) async { if (code != null && context.mounted) { if (identityRecoveryOnly && Uri.tryParse(code)?.queryParameters['mode'] != 'recover') { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.pairingScanDesktopCode)), ); return; } await ref.read(pairingProvider.notifier).pair(code); } } Future 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 themedSystemOverlayStyle = (context.theme.brightness == Brightness.dark ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark) .copyWith(statusBarColor: Colors.transparent); final pairingAppBar = addingCommunity ? AppBar( foregroundColor: isVerifyingSas ? context.colors.onSurface : _onboardingInk, systemOverlayStyle: isVerifyingSas ? themedSystemOverlayStyle : SystemUiOverlayStyle.dark.copyWith( statusBarColor: Colors.transparent, ), leading: IconButton( icon: const Icon(LucideIcons.arrowLeft), onPressed: () => Navigator.of(context).pop(), ), title: Text( identityRecoveryOnly ? context.l10n.pairingSendToDesktop : context.l10n.pairingAddCommunity, style: isVerifyingSas ? null : context.textTheme.titleMedium?.copyWith( color: _onboardingInk, ), ), ) : null; final pairingScaffold = isVerifyingSas ? AnnotatedRegion( key: const Key('pairing-sas-system-overlay'), value: themedSystemOverlayStyle, child: Scaffold( backgroundColor: context.colors.surface, appBar: pairingAppBar, body: SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: Grid.sm), child: _SasVerificationView( sasCode: pairingState.sasCode ?? '------', confirmed: pairingState.userConfirmedSas, sendsIdentityToDesktop: pairingState.sendsIdentityToDesktop, onConfirm: () => ref.read(pairingProvider.notifier).confirmSas(), onDeny: () => ref.read(pairingProvider.notifier).denySas(), ), ), ), ), ) : AnnotatedRegion( key: const Key('pairing-onboarding-system-overlay'), value: SystemUiOverlayStyle.dark.copyWith( statusBarColor: Colors.transparent, ), 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 ? _localizedPairingError( context, 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( 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)); }, ); } } String? _localizedPairingError(BuildContext context, String? error) { if (error == null || Localizations.localeOf(context).languageCode == 'en') { return error; } if (error.startsWith('SAS code mismatch')) { return context.l10n.pairingSasMismatch; } if (error.startsWith('Pairing session timed out')) { return context.l10n.pairingSessionTimedOut; } if (error.startsWith('Invalid pairing code')) { return context.l10n.pairingInvalidCode; } if (error.startsWith('Could not reach the pairing relay')) { return context.l10n.pairingRelayUnreachable; } if (error.startsWith('The pairing relay rejected authentication')) { return context.l10n.pairingAuthenticationRejected; } if (error.startsWith('Pairing stopped because of an internal error')) { return context.l10n.pairingInternalError; } if (error.startsWith('Secure connection failed')) { return context.l10n.pairingSecureConnectionFailed; } if (error.startsWith('Connection timed out')) { return context.l10n.pairingConnectionTimedOut; } if (error.startsWith('Security verification failed')) { return context.l10n.pairingSecurityVerificationFailed; } if (error.startsWith('No identity is available')) { return context.l10n.pairingNoIdentity; } if (error.startsWith('Received empty payload')) { return context.l10n.pairingEmptyPayload; } if (error.startsWith('Desktop could not store')) { return context.l10n.pairingDesktopStoreFailed; } if (error.startsWith('Source device aborted pairing')) { return context.l10n.pairingSourceAborted; } if (error.startsWith('Failed to import credentials')) { return context.l10n.pairingImportFailed; } if (error.startsWith('Lost connection to pairing relay')) { return context.l10n.pairingLostConnection; } if (error.startsWith('Could not connect to relay')) { return context.l10n.pairingRelayRejectedCode; } if (error.startsWith('Connection failed')) { return context.l10n.pairingConnectionFailed; } return context.l10n.pairingConnectionFailed; } /// SAS verification screen shown during NIP-AB pairing. class _SasVerificationView extends StatelessWidget { final String sasCode; final bool confirmed; final bool sendsIdentityToDesktop; final VoidCallback onConfirm; final VoidCallback onDeny; const _SasVerificationView({ required this.sasCode, required this.confirmed, required this.sendsIdentityToDesktop, required this.onConfirm, required this.onDeny, }); @override Widget build(BuildContext context) { return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Spacer(flex: 2), Icon(LucideIcons.shieldCheck, size: 56, color: context.colors.primary), const SizedBox(height: Grid.sm), Text(context.l10n.sasTitle, style: context.textTheme.headlineSmall), const SizedBox(height: Grid.xs), Text( confirmed ? context.l10n.sasWaitingForDesktop : context.l10n.sasDesktopPrompt, textAlign: TextAlign.center, style: context.textTheme.bodyMedium?.copyWith( color: context.colors.onSurfaceVariant, ), ), const SizedBox(height: Grid.lg), // Large SAS code display Container( padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 20), decoration: BoxDecoration( color: context.colors.primaryContainer.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(16), border: Border.all( color: context.colors.primary.withValues(alpha: 0.3), width: 2, ), ), child: Text( '${sasCode.substring(0, 3)} ${sasCode.substring(3)}', style: context.textTheme.displayMedium?.copyWith( fontFamily: 'GeistMono', fontWeight: FontWeight.w700, letterSpacing: 8, color: context.colors.primary, ), ), ), const SizedBox(height: Grid.lg), Text( sendsIdentityToDesktop ? context.l10n.sasTransferFull : context.l10n.sasTransferToDevice, textAlign: TextAlign.center, style: context.textTheme.bodySmall?.copyWith( color: context.colors.onSurfaceVariant, ), ), const SizedBox(height: Grid.lg), // Confirm / Deny buttons if (confirmed) Row( mainAxisAlignment: MainAxisAlignment.center, children: [ BuzzLoadingIndicator( size: 24, color: context.colors.primary, semanticLabel: context.l10n.connecting, ), const SizedBox(width: Grid.twelve), Text( context.l10n.sasConfirmedWaiting, style: context.textTheme.bodySmall?.copyWith( color: context.colors.onSurfaceVariant, ), ), ], ) else Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Expanded( child: OutlinedButton.icon( onPressed: onDeny, icon: const Icon(LucideIcons.x), label: Text(context.l10n.cancel), ), ), const SizedBox(width: Grid.sm), Expanded( child: FilledButton.icon( onPressed: onConfirm, icon: const Icon(LucideIcons.check), label: Text(context.l10n.codesMatch), ), ), ], ), const Spacer(flex: 3), ], ); } }