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
Signed-off-by: cls_宁波本机 <908705107@qq.com>
381 lines
13 KiB
Dart
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),
|
|
],
|
|
);
|
|
}
|
|
}
|