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 9969bde056..bccc1ac760 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 = duoQrScannerCameraFrame(context) != null || await usesDynamicIslandQrScannerPortal(); @@ -149,7 +156,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); } @@ -281,43 +298,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 41aa3b4b4a..195c4b3654 100644 --- a/mobile/lib/features/pairing/pairing_qr_scanner.dart +++ b/mobile/lib/features/pairing/pairing_qr_scanner.dart @@ -7,6 +7,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'; @@ -64,7 +65,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, @@ -72,7 +76,8 @@ Future showDynamicIslandPairingQrScanner(BuildContext context) { barrierDismissible: false, transitionDuration: Duration.zero, reverseTransitionDuration: Duration.zero, - pageBuilder: (_, _, _) => const _DynamicIslandQrScannerPortal(), + pageBuilder: (_, _, _) => + _DynamicIslandQrScannerPortal(onProgress: onProgress), ), ); } @@ -142,7 +147,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 => @@ -212,9 +216,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 9f0b1cf874..05b68f0d1f 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; } @@ -131,17 +156,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 || geometry.cameraFrame != null - ? 0.0 - : geometry.introLabelOpacityAt(progress); - final promptOpacity = math.max( - introLabelOpacity, - scannerOpacity, - ); return Positioned.fromRect( rect: frame, @@ -158,17 +172,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 bfb8c49857..54c9eb7786 100644 --- a/mobile/test/features/pairing/pairing_qr_scanner_test.dart +++ b/mobile/test/features/pairing/pairing_qr_scanner_test.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:buzz/features/pairing/pairing_qr_scanner.dart'; import 'package:duo_kit/testing.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'; @@ -212,6 +214,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( @@ -240,6 +244,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), @@ -249,10 +269,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) @@ -261,6 +290,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), @@ -318,6 +362,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 { @@ -339,19 +467,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); @@ -421,6 +558,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; @@ -434,6 +573,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); }); }