Julian Steenbakker
Committed by GitHub

Merge branch 'master' into fix_android_permission_bug

... ... @@ -21,6 +21,7 @@ Breaking changes:
* The `autoResume` attribute has been removed from the `MobileScanner` widget.
The controller already automatically resumes, so it had no effect.
* Removed `MobileScannerCallback` and `MobileScannerArgumentsCallback` typedef.
* [Web] Replaced `jsqr` library with `zxing-js` for full barcode support.
Improvements:
* Toggling the device torch now does nothing if the device has no torch, rather than throwing an error.
... ... @@ -30,6 +31,9 @@ Features:
* Added a new `placeholderBuilder` function to the `MobileScanner` widget to customize the preview placeholder.
* Added `autoStart` parameter to MobileScannerController(). If set to false, controller won't start automatically.
* Added `hasTorch` function on MobileScannerController(). After starting the controller, you can check if the device has a torch.
* [iOS] Support `torchEnabled` parameter from MobileScannerController() on iOS
* [Web] Added ability to use custom barcode scanning js libraries
by extending `WebBarcodeReaderBase` class and changing `barCodeReader` property in `MobileScannerWebPlugin`
Fixes:
* Fixes the missing gradle setup for the Android project, which prevented gradle sync from working.
... ... @@ -40,6 +44,7 @@ Fixes:
* Fixes the `MobileScanner` preview depending on all attributes of `MediaQueryData`.
Now it only depends on its layout constraints.
* Fixed a potential crash when the scanner is restarted due to the app being resumed.
* [iOS] Fix crash when changing torch state
## 3.0.0-beta.2
Breaking changes:
... ...
package dev.steenbakker.mobile_scanner
import android.app.Activity
import android.content.pm.PackageManager
import android.graphics.Rect
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Surface
import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider
... ... @@ -11,22 +14,29 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import dev.steenbakker.mobile_scanner.objects.DetectionSpeed
import dev.steenbakker.mobile_scanner.objects.MobileScannerStartParameters
import io.flutter.view.TextureRegistry
typealias MobileScannerCallback = (barcodes: List<Map<String, Any?>>, image: ByteArray?) -> Unit
import kotlin.math.roundToInt
typealias MobileScannerCallback = (barcodes: List<Map<String, Any?>>, image: ByteArray?, width: Int?, height: Int?) -> Unit
typealias AnalyzerCallback = (barcodes: List<Map<String, Any?>>?) -> Unit
typealias MobileScannerErrorCallback = (error: String) -> Unit
typealias TorchStateCallback = (state: Int) -> Unit
typealias MobileScannerStartedCallback = (parameters: MobileScannerStartParameters) -> Unit
class NoCamera : Exception()
class AlreadyStarted : Exception()
class AlreadyStopped : Exception()
class TorchError : Exception()
class CameraError : Exception()
class TorchWhenStopped : Exception()
class ZoomWhenStopped : Exception()
class ZoomNotInRange : Exception()
class MobileScanner(
private val activity: Activity,
... ... @@ -39,6 +49,7 @@ class MobileScanner(
private var camera: Camera? = null
private var preview: Preview? = null
private var textureEntry: TextureRegistry.SurfaceTextureEntry? = null
var scanWindow: List<Float>? = null
private var detectionSpeed: DetectionSpeed = DetectionSpeed.NO_DUPLICATES
private var detectionTimeout: Long = 250
... ... @@ -76,12 +87,27 @@ class MobileScanner(
lastScanned = newScannedBarcodes
}
val barcodeMap = barcodes.map { barcode -> barcode.data }
val barcodeMap: MutableList<Map<String, Any?>> = mutableListOf()
for ( barcode in barcodes) {
if(scanWindow != null) {
val match = isbarCodeInScanWindow(scanWindow!!, barcode, imageProxy)
if(!match) {
continue
} else {
barcodeMap.add(barcode.data)
}
} else {
barcodeMap.add(barcode.data)
}
}
if (barcodeMap.isNotEmpty()) {
mobileScannerCallback(
barcodeMap,
if (returnImage) mediaImage.toByteArray() else null
if (returnImage) mediaImage.toByteArray() else null,
if (returnImage) mediaImage.width else null,
if (returnImage) mediaImage.height else null
)
}
}
... ... @@ -100,6 +126,23 @@ class MobileScanner(
}
}
// scales the scanWindow to the provided inputImage and checks if that scaled
// scanWindow contains the barcode
private fun isbarCodeInScanWindow(scanWindow: List<Float>, barcode: Barcode, inputImage: ImageProxy): Boolean {
val barcodeBoundingBox = barcode.boundingBox ?: return false
val imageWidth = inputImage.height
val imageHeight = inputImage.width
val left = (scanWindow[0] * imageWidth).roundToInt()
val top = (scanWindow[1] * imageHeight).roundToInt()
val right = (scanWindow[2] * imageWidth).roundToInt()
val bottom = (scanWindow[3] * imageHeight).roundToInt()
val scaledScanWindow = Rect(left, top, right, bottom)
return scaledScanWindow.contains(barcodeBoundingBox)
}
/**
* Start barcode scanning by initializing the camera and barcode scanner.
*/
... ... @@ -182,7 +225,7 @@ class MobileScanner(
// Enable torch if provided
camera!!.cameraControl.enableTorch(torch)
val resolution = preview!!.resolutionInfo!!.resolution
val resolution = analysis.resolutionInfo!!.resolution
val portrait = camera!!.cameraInfo.sensorRotationDegrees % 180 == 0
val width = resolution.width.toDouble()
val height = resolution.height.toDouble()
... ... @@ -251,4 +294,13 @@ class MobileScanner(
}
}
/**
* Set the zoom rate of the camera.
*/
fun setScale(scale: Double) {
if (camera == null) throw ZoomWhenStopped()
if (scale > 1.0 || scale < 0) throw ZoomNotInRange()
camera!!.cameraControl.setLinearZoom(scale.toFloat())
}
}
... ...
... ... @@ -9,6 +9,72 @@ class MobileScannerPlugin : FlutterPlugin, ActivityAware {
private var activityPluginBinding: ActivityPluginBinding? = null
private var flutterPluginBinding: FlutterPlugin.FlutterPluginBinding? = null
private var methodCallHandler: MethodCallHandlerImpl? = null
private var handler: MobileScanner? = null
private var method: MethodChannel? = null
private lateinit var barcodeHandler: BarcodeHandler
private var analyzerResult: MethodChannel.Result? = null
private val callback: MobileScannerCallback = { barcodes: List<Map<String, Any?>>, image: ByteArray?, width: Int?, height: Int? ->
if (image != null) {
barcodeHandler.publishEvent(mapOf(
"name" to "barcode",
"data" to barcodes,
"image" to image,
"width" to width!!.toDouble(),
"height" to height!!.toDouble()
))
} else {
barcodeHandler.publishEvent(mapOf(
"name" to "barcode",
"data" to barcodes
))
}
}
private val analyzerCallback: AnalyzerCallback = { barcodes: List<Map<String, Any?>>?->
if (barcodes != null) {
barcodeHandler.publishEvent(mapOf(
"name" to "barcode",
"data" to barcodes
))
analyzerResult?.success(true)
} else {
analyzerResult?.success(false)
}
analyzerResult = null
}
private val errorCallback: MobileScannerErrorCallback = {error: String ->
barcodeHandler.publishEvent(mapOf(
"name" to "error",
"data" to error,
))
}
private val torchStateCallback: TorchStateCallback = {state: Int ->
barcodeHandler.publishEvent(mapOf("name" to "torchState", "data" to state))
}
@ExperimentalGetImage
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
if (handler == null) {
result.error("MobileScanner", "Called ${call.method} before initializing.", null)
return
}
when (call.method) {
"state" -> result.success(handler!!.hasCameraPermission())
"request" -> handler!!.requestPermission(result)
"start" -> start(call, result)
"torch" -> toggleTorch(call, result)
"stop" -> stop(result)
"analyzeImage" -> analyzeImage(call, result)
"setScale" -> setScale(call, result)
"updateScanWindow" -> updateScanWindow(call)
else -> result.notImplemented()
}
}
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
this.flutterPluginBinding = binding
... ... @@ -46,4 +112,19 @@ class MobileScannerPlugin : FlutterPlugin, ActivityAware {
override fun onDetachedFromActivityForConfigChanges() {
onDetachedFromActivity()
}
private fun setScale(call: MethodCall, result: MethodChannel.Result) {
try {
handler!!.setScale(call.arguments as Double)
result.success(null)
} catch (e: ZoomWhenStopped) {
result.error("MobileScanner", "Called setScale() while stopped!", null)
} catch (e: ZoomNotInRange) {
result.error("MobileScanner", "Scale should be within 0 and 1", null)
}
}
private fun updateScanWindow(call: MethodCall) {
handler!!.scanWindow = call.argument<List<Float>>("rect")
}
}
... ...
... ... @@ -354,6 +354,8 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 75Y2P2WSQQ;
ENABLE_BITCODE = NO;
... ... @@ -364,6 +366,7 @@
);
PRODUCT_BUNDLE_IDENTIFIER = dev.steenbakker.mobileScanner;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
... ... @@ -483,6 +486,8 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 75Y2P2WSQQ;
ENABLE_BITCODE = NO;
... ... @@ -493,6 +498,7 @@
);
PRODUCT_BUNDLE_IDENTIFIER = dev.steenbakker.mobileScanner;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
... ... @@ -506,6 +512,8 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 75Y2P2WSQQ;
ENABLE_BITCODE = NO;
... ... @@ -516,6 +524,7 @@
);
PRODUCT_BUNDLE_IDENTIFIER = dev.steenbakker.mobileScanner;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
... ...
... ... @@ -72,33 +72,41 @@ class _BarcodeScannerWithControllerState
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
color: Colors.white,
icon: ValueListenableBuilder(
valueListenable: controller.torchState,
builder: (context, state, child) {
if (state == null) {
return const Icon(
Icons.flash_off,
color: Colors.grey,
);
}
switch (state as TorchState) {
case TorchState.off:
return const Icon(
Icons.flash_off,
color: Colors.grey,
);
case TorchState.on:
return const Icon(
Icons.flash_on,
color: Colors.yellow,
);
}
},
),
iconSize: 32.0,
onPressed: () => controller.toggleTorch(),
ValueListenableBuilder(
valueListenable: controller.hasTorchState,
builder: (context, state, child) {
if (state != true) {
return const SizedBox.shrink();
}
return IconButton(
color: Colors.white,
icon: ValueListenableBuilder(
valueListenable: controller.torchState,
builder: (context, state, child) {
if (state == null) {
return const Icon(
Icons.flash_off,
color: Colors.grey,
);
}
switch (state as TorchState) {
case TorchState.off:
return const Icon(
Icons.flash_off,
color: Colors.grey,
);
case TorchState.on:
return const Icon(
Icons.flash_on,
color: Colors.yellow,
);
}
},
),
iconSize: 32.0,
onPressed: () => controller.toggleTorch(),
);
},
),
IconButton(
color: Colors.white,
... ...
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
class BarcodeScannerWithScanWindow extends StatefulWidget {
const BarcodeScannerWithScanWindow({Key? key}) : super(key: key);
@override
_BarcodeScannerWithScanWindowState createState() =>
_BarcodeScannerWithScanWindowState();
}
class _BarcodeScannerWithScanWindowState
extends State<BarcodeScannerWithScanWindow> {
late MobileScannerController controller = MobileScannerController();
Barcode? barcode;
BarcodeCapture? capture;
Future<void> onDetect(BarcodeCapture barcode) async {
capture = barcode;
setState(() => this.barcode = barcode.barcodes.first);
}
MobileScannerArguments? arguments;
@override
Widget build(BuildContext context) {
final scanWindow = Rect.fromCenter(
center: MediaQuery.of(context).size.center(Offset.zero),
width: 200,
height: 200,
);
return Scaffold(
backgroundColor: Colors.black,
body: Builder(
builder: (context) {
return Stack(
fit: StackFit.expand,
children: [
MobileScanner(
fit: BoxFit.contain,
scanWindow: scanWindow,
controller: controller,
onScannerStarted: (arguments) {
setState(() {
this.arguments = arguments;
});
},
onDetect: onDetect,
),
if (barcode != null &&
barcode?.corners != null &&
arguments != null)
CustomPaint(
painter: BarcodeOverlay(
barcode!,
arguments!,
BoxFit.contain,
MediaQuery.of(context).devicePixelRatio,
capture!,
),
),
CustomPaint(
painter: ScannerOverlay(scanWindow),
),
Align(
alignment: Alignment.bottomCenter,
child: Container(
alignment: Alignment.bottomCenter,
height: 100,
color: Colors.black.withOpacity(0.4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Center(
child: SizedBox(
width: MediaQuery.of(context).size.width - 120,
height: 50,
child: FittedBox(
child: Text(
barcode?.displayValue ?? 'Scan something!',
overflow: TextOverflow.fade,
style: Theme.of(context)
.textTheme
.headline4!
.copyWith(color: Colors.white),
),
),
),
),
],
),
),
),
],
);
},
),
);
}
}
class ScannerOverlay extends CustomPainter {
ScannerOverlay(this.scanWindow);
final Rect scanWindow;
@override
void paint(Canvas canvas, Size size) {
final backgroundPath = Path()..addRect(Rect.largest);
final cutoutPath = Path()..addRect(scanWindow);
final backgroundPaint = Paint()
..color = Colors.black.withOpacity(0.5)
..style = PaintingStyle.fill
..blendMode = BlendMode.dstOut;
final backgroundWithCutout = Path.combine(
PathOperation.difference,
backgroundPath,
cutoutPath,
);
canvas.drawPath(backgroundWithCutout, backgroundPaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return false;
}
}
class BarcodeOverlay extends CustomPainter {
BarcodeOverlay(
this.barcode,
this.arguments,
this.boxFit,
this.devicePixelRatio,
this.capture,
);
final BarcodeCapture capture;
final Barcode barcode;
final MobileScannerArguments arguments;
final BoxFit boxFit;
final double devicePixelRatio;
@override
void paint(Canvas canvas, Size size) {
if (barcode.corners == null) return;
final adjustedSize = applyBoxFit(boxFit, arguments.size, size);
double verticalPadding = size.height - adjustedSize.destination.height;
double horizontalPadding = size.width - adjustedSize.destination.width;
if (verticalPadding > 0) {
verticalPadding = verticalPadding / 2;
} else {
verticalPadding = 0;
}
if (horizontalPadding > 0) {
horizontalPadding = horizontalPadding / 2;
} else {
horizontalPadding = 0;
}
final ratioWidth =
(Platform.isIOS ? capture.width! : arguments.size.width) /
adjustedSize.destination.width;
final ratioHeight =
(Platform.isIOS ? capture.height! : arguments.size.height) /
adjustedSize.destination.height;
final List<Offset> adjustedOffset = [];
for (final offset in barcode.corners!) {
adjustedOffset.add(
Offset(
offset.dx / ratioWidth + horizontalPadding,
offset.dy / ratioHeight + verticalPadding,
),
);
}
final cutoutPath = Path()..addPolygon(adjustedOffset, true);
final backgroundPaint = Paint()
..color = Colors.red.withOpacity(0.3)
..style = PaintingStyle.fill
..blendMode = BlendMode.dstOut;
canvas.drawPath(cutoutPath, backgroundPaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return false;
}
}
... ...
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
class BarcodeScannerWithZoom extends StatefulWidget {
const BarcodeScannerWithZoom({Key? key}) : super(key: key);
@override
_BarcodeScannerWithZoomState createState() => _BarcodeScannerWithZoomState();
}
class _BarcodeScannerWithZoomState extends State<BarcodeScannerWithZoom>
with SingleTickerProviderStateMixin {
BarcodeCapture? barcode;
MobileScannerController controller = MobileScannerController(
torchEnabled: true,
);
bool isStarted = true;
double _zoomFactor = 0.0;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Builder(
builder: (context) {
return Stack(
children: [
MobileScanner(
controller: controller,
fit: BoxFit.contain,
onDetect: (barcode) {
setState(() {
this.barcode = barcode;
});
},
),
Align(
alignment: Alignment.bottomCenter,
child: Container(
alignment: Alignment.bottomCenter,
height: 100,
color: Colors.black.withOpacity(0.4),
child: Column(
children: [
Slider(
value: _zoomFactor,
onChanged: (value) {
setState(() {
_zoomFactor = value;
controller.setZoomScale(value);
});
},
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
color: Colors.white,
icon: ValueListenableBuilder(
valueListenable: controller.torchState,
builder: (context, state, child) {
if (state == null) {
return const Icon(
Icons.flash_off,
color: Colors.grey,
);
}
switch (state as TorchState) {
case TorchState.off:
return const Icon(
Icons.flash_off,
color: Colors.grey,
);
case TorchState.on:
return const Icon(
Icons.flash_on,
color: Colors.yellow,
);
}
},
),
iconSize: 32.0,
onPressed: () => controller.toggleTorch(),
),
IconButton(
color: Colors.white,
icon: isStarted
? const Icon(Icons.stop)
: const Icon(Icons.play_arrow),
iconSize: 32.0,
onPressed: () => setState(() {
isStarted
? controller.stop()
: controller.start();
isStarted = !isStarted;
}),
),
Center(
child: SizedBox(
width: MediaQuery.of(context).size.width - 200,
height: 50,
child: FittedBox(
child: Text(
barcode?.barcodes.first.rawValue ??
'Scan something!',
overflow: TextOverflow.fade,
style: Theme.of(context)
.textTheme
.headline4!
.copyWith(color: Colors.white),
),
),
),
),
IconButton(
color: Colors.white,
icon: ValueListenableBuilder(
valueListenable: controller.cameraFacingState,
builder: (context, state, child) {
if (state == null) {
return const Icon(Icons.camera_front);
}
switch (state as CameraFacing) {
case CameraFacing.front:
return const Icon(Icons.camera_front);
case CameraFacing.back:
return const Icon(Icons.camera_rear);
}
},
),
iconSize: 32.0,
onPressed: () => controller.switchCamera(),
),
IconButton(
color: Colors.white,
icon: const Icon(Icons.image),
iconSize: 32.0,
onPressed: () async {
final ImagePicker picker = ImagePicker();
// Pick an image
final XFile? image = await picker.pickImage(
source: ImageSource.gallery,
);
if (image != null) {
if (await controller.analyzeImage(image.path)) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Barcode found!'),
backgroundColor: Colors.green,
),
);
} else {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No barcode found!'),
backgroundColor: Colors.red,
),
);
}
}
},
),
],
),
],
),
),
),
],
);
},
),
);
}
}
... ...
... ... @@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
import 'package:mobile_scanner_example/barcode_list_scanner_controller.dart';
import 'package:mobile_scanner_example/barcode_scanner_controller.dart';
import 'package:mobile_scanner_example/barcode_scanner_returning_image.dart';
import 'package:mobile_scanner_example/barcode_scanner_window.dart';
import 'package:mobile_scanner_example/barcode_scanner_without_controller.dart';
import 'package:mobile_scanner_example/barcode_scanner_zoom.dart';
void main() => runApp(const MaterialApp(home: MyHome()));
... ... @@ -44,6 +46,16 @@ class MyHome extends StatelessWidget {
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const BarcodeScannerWithScanWindow(),
),
);
},
child: const Text('MobileScanner with ScanWindow'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const BarcodeScannerReturningImage(),
),
);
... ... @@ -62,6 +74,16 @@ class MyHome extends StatelessWidget {
},
child: const Text('MobileScanner without Controller'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const BarcodeScannerWithZoom(),
),
);
},
child: const Text('MobileScanner with zoom slider'),
),
],
),
),
... ...
... ... @@ -12,6 +12,7 @@ import MLKitVision
import MLKitBarcodeScanning
typealias MobileScannerCallback = ((Array<Barcode>?, Error?, UIImage) -> ())
typealias TorchModeChangeCallback = ((Int?) -> ())
public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate, FlutterTexture {
/// Capture session of the camera
... ... @@ -32,6 +33,9 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega
/// When results are found, this callback will be called
let mobileScannerCallback: MobileScannerCallback
/// When torch mode is changes, this callback will be called
let torchModeChangeCallback: TorchModeChangeCallback
/// If provided, the Flutter registry will be used to send the output of the CaptureOutput to a Flutter texture.
private let registry: FlutterTextureRegistry?
... ... @@ -43,9 +47,10 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega
var detectionSpeed: DetectionSpeed = DetectionSpeed.noDuplicates
init(registry: FlutterTextureRegistry?, mobileScannerCallback: @escaping MobileScannerCallback) {
init(registry: FlutterTextureRegistry?, mobileScannerCallback: @escaping MobileScannerCallback, torchModeChangeCallback: @escaping TorchModeChangeCallback) {
self.registry = registry
self.mobileScannerCallback = mobileScannerCallback
self.torchModeChangeCallback = torchModeChangeCallback
super.init()
}
... ... @@ -127,17 +132,6 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega
throw MobileScannerError.noCamera
}
// Enable the torch if parameter is set and torch is available
if (device.hasTorch && device.isTorchAvailable) {
do {
try device.lockForConfiguration()
device.torchMode = torch
device.unlockForConfiguration()
} catch {
throw MobileScannerError.torchError(error)
}
}
device.addObserver(self, forKeyPath: #keyPath(AVCaptureDevice.torchMode), options: .new, context: nil)
captureSession.beginConfiguration()
... ... @@ -169,6 +163,13 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega
}
captureSession.commitConfiguration()
captureSession.startRunning()
// Enable the torch if parameter is set and torch is available
// torch should be set after 'startRunning' is called
do {
try toggleTorch(torch)
} catch {
print("Failed to set initial torch state.")
}
let dimensions = CMVideoFormatDescriptionGetDimensions(device.activeFormat.formatDescription)
return MobileScannerStartParameters(width: Double(dimensions.height), height: Double(dimensions.width), hasTorch: device.hasTorch, textureId: textureId)
... ... @@ -198,13 +199,54 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega
if (device == nil) {
throw MobileScannerError.torchWhenStopped
}
if (device.hasTorch && device.isTorchAvailable) {
do {
try device.lockForConfiguration()
device.torchMode = torch
device.unlockForConfiguration()
} catch {
throw MobileScannerError.torchError(error)
}
}
}
// Observer for torch state
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
switch keyPath {
case "torchMode":
// off = 0; on = 1; auto = 2;
let state = change?[.newKey] as? Int
torchModeChangeCallback(state)
default:
break
}
}
/// Set the zoom factor of the camera
func setScale(_ scale: CGFloat) throws {
if (device == nil) {
throw MobileScannerError.torchWhenStopped
}
do {
try device.lockForConfiguration()
device.torchMode = torch
var maxZoomFactor = device.activeFormat.videoMaxZoomFactor
var actualScale = (scale * 4) + 1
// Set maximum zoomrate of 5x
actualScale = min(5.0, actualScale)
// Limit to max rate of camera
actualScale = min(maxZoomFactor, actualScale)
// Limit to 1.0 scale
device.ramp(toVideoZoomFactor: actualScale, withRate: 5)
device.unlockForConfiguration()
} catch {
throw MobileScannerError.torchError(error)
throw MobileScannerError.zoomError(error)
}
}
/// Analyze a single image
... ...
... ... @@ -13,5 +13,7 @@ enum MobileScannerError: Error {
case torchError(_ error: Error)
case cameraError(_ error: Error)
case torchWhenStopped
case zoomWhenStopped
case zoomError(_ error: Error)
case analyzerError(_ error: Error)
}
... ...
... ... @@ -2,6 +2,7 @@ import Flutter
import MLKitVision
import MLKitBarcodeScanning
import AVFoundation
import UIKit
public class SwiftMobileScannerPlugin: NSObject, FlutterPlugin {
... ... @@ -10,19 +11,52 @@ public class SwiftMobileScannerPlugin: NSObject, FlutterPlugin {
/// The handler sends all information via an event channel back to Flutter
private let barcodeHandler: BarcodeHandler
static var scanWindow: [CGFloat]?
private static func isBarcodeInScanWindow(barcode: Barcode, imageSize: CGSize) -> Bool {
let scanwindow = SwiftMobileScannerPlugin.scanWindow!
let barcodeminX = barcode.cornerPoints![0].cgPointValue.x
let barcodeminY = barcode.cornerPoints![1].cgPointValue.y
let barcodewidth = barcode.cornerPoints![2].cgPointValue.x - barcodeminX
let barcodeheight = barcode.cornerPoints![3].cgPointValue.y - barcodeminY
let barcodeBox = CGRect(x: barcodeminX, y: barcodeminY, width: barcodewidth, height: barcodeheight)
let minX = scanwindow[0] * imageSize.width
let minY = scanwindow[1] * imageSize.height
let width = (scanwindow[2] * imageSize.width) - minX
let height = (scanwindow[3] * imageSize.height) - minY
let scaledWindow = CGRect(x: minX, y: minY, width: width, height: height)
return scaledWindow.contains(barcodeBox)
}
init(barcodeHandler: BarcodeHandler, registry: FlutterTextureRegistry) {
self.mobileScanner = MobileScanner(registry: registry, mobileScannerCallback: { barcodes, error, image in
if barcodes != nil {
let barcodesMap = barcodes!.map { barcode in
return barcode.data
let barcodesMap = barcodes!.compactMap { barcode in
if (SwiftMobileScannerPlugin.scanWindow != nil) {
if (SwiftMobileScannerPlugin.isBarcodeInScanWindow(barcode: barcode, imageSize: image.size)) {
return barcode.data
} else {
return nil
}
} else {
return barcode.data
}
}
if (!barcodesMap.isEmpty) {
barcodeHandler.publishEvent(["name": "barcode", "data": barcodesMap, "image": FlutterStandardTypedData(bytes: image.jpegData(compressionQuality: 0.8)!)])
barcodeHandler.publishEvent(["name": "barcode", "data": barcodesMap, "image": FlutterStandardTypedData(bytes: image.jpegData(compressionQuality: 0.8)!), "width": image.size.width, "height": image.size.height])
}
} else if (error != nil){
barcodeHandler.publishEvent(["name": "error", "data": error!.localizedDescription])
}
}, torchModeChangeCallback: { torchState in
barcodeHandler.publishEvent(["name": "torchState", "data": torchState])
})
self.barcodeHandler = barcodeHandler
super.init()
... ... @@ -49,6 +83,10 @@ public class SwiftMobileScannerPlugin: NSObject, FlutterPlugin {
toggleTorch(call, result)
case "analyzeImage":
analyzeImage(call, result)
case "setScale":
setScale(call, result)
case "updateScanWindow":
updateScanWindow(call, result)
default:
result(FlutterMethodNotImplemented)
}
... ... @@ -64,7 +102,7 @@ public class SwiftMobileScannerPlugin: NSObject, FlutterPlugin {
let formatList = formats.map { format in return BarcodeFormat(rawValue: format)}
var barcodeOptions: BarcodeScannerOptions? = nil
if (formatList.count != 0) {
var barcodeFormats: BarcodeFormat = []
for index in formats {
... ... @@ -123,6 +161,55 @@ public class SwiftMobileScannerPlugin: NSObject, FlutterPlugin {
result(nil)
}
/// Toggles the zoomScale
private func setScale(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) {
var scale = call.arguments as? CGFloat
if (scale == nil) {
result(FlutterError(code: "MobileScanner",
message: "You must provide a scale when calling setScale!",
details: nil))
return
}
do {
try mobileScanner.setScale(scale!)
} catch MobileScannerError.zoomWhenStopped {
result(FlutterError(code: "MobileScanner",
message: "Called setScale() while stopped!",
details: nil))
} catch MobileScannerError.zoomError(let error) {
result(FlutterError(code: "MobileScanner",
message: "Error while zooming.",
details: error))
} catch {
result(FlutterError(code: "MobileScanner",
message: "Error while zooming.",
details: nil))
}
result(nil)
}
/// Toggles the torch
func updateScanWindow(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) {
let scanWindowData: Array? = (call.arguments as? [String: Any])?["rect"] as? [CGFloat]
SwiftMobileScannerPlugin.scanWindow = scanWindowData
result(nil)
}
static func arrayToRect(scanWindowData: [CGFloat]?) -> CGRect? {
if (scanWindowData == nil) {
return nil
}
let minX = scanWindowData![0]
let minY = scanWindowData![1]
let width = scanWindowData![2] - minX
let height = scanWindowData![3] - minY
return CGRect(x: minX, y: minY, width: width, height: height)
}
/// Analyzes a single image
private func analyzeImage(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) {
let uiImage = UIImage(contentsOfFile: call.arguments as? String ?? "")
... ... @@ -145,16 +232,4 @@ public class SwiftMobileScannerPlugin: NSObject, FlutterPlugin {
})
result(nil)
}
/// Observer for torch state
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
switch keyPath {
case "torchMode":
// off = 0; on = 1; auto = 2;
let state = change?[.newKey] as? Int
barcodeHandler.publishEvent(["name": "torchState", "data": state])
default:
break
}
}
}
... ...
... ... @@ -5,7 +5,9 @@ import 'dart:ui' as ui;
import 'package:flutter/services.dart';
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
import 'package:mobile_scanner/mobile_scanner_web.dart';
import 'package:mobile_scanner/src/barcode_utility.dart';
import 'package:mobile_scanner/src/enums/camera_facing.dart';
import 'package:mobile_scanner/src/objects/barcode.dart';
/// This plugin is the web implementation of mobile_scanner.
/// It only supports QR codes.
... ... @@ -35,6 +37,17 @@ class MobileScannerWebPlugin {
static final html.DivElement vidDiv = html.DivElement();
/// Represents barcode reader library.
/// Change this property if you want to use a custom implementation.
///
/// Example of using the jsQR library:
/// void main() {
/// if (kIsWeb) {
/// MobileScannerWebPlugin.barCodeReader =
/// JsQrCodeReader(videoContainer: MobileScannerWebPlugin.vidDiv);
/// }
/// runApp(const MaterialApp(home: MyHome()));
/// }
static WebBarcodeReaderBase barCodeReader =
ZXingBarcodeReader(videoContainer: vidDiv);
StreamSubscription? _barCodeStreamSubscription;
... ... @@ -82,16 +95,32 @@ class MobileScannerWebPlugin {
// Check if stream is running
if (barCodeReader.isStarted) {
final hasTorch = await barCodeReader.hasTorch();
return {
'ViewID': viewID,
'videoWidth': barCodeReader.videoWidth,
'videoHeight': barCodeReader.videoHeight,
'torchable': barCodeReader.hasTorch,
'torchable': hasTorch,
};
}
try {
List<BarcodeFormat>? formats;
if (arguments.containsKey('formats')) {
formats = (arguments['formats'] as List)
.cast<int>()
.map((e) => toFormat(e))
.toList();
}
final Duration? detectionTimeout;
if (arguments.containsKey('timeout')) {
detectionTimeout = Duration(milliseconds: arguments['timeout'] as int);
} else {
detectionTimeout = null;
}
await barCodeReader.start(
cameraFacing: cameraFacing,
formats: formats,
detectionTimeout: detectionTimeout,
);
_barCodeStreamSubscription =
... ... @@ -102,16 +131,22 @@ class MobileScannerWebPlugin {
'data': {
'rawValue': code.rawValue,
'rawBytes': code.rawBytes,
'format': code.format.rawValue,
},
});
}
});
final hasTorch = await barCodeReader.hasTorch();
if (hasTorch && arguments.containsKey('torch')) {
barCodeReader.toggleTorch(enabled: arguments['torch'] as bool);
}
return {
'ViewID': viewID,
'videoWidth': barCodeReader.videoWidth,
'videoHeight': barCodeReader.videoHeight,
'torchable': barCodeReader.hasTorch,
'torchable': hasTorch,
};
} catch (e) {
throw PlatformException(code: 'MobileScannerWeb', message: '$e');
... ...
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
... ... @@ -147,3 +149,79 @@ WiFi? toWiFi(Map? data) {
return null;
}
}
Size applyBoxFit(BoxFit fit, Size input, Size output) {
if (input.height <= 0.0 ||
input.width <= 0.0 ||
output.height <= 0.0 ||
output.width <= 0.0) {
return Size.zero;
}
Size destination;
final inputAspectRatio = input.width / input.height;
final outputAspectRatio = output.width / output.height;
switch (fit) {
case BoxFit.fill:
destination = output;
break;
case BoxFit.contain:
if (outputAspectRatio > inputAspectRatio) {
destination = Size(
input.width * output.height / input.height,
output.height,
);
} else {
destination = Size(
output.width,
input.height * output.width / input.width,
);
}
break;
case BoxFit.cover:
if (outputAspectRatio > inputAspectRatio) {
destination = Size(
output.width,
input.height * (output.width / input.width),
);
} else {
destination = Size(
input.width * (output.height / input.height),
output.height,
);
}
break;
case BoxFit.fitWidth:
destination = Size(
output.width,
input.height * (output.width / input.width),
);
break;
case BoxFit.fitHeight:
destination = Size(
input.width * (output.height / input.height),
output.height,
);
break;
case BoxFit.none:
destination = Size(
math.min(input.width, output.width),
math.min(input.height, output.height),
);
break;
case BoxFit.scaleDown:
destination = input;
if (destination.height > output.height) {
destination = Size(output.height * inputAspectRatio, output.height);
}
if (destination.width > output.width) {
destination = Size(output.width, output.width / inputAspectRatio);
}
break;
}
return destination;
}
... ...
... ... @@ -49,6 +49,13 @@ class MobileScanner extends StatefulWidget {
/// If this is null, a black [ColoredBox] is used as placeholder.
final Widget Function(BuildContext, Widget?)? placeholderBuilder;
/// if set barcodes will only be scanned if they fall within this [Rect]
/// useful for having a cut-out overlay for example. these [Rect]
/// coordinates are relative to the widget size, so by how much your
/// rectangle overlays the actual image can depend on things like the
/// [BoxFit]
final Rect? scanWindow;
/// Create a new [MobileScanner] using the provided [controller]
/// and [onBarcodeDetected] callback.
const MobileScanner({
... ... @@ -59,6 +66,7 @@ class MobileScanner extends StatefulWidget {
@Deprecated('Use onScannerStarted() instead.') this.onStart,
this.onScannerStarted,
this.placeholderBuilder,
this.scanWindow,
super.key,
});
... ... @@ -156,33 +164,101 @@ class _MobileScannerState extends State<MobileScanner>
}
}
/// the [scanWindow] rect will be relative and scaled to the [widgetSize] not the texture. so it is possible,
/// depending on the [fit], for the [scanWindow] to partially or not at all overlap the [textureSize]
///
/// since when using a [BoxFit] the content will always be centered on its parent. we can convert the rect
/// to be relative to the texture.
///
/// since the textures size and the actuall image (on the texture size) might not be the same, we also need to
/// calculate the scanWindow in terms of percentages of the texture, not pixels.
Rect calculateScanWindowRelativeToTextureInPercentage(
BoxFit fit,
Rect scanWindow,
Size textureSize,
Size widgetSize,
) {
/// map the texture size to get its new size after fitted to screen
final fittedTextureSize = applyBoxFit(fit, textureSize, widgetSize);
/// create a new rectangle that represents the texture on the screen
final minX = widgetSize.width / 2 - fittedTextureSize.destination.width / 2;
final minY =
widgetSize.height / 2 - fittedTextureSize.destination.height / 2;
final textureWindow = Offset(minX, minY) & fittedTextureSize.destination;
/// create a new scan window and with only the area of the rect intersecting the texture window
final scanWindowInTexture = scanWindow.intersect(textureWindow);
/// update the scanWindow left and top to be relative to the texture not the widget
final newLeft = scanWindowInTexture.left - textureWindow.left;
final newTop = scanWindowInTexture.top - textureWindow.top;
final newWidth = scanWindowInTexture.width;
final newHeight = scanWindowInTexture.height;
/// new scanWindow that is adapted to the boxfit and relative to the texture
final windowInTexture = Rect.fromLTWH(newLeft, newTop, newWidth, newHeight);
/// get the scanWindow as a percentage of the texture
final percentageLeft =
windowInTexture.left / fittedTextureSize.destination.width;
final percentageTop =
windowInTexture.top / fittedTextureSize.destination.height;
final percentageRight =
windowInTexture.right / fittedTextureSize.destination.width;
final percentagebottom =
windowInTexture.bottom / fittedTextureSize.destination.height;
/// this rectangle can be send to native code and used to cut out a rectangle of the scan image
return Rect.fromLTRB(
percentageLeft,
percentageTop,
percentageRight,
percentagebottom,
);
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<MobileScannerArguments?>(
valueListenable: _controller.startArguments,
builder: (context, value, child) {
if (value == null) {
return __buildPlaceholderOrError(context, child);
}
return LayoutBuilder(
builder: (context, constraints) {
return ValueListenableBuilder<MobileScannerArguments?>(
valueListenable: _controller.startArguments,
builder: (context, value, child) {
if (value == null) {
return __buildPlaceholderOrError(context, child);
}
return ClipRect(
child: LayoutBuilder(
builder: (_, constraints) {
return SizedBox.fromSize(
size: constraints.biggest,
child: FittedBox(
fit: widget.fit,
child: SizedBox(
width: value.size.width,
height: value.size.height,
child: kIsWeb
? HtmlElementView(viewType: value.webId!)
: Texture(textureId: value.textureId!),
),
),
if (widget.scanWindow != null) {
final window = calculateScanWindowRelativeToTextureInPercentage(
widget.fit,
widget.scanWindow!,
value.size,
Size(constraints.maxWidth, constraints.maxHeight),
);
},
),
_controller.updateScanWindow(window);
}
return ClipRect(
child: LayoutBuilder(
builder: (_, constraints) {
return SizedBox.fromSize(
size: constraints.biggest,
child: FittedBox(
fit: widget.fit,
child: SizedBox(
width: value.size.width,
height: value.size.height,
child: kIsWeb
? HtmlElementView(viewType: value.webId!)
: Texture(textureId: value.textureId!),
),
),
);
},
),
);
},
);
},
);
... ...
... ... @@ -99,19 +99,21 @@ class MobileScannerController {
bool isStarting = false;
bool? _hasTorch;
/// A notifier that provides availability of the Torch (Flash)
final ValueNotifier<bool?> hasTorchState = ValueNotifier(false);
/// Returns whether the device has a torch.
///
/// Throws an error if the controller is not initialized.
bool get hasTorch {
if (_hasTorch == null) {
final hasTorch = hasTorchState.value;
if (hasTorch == null) {
throw const MobileScannerException(
errorCode: MobileScannerErrorCode.controllerUninitialized,
);
}
return _hasTorch!;
return hasTorch;
}
/// Set the starting arguments for the camera
... ... @@ -124,11 +126,20 @@ class MobileScannerController {
arguments['speed'] = detectionSpeed.index;
arguments['timeout'] = detectionTimeoutMs;
/* if (scanWindow != null) {
arguments['scanWindow'] = [
scanWindow!.left,
scanWindow!.top,
scanWindow!.right,
scanWindow!.bottom,
];
} */
if (formats != null) {
if (Platform.isAndroid) {
arguments['formats'] = formats!.map((e) => e.index).toList();
} else if (Platform.isIOS || Platform.isMacOS) {
if (kIsWeb || Platform.isIOS || Platform.isMacOS) {
arguments['formats'] = formats!.map((e) => e.rawValue).toList();
} else if (Platform.isAndroid) {
arguments['formats'] = formats!.map((e) => e.index).toList();
}
}
arguments['returnImage'] = true;
... ... @@ -221,8 +232,9 @@ class MobileScannerController {
);
}
_hasTorch = startResult['torchable'] as bool? ?? false;
if (_hasTorch! && torchEnabled) {
final hasTorch = startResult['torchable'] as bool? ?? false;
hasTorchState.value = hasTorch;
if (hasTorch && torchEnabled) {
torchState.value = TorchState.on;
}
... ... @@ -234,7 +246,7 @@ class MobileScannerController {
startResult['videoHeight'] as double? ?? 0,
)
: toSize(startResult['size'] as Map? ?? {}),
hasTorch: _hasTorch!,
hasTorch: hasTorch,
textureId: kIsWeb ? null : startResult['textureId'] as int?,
webId: kIsWeb ? startResult['ViewID'] as String? : null,
);
... ... @@ -255,7 +267,7 @@ class MobileScannerController {
///
/// Throws if the controller was not initialized.
Future<void> toggleTorch() async {
final hasTorch = _hasTorch;
final hasTorch = hasTorchState.value;
if (hasTorch == null) {
throw const MobileScannerException(
... ... @@ -294,6 +306,22 @@ class MobileScannerController {
.then<bool>((bool? value) => value ?? false);
}
/// Set the zoomScale of the camera.
///
/// [zoomScale] must be within 0.0 and 1.0, where 1.0 is the max zoom, and 0.0
/// is zoomed out.
Future<void> setZoomScale(double zoomScale) async {
if (zoomScale < 0 || zoomScale > 1) {
throw const MobileScannerException(
errorCode: MobileScannerErrorCode.genericError,
errorDetails: MobileScannerErrorDetails(
message: 'The zoomScale must be between 0 and 1.',
),
);
}
await _methodChannel.invokeMethod('setScale', zoomScale);
}
/// Disposes the MobileScannerController and closes all listeners.
///
/// If you call this, you cannot use this controller object anymore.
... ... @@ -325,6 +353,8 @@ class MobileScannerController {
BarcodeCapture(
barcodes: parsed,
image: event['image'] as Uint8List?,
width: event['width'] as double?,
height: event['height'] as double?,
),
);
break;
... ... @@ -344,10 +374,12 @@ class MobileScannerController {
_barcodesController.add(
BarcodeCapture(
barcodes: [
Barcode(
rawValue: barcode?['rawValue'] as String?,
rawBytes: barcode?['rawBytes'] as Uint8List?,
)
if (barcode != null)
Barcode(
rawValue: barcode['rawValue'] as String?,
rawBytes: barcode['rawBytes'] as Uint8List?,
format: toFormat(barcode['format'] as int),
),
],
),
);
... ... @@ -361,4 +393,10 @@ class MobileScannerController {
throw UnimplementedError(name as String?);
}
}
/// updates the native scanwindow
Future<void> updateScanWindow(Rect window) async {
final data = [window.left, window.top, window.right, window.bottom];
await _methodChannel.invokeMethod('updateScanWindow', {'rect': data});
}
}
... ...
... ... @@ -12,8 +12,14 @@ class BarcodeCapture {
final Uint8List? image;
final double? width;
final double? height;
BarcodeCapture({
required this.barcodes,
this.image,
this.width,
this.height,
});
}
... ...
import 'dart:html';
import 'dart:html' as html;
import 'package:flutter/material.dart';
import 'package:js/js.dart';
import 'package:js/js_util.dart';
import 'package:mobile_scanner/src/enums/camera_facing.dart';
import 'package:mobile_scanner/src/objects/barcode.dart';
import 'package:mobile_scanner/src/web/media.dart';
abstract class WebBarcodeReaderBase {
/// Timer used to capture frames to be analyzed
final Duration frameInterval;
final DivElement videoContainer;
Duration frameInterval = const Duration(milliseconds: 200);
final html.DivElement videoContainer;
const WebBarcodeReaderBase({
WebBarcodeReaderBase({
required this.videoContainer,
this.frameInterval = const Duration(milliseconds: 200),
});
bool get isStarted;
... ... @@ -23,6 +24,8 @@ abstract class WebBarcodeReaderBase {
/// Starts streaming video
Future<void> start({
required CameraFacing cameraFacing,
List<BarcodeFormat>? formats,
Duration? detectionTimeout,
});
/// Starts scanning QR codes or barcodes
... ... @@ -35,24 +38,24 @@ abstract class WebBarcodeReaderBase {
Future<void> toggleTorch({required bool enabled});
/// Determine whether device has flash
bool get hasTorch;
Future<bool> hasTorch();
}
mixin InternalStreamCreation on WebBarcodeReaderBase {
/// The video stream.
/// Will be initialized later to see which camera needs to be used.
MediaStream? localMediaStream;
final VideoElement video = VideoElement();
html.MediaStream? localMediaStream;
final html.VideoElement video = html.VideoElement();
@override
int get videoWidth => video.videoWidth;
@override
int get videoHeight => video.videoHeight;
Future<MediaStream?> initMediaStream(CameraFacing cameraFacing) async {
Future<html.MediaStream?> initMediaStream(CameraFacing cameraFacing) async {
// Check if browser supports multiple camera's and set if supported
final Map? capabilities =
window.navigator.mediaDevices?.getSupportedConstraints();
html.window.navigator.mediaDevices?.getSupportedConstraints();
final Map<String, dynamic> constraints;
if (capabilities != null && capabilities['facingMode'] as bool) {
constraints = {
... ... @@ -65,15 +68,15 @@ mixin InternalStreamCreation on WebBarcodeReaderBase {
constraints = {'video': true};
}
final stream =
await window.navigator.mediaDevices?.getUserMedia(constraints);
await html.window.navigator.mediaDevices?.getUserMedia(constraints);
return stream;
}
void prepareVideoElement(VideoElement videoSource);
void prepareVideoElement(html.VideoElement videoSource);
Future<void> attachStreamToVideo(
MediaStream stream,
VideoElement videoSource,
html.MediaStream stream,
html.VideoElement videoSource,
);
@override
... ... @@ -96,19 +99,34 @@ mixin InternalStreamCreation on WebBarcodeReaderBase {
/// Mixin for libraries that don't have built-in torch support
mixin InternalTorchDetection on InternalStreamCreation {
Future<List<String>> getSupportedTorchStates() async {
try {
final track = localMediaStream?.getVideoTracks();
if (track != null) {
final imageCapture = ImageCapture(track.first);
final photoCapabilities = await promiseToFuture<PhotoCapabilities>(
imageCapture.getPhotoCapabilities(),
);
final fillLightMode = photoCapabilities.fillLightMode;
if (fillLightMode != null) {
return fillLightMode;
}
}
} catch (e) {
// ImageCapture is not supported by some browsers:
// https://developer.mozilla.org/en-US/docs/Web/API/ImageCapture#browser_compatibility
}
return [];
}
@override
bool get hasTorch {
// TODO: fix flash light. See https://github.com/dart-lang/sdk/issues/48533
// final track = _localStream?.getVideoTracks();
// if (track != null) {
// final imageCapture = html.ImageCapture(track.first);
// final photoCapabilities = await imageCapture.getPhotoCapabilities();
// }
return false;
Future<bool> hasTorch() async {
return (await getSupportedTorchStates()).isNotEmpty;
}
@override
Future<void> toggleTorch({required bool enabled}) async {
final hasTorch = await this.hasTorch();
if (hasTorch) {
final track = localMediaStream?.getVideoTracks();
await track?.first.applyConstraints({
... ... @@ -119,3 +137,36 @@ mixin InternalTorchDetection on InternalStreamCreation {
}
}
}
@JS('Promise')
@staticInterop
class Promise<T> {}
@JS()
@anonymous
class PhotoCapabilities {
/// Returns an array of available fill light options. Options include auto, off, or flash.
external List<String>? get fillLightMode;
}
@JS('ImageCapture')
@staticInterop
class ImageCapture {
/// MediaStreamTrack
external factory ImageCapture(dynamic track);
}
extension ImageCaptureExt on ImageCapture {
external Promise<PhotoCapabilities> getPhotoCapabilities();
}
@JS('Map')
@staticInterop
class JsMap {
external factory JsMap();
}
extension JsMapExt on JsMap {
external void set(dynamic key, dynamic value);
external dynamic get(dynamic key);
}
... ...
... ... @@ -20,6 +20,11 @@ class Code {
external Uint8ClampedList get binaryData;
}
/// Barcode reader that uses jsQR library.
/// jsQR supports only QR codes format.
///
/// Include jsQR to your index.html file:
/// <script src="https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js"></script>
class JsQrCodeReader extends WebBarcodeReaderBase
with InternalStreamCreation, InternalTorchDetection {
JsQrCodeReader({required super.videoContainer});
... ... @@ -30,9 +35,15 @@ class JsQrCodeReader extends WebBarcodeReaderBase
@override
Future<void> start({
required CameraFacing cameraFacing,
List<BarcodeFormat>? formats,
Duration? detectionTimeout,
}) async {
videoContainer.children = [video];
if (detectionTimeout != null) {
frameInterval = detectionTimeout;
}
final stream = await initMediaStream(cameraFacing);
prepareVideoElement(video);
... ...
... ... @@ -7,10 +7,6 @@ import 'package:mobile_scanner/src/enums/camera_facing.dart';
import 'package:mobile_scanner/src/objects/barcode.dart';
import 'package:mobile_scanner/src/web/base.dart';
@JS('Promise')
@staticInterop
class Promise<T> {}
@JS('ZXing.BrowserMultiFormatReader')
@staticInterop
class JsZXingBrowserMultiFormatReader {
... ... @@ -47,12 +43,14 @@ extension ResultExt on Result {
/// https://github.com/zxing-js/library/blob/1e9ccb3b6b28d75b9eef866dba196d8937eb4449/src/core/BarcodeFormat.ts#L28
BarcodeFormat get barcodeFormat {
switch (format) {
case 1:
case 0:
return BarcodeFormat.aztec;
case 2:
case 1:
return BarcodeFormat.codebar;
case 3:
case 2:
return BarcodeFormat.code39;
case 3:
return BarcodeFormat.code93;
case 4:
return BarcodeFormat.code128;
case 5:
... ... @@ -83,6 +81,42 @@ extension ResultExt on Result {
}
}
extension ZXingBarcodeFormat on BarcodeFormat {
int get zxingBarcodeFormat {
switch (this) {
case BarcodeFormat.aztec:
return 0;
case BarcodeFormat.codebar:
return 1;
case BarcodeFormat.code39:
return 2;
case BarcodeFormat.code93:
return 3;
case BarcodeFormat.code128:
return 4;
case BarcodeFormat.dataMatrix:
return 5;
case BarcodeFormat.ean8:
return 6;
case BarcodeFormat.ean13:
return 7;
case BarcodeFormat.itf:
return 8;
case BarcodeFormat.pdf417:
return 10;
case BarcodeFormat.qrCode:
return 11;
case BarcodeFormat.upcA:
return 14;
case BarcodeFormat.upcE:
return 15;
case BarcodeFormat.unknown:
case BarcodeFormat.all:
return -1;
}
}
}
typedef BarcodeDetectionCallback = void Function(
Result? result,
dynamic error,
... ... @@ -134,13 +168,13 @@ extension JsZXingBrowserMultiFormatReaderExt
external MediaStream? stream;
}
/// Barcode reader that uses zxing-js library.
///
/// Include zxing-js to your index.html file:
/// <script type="text/javascript" src="https://unpkg.com/@zxing/library@0.19.1"></script>
class ZXingBarcodeReader extends WebBarcodeReaderBase
with InternalStreamCreation, InternalTorchDetection {
late final JsZXingBrowserMultiFormatReader _reader =
JsZXingBrowserMultiFormatReader(
null,
frameInterval.inMilliseconds,
);
JsZXingBrowserMultiFormatReader? _reader;
ZXingBarcodeReader({required super.videoContainer});
... ... @@ -150,7 +184,27 @@ class ZXingBarcodeReader extends WebBarcodeReaderBase
@override
Future<void> start({
required CameraFacing cameraFacing,
List<BarcodeFormat>? formats,
Duration? detectionTimeout,
}) async {
final JsMap? hints;
if (formats != null && !formats.contains(BarcodeFormat.all)) {
hints = JsMap();
final zxingFormats =
formats.map((e) => e.zxingBarcodeFormat).where((e) => e > 0).toList();
// set hint DecodeHintType.POSSIBLE_FORMATS
// https://github.com/zxing-js/library/blob/1e9ccb3b6b28d75b9eef866dba196d8937eb4449/src/core/DecodeHintType.ts#L28
hints.set(2, zxingFormats);
} else {
hints = null;
}
if (detectionTimeout != null) {
frameInterval = detectionTimeout;
}
_reader = JsZXingBrowserMultiFormatReader(
hints,
frameInterval.inMilliseconds,
);
videoContainer.children = [video];
final stream = await initMediaStream(cameraFacing);
... ... @@ -163,7 +217,7 @@ class ZXingBarcodeReader extends WebBarcodeReaderBase
@override
void prepareVideoElement(VideoElement videoSource) {
_reader.prepareVideoElement(videoSource);
_reader?.prepareVideoElement(videoSource);
}
@override
... ... @@ -171,9 +225,9 @@ class ZXingBarcodeReader extends WebBarcodeReaderBase
MediaStream stream,
VideoElement videoSource,
) async {
_reader.addVideoSource(videoSource, stream);
_reader.videoElement = videoSource;
_reader.stream = stream;
_reader?.addVideoSource(videoSource, stream);
_reader?.videoElement = videoSource;
_reader?.stream = stream;
localMediaStream = stream;
await videoSource.play();
}
... ... @@ -182,7 +236,7 @@ class ZXingBarcodeReader extends WebBarcodeReaderBase
Stream<Barcode?> detectBarcodeContinuously() {
final controller = StreamController<Barcode?>();
controller.onListen = () async {
_reader.decodeContinuously(
_reader?.decodeContinuously(
video,
allowInterop((result, error) {
if (result != null) {
... ... @@ -192,14 +246,14 @@ class ZXingBarcodeReader extends WebBarcodeReaderBase
);
};
controller.onCancel = () {
_reader.stopContinuousDecode();
_reader?.stopContinuousDecode();
};
return controller.stream;
}
@override
Future<void> stop() async {
_reader.reset();
_reader?.reset();
super.stop();
}
}
... ...
import AVFoundation
import FlutterMacOS
import Vision
import UIKit
public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, FlutterTexture, AVCaptureVideoDataOutputSampleBufferDelegate {
... ... @@ -20,6 +21,9 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler,
// Image to be sent to the texture
var latestBuffer: CVImageBuffer!
// optional window to limit scan search
var scanWindow: CGRect?
// var analyzeMode: Int = 0
... ... @@ -57,6 +61,8 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler,
// switchAnalyzeMode(call, result)
case "stop":
stop(result)
case "updateScanWindow":
updateScanWindow(call)
default:
result(FlutterMethodNotImplemented)
}
... ... @@ -109,10 +115,17 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler,
try imageRequestHandler.perform([VNDetectBarcodesRequest { (request, error) in
if error == nil {
if let results = request.results as? [VNBarcodeObservation] {
for barcode in results {
let barcodeType = String(barcode.symbology.rawValue).replacingOccurrences(of: "VNBarcodeSymbology", with: "")
let event: [String: Any?] = ["name": "barcodeMac", "data" : ["payload": barcode.payloadStringValue, "symbology": barcodeType]]
self.sink?(event)
for barcode in results {
if scanWindow != nil {
let match = isbarCodeInScanWindow(scanWindow!, barcode, buffer!.image)
if (!match) {
continue
}
}
let barcodeType = String(barcode.symbology.rawValue).replacingOccurrences(of: "VNBarcodeSymbology", with: "")
let event: [String: Any?] = ["name": "barcodeMac", "data" : ["payload": barcode.payloadStringValue, "symbology": barcodeType]]
self.sink?(event)
// if barcodeType == "QR" {
// let image = CIImage(image: source)
... ... @@ -158,6 +171,38 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler,
}
}
func updateScanWindow(_ call: FlutterMethodCall) {
let argReader = MapArgumentReader(call.arguments as? [String: Any])
let scanWindowData: Array? = argReader.floatArray(key: "rect")
if (scanWindowData == nil) {
return
}
let minX = scanWindowData![0]
let minY = scanWindowData![1]
let width = scanWindowData![2] - minX
let height = scanWindowData![3] - minY
scanWindow = CGRect(x: minX, y: minY, width: width, height: height)
}
func isbarCodeInScanWindow(_ scanWindow: CGRect, _ barcode: Barcode, _ inputImage: UIImage) -> Bool {
let barcodeBoundingBox = barcode.frame
let imageWidth = inputImage.size.width;
let imageHeight = inputImage.size.height;
let minX = scanWindow.minX * imageWidth
let minY = scanWindow.minY * imageHeight
let width = scanWindow.width * imageWidth
let height = scanWindow.height * imageHeight
let scaledScanWindow = CGRect(x: minX, y: minY, width: width, height: height)
return scaledScanWindow.contains(barcodeBoundingBox)
}
func start(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) {
if (device != nil) {
result(FlutterError(code: "MobileScanner",
... ... @@ -318,5 +363,9 @@ class MapArgumentReader {
func stringArray(key: String) -> [String]? {
return args?[key] as? [String]
}
func floatArray(key: String) -> [CGFloat]? {
return args?[key] as? [CGFloat]
}
}
... ...