From c44eb83bb5a421767b5b79bfaa34632682a64593 Mon Sep 17 00:00:00 2001 From: josefinalliende Date: Fri, 15 May 2026 01:54:59 -0400 Subject: [PATCH 1/4] fix: add qr scanner hook with fixed os back case on camera access permission --- lib/hooks/use_qr_scanner.dart | 164 ++++++++++++ test/hooks/use_qr_scanner_test.dart | 401 ++++++++++++++++++++++++++++ 2 files changed, 565 insertions(+) create mode 100644 lib/hooks/use_qr_scanner.dart create mode 100644 test/hooks/use_qr_scanner_test.dart diff --git a/lib/hooks/use_qr_scanner.dart b/lib/hooks/use_qr_scanner.dart new file mode 100644 index 00000000..162afd12 --- /dev/null +++ b/lib/hooks/use_qr_scanner.dart @@ -0,0 +1,164 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:permission_handler/permission_handler.dart'; + +class _Injectable { + final T _default; + late T _current; + _Injectable(this._default) : _current = _default; + T get value => _current; + void set(T v) => _current = v; + void reset() => _current = _default; +} + +final _controllerFactory = _Injectable( + () => MobileScannerController( + formats: [BarcodeFormat.qrCode], + autoStart: false, + ), +); +final _permissionRequester = _Injectable Function()>( + () => Permission.camera.request(), +); +final _permissionStatusChecker = _Injectable Function()>( + () => Permission.camera.status, +); + +MobileScannerController createScannerController() => _controllerFactory.value(); +Future requestCameraPermission() => _permissionRequester.value(); +Future checkCameraPermissionStatus() => _permissionStatusChecker.value(); + +void setScannerControllerFactory(MobileScannerController Function() factory) => + _controllerFactory.set(factory); +void resetScannerControllerFactory() => _controllerFactory.reset(); +void setPermissionRequester(Future Function() requester) => + _permissionRequester.set(requester); +void resetPermissionRequester() => _permissionRequester.reset(); +void setPermissionStatusChecker(Future Function() checker) => + _permissionStatusChecker.set(checker); +void resetPermissionStatusChecker() => _permissionStatusChecker.reset(); + +enum ScannerState { loading, ready, permissionDenied, initError, error } + +({ + ScannerState scannerState, + MobileScannerController controller, + VoidCallback retryScanner, + void Function(ScannerState) setErrorState, +}) +useQrScanner({required void Function(String) onQrCodeDetected}) { + final isProcessing = useState(false); + final scannerRetryKey = useState(UniqueKey()); + final isMounted = useRef(true); + final scannerState = useState(ScannerState.loading); + final permissionStatusAtDenial = useRef(null); + + useEffect(() { + isMounted.value = true; + return () => isMounted.value = false; + }, const []); + + useEffect(() { + Future checkPermission() async { + final status = await requestCameraPermission(); + if (!isMounted.value) return; + + if (status.isGranted || status.isLimited) { + permissionStatusAtDenial.value = null; + scannerState.value = ScannerState.ready; + } else { + final currentStatus = await checkCameraPermissionStatus(); + if (!isMounted.value) return; + permissionStatusAtDenial.value = currentStatus; + scannerState.value = ScannerState.permissionDenied; + } + } + + unawaited(checkPermission()); + return null; + }, [scannerRetryKey.value]); + + final controller = useMemoized(createScannerController, [ + scannerRetryKey.value, + ]); + + useEffect(() { + if (scannerState.value != ScannerState.ready) return null; + + var startCalled = false; + + void handleQrCode(BarcodeCapture capture) { + if (capture.barcodes.isEmpty) return; + if (isProcessing.value) return; + final barcode = capture.barcodes.first; + final rawValue = barcode.rawValue ?? ''; + if (rawValue.isEmpty) return; + isProcessing.value = true; + onQrCodeDetected(rawValue.trim()); + Future.delayed(const Duration(milliseconds: 500), () { + if (isMounted.value) isProcessing.value = false; + }); + } + + Future startController() async { + if (startCalled) return; + startCalled = true; + try { + await controller.start(); + } on MobileScannerException { + if (isMounted.value) scannerState.value = ScannerState.initError; + } + } + + final subscription = controller.barcodes.listen(handleQrCode); + unawaited(startController()); + + return () { + unawaited(subscription.cancel()); + unawaited(controller.dispose()); + }; + }, [controller, scannerState.value]); + + void retryScanner() { + scannerState.value = ScannerState.loading; + scannerRetryKey.value = UniqueKey(); + } + + useOnAppLifecycleStateChange((previous, current) { + if (current != AppLifecycleState.resumed || !isMounted.value) return; + if (scannerState.value == ScannerState.permissionDenied) { + Future checkAndMaybeRetry() async { + final cameraPermissionStatus = await checkCameraPermissionStatus(); + if (!isMounted.value) return; + final snapshotWasDenied = + permissionStatusAtDenial.value != null && + !(permissionStatusAtDenial.value!.isGranted || + permissionStatusAtDenial.value!.isLimited); + if (snapshotWasDenied && + (cameraPermissionStatus.isGranted || cameraPermissionStatus.isLimited)) { + retryScanner(); + } + } + + unawaited(checkAndMaybeRetry()); + } else { + retryScanner(); + } + }); + + void setErrorState(ScannerState state) { + if (isMounted.value) { + scannerState.value = state; + } + } + + return ( + scannerState: scannerState.value, + controller: controller, + retryScanner: retryScanner, + setErrorState: setErrorState, + ); +} diff --git a/test/hooks/use_qr_scanner_test.dart b/test/hooks/use_qr_scanner_test.dart new file mode 100644 index 00000000..74c16f66 --- /dev/null +++ b/test/hooks/use_qr_scanner_test.dart @@ -0,0 +1,401 @@ +import 'dart:async'; +import 'dart:ui' show AppLifecycleState; + +import 'package:flutter/material.dart' show Key; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:whitenoise/widgets/qr_scanner.dart'; +import '../mocks/mock_scanner_controller.dart'; +import '../test_helpers.dart' show mountWidget; + +void main() { + group('useQrScanner', () { + late MockScannerController mockController; + + setUp(() { + mockController = setupMockScannerController(); + setPermissionRequester(() async => PermissionStatus.granted); + setPermissionStatusChecker(() async => PermissionStatus.granted); + }); + + tearDown(() { + tearDownMockScannerController(); + resetPermissionRequester(); + resetPermissionStatusChecker(); + }); + + group('qrCode detection', () { + testWidgets('calls onQrCodeDetected when qr code is scanned', ( + tester, + ) async { + String? detectedValue; + + await mountWidget( + QrScanner(onQrCodeDetected: (value) => detectedValue = value), + tester, + ); + await tester.pump(); + + mockController.emitBarcode('npub1testvalue'); + await tester.pump(); + + expect(detectedValue, 'npub1testvalue'); + + await tester.pump(const Duration(milliseconds: 600)); + }); + + testWidgets('ignores empty barcode list', (tester) async { + String? detectedValue; + + await mountWidget( + QrScanner(onQrCodeDetected: (value) => detectedValue = value), + tester, + ); + await tester.pump(); + + mockController.emitEmpty(); + await tester.pump(); + + expect(detectedValue, isNull); + }); + + testWidgets('ignores barcode with empty value', (tester) async { + String? detectedValue; + + await mountWidget( + QrScanner(onQrCodeDetected: (value) => detectedValue = value), + tester, + ); + await tester.pump(); + + mockController.emitBarcodeWithEmptyValue(); + await tester.pump(); + + expect(detectedValue, isNull); + }); + + testWidgets('trims whitespace from scanned value', (tester) async { + String? detectedValue; + + await mountWidget( + QrScanner(onQrCodeDetected: (value) => detectedValue = value), + tester, + ); + await tester.pump(); + + mockController.emitBarcode(' npub1test '); + await tester.pump(); + + expect(detectedValue, 'npub1test'); + + await tester.pump(const Duration(milliseconds: 600)); + }); + + testWidgets('ignores qr codes while processing', (tester) async { + final detectedValues = []; + + await mountWidget( + QrScanner(onQrCodeDetected: (value) => detectedValues.add(value)), + tester, + ); + await tester.pump(); + + mockController.emitBarcode('first'); + await tester.pump(); + mockController.emitBarcode('second'); + await tester.pump(); + + expect(detectedValues, ['first']); + + await tester.pump(const Duration(milliseconds: 600)); + }); + + testWidgets('resets processing state after delay', (tester) async { + final detectedValues = []; + + await mountWidget( + QrScanner(onQrCodeDetected: (value) => detectedValues.add(value)), + tester, + ); + await tester.pump(); + + mockController.emitBarcode('first'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + + mockController.emitBarcode('second'); + await tester.pump(); + + expect(detectedValues, ['first', 'second']); + + await tester.pump(const Duration(milliseconds: 600)); + }); + }); + + group('lifecycle', () { + testWidgets('recreates scanner controller on resume', (tester) async { + setPermissionStatusChecker(() async => PermissionStatus.granted); + + await mountWidget(QrScanner(onQrCodeDetected: (_) {}), tester); + await tester.pump(); + + expect(mockController.startCallCount, 1); + expect(mockController.disposeCalled, isFalse); + + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + await tester.pump(); + await tester.pump(); + await tester.pump(); + + expect(mockController.startCallCount, 2); + expect(mockController.disposeCalled, isTrue); + expect(find.byType(MobileScanner), findsOneWidget); + }); + + testWidgets( + 'recreates scanner controller after first-time permission grant when app resumes', + (tester) async { + final completer = Completer(); + setPermissionRequester(() => completer.future); + + await mountWidget(QrScanner(onQrCodeDetected: (_) {}), tester); + await tester.pump(); + + expect(find.byKey(const Key('scanner_placeholder')), findsOneWidget); + + completer.complete(PermissionStatus.granted); + await tester.pump(); + await tester.pump(); + expect(find.byType(MobileScanner), findsOneWidget); + expect(mockController.startCallCount, 1); + + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + await tester.pump(); + await tester.pump(); + + expect(mockController.startCallCount, 2); + expect(mockController.disposeCalled, isTrue); + expect(find.byType(MobileScanner), findsOneWidget); + }, + ); + + testWidgets('does not re-request permission on resume when denied', ( + tester, + ) async { + var requestCount = 0; + setPermissionRequester(() async { + requestCount++; + return PermissionStatus.denied; + }); + setPermissionStatusChecker(() async => PermissionStatus.denied); + + await mountWidget(QrScanner(onQrCodeDetected: (_) {}), tester); + await tester.pump(); + + expect(requestCount, 1); + + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + await tester.pump(); + await tester.pump(); + + expect(requestCount, 1); + }); + + testWidgets('shows error UI after denial when lifecycle resume fires', ( + tester, + ) async { + setPermissionRequester(() async => PermissionStatus.denied); + setPermissionStatusChecker(() async => PermissionStatus.denied); + + await mountWidget(QrScanner(onQrCodeDetected: (_) {}), tester); + await tester.pump(); + + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + await tester.pump(); + await tester.pump(); + await tester.pump(); + + expect(find.byKey(const Key('scanner_notice_icon')), findsOneWidget); + expect(find.byKey(const Key('open_settings_button')), findsOneWidget); + expect(find.byType(MobileScanner), findsNothing); + }); + + testWidgets( + 'retries scanner on resume after user grants permission in settings', + (tester) async { + setPermissionRequester(() async => PermissionStatus.denied); + setPermissionStatusChecker(() async => PermissionStatus.denied); + + await mountWidget(QrScanner(onQrCodeDetected: (_) {}), tester); + await tester.pump(); + + expect(find.byKey(const Key('open_settings_button')), findsOneWidget); + + setPermissionRequester(() async => PermissionStatus.granted); + setPermissionStatusChecker(() async => PermissionStatus.granted); + + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + await tester.pump(); + await tester.pump(); + await tester.pump(); + await tester.pump(); + + expect(find.byType(MobileScanner), findsOneWidget); + }, + ); + + testWidgets( + 'does not retry permission on resume after dialog dismissal even if status returns granted', + (tester) async { + var requestCount = 0; + setPermissionRequester(() async { + requestCount++; + return PermissionStatus.denied; + }); + setPermissionStatusChecker(() async => PermissionStatus.granted); + + await mountWidget(QrScanner(onQrCodeDetected: (_) {}), tester); + await tester.pump(); + + expect(requestCount, 1); + expect(find.byKey(const Key('open_settings_button')), findsOneWidget); + + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + await tester.pump(); + await tester.pump(); + await tester.pump(); + + expect( + requestCount, + 1, + reason: + 'must not retry permission on lifecycle resume just because the cached ' + 'status checker returned granted; only an explicit settings trip should retry', + ); + expect(find.byKey(const Key('open_settings_button')), findsOneWidget); + }, + ); + }); + + group('permission handling', () { + testWidgets('shows placeholder when permission is permanently denied', ( + tester, + ) async { + setPermissionRequester(() async => PermissionStatus.permanentlyDenied); + + await mountWidget(QrScanner(onQrCodeDetected: (_) {}), tester); + await tester.pump(); + + expect(find.byKey(const Key('scanner_notice')), findsOneWidget); + expect(find.byType(MobileScanner), findsNothing); + }); + + testWidgets('shows scanner when permission is limited', (tester) async { + setPermissionRequester(() async => PermissionStatus.limited); + + await mountWidget(QrScanner(onQrCodeDetected: (_) {}), tester); + await tester.pump(); + + expect(find.byType(MobileScanner), findsOneWidget); + }); + }); + + group('start guard', () { + testWidgets('does not call start twice on the same controller', ( + tester, + ) async { + await mountWidget(QrScanner(onQrCodeDetected: (_) {}), tester); + await tester.pump(); + + expect(mockController.startCallCount, 1); + }); + + testWidgets('resets start guard on resume', (tester) async { + setPermissionStatusChecker(() async => PermissionStatus.granted); + + await mountWidget(QrScanner(onQrCodeDetected: (_) {}), tester); + await tester.pump(); + + expect(mockController.startCallCount, 1); + + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + await tester.pump(); + await tester.pump(); + await tester.pump(); + + expect(mockController.startCallCount, 2); + }); + }); + + group('controller.start failures', () { + testWidgets( + 'transitions to initError when controller.start throws controllerNotAttached', + (tester) async { + mockController.startException = const MobileScannerException( + errorCode: MobileScannerErrorCode.controllerNotAttached, + ); + + await mountWidget(QrScanner(onQrCodeDetected: (_) {}), tester); + await tester.pump(); + await tester.pump(); + + expect(find.byType(MobileScanner), findsNothing); + expect(find.byKey(const Key('retry_scanner_button')), findsOneWidget); + }, + ); + + testWidgets( + 'transitions to initError when controller.start throws controllerDisposed', + (tester) async { + mockController.startException = const MobileScannerException( + errorCode: MobileScannerErrorCode.controllerDisposed, + ); + + await mountWidget(QrScanner(onQrCodeDetected: (_) {}), tester); + await tester.pump(); + await tester.pump(); + + expect(find.byType(MobileScanner), findsNothing); + expect(find.byKey(const Key('retry_scanner_button')), findsOneWidget); + }, + ); + + testWidgets('retry recovers from initError when start no longer throws', ( + tester, + ) async { + mockController.startException = const MobileScannerException( + errorCode: MobileScannerErrorCode.controllerNotAttached, + ); + + await mountWidget(QrScanner(onQrCodeDetected: (_) {}), tester); + await tester.pump(); + await tester.pump(); + + expect(find.byKey(const Key('retry_scanner_button')), findsOneWidget); + + mockController.startException = null; + await tester.tap(find.byKey(const Key('retry_scanner_button'))); + await tester.pump(); + await tester.pump(); + await tester.pump(); + + expect(find.byType(MobileScanner), findsOneWidget); + }); + }); + }); +} From 79a3da3612ff72e0dbb4db8beaf90771778c34f0 Mon Sep 17 00:00:00 2001 From: josefinalliende Date: Sat, 16 May 2026 00:53:29 -0400 Subject: [PATCH 2/4] refactor: use new hook in qr scanner widget and rename barcode to qrcode --- lib/hooks/use_qr_scanner.dart | 115 +++++--- lib/widgets/qr_scanner.dart | 175 ++---------- test/hooks/use_qr_scanner_test.dart | 66 +++++ test/widgets/qr_scanner_test.dart | 409 +++------------------------- 4 files changed, 197 insertions(+), 568 deletions(-) diff --git a/lib/hooks/use_qr_scanner.dart b/lib/hooks/use_qr_scanner.dart index 162afd12..5b36529c 100644 --- a/lib/hooks/use_qr_scanner.dart +++ b/lib/hooks/use_qr_scanner.dart @@ -43,43 +43,87 @@ void resetPermissionStatusChecker() => _permissionStatusChecker.reset(); enum ScannerState { loading, ready, permissionDenied, initError, error } -({ - ScannerState scannerState, - MobileScannerController controller, - VoidCallback retryScanner, - void Function(ScannerState) setErrorState, -}) -useQrScanner({required void Function(String) onQrCodeDetected}) { - final isProcessing = useState(false); - final scannerRetryKey = useState(UniqueKey()); - final isMounted = useRef(true); - final scannerState = useState(ScannerState.loading); +void _useCameraPermission({ + required UniqueKey retryKey, + required ObjectRef isMounted, + required VoidCallback onGranted, + required VoidCallback onDenied, + required VoidCallback retryScanner, +}) { + final isDenied = useRef(false); final permissionStatusAtDenial = useRef(null); - useEffect(() { - isMounted.value = true; - return () => isMounted.value = false; - }, const []); - useEffect(() { Future checkPermission() async { final status = await requestCameraPermission(); if (!isMounted.value) return; if (status.isGranted || status.isLimited) { + isDenied.value = false; permissionStatusAtDenial.value = null; - scannerState.value = ScannerState.ready; + onGranted(); } else { final currentStatus = await checkCameraPermissionStatus(); if (!isMounted.value) return; + isDenied.value = true; permissionStatusAtDenial.value = currentStatus; - scannerState.value = ScannerState.permissionDenied; + onDenied(); } } unawaited(checkPermission()); return null; - }, [scannerRetryKey.value]); + }, [retryKey]); + + useOnAppLifecycleStateChange((previous, current) { + if (current != AppLifecycleState.resumed || !isMounted.value) return; + if (!isDenied.value) return; + + Future checkAndRetry() async { + final cameraPermissionStatus = await checkCameraPermissionStatus(); + if (!isMounted.value) return; + final snapshotWasDenied = + permissionStatusAtDenial.value != null && + !(permissionStatusAtDenial.value!.isGranted || permissionStatusAtDenial.value!.isLimited); + if (snapshotWasDenied && + (cameraPermissionStatus.isGranted || cameraPermissionStatus.isLimited)) { + retryScanner(); + } + } + + unawaited(checkAndRetry()); + }); +} + +({ + ScannerState scannerState, + MobileScannerController controller, + VoidCallback retryScanner, + void Function(ScannerState) setErrorState, +}) +useQrScanner({required void Function(String) onQrCodeDetected}) { + final isProcessing = useState(false); + final scannerRetryKey = useState(UniqueKey()); + final isMounted = useRef(true); + final scannerState = useState(ScannerState.loading); + + useEffect(() { + isMounted.value = true; + return () => isMounted.value = false; + }, const []); + + void retryScanner() { + scannerState.value = ScannerState.loading; + scannerRetryKey.value = UniqueKey(); + } + + _useCameraPermission( + retryKey: scannerRetryKey.value, + isMounted: isMounted, + onGranted: () => scannerState.value = ScannerState.ready, + onDenied: () => scannerState.value = ScannerState.permissionDenied, + retryScanner: retryScanner, + ); final controller = useMemoized(createScannerController, [ scannerRetryKey.value, @@ -122,37 +166,26 @@ useQrScanner({required void Function(String) onQrCodeDetected}) { }; }, [controller, scannerState.value]); - void retryScanner() { - scannerState.value = ScannerState.loading; - scannerRetryKey.value = UniqueKey(); - } - useOnAppLifecycleStateChange((previous, current) { if (current != AppLifecycleState.resumed || !isMounted.value) return; - if (scannerState.value == ScannerState.permissionDenied) { - Future checkAndMaybeRetry() async { - final cameraPermissionStatus = await checkCameraPermissionStatus(); + if (scannerState.value == ScannerState.permissionDenied) return; + + if (scannerState.value == ScannerState.loading) { + unawaited(() async { + final status = await checkCameraPermissionStatus(); if (!isMounted.value) return; - final snapshotWasDenied = - permissionStatusAtDenial.value != null && - !(permissionStatusAtDenial.value!.isGranted || - permissionStatusAtDenial.value!.isLimited); - if (snapshotWasDenied && - (cameraPermissionStatus.isGranted || cameraPermissionStatus.isLimited)) { + if (status.isGranted || status.isLimited) { retryScanner(); } - } - - unawaited(checkAndMaybeRetry()); - } else { - retryScanner(); + }()); + return; } + + retryScanner(); }); void setErrorState(ScannerState state) { - if (isMounted.value) { - scannerState.value = state; - } + if (isMounted.value) scannerState.value = state; } return ( diff --git a/lib/widgets/qr_scanner.dart b/lib/widgets/qr_scanner.dart index bb2aac15..4aac8ac1 100644 --- a/lib/widgets/qr_scanner.dart +++ b/lib/widgets/qr_scanner.dart @@ -1,177 +1,48 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:gap/gap.dart' show Gap; import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'package:whitenoise/hooks/use_qr_scanner.dart'; import 'package:whitenoise/l10n/l10n.dart'; import 'package:whitenoise/theme.dart'; import 'package:whitenoise/widgets/wn_button.dart'; import 'package:whitenoise/widgets/wn_icon.dart'; -MobileScannerController Function() _controllerFactory = _defaultControllerFactory; - -MobileScannerController _defaultControllerFactory() => - MobileScannerController(formats: [BarcodeFormat.qrCode], autoStart: false); - -MobileScannerController createScannerController() => _controllerFactory(); - -void setScannerControllerFactory(MobileScannerController Function() factory) { - _controllerFactory = factory; -} - -void resetScannerControllerFactory() { - _controllerFactory = _defaultControllerFactory; -} - -Future Function() _permissionRequester = _defaultPermissionRequester; - -Future _defaultPermissionRequester() => Permission.camera.request(); - -Future requestCameraPermission() => _permissionRequester(); - -void setPermissionRequester(Future Function() requester) { - _permissionRequester = requester; -} - -void resetPermissionRequester() { - _permissionRequester = _defaultPermissionRequester; -} - -Future Function() _permissionStatusChecker = _defaultPermissionStatusChecker; - -Future _defaultPermissionStatusChecker() => Permission.camera.status; - -Future checkCameraPermissionStatus() => _permissionStatusChecker(); - -void setPermissionStatusChecker(Future Function() checker) { - _permissionStatusChecker = checker; -} - -void resetPermissionStatusChecker() { - _permissionStatusChecker = _defaultPermissionStatusChecker; -} - -enum ScannerState { - loading, - ready, - permissionDenied, - initError, - error, -} +export 'package:whitenoise/hooks/use_qr_scanner.dart' + show + ScannerState, + createScannerController, + setScannerControllerFactory, + resetScannerControllerFactory, + requestCameraPermission, + setPermissionRequester, + resetPermissionRequester, + checkCameraPermissionStatus, + setPermissionStatusChecker, + resetPermissionStatusChecker; class QrScanner extends HookWidget { const QrScanner({ super.key, - required this.onBarcodeDetected, + required this.onQrCodeDetected, this.width, this.height, }); - final void Function(String value) onBarcodeDetected; + final void Function(String value) onQrCodeDetected; final double? width; final double? height; @override Widget build(BuildContext context) { final colors = context.colors; - final isProcessing = useState(false); - final scannerRetryKey = useState(UniqueKey()); - final isMounted = useRef(true); - final scannerState = useState(ScannerState.loading); - final lastDeniedStatus = useRef(null); - - useEffect(() { - isMounted.value = true; - return () => isMounted.value = false; - }, const []); - - useEffect(() { - Future checkPermission() async { - final status = await requestCameraPermission(); - if (!isMounted.value) return; - - if (status.isGranted || status.isLimited) { - lastDeniedStatus.value = null; - scannerState.value = ScannerState.ready; - } else { - final currentStatus = await checkCameraPermissionStatus(); - if (!isMounted.value) return; - lastDeniedStatus.value = currentStatus; - scannerState.value = ScannerState.permissionDenied; - } - } - - unawaited(checkPermission()); - return null; - }, [scannerRetryKey.value]); - - final controller = useMemoized( - createScannerController, - [scannerRetryKey.value], + final (:scannerState, :controller, :retryScanner, :setErrorState) = useQrScanner( + onQrCodeDetected: onQrCodeDetected, ); - - useEffect(() { - if (scannerState.value != ScannerState.ready) return null; - - var started = false; - - void handleBarcode(BarcodeCapture capture) { - if (capture.barcodes.isEmpty) return; - if (isProcessing.value) return; - final barcode = capture.barcodes.first; - final rawValue = barcode.rawValue ?? ''; - if (rawValue.isEmpty) return; - isProcessing.value = true; - onBarcodeDetected(rawValue.trim()); - Future.delayed(const Duration(milliseconds: 500), () { - if (isMounted.value) isProcessing.value = false; - }); - } - - Future startController() async { - if (started) return; - started = true; - await controller.start(); - } - - final subscription = controller.barcodes.listen(handleBarcode); - unawaited(startController()); - - return () { - unawaited(subscription.cancel()); - controller.dispose(); - }; - }, [controller, scannerState.value]); - - void retryScanner() { - scannerState.value = ScannerState.loading; - scannerRetryKey.value = UniqueKey(); - } - - useOnAppLifecycleStateChange((previous, current) { - if (current != AppLifecycleState.resumed || !isMounted.value) return; - if (scannerState.value == ScannerState.permissionDenied) { - unawaited(() async { - final cameraPermissionStatus = await checkCameraPermissionStatus(); - if (!isMounted.value) return; - final snapshotWasDenied = - lastDeniedStatus.value != null && - !(lastDeniedStatus.value!.isGranted || lastDeniedStatus.value!.isLimited); - if (snapshotWasDenied && - (cameraPermissionStatus.isGranted || cameraPermissionStatus.isLimited)) { - retryScanner(); - } - }()); - } else { - retryScanner(); - } - }); - - final showScanner = scannerState.value == ScannerState.ready; - final isError = scannerState.value != ScannerState.loading && !showScanner; + final showScanner = scannerState == ScannerState.ready; + final isError = scannerState != ScannerState.loading && !showScanner; return Container( key: const Key('qr_scanner'), @@ -185,7 +56,7 @@ class QrScanner extends HookWidget { borderRadius: BorderRadius.circular(7.r), child: showScanner ? MobileScanner( - key: ValueKey(scannerRetryKey.value), + key: ValueKey(controller), controller: controller, errorBuilder: (context, error) { final newState = switch (error.errorCode) { @@ -194,9 +65,7 @@ class QrScanner extends HookWidget { _ => ScannerState.error, }; Future.microtask(() { - if (isMounted.value) { - scannerState.value = newState; - } + setErrorState(newState); }); return _ScannerNotice( scannerState: newState, @@ -206,7 +75,7 @@ class QrScanner extends HookWidget { ) : isError ? _ScannerNotice( - scannerState: scannerState.value, + scannerState: scannerState, onRetry: retryScanner, ) : _ScannerPlaceholder(colors: colors), diff --git a/test/hooks/use_qr_scanner_test.dart b/test/hooks/use_qr_scanner_test.dart index 74c16f66..150d050e 100644 --- a/test/hooks/use_qr_scanner_test.dart +++ b/test/hooks/use_qr_scanner_test.dart @@ -288,6 +288,72 @@ void main() { expect(find.byKey(const Key('open_settings_button')), findsOneWidget); }, ); + + testWidgets( + 'does not re-request permission when lifecycle resume fires after user dismissed dialog with back', + (tester) async { + var requestCount = 0; + final completer = Completer(); + setPermissionRequester(() async { + requestCount++; + return completer.future; + }); + setPermissionStatusChecker(() async => PermissionStatus.denied); + + await mountWidget(QrScanner(onQrCodeDetected: (_) {}), tester); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + await tester.pump(); + + completer.complete(PermissionStatus.denied); + await tester.pump(); + await tester.pump(); + + expect( + requestCount, + 1, + reason: + 'must not re-request permission when lifecycle resumes during ' + 'loading state and permission is still denied (user dismissed)', + ); + }, + ); + + testWidgets( + 'recreates scanner controller when lifecycle resume fires before permission grant resolves', + (tester) async { + var factoryCallCount = 0; + setScannerControllerFactory(() { + factoryCallCount++; + return mockController; + }); + final completer = Completer(); + setPermissionRequester(() => completer.future); + setPermissionStatusChecker(() async => PermissionStatus.granted); + + await mountWidget(QrScanner(onQrCodeDetected: (_) {}), tester); + expect(factoryCallCount, 1); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + await tester.pump(); + + completer.complete(PermissionStatus.granted); + await tester.pump(); + await tester.pump(); + await tester.pump(); + + expect(find.byType(MobileScanner), findsOneWidget); + expect( + factoryCallCount, + greaterThanOrEqualTo(2), + reason: + 'controller must be recreated when lifecycle resume fires ' + 'during loading and permission was actually granted', + ); + }, + ); }); group('permission handling', () { diff --git a/test/widgets/qr_scanner_test.dart b/test/widgets/qr_scanner_test.dart index 985d694d..2dad4db8 100644 --- a/test/widgets/qr_scanner_test.dart +++ b/test/widgets/qr_scanner_test.dart @@ -1,6 +1,3 @@ -import 'dart:async'; -import 'dart:ui' show AppLifecycleState; - import 'package:flutter/material.dart' show Key, SizedBox; import 'package:flutter_test/flutter_test.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; @@ -25,7 +22,9 @@ void main() { resetPermissionStatusChecker(); }); - testWidgets('shows loading placeholder before permission resolves', (tester) async { + testWidgets('shows loading placeholder before permission resolves', ( + tester, + ) async { var resolvePermission = false; setPermissionRequester(() async { while (!resolvePermission) { @@ -34,10 +33,7 @@ void main() { return PermissionStatus.granted; }); - await mountWidget( - QrScanner(onBarcodeDetected: (_) {}), - tester, - ); + await mountWidget(QrScanner(onQrCodeDetected: (_) {}), tester); expect(find.byKey(const Key('scanner_placeholder')), findsOneWidget); @@ -46,11 +42,10 @@ void main() { await tester.pump(); }); - testWidgets('renders scanner container after permission granted', (tester) async { - await mountWidget( - QrScanner(onBarcodeDetected: (_) {}), - tester, - ); + testWidgets('renders scanner container after permission granted', ( + tester, + ) async { + await mountWidget(QrScanner(onQrCodeDetected: (_) {}), tester); await tester.pump(); expect(find.byType(MobileScanner), findsOneWidget); @@ -59,10 +54,7 @@ void main() { testWidgets('shows placeholder when permission is denied', (tester) async { setPermissionRequester(() async => PermissionStatus.denied); - await mountWidget( - QrScanner(onBarcodeDetected: (_) {}), - tester, - ); + await mountWidget(QrScanner(onQrCodeDetected: (_) {}), tester); await tester.pump(); expect(find.byKey(const Key('scanner_notice')), findsOneWidget); @@ -72,16 +64,15 @@ void main() { testWidgets('shows error UI when permission is denied', (tester) async { setPermissionRequester(() async => PermissionStatus.denied); - await mountWidget( - QrScanner(onBarcodeDetected: (_) {}), - tester, - ); + await mountWidget(QrScanner(onQrCodeDetected: (_) {}), tester); await tester.pump(); expect(find.byKey(const Key('scanner_notice_icon')), findsOneWidget); expect(find.text('Camera permission denied'), findsOneWidget); expect( - find.text('Please enable camera access in your device settings to scan QR codes.'), + find.text( + 'Please enable camera access in your device settings to scan QR codes.', + ), findsOneWidget, ); expect(find.byKey(const Key('open_settings_button')), findsOneWidget); @@ -96,10 +87,7 @@ void main() { return PermissionStatus.granted; }); - await mountWidget( - QrScanner(onBarcodeDetected: (_) {}), - tester, - ); + await mountWidget(QrScanner(onQrCodeDetected: (_) {}), tester); expect(find.byKey(const Key('scanner_placeholder')), findsOneWidget); expect(find.byKey(const Key('scanner_notice_icon')), findsNothing); @@ -111,11 +99,7 @@ void main() { testWidgets('uses custom dimensions when provided', (tester) async { await mountWidget( - QrScanner( - onBarcodeDetected: (_) {}, - width: 200, - height: 300, - ), + QrScanner(onQrCodeDetected: (_) {}, width: 200, height: 300), tester, ); await tester.pump(); @@ -126,20 +110,14 @@ void main() { }); testWidgets('does not show scan button key by default', (tester) async { - await mountWidget( - QrScanner(onBarcodeDetected: (_) {}), - tester, - ); + await mountWidget(QrScanner(onQrCodeDetected: (_) {}), tester); await tester.pump(); expect(find.byKey(const Key('scan_button')), findsNothing); }); testWidgets('scanner is configured for qrCode format', (tester) async { - await mountWidget( - QrScanner(onBarcodeDetected: (_) {}), - tester, - ); + await mountWidget(QrScanner(onQrCodeDetected: (_) {}), tester); await tester.pump(); final scanner = tester.widget(find.byType(MobileScanner)); @@ -147,10 +125,7 @@ void main() { }); testWidgets('controller has autoStart disabled', (tester) async { - await mountWidget( - QrScanner(onBarcodeDetected: (_) {}), - tester, - ); + await mountWidget(QrScanner(onQrCodeDetected: (_) {}), tester); await tester.pump(); final scanner = tester.widget(find.byType(MobileScanner)); @@ -158,20 +133,14 @@ void main() { }); testWidgets('calls start on controller when mounted', (tester) async { - await mountWidget( - QrScanner(onBarcodeDetected: (_) {}), - tester, - ); + await mountWidget(QrScanner(onQrCodeDetected: (_) {}), tester); await tester.pump(); expect(mockController.startCalled, isTrue); }); testWidgets('disposes controller on unmount', (tester) async { - await mountWidget( - QrScanner(onBarcodeDetected: (_) {}), - tester, - ); + await mountWidget(QrScanner(onQrCodeDetected: (_) {}), tester); await tester.pump(); expect(mockController.disposeCalled, isFalse); @@ -182,264 +151,14 @@ void main() { expect(mockController.disposeCalled, isTrue); }); - group('barcode detection', () { - testWidgets('calls onBarcodeDetected when barcode is scanned', (tester) async { - String? detectedValue; - - await mountWidget( - QrScanner(onBarcodeDetected: (value) => detectedValue = value), - tester, - ); - await tester.pump(); - - mockController.emitBarcode('npub1testvalue'); - await tester.pump(); - - expect(detectedValue, 'npub1testvalue'); - - await tester.pump(const Duration(milliseconds: 600)); - }); - - testWidgets('ignores empty barcode list', (tester) async { - String? detectedValue; - - await mountWidget( - QrScanner(onBarcodeDetected: (value) => detectedValue = value), - tester, - ); - await tester.pump(); - - mockController.emitEmpty(); - await tester.pump(); - - expect(detectedValue, isNull); - }); - - testWidgets('ignores barcode with empty value', (tester) async { - String? detectedValue; - - await mountWidget( - QrScanner(onBarcodeDetected: (value) => detectedValue = value), - tester, - ); - await tester.pump(); - - mockController.emitBarcodeWithEmptyValue(); - await tester.pump(); - - expect(detectedValue, isNull); - }); - - testWidgets('trims whitespace from scanned value', (tester) async { - String? detectedValue; - - await mountWidget( - QrScanner(onBarcodeDetected: (value) => detectedValue = value), - tester, - ); - await tester.pump(); - - mockController.emitBarcode(' npub1test '); - await tester.pump(); - - expect(detectedValue, 'npub1test'); - - await tester.pump(const Duration(milliseconds: 600)); - }); - - testWidgets('ignores barcodes while processing', (tester) async { - final detectedValues = []; - - await mountWidget( - QrScanner(onBarcodeDetected: (value) => detectedValues.add(value)), - tester, - ); - await tester.pump(); - - mockController.emitBarcode('first'); - await tester.pump(); - mockController.emitBarcode('second'); - await tester.pump(); - - expect(detectedValues, ['first']); - - await tester.pump(const Duration(milliseconds: 600)); - }); - - testWidgets('resets processing state after delay', (tester) async { - final detectedValues = []; - - await mountWidget( - QrScanner(onBarcodeDetected: (value) => detectedValues.add(value)), - tester, - ); - await tester.pump(); - - mockController.emitBarcode('first'); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 600)); - - mockController.emitBarcode('second'); - await tester.pump(); - - expect(detectedValues, ['first', 'second']); - - await tester.pump(const Duration(milliseconds: 600)); - }); - }); - - group('lifecycle', () { - testWidgets('recreates scanner controller on resume', (tester) async { - setPermissionStatusChecker(() async => PermissionStatus.granted); - - await mountWidget( - QrScanner(onBarcodeDetected: (_) {}), - tester, - ); - await tester.pump(); - - expect(mockController.startCallCount, 1); - expect(mockController.disposeCalled, isFalse); - - tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); - await tester.pump(); - await tester.pump(); - await tester.pump(); - - expect(mockController.startCallCount, 2); - expect(mockController.disposeCalled, isTrue); - expect(find.byType(MobileScanner), findsOneWidget); - }); - - testWidgets( - 'recreates scanner controller after first-time permission grant when app resumes', - (tester) async { - final completer = Completer(); - setPermissionRequester(() => completer.future); - - await mountWidget(QrScanner(onBarcodeDetected: (_) {}), tester); - await tester.pump(); - - expect(find.byKey(const Key('scanner_placeholder')), findsOneWidget); - - completer.complete(PermissionStatus.granted); - await tester.pump(); - await tester.pump(); - expect(find.byType(MobileScanner), findsOneWidget); - expect(mockController.startCallCount, 1); - - tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); - await tester.pump(); - await tester.pump(); - - expect(mockController.startCallCount, 2); - expect(mockController.disposeCalled, isTrue); - expect(find.byType(MobileScanner), findsOneWidget); - }, - ); - - testWidgets('does not re-request permission on resume when denied', (tester) async { - var requestCount = 0; - setPermissionRequester(() async { - requestCount++; - return PermissionStatus.denied; - }); - setPermissionStatusChecker(() async => PermissionStatus.denied); - - await mountWidget(QrScanner(onBarcodeDetected: (_) {}), tester); - await tester.pump(); - - expect(requestCount, 1); - - tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); - await tester.pump(); - await tester.pump(); - - expect(requestCount, 1); - }); - - testWidgets('shows error UI after denial when lifecycle resume fires', (tester) async { - setPermissionRequester(() async => PermissionStatus.denied); - setPermissionStatusChecker(() async => PermissionStatus.denied); - - await mountWidget(QrScanner(onBarcodeDetected: (_) {}), tester); - await tester.pump(); - - tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); - await tester.pump(); - await tester.pump(); - await tester.pump(); - - expect(find.byKey(const Key('scanner_notice_icon')), findsOneWidget); - expect(find.byKey(const Key('open_settings_button')), findsOneWidget); - expect(find.byType(MobileScanner), findsNothing); - }); - - testWidgets('retries scanner on resume after user grants permission in settings', ( - tester, - ) async { - setPermissionRequester(() async => PermissionStatus.denied); - setPermissionStatusChecker(() async => PermissionStatus.denied); - - await mountWidget(QrScanner(onBarcodeDetected: (_) {}), tester); - await tester.pump(); - - expect(find.byKey(const Key('open_settings_button')), findsOneWidget); - - setPermissionRequester(() async => PermissionStatus.granted); - setPermissionStatusChecker(() async => PermissionStatus.granted); - - tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); - await tester.pump(); - await tester.pump(); - await tester.pump(); - await tester.pump(); - - expect(find.byType(MobileScanner), findsOneWidget); - }); - - testWidgets( - 'does not retry permission on resume after dialog dismissal even if status returns granted', - (tester) async { - var requestCount = 0; - setPermissionRequester(() async { - requestCount++; - return PermissionStatus.denied; - }); - setPermissionStatusChecker(() async => PermissionStatus.granted); - - await mountWidget(QrScanner(onBarcodeDetected: (_) {}), tester); - await tester.pump(); - - expect(requestCount, 1); - expect(find.byKey(const Key('open_settings_button')), findsOneWidget); - - tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); - await tester.pump(); - await tester.pump(); - await tester.pump(); - - expect( - requestCount, - 1, - reason: - 'must not retry permission on lifecycle resume just because the cached ' - 'status checker returned granted; only an explicit settings trip should retry', - ); - expect(find.byKey(const Key('open_settings_button')), findsOneWidget); - }, - ); - }); - group('error handling', () { testWidgets('errorBuilder returns placeholder widget', (tester) async { - await mountWidget( - QrScanner(onBarcodeDetected: (_) {}), - tester, - ); + await mountWidget(QrScanner(onQrCodeDetected: (_) {}), tester); await tester.pump(); - final scanner = tester.widget(find.byType(MobileScanner)); + final scanner = tester.widget( + find.byType(MobileScanner), + ); final context = tester.element(find.byType(MobileScanner)); const error = MobileScannerException( errorCode: MobileScannerErrorCode.permissionDenied, @@ -452,13 +171,12 @@ void main() { testWidgets('shows permission denied message when permission is denied', ( tester, ) async { - await mountWidget( - QrScanner(onBarcodeDetected: (_) {}), - tester, - ); + await mountWidget(QrScanner(onQrCodeDetected: (_) {}), tester); await tester.pump(); - final scanner = tester.widget(find.byType(MobileScanner)); + final scanner = tester.widget( + find.byType(MobileScanner), + ); final context = tester.element(find.byType(MobileScanner)); const error = MobileScannerException( errorCode: MobileScannerErrorCode.permissionDenied, @@ -472,14 +190,15 @@ void main() { expect(find.byKey(const Key('open_settings_button')), findsOneWidget); }); - testWidgets('shows scanner error UI with retry after generic error', (tester) async { - await mountWidget( - QrScanner(onBarcodeDetected: (_) {}), - tester, - ); + testWidgets('shows scanner error UI with retry after generic error', ( + tester, + ) async { + await mountWidget(QrScanner(onQrCodeDetected: (_) {}), tester); await tester.pump(); - final scanner = tester.widget(find.byType(MobileScanner)); + final scanner = tester.widget( + find.byType(MobileScanner), + ); final context = tester.element(find.byType(MobileScanner)); const error = MobileScannerException( errorCode: MobileScannerErrorCode.genericError, @@ -493,63 +212,5 @@ void main() { expect(find.byKey(const Key('retry_scanner_button')), findsOneWidget); }); }); - - group('start guard', () { - testWidgets('does not call start twice on the same controller', (tester) async { - await mountWidget( - QrScanner(onBarcodeDetected: (_) {}), - tester, - ); - await tester.pump(); - - expect(mockController.startCallCount, 1); - }); - - testWidgets('resets start guard on resume', (tester) async { - setPermissionStatusChecker(() async => PermissionStatus.granted); - - await mountWidget( - QrScanner(onBarcodeDetected: (_) {}), - tester, - ); - await tester.pump(); - - expect(mockController.startCallCount, 1); - - tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); - await tester.pump(); - await tester.pump(); - await tester.pump(); - - expect(mockController.startCallCount, 2); - }); - }); - - group('permission handling', () { - testWidgets('shows placeholder when permission is permanently denied', (tester) async { - setPermissionRequester(() async => PermissionStatus.permanentlyDenied); - - await mountWidget( - QrScanner(onBarcodeDetected: (_) {}), - tester, - ); - await tester.pump(); - - expect(find.byKey(const Key('scanner_notice')), findsOneWidget); - expect(find.byType(MobileScanner), findsNothing); - }); - - testWidgets('shows scanner when permission is limited', (tester) async { - setPermissionRequester(() async => PermissionStatus.limited); - - await mountWidget( - QrScanner(onBarcodeDetected: (_) {}), - tester, - ); - await tester.pump(); - - expect(find.byType(MobileScanner), findsOneWidget); - }); - }); }); } From cf3c882f1311fc8958cf89863290e2ac28d6e2d3 Mon Sep 17 00:00:00 2001 From: josefinalliende Date: Sat, 16 May 2026 00:54:04 -0400 Subject: [PATCH 3/4] refactor: use renamed qr code method in scan npub screen --- lib/screens/scan_npub_screen.dart | 8 +- lib/screens/scan_nsec_screen.dart | 2 +- test/screens/scan_npub_screen_test.dart | 114 ++++++++++++++---------- test/screens/scan_nsec_screen_test.dart | 4 +- 4 files changed, 70 insertions(+), 58 deletions(-) diff --git a/lib/screens/scan_npub_screen.dart b/lib/screens/scan_npub_screen.dart index 129a07cf..a1b17039 100644 --- a/lib/screens/scan_npub_screen.dart +++ b/lib/screens/scan_npub_screen.dart @@ -21,7 +21,7 @@ class ScanNpubScreen extends HookWidget { final l10n = context.l10n; final showInvalidNpubError = useState(false); - void onBarcodeDetected(String value) { + void onQrCodeDetected(String value) { final deepLinkTarget = DeepLinks.parseString(value); if (deepLinkTarget != null) { Routes.goBack(context); @@ -51,11 +51,7 @@ class ScanNpubScreen extends HookWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Expanded( - child: QrScanner( - onBarcodeDetected: onBarcodeDetected, - ), - ), + Expanded(child: QrScanner(onQrCodeDetected: onQrCodeDetected)), Gap(12.h), Text( showInvalidNpubError.value ? l10n.invalidNpub : l10n.scanNpubHint, diff --git a/lib/screens/scan_nsec_screen.dart b/lib/screens/scan_nsec_screen.dart index 5a644283..b208be8c 100644 --- a/lib/screens/scan_nsec_screen.dart +++ b/lib/screens/scan_nsec_screen.dart @@ -42,7 +42,7 @@ class ScanNsecScreen extends StatelessWidget { children: [ Expanded( child: QrScanner( - onBarcodeDetected: (value) => GoRouter.of(context).pop(value), + onQrCodeDetected: (value) => GoRouter.of(context).pop(value), ), ), Gap(12.h), diff --git a/test/screens/scan_npub_screen_test.dart b/test/screens/scan_npub_screen_test.dart index 3e0fedf0..22ae9e0c 100644 --- a/test/screens/scan_npub_screen_test.dart +++ b/test/screens/scan_npub_screen_test.dart @@ -102,7 +102,9 @@ void main() { }); group('navigation', () { - testWidgets('tapping back button returns to share profile screen', (tester) async { + testWidgets('tapping back button returns to share profile screen', ( + tester, + ) async { await pumpScanNpubScreen(tester); await tester.tap(find.byKey(const Key('slate_back_button'))); await tester.pumpAndSettle(); @@ -110,66 +112,80 @@ void main() { }); }); - group('barcode detection', () { - testWidgets('calling onBarcodeDetected with valid npub navigates to start chat', ( - tester, - ) async { - await pumpScanNpubScreen(tester); + group('QrCode detection', () { + testWidgets( + 'calling onQrCodeDetected with valid npub navigates to start chat', + (tester) async { + await pumpScanNpubScreen(tester); - final scanBox = tester.widget(find.byType(QrScanner)); - scanBox.onBarcodeDetected(testNpubB); - await tester.pumpAndSettle(); - - expect(find.byType(UserProfileScreen), findsOneWidget); - }); + final qrScanner = tester.widget(find.byType(QrScanner)); + qrScanner.onQrCodeDetected(testNpubB); + await tester.pumpAndSettle(); - testWidgets('calling onBarcodeDetected with user deep link navigates to start chat', ( - tester, - ) async { - await pumpScanNpubScreen(tester); + expect(find.byType(UserProfileScreen), findsOneWidget); + }, + ); - final scanBox = tester.widget(find.byType(QrScanner)); - scanBox.onBarcodeDetected('whitenoise://user/$testNpubB'); - await tester.pumpAndSettle(); + testWidgets( + 'calling onQrCodeDetected with user deep link navigates to start chat', + (tester) async { + await pumpScanNpubScreen(tester); - final screen = tester.widget(find.byType(UserProfileScreen)); - expect(screen.userPubkey, testPubkeyB); - }); + final qrScanner = tester.widget(find.byType(QrScanner)); + qrScanner.onQrCodeDetected('whitenoise://user/$testNpubB'); + await tester.pumpAndSettle(); - testWidgets('calling onBarcodeDetected with chat deep link navigates to chat', ( - tester, - ) async { - await pumpScanNpubScreen(tester); - - final scanBox = tester.widget(find.byType(QrScanner)); - scanBox.onBarcodeDetected('whitenoise://chat/$testGroupId'); - await tester.pumpAndSettle(); + final screen = tester.widget( + find.byType(UserProfileScreen), + ); + expect(screen.userPubkey, testPubkeyB); + }, + ); - final screen = tester.widget(find.byType(ChatScreen)); - expect(screen.groupId, testGroupId); - }); + testWidgets( + 'calling onQrCodeDetected with chat deep link navigates to chat', + (tester) async { + await pumpScanNpubScreen(tester); - testWidgets('calling onBarcodeDetected with non-npub value does nothing', (tester) async { - await pumpScanNpubScreen(tester); + final qrScanner = tester.widget(find.byType(QrScanner)); + qrScanner.onQrCodeDetected('whitenoise://chat/$testGroupId'); + await tester.pumpAndSettle(); - final scanBox = tester.widget(find.byType(QrScanner)); - scanBox.onBarcodeDetected('https://example.com'); - await tester.pumpAndSettle(); + final screen = tester.widget(find.byType(ChatScreen)); + expect(screen.groupId, testGroupId); + }, + ); - expect(find.byType(QrScanner), findsOneWidget); - expect(find.text('Scan a contact\'s QR code.'), findsOneWidget); - }); + testWidgets( + 'calling onQrCodeDetected with non-npub value does nothing', + (tester) async { + await pumpScanNpubScreen(tester); - testWidgets('calling onBarcodeDetected with invalid npub shows error', (tester) async { - await pumpScanNpubScreen(tester); + final qrScanner = tester.widget(find.byType(QrScanner)); + qrScanner.onQrCodeDetected('https://example.com'); + await tester.pumpAndSettle(); - final scanBox = tester.widget(find.byType(QrScanner)); - scanBox.onBarcodeDetected('npub1invalidkey'); - await tester.pumpAndSettle(); + expect(find.byType(QrScanner), findsOneWidget); + expect(find.text('Scan a contact\'s QR code.'), findsOneWidget); + }, + ); - expect(find.byType(QrScanner), findsOneWidget); - expect(find.text('Invalid public key. Please try again.'), findsOneWidget); - }); + testWidgets( + 'calling onQrCodeDetected with invalid npub shows error', + (tester) async { + await pumpScanNpubScreen(tester); + + final qrScanner = tester.widget(find.byType(QrScanner)); + qrScanner.onQrCodeDetected('npub1invalid'); + await tester.pumpAndSettle(); + + expect(find.byType(QrScanner), findsOneWidget); + expect( + find.text('Invalid public key. Please try again.'), + findsOneWidget, + ); + }, + ); }); }); diff --git a/test/screens/scan_nsec_screen_test.dart b/test/screens/scan_nsec_screen_test.dart index a5c2fe1d..28c9c878 100644 --- a/test/screens/scan_nsec_screen_test.dart +++ b/test/screens/scan_nsec_screen_test.dart @@ -117,11 +117,11 @@ void main() { }); group('barcode detection', () { - testWidgets('calling onBarcodeDetected pops with scanned value', (tester) async { + testWidgets('calling onQrCodeDetected pops with scanned value', (tester) async { await pumpScanNsecScreen(tester); final scanBox = tester.widget(find.byType(QrScanner)); - scanBox.onBarcodeDetected('nsec1scannedvalue'); + scanBox.onQrCodeDetected('nsec1scannedvalue'); await tester.pumpAndSettle(); expect(find.byType(LoginScreen), findsOneWidget); From 838992f2e8da5ff4689a762c10165d28eca6f3bc Mon Sep 17 00:00:00 2001 From: josefinalliende Date: Tue, 19 May 2026 23:59:00 -0400 Subject: [PATCH 4/4] docs: update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e970adba..23344d8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ and this project adheres to Calendar Versioning (CalVer). - Fix deleting all app data reusing a closed Whitenoise database instance [PR #662](https://github.com/marmot-protocol/whitenoise/pull/662) - Fixed QR scanner not showing camera on first attempt after giving permission [PR #654](https://github.com/marmot-protocol/whitenoise/pull/654) - Fix show author name in last message of pending invites in chat list [PR #668](https://github.com/marmot-protocol/whitenoise/pull/668) +- Fix double camera permission request on OS back [PR #655](https://github.com/marmot-protocol/whitenoise/pull/655) ### Security