Files
buzz/mobile/lib/features/pairing/pairing_page.dart
klopez4212 e922dc08ff Unify mobile onboarding and community entry screens (#8687)
## Summary

Use consistent fullscreen screens for identity setup, community
selection, joining, and Add Community. Restore all memberships and open
the selected community; share the community grid with the switcher.

Add QR-first choices, animated link entry with 12px keyboard clearance,
a two-finger double-tap pairing toggle, and the centered reversing bee
loader. Preserve cancellation, Back, and reduced-motion behavior.

Add intentionally omits community creation and Builderlab sign-in: this
route follows sign-in and the existing-community picker.

### Related issue

Related: #8628 (native profile setup). No duplicate fullscreen
onboarding PR found.

### Testing

- Full standard and hosted-authentication mobile suites, static
analysis, and required push checks passed.
- iPhone preview iterated with Kenny; final simulator walkthrough below
uses synthetic data and does not authenticate or save account changes.
- Independent review completed; regressions cover cancellation during
loading, Add recovery after failed/conflicting memberships, retrying
skipped communities, and explicit picker stages.

Walkthrough recorded before the copy and recovery followups:


https://github.com/user-attachments/assets/bfea3ad3-1dde-40c4-8748-44c84226c292

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-29 16:12:31 +00:00

438 lines
18 KiB
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: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: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/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 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.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).showSnackBar(
const SnackBar(content: Text('Scan a desktop recovery code.')),
);
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 onboardingSystemOverlayStyle =
(context._onboardingIsDark
? 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,
showBottomDivider: false,
iconColor: context._onboardingInk,
title: identityRecoveryOnly
? const Text('Send to Desktop')
: null,
),
],
),
)
: AppBar(
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
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(BuzzIcons.arrowLeft),
tooltip: 'Back',
onPressed: () => Navigator.of(context).maybePop(),
),
title: identityRecoveryOnly
? Text(
'Send to Desktop',
style: context.textTheme.titleMedium?.copyWith(
color: context._onboardingInk,
),
)
: 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 && connectionLanding
? ValueListenableBuilder<double>(
valueListenable: islandScannerProgress,
builder: (context, progress, _) => AddCommunityPage(
scannerProgress: progress,
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(
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: 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'),
onPopInvokedWithResult: (didPop, _) {
if (didPop && pairingState.status != PairingStatus.success) {
ref.read(pairingProvider.notifier).reset();
}
},
child: pairingScaffold,
);
if (!fallbackScannerVisible.value) {
return appSurface;
}
return FallbackPairingQrScanner(
appSurface: appSurface,
onClosed: (code) {
fallbackScannerVisible.value = false;
unawaited(handleScannerResult(code));
},
);
}
}