diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index c6ae676c6e..5edb4b6aaf 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,6 +25,63 @@ 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( + safeAreaTopInset: activeWindowSafeAreaTopInset() + ) + ) + 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( + safeAreaTopInset: CGFloat + ) -> Bool { + safeAreaTopInset > 50 + } + + 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 func handleMediaUploadMethodCall( diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index ecaf590fd7..a174c78684 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -6,6 +6,28 @@ import XCTest class RunnerTests: XCTestCase { + func testDynamicIslandQrScannerRecognizesTallSafeAreas() { + for safeAreaTopInset in [51, 59, 62] { + XCTAssertTrue( + AppDelegate.usesDynamicIslandQrScannerPortal( + safeAreaTopInset: CGFloat(safeAreaTopInset) + ), + "\(safeAreaTopInset)" + ) + } + } + + func testDynamicIslandQrScannerRejectsStandardSafeAreas() { + for safeAreaTopInset in [0, 44, 47, 50] { + XCTAssertFalse( + AppDelegate.usesDynamicIslandQrScannerPortal( + safeAreaTopInset: CGFloat(safeAreaTopInset) + ), + "\(safeAreaTopInset)" + ) + } + } + 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..79aa81ab48 --- /dev/null +++ b/mobile/test/features/pairing/pairing_qr_scanner_test.dart @@ -0,0 +1,277 @@ +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', () { + 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; + }); +} + +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(); + } +}