mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
Make QR scanning the primary onboarding action, with full-width contrasting buttons inside the existing gutters. The landing offers **Scan a QR code** and **or sign in with email**. A two-finger double tap on the open welcome area reveals **Enter a community link** and **Use a pairing code**; a screen-reader action provides the same access. Action buttons remain outside double-tap detection so scanner taps respond immediately. Pending sign-in keeps its cancellation action. The Dynamic Island scanner uses 230 ms opening and closing springs. Welcome content fades and scales with the same progress, camera startup begins on the first frame, the scanner prompt fades with the camera reveal, the live preview adds a 230 ms fade when camera startup completes, and opening gives selection haptics. Native iOS status indicators request a matching 230 ms fade, returning when dismissal begins. Reduced motion is respected. Validation: full mobile suites and analysis run through local checks and pre-push hooks. The 230 ms iOS Release build was signed, installed, and launched in the separate Onboarding app on iPhone Air. The owner reviewed the simplified landing and immediate tap response; the latest 230 ms timing is installed for comparison. The latest 230 ms build, including the prompt fade, is installed and launched for comparison. Native status-bar timing has not been independently measured on-device. Product intent confirmed by Kenny: the supported visible onboarding choices are email sign-in or QR scanning. Camera permission/startup failure intentionally does not expose manual pairing or community-link recovery. Those remain hidden developer options, with no keyboard path by design. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
389 lines
16 KiB
Dart
389 lines
16 KiB
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/cupertino.dart'
|
|
show CupertinoColors, CupertinoTextField;
|
|
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 '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).pop(),
|
|
),
|
|
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 pairingScaffold = showLoading
|
|
? CommunityLoadingSurface(
|
|
key: const Key('pairing-community-loading'),
|
|
name: destinationRelay == null
|
|
? null
|
|
: Community.nameFromUrl(destinationRelay),
|
|
relayUrl: destinationRelay,
|
|
)
|
|
: isVerifyingSas
|
|
? 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: _SasVerificationView(
|
|
key: ValueKey(pairingState.sasCode),
|
|
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(),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
: 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));
|
|
},
|
|
);
|
|
}
|
|
}
|