From ff9eb8f4dc56c312983287321ea8748b93c62145 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 27 Oct 2026 16:32:04 +0000 Subject: [PATCH] Make mobile onboarding QR-first and synchronize scanner motion (#8599) 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 Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> --- mobile/ios/Runner/AppDelegate.swift | 7 +- mobile/ios/Runner/Base.lproj/Main.storyboard | 2 +- mobile/ios/Runner/SceneDelegate.swift | 26 +++ mobile/lib/features/pairing/pairing_page.dart | 117 +++++++++----- .../onboarding_connection_view.dart | 34 ++-- .../pairing_page/pairing_welcome_view.dart | 108 +++++++++---- .../pairing_page/two_finger_double_tap.dart | 83 ++++++++++ .../features/pairing/pairing_qr_scanner.dart | 15 +- .../dynamic_island_portal.dart | 60 ++++--- .../pairing_qr_scanner/scanner_camera.dart | 107 +++++++----- mobile/lib/onboarding_app_entry.dart | 3 +- .../onboarding/hosted_communities_test.dart | 114 ++++++++++--- .../pairing/pairing_qr_scanner_test.dart | 153 +++++++++++++++++- mobile/test/widget_test.dart | 12 +- 14 files changed, 638 insertions(+), 203 deletions(-) create mode 100644 mobile/lib/features/pairing/pairing_page/two_finger_double_tap.dart diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 0da24ca08d..4e59478ad6 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -466,7 +466,12 @@ import os.log ) return } - UIApplication.shared.setStatusBarHidden(hidden, with: .fade) + let controller = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .compactMap { $0.rootViewController as? BuzzFlutterViewController } + .first + controller?.setScannerStatusBarHidden(hidden) result(nil) case "performDynamicIslandQrScanSuccessHaptic": let generator = UINotificationFeedbackGenerator() diff --git a/mobile/ios/Runner/Base.lproj/Main.storyboard b/mobile/ios/Runner/Base.lproj/Main.storyboard index f3c28516fb..5f178871bd 100644 --- a/mobile/ios/Runner/Base.lproj/Main.storyboard +++ b/mobile/ios/Runner/Base.lproj/Main.storyboard @@ -8,7 +8,7 @@ - + diff --git a/mobile/ios/Runner/SceneDelegate.swift b/mobile/ios/Runner/SceneDelegate.swift index b9ce8ea2b2..6464f1a2dd 100644 --- a/mobile/ios/Runner/SceneDelegate.swift +++ b/mobile/ios/Runner/SceneDelegate.swift @@ -4,3 +4,29 @@ import UIKit class SceneDelegate: FlutterSceneDelegate { } + +/// Owns scanner visibility without replacing Flutter's normal status-bar policy. +@objc(BuzzFlutterViewController) +class BuzzFlutterViewController: FlutterViewController { + private var scannerHidesStatusBar = false + + override var prefersStatusBarHidden: Bool { + scannerHidesStatusBar || super.prefersStatusBarHidden + } + + override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation { + UIAccessibility.isReduceMotionEnabled ? .none : .fade + } + + func setScannerStatusBarHidden(_ hidden: Bool) { + guard scannerHidesStatusBar != hidden else { return } + scannerHidesStatusBar = hidden + UIView.animate( + withDuration: UIAccessibility.isReduceMotionEnabled ? 0 : 0.23, + delay: 0, + options: [.beginFromCurrentState, .curveEaseInOut] + ) { + self.setNeedsStatusBarAppearanceUpdate() + } + } +} diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index ad3a12f5f8..05984efc42 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -13,6 +13,8 @@ import 'package:flutter/cupertino.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'; @@ -35,6 +37,7 @@ 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'; @@ -61,6 +64,7 @@ class PairingPage extends HookConsumerWidget { final Widget? setupContent; final String builderlabLabel; final bool builderlabBusy; + final bool signInPending; const PairingPage({ super.key, @@ -81,6 +85,7 @@ class PairingPage extends HookConsumerWidget { this.setupContent, this.builderlabLabel = 'Connect to Builderlab', this.builderlabBusy = false, + this.signInPending = false, }); @override @@ -92,6 +97,7 @@ class PairingPage extends HookConsumerWidget { 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 = @@ -137,6 +143,7 @@ class PairingPage extends HookConsumerWidget { } Future openScanner() async { + unawaited(HapticFeedback.selectionClick()); final usesDynamicIslandPortal = await usesDynamicIslandQrScannerPortal(); if (!context.mounted) { return; @@ -147,7 +154,17 @@ class PairingPage extends HookConsumerWidget { return; } - final code = await showDynamicIslandPairingQrScanner(context); + 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); } @@ -279,43 +296,67 @@ class PairingPage extends HookConsumerWidget { backgroundColor: Colors.transparent, appBar: pairingAppBar, body: SafeArea( - 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, - 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)); - } - }, + child: ValueListenableBuilder( + 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)); + } + }, + ), ), ), ), diff --git a/mobile/lib/features/pairing/pairing_page/onboarding_connection_view.dart b/mobile/lib/features/pairing/pairing_page/onboarding_connection_view.dart index bf21893793..72d3341f57 100644 --- a/mobile/lib/features/pairing/pairing_page/onboarding_connection_view.dart +++ b/mobile/lib/features/pairing/pairing_page/onboarding_connection_view.dart @@ -7,7 +7,6 @@ class _OnboardingConnectionView extends HookWidget { required this.onCommunityLink, required this.onBack, required this.onOpenCommunity, - required this.onScan, required this.onPairing, }); @@ -15,7 +14,6 @@ class _OnboardingConnectionView extends HookWidget { final VoidCallback? onCommunityLink; final VoidCallback? onBack; final ValueChanged onOpenCommunity; - final VoidCallback? onScan; final VoidCallback? onPairing; double _textProgress(double progress, bool opening) => @@ -145,7 +143,7 @@ class _OnboardingConnectionView extends HookWidget { child: const _ConnectionHeading( title: 'Connect to a community', subtitle: - 'Scan a QR code or enter a community link to continue.', + 'Enter a community link or use a pairing code to continue.', ), ), _ConnectionMotionLayer( @@ -173,12 +171,11 @@ class _OnboardingConnectionView extends HookWidget { active: !enteringLink, maintainSize: true, child: _OnboardingChoiceContainer( - title: 'Scan a QR code', + title: 'Use a pairing code', icon: BuzzIcons.qrcode, subtitle: - 'Scan a community or desktop pairing code.', - onTap: onScan, - onIconDoubleTap: onPairing, + 'Paste the pairing code from your desktop app.', + onTap: onPairing, ), ), ), @@ -408,7 +405,6 @@ class _OnboardingChoiceContainer extends StatelessWidget { required this.subtitle, required this.icon, this.onTap, - this.onIconDoubleTap, this.transparent = false, }); @@ -417,7 +413,6 @@ class _OnboardingChoiceContainer extends StatelessWidget { final IconData icon; final bool transparent; final VoidCallback? onTap; - final VoidCallback? onIconDoubleTap; @override Widget build(BuildContext context) => Semantics( @@ -445,20 +440,13 @@ class _OnboardingChoiceContainer extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ ExcludeSemantics( - child: GestureDetector( - key: onIconDoubleTap == null - ? null - : const Key('developer-pairing-trigger'), - excludeFromSemantics: true, - onDoubleTap: onIconDoubleTap, - child: SizedBox( - width: Grid.sm, - height: Grid.sm, - child: Icon( - icon, - size: Grid.sm, - color: context.colors.onSurface, - ), + child: SizedBox( + width: Grid.sm, + height: Grid.sm, + child: Icon( + icon, + size: Grid.sm, + color: context.colors.onSurface, ), ), ), diff --git a/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart b/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart index 63bc191344..d49fc1ddf6 100644 --- a/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart +++ b/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart @@ -24,6 +24,7 @@ class _PairingWelcomeView extends HookWidget { final Widget? setupContent; final String builderlabLabel; final bool builderlabBusy; + final bool signInPending; final ValueChanged onOpenCommunity; const _PairingWelcomeView({ @@ -51,6 +52,7 @@ class _PairingWelcomeView extends HookWidget { this.setupContent, this.builderlabLabel = 'Connect to Builderlab', this.builderlabBusy = false, + this.signInPending = false, }); @override @@ -124,7 +126,16 @@ class _PairingWelcomeView extends HookWidget { const SizedBox(height: Grid.xs), ], if (page == 1) ...[ - if (HostedEnvironment.enabled) + if (!showConnectionOptions && !signInPending) + SizedBox( + width: double.infinity, + child: FilledButton( + style: context._onboardingButtonStyle, + onPressed: isBusy ? null : onScan, + child: const Text('Scan a QR code'), + ), + ) + else if (HostedEnvironment.enabled) FilledButton( style: context._onboardingGlassButtonStyle, onPressed: onBuilderlab, @@ -136,14 +147,17 @@ class _PairingWelcomeView extends HookWidget { ) : Text(builderlabLabel), ), - if (!showConnectionOptions) ...[ + if (!showConnectionOptions && + !signInPending && + HostedEnvironment.enabled) ...[ const SizedBox(height: Grid.xxs), - TextButton( - style: context._onboardingGhostButtonStyle, - onPressed: isBusy - ? null - : () => optionsExpanded.value = !optionsExpanded.value, - child: const Text('Other options'), + SizedBox( + width: double.infinity, + child: TextButton( + style: context._onboardingGhostButtonStyle, + onPressed: isBusy ? null : onBuilderlab, + child: const Text('or sign in with email'), + ), ), ], if (showConnectionOptions) ...connectionActions(), @@ -298,6 +312,17 @@ class _PairingWelcomeView extends HookWidget { ], ); + void revealOptions() { + optionsExpanded.value = true; + } + + final canRevealOptions = + connectionLanding && + !showConnectionOptions && + !existingUser && + setupContent == null && + !signInPending && + !isBusy; final welcome = Padding( padding: const EdgeInsets.symmetric( horizontal: Grid.gutter, @@ -313,31 +338,46 @@ class _PairingWelcomeView extends HookWidget { child: Column( children: [ Expanded( - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 320), - child: AspectRatio( - aspectRatio: 777 / 326, - child: OnboardingWordmark( - key: const Key('pairing-buzz-wordmark'), - color: context._onboardingIsDark - ? context._onboardingInk - : null, + child: _TwoFingerDoubleTapSurface( + onDoubleTap: canRevealOptions ? revealOptions : null, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: AspectRatio( + aspectRatio: 777 / 326, + child: Semantics( + // Hidden developer entry intentionally has no + // keyboard shortcut. Keep its assistive action + // on the existing labelled wordmark node. + customSemanticsActions: canRevealOptions + ? { + const CustomSemanticsAction( + label: 'More connection options', + ): revealOptions, + } + : null, + child: OnboardingWordmark( + key: const Key('pairing-buzz-wordmark'), + color: context._onboardingIsDark + ? context._onboardingInk + : null, + ), + ), ), ), - ), - const SizedBox(height: Grid.xxs), - Text( - 'Your people, your agents, your projects —\nall in one place.', - textAlign: TextAlign.center, - style: context.textTheme.bodyLarge?.copyWith( - color: context._onboardingInk, + const SizedBox(height: Grid.xxs), + Text( + 'Your people, your agents, your projects —\nall in one place.', + textAlign: TextAlign.center, + style: context.textTheme.bodyLarge?.copyWith( + color: context._onboardingInk, + ), ), - ), - ], + ], + ), ), ), ), @@ -455,8 +495,12 @@ class _PairingWelcomeView extends HookWidget { } }, onOpenCommunity: onOpenCommunity, - onScan: isBusy ? null : onScan, - onPairing: isBusy ? null : onAlreadyUseBuzz, + onPairing: isBusy + ? null + : () { + optionsExpanded.value = false; + onAlreadyUseBuzz?.call(); + }, ), ), ), diff --git a/mobile/lib/features/pairing/pairing_page/two_finger_double_tap.dart b/mobile/lib/features/pairing/pairing_page/two_finger_double_tap.dart new file mode 100644 index 0000000000..beb3235b76 --- /dev/null +++ b/mobile/lib/features/pairing/pairing_page/two_finger_double_tap.dart @@ -0,0 +1,83 @@ +part of '../pairing_page.dart'; + +class _TwoFingerDoubleTapSurface extends HookWidget { + const _TwoFingerDoubleTapSurface({required this.child, this.onDoubleTap}); + + final Widget child; + final VoidCallback? onDoubleTap; + + @override + Widget build(BuildContext context) { + final taps = useMemoized(_TwoFingerTapTracker.new, [onDoubleTap != null]); + return Listener( + behavior: HitTestBehavior.opaque, + onPointerDown: onDoubleTap == null ? null : taps.down, + onPointerMove: onDoubleTap == null ? null : taps.move, + onPointerCancel: onDoubleTap == null ? null : (_) => taps.cancel(), + onPointerUp: onDoubleTap == null + ? null + : (event) => taps.up(event, onDoubleTap!), + child: child, + ); + } +} + +// Observe two short, stationary two-finger chords without competing with taps +// or scrolling in Flutter's gesture arena. +class _TwoFingerTapTracker { + final _starts = {}; + final _down = {}; + Duration _started = Duration.zero; + Duration? _previousEnd; + Offset? _previousCenter; + bool _valid = true; + + void down(PointerDownEvent event) { + if (_down.isEmpty) { + _starts.clear(); + _started = event.timeStamp; + _valid = true; + } + _starts[event.pointer] = event.position; + _down.add(event.pointer); + if (_starts.length > 2) _valid = false; + } + + void move(PointerMoveEvent event) { + final start = _starts[event.pointer]; + if (start != null && (event.position - start).distance > kTouchSlop) { + _valid = false; + } + } + + void cancel() { + _down.clear(); + _starts.clear(); + _previousEnd = null; + _previousCenter = null; + _valid = false; + } + + void up(PointerUpEvent event, VoidCallback onDoubleTap) { + if (!_down.remove(event.pointer) || _down.isNotEmpty) return; + if (!_valid || + _starts.length != 2 || + event.timeStamp - _started > kDoubleTapTimeout) { + cancel(); + return; + } + final center = (_starts.values.first + _starts.values.last) / 2; + final gap = _previousEnd == null ? null : _started - _previousEnd!; + final isDouble = + gap != null && + gap >= kDoubleTapMinTime && + gap <= kDoubleTapTimeout && + (center - _previousCenter!).distance <= kDoubleTapSlop; + _previousEnd = event.timeStamp; + _previousCenter = center; + if (isDouble) { + cancel(); + onDoubleTap(); + } + } +} diff --git a/mobile/lib/features/pairing/pairing_qr_scanner.dart b/mobile/lib/features/pairing/pairing_qr_scanner.dart index ff6c14f2f9..95891855ef 100644 --- a/mobile/lib/features/pairing/pairing_qr_scanner.dart +++ b/mobile/lib/features/pairing/pairing_qr_scanner.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/physics.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:buzz/shared/theme/buzz_icons.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; @@ -44,7 +45,10 @@ Future usesDynamicIslandQrScannerPortal() async { /// Callers determine device support with /// [usesDynamicIslandQrScannerPortal]. Standard devices reveal the camera /// behind their existing app surface with [FallbackPairingQrScanner]. -Future showDynamicIslandPairingQrScanner(BuildContext context) { +Future showDynamicIslandPairingQrScanner( + BuildContext context, { + ValueChanged? onProgress, +}) { return Navigator.of(context).push( PageRouteBuilder( opaque: false, @@ -52,7 +56,8 @@ Future showDynamicIslandPairingQrScanner(BuildContext context) { barrierDismissible: false, transitionDuration: Duration.zero, reverseTransitionDuration: Duration.zero, - pageBuilder: (_, _, _) => const _DynamicIslandQrScannerPortal(), + pageBuilder: (_, _, _) => + _DynamicIslandQrScannerPortal(onProgress: onProgress), ), ); } @@ -118,7 +123,6 @@ class DynamicIslandQrScannerGeometry { static const _edgeMargin = 15.0; static const _expandedCornerRadius = 40.0; static const _scannerFadeStart = 0.18; - static const _introLabelFadeEnd = 0.42; /// The physical-island-aligned top edge used throughout the morph. double get top => @@ -168,9 +172,4 @@ class DynamicIslandQrScannerGeometry { 1.0, ); } - - /// Returns the opacity of the compact prompt inside the island pill. - double introLabelOpacityAt(double progress) { - return (1 - (progress / _introLabelFadeEnd)).clamp(0.0, 1.0); - } } diff --git a/mobile/lib/features/pairing/pairing_qr_scanner/dynamic_island_portal.dart b/mobile/lib/features/pairing/pairing_qr_scanner/dynamic_island_portal.dart index 2e19c4d19f..c2f8647a21 100644 --- a/mobile/lib/features/pairing/pairing_qr_scanner/dynamic_island_portal.dart +++ b/mobile/lib/features/pairing/pairing_qr_scanner/dynamic_island_portal.dart @@ -2,11 +2,13 @@ part of '../pairing_qr_scanner.dart'; // A smooth, non-bouncy spring inspired by Apple's public motion guidance; // these are our timings, not private Dynamic Island animation constants. -const _dynamicIslandOpenDuration = Duration(milliseconds: 440); -const _dynamicIslandCloseDuration = Duration(milliseconds: 360); +const _dynamicIslandOpenDuration = Duration(milliseconds: 230); +const _dynamicIslandCloseDuration = Duration(milliseconds: 230); class _DynamicIslandQrScannerPortal extends HookWidget { - const _DynamicIslandQrScannerPortal(); + const _DynamicIslandQrScannerPortal({this.onProgress}); + + final ValueChanged? onProgress; @override Widget build(BuildContext context) { @@ -15,7 +17,6 @@ class _DynamicIslandQrScannerPortal extends HookWidget { final isClosing = useState(false); final canPop = useState(false); final hasHandledResult = useRef(false); - final cameraMounted = useRef(false); final reduceMotion = MediaQuery.disableAnimationsOf(context); useEffect(() { @@ -26,6 +27,29 @@ class _DynamicIslandQrScannerPortal extends HookWidget { }; }, [controller]); + useEffect(() { + var active = true; + void publish() { + if (active) onProgress?.call(animation.value.clamp(0.0, 1.0)); + } + + void changed() { + // Reduced motion can update the controller while this route builds. + if (WidgetsBinding.instance.schedulerPhase == + SchedulerPhase.persistentCallbacks) { + WidgetsBinding.instance.addPostFrameCallback((_) => publish()); + } else { + publish(); + } + } + + animation.addListener(changed); + return () { + active = false; + animation.removeListener(changed); + }; + }, [animation, onProgress]); + Future finish(String? result) async { if (canPop.value) return; canPop.value = true; @@ -74,6 +98,7 @@ class _DynamicIslandQrScannerPortal extends HookWidget { void closePortal() { if (isClosing.value || hasHandledResult.value) return; hasHandledResult.value = true; + unawaited(_setDynamicIslandScannerStatusBarHidden(false)); isClosing.value = true; } @@ -130,16 +155,6 @@ class _DynamicIslandQrScannerPortal extends HookWidget { final scannerOpacity = geometry.scannerOpacityAt( progress, ); - // Once revealed, keep the camera mounted until the route - // closes. Native camera teardown can stall a moving frame. - if (scannerOpacity > 0) cameraMounted.value = true; - final introLabelOpacity = isClosing.value - ? 0.0 - : geometry.introLabelOpacityAt(progress); - final promptOpacity = math.max( - introLabelOpacity, - scannerOpacity, - ); return Positioned.fromRect( rect: frame, @@ -156,17 +171,18 @@ class _DynamicIslandQrScannerPortal extends HookWidget { child: Stack( fit: StackFit.expand, children: [ - if (cameraMounted.value) - Opacity( - opacity: scannerOpacity, - child: _QrScannerCamera( - controller: controller, - onDetect: handleDetection, - ), + // Start warming the camera on the first frame, + // and keep it mounted throughout dismissal. + Opacity( + opacity: scannerOpacity, + child: _QrScannerCamera( + controller: controller, + onDetect: handleDetection, ), + ), IgnorePointer( child: Opacity( - opacity: promptOpacity, + opacity: scannerOpacity, child: Center( child: Text( 'Scan a QR code', diff --git a/mobile/lib/features/pairing/pairing_qr_scanner/scanner_camera.dart b/mobile/lib/features/pairing/pairing_qr_scanner/scanner_camera.dart index a5f85a1f15..36fb196700 100644 --- a/mobile/lib/features/pairing/pairing_qr_scanner/scanner_camera.dart +++ b/mobile/lib/features/pairing/pairing_qr_scanner/scanner_camera.dart @@ -1,6 +1,6 @@ part of '../pairing_qr_scanner.dart'; -class _QrScannerCamera extends StatelessWidget { +class _QrScannerCamera extends HookWidget { const _QrScannerCamera({required this.controller, required this.onDetect}); final MobileScannerController controller; @@ -8,46 +8,79 @@ class _QrScannerCamera extends StatelessWidget { @override Widget build(BuildContext context) { - return MobileScanner( - controller: controller, - fit: BoxFit.cover, - errorBuilder: (context, error) { - final message = switch (error.errorCode) { - MobileScannerErrorCode.permissionDenied => - 'Camera permission is required to scan QR codes.\n\n' - 'Please grant camera access in your device settings.', - _ => - 'Could not start camera: ' - "${error.errorDetails?.message ?? 'unknown error'}", - }; - return ColoredBox( - color: Colors.black, - child: Center( - child: Padding( - padding: const EdgeInsets.all(Grid.sm), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - BuzzIcons.cameraOff, - size: 48, - color: Colors.white.withValues(alpha: 0.72), - ), - const SizedBox(height: Grid.xs), - Text( - message, - textAlign: TextAlign.center, - style: context.textTheme.bodyMedium?.copyWith( + final cameraState = useState(controller.value); + useEffect(() { + var active = true; + void publish() { + if (active) cameraState.value = controller.value; + } + + void changed() { + // The scanner can publish initialization state while its child builds. + if (WidgetsBinding.instance.schedulerPhase == + SchedulerPhase.persistentCallbacks) { + WidgetsBinding.instance.addPostFrameCallback((_) => publish()); + } else { + publish(); + } + } + + controller.addListener(changed); + return () { + active = false; + controller.removeListener(changed); + }; + }, [controller]); + final state = cameraState.value; + final ready = state.isInitialized && state.isRunning; + return AnimatedOpacity( + key: const ValueKey('qr-camera-ready-fade'), + opacity: ready || state.error != null ? 1 : 0, + duration: MediaQuery.disableAnimationsOf(context) || state.error != null + ? Duration.zero + : const Duration(milliseconds: 230), + curve: Curves.easeOut, + child: MobileScanner( + controller: controller, + fit: BoxFit.cover, + errorBuilder: (context, error) { + final message = switch (error.errorCode) { + MobileScannerErrorCode.permissionDenied => + 'Camera permission is required to scan QR codes.\n\n' + 'Please grant camera access in your device settings.', + _ => + 'Could not start camera: ' + "${error.errorDetails?.message ?? 'unknown error'}", + }; + return ColoredBox( + color: Colors.black, + child: Center( + child: Padding( + padding: const EdgeInsets.all(Grid.sm), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + BuzzIcons.cameraOff, + size: 48, color: Colors.white.withValues(alpha: 0.72), ), - ), - ], + const SizedBox(height: Grid.xs), + Text( + message, + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: Colors.white.withValues(alpha: 0.72), + ), + ), + ], + ), ), ), - ), - ); - }, - onDetect: onDetect, + ); + }, + onDetect: onDetect, + ), ); } } diff --git a/mobile/lib/onboarding_app_entry.dart b/mobile/lib/onboarding_app_entry.dart index ba4d01219d..71640e5c7e 100644 --- a/mobile/lib/onboarding_app_entry.dart +++ b/mobile/lib/onboarding_app_entry.dart @@ -153,6 +153,7 @@ class OnboardingAppEntry extends HookConsumerWidget { : () => unawaited(flow.signInAccount()), accountStatus: status, builderlabBusy: busy, + signInPending: signingIn || busy, setupContent: flow != null && inlineSetup ? Column( mainAxisSize: MainAxisSize.min, @@ -168,7 +169,7 @@ class OnboardingAppEntry extends HookConsumerWidget { ? 'Connecting…' : connected ? 'Continue with Builderlab' - : 'Sign in with builderlab', + : 'Sign in with email', onWelcomeBack: back, onAlreadyUseBuzz: () => existingUser.value = true, ), diff --git a/mobile/test/features/onboarding/hosted_communities_test.dart b/mobile/test/features/onboarding/hosted_communities_test.dart index fdb32b4dde..8ca1c279d6 100644 --- a/mobile/test/features/onboarding/hosted_communities_test.dart +++ b/mobile/test/features/onboarding/hosted_communities_test.dart @@ -1,3 +1,6 @@ +import 'dart:ui' as ui; + +import 'package:buzz/shared/hosted/hosted_community_creation.dart'; import 'package:buzz/shared/hosted/hosted_environment.dart'; import 'package:buzz/features/onboarding/hosted_connect_page.dart'; import 'package:buzz/hosted_app_gate.dart'; @@ -9,6 +12,7 @@ import 'package:buzz/shared/hosted/builderlab_account.dart'; import 'package:buzz/shared/hosted/hosted_community_connection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/semantics.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -45,7 +49,7 @@ void main() { ), ); await tester.pumpAndSettle(); - await tester.tap(find.text('Other options')); + await revealOptionsWithSemantics(tester); await tester.pumpAndSettle(); tester.view.physicalSize = const Size(420, 900); await tester.pumpAndSettle(); @@ -55,11 +59,11 @@ void main() { ); expect(tester.getBottomRight(linkCard).dy, closeTo(876, 1)); final scanCard = find.ancestor( - of: find.text('Scan a QR code').hitTestable(), + of: find.text('Use a pairing code').hitTestable(), matching: find.byType(InkWell), ); final subtitle = find - .text('Scan a QR code or enter a community link to continue.') + .text('Enter a community link or use a pairing code to continue.') .hitTestable(); expect( tester.getTopLeft(scanCard).dy - tester.getBottomLeft(subtitle).dy, @@ -114,7 +118,7 @@ void main() { await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); expect(tester.testTextInput.isVisible, isFalse); - expect(find.text('Scan a QR code').hitTestable(), findsOneWidget); + expect(find.text('Use a pairing code').hitTestable(), findsOneWidget); await tester.tap(find.text('Enter a community link').hitTestable()); await tester.pump(); // A hardware keyboard produces no software-keyboard notification. @@ -147,12 +151,7 @@ void main() { await tester.pumpAndSettle(); expect(tester.testTextInput.isVisible, isFalse); expect(find.text('Use pairing code'), findsNothing); - final developerTrigger = find - .byKey(const Key('developer-pairing-trigger')) - .hitTestable(); - await tester.tap(developerTrigger); - await tester.pump(const Duration(milliseconds: 50)); - await tester.tap(developerTrigger); + await tester.tap(find.text('Use a pairing code').hitTestable()); await tester.pumpAndSettle(); expect( find @@ -162,7 +161,10 @@ void main() { ); await tester.tap(find.text('Back').hitTestable()); await tester.pumpAndSettle(); - expect(find.text('Scan a QR code').hitTestable(), findsOneWidget); + expect( + find.text('or sign in with email').hitTestable(), + findsOneWidget, + ); await tester.pumpWidget(const SizedBox.shrink()); await tester.pumpAndSettle(); await f.dispose(); @@ -195,31 +197,31 @@ void main() { ), ); await tester.pumpAndSettle(); - expect( - find.text('Sign in with builderlab').hitTestable(), - findsOneWidget, - ); + expect(find.text('Scan a QR code').hitTestable(), findsOneWidget); expect(find.text('Enter a community link').hitTestable(), findsNothing); - expect(find.text('Scan a QR code'), findsNothing); + expect(find.text('Sign in with email').hitTestable(), findsNothing); expect(find.text('Use pairing code'), findsNothing); - await tester.tap(find.text('Other options')); + await revealOptions(tester); await tester.pumpAndSettle(); expect( find.text('Enter a community link').hitTestable(), findsOneWidget, ); - expect(find.text('Scan a QR code').hitTestable(), findsOneWidget); + expect(find.text('Use a pairing code').hitTestable(), findsOneWidget); expect(find.text('Use pairing code').hitTestable(), findsNothing); await tester.tap(find.byType(BackButton)); await tester.pumpAndSettle(); expect(find.text('Enter a community link').hitTestable(), findsNothing); - await tester.tap(find.text('Other options')); + await revealOptions(tester); await tester.pumpAndSettle(); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); - expect(find.text('Other options').hitTestable(), findsOneWidget); + expect( + find.text('or sign in with email').hitTestable(), + findsOneWidget, + ); expect(find.text('Enter a community link').hitTestable(), findsNothing); - await tester.tap(find.text('Sign in with builderlab')); + await tester.tap(find.text('or sign in with email').hitTestable()); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); expect(f.browser.startedIntent, {'type': 'account'}); @@ -229,10 +231,18 @@ void main() { ); await tester.tap(find.bySemanticsLabel('Cancel sign-in').hitTestable()); await tester.pumpAndSettle(); - expect( - find.text('Sign in with builderlab').hitTestable(), - findsOneWidget, - ); + expect(find.text('Scan a QR code').hitTestable(), findsOneWidget); + // Target sign-in remains pending when an Android Custom Tab closes. + await f.flow.start(HostedCreateCommunity('new-community')); + await tester.pumpAndSettle(); + expect(f.flow.accountOnly, isFalse); + expect(f.flow.stage, HostedFlowStage.signIn); + expect(find.text('Cancel sign-in').hitTestable(), findsOneWidget); + expect(find.text('Scan a QR code').hitTestable(), findsNothing); + await tester.tap(find.text('Cancel sign-in').hitTestable()); + await tester.pumpAndSettle(); + expect(f.flow.stage, HostedFlowStage.idle); + expect(find.text('Scan a QR code').hitTestable(), findsOneWidget); f.expired = false; await f.flow.signInAccount(openBrowser: false); await tester.pumpAndSettle(); @@ -289,3 +299,57 @@ void main() { skip: HostedEnvironment.authDomains.isEmpty, ); } + +Future revealOptions(WidgetTester tester) async { + // Ordinary double taps must not expose the hidden connection options. + await tester.tapAt(const Offset(24, 120)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(const Offset(24, 120)); + await tester.pumpAndSettle(); + expect(find.text('Use a pairing code').hitTestable(), findsNothing); + + for (var tap = 0; tap < 2; tap++) { + final first = await tester.createGesture(pointer: 11); + final second = await tester.createGesture(pointer: 12); + await first.down( + const Offset(24, 120), + timeStamp: Duration(milliseconds: tap * 70), + ); + await second.down( + const Offset(70, 120), + timeStamp: Duration(milliseconds: tap * 70), + ); + await tester.pump(const Duration(milliseconds: 20)); + await first.up(timeStamp: Duration(milliseconds: tap * 70 + 20)); + await second.up(timeStamp: Duration(milliseconds: tap * 70 + 20)); + await tester.pump(const Duration(milliseconds: 50)); + } +} + +Future revealOptionsWithSemantics(WidgetTester tester) async { + final handle = tester.ensureSemantics(); + try { + await tester.pump(); + final wordmark = tester.getSemantics(find.bySemanticsLabel('Buzz')); + final data = wordmark.getSemanticsData(); + final actionIds = data.customSemanticsActionIds!; + expect(actionIds, hasLength(1)); + expect( + CustomSemanticsAction.getAction(actionIds.single)!.label, + 'More connection options', + ); + // The action belongs to the wordmark, never an unlabelled page-sized stop. + expect(data.label, 'Buzz'); + expect(wordmark.rect.height, lessThan(200)); + wordmark.owner!.performAction( + wordmark.id, + ui.SemanticsAction.customAction, + actionIds.single, + ); + await tester.pumpAndSettle(); + expect(find.text('Enter a community link').hitTestable(), findsOneWidget); + expect(find.text('Use a pairing code').hitTestable(), findsOneWidget); + } finally { + handle.dispose(); + } +} diff --git a/mobile/test/features/pairing/pairing_qr_scanner_test.dart b/mobile/test/features/pairing/pairing_qr_scanner_test.dart index e47306835d..2aa20649a5 100644 --- a/mobile/test/features/pairing/pairing_qr_scanner_test.dart +++ b/mobile/test/features/pairing/pairing_qr_scanner_test.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:buzz/features/pairing/pairing_qr_scanner.dart'; +import 'package:buzz/features/pairing/pairing_page.dart'; +import '../../helpers/widget_helpers.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -136,6 +138,8 @@ void main() { await tester.binding.setSurfaceSize(const Size(393, 852)); addTearDown(() => tester.binding.setSurfaceSize(null)); + final cameraStart = Completer(); + fakeScannerPlatform.startGate = cameraStart.future; var scannerClosed = false; await tester.pumpWidget( MaterialApp( @@ -164,6 +168,22 @@ void main() { const ValueKey('dynamic-island-qr-scanner-portal'), ); expect(tester.getRect(portal), const Rect.fromLTWH(136.5, 11, 120, 36)); + final prompt = find + .ancestor( + of: find.text('Scan a QR code'), + matching: find.byType(Opacity), + ) + .first; + final camera = find + .ancestor( + of: find.byType(MobileScanner), + matching: find.byType(Opacity), + ) + .first; + expect(tester.widget(prompt).opacity, 0); + expect(find.byType(MobileScanner), findsOneWidget); + await tester.pump(); + expect(fakeScannerPlatform.startCalls, 1); expect( tester.widget(portal).borderRadius, BorderRadius.circular(18), @@ -173,10 +193,19 @@ void main() { Clip.antiAlias, ); - await tester.pump(const Duration(milliseconds: 16)); + await tester.pump(const Duration(milliseconds: 12)); final firstOpeningFrame = tester.getRect(portal); - expect(firstOpeningFrame.height, inExclusiveRange(36, 50)); - await tester.pump(const Duration(milliseconds: 214)); + expect(firstOpeningFrame.height, inExclusiveRange(36, 363)); + await tester.pump(const Duration(milliseconds: 218)); + expect( + tester.widget(prompt).opacity, + tester.widget(camera).opacity, + ); + expect(tester.widget(prompt).opacity, inExclusiveRange(0, 1)); + expect( + tester.getRect(portal).height, + greaterThan(firstOpeningFrame.height), + ); final openingRadius = (tester.widget(portal).borderRadius as BorderRadius) @@ -185,6 +214,21 @@ void main() { expect(openingRadius, inExclusiveRange(18, 40)); await tester.pumpAndSettle(); + final readyFade = find.byKey(const ValueKey('qr-camera-ready-fade')); + final previewFade = find + .descendant(of: readyFade, matching: find.byType(FadeTransition)) + .first; + expect(tester.widget(previewFade).opacity.value, 0); + cameraStart.complete(); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 60)); + expect( + tester.widget(previewFade).opacity.value, + inExclusiveRange(0, 1), + ); + await tester.pumpAndSettle(); + expect(tester.widget(previewFade).opacity.value, 1); expect( tester.widget(portal).borderRadius, BorderRadius.circular(40), @@ -242,6 +286,90 @@ void main() { }, ); + testWidgets( + 'welcome content follows the scanner spring and returns on close', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_qrScannerPlatformChannel, (call) async { + return call.method == 'usesDynamicIslandQrScannerPortal' + ? true + : null; + }); + await tester.binding.setSurfaceSize(const Size(393, 852)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + for (final reduced in [false, true]) { + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: reduced, + child: const PairingPage( + connectionLanding: true, + showConnectionOptions: false, + ), + ), + ); + await tester.pumpAndSettle(); + final opacity = find.byKey(const Key('scanner-welcome-opacity')); + final scale = find.byKey(const Key('scanner-welcome-scale')); + expect(tester.widget(opacity).opacity, 1); + await tester.tap(find.text('Scan a QR code').hitTestable()); + await tester.pump(); + // Opening must begin without waiting for the welcome double-tap timeout. + expect( + find.byKey(const ValueKey('dynamic-island-qr-scanner-portal')), + findsOneWidget, + ); + await tester.pump(const Duration(milliseconds: 100)); + if (!reduced) { + final portal = find.byKey( + const ValueKey('dynamic-island-qr-scanner-portal'), + ); + final progress = (tester.getSize(portal).height - 36) / (363 - 36); + expect( + tester.widget(opacity).opacity, + closeTo(1 - progress, 0.001), + ); + expect( + tester.widget(scale).transform.storage[0], + closeTo(1 - 0.04 * progress, 0.001), + ); + } + await tester.pumpAndSettle(); + expect(tester.widget(opacity).opacity, 0); + expect( + tester.widget(scale).transform.storage[0], + closeTo(reduced ? 1 : 0.96, 0.001), + ); + expect( + find + .ancestor( + of: find.text('Scan a QR code'), + matching: find.byType(FilledButton), + ) + .hitTestable(), + findsNothing, + ); + await tester.tapAt(const Offset(196.5, 700)); + await tester.pumpAndSettle(); + expect(tester.widget(opacity).opacity, 1); + expect(tester.widget(scale).transform.storage[0], 1); + expect( + find + .ancestor( + of: find.text('Scan a QR code'), + matching: find.byType(FilledButton), + ) + .hitTestable(), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + } + debugDefaultTargetPlatformOverride = null; + }, + ); + testWidgets( 'an early dismissal keeps its frame and settles back into the island', (tester) async { @@ -263,19 +391,28 @@ void main() { ); await tester.tap(find.text('Open scanner')); await tester.pump(); - await tester.pump(const Duration(milliseconds: 80)); + await tester.pump(const Duration(milliseconds: 72)); final portal = find.byKey( const ValueKey('dynamic-island-qr-scanner-portal'), ); + final beforeLastOpeningFrame = tester.getRect(portal); + await tester.pump(const Duration(milliseconds: 8)); final beforeDismissal = tester.getRect(portal); await tester.tapAt(const Offset(196.5, 700)); await tester.pump(); - expect(tester.getRect(portal), beforeDismissal); + expect( + tester.getRect(portal), + rectMoreOrLessEquals(beforeDismissal, epsilon: 0.00001), + ); // Preserve the outgoing velocity briefly instead of abruptly flipping it. await tester.pump(const Duration(milliseconds: 8)); final afterDismissal = tester.getRect(portal); expect(afterDismissal.height, greaterThan(beforeDismissal.height)); - expect(afterDismissal.height - beforeDismissal.height, lessThan(12)); + // Reversing decelerates the outgoing motion over an equal frame interval. + expect( + afterDismissal.height - beforeDismissal.height, + lessThan(beforeDismissal.height - beforeLastOpeningFrame.height), + ); await tester.tapAt(const Offset(196.5, 700)); await tester.pumpAndSettle(); expect(closeCount, 1); @@ -345,6 +482,8 @@ class _FakeMobileScannerPlatform extends MobileScannerPlatform { final _barcodes = StreamController.broadcast(); var _isDisposed = false; var stopCalls = 0; + var startCalls = 0; + Future? startGate; @override Stream get barcodesStream => _barcodes.stream; @@ -358,6 +497,8 @@ class _FakeMobileScannerPlatform extends MobileScannerPlatform { @override Future start(StartOptions startOptions) async { + startCalls++; + await startGate; return const MobileScannerViewAttributes( cameraDirection: CameraFacing.back, currentTorchMode: TorchState.unavailable, diff --git a/mobile/test/widget_test.dart b/mobile/test/widget_test.dart index 3c4cb0a5ae..f10cd7d2cd 100644 --- a/mobile/test/widget_test.dart +++ b/mobile/test/widget_test.dart @@ -101,18 +101,12 @@ void main() { ); await tester.pump(); expect(find.bySemanticsLabel('Buzz'), findsOneWidget); + expect(find.text('Sign in with email').hitTestable(), findsNothing); expect( - find.text('Sign in with builderlab'), + find.text('or sign in with email'), HostedEnvironment.enabled ? findsOneWidget : findsNothing, ); - expect( - find.text('Other options'), - HostedEnvironment.enabled ? findsOneWidget : findsNothing, - ); - expect( - find.text('Scan a QR code'), - HostedEnvironment.enabled ? findsNothing : findsOneWidget, - ); + expect(find.text('Scan a QR code'), findsOneWidget); }); }