From cc6f37c7c7371751056c4d73fea7c28f4f20dd18 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 26 Oct 2026 10:04:46 +0000 Subject: [PATCH] Make mobile onboarding QR-first and synchronize scanner motion Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> Signed-off-by: kenny lopez --- 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 | 111 ++++++++++++------ .../onboarding_connection_view.dart | 17 +-- .../pairing_page/pairing_welcome_view.dart | 35 ++++-- .../features/pairing/pairing_qr_scanner.dart | 9 +- .../dynamic_island_portal.dart | 51 +++++--- mobile/lib/onboarding_app_entry.dart | 2 +- .../onboarding/hosted_communities_test.dart | 26 ++-- .../pairing/pairing_qr_scanner_test.dart | 89 +++++++++++++- mobile/test/widget_test.dart | 10 +- 12 files changed, 284 insertions(+), 101 deletions(-) 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..f4510b9282 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.3, + 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..5c858d50c6 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -92,6 +92,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 +138,7 @@ class PairingPage extends HookConsumerWidget { } Future openScanner() async { + unawaited(HapticFeedback.selectionClick()); final usesDynamicIslandPortal = await usesDynamicIslandQrScannerPortal(); if (!context.mounted) { return; @@ -147,7 +149,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 +291,66 @@ 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, + 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..fb6664c67d 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,8 @@ class _OnboardingConnectionView extends HookWidget { required this.onCommunityLink, required this.onBack, required this.onOpenCommunity, - required this.onScan, + required this.onBuilderlab, + required this.builderlabLabel, required this.onPairing, }); @@ -15,7 +16,8 @@ class _OnboardingConnectionView extends HookWidget { final VoidCallback? onCommunityLink; final VoidCallback? onBack; final ValueChanged onOpenCommunity; - final VoidCallback? onScan; + final VoidCallback? onBuilderlab; + final String builderlabLabel; final VoidCallback? onPairing; double _textProgress(double progress, bool opening) => @@ -145,7 +147,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.', + 'Sign in with email or enter a community link to continue.', ), ), _ConnectionMotionLayer( @@ -173,11 +175,10 @@ class _OnboardingConnectionView extends HookWidget { active: !enteringLink, maintainSize: true, child: _OnboardingChoiceContainer( - title: 'Scan a QR code', - icon: BuzzIcons.qrcode, - subtitle: - 'Scan a community or desktop pairing code.', - onTap: onScan, + title: builderlabLabel, + icon: BuzzIcons.mail, + subtitle: 'Use your email address to connect.', + onTap: onBuilderlab, onIconDoubleTap: onPairing, ), ), 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..c96078a5d7 100644 --- a/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart +++ b/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart @@ -124,7 +124,16 @@ class _PairingWelcomeView extends HookWidget { const SizedBox(height: Grid.xs), ], if (page == 1) ...[ - if (HostedEnvironment.enabled) + if (!showConnectionOptions && !builderlabBusy) + 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, @@ -138,12 +147,16 @@ class _PairingWelcomeView extends HookWidget { ), if (!showConnectionOptions) ...[ 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 + : () => + optionsExpanded.value = !optionsExpanded.value, + child: const Text('Other options'), + ), ), ], if (showConnectionOptions) ...connectionActions(), @@ -455,7 +468,13 @@ class _PairingWelcomeView extends HookWidget { } }, onOpenCommunity: onOpenCommunity, - onScan: isBusy ? null : onScan, + onBuilderlab: onBuilderlab == null + ? null + : () { + optionsExpanded.value = false; + onBuilderlab!(); + }, + builderlabLabel: builderlabLabel, onPairing: isBusy ? null : onAlreadyUseBuzz, ), ), diff --git a/mobile/lib/features/pairing/pairing_qr_scanner.dart b/mobile/lib/features/pairing/pairing_qr_scanner.dart index ff6c14f2f9..5115fb71da 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), ), ); } 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..f9836a8f7f 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: 300); +const _dynamicIslandCloseDuration = Duration(milliseconds: 300); 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,9 +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); @@ -156,14 +178,15 @@ 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, diff --git a/mobile/lib/onboarding_app_entry.dart b/mobile/lib/onboarding_app_entry.dart index ba4d01219d..57efc6af12 100644 --- a/mobile/lib/onboarding_app_entry.dart +++ b/mobile/lib/onboarding_app_entry.dart @@ -168,7 +168,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..19c0202dc1 100644 --- a/mobile/test/features/onboarding/hosted_communities_test.dart +++ b/mobile/test/features/onboarding/hosted_communities_test.dart @@ -55,11 +55,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('Sign in with email').hitTestable(), matching: find.byType(InkWell), ); final subtitle = find - .text('Scan a QR code or enter a community link to continue.') + .text('Sign in with email or enter a community link to continue.') .hitTestable(); expect( tester.getTopLeft(scanCard).dy - tester.getBottomLeft(subtitle).dy, @@ -114,7 +114,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('Sign in with email').hitTestable(), findsOneWidget); await tester.tap(find.text('Enter a community link').hitTestable()); await tester.pump(); // A hardware keyboard produces no software-keyboard notification. @@ -162,7 +162,7 @@ void main() { ); await tester.tap(find.text('Back').hitTestable()); await tester.pumpAndSettle(); - expect(find.text('Scan a QR code').hitTestable(), findsOneWidget); + expect(find.text('Sign in with email').hitTestable(), findsOneWidget); await tester.pumpWidget(const SizedBox.shrink()); await tester.pumpAndSettle(); await f.dispose(); @@ -195,12 +195,9 @@ 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 tester.pumpAndSettle(); @@ -208,7 +205,7 @@ void main() { find.text('Enter a community link').hitTestable(), findsOneWidget, ); - expect(find.text('Scan a QR code').hitTestable(), findsOneWidget); + expect(find.text('Sign in with email').hitTestable(), findsOneWidget); expect(find.text('Use pairing code').hitTestable(), findsNothing); await tester.tap(find.byType(BackButton)); await tester.pumpAndSettle(); @@ -219,7 +216,9 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Other options').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('Other options')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Sign in with email').hitTestable()); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); expect(f.browser.startedIntent, {'type': 'account'}); @@ -229,10 +228,7 @@ 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); f.expired = false; await f.flow.signInAccount(openBrowser: false); await tester.pumpAndSettle(); diff --git a/mobile/test/features/pairing/pairing_qr_scanner_test.dart b/mobile/test/features/pairing/pairing_qr_scanner_test.dart index e47306835d..35c92ced8d 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'; @@ -164,6 +166,9 @@ void main() { const ValueKey('dynamic-island-qr-scanner-portal'), ); expect(tester.getRect(portal), const Rect.fromLTWH(136.5, 11, 120, 36)); + expect(find.byType(MobileScanner), findsOneWidget); + await tester.pump(); + expect(fakeScannerPlatform.startCalls, 1); expect( tester.widget(portal).borderRadius, BorderRadius.circular(18), @@ -173,10 +178,10 @@ 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)); + await tester.pump(const Duration(milliseconds: 218)); final openingRadius = (tester.widget(portal).borderRadius as BorderRadius) @@ -242,6 +247,69 @@ 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(); + 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.text('Other options').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.text('Other options').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 +331,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 +422,7 @@ class _FakeMobileScannerPlatform extends MobileScannerPlatform { final _barcodes = StreamController.broadcast(); var _isDisposed = false; var stopCalls = 0; + var startCalls = 0; @override Stream get barcodesStream => _barcodes.stream; @@ -358,6 +436,7 @@ class _FakeMobileScannerPlatform extends MobileScannerPlatform { @override Future start(StartOptions startOptions) async { + startCalls++; 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..8ec786a825 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 builderlab'), - HostedEnvironment.enabled ? findsOneWidget : findsNothing, - ); + expect(find.text('Sign in with email').hitTestable(), 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); }); }