Julian Steenbakker
Committed by GitHub

Merge branch 'master' into pavel/ios-torch

... ... @@ -12,6 +12,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.
... ... @@ -22,6 +23,8 @@ Features:
* 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.
... ...
... ... @@ -3,9 +3,11 @@ package dev.steenbakker.mobile_scanner
import android.Manifest
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
... ... @@ -14,18 +16,23 @@ 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.plugin.common.MethodChannel
import io.flutter.plugin.common.PluginRegistry
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()
... ... @@ -53,6 +60,7 @@ class MobileScanner(
private var pendingPermissionResult: MethodChannel.Result? = 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
... ... @@ -138,12 +146,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
)
}
}
... ... @@ -162,6 +185,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.
*/
... ... @@ -244,7 +284,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()
... ...
... ... @@ -25,12 +25,14 @@ class MobileScannerPlugin : FlutterPlugin, ActivityAware, MethodChannel.MethodCa
private var analyzerResult: MethodChannel.Result? = null
private val callback: MobileScannerCallback = { barcodes: List<Map<String, Any?>>, image: ByteArray? ->
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
"image" to image,
"width" to width!!.toDouble(),
"height" to height!!.toDouble()
))
} else {
barcodeHandler.publishEvent(mapOf(
... ... @@ -77,6 +79,7 @@ class MobileScannerPlugin : FlutterPlugin, ActivityAware, MethodChannel.MethodCa
"torch" -> toggleTorch(call, result)
"stop" -> stop(result)
"analyzeImage" -> analyzeImage(call, result)
"updateScanWindow" -> updateScanWindow(call)
else -> result.notImplemented()
}
}
... ... @@ -215,4 +218,8 @@ class MobileScannerPlugin : FlutterPlugin, ActivityAware, MethodChannel.MethodCa
result.error("MobileScanner", "Called toggleTorch() while stopped!", 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";
... ...
... ... @@ -77,7 +77,13 @@ class _BarcodeScannerWithControllerState
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
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,
... ... @@ -104,6 +110,8 @@ class _BarcodeScannerWithControllerState
),
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;
}
}
... ...
... ... @@ -2,6 +2,7 @@ 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';
void main() => runApp(const MaterialApp(home: MyHome()));
... ... @@ -44,6 +45,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(),
),
);
... ...
... ... @@ -2,6 +2,7 @@ import Flutter
import MLKitVision
import MLKitBarcodeScanning
import AVFoundation
import UIKit
public class SwiftMobileScannerPlugin: NSObject, FlutterPlugin {
... ... @@ -11,14 +12,45 @@ 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
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])
... ... @@ -51,6 +83,8 @@ public class SwiftMobileScannerPlugin: NSObject, FlutterPlugin {
toggleTorch(call, result)
case "analyzeImage":
analyzeImage(call, result)
case "updateScanWindow":
updateScanWindow(call, result)
default:
result(FlutterMethodNotImplemented)
}
... ... @@ -125,6 +159,28 @@ public class SwiftMobileScannerPlugin: NSObject, FlutterPlugin {
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 ?? "")
... ...
... ... @@ -35,6 +35,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,11 +93,12 @@ 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 {
... ... @@ -106,12 +118,17 @@ class MobileScannerWebPlugin {
});
}
});
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;
}
... ...
... ... @@ -34,6 +34,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({
... ... @@ -43,6 +50,7 @@ class MobileScanner extends StatefulWidget {
@Deprecated('Use onScannerStarted() instead.') this.onStart,
this.onScannerStarted,
this.placeholderBuilder,
this.scanWindow,
super.key,
});
... ... @@ -117,8 +125,64 @@ 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 LayoutBuilder(
builder: (context, constraints) {
return ValueListenableBuilder<MobileScannerArguments?>(
valueListenable: _controller.startArguments,
builder: (context, value, child) {
... ... @@ -127,6 +191,16 @@ class _MobileScannerState extends State<MobileScanner>
const ColoredBox(color: Colors.black);
}
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) {
... ... @@ -148,6 +222,8 @@ class _MobileScannerState extends State<MobileScanner>
);
},
);
},
);
}
@override
... ...
... ... @@ -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,6 +126,15 @@ 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();
... ... @@ -210,8 +221,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;
}
... ... @@ -223,7 +235,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,
);
... ... @@ -244,7 +256,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(
... ... @@ -314,6 +326,8 @@ class MobileScannerController {
BarcodeCapture(
barcodes: parsed,
image: event['image'] as Uint8List?,
width: event['width'] as double?,
height: event['height'] as double?,
),
);
break;
... ... @@ -350,4 +364,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';
... ... @@ -8,7 +10,7 @@ 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;
final html.DivElement videoContainer;
const WebBarcodeReaderBase({
required this.videoContainer,
... ... @@ -35,24 +37,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 +67,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 +98,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 +136,25 @@ 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();
}
... ...
... ... @@ -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});
... ...
... ... @@ -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 {
... ... @@ -134,6 +130,10 @@ 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 =
... ...
import AVFoundation
import FlutterMacOS
import Vision
import UIKit
public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, FlutterTexture, AVCaptureVideoDataOutputSampleBufferDelegate {
... ... @@ -21,6 +22,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
var analyzing: Bool = false
... ... @@ -57,6 +61,8 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler,
// switchAnalyzeMode(call, result)
case "stop":
stop(result)
case "updateScanWindow":
updateScanWindow(call)
default:
result(FlutterMethodNotImplemented)
}
... ... @@ -110,6 +116,13 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler,
if error == nil {
if let results = request.results as? [VNBarcodeObservation] {
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)
... ... @@ -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",
... ... @@ -319,4 +364,8 @@ class MapArgumentReader {
return args?[key] as? [String]
}
func floatArray(key: String) -> [CGFloat]? {
return args?[key] as? [CGFloat]
}
}
... ...