Files
buzz/mobile/lib/features/pairing/pairing_page.dart
Wes af54258f19 fix(mobile): streamline identity setup and community selection (#9070)
Selecting a saved community now connects only that destination, and
**Find my communities** remains available through **Switcher → Add
Community**. Failed destinations are named; completing nested Add/Find
opens the selected destination without resuming the outer batch.

Fresh hosted account setup now creates and backs up an identity
automatically through email sign-in, invites, community links and
community creation when the resolver confirms there are no
account-bound, backed-up, saved or device identities. Welcome has one
email sign-in entry. Authenticated import, existing-key recovery and
selection retain their explicit paths; pending import survives restart
and attaching a destination. Backup retry retains the same identity.

Web binding retains explicit identity creation and its browser-return
behavior. Self-hosted setup is separate and explicit. New-key creation
still requires platform sealing support; passive account restoration
never creates, binds or backs up an identity.

**Account tradeoff:** setup cannot discover a key stored only elsewhere
and never associated with this account. Such an account receives a new
backed-up, bound identity automatically. Importing a different old key
afterward fails with a binding mismatch; this flow does not undo that
account binding. There is no separate pre-authentication import button.

Pending import now survives retained community/web-bind link decoding:
internal import metadata is stripped before strict public-link parsing.
Once an identity is saved and resolved, the import request is retired
before binding or backup, so restart resumes the existing key. Passive
restoration also repairs stale import intents written by earlier builds
without creating, binding or backing up a key.

Validation (`f114c0e228`):
- All 30 hosted onboarding journeys pass. Import coverage includes
invite-to-community-link retention, restart before import, failed-backup
restart, successful-backup restart, and an older stale import intent.
- Three isolated production mutations (decode filtering, import
completion persistence, passive stale-intent handling) each fail the
relevant journey; restored control passes. Earlier auto-creation and
nested-selection mutations also failed their relevant journeys.
- Carl reviewed both P2 fixes and the final commit with no blockers.
Carl and Pinky previously cleared the shared setup behavior and Android
parity.
- Wes confirmed Android sign-in and fresh account creation on the
preceding build `be5690c684`. Android/iOS builds and earlier manual
acceptance do not establish native acceptance of the new import/restart
fixes. Installed devices remain on that preceding build.

**Remaining acceptance:** current-head CI and native validation of the
import/restart edge cases. Required publication hooks passed, including
mobile analysis/tests, branch-skew and file-size checks. The existing
iOS 16 sealing limitation remains: it cannot create a fresh hosted
identity. No membership/ownership repair, denial-classification change
or floating sign-in-control fix is included.

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
2026-11-04 14:42:24 -08:00

492 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 VoidCallback? onImportIdentity;
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.onImportIdentity,
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) && connectionLanding
? ValueListenableBuilder<double>(
valueListenable: islandScannerProgress,
builder: (context, progress, _) => AddCommunityPage(
scannerProgress: progress,
onboarding: signedInOnboarding,
onFindCommunities: hasKnownCommunities ? onBuilderlab : null,
onImportIdentity: signedInOnboarding ? onImportIdentity : null,
onCreateCommunity: signedInOnboarding ? onCreateCommunity : null,
enteringCommunityLink: enteringCommunityLink,
creatingCommunity: creatingCommunity,
onSubmitCommunity: onSubmitCommunity,
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));
},
);
}
}