From d57cbba6ad770e0e211b71ed8a2947ccd76c5da2 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 24 Jul 2026 10:49:40 -0700 Subject: [PATCH 1/3] Add adaptive community QR scanner --- mobile/ios/Runner/AppDelegate.swift | 78 +++++ mobile/ios/RunnerTests/RunnerTests.swift | 30 ++ mobile/lib/features/pairing/pairing_page.dart | 122 +++----- .../features/pairing/pairing_qr_scanner.dart | 175 +++++++++++ .../dynamic_island_portal.dart | 177 +++++++++++ .../pairing_qr_scanner/fallback_scanner.dart | 282 ++++++++++++++++++ .../pairing_qr_scanner/scanner_camera.dart | 53 ++++ .../pairing/pairing_qr_scanner_test.dart | 225 ++++++++++++++ 8 files changed, 1057 insertions(+), 85 deletions(-) create mode 100644 mobile/lib/features/pairing/pairing_qr_scanner.dart create mode 100644 mobile/lib/features/pairing/pairing_qr_scanner/dynamic_island_portal.dart create mode 100644 mobile/lib/features/pairing/pairing_qr_scanner/fallback_scanner.dart create mode 100644 mobile/lib/features/pairing/pairing_qr_scanner/scanner_camera.dart create mode 100644 mobile/test/features/pairing/pairing_qr_scanner_test.dart diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index c6ae676c6e..5ebc8e9e3e 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -6,6 +6,7 @@ import UserNotifications @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { private var mediaUploadChannel: FlutterMethodChannel? + private var qrScannerChannel: FlutterMethodChannel? override func application( _ application: UIApplication, @@ -24,8 +25,85 @@ import UserNotifications mediaUploadChannel?.setMethodCallHandler { [weak self] call, result in self?.handleMediaUploadMethodCall(call, result: result) } + qrScannerChannel = FlutterMethodChannel( + name: "buzz/qr_scanner", + binaryMessenger: engineBridge.applicationRegistrar.messenger() + ) + qrScannerChannel?.setMethodCallHandler { call, result in + Self.handleQrScannerMethodCall(call, result: result) + } } + private static func handleQrScannerMethodCall( + _ call: FlutterMethodCall, + result: @escaping FlutterResult + ) { + switch call.method { + case "usesDynamicIslandQrScannerPortal": + result( + UIDevice.current.userInterfaceIdiom == .phone + && usesDynamicIslandQrScannerPortal( + modelIdentifier: currentDeviceModelIdentifier() + ) + ) + case "setDynamicIslandScannerStatusBarHidden": + guard let hidden = call.arguments as? Bool else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected a Bool status-bar visibility value.", + details: nil + ) + ) + return + } + UIApplication.shared.setStatusBarHidden(hidden, with: .fade) + result(nil) + case "performDynamicIslandQrScanSuccessHaptic": + let generator = UINotificationFeedbackGenerator() + generator.prepare() + generator.notificationOccurred(.success) + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + + static func usesDynamicIslandQrScannerPortal( + modelIdentifier: String + ) -> Bool { + knownDynamicIslandIPhoneModelIdentifiers.contains(modelIdentifier) + } + + private static func currentDeviceModelIdentifier() -> String { + if let simulatorModel = ProcessInfo.processInfo.environment[ + "SIMULATOR_MODEL_IDENTIFIER" + ] { + return simulatorModel + } + + var systemInfo = utsname() + uname(&systemInfo) + return withUnsafePointer(to: &systemInfo.machine) { + $0.withMemoryRebound(to: CChar.self, capacity: 1) { + String(cString: $0) + } + } + } + + private static let knownDynamicIslandIPhoneModelIdentifiers: Set = [ + // iPhone 14 Pro, iPhone 14 Pro Max + "iPhone15,2", "iPhone15,3", + // iPhone 15, iPhone 15 Plus, iPhone 15 Pro, iPhone 15 Pro Max + "iPhone15,4", "iPhone15,5", "iPhone16,1", "iPhone16,2", + // iPhone 16, iPhone 16 Plus, iPhone 16 Pro, iPhone 16 Pro Max. + // iPhone17,5 is iPhone 16e and intentionally excluded. + "iPhone17,1", "iPhone17,2", "iPhone17,3", "iPhone17,4", + // iPhone 17 Pro, iPhone 17 Pro Max, iPhone 17, iPhone Air. + // iPhone18,5 is iPhone 17e and intentionally excluded. + "iPhone18,1", "iPhone18,2", "iPhone18,3", "iPhone18,4", + ] + private func handleMediaUploadMethodCall( _ call: FlutterMethodCall, result: @escaping FlutterResult diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index ecaf590fd7..907f3a2572 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -6,6 +6,36 @@ import XCTest class RunnerTests: XCTestCase { + func testDynamicIslandQrScannerRecognizesSupportedIPhones() { + for modelIdentifier in [ + "iPhone15,2", "iPhone15,3", + "iPhone15,4", "iPhone15,5", "iPhone16,1", "iPhone16,2", + "iPhone17,1", "iPhone17,2", "iPhone17,3", "iPhone17,4", + "iPhone18,1", "iPhone18,2", "iPhone18,3", "iPhone18,4", + ] { + XCTAssertTrue( + AppDelegate.usesDynamicIslandQrScannerPortal( + modelIdentifier: modelIdentifier + ), + modelIdentifier + ) + } + } + + func testDynamicIslandQrScannerRejectsUnsupportedDevices() { + for modelIdentifier in [ + "iPhone14,7", "iPhone14,8", "iPhone17,5", "iPhone18,5", + "iPad16,3", "arm64", + ] { + XCTAssertFalse( + AppDelegate.usesDynamicIslandQrScannerPortal( + modelIdentifier: modelIdentifier + ), + modelIdentifier + ) + } + } + func testClipboardImageDataPrefersOriginalPngBytes() throws { let pasteboard = try XCTUnwrap( UIPasteboard(name: UIPasteboard.Name(UUID().uuidString), create: true) diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index 223936017e..70514f274d 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -1,11 +1,13 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; -import 'package:mobile_scanner/mobile_scanner.dart'; import '../../shared/theme/theme.dart'; import 'pairing_provider.dart'; +import 'pairing_qr_scanner.dart'; class PairingPage extends HookConsumerWidget { /// When true, the pairing page is being used to add a new community @@ -18,6 +20,7 @@ class PairingPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final pairingState = ref.watch(pairingProvider); final codeController = useTextEditingController(); + final fallbackScannerVisible = useState(false); final isBusy = pairingState.status == PairingStatus.connecting || pairingState.status == PairingStatus.transferring || @@ -33,7 +36,28 @@ class PairingPage extends HookConsumerWidget { }); } - return PopScope( + Future handleScannerResult(String? code) async { + if (code != null && context.mounted) { + await ref.read(pairingProvider.notifier).pair(code); + } + } + + Future openScanner() async { + final usesDynamicIslandPortal = await usesDynamicIslandQrScannerPortal(); + if (!context.mounted) { + return; + } + + if (!usesDynamicIslandPortal) { + fallbackScannerVisible.value = true; + return; + } + + final code = await showDynamicIslandPairingQrScanner(context); + await handleScannerResult(code); + } + + final appSurface = PopScope( onPopInvokedWithResult: (didPop, _) { if (didPop) { ref.read(pairingProvider.notifier).reset(); @@ -84,9 +108,7 @@ class PairingPage extends HookConsumerWidget { // Scan QR button FilledButton.icon( - onPressed: isBusy - ? null - : () => _openScanner(context, ref), + onPressed: isBusy ? null : openScanner, icon: const Icon(LucideIcons.scanLine), label: const Text('Scan QR Code'), ), @@ -198,18 +220,17 @@ class PairingPage extends HookConsumerWidget { ), ), ); - } - void _openScanner(BuildContext context, WidgetRef ref) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => _ScannerPage( - onScanned: (code) { - Navigator.of(context).pop(); - ref.read(pairingProvider.notifier).pair(code); - }, - ), - ), + if (!fallbackScannerVisible.value) { + return appSurface; + } + + return FallbackPairingQrScanner( + appSurface: appSurface, + onClosed: (code) { + fallbackScannerVisible.value = false; + unawaited(handleScannerResult(code)); + }, ); } } @@ -336,72 +357,3 @@ class _SasVerificationView extends StatelessWidget { ); } } - -class _ScannerPage extends HookWidget { - final void Function(String code) onScanned; - - const _ScannerPage({required this.onScanned}); - - @override - Widget build(BuildContext context) { - final handled = useState(false); - final controller = useMemoized(() => MobileScannerController()); - - useEffect(() => controller.dispose, const []); - - return Scaffold( - appBar: AppBar( - title: const Text('Scan QR Code'), - leading: IconButton( - icon: const Icon(LucideIcons.arrowLeft), - onPressed: () => Navigator.of(context).pop(), - ), - ), - body: MobileScanner( - controller: controller, - errorBuilder: (context, error) { - final message = switch (error.errorCode) { - MobileScannerErrorCode.permissionDenied => - 'Camera permission is required to scan QR codes.\n\nPlease grant camera access in your device settings.', - _ => - 'Could not start camera: ${error.errorDetails?.message ?? 'unknown error'}', - }; - return Center( - child: Padding( - padding: const EdgeInsets.all(Grid.sm), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - LucideIcons.cameraOff, - size: 48, - color: context.colors.onSurfaceVariant, - ), - const SizedBox(height: Grid.xs), - Text( - message, - textAlign: TextAlign.center, - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ], - ), - ), - ); - }, - onDetect: (capture) { - if (handled.value) return; - final barcodes = capture.barcodes; - if (barcodes.isNotEmpty) { - final value = barcodes.first.rawValue; - if (value != null && value.isNotEmpty) { - handled.value = true; - onScanned(value); - } - } - }, - ), - ); - } -} diff --git a/mobile/lib/features/pairing/pairing_qr_scanner.dart b/mobile/lib/features/pairing/pairing_qr_scanner.dart new file mode 100644 index 0000000000..648ca451cd --- /dev/null +++ b/mobile/lib/features/pairing/pairing_qr_scanner.dart @@ -0,0 +1,175 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +import '../../shared/theme/theme.dart'; + +part 'pairing_qr_scanner/dynamic_island_portal.dart'; +part 'pairing_qr_scanner/fallback_scanner.dart'; +part 'pairing_qr_scanner/scanner_camera.dart'; + +const _qrScannerPlatformChannel = MethodChannel('buzz/qr_scanner'); + +/// Returns whether the current iPhone should use the Dynamic Island scanner. +/// +/// Android, iPad, and iPhones without a Dynamic Island always use the standard +/// full-screen scanner. +Future usesDynamicIslandQrScannerPortal() async { + if (defaultTargetPlatform != TargetPlatform.iOS) { + return false; + } + + try { + return await _qrScannerPlatformChannel.invokeMethod( + 'usesDynamicIslandQrScannerPortal', + ) ?? + false; + } on PlatformException { + return false; + } on MissingPluginException { + return false; + } +} + +/// Opens the Dynamic Island QR scanner portal. +/// +/// Callers determine device support with +/// [usesDynamicIslandQrScannerPortal]. Standard devices reveal the camera +/// behind their existing app surface with [FallbackPairingQrScanner]. +Future showDynamicIslandPairingQrScanner(BuildContext context) { + return Navigator.of(context).push( + PageRouteBuilder( + opaque: false, + barrierColor: Colors.transparent, + barrierDismissible: false, + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + pageBuilder: (_, _, _) => const _DynamicIslandQrScannerPortal(), + ), + ); +} + +Future _setDynamicIslandScannerStatusBarHidden(bool hidden) async { + try { + await _qrScannerPlatformChannel.invokeMethod( + 'setDynamicIslandScannerStatusBarHidden', + hidden, + ); + } on PlatformException { + // The scanner still works if the native status-bar bridge is unavailable. + } on MissingPluginException { + // Widget tests and non-iOS embedders do not register the bridge. + } +} + +Future _performDynamicIslandQrScanSuccessHaptic() async { + try { + await _qrScannerPlatformChannel.invokeMethod( + 'performDynamicIslandQrScanSuccessHaptic', + ); + } on PlatformException { + // Scanning should still complete if haptics are unavailable. + } on MissingPluginException { + // Widget tests and non-iOS embedders do not register the bridge. + } +} + +String? _firstScannedValue(BarcodeCapture capture) { + for (final barcode in capture.barcodes) { + final value = barcode.rawValue; + if (value != null && value.isNotEmpty) { + return value; + } + } + return null; +} + +/// Geometry for the iPhone Dynamic Island scanner portal. +/// +/// The collapsed and expanded frames share one top edge. This makes the camera +/// grow down from the hardware cutout instead of scaling around the center of +/// its final square. +@visibleForTesting +class DynamicIslandQrScannerGeometry { + /// Creates geometry for a viewport and its top safe-area inset. + const DynamicIslandQrScannerGeometry({ + required this.viewport, + required this.safeAreaTop, + }); + + /// The full logical-pixel size available to the portal route. + final Size viewport; + + /// The top safe-area inset reported by iOS. + final double safeAreaTop; + + static const _collapsedWidth = 120.0; + static const _collapsedHeight = 36.0; + static const _fallbackTop = 11.0; + static const _referenceSafeAreaTop = 59.0; + 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 => + _fallbackTop + math.max(0, safeAreaTop - _referenceSafeAreaTop); + + /// The portal frame before the opening animation starts. + Rect get collapsedFrame => Rect.fromLTWH( + (viewport.width - _collapsedWidth) / 2, + top, + _collapsedWidth, + _collapsedHeight, + ); + + /// The square camera frame after the opening animation completes. + Rect get expandedFrame { + final maxHeight = math.max( + _collapsedHeight, + viewport.height - top - _edgeMargin, + ); + final size = math.min(viewport.width - (_edgeMargin * 2), maxHeight); + return Rect.fromLTWH(_edgeMargin, top, size, size); + } + + /// Returns the top-anchored portal frame at [progress]. + Rect frameAt(double progress) { + final t = progress.clamp(0.0, 1.0); + final start = collapsedFrame; + final end = expandedFrame; + return Rect.fromLTRB( + lerpDouble(start.left, end.left, t)!, + top, + lerpDouble(start.right, end.right, t)!, + lerpDouble(start.bottom, end.bottom, t)!, + ); + } + + /// Returns the portal corner radius at [progress]. + double cornerRadiusAt(double progress) { + final t = progress.clamp(0.0, 1.0); + return lerpDouble(_collapsedHeight / 2, _expandedCornerRadius, t)!; + } + + /// Returns the camera opacity after its delayed entrance. + double scannerOpacityAt(double progress) { + return ((progress - _scannerFadeStart) / (1 - _scannerFadeStart)).clamp( + 0.0, + 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 new file mode 100644 index 0000000000..11d7061be2 --- /dev/null +++ b/mobile/lib/features/pairing/pairing_qr_scanner/dynamic_island_portal.dart @@ -0,0 +1,177 @@ +part of '../pairing_qr_scanner.dart'; + +const _dynamicIslandOpenDuration = Duration(milliseconds: 460); +const _dynamicIslandCloseDuration = Duration(milliseconds: 340); +const _dynamicIslandEaseOut = Cubic(0.16, 1, 0.3, 1); + +class _DynamicIslandQrScannerPortal extends HookWidget { + const _DynamicIslandQrScannerPortal(); + + @override + Widget build(BuildContext context) { + final controller = useMemoized(MobileScannerController.new); + final animation = useAnimationController( + duration: _dynamicIslandOpenDuration, + reverseDuration: _dynamicIslandCloseDuration, + ); + final isClosing = useState(false); + final canPop = useState(false); + final hasHandledResult = useRef(false); + final reduceMotion = MediaQuery.disableAnimationsOf(context); + + useEffect(() { + unawaited(_setDynamicIslandScannerStatusBarHidden(true)); + if (reduceMotion) { + animation.value = 1; + } else { + unawaited(animation.forward()); + } + + return () { + unawaited(_setDynamicIslandScannerStatusBarHidden(false)); + unawaited(controller.dispose()); + }; + }, [animation, controller, reduceMotion]); + + Future finish(String? result) async { + canPop.value = true; + await WidgetsBinding.instance.endOfFrame; + if (context.mounted) { + Navigator.of(context).pop(result); + } + } + + Future closePortal() async { + if (isClosing.value || hasHandledResult.value) { + return; + } + hasHandledResult.value = true; + isClosing.value = true; + canPop.value = true; + + if (reduceMotion) { + animation.value = 0; + await WidgetsBinding.instance.endOfFrame; + } else { + await animation.reverse(); + } + if (context.mounted) { + Navigator.of(context).pop(); + } + } + + void handleDetection(BarcodeCapture capture) { + if (isClosing.value || hasHandledResult.value) { + return; + } + final value = _firstScannedValue(capture); + if (value == null) { + return; + } + + hasHandledResult.value = true; + unawaited(_performDynamicIslandQrScanSuccessHaptic()); + unawaited(finish(value)); + } + + return PopScope( + canPop: canPop.value, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) { + unawaited(closePortal()); + } + }, + child: Material( + type: MaterialType.transparency, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: closePortal, + child: LayoutBuilder( + builder: (context, constraints) { + final geometry = DynamicIslandQrScannerGeometry( + viewport: constraints.biggest, + safeAreaTop: MediaQuery.viewPaddingOf(context).top, + ); + + return Stack( + children: [ + Positioned.fill( + child: AnimatedBuilder( + animation: animation, + builder: (context, _) => ColoredBox( + color: Colors.black.withValues( + alpha: 0.12 * animation.value, + ), + ), + ), + ), + AnimatedBuilder( + animation: animation, + builder: (context, _) { + final progress = _dynamicIslandEaseOut.transform( + animation.value.clamp(0.0, 1.0), + ); + final frame = geometry.frameAt(progress); + final scannerOpacity = geometry.scannerOpacityAt( + progress, + ); + final introLabelOpacity = isClosing.value + ? 0.0 + : geometry.introLabelOpacityAt(progress); + final promptOpacity = isClosing.value + ? 0.0 + : math.max(introLabelOpacity, scannerOpacity); + + return Positioned.fromRect( + rect: frame, + child: ClipRRect( + key: const ValueKey( + 'dynamic-island-qr-scanner-portal', + ), + borderRadius: BorderRadius.circular( + geometry.cornerRadiusAt(progress), + ), + child: ColoredBox( + color: Colors.black, + child: Stack( + fit: StackFit.expand, + children: [ + if (!isClosing.value && scannerOpacity > 0) + Opacity( + opacity: scannerOpacity, + child: _QrScannerCamera( + controller: controller, + onDetect: handleDetection, + ), + ), + IgnorePointer( + child: Opacity( + opacity: promptOpacity, + child: Center( + child: Text( + 'Scan a QR code', + style: context.textTheme.bodyMedium + ?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ], + ); + }, + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/pairing/pairing_qr_scanner/fallback_scanner.dart b/mobile/lib/features/pairing/pairing_qr_scanner/fallback_scanner.dart new file mode 100644 index 0000000000..52fd263ca5 --- /dev/null +++ b/mobile/lib/features/pairing/pairing_qr_scanner/fallback_scanner.dart @@ -0,0 +1,282 @@ +part of '../pairing_qr_scanner.dart'; + +const _fallbackScannerOpenDuration = Duration(milliseconds: 420); +const _fallbackScannerCloseDuration = Duration(milliseconds: 320); +const _fallbackScannerSheetPeekHeight = 112.0; +const _fallbackScannerSheetCornerRadius = 32.0; +const _fallbackScannerDrawerCurve = Cubic(0.32, 0.72, 0, 1); + +/// Reveals the scanner behind the current app surface on standard devices. +/// +/// The app surface moves down as a rounded sheet instead of navigating to a +/// separate scanner page. This is used by Android and iPhones without a +/// Dynamic Island. +class FallbackPairingQrScanner extends HookWidget { + const FallbackPairingQrScanner({ + required this.appSurface, + required this.onClosed, + super.key, + }); + + /// The current Buzz screen that moves down to reveal the camera. + final Widget appSurface; + + /// Called after the app surface has returned, with a scanned value if any. + final ValueChanged onClosed; + + @override + Widget build(BuildContext context) { + final controller = useMemoized(MobileScannerController.new); + final animation = useAnimationController( + duration: _fallbackScannerOpenDuration, + reverseDuration: _fallbackScannerCloseDuration, + ); + final isClosing = useRef(false); + final reduceMotion = MediaQuery.disableAnimationsOf(context); + + useEffect( + () => () { + unawaited(controller.dispose()); + }, + [controller], + ); + + useEffect(() { + if (reduceMotion) { + animation.value = 1; + } else { + unawaited(animation.forward()); + } + return null; + }, [animation, reduceMotion]); + + Future closeScanner([String? result]) async { + if (isClosing.value) { + return; + } + isClosing.value = true; + + if (reduceMotion) { + animation.value = 0; + await WidgetsBinding.instance.endOfFrame; + } else { + await animation.reverse(); + } + + if (context.mounted) { + onClosed(result); + } + } + + void handleDetection(BarcodeCapture capture) { + if (isClosing.value) { + return; + } + final value = _firstScannedValue(capture); + if (value == null) { + return; + } + + unawaited(closeScanner(value)); + } + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) { + unawaited(closeScanner()); + } + }, + child: AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: Material( + key: const ValueKey('fallback-qr-scanner-reveal'), + color: Colors.black, + child: LayoutBuilder( + builder: (context, constraints) { + final viewport = constraints.biggest; + final sheetTravel = math.max( + 0.0, + viewport.height - _fallbackScannerSheetPeekHeight, + ); + + return Stack( + clipBehavior: Clip.hardEdge, + children: [ + Positioned.fill( + child: Semantics( + button: true, + label: 'Close QR scanner', + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: closeScanner, + child: Stack( + fit: StackFit.expand, + children: [ + _QrScannerCamera( + controller: controller, + onDetect: handleDetection, + ), + const IgnorePointer( + child: _FallbackQrScannerViewfinder(), + ), + ], + ), + ), + ), + ), + AnimatedBuilder( + animation: animation, + child: appSurface, + builder: (context, child) { + final progress = _fallbackScannerDrawerCurve.transform( + animation.value.clamp(0.0, 1.0), + ); + final offset = sheetTravel * progress; + final radius = + _fallbackScannerSheetCornerRadius * progress; + + return Positioned( + top: offset, + left: 0, + right: 0, + height: viewport.height, + child: Semantics( + button: true, + label: 'Close QR scanner', + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: closeScanner, + child: ClipRRect( + key: const ValueKey( + 'fallback-qr-scanner-app-sheet', + ), + borderRadius: BorderRadius.vertical( + top: Radius.circular(radius), + ), + child: Stack( + fit: StackFit.expand, + children: [ + AbsorbPointer(child: child), + if (progress > 0) + Align( + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.only( + top: Grid.twelve, + ), + child: Opacity( + opacity: progress, + child: Container( + width: Grid.md, + height: Grid.half, + decoration: BoxDecoration( + color: context + .colors + .onSurfaceVariant + .withValues(alpha: 0.45), + borderRadius: + BorderRadius.circular( + Grid.half, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + }, + ), + ], + ); + }, + ), + ), + ), + ); + } +} + +class _FallbackQrScannerViewfinder extends StatelessWidget { + const _FallbackQrScannerViewfinder(); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final size = math.min( + constraints.maxWidth - (Grid.xl * 2), + constraints.maxHeight - 240, + ); + + return Stack( + fit: StackFit.expand, + children: [ + CustomPaint( + painter: _FallbackQrScannerViewfinderPainter( + viewfinderSize: math.max(0, size), + ), + ), + Align( + alignment: Alignment.center, + child: Transform.translate( + offset: Offset(0, (size / 2) + Grid.md), + child: Text( + 'Scan a QR code', + style: context.textTheme.bodyMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ); + }, + ); + } +} + +class _FallbackQrScannerViewfinderPainter extends CustomPainter { + const _FallbackQrScannerViewfinderPainter({required this.viewfinderSize}); + + final double viewfinderSize; + + @override + void paint(Canvas canvas, Size size) { + final rect = Rect.fromCenter( + center: size.center(Offset.zero), + width: viewfinderSize, + height: viewfinderSize, + ); + final roundedRect = RRect.fromRectAndRadius( + rect, + const Radius.circular(40), + ); + + canvas.saveLayer(Offset.zero & size, Paint()); + canvas.drawRect( + Offset.zero & size, + Paint()..color = Colors.black.withValues(alpha: 0.6), + ); + canvas.drawRRect(roundedRect, Paint()..blendMode = BlendMode.clear); + canvas.restore(); + canvas.drawRRect( + roundedRect, + Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 3, + ); + } + + @override + bool shouldRepaint(_FallbackQrScannerViewfinderPainter oldDelegate) { + return oldDelegate.viewfinderSize != viewfinderSize; + } +} diff --git a/mobile/lib/features/pairing/pairing_qr_scanner/scanner_camera.dart b/mobile/lib/features/pairing/pairing_qr_scanner/scanner_camera.dart new file mode 100644 index 0000000000..d02a820a05 --- /dev/null +++ b/mobile/lib/features/pairing/pairing_qr_scanner/scanner_camera.dart @@ -0,0 +1,53 @@ +part of '../pairing_qr_scanner.dart'; + +class _QrScannerCamera extends StatelessWidget { + const _QrScannerCamera({required this.controller, required this.onDetect}); + + final MobileScannerController controller; + final ValueChanged onDetect; + + @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( + LucideIcons.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, + ); + } +} diff --git a/mobile/test/features/pairing/pairing_qr_scanner_test.dart b/mobile/test/features/pairing/pairing_qr_scanner_test.dart new file mode 100644 index 0000000000..ac9abfe641 --- /dev/null +++ b/mobile/test/features/pairing/pairing_qr_scanner_test.dart @@ -0,0 +1,225 @@ +import 'package:buzz/features/pairing/pairing_qr_scanner.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _qrScannerPlatformChannel = MethodChannel('buzz/qr_scanner'); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + tearDown(() async { + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_qrScannerPlatformChannel, null); + }); + + group('DynamicIslandQrScannerGeometry', () { + const viewport = Size(393, 852); + const geometry = DynamicIslandQrScannerGeometry( + viewport: viewport, + safeAreaTop: 59, + ); + + test('starts at the physical Dynamic Island frame', () { + expect(geometry.collapsedFrame, const Rect.fromLTWH(136.5, 11, 120, 36)); + }); + + test('keeps the top edge fixed while the camera grows down', () { + final start = geometry.frameAt(0); + final middle = geometry.frameAt(0.5); + final end = geometry.frameAt(1); + + expect(middle.top, start.top); + expect(end.top, start.top); + expect(middle.bottom, greaterThan(start.bottom)); + expect(end.bottom, greaterThan(middle.bottom)); + expect(end, const Rect.fromLTWH(15, 11, 363, 363)); + }); + + test('delays the camera until the portal has left the island', () { + expect(geometry.scannerOpacityAt(0.18), 0); + expect(geometry.scannerOpacityAt(0.5), greaterThan(0)); + expect(geometry.scannerOpacityAt(1), 1); + }); + }); + + group('usesDynamicIslandQrScannerPortal', () { + test('asks the native bridge on iOS', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_qrScannerPlatformChannel, (call) async { + expect(call.method, 'usesDynamicIslandQrScannerPortal'); + return true; + }); + + expect(await usesDynamicIslandQrScannerPortal(), isTrue); + }); + + test('uses the fallback without asking native code on Android', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + var nativeCalls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_qrScannerPlatformChannel, (call) async { + nativeCalls += 1; + return true; + }); + + expect(await usesDynamicIslandQrScannerPortal(), isFalse); + expect(nativeCalls, 0); + }); + }); + + testWidgets('fallback reveals the camera behind the current app surface', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(375, 667)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + var scannerClosed = false; + await tester.pumpWidget( + MaterialApp( + home: FallbackPairingQrScanner( + appSurface: const ColoredBox( + color: Colors.white, + child: Center(child: Text('Current app surface')), + ), + onClosed: (_) { + scannerClosed = true; + }, + ), + ), + ); + + final sheet = find.byKey(const ValueKey('fallback-qr-scanner-app-sheet')); + expect(tester.getRect(sheet).top, 0); + expect(find.text('Current app surface'), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 420)); + expect(tester.getRect(sheet).top, closeTo(555, 0.1)); + + await tester.tapAt(const Offset(187.5, 100)); + await tester.pump(); + expect(scannerClosed, isFalse); + + await tester.pump(const Duration(milliseconds: 320)); + await tester.pumpAndSettle(); + expect(scannerClosed, isTrue); + expect(tester.getRect(sheet).top, 0); + }); + + testWidgets( + 'portal route opens from the island and an outside tap reverses it closed', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_qrScannerPlatformChannel, (call) async { + return switch (call.method) { + 'usesDynamicIslandQrScannerPortal' => true, + 'setDynamicIslandScannerStatusBarHidden' => null, + _ => null, + }; + }); + await tester.binding.setSurfaceSize(const Size(393, 852)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + var scannerClosed = false; + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData( + size: Size(393, 852), + viewPadding: EdgeInsets.only(top: 59), + ), + child: Builder( + builder: (context) => TextButton( + onPressed: () async { + await showDynamicIslandPairingQrScanner(context); + scannerClosed = true; + }, + child: const Text('Open scanner'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open scanner')); + await tester.pump(); + + final portal = find.byKey( + const ValueKey('dynamic-island-qr-scanner-portal'), + ); + expect(tester.getRect(portal), const Rect.fromLTWH(136.5, 11, 120, 36)); + + await tester.pump(const Duration(milliseconds: 460)); + expect(tester.getRect(portal).top, 11); + expect( + find.byKey(const ValueKey('dynamic-island-qr-scanner-close')), + findsNothing, + ); + + await tester.tapAt(const Offset(196.5, 700)); + await tester.pump(); + expect(scannerClosed, isFalse); + + await tester.pump(const Duration(milliseconds: 340)); + await tester.pumpAndSettle(); + expect(scannerClosed, isTrue); + expect(portal, findsNothing); + debugDefaultTargetPlatformOverride = null; + }, + ); + + testWidgets('tapping the scanner closes it without a separate control', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_qrScannerPlatformChannel, (call) async { + return switch (call.method) { + 'usesDynamicIslandQrScannerPortal' => true, + 'setDynamicIslandScannerStatusBarHidden' => null, + _ => null, + }; + }); + await tester.binding.setSurfaceSize(const Size(393, 852)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + var scannerClosed = false; + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(disableAnimations: true), + child: child!, + ), + home: Builder( + builder: (context) => TextButton( + onPressed: () async { + await showDynamicIslandPairingQrScanner(context); + scannerClosed = true; + }, + child: const Text('Open scanner'), + ), + ), + ), + ); + + await tester.tap(find.text('Open scanner')); + await tester.pump(); + + final portal = find.byKey( + const ValueKey('dynamic-island-qr-scanner-portal'), + ); + expect(tester.getRect(portal), const Rect.fromLTWH(15, 11, 363, 363)); + + await tester.tapAt(tester.getCenter(portal)); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(scannerClosed, isTrue); + expect(portal, findsNothing); + debugDefaultTargetPlatformOverride = null; + }); +} From 58a0ebeffa768ddfe43208b62a51d343c7d21dcd Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 24 Jul 2026 11:35:00 -0700 Subject: [PATCH 2/3] Use safe area for Dynamic Island detection --- mobile/ios/Runner/AppDelegate.swift | 40 ++++++------------------ mobile/ios/RunnerTests/RunnerTests.swift | 24 +++++--------- 2 files changed, 18 insertions(+), 46 deletions(-) diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 5ebc8e9e3e..5edb4b6aaf 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -43,7 +43,7 @@ import UserNotifications result( UIDevice.current.userInterfaceIdiom == .phone && usesDynamicIslandQrScannerPortal( - modelIdentifier: currentDeviceModelIdentifier() + safeAreaTopInset: activeWindowSafeAreaTopInset() ) ) case "setDynamicIslandScannerStatusBarHidden": @@ -70,40 +70,20 @@ import UserNotifications } static func usesDynamicIslandQrScannerPortal( - modelIdentifier: String + safeAreaTopInset: CGFloat ) -> Bool { - knownDynamicIslandIPhoneModelIdentifiers.contains(modelIdentifier) + safeAreaTopInset > 50 } - private static func currentDeviceModelIdentifier() -> String { - if let simulatorModel = ProcessInfo.processInfo.environment[ - "SIMULATOR_MODEL_IDENTIFIER" - ] { - return simulatorModel - } - - var systemInfo = utsname() - uname(&systemInfo) - return withUnsafePointer(to: &systemInfo.machine) { - $0.withMemoryRebound(to: CChar.self, capacity: 1) { - String(cString: $0) - } - } + private static func activeWindowSafeAreaTopInset() -> CGFloat { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { $0.activationState == .foregroundActive } + .flatMap(\.windows) + .first(where: \.isKeyWindow)? + .safeAreaInsets.top ?? 0 } - private static let knownDynamicIslandIPhoneModelIdentifiers: Set = [ - // iPhone 14 Pro, iPhone 14 Pro Max - "iPhone15,2", "iPhone15,3", - // iPhone 15, iPhone 15 Plus, iPhone 15 Pro, iPhone 15 Pro Max - "iPhone15,4", "iPhone15,5", "iPhone16,1", "iPhone16,2", - // iPhone 16, iPhone 16 Plus, iPhone 16 Pro, iPhone 16 Pro Max. - // iPhone17,5 is iPhone 16e and intentionally excluded. - "iPhone17,1", "iPhone17,2", "iPhone17,3", "iPhone17,4", - // iPhone 17 Pro, iPhone 17 Pro Max, iPhone 17, iPhone Air. - // iPhone18,5 is iPhone 17e and intentionally excluded. - "iPhone18,1", "iPhone18,2", "iPhone18,3", "iPhone18,4", - ] - private func handleMediaUploadMethodCall( _ call: FlutterMethodCall, result: @escaping FlutterResult diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index 907f3a2572..a174c78684 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -6,32 +6,24 @@ import XCTest class RunnerTests: XCTestCase { - func testDynamicIslandQrScannerRecognizesSupportedIPhones() { - for modelIdentifier in [ - "iPhone15,2", "iPhone15,3", - "iPhone15,4", "iPhone15,5", "iPhone16,1", "iPhone16,2", - "iPhone17,1", "iPhone17,2", "iPhone17,3", "iPhone17,4", - "iPhone18,1", "iPhone18,2", "iPhone18,3", "iPhone18,4", - ] { + func testDynamicIslandQrScannerRecognizesTallSafeAreas() { + for safeAreaTopInset in [51, 59, 62] { XCTAssertTrue( AppDelegate.usesDynamicIslandQrScannerPortal( - modelIdentifier: modelIdentifier + safeAreaTopInset: CGFloat(safeAreaTopInset) ), - modelIdentifier + "\(safeAreaTopInset)" ) } } - func testDynamicIslandQrScannerRejectsUnsupportedDevices() { - for modelIdentifier in [ - "iPhone14,7", "iPhone14,8", "iPhone17,5", "iPhone18,5", - "iPad16,3", "arm64", - ] { + func testDynamicIslandQrScannerRejectsStandardSafeAreas() { + for safeAreaTopInset in [0, 44, 47, 50] { XCTAssertFalse( AppDelegate.usesDynamicIslandQrScannerPortal( - modelIdentifier: modelIdentifier + safeAreaTopInset: CGFloat(safeAreaTopInset) ), - modelIdentifier + "\(safeAreaTopInset)" ) } } From 734fb7ec41e7a933db803536773dbfb06627c28d Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 24 Jul 2026 15:29:21 -0700 Subject: [PATCH 3/3] Mock scanner platform in QR tests --- .../pairing/pairing_qr_scanner_test.dart | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/mobile/test/features/pairing/pairing_qr_scanner_test.dart b/mobile/test/features/pairing/pairing_qr_scanner_test.dart index ac9abfe641..79aa81ab48 100644 --- a/mobile/test/features/pairing/pairing_qr_scanner_test.dart +++ b/mobile/test/features/pairing/pairing_qr_scanner_test.dart @@ -1,18 +1,30 @@ +import 'dart:async'; + import 'package:buzz/features/pairing/pairing_qr_scanner.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; const _qrScannerPlatformChannel = MethodChannel('buzz/qr_scanner'); void main() { TestWidgetsFlutterBinding.ensureInitialized(); + final defaultScannerPlatform = MobileScannerPlatform.instance; + late _FakeMobileScannerPlatform fakeScannerPlatform; + + setUp(() { + fakeScannerPlatform = _FakeMobileScannerPlatform(); + MobileScannerPlatform.instance = fakeScannerPlatform; + }); tearDown(() async { debugDefaultTargetPlatformOverride = null; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_qrScannerPlatformChannel, null); + await fakeScannerPlatform.dispose(); + MobileScannerPlatform.instance = defaultScannerPlatform; }); group('DynamicIslandQrScannerGeometry', () { @@ -223,3 +235,43 @@ void main() { debugDefaultTargetPlatformOverride = null; }); } + +class _FakeMobileScannerPlatform extends MobileScannerPlatform { + final _barcodes = StreamController.broadcast(); + var _isDisposed = false; + + @override + Stream get barcodesStream => _barcodes.stream; + + @override + Stream get torchStateStream => + Stream.value(TorchState.unavailable); + + @override + Stream get zoomScaleStateStream => Stream.value(1); + + @override + Future start(StartOptions startOptions) async { + return const MobileScannerViewAttributes( + cameraDirection: CameraFacing.back, + currentTorchMode: TorchState.unavailable, + size: Size(200, 200), + numberOfCameras: 1, + ); + } + + @override + Widget buildCameraView() => const SizedBox.expand(); + + @override + Future stop() async {} + + @override + Future dispose() async { + if (_isDisposed) { + return; + } + _isDisposed = true; + await _barcodes.close(); + } +}