Committed by
GitHub
Merge pull request #1005 from navaronbracke/5_0_0-beta-3
feat: 5.0.0-beta 3
Showing
22 changed files
with
296 additions
and
351 deletions
| 1 | -## 5.0.0-beta.2 | 1 | +## 5.0.0-beta.3 |
| 2 | 2 | ||
| 3 | **BREAKING CHANGES:** | 3 | **BREAKING CHANGES:** |
| 4 | 4 | ||
| 5 | -* Flutter 3.16.0 is now required. | 5 | +* Flutter 3.19.0 is now required. |
| 6 | +* [iOS] iOS 12.0 is now the minimum supported iOS version. | ||
| 7 | +* [iOS] Adds a Privacy Manifest. | ||
| 8 | + | ||
| 9 | +Bugs fixed: | ||
| 10 | +* Fixed an issue where the camera preview and barcode scanner did not work the second time on web. | ||
| 11 | + | ||
| 12 | +Improvements: | ||
| 13 | +* [web] Migrates to extension types. (thanks @koji-1009 !) | ||
| 14 | + | ||
| 15 | +## 5.0.0-beta.2 | ||
| 6 | 16 | ||
| 7 | Bugs fixed: | 17 | Bugs fixed: |
| 8 | * Fixed an issue where the scan window was not updated when its size was changed. (thanks @navaronbracke !) | 18 | * Fixed an issue where the scan window was not updated when its size was changed. (thanks @navaronbracke !) |
| 1 | -buildscript { | ||
| 2 | - ext.kotlin_version = '1.9.22' | ||
| 3 | - repositories { | ||
| 4 | - google() | ||
| 5 | - mavenCentral() | ||
| 6 | - } | ||
| 7 | - | ||
| 8 | - dependencies { | ||
| 9 | - classpath 'com.android.tools.build:gradle:8.2.2' | ||
| 10 | - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" | ||
| 11 | - } | ||
| 12 | -} | ||
| 13 | - | ||
| 14 | allprojects { | 1 | allprojects { |
| 15 | repositories { | 2 | repositories { |
| 16 | google() | 3 | google() |
| @@ -5,12 +5,21 @@ pluginManagement { | @@ -5,12 +5,21 @@ pluginManagement { | ||
| 5 | def flutterSdkPath = properties.getProperty("flutter.sdk") | 5 | def flutterSdkPath = properties.getProperty("flutter.sdk") |
| 6 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" | 6 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" |
| 7 | return flutterSdkPath | 7 | return flutterSdkPath |
| 8 | - } | ||
| 9 | - settings.ext.flutterSdkPath = flutterSdkPath() | 8 | + }() |
| 9 | + | ||
| 10 | + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") | ||
| 10 | 11 | ||
| 11 | - includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle") | 12 | + repositories { |
| 13 | + google() | ||
| 14 | + mavenCentral() | ||
| 15 | + gradlePluginPortal() | ||
| 16 | + } | ||
| 12 | } | 17 | } |
| 13 | 18 | ||
| 14 | -include ":app" | 19 | +plugins { |
| 20 | + id "dev.flutter.flutter-plugin-loader" version "1.0.0" | ||
| 21 | + id "com.android.application" version "8.2.2" apply false | ||
| 22 | + id "org.jetbrains.kotlin.android" version "1.9.22" apply false | ||
| 23 | +} | ||
| 15 | 24 | ||
| 16 | -apply from: "${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle/app_plugin_loader.gradle" | 25 | +include ":app" |
| @@ -21,6 +21,6 @@ | @@ -21,6 +21,6 @@ | ||
| 21 | <key>CFBundleVersion</key> | 21 | <key>CFBundleVersion</key> |
| 22 | <string>1.0</string> | 22 | <string>1.0</string> |
| 23 | <key>MinimumOSVersion</key> | 23 | <key>MinimumOSVersion</key> |
| 24 | - <string>11.0</string> | 24 | + <string>12.0</string> |
| 25 | </dict> | 25 | </dict> |
| 26 | </plist> | 26 | </plist> |
| 1 | # Uncomment this line to define a global platform for your project | 1 | # Uncomment this line to define a global platform for your project |
| 2 | -# platform :ios, '11.0' | 2 | +# platform :ios, '12.0' |
| 3 | 3 | ||
| 4 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. | 4 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. |
| 5 | ENV['COCOAPODS_DISABLE_STATS'] = 'true' | 5 | ENV['COCOAPODS_DISABLE_STATS'] = 'true' |
| @@ -41,7 +41,7 @@ post_install do |installer| | @@ -41,7 +41,7 @@ post_install do |installer| | ||
| 41 | installer.pods_project.targets.each do |target| | 41 | installer.pods_project.targets.each do |target| |
| 42 | flutter_additional_ios_build_settings(target) | 42 | flutter_additional_ios_build_settings(target) |
| 43 | target.build_configurations.each do |config| | 43 | target.build_configurations.each do |config| |
| 44 | - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '11.0' | 44 | + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.0' |
| 45 | end | 45 | end |
| 46 | end | 46 | end |
| 47 | end | 47 | end |
| @@ -452,7 +452,7 @@ | @@ -452,7 +452,7 @@ | ||
| 452 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; | 452 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; |
| 453 | GCC_WARN_UNUSED_FUNCTION = YES; | 453 | GCC_WARN_UNUSED_FUNCTION = YES; |
| 454 | GCC_WARN_UNUSED_VARIABLE = YES; | 454 | GCC_WARN_UNUSED_VARIABLE = YES; |
| 455 | - IPHONEOS_DEPLOYMENT_TARGET = 11.0; | 455 | + IPHONEOS_DEPLOYMENT_TARGET = 12.0; |
| 456 | MTL_ENABLE_DEBUG_INFO = NO; | 456 | MTL_ENABLE_DEBUG_INFO = NO; |
| 457 | SDKROOT = iphoneos; | 457 | SDKROOT = iphoneos; |
| 458 | SUPPORTED_PLATFORMS = iphoneos; | 458 | SUPPORTED_PLATFORMS = iphoneos; |
| @@ -583,7 +583,7 @@ | @@ -583,7 +583,7 @@ | ||
| 583 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; | 583 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; |
| 584 | GCC_WARN_UNUSED_FUNCTION = YES; | 584 | GCC_WARN_UNUSED_FUNCTION = YES; |
| 585 | GCC_WARN_UNUSED_VARIABLE = YES; | 585 | GCC_WARN_UNUSED_VARIABLE = YES; |
| 586 | - IPHONEOS_DEPLOYMENT_TARGET = 11.0; | 586 | + IPHONEOS_DEPLOYMENT_TARGET = 12.0; |
| 587 | MTL_ENABLE_DEBUG_INFO = YES; | 587 | MTL_ENABLE_DEBUG_INFO = YES; |
| 588 | ONLY_ACTIVE_ARCH = YES; | 588 | ONLY_ACTIVE_ARCH = YES; |
| 589 | SDKROOT = iphoneos; | 589 | SDKROOT = iphoneos; |
| @@ -632,7 +632,7 @@ | @@ -632,7 +632,7 @@ | ||
| 632 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; | 632 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; |
| 633 | GCC_WARN_UNUSED_FUNCTION = YES; | 633 | GCC_WARN_UNUSED_FUNCTION = YES; |
| 634 | GCC_WARN_UNUSED_VARIABLE = YES; | 634 | GCC_WARN_UNUSED_VARIABLE = YES; |
| 635 | - IPHONEOS_DEPLOYMENT_TARGET = 11.0; | 635 | + IPHONEOS_DEPLOYMENT_TARGET = 12.0; |
| 636 | MTL_ENABLE_DEBUG_INFO = NO; | 636 | MTL_ENABLE_DEBUG_INFO = NO; |
| 637 | SDKROOT = iphoneos; | 637 | SDKROOT = iphoneos; |
| 638 | SUPPORTED_PLATFORMS = iphoneos; | 638 | SUPPORTED_PLATFORMS = iphoneos; |
| @@ -6,8 +6,8 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev | @@ -6,8 +6,8 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev | ||
| 6 | version: 0.0.1 | 6 | version: 0.0.1 |
| 7 | 7 | ||
| 8 | environment: | 8 | environment: |
| 9 | - sdk: ">=3.2.0 <4.0.0" | ||
| 10 | - flutter: ">=3.16.0" | 9 | + sdk: ">=3.3.0 <4.0.0" |
| 10 | + flutter: ">=3.19.0" | ||
| 11 | 11 | ||
| 12 | # Dependencies specify other packages that your package needs in order to work. | 12 | # Dependencies specify other packages that your package needs in order to work. |
| 13 | # To automatically upgrade your package dependencies to the latest versions | 13 | # To automatically upgrade your package dependencies to the latest versions |
ios/Resources/PrivacyInfo.xcprivacy
0 → 100644
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | ||
| 2 | +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||
| 3 | +<plist version="1.0"> | ||
| 4 | +<dict> | ||
| 5 | + <key>NSPrivacyAccessedAPITypes</key> | ||
| 6 | + <array/> | ||
| 7 | + <key>NSPrivacyCollectedDataTypes</key> | ||
| 8 | + <array/> | ||
| 9 | + <key>NSPrivacyTrackingDomains</key> | ||
| 10 | + <array/> | ||
| 11 | + <key>NSPrivacyTracking</key> | ||
| 12 | + <false/> | ||
| 13 | +</dict> | ||
| 14 | +</plist> |
| @@ -4,21 +4,22 @@ | @@ -4,21 +4,22 @@ | ||
| 4 | # | 4 | # |
| 5 | Pod::Spec.new do |s| | 5 | Pod::Spec.new do |s| |
| 6 | s.name = 'mobile_scanner' | 6 | s.name = 'mobile_scanner' |
| 7 | - s.version = '3.5.6' | 7 | + s.version = '5.0.0' |
| 8 | s.summary = 'An universal scanner for Flutter based on MLKit.' | 8 | s.summary = 'An universal scanner for Flutter based on MLKit.' |
| 9 | s.description = <<-DESC | 9 | s.description = <<-DESC |
| 10 | An universal scanner for Flutter based on MLKit. | 10 | An universal scanner for Flutter based on MLKit. |
| 11 | DESC | 11 | DESC |
| 12 | - s.homepage = 'http://example.com' | 12 | + s.homepage = 'https://github.com/juliansteenbakker/mobile_scanner' |
| 13 | s.license = { :file => '../LICENSE' } | 13 | s.license = { :file => '../LICENSE' } |
| 14 | - s.author = { 'Your Company' => 'email@example.com' } | 14 | + s.author = { 'Julian Steenbakker' => 'juliansteenbakker@outlook.com' } |
| 15 | s.source = { :path => '.' } | 15 | s.source = { :path => '.' } |
| 16 | s.source_files = 'Classes/**/*' | 16 | s.source_files = 'Classes/**/*' |
| 17 | s.dependency 'Flutter' | 17 | s.dependency 'Flutter' |
| 18 | - s.dependency 'GoogleMLKit/BarcodeScanning', '~> 4.0.0' | ||
| 19 | - s.platform = :ios, '11.0' | 18 | + s.dependency 'GoogleMLKit/BarcodeScanning', '~> 5.0.0' |
| 19 | + s.platform = :ios, '12.0' | ||
| 20 | s.static_framework = true | 20 | s.static_framework = true |
| 21 | # Flutter.framework does not contain a i386 slice. | 21 | # Flutter.framework does not contain a i386 slice. |
| 22 | s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } | 22 | s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } |
| 23 | s.swift_version = '5.0' | 23 | s.swift_version = '5.0' |
| 24 | + s.resource_bundles = { 'mobile_scanner_privacy' => ['Resources/PrivacyInfo.xcprivacy'] } | ||
| 24 | end | 25 | end |
| @@ -94,12 +94,29 @@ class MethodChannelMobileScanner extends MobileScannerPlatform { | @@ -94,12 +94,29 @@ class MethodChannelMobileScanner extends MobileScannerPlatform { | ||
| 94 | /// | 94 | /// |
| 95 | /// Throws a [MobileScannerException] if the permission is not granted. | 95 | /// Throws a [MobileScannerException] if the permission is not granted. |
| 96 | Future<void> _requestCameraPermission() async { | 96 | Future<void> _requestCameraPermission() async { |
| 97 | - final MobileScannerAuthorizationState authorizationState; | ||
| 98 | - | ||
| 99 | try { | 97 | try { |
| 100 | - authorizationState = MobileScannerAuthorizationState.fromRawValue( | 98 | + final MobileScannerAuthorizationState authorizationState = |
| 99 | + MobileScannerAuthorizationState.fromRawValue( | ||
| 101 | await methodChannel.invokeMethod<int>('state') ?? 0, | 100 | await methodChannel.invokeMethod<int>('state') ?? 0, |
| 102 | ); | 101 | ); |
| 102 | + | ||
| 103 | + switch (authorizationState) { | ||
| 104 | + // Authorization was already granted, no need to request it again. | ||
| 105 | + case MobileScannerAuthorizationState.authorized: | ||
| 106 | + return; | ||
| 107 | + // Android does not have an undetermined authorization state. | ||
| 108 | + // So if the permission was denied, request it again. | ||
| 109 | + case MobileScannerAuthorizationState.denied: | ||
| 110 | + case MobileScannerAuthorizationState.undetermined: | ||
| 111 | + final bool permissionGranted = | ||
| 112 | + await methodChannel.invokeMethod<bool>('request') ?? false; | ||
| 113 | + | ||
| 114 | + if (!permissionGranted) { | ||
| 115 | + throw const MobileScannerException( | ||
| 116 | + errorCode: MobileScannerErrorCode.permissionDenied, | ||
| 117 | + ); | ||
| 118 | + } | ||
| 119 | + } | ||
| 103 | } on PlatformException catch (error) { | 120 | } on PlatformException catch (error) { |
| 104 | // If the permission state is invalid, that is an error. | 121 | // If the permission state is invalid, that is an error. |
| 105 | throw MobileScannerException( | 122 | throw MobileScannerException( |
| @@ -111,37 +128,6 @@ class MethodChannelMobileScanner extends MobileScannerPlatform { | @@ -111,37 +128,6 @@ class MethodChannelMobileScanner extends MobileScannerPlatform { | ||
| 111 | ), | 128 | ), |
| 112 | ); | 129 | ); |
| 113 | } | 130 | } |
| 114 | - | ||
| 115 | - switch (authorizationState) { | ||
| 116 | - case MobileScannerAuthorizationState.authorized: | ||
| 117 | - return; // Already authorized. | ||
| 118 | - // Android does not have an undetermined authorization state. | ||
| 119 | - // So if the permission was denied, request it again. | ||
| 120 | - case MobileScannerAuthorizationState.denied: | ||
| 121 | - case MobileScannerAuthorizationState.undetermined: | ||
| 122 | - try { | ||
| 123 | - final bool granted = | ||
| 124 | - await methodChannel.invokeMethod<bool>('request') ?? false; | ||
| 125 | - | ||
| 126 | - if (granted) { | ||
| 127 | - return; // Authorization was granted. | ||
| 128 | - } | ||
| 129 | - | ||
| 130 | - throw const MobileScannerException( | ||
| 131 | - errorCode: MobileScannerErrorCode.permissionDenied, | ||
| 132 | - ); | ||
| 133 | - } on PlatformException catch (error) { | ||
| 134 | - // If the permission state is invalid, that is an error. | ||
| 135 | - throw MobileScannerException( | ||
| 136 | - errorCode: MobileScannerErrorCode.genericError, | ||
| 137 | - errorDetails: MobileScannerErrorDetails( | ||
| 138 | - code: error.code, | ||
| 139 | - details: error.details as Object?, | ||
| 140 | - message: error.message, | ||
| 141 | - ), | ||
| 142 | - ); | ||
| 143 | - } | ||
| 144 | - } | ||
| 145 | } | 131 | } |
| 146 | 132 | ||
| 147 | @override | 133 | @override |
| @@ -223,12 +223,14 @@ class MobileScannerController extends ValueNotifier<MobileScannerState> { | @@ -223,12 +223,14 @@ class MobileScannerController extends ValueNotifier<MobileScannerState> { | ||
| 223 | } | 223 | } |
| 224 | 224 | ||
| 225 | /// Start scanning for barcodes. | 225 | /// Start scanning for barcodes. |
| 226 | - /// Upon calling this method, the necessary camera permission will be requested. | ||
| 227 | /// | 226 | /// |
| 228 | /// The [cameraDirection] can be used to specify the camera direction. | 227 | /// The [cameraDirection] can be used to specify the camera direction. |
| 229 | /// If this is null, this defaults to the [facing] value. | 228 | /// If this is null, this defaults to the [facing] value. |
| 230 | /// | 229 | /// |
| 231 | /// Does nothing if the camera is already running. | 230 | /// Does nothing if the camera is already running. |
| 231 | + /// Upon calling this method, the necessary camera permission will be requested. | ||
| 232 | + /// | ||
| 233 | + /// If the permission is denied on iOS, MacOS or Web, there is no way to request it again. | ||
| 232 | Future<void> start({CameraFacing? cameraDirection}) async { | 234 | Future<void> start({CameraFacing? cameraDirection}) async { |
| 233 | if (_isDisposed) { | 235 | if (_isDisposed) { |
| 234 | throw const MobileScannerException( | 236 | throw const MobileScannerException( |
| @@ -240,6 +242,13 @@ class MobileScannerController extends ValueNotifier<MobileScannerState> { | @@ -240,6 +242,13 @@ class MobileScannerController extends ValueNotifier<MobileScannerState> { | ||
| 240 | ); | 242 | ); |
| 241 | } | 243 | } |
| 242 | 244 | ||
| 245 | + // Permission was denied, do nothing. | ||
| 246 | + // When the controller is stopped, | ||
| 247 | + // the error is reset so the permission can be requested again if possible. | ||
| 248 | + if (value.error?.errorCode == MobileScannerErrorCode.permissionDenied) { | ||
| 249 | + return; | ||
| 250 | + } | ||
| 251 | + | ||
| 243 | // Do nothing if the camera is already running. | 252 | // Do nothing if the camera is already running. |
| 244 | if (value.isRunning) { | 253 | if (value.isRunning) { |
| 245 | return; | 254 | return; |
| @@ -62,20 +62,19 @@ abstract class BarcodeReader { | @@ -62,20 +62,19 @@ abstract class BarcodeReader { | ||
| 62 | 62 | ||
| 63 | final Completer<void> completer = Completer(); | 63 | final Completer<void> completer = Completer(); |
| 64 | 64 | ||
| 65 | - final HTMLScriptElement script = | ||
| 66 | - (document.createElement('script') as HTMLScriptElement) | ||
| 67 | - ..id = scriptId | ||
| 68 | - ..async = true | ||
| 69 | - ..defer = false | ||
| 70 | - ..type = 'application/javascript' | ||
| 71 | - ..lang = 'javascript' | ||
| 72 | - ..crossOrigin = 'anonymous' | ||
| 73 | - ..src = alternateScriptUrl ?? scriptUrl | ||
| 74 | - ..onload = (JSAny _) { | ||
| 75 | - if (!completer.isCompleted) { | ||
| 76 | - completer.complete(); | ||
| 77 | - } | ||
| 78 | - }.toJS; | 65 | + final HTMLScriptElement script = HTMLScriptElement() |
| 66 | + ..id = scriptId | ||
| 67 | + ..async = true | ||
| 68 | + ..defer = false | ||
| 69 | + ..type = 'application/javascript' | ||
| 70 | + ..lang = 'javascript' | ||
| 71 | + ..crossOrigin = 'anonymous' | ||
| 72 | + ..src = alternateScriptUrl ?? scriptUrl | ||
| 73 | + ..onload = (JSAny _) { | ||
| 74 | + if (!completer.isCompleted) { | ||
| 75 | + completer.complete(); | ||
| 76 | + } | ||
| 77 | + }.toJS; | ||
| 79 | 78 | ||
| 80 | script.onerror = (JSAny _) { | 79 | script.onerror = (JSAny _) { |
| 81 | if (!completer.isCompleted) { | 80 | if (!completer.isCompleted) { |
| @@ -9,12 +9,10 @@ import 'dart:js_interop'; | @@ -9,12 +9,10 @@ import 'dart:js_interop'; | ||
| 9 | /// | 9 | /// |
| 10 | /// Object literals can be made using [jsify]. | 10 | /// Object literals can be made using [jsify]. |
| 11 | @JS('Map') | 11 | @JS('Map') |
| 12 | -@staticInterop | ||
| 13 | -class JSMap<K extends JSAny, V extends JSAny> { | 12 | +extension type JSMap<K extends JSAny, V extends JSAny>._(JSObject _) |
| 13 | + implements JSObject { | ||
| 14 | external factory JSMap(); | 14 | external factory JSMap(); |
| 15 | -} | ||
| 16 | 15 | ||
| 17 | -extension JSMapExtension<K extends JSAny, V extends JSAny> on JSMap<K, V> { | ||
| 18 | external V? get(K key); | 16 | external V? get(K key); |
| 19 | external JSVoid set(K key, V? value); | 17 | external JSVoid set(K key, V? value); |
| 20 | } | 18 | } |
| @@ -9,25 +9,28 @@ final class MediaTrackConstraintsDelegate { | @@ -9,25 +9,28 @@ final class MediaTrackConstraintsDelegate { | ||
| 9 | 9 | ||
| 10 | /// Get the settings for the given [mediaStream]. | 10 | /// Get the settings for the given [mediaStream]. |
| 11 | MediaTrackSettings? getSettings(MediaStream? mediaStream) { | 11 | MediaTrackSettings? getSettings(MediaStream? mediaStream) { |
| 12 | - final List<JSAny?>? tracks = mediaStream?.getVideoTracks().toDart; | 12 | + final List<MediaStreamTrack>? tracks = mediaStream?.getVideoTracks().toDart; |
| 13 | 13 | ||
| 14 | if (tracks == null || tracks.isEmpty) { | 14 | if (tracks == null || tracks.isEmpty) { |
| 15 | return null; | 15 | return null; |
| 16 | } | 16 | } |
| 17 | 17 | ||
| 18 | - final MediaStreamTrack? track = tracks.first as MediaStreamTrack?; | ||
| 19 | - | ||
| 20 | - if (track == null) { | ||
| 21 | - return null; | ||
| 22 | - } | 18 | + final MediaStreamTrack track = tracks.first; |
| 23 | 19 | ||
| 20 | + final MediaTrackCapabilities capabilities = track.getCapabilities(); | ||
| 24 | final MediaTrackSettings settings = track.getSettings(); | 21 | final MediaTrackSettings settings = track.getSettings(); |
| 25 | 22 | ||
| 23 | + if (capabilities.facingMode.toDart.isEmpty) { | ||
| 24 | + return MediaTrackSettings( | ||
| 25 | + width: settings.width, | ||
| 26 | + height: settings.height, | ||
| 27 | + ); | ||
| 28 | + } | ||
| 29 | + | ||
| 26 | return MediaTrackSettings( | 30 | return MediaTrackSettings( |
| 27 | width: settings.width, | 31 | width: settings.width, |
| 28 | height: settings.height, | 32 | height: settings.height, |
| 29 | facingMode: settings.facingMode, | 33 | facingMode: settings.facingMode, |
| 30 | - aspectRatio: settings.aspectRatio, | ||
| 31 | ); | 34 | ); |
| 32 | } | 35 | } |
| 33 | } | 36 | } |
| @@ -25,7 +25,7 @@ class MobileScannerWeb extends MobileScannerPlatform { | @@ -25,7 +25,7 @@ class MobileScannerWeb extends MobileScannerPlatform { | ||
| 25 | String? _alternateScriptUrl; | 25 | String? _alternateScriptUrl; |
| 26 | 26 | ||
| 27 | /// The internal barcode reader. | 27 | /// The internal barcode reader. |
| 28 | - final BarcodeReader _barcodeReader = ZXingBarcodeReader(); | 28 | + BarcodeReader? _barcodeReader; |
| 29 | 29 | ||
| 30 | /// The stream controller for the barcode stream. | 30 | /// The stream controller for the barcode stream. |
| 31 | final StreamController<BarcodeCapture> _barcodesController = | 31 | final StreamController<BarcodeCapture> _barcodesController = |
| @@ -35,21 +35,13 @@ class MobileScannerWeb extends MobileScannerPlatform { | @@ -35,21 +35,13 @@ class MobileScannerWeb extends MobileScannerPlatform { | ||
| 35 | StreamSubscription<Object?>? _barcodesSubscription; | 35 | StreamSubscription<Object?>? _barcodesSubscription; |
| 36 | 36 | ||
| 37 | /// The container div element for the camera view. | 37 | /// The container div element for the camera view. |
| 38 | - /// | ||
| 39 | - /// This container element is used by the barcode reader. | ||
| 40 | - HTMLDivElement? _divElement; | 38 | + late HTMLDivElement _divElement; |
| 41 | 39 | ||
| 42 | - /// This [Completer] is used to prevent additional calls to the [start] method. | ||
| 43 | - /// | ||
| 44 | - /// To handle lifecycle changes properly, | ||
| 45 | - /// the scanner is stopped when the application is inactive, | ||
| 46 | - /// and restarted when the application gains focus. | 40 | + /// The flag that keeps track of whether a permission request is in progress. |
| 47 | /// | 41 | /// |
| 48 | - /// However, when the camera permission is requested, | ||
| 49 | - /// the application is put in the inactive state due to the permission popup gaining focus. | ||
| 50 | - /// Thus, as long as the permission status is not known, | ||
| 51 | - /// any calls to the [start] method are ignored. | ||
| 52 | - Completer<void>? _cameraPermissionCompleter; | 42 | + /// On the web, a permission request triggers a dialog, that in turn triggers a lifecycle change. |
| 43 | + /// While the permission request is in progress, any attempts at (re)starting the camera should be ignored. | ||
| 44 | + bool _permissionRequestInProgress = false; | ||
| 53 | 45 | ||
| 54 | /// The stream controller for the media track settings stream. | 46 | /// The stream controller for the media track settings stream. |
| 55 | /// | 47 | /// |
| @@ -60,18 +52,19 @@ class MobileScannerWeb extends MobileScannerPlatform { | @@ -60,18 +52,19 @@ class MobileScannerWeb extends MobileScannerPlatform { | ||
| 60 | final StreamController<MediaTrackSettings> _settingsController = | 52 | final StreamController<MediaTrackSettings> _settingsController = |
| 61 | StreamController.broadcast(); | 53 | StreamController.broadcast(); |
| 62 | 54 | ||
| 63 | - /// The view type for the platform view factory. | ||
| 64 | - static const String _viewType = 'MobileScannerWeb'; | 55 | + /// The texture ID for the camera view. |
| 56 | + int _textureId = 1; | ||
| 57 | + | ||
| 58 | + /// The video element for the camera view. | ||
| 59 | + late HTMLVideoElement _videoElement; | ||
| 60 | + | ||
| 61 | + /// Get the view type for the platform view factory. | ||
| 62 | + String _getViewType(int textureId) => 'mobile-scanner-view-$textureId'; | ||
| 65 | 63 | ||
| 66 | static void registerWith(Registrar registrar) { | 64 | static void registerWith(Registrar registrar) { |
| 67 | MobileScannerPlatform.instance = MobileScannerWeb(); | 65 | MobileScannerPlatform.instance = MobileScannerWeb(); |
| 68 | } | 66 | } |
| 69 | 67 | ||
| 70 | - bool get _hasPendingPermissionRequest { | ||
| 71 | - return _cameraPermissionCompleter != null && | ||
| 72 | - !_cameraPermissionCompleter!.isCompleted; | ||
| 73 | - } | ||
| 74 | - | ||
| 75 | @override | 68 | @override |
| 76 | Stream<BarcodeCapture?> get barcodesStream => _barcodesController.stream; | 69 | Stream<BarcodeCapture?> get barcodesStream => _barcodesController.stream; |
| 77 | 70 | ||
| @@ -83,6 +76,33 @@ class MobileScannerWeb extends MobileScannerPlatform { | @@ -83,6 +76,33 @@ class MobileScannerWeb extends MobileScannerPlatform { | ||
| 83 | Stream<double> get zoomScaleStateStream => | 76 | Stream<double> get zoomScaleStateStream => |
| 84 | _settingsController.stream.map((_) => 1.0); | 77 | _settingsController.stream.map((_) => 1.0); |
| 85 | 78 | ||
| 79 | + /// Create the [HTMLVideoElement] along with its parent container [HTMLDivElement]. | ||
| 80 | + HTMLVideoElement _createVideoElement(int textureId) { | ||
| 81 | + final HTMLVideoElement videoElement = HTMLVideoElement(); | ||
| 82 | + | ||
| 83 | + videoElement.style | ||
| 84 | + ..height = '100%' | ||
| 85 | + ..width = '100%' | ||
| 86 | + ..objectFit = 'cover' | ||
| 87 | + ..transformOrigin = 'center' | ||
| 88 | + ..pointerEvents = 'none'; | ||
| 89 | + | ||
| 90 | + // Attach the video element to its parent container | ||
| 91 | + // and setup the PlatformView factory for this `textureId`. | ||
| 92 | + _divElement = HTMLDivElement() | ||
| 93 | + ..style.objectFit = 'cover' | ||
| 94 | + ..style.height = '100%' | ||
| 95 | + ..style.width = '100%' | ||
| 96 | + ..append(videoElement); | ||
| 97 | + | ||
| 98 | + ui_web.platformViewRegistry.registerViewFactory( | ||
| 99 | + _getViewType(textureId), | ||
| 100 | + (_) => _divElement, | ||
| 101 | + ); | ||
| 102 | + | ||
| 103 | + return videoElement; | ||
| 104 | + } | ||
| 105 | + | ||
| 86 | void _handleMediaTrackSettingsChange(MediaTrackSettings settings) { | 106 | void _handleMediaTrackSettingsChange(MediaTrackSettings settings) { |
| 87 | if (_settingsController.isClosed) { | 107 | if (_settingsController.isClosed) { |
| 88 | return; | 108 | return; |
| @@ -91,6 +111,32 @@ class MobileScannerWeb extends MobileScannerPlatform { | @@ -91,6 +111,32 @@ class MobileScannerWeb extends MobileScannerPlatform { | ||
| 91 | _settingsController.add(settings); | 111 | _settingsController.add(settings); |
| 92 | } | 112 | } |
| 93 | 113 | ||
| 114 | + /// Flip the [videoElement] horizontally, | ||
| 115 | + /// if the [videoStream] indicates that is facing the user. | ||
| 116 | + void _maybeFlipVideoPreview( | ||
| 117 | + HTMLVideoElement videoElement, | ||
| 118 | + MediaStream videoStream, | ||
| 119 | + ) { | ||
| 120 | + final List<MediaStreamTrack> tracks = videoStream.getVideoTracks().toDart; | ||
| 121 | + | ||
| 122 | + if (tracks.isEmpty) { | ||
| 123 | + return; | ||
| 124 | + } | ||
| 125 | + | ||
| 126 | + final MediaStreamTrack videoTrack = tracks.first; | ||
| 127 | + final MediaTrackCapabilities capabilities = videoTrack.getCapabilities(); | ||
| 128 | + | ||
| 129 | + // TODO: this is empty on MacOS, where there is no facing mode, but one, user facing camera. | ||
| 130 | + // Facing mode is not supported by this track, do nothing. | ||
| 131 | + if (capabilities.facingMode.toDart.isEmpty) { | ||
| 132 | + return; | ||
| 133 | + } | ||
| 134 | + | ||
| 135 | + if (videoTrack.getSettings().facingMode == 'user') { | ||
| 136 | + videoElement.style.transform = 'scaleX(-1)'; | ||
| 137 | + } | ||
| 138 | + } | ||
| 139 | + | ||
| 94 | /// Prepare a [MediaStream] for the video output. | 140 | /// Prepare a [MediaStream] for the video output. |
| 95 | /// | 141 | /// |
| 96 | /// This method requests permission to use the camera. | 142 | /// This method requests permission to use the camera. |
| @@ -100,7 +146,7 @@ class MobileScannerWeb extends MobileScannerPlatform { | @@ -100,7 +146,7 @@ class MobileScannerWeb extends MobileScannerPlatform { | ||
| 100 | Future<MediaStream> _prepareVideoStream( | 146 | Future<MediaStream> _prepareVideoStream( |
| 101 | CameraFacing cameraDirection, | 147 | CameraFacing cameraDirection, |
| 102 | ) async { | 148 | ) async { |
| 103 | - if ((window.navigator.mediaDevices as JSAny?).isUndefinedOrNull) { | 149 | + if (window.navigator.mediaDevices.isUndefinedOrNull) { |
| 104 | throw const MobileScannerException( | 150 | throw const MobileScannerException( |
| 105 | errorCode: MobileScannerErrorCode.unsupported, | 151 | errorCode: MobileScannerErrorCode.unsupported, |
| 106 | errorDetails: MobileScannerErrorDetails( | 152 | errorDetails: MobileScannerErrorDetails( |
| @@ -115,7 +161,7 @@ class MobileScannerWeb extends MobileScannerPlatform { | @@ -115,7 +161,7 @@ class MobileScannerWeb extends MobileScannerPlatform { | ||
| 115 | 161 | ||
| 116 | final MediaStreamConstraints constraints; | 162 | final MediaStreamConstraints constraints; |
| 117 | 163 | ||
| 118 | - if ((capabilities as JSAny).isUndefinedOrNull || !capabilities.facingMode) { | 164 | + if (capabilities.isUndefinedOrNull || !capabilities.facingMode) { |
| 119 | constraints = MediaStreamConstraints(video: true.toJS); | 165 | constraints = MediaStreamConstraints(video: true.toJS); |
| 120 | } else { | 166 | } else { |
| 121 | final String facingMode = switch (cameraDirection) { | 167 | final String facingMode = switch (cameraDirection) { |
| @@ -124,43 +170,24 @@ class MobileScannerWeb extends MobileScannerPlatform { | @@ -124,43 +170,24 @@ class MobileScannerWeb extends MobileScannerPlatform { | ||
| 124 | }; | 170 | }; |
| 125 | 171 | ||
| 126 | constraints = MediaStreamConstraints( | 172 | constraints = MediaStreamConstraints( |
| 127 | - video: MediaTrackConstraintSet(facingMode: facingMode.toJS) as JSAny, | 173 | + video: MediaTrackConstraintSet( |
| 174 | + facingMode: facingMode.toJS, | ||
| 175 | + ), | ||
| 128 | ); | 176 | ); |
| 129 | } | 177 | } |
| 130 | 178 | ||
| 131 | try { | 179 | try { |
| 132 | - // Retrieving the video track requests the camera permission. | ||
| 133 | - // If the completer is not null, the permission was never requested before. | ||
| 134 | - _cameraPermissionCompleter ??= Completer<void>(); | 180 | + // Retrieving the media devices requests the camera permission. |
| 181 | + _permissionRequestInProgress = true; | ||
| 135 | 182 | ||
| 136 | - final MediaStream? videoStream = await window.navigator.mediaDevices | ||
| 137 | - .getUserMedia(constraints) | ||
| 138 | - .toDart as MediaStream?; | 183 | + final MediaStream videoStream = |
| 184 | + await window.navigator.mediaDevices.getUserMedia(constraints).toDart; | ||
| 139 | 185 | ||
| 140 | // At this point the permission is granted. | 186 | // At this point the permission is granted. |
| 141 | - if (!_cameraPermissionCompleter!.isCompleted) { | ||
| 142 | - _cameraPermissionCompleter!.complete(); | ||
| 143 | - } | ||
| 144 | - | ||
| 145 | - if (videoStream == null) { | ||
| 146 | - throw const MobileScannerException( | ||
| 147 | - errorCode: MobileScannerErrorCode.genericError, | ||
| 148 | - errorDetails: MobileScannerErrorDetails( | ||
| 149 | - message: | ||
| 150 | - 'Could not create a video stream from the camera with the given options. ' | ||
| 151 | - 'The browser might not support the given constraints.', | ||
| 152 | - ), | ||
| 153 | - ); | ||
| 154 | - } | 187 | + _permissionRequestInProgress = false; |
| 155 | 188 | ||
| 156 | return videoStream; | 189 | return videoStream; |
| 157 | } on DOMException catch (error, stackTrace) { | 190 | } on DOMException catch (error, stackTrace) { |
| 158 | - // At this point the permission request completed, although with an error, | ||
| 159 | - // but the error is irrelevant for the completer. | ||
| 160 | - if (!_cameraPermissionCompleter!.isCompleted) { | ||
| 161 | - _cameraPermissionCompleter!.complete(); | ||
| 162 | - } | ||
| 163 | - | ||
| 164 | final String errorMessage = error.toString(); | 191 | final String errorMessage = error.toString(); |
| 165 | 192 | ||
| 166 | MobileScannerErrorCode errorCode = MobileScannerErrorCode.genericError; | 193 | MobileScannerErrorCode errorCode = MobileScannerErrorCode.genericError; |
| @@ -173,6 +200,10 @@ class MobileScannerWeb extends MobileScannerPlatform { | @@ -173,6 +200,10 @@ class MobileScannerWeb extends MobileScannerPlatform { | ||
| 173 | errorCode = MobileScannerErrorCode.permissionDenied; | 200 | errorCode = MobileScannerErrorCode.permissionDenied; |
| 174 | } | 201 | } |
| 175 | 202 | ||
| 203 | + // At this point the permission request completed, although with an error, | ||
| 204 | + // but the error is irrelevant. | ||
| 205 | + _permissionRequestInProgress = false; | ||
| 206 | + | ||
| 176 | throw MobileScannerException( | 207 | throw MobileScannerException( |
| 177 | errorCode: errorCode, | 208 | errorCode: errorCode, |
| 178 | errorDetails: MobileScannerErrorDetails( | 209 | errorDetails: MobileScannerErrorDetails( |
| @@ -190,11 +221,11 @@ class MobileScannerWeb extends MobileScannerPlatform { | @@ -190,11 +221,11 @@ class MobileScannerWeb extends MobileScannerPlatform { | ||
| 190 | 221 | ||
| 191 | @override | 222 | @override |
| 192 | Widget buildCameraView() { | 223 | Widget buildCameraView() { |
| 193 | - if (!_barcodeReader.isScanning) { | ||
| 194 | - return const SizedBox(); | 224 | + if (_barcodeReader?.isScanning ?? false) { |
| 225 | + return HtmlElementView(viewType: _getViewType(_textureId)); | ||
| 195 | } | 226 | } |
| 196 | 227 | ||
| 197 | - return const HtmlElementView(viewType: _viewType); | 228 | + return const SizedBox(); |
| 198 | } | 229 | } |
| 199 | 230 | ||
| 200 | @override | 231 | @override |
| @@ -231,27 +262,17 @@ class MobileScannerWeb extends MobileScannerPlatform { | @@ -231,27 +262,17 @@ class MobileScannerWeb extends MobileScannerPlatform { | ||
| 231 | // If the permission request has not yet completed, | 262 | // If the permission request has not yet completed, |
| 232 | // the camera view is not ready yet. | 263 | // the camera view is not ready yet. |
| 233 | // Prevent the permission popup from triggering a restart of the scanner. | 264 | // Prevent the permission popup from triggering a restart of the scanner. |
| 234 | - if (_hasPendingPermissionRequest) { | 265 | + if (_permissionRequestInProgress) { |
| 235 | throw PermissionRequestPendingException(); | 266 | throw PermissionRequestPendingException(); |
| 236 | } | 267 | } |
| 237 | 268 | ||
| 238 | - await _barcodeReader.maybeLoadLibrary( | 269 | + _barcodeReader = ZXingBarcodeReader(); |
| 270 | + | ||
| 271 | + await _barcodeReader?.maybeLoadLibrary( | ||
| 239 | alternateScriptUrl: _alternateScriptUrl, | 272 | alternateScriptUrl: _alternateScriptUrl, |
| 240 | ); | 273 | ); |
| 241 | 274 | ||
| 242 | - // Setup the view factory & container element. | ||
| 243 | - if (_divElement == null) { | ||
| 244 | - _divElement = (document.createElement('div') as HTMLDivElement) | ||
| 245 | - ..style.width = '100%' | ||
| 246 | - ..style.height = '100%'; | ||
| 247 | - | ||
| 248 | - ui_web.platformViewRegistry.registerViewFactory( | ||
| 249 | - _viewType, | ||
| 250 | - (int id) => _divElement!, | ||
| 251 | - ); | ||
| 252 | - } | ||
| 253 | - | ||
| 254 | - if (_barcodeReader.isScanning) { | 275 | + if (_barcodeReader?.isScanning ?? false) { |
| 255 | throw const MobileScannerException( | 276 | throw const MobileScannerException( |
| 256 | errorCode: MobileScannerErrorCode.controllerAlreadyInitialized, | 277 | errorCode: MobileScannerErrorCode.controllerAlreadyInitialized, |
| 257 | errorDetails: MobileScannerErrorDetails( | 278 | errorDetails: MobileScannerErrorDetails( |
| @@ -273,25 +294,19 @@ class MobileScannerWeb extends MobileScannerPlatform { | @@ -273,25 +294,19 @@ class MobileScannerWeb extends MobileScannerPlatform { | ||
| 273 | } | 294 | } |
| 274 | 295 | ||
| 275 | // Listen for changes to the media track settings. | 296 | // Listen for changes to the media track settings. |
| 276 | - _barcodeReader.setMediaTrackSettingsListener( | 297 | + _barcodeReader?.setMediaTrackSettingsListener( |
| 277 | _handleMediaTrackSettingsChange, | 298 | _handleMediaTrackSettingsChange, |
| 278 | ); | 299 | ); |
| 279 | 300 | ||
| 280 | - final HTMLVideoElement videoElement; | 301 | + _textureId += 1; // Request a new texture. |
| 281 | 302 | ||
| 282 | - // Attach the video element to the DOM, through its parent container. | ||
| 283 | - // If a video element is already present, reuse it. | ||
| 284 | - if (_divElement!.children.length == 0) { | ||
| 285 | - videoElement = document.createElement('video') as HTMLVideoElement; | 303 | + _videoElement = _createVideoElement(_textureId); |
| 286 | 304 | ||
| 287 | - _divElement!.appendChild(videoElement); | ||
| 288 | - } else { | ||
| 289 | - videoElement = _divElement!.children.item(0)! as HTMLVideoElement; | ||
| 290 | - } | 305 | + _maybeFlipVideoPreview(_videoElement, videoStream); |
| 291 | 306 | ||
| 292 | - await _barcodeReader.start( | 307 | + await _barcodeReader?.start( |
| 293 | startOptions, | 308 | startOptions, |
| 294 | - videoElement: videoElement, | 309 | + videoElement: _videoElement, |
| 295 | videoStream: videoStream, | 310 | videoStream: videoStream, |
| 296 | ); | 311 | ); |
| 297 | } catch (error, stackTrace) { | 312 | } catch (error, stackTrace) { |
| @@ -305,7 +320,7 @@ class MobileScannerWeb extends MobileScannerPlatform { | @@ -305,7 +320,7 @@ class MobileScannerWeb extends MobileScannerPlatform { | ||
| 305 | } | 320 | } |
| 306 | 321 | ||
| 307 | try { | 322 | try { |
| 308 | - _barcodesSubscription = _barcodeReader.detectBarcodes().listen( | 323 | + _barcodesSubscription = _barcodeReader?.detectBarcodes().listen( |
| 309 | (BarcodeCapture barcode) { | 324 | (BarcodeCapture barcode) { |
| 310 | if (_barcodesController.isClosed) { | 325 | if (_barcodesController.isClosed) { |
| 311 | return; | 326 | return; |
| @@ -315,15 +330,15 @@ class MobileScannerWeb extends MobileScannerPlatform { | @@ -315,15 +330,15 @@ class MobileScannerWeb extends MobileScannerPlatform { | ||
| 315 | }, | 330 | }, |
| 316 | ); | 331 | ); |
| 317 | 332 | ||
| 318 | - final bool hasTorch = await _barcodeReader.hasTorch(); | 333 | + final bool hasTorch = await _barcodeReader?.hasTorch() ?? false; |
| 319 | 334 | ||
| 320 | if (hasTorch && startOptions.torchEnabled) { | 335 | if (hasTorch && startOptions.torchEnabled) { |
| 321 | - await _barcodeReader.setTorchState(TorchState.on); | 336 | + await _barcodeReader?.setTorchState(TorchState.on); |
| 322 | } | 337 | } |
| 323 | 338 | ||
| 324 | return MobileScannerViewAttributes( | 339 | return MobileScannerViewAttributes( |
| 325 | hasTorch: hasTorch, | 340 | hasTorch: hasTorch, |
| 326 | - size: _barcodeReader.videoSize, | 341 | + size: _barcodeReader?.videoSize ?? Size.zero, |
| 327 | ); | 342 | ); |
| 328 | } catch (error, stackTrace) { | 343 | } catch (error, stackTrace) { |
| 329 | throw MobileScannerException( | 344 | throw MobileScannerException( |
| @@ -338,15 +353,12 @@ class MobileScannerWeb extends MobileScannerPlatform { | @@ -338,15 +353,12 @@ class MobileScannerWeb extends MobileScannerPlatform { | ||
| 338 | 353 | ||
| 339 | @override | 354 | @override |
| 340 | Future<void> stop() async { | 355 | Future<void> stop() async { |
| 341 | - if (_barcodesController.isClosed) { | ||
| 342 | - return; | ||
| 343 | - } | ||
| 344 | - | ||
| 345 | // Ensure the barcode scanner is stopped, by cancelling the subscription. | 356 | // Ensure the barcode scanner is stopped, by cancelling the subscription. |
| 346 | await _barcodesSubscription?.cancel(); | 357 | await _barcodesSubscription?.cancel(); |
| 347 | _barcodesSubscription = null; | 358 | _barcodesSubscription = null; |
| 348 | 359 | ||
| 349 | - await _barcodeReader.stop(); | 360 | + await _barcodeReader?.stop(); |
| 361 | + _barcodeReader = null; | ||
| 350 | } | 362 | } |
| 351 | 363 | ||
| 352 | @override | 364 | @override |
| @@ -358,31 +370,8 @@ class MobileScannerWeb extends MobileScannerPlatform { | @@ -358,31 +370,8 @@ class MobileScannerWeb extends MobileScannerPlatform { | ||
| 358 | 370 | ||
| 359 | @override | 371 | @override |
| 360 | Future<void> dispose() async { | 372 | Future<void> dispose() async { |
| 361 | - if (_barcodesController.isClosed) { | ||
| 362 | - return; | ||
| 363 | - } | ||
| 364 | - | 373 | + // The `_barcodesController` and `_settingsController` |
| 374 | + // are not closed, as these have the same lifetime as the plugin. | ||
| 365 | await stop(); | 375 | await stop(); |
| 366 | - await _barcodesController.close(); | ||
| 367 | - await _settingsController.close(); | ||
| 368 | - | ||
| 369 | - // Finally, remove the video element from the DOM. | ||
| 370 | - try { | ||
| 371 | - final HTMLCollection? divChildren = _divElement?.children; | ||
| 372 | - | ||
| 373 | - // Since the exact element is unknown, remove all children. | ||
| 374 | - // In practice, there should only be one child, the single video element. | ||
| 375 | - if (divChildren != null && divChildren.length > 0) { | ||
| 376 | - for (int i = 0; i < divChildren.length; i++) { | ||
| 377 | - final Node? child = divChildren.item(i); | ||
| 378 | - | ||
| 379 | - if (child != null) { | ||
| 380 | - _divElement?.removeChild(child); | ||
| 381 | - } | ||
| 382 | - } | ||
| 383 | - } | ||
| 384 | - } catch (_) { | ||
| 385 | - // The video element was no longer a child of the container element. | ||
| 386 | - } | ||
| 387 | } | 376 | } |
| 388 | } | 377 | } |
| @@ -11,96 +11,68 @@ import 'package:mobile_scanner/src/web/zxing/result_point.dart'; | @@ -11,96 +11,68 @@ import 'package:mobile_scanner/src/web/zxing/result_point.dart'; | ||
| 11 | /// | 11 | /// |
| 12 | /// See also: https://github.com/zxing-js/library/blob/master/src/core/Result.ts | 12 | /// See also: https://github.com/zxing-js/library/blob/master/src/core/Result.ts |
| 13 | @JS() | 13 | @JS() |
| 14 | -@anonymous | ||
| 15 | -@staticInterop | ||
| 16 | -abstract class Result {} | ||
| 17 | - | ||
| 18 | -extension ResultExt on Result { | 14 | +extension type Result(JSObject _) implements JSObject { |
| 19 | @JS('barcodeFormat') | 15 | @JS('barcodeFormat') |
| 20 | - external JSNumber? get _barcodeFormat; | 16 | + external int? get _barcodeFormat; |
| 21 | 17 | ||
| 22 | - @JS('text') | ||
| 23 | - external JSString? get _text; | 18 | + /// Get the text of the result. |
| 19 | + external String? get text; | ||
| 24 | 20 | ||
| 25 | @JS('rawBytes') | 21 | @JS('rawBytes') |
| 26 | external JSUint8Array? get _rawBytes; | 22 | external JSUint8Array? get _rawBytes; |
| 27 | 23 | ||
| 28 | @JS('resultPoints') | 24 | @JS('resultPoints') |
| 29 | - external JSArray? get _resultPoints; | 25 | + external JSArray<ResultPoint>? get _resultPoints; |
| 30 | 26 | ||
| 31 | - @JS('timestamp') | ||
| 32 | - external JSNumber? get _timestamp; | 27 | + /// Get the timestamp of the result. |
| 28 | + external int? get timestamp; | ||
| 33 | 29 | ||
| 34 | /// Get the barcode format of the result. | 30 | /// Get the barcode format of the result. |
| 35 | /// | 31 | /// |
| 36 | /// See also https://github.com/zxing-js/library/blob/master/src/core/BarcodeFormat.ts | 32 | /// See also https://github.com/zxing-js/library/blob/master/src/core/BarcodeFormat.ts |
| 37 | BarcodeFormat get barcodeFormat { | 33 | BarcodeFormat get barcodeFormat { |
| 38 | - switch (_barcodeFormat?.toDartInt) { | ||
| 39 | - case 0: | ||
| 40 | - return BarcodeFormat.aztec; | ||
| 41 | - case 1: | ||
| 42 | - return BarcodeFormat.codabar; | ||
| 43 | - case 2: | ||
| 44 | - return BarcodeFormat.code39; | ||
| 45 | - case 3: | ||
| 46 | - return BarcodeFormat.code93; | ||
| 47 | - case 4: | ||
| 48 | - return BarcodeFormat.code128; | ||
| 49 | - case 5: | ||
| 50 | - return BarcodeFormat.dataMatrix; | ||
| 51 | - case 6: | ||
| 52 | - return BarcodeFormat.ean8; | ||
| 53 | - case 7: | ||
| 54 | - return BarcodeFormat.ean13; | ||
| 55 | - case 8: | ||
| 56 | - return BarcodeFormat.itf; | ||
| 57 | - case 9: | ||
| 58 | - // Maxicode | ||
| 59 | - return BarcodeFormat.unknown; | ||
| 60 | - case 10: | ||
| 61 | - return BarcodeFormat.pdf417; | ||
| 62 | - case 11: | ||
| 63 | - return BarcodeFormat.qrCode; | ||
| 64 | - case 12: | ||
| 65 | - // RSS 14 | ||
| 66 | - return BarcodeFormat.unknown; | ||
| 67 | - case 13: | ||
| 68 | - // RSS EXPANDED | ||
| 69 | - return BarcodeFormat.unknown; | ||
| 70 | - case 14: | ||
| 71 | - return BarcodeFormat.upcA; | ||
| 72 | - case 15: | ||
| 73 | - return BarcodeFormat.upcE; | ||
| 74 | - case 16: | ||
| 75 | - // UPC/EAN extension | ||
| 76 | - return BarcodeFormat.unknown; | ||
| 77 | - default: | ||
| 78 | - return BarcodeFormat.unknown; | ||
| 79 | - } | 34 | + return switch (_barcodeFormat) { |
| 35 | + 0 => BarcodeFormat.aztec, | ||
| 36 | + 1 => BarcodeFormat.codabar, | ||
| 37 | + 2 => BarcodeFormat.code39, | ||
| 38 | + 3 => BarcodeFormat.code93, | ||
| 39 | + 4 => BarcodeFormat.code128, | ||
| 40 | + 5 => BarcodeFormat.dataMatrix, | ||
| 41 | + 6 => BarcodeFormat.ean8, | ||
| 42 | + 7 => BarcodeFormat.ean13, | ||
| 43 | + 8 => BarcodeFormat.itf, | ||
| 44 | + // Maxicode | ||
| 45 | + 9 => BarcodeFormat.unknown, | ||
| 46 | + 10 => BarcodeFormat.pdf417, | ||
| 47 | + 11 => BarcodeFormat.qrCode, | ||
| 48 | + // RSS 14 | ||
| 49 | + 12 => BarcodeFormat.unknown, | ||
| 50 | + // RSS EXPANDED | ||
| 51 | + 13 => BarcodeFormat.unknown, | ||
| 52 | + 14 => BarcodeFormat.upcA, | ||
| 53 | + 15 => BarcodeFormat.upcE, | ||
| 54 | + // UPC/EAN extension | ||
| 55 | + 16 => BarcodeFormat.unknown, | ||
| 56 | + _ => BarcodeFormat.unknown | ||
| 57 | + }; | ||
| 80 | } | 58 | } |
| 81 | 59 | ||
| 60 | + /// Get the raw bytes of the result. | ||
| 61 | + Uint8List? get rawBytes => _rawBytes?.toDart; | ||
| 62 | + | ||
| 82 | /// Get the corner points of the result. | 63 | /// Get the corner points of the result. |
| 83 | List<Offset> get resultPoints { | 64 | List<Offset> get resultPoints { |
| 84 | - final JSArray? points = _resultPoints; | 65 | + final JSArray<ResultPoint>? points = _resultPoints; |
| 85 | 66 | ||
| 86 | if (points == null) { | 67 | if (points == null) { |
| 87 | - return []; | 68 | + return const []; |
| 88 | } | 69 | } |
| 89 | 70 | ||
| 90 | - return points.toDart.cast<ResultPoint>().map((point) { | 71 | + return points.toDart.map((point) { |
| 91 | return Offset(point.x, point.y); | 72 | return Offset(point.x, point.y); |
| 92 | }).toList(); | 73 | }).toList(); |
| 93 | } | 74 | } |
| 94 | 75 | ||
| 95 | - /// Get the raw bytes of the result. | ||
| 96 | - Uint8List? get rawBytes => _rawBytes?.toDart; | ||
| 97 | - | ||
| 98 | - /// Get the text of the result. | ||
| 99 | - String? get text => _text?.toDart; | ||
| 100 | - | ||
| 101 | - /// Get the timestamp of the result. | ||
| 102 | - int? get timestamp => _timestamp?.toDartInt; | ||
| 103 | - | ||
| 104 | /// Convert this result to a [Barcode]. | 76 | /// Convert this result to a [Barcode]. |
| 105 | Barcode get toBarcode { | 77 | Barcode get toBarcode { |
| 106 | return Barcode( | 78 | return Barcode( |
| @@ -4,20 +4,10 @@ import 'dart:js_interop'; | @@ -4,20 +4,10 @@ import 'dart:js_interop'; | ||
| 4 | /// | 4 | /// |
| 5 | /// See also: https://github.com/zxing-js/library/blob/master/src/core/ResultPoint.ts | 5 | /// See also: https://github.com/zxing-js/library/blob/master/src/core/ResultPoint.ts |
| 6 | @JS() | 6 | @JS() |
| 7 | -@anonymous | ||
| 8 | -@staticInterop | ||
| 9 | -abstract class ResultPoint {} | ||
| 10 | - | ||
| 11 | -extension ResultPointExt on ResultPoint { | ||
| 12 | - @JS('x') | ||
| 13 | - external JSNumber get _x; | ||
| 14 | - | ||
| 15 | - @JS('y') | ||
| 16 | - external JSNumber get _y; | ||
| 17 | - | 7 | +extension type ResultPoint(JSObject _) implements JSObject { |
| 18 | /// The x coordinate of the point. | 8 | /// The x coordinate of the point. |
| 19 | - double get x => _x.toDartDouble; | 9 | + external double get x; |
| 20 | 10 | ||
| 21 | /// The y coordinate of the point. | 11 | /// The y coordinate of the point. |
| 22 | - double get y => _y.toDartDouble; | 12 | + external double get y; |
| 23 | } | 13 | } |
| @@ -12,8 +12,6 @@ import 'package:mobile_scanner/src/web/zxing/result.dart'; | @@ -12,8 +12,6 @@ import 'package:mobile_scanner/src/web/zxing/result.dart'; | ||
| 12 | import 'package:mobile_scanner/src/web/zxing/zxing_browser_multi_format_reader.dart'; | 12 | import 'package:mobile_scanner/src/web/zxing/zxing_browser_multi_format_reader.dart'; |
| 13 | import 'package:web/web.dart' as web; | 13 | import 'package:web/web.dart' as web; |
| 14 | 14 | ||
| 15 | -// TODO: remove the JSAny casts once upgraded to a package:web version that restores "implements JSAny" | ||
| 16 | - | ||
| 17 | /// A barcode reader implementation that uses the ZXing library. | 15 | /// A barcode reader implementation that uses the ZXing library. |
| 18 | final class ZXingBarcodeReader extends BarcodeReader { | 16 | final class ZXingBarcodeReader extends BarcodeReader { |
| 19 | ZXingBarcodeReader(); | 17 | ZXingBarcodeReader(); |
| @@ -48,42 +46,6 @@ final class ZXingBarcodeReader extends BarcodeReader { | @@ -48,42 +46,6 @@ final class ZXingBarcodeReader extends BarcodeReader { | ||
| 48 | @override | 46 | @override |
| 49 | String get scriptUrl => 'https://unpkg.com/@zxing/library@0.19.1'; | 47 | String get scriptUrl => 'https://unpkg.com/@zxing/library@0.19.1'; |
| 50 | 48 | ||
| 51 | - /// Get the barcode format from the ZXing library, for the given [format]. | ||
| 52 | - static int getZXingBarcodeFormat(BarcodeFormat format) { | ||
| 53 | - switch (format) { | ||
| 54 | - case BarcodeFormat.aztec: | ||
| 55 | - return 0; | ||
| 56 | - case BarcodeFormat.codabar: | ||
| 57 | - return 1; | ||
| 58 | - case BarcodeFormat.code39: | ||
| 59 | - return 2; | ||
| 60 | - case BarcodeFormat.code93: | ||
| 61 | - return 3; | ||
| 62 | - case BarcodeFormat.code128: | ||
| 63 | - return 4; | ||
| 64 | - case BarcodeFormat.dataMatrix: | ||
| 65 | - return 5; | ||
| 66 | - case BarcodeFormat.ean8: | ||
| 67 | - return 6; | ||
| 68 | - case BarcodeFormat.ean13: | ||
| 69 | - return 7; | ||
| 70 | - case BarcodeFormat.itf: | ||
| 71 | - return 8; | ||
| 72 | - case BarcodeFormat.pdf417: | ||
| 73 | - return 10; | ||
| 74 | - case BarcodeFormat.qrCode: | ||
| 75 | - return 11; | ||
| 76 | - case BarcodeFormat.upcA: | ||
| 77 | - return 14; | ||
| 78 | - case BarcodeFormat.upcE: | ||
| 79 | - return 15; | ||
| 80 | - case BarcodeFormat.unknown: | ||
| 81 | - case BarcodeFormat.all: | ||
| 82 | - default: | ||
| 83 | - return -1; | ||
| 84 | - } | ||
| 85 | - } | ||
| 86 | - | ||
| 87 | JSMap? _createReaderHints(List<BarcodeFormat> formats) { | 49 | JSMap? _createReaderHints(List<BarcodeFormat> formats) { |
| 88 | if (formats.isEmpty || formats.contains(BarcodeFormat.all)) { | 50 | if (formats.isEmpty || formats.contains(BarcodeFormat.all)) { |
| 89 | return null; | 51 | return null; |
| @@ -96,8 +58,7 @@ final class ZXingBarcodeReader extends BarcodeReader { | @@ -96,8 +58,7 @@ final class ZXingBarcodeReader extends BarcodeReader { | ||
| 96 | hints.set( | 58 | hints.set( |
| 97 | 2.toJS, | 59 | 2.toJS, |
| 98 | [ | 60 | [ |
| 99 | - for (final BarcodeFormat format in formats) | ||
| 100 | - getZXingBarcodeFormat(format).toJS, | 61 | + for (final BarcodeFormat format in formats) format.toJS, |
| 101 | ].toJS, | 62 | ].toJS, |
| 102 | ); | 63 | ); |
| 103 | 64 | ||
| @@ -114,9 +75,9 @@ final class ZXingBarcodeReader extends BarcodeReader { | @@ -114,9 +75,9 @@ final class ZXingBarcodeReader extends BarcodeReader { | ||
| 114 | web.MediaStream videoStream, | 75 | web.MediaStream videoStream, |
| 115 | ) async { | 76 | ) async { |
| 116 | final JSPromise? result = _reader?.attachStreamToVideo.callAsFunction( | 77 | final JSPromise? result = _reader?.attachStreamToVideo.callAsFunction( |
| 117 | - _reader as JSAny?, | ||
| 118 | - videoStream as JSAny, | ||
| 119 | - videoElement as JSAny, | 78 | + _reader, |
| 79 | + videoStream, | ||
| 80 | + videoElement, | ||
| 120 | ) as JSPromise?; | 81 | ) as JSPromise?; |
| 121 | 82 | ||
| 122 | await result?.toDart; | 83 | await result?.toDart; |
| @@ -135,8 +96,8 @@ final class ZXingBarcodeReader extends BarcodeReader { | @@ -135,8 +96,8 @@ final class ZXingBarcodeReader extends BarcodeReader { | ||
| 135 | 96 | ||
| 136 | controller.onListen = () { | 97 | controller.onListen = () { |
| 137 | _reader?.decodeContinuously.callAsFunction( | 98 | _reader?.decodeContinuously.callAsFunction( |
| 138 | - _reader as JSAny?, | ||
| 139 | - _reader?.videoElement as JSAny?, | 99 | + _reader, |
| 100 | + _reader?.videoElement, | ||
| 140 | (Result? result, JSAny? error) { | 101 | (Result? result, JSAny? error) { |
| 141 | if (controller.isClosed || result == null) { | 102 | if (controller.isClosed || result == null) { |
| 142 | return; | 103 | return; |
| @@ -155,8 +116,8 @@ final class ZXingBarcodeReader extends BarcodeReader { | @@ -155,8 +116,8 @@ final class ZXingBarcodeReader extends BarcodeReader { | ||
| 155 | // when the stream subscription returned by this method is cancelled in `MobileScannerWeb.stop()`. | 116 | // when the stream subscription returned by this method is cancelled in `MobileScannerWeb.stop()`. |
| 156 | // This avoids both leaving the barcode scanner running and a memory leak for the stream subscription. | 117 | // This avoids both leaving the barcode scanner running and a memory leak for the stream subscription. |
| 157 | controller.onCancel = () async { | 118 | controller.onCancel = () async { |
| 158 | - _reader?.stopContinuousDecode.callAsFunction(_reader as JSAny?); | ||
| 159 | - _reader?.reset.callAsFunction(_reader as JSAny?); | 119 | + _reader?.stopContinuousDecode.callAsFunction(_reader); |
| 120 | + _reader?.reset.callAsFunction(_reader); | ||
| 160 | await controller.close(); | 121 | await controller.close(); |
| 161 | }; | 122 | }; |
| 162 | 123 | ||
| @@ -185,7 +146,7 @@ final class ZXingBarcodeReader extends BarcodeReader { | @@ -185,7 +146,7 @@ final class ZXingBarcodeReader extends BarcodeReader { | ||
| 185 | 146 | ||
| 186 | _reader = ZXingBrowserMultiFormatReader( | 147 | _reader = ZXingBrowserMultiFormatReader( |
| 187 | _createReaderHints(formats), | 148 | _createReaderHints(formats), |
| 188 | - detectionTimeoutMs.toJS, | 149 | + detectionTimeoutMs, |
| 189 | ); | 150 | ); |
| 190 | 151 | ||
| 191 | await _prepareVideoElement(videoElement, videoStream); | 152 | await _prepareVideoElement(videoElement, videoStream); |
| @@ -194,8 +155,32 @@ final class ZXingBarcodeReader extends BarcodeReader { | @@ -194,8 +155,32 @@ final class ZXingBarcodeReader extends BarcodeReader { | ||
| 194 | @override | 155 | @override |
| 195 | Future<void> stop() async { | 156 | Future<void> stop() async { |
| 196 | _onMediaTrackSettingsChanged = null; | 157 | _onMediaTrackSettingsChanged = null; |
| 197 | - _reader?.stopContinuousDecode.callAsFunction(_reader as JSAny?); | ||
| 198 | - _reader?.reset.callAsFunction(_reader as JSAny?); | 158 | + _reader?.stopContinuousDecode.callAsFunction(_reader); |
| 159 | + _reader?.reset.callAsFunction(_reader); | ||
| 199 | _reader = null; | 160 | _reader = null; |
| 200 | } | 161 | } |
| 201 | } | 162 | } |
| 163 | + | ||
| 164 | +extension on BarcodeFormat { | ||
| 165 | + /// Get the barcode format from the ZXing library. | ||
| 166 | + JSNumber get toJS { | ||
| 167 | + final int zxingFormat = switch (this) { | ||
| 168 | + BarcodeFormat.aztec => 0, | ||
| 169 | + BarcodeFormat.codabar => 1, | ||
| 170 | + BarcodeFormat.code39 => 2, | ||
| 171 | + BarcodeFormat.code93 => 3, | ||
| 172 | + BarcodeFormat.code128 => 4, | ||
| 173 | + BarcodeFormat.dataMatrix => 5, | ||
| 174 | + BarcodeFormat.ean8 => 6, | ||
| 175 | + BarcodeFormat.ean13 => 7, | ||
| 176 | + BarcodeFormat.itf => 8, | ||
| 177 | + BarcodeFormat.pdf417 => 10, | ||
| 178 | + BarcodeFormat.qrCode => 11, | ||
| 179 | + BarcodeFormat.upcA => 14, | ||
| 180 | + BarcodeFormat.upcE => 15, | ||
| 181 | + BarcodeFormat.unknown || BarcodeFormat.all || _ => -1, | ||
| 182 | + }; | ||
| 183 | + | ||
| 184 | + return zxingFormat.toJS; | ||
| 185 | + } | ||
| 186 | +} |
| @@ -7,8 +7,7 @@ import 'package:web/web.dart'; | @@ -7,8 +7,7 @@ import 'package:web/web.dart'; | ||
| 7 | /// | 7 | /// |
| 8 | /// See https://github.com/zxing-js/library/blob/master/src/browser/BrowserMultiFormatReader.ts | 8 | /// See https://github.com/zxing-js/library/blob/master/src/browser/BrowserMultiFormatReader.ts |
| 9 | @JS('ZXing.BrowserMultiFormatReader') | 9 | @JS('ZXing.BrowserMultiFormatReader') |
| 10 | -@staticInterop | ||
| 11 | -class ZXingBrowserMultiFormatReader { | 10 | +extension type ZXingBrowserMultiFormatReader._(JSObject _) implements JSObject { |
| 12 | /// Construct a new `ZXing.BrowserMultiFormatReader`. | 11 | /// Construct a new `ZXing.BrowserMultiFormatReader`. |
| 13 | /// | 12 | /// |
| 14 | /// The [hints] are the configuration options for the reader. | 13 | /// The [hints] are the configuration options for the reader. |
| @@ -17,11 +16,9 @@ class ZXingBrowserMultiFormatReader { | @@ -17,11 +16,9 @@ class ZXingBrowserMultiFormatReader { | ||
| 17 | /// See also: https://github.com/zxing-js/library/blob/master/src/core/DecodeHintType.ts | 16 | /// See also: https://github.com/zxing-js/library/blob/master/src/core/DecodeHintType.ts |
| 18 | external factory ZXingBrowserMultiFormatReader( | 17 | external factory ZXingBrowserMultiFormatReader( |
| 19 | JSMap? hints, | 18 | JSMap? hints, |
| 20 | - JSNumber? timeBetweenScansMillis, | 19 | + int timeBetweenScansMillis, |
| 21 | ); | 20 | ); |
| 22 | -} | ||
| 23 | 21 | ||
| 24 | -extension ZXingBrowserMultiFormatReaderExt on ZXingBrowserMultiFormatReader { | ||
| 25 | /// Attach a [MediaStream] to a [HTMLVideoElement]. | 22 | /// Attach a [MediaStream] to a [HTMLVideoElement]. |
| 26 | /// | 23 | /// |
| 27 | /// This function accepts a [MediaStream] and a [HTMLVideoElement] as arguments, | 24 | /// This function accepts a [MediaStream] and a [HTMLVideoElement] as arguments, |
| @@ -4,14 +4,14 @@ | @@ -4,14 +4,14 @@ | ||
| 4 | # | 4 | # |
| 5 | Pod::Spec.new do |s| | 5 | Pod::Spec.new do |s| |
| 6 | s.name = 'mobile_scanner' | 6 | s.name = 'mobile_scanner' |
| 7 | - s.version = '3.5.6' | 7 | + s.version = '5.0.0' |
| 8 | s.summary = 'An universal scanner for Flutter based on MLKit.' | 8 | s.summary = 'An universal scanner for Flutter based on MLKit.' |
| 9 | s.description = <<-DESC | 9 | s.description = <<-DESC |
| 10 | An universal scanner for Flutter based on MLKit. | 10 | An universal scanner for Flutter based on MLKit. |
| 11 | DESC | 11 | DESC |
| 12 | - s.homepage = 'http://example.com' | 12 | + s.homepage = 'https://github.com/juliansteenbakker/mobile_scanner' |
| 13 | s.license = { :file => '../LICENSE' } | 13 | s.license = { :file => '../LICENSE' } |
| 14 | - s.author = { 'Your Company' => 'email@example.com' } | 14 | + s.author = { 'Julian Steenbakker' => 'juliansteenbakker@outlook.com' } |
| 15 | s.source = { :path => '.' } | 15 | s.source = { :path => '.' } |
| 16 | s.source_files = 'Classes/**/*' | 16 | s.source_files = 'Classes/**/*' |
| 17 | s.dependency 'FlutterMacOS' | 17 | s.dependency 'FlutterMacOS' |
| 1 | name: mobile_scanner | 1 | name: mobile_scanner |
| 2 | description: A universal barcode and QR code scanner for Flutter based on MLKit. Uses CameraX on Android, AVFoundation on iOS and Apple Vision & AVFoundation on macOS. | 2 | description: A universal barcode and QR code scanner for Flutter based on MLKit. Uses CameraX on Android, AVFoundation on iOS and Apple Vision & AVFoundation on macOS. |
| 3 | -version: 5.0.0-beta.2 | 3 | +version: 5.0.0-beta.3 |
| 4 | repository: https://github.com/juliansteenbakker/mobile_scanner | 4 | repository: https://github.com/juliansteenbakker/mobile_scanner |
| 5 | 5 | ||
| 6 | screenshots: | 6 | screenshots: |
| @@ -16,8 +16,8 @@ screenshots: | @@ -16,8 +16,8 @@ screenshots: | ||
| 16 | path: example/screenshots/overlay.png | 16 | path: example/screenshots/overlay.png |
| 17 | 17 | ||
| 18 | environment: | 18 | environment: |
| 19 | - sdk: ">=3.2.0 <4.0.0" | ||
| 20 | - flutter: ">=3.16.0" | 19 | + sdk: ">=3.3.0 <4.0.0" |
| 20 | + flutter: ">=3.19.0" | ||
| 21 | 21 | ||
| 22 | dependencies: | 22 | dependencies: |
| 23 | flutter: | 23 | flutter: |
| @@ -25,7 +25,7 @@ dependencies: | @@ -25,7 +25,7 @@ dependencies: | ||
| 25 | flutter_web_plugins: | 25 | flutter_web_plugins: |
| 26 | sdk: flutter | 26 | sdk: flutter |
| 27 | plugin_platform_interface: ^2.0.2 | 27 | plugin_platform_interface: ^2.0.2 |
| 28 | - web: ^0.4.0 | 28 | + web: ^0.5.1 |
| 29 | 29 | ||
| 30 | dev_dependencies: | 30 | dev_dependencies: |
| 31 | flutter_test: | 31 | flutter_test: |
-
Please register or login to post a comment