mirror of
https://github.com/block/buzz.git
synced 2026-11-07 18:01:06 +00:00
## Summary After a pairing QR scan, the app shows connection progress until code entry or a connection error. A WebSocket handshake that remains pending stops after ten seconds. Cancel is available until storage starts. It resets pairing and restores the entry form. Pasted codes remain available after an error or cancellation. Back navigation stays blocked during identity transfer and storage. Cancel on Send identity to desktop returns to Settings, so another attempt starts through the authorization step. ### Related issue Related to [#9001](https://github.com/block/buzz/pull/9001), which covers organization sign-in after identity transfer. This change covers the earlier connection stage. ### Testing A simulator build with PR #9001 completes identity transfer and saves the community. The community chooser still blocks organization sign-in. [PR #9028](https://github.com/block/buzz/pull/9028) covers that step. Physical iPhone testing and successful real-relay checks in light and dark mode remain pending. On a build with this change, scan a desktop pairing QR code. Check that progress appears, Cancel returns to the entry form, and a fresh attempt reaches code entry. Paste a pairing code, cancel during connection, and check that the form keeps the code. Test a failed connection and retry. In Settings, open Send identity to desktop, start a recovery connection, and cancel. Check that Settings appears and another attempt asks for authorization. Camera recognition speed and the reported intermittent phone network failure remain unverified. Generated with Codex --------- Signed-off-by: Daniel Rouleau <drouleau@squareup.com> Co-authored-by: Sol <c35e7cc95f6fdbfb7e861dccc02bdca567b6d6b20f16b5115c3c6ae082bbeede@buzz.block.builderlab.xyz>
490 lines
19 KiB
Dart
490 lines
19 KiB
Dart
import 'package:buzz/shared/widgets/feedback_snackbar.dart';
|
|
|
|
import 'community_link_focus.dart';
|
|
import '../../shared/widgets/ios_community_link_input.dart';
|
|
|
|
import 'dart:async';
|
|
|
|
import '../../shared/hosted/hosted_flow_provider.dart';
|
|
import '../../shared/hosted/hosted_flow.dart';
|
|
import '../../shared/hosted/hosted_community_creation.dart';
|
|
import '../../shared/hosted/hosted_environment.dart';
|
|
import '../../shared/hosted/hosted_flow_problem_message.dart';
|
|
|
|
import 'dart:math' show sqrt2;
|
|
import 'dart:math' as math;
|
|
import 'dart:ui' show ImageFilter;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import '../../shared/widgets/frosted_app_bar.dart';
|
|
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter/semantics.dart';
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
import 'package:flutter_svg/flutter_svg.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:buzz/shared/theme/buzz_icons.dart';
|
|
|
|
import '../../shared/security/sensitive_action_authorizer.dart';
|
|
import '../../shared/error_haptic.dart';
|
|
import '../../shared/deeplink/deep_link.dart';
|
|
import '../../shared/deeplink/community_link_input.dart';
|
|
import '../../shared/deeplink/pending_deep_link_provider.dart';
|
|
import '../../shared/community/community.dart';
|
|
import '../../shared/community/community_loading_surface.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 'add_community_page.dart';
|
|
import '../../shared/widgets/onboarding_step_page.dart';
|
|
import 'pairing_qr_scanner.dart';
|
|
|
|
part 'pairing_page/onboarding_background.dart';
|
|
part 'pairing_page/website_welcome_background.dart';
|
|
part 'pairing_page/website_bee_swarm.dart';
|
|
part 'pairing_page/website_bee_motion.dart';
|
|
part 'pairing_page/onboarding_colors.dart';
|
|
part 'pairing_page/onboarding_glass_button.dart';
|
|
part 'pairing_page/pairing_welcome_view.dart';
|
|
part 'pairing_page/two_finger_double_tap.dart';
|
|
part 'pairing_page/community_link_form.dart';
|
|
part 'pairing_page/onboarding_connection_view.dart';
|
|
part 'pairing_page/sas_verification_view.dart';
|
|
part 'pairing_page/pairing_code_entry.dart';
|
|
part 'pairing_page/pairing_error_shake.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;
|
|
final bool signedInOnboarding;
|
|
final bool hasKnownCommunities;
|
|
final VoidCallback? onAlreadyUseBuzz;
|
|
final VoidCallback? onWelcomeBack;
|
|
final bool connectionLanding;
|
|
final bool showConnectionOptions;
|
|
final bool existingUser;
|
|
final bool enteringCommunityLink;
|
|
final bool creatingCommunity;
|
|
final ValueChanged<String>? onSubmitCommunity;
|
|
final VoidCallback? onCommunityLink;
|
|
final VoidCallback? onCreateCommunity;
|
|
final VoidCallback? onBuilderlab;
|
|
final String? accountStatus;
|
|
final Widget? setupContent;
|
|
final String builderlabLabel;
|
|
final bool builderlabBusy;
|
|
final bool signInPending;
|
|
|
|
const PairingPage({
|
|
super.key,
|
|
this.addingCommunity = false,
|
|
this.identityRecoveryOnly = false,
|
|
this.signedInOnboarding = false,
|
|
this.hasKnownCommunities = false,
|
|
this.onAlreadyUseBuzz,
|
|
this.onWelcomeBack,
|
|
this.connectionLanding = false,
|
|
this.showConnectionOptions = true,
|
|
this.existingUser = false,
|
|
this.enteringCommunityLink = false,
|
|
this.creatingCommunity = false,
|
|
this.onSubmitCommunity,
|
|
this.onCommunityLink,
|
|
this.onCreateCommunity,
|
|
this.onBuilderlab,
|
|
this.accountStatus,
|
|
this.setupContent,
|
|
this.builderlabLabel = 'Connect to Builderlab',
|
|
this.builderlabBusy = false,
|
|
this.signInPending = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final hostedFlow = useListenable(
|
|
HostedEnvironment.enabled ? ref.watch(hostedFlowProvider) : null,
|
|
);
|
|
final pairingState = ref.watch(pairingProvider);
|
|
final enrolledBiometrics = ref.watch(enrolledBiometricsProvider);
|
|
final codeController = useTextEditingController();
|
|
final fallbackScannerVisible = useState(false);
|
|
final islandScannerProgress = useValueNotifier(0.0);
|
|
final pairingCodeExpanded = useState(false);
|
|
final optionsExpanded = 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) {
|
|
final route = ModalRoute.of(context);
|
|
if (route != null && route.isActive) {
|
|
final notifier = ref.read(pairingProvider.notifier);
|
|
Navigator.of(context).removeRoute(route);
|
|
// Reset only after removal so the dismissed page cannot flash its
|
|
// scanner, and subsequent recovery/onboarding starts fresh.
|
|
notifier.reset();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> handleScannerResult(String? code) async {
|
|
if (code != null && context.mounted) {
|
|
if (connectionLanding && !existingUser) {
|
|
final uri = Uri.tryParse(code.trim());
|
|
final link = uri == null ? null : parseBuzzDeepLink(uri);
|
|
if (link is InviteDeepLink || link is AddCommunityDeepLink) {
|
|
ref.read(pendingDeepLinkProvider.notifier).open(uri!);
|
|
return;
|
|
}
|
|
}
|
|
if (identityRecoveryOnly &&
|
|
Uri.tryParse(code)?.queryParameters['mode'] != 'recover') {
|
|
ScaffoldMessenger.of(context).showFeedbackSnackBar(
|
|
const SnackBar(content: Text('Scan a desktop recovery code.')),
|
|
kind: ToastKind.generic,
|
|
);
|
|
return;
|
|
}
|
|
await ref.read(pairingProvider.notifier).pair(code);
|
|
}
|
|
}
|
|
|
|
Future<void> openScanner() async {
|
|
unawaited(HapticFeedback.selectionClick());
|
|
final usesDynamicIslandPortal = await usesDynamicIslandQrScannerPortal();
|
|
if (!context.mounted) {
|
|
return;
|
|
}
|
|
|
|
if (!usesDynamicIslandPortal) {
|
|
fallbackScannerVisible.value = true;
|
|
return;
|
|
}
|
|
|
|
String? code;
|
|
try {
|
|
code = await showDynamicIslandPairingQrScanner(
|
|
context,
|
|
onProgress: (value) {
|
|
if (context.mounted) islandScannerProgress.value = value;
|
|
},
|
|
);
|
|
} finally {
|
|
if (context.mounted) islandScannerProgress.value = 0;
|
|
}
|
|
await handleScannerResult(code);
|
|
}
|
|
|
|
final isVerifyingSas = pairingState.status == PairingStatus.confirmingSas;
|
|
final websiteWelcome =
|
|
connectionLanding &&
|
|
!addingCommunity &&
|
|
!existingUser &&
|
|
!enteringCommunityLink &&
|
|
!creatingCommunity &&
|
|
setupContent == null &&
|
|
!isVerifyingSas;
|
|
final onboardingSystemOverlayStyle =
|
|
(context._onboardingIsDark && !websiteWelcome
|
|
? SystemUiOverlayStyle.light
|
|
: SystemUiOverlayStyle.dark)
|
|
.copyWith(statusBarColor: Colors.transparent);
|
|
final pairingAppBar = addingCommunity
|
|
? defaultTargetPlatform == TargetPlatform.iOS
|
|
? PreferredSize(
|
|
preferredSize: const Size.fromHeight(44),
|
|
child: Stack(
|
|
children: [
|
|
FrostedAppBar(
|
|
nativeTitle: identityRecoveryOnly
|
|
? 'Send to Desktop'
|
|
: '',
|
|
frosted: false,
|
|
|
|
iconColor: context._onboardingInk,
|
|
|
|
title: identityRecoveryOnly
|
|
? const Text('Send to Desktop')
|
|
: null,
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: PreferredSize(
|
|
preferredSize: Size.fromHeight(
|
|
frostedAppBarHeight(context) -
|
|
MediaQuery.paddingOf(context).top,
|
|
),
|
|
child: Stack(
|
|
children: [
|
|
FrostedAppBar(
|
|
alwaysFrosted: true,
|
|
leading: IconButton(
|
|
tooltip: 'Back',
|
|
icon: const Icon(BuzzIcons.arrowLeft),
|
|
onPressed: () => Navigator.of(context).maybePop(),
|
|
),
|
|
iconColor: context._onboardingInk,
|
|
title: identityRecoveryOnly
|
|
? const Text('Send to Desktop')
|
|
: null,
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: null;
|
|
|
|
final showLoading =
|
|
pairingState.status == PairingStatus.transferring ||
|
|
pairingState.status == PairingStatus.storing ||
|
|
pairingState.status == PairingStatus.success;
|
|
final destinationRelay = pairingState.destinationRelayUrl;
|
|
final verificationView = _SasVerificationView(
|
|
key: ValueKey(pairingState.sasCode),
|
|
fullScreen: addingCommunity && connectionLanding,
|
|
sasCode: pairingState.sasCode ?? '------',
|
|
verifyDesktopCode: pairingState.requiresDesktopCode
|
|
? ref.read(pairingProvider.notifier).verifyDesktopCode
|
|
: null,
|
|
confirmed: pairingState.userConfirmedSas,
|
|
sendsIdentityToDesktop: pairingState.sendsIdentityToDesktop,
|
|
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(),
|
|
);
|
|
final pairingScaffold = showLoading
|
|
? CommunityLoadingSurface(
|
|
key: const Key('pairing-community-loading'),
|
|
name: destinationRelay == null
|
|
? null
|
|
: Community.nameFromUrl(destinationRelay),
|
|
relayUrl: destinationRelay,
|
|
)
|
|
: isVerifyingSas
|
|
? addingCommunity && connectionLanding
|
|
? verificationView
|
|
: AnnotatedRegion<SystemUiOverlayStyle>(
|
|
key: const Key('pairing-sas-system-overlay'),
|
|
value: onboardingSystemOverlayStyle,
|
|
child: _OnboardingBackground(
|
|
child: Scaffold(
|
|
resizeToAvoidBottomInset:
|
|
hostedFlow?.stage != HostedFlowStage.profile,
|
|
backgroundColor: Colors.transparent,
|
|
body: SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: Grid.sm,
|
|
),
|
|
child: verificationView,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
: (addingCommunity || (signedInOnboarding && !creatingCommunity)) &&
|
|
connectionLanding
|
|
? ValueListenableBuilder<double>(
|
|
valueListenable: islandScannerProgress,
|
|
builder: (context, progress, _) => AddCommunityPage(
|
|
scannerProgress: progress,
|
|
onboarding: signedInOnboarding,
|
|
onFindCommunities: signedInOnboarding && hasKnownCommunities
|
|
? onBuilderlab
|
|
: null,
|
|
onCreateCommunity: signedInOnboarding ? onCreateCommunity : null,
|
|
enteringCommunityLink: enteringCommunityLink,
|
|
existingUser: existingUser,
|
|
onBack: onWelcomeBack ?? () => Navigator.of(context).pop(),
|
|
onCommunityLink: onCommunityLink,
|
|
onPairing: onAlreadyUseBuzz,
|
|
onScan: openScanner,
|
|
onOpenCommunity: (uri) {
|
|
ref.read(hostedInlineEntryProvider.notifier).setActive(true);
|
|
ref.read(pendingDeepLinkProvider.notifier).open(uri);
|
|
},
|
|
onSubmitPairing: (code) => unawaited(handleScannerResult(code)),
|
|
accountStatus: accountStatus,
|
|
busy:
|
|
isBusy ||
|
|
builderlabBusy ||
|
|
(hostedFlow != null &&
|
|
!hostedFlow.accountOnly &&
|
|
(hostedFlow.stage == HostedFlowStage.working ||
|
|
hostedFlow.stage == HostedFlowStage.signIn)),
|
|
canSubmit:
|
|
hostedFlow == null ||
|
|
hostedFlow.accountOnly ||
|
|
hostedFlow.problem == null ||
|
|
hostedFlow.canRetry,
|
|
errorMessage:
|
|
pairingState.errorMessage ??
|
|
(hostedFlow != null &&
|
|
!hostedFlow.accountOnly &&
|
|
hostedFlow.problem != null
|
|
? hostedFlowProblemMessage(hostedFlow.problem)
|
|
: null),
|
|
onInputChanged: () {
|
|
if (hostedFlow != null &&
|
|
!hostedFlow.accountOnly &&
|
|
hostedFlow.problem != null) {
|
|
unawaited(hostedFlow.leave());
|
|
}
|
|
},
|
|
),
|
|
)
|
|
: AnnotatedRegion<SystemUiOverlayStyle>(
|
|
key: const Key('pairing-onboarding-system-overlay'),
|
|
value: onboardingSystemOverlayStyle,
|
|
child: _OnboardingBackground(
|
|
websiteWelcome: websiteWelcome,
|
|
scannerProgress: islandScannerProgress,
|
|
child: Scaffold(
|
|
resizeToAvoidBottomInset:
|
|
hostedFlow?.stage != HostedFlowStage.profile,
|
|
backgroundColor: Colors.transparent,
|
|
appBar: pairingAppBar,
|
|
body: SafeArea(
|
|
child: ValueListenableBuilder<double>(
|
|
valueListenable: islandScannerProgress,
|
|
builder: (context, progress, child) => ExcludeFocus(
|
|
excluding: progress > 0,
|
|
child: ExcludeSemantics(
|
|
excluding: progress > 0,
|
|
child: IgnorePointer(
|
|
ignoring: progress > 0,
|
|
child: Opacity(
|
|
key: const Key('scanner-welcome-opacity'),
|
|
opacity: 1 - progress,
|
|
child: Transform.scale(
|
|
key: const Key('scanner-welcome-scale'),
|
|
scale: MediaQuery.disableAnimationsOf(context)
|
|
? 1
|
|
: 1 - 0.04 * progress,
|
|
child: child,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
child: _PairingWelcomeView(
|
|
optionsExpanded: optionsExpanded,
|
|
onAlreadyUseBuzz: onAlreadyUseBuzz,
|
|
onWelcomeBack: onWelcomeBack,
|
|
connectionLanding: connectionLanding,
|
|
showConnectionOptions: showConnectionOptions,
|
|
existingUser: existingUser,
|
|
enteringCommunityLink: enteringCommunityLink,
|
|
creatingCommunity: creatingCommunity,
|
|
onSubmitCommunity: onSubmitCommunity,
|
|
onCommunityLink: onCommunityLink,
|
|
onCreateCommunity: onCreateCommunity,
|
|
onOpenCommunity: (uri) {
|
|
FocusManager.instance.primaryFocus?.unfocus();
|
|
ref.read(pendingDeepLinkProvider.notifier).open(uri);
|
|
},
|
|
onBuilderlab: onBuilderlab,
|
|
accountStatus: signedInOnboarding ? null : accountStatus,
|
|
setupContent: setupContent,
|
|
builderlabLabel: builderlabLabel,
|
|
builderlabBusy: builderlabBusy,
|
|
signInPending: signInPending,
|
|
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'),
|
|
canPop: !showLoading,
|
|
onPopInvokedWithResult: (didPop, _) {
|
|
if (didPop && pairingState.status != PairingStatus.success) {
|
|
ref.read(pairingProvider.notifier).reset();
|
|
}
|
|
},
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
ExcludeFocus(
|
|
excluding: pairingState.status == PairingStatus.connecting,
|
|
child: Offstage(
|
|
offstage: pairingState.status == PairingStatus.connecting,
|
|
child: pairingScaffold,
|
|
),
|
|
),
|
|
if (pairingState.status == PairingStatus.connecting)
|
|
const CommunityLoadingSurface(
|
|
key: Key('pairing-community-loading'),
|
|
),
|
|
if (pairingState.status == PairingStatus.connecting)
|
|
SafeArea(
|
|
child: Align(
|
|
alignment: Alignment.topLeft,
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: TextButton(
|
|
onPressed: () {
|
|
ref.read(pairingProvider.notifier).reset();
|
|
if (identityRecoveryOnly) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
},
|
|
child: const Text('Cancel'),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (!fallbackScannerVisible.value) {
|
|
return appSurface;
|
|
}
|
|
|
|
return FallbackPairingQrScanner(
|
|
appSurface: appSurface,
|
|
onClosed: (code) {
|
|
fallbackScannerVisible.value = false;
|
|
unawaited(handleScannerResult(code));
|
|
},
|
|
);
|
|
}
|
|
}
|