Files
buzz/mobile/lib/features/pairing/pairing_page.dart
T
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

381 lines
13 KiB
Dart

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<void> 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<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 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<SystemUiOverlayStyle>(
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<SystemUiOverlayStyle>(
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),
],
);
}
}