diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 03b2c845..e06a3133 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,6 +7,9 @@ + @@ -15,6 +18,7 @@ + Permission.camera.request()); + +class QRCodeScannerScreen extends StatefulHookConsumerWidget { const QRCodeScannerScreen({super.key}); Future open(BuildContext context) async { @@ -20,110 +23,194 @@ class QRCodeScannerScreen extends HookConsumerWidget with PresLogger { } @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => + _QRCodeScannerScreenState(); +} + +class _QRCodeScannerScreenState extends ConsumerState + with WidgetsBindingObserver, PresLogger { + final controller = + MobileScannerController(detectionTimeoutMs: 500, autoStart: false); + bool started = false; + bool settingsOpened = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + controller.stop(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed && settingsOpened) { + loggy.debug("resumed"); + ref.invalidate(_cameraPermissionProvider); + settingsOpened = false; + } + } + + @override + Widget build(BuildContext context) { final t = ref.watch(translationsProvider); - final controller = useMemoized( - () => MobileScannerController(detectionTimeoutMs: 500), + ref.listen( + _cameraPermissionProvider, + (previous, next) async { + if (next case AsyncData(:final value) + when value == PermissionStatus.granted) { + try { + final result = await controller.start(); + if (result != null) { + setState(() { + started = true; + }); + } + } catch (error) { + loggy.warning("error starting scanner: $error", error); + } + } + }, ); - useEffect(() => controller.dispose, []); + switch (ref.watch(_cameraPermissionProvider)) { + case AsyncData(value: final status) + when status == PermissionStatus.granted: + final size = MediaQuery.sizeOf(context); + final overlaySize = (size.shortestSide - 12).coerceAtMost(248); - final size = MediaQuery.sizeOf(context); - final overlaySize = (size.shortestSide - 12).coerceAtMost(248); - - return Scaffold( - extendBodyBehindAppBar: true, - appBar: AppBar( - backgroundColor: Colors.transparent, - iconTheme: Theme.of(context).iconTheme.copyWith( - color: Colors.white, - size: 32, - ), - actions: [ - IconButton( - icon: ValueListenableBuilder( - valueListenable: controller.torchState, - builder: (context, state, child) { - switch (state) { - case TorchState.off: - return const Icon( - FluentIcons.flash_off_24_regular, - color: Colors.grey, - ); - case TorchState.on: - return const Icon( - FluentIcons.flash_24_regular, - color: Colors.yellow, - ); - } - }, - ), - tooltip: t.profile.add.qrScanner.torchSemanticLabel, - onPressed: () => controller.toggleTorch(), - ), - IconButton( - icon: const Icon(FluentIcons.camera_switch_24_regular), - tooltip: t.profile.add.qrScanner.facingSemanticLabel, - onPressed: () => controller.switchCamera(), - ), - ], - ), - body: Stack( - children: [ - MobileScanner( - controller: controller, - onDetect: (capture) { - final rawData = capture.barcodes.first.rawValue; - loggy.debug('captured raw: [$rawData]'); - if (rawData != null) { - final uri = Uri.tryParse(rawData); - if (context.mounted && uri != null) { - loggy.debug('captured url: [$uri]'); - Navigator.of(context, rootNavigator: true) - .pop(uri.toString()); - } - } else { - loggy.warning("unable to capture"); - } - }, - errorBuilder: (_, error, __) { - final message = switch (error.errorCode) { - MobileScannerErrorCode.permissionDenied => - t.profile.add.qrScanner.permissionDeniedError, - _ => t.profile.add.qrScanner.unexpectedError, - }; - - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Padding( - padding: EdgeInsets.only(bottom: 8), - child: Icon( - FluentIcons.error_circle_24_regular, - color: Colors.white, - ), - ), - Text(message), - Text(error.errorDetails?.message ?? ''), - ], + return Scaffold( + extendBodyBehindAppBar: true, + appBar: AppBar( + backgroundColor: Colors.transparent, + iconTheme: Theme.of(context).iconTheme.copyWith( + color: Colors.white, + size: 32, ), - ); - }, - ), - CustomPaint( - painter: ScannerOverlay( - Rect.fromCenter( - center: size.center(Offset.zero), - width: overlaySize, - height: overlaySize, + actions: [ + IconButton( + icon: ValueListenableBuilder( + valueListenable: controller.torchState, + builder: (context, state, child) { + switch (state) { + case TorchState.off: + return const Icon( + FluentIcons.flash_off_24_regular, + color: Colors.grey, + ); + case TorchState.on: + return const Icon( + FluentIcons.flash_24_regular, + color: Colors.yellow, + ); + } + }, + ), + tooltip: t.profile.add.qrScanner.torchSemanticLabel, + onPressed: () => controller.toggleTorch(), ), + IconButton( + icon: const Icon(FluentIcons.camera_switch_24_regular), + tooltip: t.profile.add.qrScanner.facingSemanticLabel, + onPressed: () => controller.switchCamera(), + ), + ], + ), + body: Stack( + children: [ + MobileScanner( + controller: controller, + onDetect: (capture) { + final rawData = capture.barcodes.first.rawValue; + loggy.debug('captured raw: [$rawData]'); + if (rawData != null) { + final uri = Uri.tryParse(rawData); + if (context.mounted && uri != null) { + loggy.debug('captured url: [$uri]'); + Navigator.of(context, rootNavigator: true) + .pop(uri.toString()); + } + } else { + loggy.warning("unable to capture"); + } + }, + errorBuilder: (_, error, __) { + final message = switch (error.errorCode) { + MobileScannerErrorCode.permissionDenied => + t.profile.add.qrScanner.permissionDeniedError, + _ => t.profile.add.qrScanner.unexpectedError, + }; + + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: Icon( + FluentIcons.error_circle_24_regular, + color: Colors.white, + ), + ), + Text(message), + Text(error.errorDetails?.message ?? ''), + ], + ), + ); + }, + ), + if (started) + CustomPaint( + painter: ScannerOverlay( + Rect.fromCenter( + center: size.center(Offset.zero), + width: overlaySize, + height: overlaySize, + ), + ), + ), + ], + ), + ); + + case AsyncData(value: final status): + return Scaffold( + appBar: AppBar(), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(t.profile.add.qrScanner.permissionDeniedError), + if (status == PermissionStatus.permanentlyDenied) + TextButton( + onPressed: () async { + settingsOpened = await openAppSettings(); + }, + child: Text(t.general.openAppSettings), + ) + else + TextButton( + onPressed: () { + ref.invalidate(_cameraPermissionProvider); + }, + child: Text(t.general.grantPermission), + ), + ], ), ), - ], - ), - ); + ); + + default: + return Scaffold( + appBar: AppBar(), + ); + } } } diff --git a/pubspec.lock b/pubspec.lock index 706d4bb8..ff1ae15f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1102,6 +1102,54 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.3" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "74e962b7fad7ff75959161bb2c0ad8fe7f2568ee82621c9c2660b751146bfe44" + url: "https://pub.dev" + source: hosted + version: "11.3.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1acac6bae58144b442f11e66621c062aead9c99841093c38f5bcdcc24c1c3474" + url: "https://pub.dev" + source: hosted + version: "12.0.5" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: bdafc6db74253abb63907f4e357302e6bb786ab41465e8635f362ee71fd8707b + url: "https://pub.dev" + source: hosted + version: "9.4.0" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "54bf176b90f6eddd4ece307e2c06cf977fb3973719c35a93b85cc7093eb6070d" + url: "https://pub.dev" + source: hosted + version: "0.1.1" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: "23dfba8447c076ab5be3dee9ceb66aad345c4a648f0cac292c77b1eb0e800b78" + url: "https://pub.dev" + source: hosted + version: "4.2.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" petitparser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index efccc5e6..ca3fa051 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -81,6 +81,7 @@ dependencies: http: ^1.2.0 timezone_to_country: ^2.1.0 json_path: ^0.7.1 + permission_handler: ^11.3.0 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index c9dae969..47f071f8 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -17,6 +18,8 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); ProtocolHandlerWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("ProtocolHandlerWindowsPluginCApi")); ScreenRetrieverPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 9642d1c9..58127d66 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + permission_handler_windows protocol_handler_windows screen_retriever sentry_flutter