Julian Steenbakker
Committed by GitHub

Merge pull request #411 from juliansteenbakker/feature/scan-window

feat: add scan window overlay and area
@@ -3,9 +3,11 @@ package dev.steenbakker.mobile_scanner @@ -3,9 +3,11 @@ package dev.steenbakker.mobile_scanner
3 import android.Manifest 3 import android.Manifest
4 import android.app.Activity 4 import android.app.Activity
5 import android.content.pm.PackageManager 5 import android.content.pm.PackageManager
  6 +import android.graphics.Rect
6 import android.net.Uri 7 import android.net.Uri
7 import android.os.Handler 8 import android.os.Handler
8 import android.os.Looper 9 import android.os.Looper
  10 +import android.util.Log
9 import android.view.Surface 11 import android.view.Surface
10 import androidx.camera.core.* 12 import androidx.camera.core.*
11 import androidx.camera.lifecycle.ProcessCameraProvider 13 import androidx.camera.lifecycle.ProcessCameraProvider
@@ -14,18 +16,23 @@ import androidx.core.content.ContextCompat @@ -14,18 +16,23 @@ import androidx.core.content.ContextCompat
14 import androidx.lifecycle.LifecycleOwner 16 import androidx.lifecycle.LifecycleOwner
15 import com.google.mlkit.vision.barcode.BarcodeScannerOptions 17 import com.google.mlkit.vision.barcode.BarcodeScannerOptions
16 import com.google.mlkit.vision.barcode.BarcodeScanning 18 import com.google.mlkit.vision.barcode.BarcodeScanning
  19 +import com.google.mlkit.vision.barcode.common.Barcode
17 import com.google.mlkit.vision.common.InputImage 20 import com.google.mlkit.vision.common.InputImage
18 import dev.steenbakker.mobile_scanner.objects.DetectionSpeed 21 import dev.steenbakker.mobile_scanner.objects.DetectionSpeed
19 import dev.steenbakker.mobile_scanner.objects.MobileScannerStartParameters 22 import dev.steenbakker.mobile_scanner.objects.MobileScannerStartParameters
20 import io.flutter.plugin.common.MethodChannel 23 import io.flutter.plugin.common.MethodChannel
21 import io.flutter.plugin.common.PluginRegistry 24 import io.flutter.plugin.common.PluginRegistry
22 import io.flutter.view.TextureRegistry 25 import io.flutter.view.TextureRegistry
23 -typealias MobileScannerCallback = (barcodes: List<Map<String, Any?>>, image: ByteArray?) -> Unit 26 +import kotlin.math.roundToInt
  27 +
  28 +
  29 +typealias MobileScannerCallback = (barcodes: List<Map<String, Any?>>, image: ByteArray?, width: Int?, height: Int?) -> Unit
24 typealias AnalyzerCallback = (barcodes: List<Map<String, Any?>>?) -> Unit 30 typealias AnalyzerCallback = (barcodes: List<Map<String, Any?>>?) -> Unit
25 typealias MobileScannerErrorCallback = (error: String) -> Unit 31 typealias MobileScannerErrorCallback = (error: String) -> Unit
26 typealias TorchStateCallback = (state: Int) -> Unit 32 typealias TorchStateCallback = (state: Int) -> Unit
27 typealias MobileScannerStartedCallback = (parameters: MobileScannerStartParameters) -> Unit 33 typealias MobileScannerStartedCallback = (parameters: MobileScannerStartParameters) -> Unit
28 34
  35 +
29 class NoCamera : Exception() 36 class NoCamera : Exception()
30 class AlreadyStarted : Exception() 37 class AlreadyStarted : Exception()
31 class AlreadyStopped : Exception() 38 class AlreadyStopped : Exception()
@@ -53,6 +60,7 @@ class MobileScanner( @@ -53,6 +60,7 @@ class MobileScanner(
53 private var pendingPermissionResult: MethodChannel.Result? = null 60 private var pendingPermissionResult: MethodChannel.Result? = null
54 private var preview: Preview? = null 61 private var preview: Preview? = null
55 private var textureEntry: TextureRegistry.SurfaceTextureEntry? = null 62 private var textureEntry: TextureRegistry.SurfaceTextureEntry? = null
  63 + var scanWindow: List<Float>? = null
56 64
57 private var detectionSpeed: DetectionSpeed = DetectionSpeed.NO_DUPLICATES 65 private var detectionSpeed: DetectionSpeed = DetectionSpeed.NO_DUPLICATES
58 private var detectionTimeout: Long = 250 66 private var detectionTimeout: Long = 250
@@ -138,12 +146,27 @@ class MobileScanner( @@ -138,12 +146,27 @@ class MobileScanner(
138 lastScanned = newScannedBarcodes 146 lastScanned = newScannedBarcodes
139 } 147 }
140 148
141 - val barcodeMap = barcodes.map { barcode -> barcode.data } 149 + val barcodeMap: MutableList<Map<String, Any?>> = mutableListOf()
  150 +
  151 + for ( barcode in barcodes) {
  152 + if(scanWindow != null) {
  153 + val match = isbarCodeInScanWindow(scanWindow!!, barcode, imageProxy)
  154 + if(!match) {
  155 + continue
  156 + } else {
  157 + barcodeMap.add(barcode.data)
  158 + }
  159 + } else {
  160 + barcodeMap.add(barcode.data)
  161 + }
  162 + }
142 163
143 if (barcodeMap.isNotEmpty()) { 164 if (barcodeMap.isNotEmpty()) {
144 mobileScannerCallback( 165 mobileScannerCallback(
145 barcodeMap, 166 barcodeMap,
146 - if (returnImage) mediaImage.toByteArray() else null 167 + if (returnImage) mediaImage.toByteArray() else null,
  168 + if (returnImage) mediaImage.width else null,
  169 + if (returnImage) mediaImage.height else null
147 ) 170 )
148 } 171 }
149 } 172 }
@@ -162,6 +185,23 @@ class MobileScanner( @@ -162,6 +185,23 @@ class MobileScanner(
162 } 185 }
163 } 186 }
164 187
  188 + // scales the scanWindow to the provided inputImage and checks if that scaled
  189 + // scanWindow contains the barcode
  190 + private fun isbarCodeInScanWindow(scanWindow: List<Float>, barcode: Barcode, inputImage: ImageProxy): Boolean {
  191 + val barcodeBoundingBox = barcode.boundingBox ?: return false
  192 +
  193 + val imageWidth = inputImage.height
  194 + val imageHeight = inputImage.width
  195 +
  196 + val left = (scanWindow[0] * imageWidth).roundToInt()
  197 + val top = (scanWindow[1] * imageHeight).roundToInt()
  198 + val right = (scanWindow[2] * imageWidth).roundToInt()
  199 + val bottom = (scanWindow[3] * imageHeight).roundToInt()
  200 +
  201 + val scaledScanWindow = Rect(left, top, right, bottom)
  202 + return scaledScanWindow.contains(barcodeBoundingBox)
  203 + }
  204 +
165 /** 205 /**
166 * Start barcode scanning by initializing the camera and barcode scanner. 206 * Start barcode scanning by initializing the camera and barcode scanner.
167 */ 207 */
@@ -244,7 +284,7 @@ class MobileScanner( @@ -244,7 +284,7 @@ class MobileScanner(
244 // Enable torch if provided 284 // Enable torch if provided
245 camera!!.cameraControl.enableTorch(torch) 285 camera!!.cameraControl.enableTorch(torch)
246 286
247 - val resolution = preview!!.resolutionInfo!!.resolution 287 + val resolution = analysis.resolutionInfo!!.resolution
248 val portrait = camera!!.cameraInfo.sensorRotationDegrees % 180 == 0 288 val portrait = camera!!.cameraInfo.sensorRotationDegrees % 180 == 0
249 val width = resolution.width.toDouble() 289 val width = resolution.width.toDouble()
250 val height = resolution.height.toDouble() 290 val height = resolution.height.toDouble()
@@ -25,12 +25,14 @@ class MobileScannerPlugin : FlutterPlugin, ActivityAware, MethodChannel.MethodCa @@ -25,12 +25,14 @@ class MobileScannerPlugin : FlutterPlugin, ActivityAware, MethodChannel.MethodCa
25 25
26 private var analyzerResult: MethodChannel.Result? = null 26 private var analyzerResult: MethodChannel.Result? = null
27 27
28 - private val callback: MobileScannerCallback = { barcodes: List<Map<String, Any?>>, image: ByteArray? -> 28 + private val callback: MobileScannerCallback = { barcodes: List<Map<String, Any?>>, image: ByteArray?, width: Int?, height: Int? ->
29 if (image != null) { 29 if (image != null) {
30 barcodeHandler.publishEvent(mapOf( 30 barcodeHandler.publishEvent(mapOf(
31 "name" to "barcode", 31 "name" to "barcode",
32 "data" to barcodes, 32 "data" to barcodes,
33 - "image" to image 33 + "image" to image,
  34 + "width" to width!!.toDouble(),
  35 + "height" to height!!.toDouble()
34 )) 36 ))
35 } else { 37 } else {
36 barcodeHandler.publishEvent(mapOf( 38 barcodeHandler.publishEvent(mapOf(
@@ -77,6 +79,7 @@ class MobileScannerPlugin : FlutterPlugin, ActivityAware, MethodChannel.MethodCa @@ -77,6 +79,7 @@ class MobileScannerPlugin : FlutterPlugin, ActivityAware, MethodChannel.MethodCa
77 "torch" -> toggleTorch(call, result) 79 "torch" -> toggleTorch(call, result)
78 "stop" -> stop(result) 80 "stop" -> stop(result)
79 "analyzeImage" -> analyzeImage(call, result) 81 "analyzeImage" -> analyzeImage(call, result)
  82 + "updateScanWindow" -> updateScanWindow(call)
80 else -> result.notImplemented() 83 else -> result.notImplemented()
81 } 84 }
82 } 85 }
@@ -215,4 +218,8 @@ class MobileScannerPlugin : FlutterPlugin, ActivityAware, MethodChannel.MethodCa @@ -215,4 +218,8 @@ class MobileScannerPlugin : FlutterPlugin, ActivityAware, MethodChannel.MethodCa
215 result.error("MobileScanner", "Called toggleTorch() while stopped!", null) 218 result.error("MobileScanner", "Called toggleTorch() while stopped!", null)
216 } 219 }
217 } 220 }
  221 +
  222 + private fun updateScanWindow(call: MethodCall) {
  223 + handler!!.scanWindow = call.argument<List<Float>>("rect")
  224 + }
218 } 225 }
@@ -354,6 +354,8 @@ @@ -354,6 +354,8 @@
354 buildSettings = { 354 buildSettings = {
355 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 355 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
356 CLANG_ENABLE_MODULES = YES; 356 CLANG_ENABLE_MODULES = YES;
  357 + CODE_SIGN_IDENTITY = "Apple Development";
  358 + CODE_SIGN_STYLE = Automatic;
357 CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; 359 CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
358 DEVELOPMENT_TEAM = 75Y2P2WSQQ; 360 DEVELOPMENT_TEAM = 75Y2P2WSQQ;
359 ENABLE_BITCODE = NO; 361 ENABLE_BITCODE = NO;
@@ -364,6 +366,7 @@ @@ -364,6 +366,7 @@
364 ); 366 );
365 PRODUCT_BUNDLE_IDENTIFIER = dev.steenbakker.mobileScanner; 367 PRODUCT_BUNDLE_IDENTIFIER = dev.steenbakker.mobileScanner;
366 PRODUCT_NAME = "$(TARGET_NAME)"; 368 PRODUCT_NAME = "$(TARGET_NAME)";
  369 + PROVISIONING_PROFILE_SPECIFIER = "";
367 SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; 370 SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
368 SWIFT_VERSION = 5.0; 371 SWIFT_VERSION = 5.0;
369 VERSIONING_SYSTEM = "apple-generic"; 372 VERSIONING_SYSTEM = "apple-generic";
@@ -483,6 +486,8 @@ @@ -483,6 +486,8 @@
483 buildSettings = { 486 buildSettings = {
484 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 487 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
485 CLANG_ENABLE_MODULES = YES; 488 CLANG_ENABLE_MODULES = YES;
  489 + CODE_SIGN_IDENTITY = "Apple Development";
  490 + CODE_SIGN_STYLE = Automatic;
486 CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; 491 CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
487 DEVELOPMENT_TEAM = 75Y2P2WSQQ; 492 DEVELOPMENT_TEAM = 75Y2P2WSQQ;
488 ENABLE_BITCODE = NO; 493 ENABLE_BITCODE = NO;
@@ -493,6 +498,7 @@ @@ -493,6 +498,7 @@
493 ); 498 );
494 PRODUCT_BUNDLE_IDENTIFIER = dev.steenbakker.mobileScanner; 499 PRODUCT_BUNDLE_IDENTIFIER = dev.steenbakker.mobileScanner;
495 PRODUCT_NAME = "$(TARGET_NAME)"; 500 PRODUCT_NAME = "$(TARGET_NAME)";
  501 + PROVISIONING_PROFILE_SPECIFIER = "";
496 SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; 502 SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
497 SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 503 SWIFT_OPTIMIZATION_LEVEL = "-Onone";
498 SWIFT_VERSION = 5.0; 504 SWIFT_VERSION = 5.0;
@@ -506,6 +512,8 @@ @@ -506,6 +512,8 @@
506 buildSettings = { 512 buildSettings = {
507 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 513 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
508 CLANG_ENABLE_MODULES = YES; 514 CLANG_ENABLE_MODULES = YES;
  515 + CODE_SIGN_IDENTITY = "Apple Development";
  516 + CODE_SIGN_STYLE = Automatic;
509 CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; 517 CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
510 DEVELOPMENT_TEAM = 75Y2P2WSQQ; 518 DEVELOPMENT_TEAM = 75Y2P2WSQQ;
511 ENABLE_BITCODE = NO; 519 ENABLE_BITCODE = NO;
@@ -516,6 +524,7 @@ @@ -516,6 +524,7 @@
516 ); 524 );
517 PRODUCT_BUNDLE_IDENTIFIER = dev.steenbakker.mobileScanner; 525 PRODUCT_BUNDLE_IDENTIFIER = dev.steenbakker.mobileScanner;
518 PRODUCT_NAME = "$(TARGET_NAME)"; 526 PRODUCT_NAME = "$(TARGET_NAME)";
  527 + PROVISIONING_PROFILE_SPECIFIER = "";
519 SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; 528 SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
520 SWIFT_VERSION = 5.0; 529 SWIFT_VERSION = 5.0;
521 VERSIONING_SYSTEM = "apple-generic"; 530 VERSIONING_SYSTEM = "apple-generic";
  1 +import 'dart:io';
  2 +
  3 +import 'package:flutter/material.dart';
  4 +import 'package:mobile_scanner/mobile_scanner.dart';
  5 +
  6 +class BarcodeScannerWithScanWindow extends StatefulWidget {
  7 + const BarcodeScannerWithScanWindow({Key? key}) : super(key: key);
  8 +
  9 + @override
  10 + _BarcodeScannerWithScanWindowState createState() =>
  11 + _BarcodeScannerWithScanWindowState();
  12 +}
  13 +
  14 +class _BarcodeScannerWithScanWindowState
  15 + extends State<BarcodeScannerWithScanWindow> {
  16 + late MobileScannerController controller = MobileScannerController();
  17 + Barcode? barcode;
  18 + BarcodeCapture? capture;
  19 +
  20 + Future<void> onDetect(BarcodeCapture barcode) async {
  21 + capture = barcode;
  22 + setState(() => this.barcode = barcode.barcodes.first);
  23 + }
  24 +
  25 + MobileScannerArguments? arguments;
  26 +
  27 + @override
  28 + Widget build(BuildContext context) {
  29 + final scanWindow = Rect.fromCenter(
  30 + center: MediaQuery.of(context).size.center(Offset.zero),
  31 + width: 200,
  32 + height: 200,
  33 + );
  34 + return Scaffold(
  35 + backgroundColor: Colors.black,
  36 + body: Builder(
  37 + builder: (context) {
  38 + return Stack(
  39 + fit: StackFit.expand,
  40 + children: [
  41 + MobileScanner(
  42 + fit: BoxFit.contain,
  43 + scanWindow: scanWindow,
  44 + controller: controller,
  45 + onScannerStarted: (arguments) {
  46 + setState(() {
  47 + this.arguments = arguments;
  48 + });
  49 + },
  50 + onDetect: onDetect,
  51 + ),
  52 + if (barcode != null &&
  53 + barcode?.corners != null &&
  54 + arguments != null)
  55 + CustomPaint(
  56 + painter: BarcodeOverlay(
  57 + barcode!,
  58 + arguments!,
  59 + BoxFit.contain,
  60 + MediaQuery.of(context).devicePixelRatio,
  61 + capture!,
  62 + ),
  63 + ),
  64 + CustomPaint(
  65 + painter: ScannerOverlay(scanWindow),
  66 + ),
  67 + Align(
  68 + alignment: Alignment.bottomCenter,
  69 + child: Container(
  70 + alignment: Alignment.bottomCenter,
  71 + height: 100,
  72 + color: Colors.black.withOpacity(0.4),
  73 + child: Row(
  74 + mainAxisAlignment: MainAxisAlignment.spaceEvenly,
  75 + children: [
  76 + Center(
  77 + child: SizedBox(
  78 + width: MediaQuery.of(context).size.width - 120,
  79 + height: 50,
  80 + child: FittedBox(
  81 + child: Text(
  82 + barcode?.displayValue ?? 'Scan something!',
  83 + overflow: TextOverflow.fade,
  84 + style: Theme.of(context)
  85 + .textTheme
  86 + .headline4!
  87 + .copyWith(color: Colors.white),
  88 + ),
  89 + ),
  90 + ),
  91 + ),
  92 + ],
  93 + ),
  94 + ),
  95 + ),
  96 + ],
  97 + );
  98 + },
  99 + ),
  100 + );
  101 + }
  102 +}
  103 +
  104 +class ScannerOverlay extends CustomPainter {
  105 + ScannerOverlay(this.scanWindow);
  106 +
  107 + final Rect scanWindow;
  108 +
  109 + @override
  110 + void paint(Canvas canvas, Size size) {
  111 + final backgroundPath = Path()..addRect(Rect.largest);
  112 + final cutoutPath = Path()..addRect(scanWindow);
  113 +
  114 + final backgroundPaint = Paint()
  115 + ..color = Colors.black.withOpacity(0.5)
  116 + ..style = PaintingStyle.fill
  117 + ..blendMode = BlendMode.dstOut;
  118 +
  119 + final backgroundWithCutout = Path.combine(
  120 + PathOperation.difference,
  121 + backgroundPath,
  122 + cutoutPath,
  123 + );
  124 + canvas.drawPath(backgroundWithCutout, backgroundPaint);
  125 + }
  126 +
  127 + @override
  128 + bool shouldRepaint(covariant CustomPainter oldDelegate) {
  129 + return false;
  130 + }
  131 +}
  132 +
  133 +class BarcodeOverlay extends CustomPainter {
  134 + BarcodeOverlay(
  135 + this.barcode,
  136 + this.arguments,
  137 + this.boxFit,
  138 + this.devicePixelRatio,
  139 + this.capture,
  140 + );
  141 +
  142 + final BarcodeCapture capture;
  143 + final Barcode barcode;
  144 + final MobileScannerArguments arguments;
  145 + final BoxFit boxFit;
  146 + final double devicePixelRatio;
  147 +
  148 + @override
  149 + void paint(Canvas canvas, Size size) {
  150 + if (barcode.corners == null) return;
  151 + final adjustedSize = applyBoxFit(boxFit, arguments.size, size);
  152 +
  153 + double verticalPadding = size.height - adjustedSize.destination.height;
  154 + double horizontalPadding = size.width - adjustedSize.destination.width;
  155 + if (verticalPadding > 0) {
  156 + verticalPadding = verticalPadding / 2;
  157 + } else {
  158 + verticalPadding = 0;
  159 + }
  160 +
  161 + if (horizontalPadding > 0) {
  162 + horizontalPadding = horizontalPadding / 2;
  163 + } else {
  164 + horizontalPadding = 0;
  165 + }
  166 +
  167 + final ratioWidth =
  168 + (Platform.isIOS ? capture.width! : arguments.size.width) /
  169 + adjustedSize.destination.width;
  170 + final ratioHeight =
  171 + (Platform.isIOS ? capture.height! : arguments.size.height) /
  172 + adjustedSize.destination.height;
  173 +
  174 + final List<Offset> adjustedOffset = [];
  175 + for (final offset in barcode.corners!) {
  176 + adjustedOffset.add(
  177 + Offset(
  178 + offset.dx / ratioWidth + horizontalPadding,
  179 + offset.dy / ratioHeight + verticalPadding,
  180 + ),
  181 + );
  182 + }
  183 + final cutoutPath = Path()..addPolygon(adjustedOffset, true);
  184 +
  185 + final backgroundPaint = Paint()
  186 + ..color = Colors.red.withOpacity(0.3)
  187 + ..style = PaintingStyle.fill
  188 + ..blendMode = BlendMode.dstOut;
  189 +
  190 + canvas.drawPath(cutoutPath, backgroundPaint);
  191 + }
  192 +
  193 + @override
  194 + bool shouldRepaint(covariant CustomPainter oldDelegate) {
  195 + return false;
  196 + }
  197 +}
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; @@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
2 import 'package:mobile_scanner_example/barcode_list_scanner_controller.dart'; 2 import 'package:mobile_scanner_example/barcode_list_scanner_controller.dart';
3 import 'package:mobile_scanner_example/barcode_scanner_controller.dart'; 3 import 'package:mobile_scanner_example/barcode_scanner_controller.dart';
4 import 'package:mobile_scanner_example/barcode_scanner_returning_image.dart'; 4 import 'package:mobile_scanner_example/barcode_scanner_returning_image.dart';
  5 +import 'package:mobile_scanner_example/barcode_scanner_window.dart';
5 import 'package:mobile_scanner_example/barcode_scanner_without_controller.dart'; 6 import 'package:mobile_scanner_example/barcode_scanner_without_controller.dart';
6 7
7 void main() => runApp(const MaterialApp(home: MyHome())); 8 void main() => runApp(const MaterialApp(home: MyHome()));
@@ -44,6 +45,16 @@ class MyHome extends StatelessWidget { @@ -44,6 +45,16 @@ class MyHome extends StatelessWidget {
44 onPressed: () { 45 onPressed: () {
45 Navigator.of(context).push( 46 Navigator.of(context).push(
46 MaterialPageRoute( 47 MaterialPageRoute(
  48 + builder: (context) => const BarcodeScannerWithScanWindow(),
  49 + ),
  50 + );
  51 + },
  52 + child: const Text('MobileScanner with ScanWindow'),
  53 + ),
  54 + ElevatedButton(
  55 + onPressed: () {
  56 + Navigator.of(context).push(
  57 + MaterialPageRoute(
47 builder: (context) => const BarcodeScannerReturningImage(), 58 builder: (context) => const BarcodeScannerReturningImage(),
48 ), 59 ),
49 ); 60 );
@@ -2,6 +2,7 @@ import Flutter @@ -2,6 +2,7 @@ import Flutter
2 import MLKitVision 2 import MLKitVision
3 import MLKitBarcodeScanning 3 import MLKitBarcodeScanning
4 import AVFoundation 4 import AVFoundation
  5 +import UIKit
5 6
6 public class SwiftMobileScannerPlugin: NSObject, FlutterPlugin { 7 public class SwiftMobileScannerPlugin: NSObject, FlutterPlugin {
7 8
@@ -11,14 +12,45 @@ public class SwiftMobileScannerPlugin: NSObject, FlutterPlugin { @@ -11,14 +12,45 @@ public class SwiftMobileScannerPlugin: NSObject, FlutterPlugin {
11 /// The handler sends all information via an event channel back to Flutter 12 /// The handler sends all information via an event channel back to Flutter
12 private let barcodeHandler: BarcodeHandler 13 private let barcodeHandler: BarcodeHandler
13 14
  15 + static var scanWindow: [CGFloat]?
  16 +
  17 + private static func isBarcodeInScanWindow(barcode: Barcode, imageSize: CGSize) -> Bool {
  18 + let scanwindow = SwiftMobileScannerPlugin.scanWindow!
  19 + let barcodeminX = barcode.cornerPoints![0].cgPointValue.x
  20 + let barcodeminY = barcode.cornerPoints![1].cgPointValue.y
  21 +
  22 + let barcodewidth = barcode.cornerPoints![2].cgPointValue.x - barcodeminX
  23 + let barcodeheight = barcode.cornerPoints![3].cgPointValue.y - barcodeminY
  24 + let barcodeBox = CGRect(x: barcodeminX, y: barcodeminY, width: barcodewidth, height: barcodeheight)
  25 +
  26 +
  27 + let minX = scanwindow[0] * imageSize.width
  28 + let minY = scanwindow[1] * imageSize.height
  29 +
  30 + let width = (scanwindow[2] * imageSize.width) - minX
  31 + let height = (scanwindow[3] * imageSize.height) - minY
  32 +
  33 + let scaledWindow = CGRect(x: minX, y: minY, width: width, height: height)
  34 +
  35 + return scaledWindow.contains(barcodeBox)
  36 + }
  37 +
14 init(barcodeHandler: BarcodeHandler, registry: FlutterTextureRegistry) { 38 init(barcodeHandler: BarcodeHandler, registry: FlutterTextureRegistry) {
15 self.mobileScanner = MobileScanner(registry: registry, mobileScannerCallback: { barcodes, error, image in 39 self.mobileScanner = MobileScanner(registry: registry, mobileScannerCallback: { barcodes, error, image in
16 if barcodes != nil { 40 if barcodes != nil {
17 - let barcodesMap = barcodes!.map { barcode in 41 + let barcodesMap = barcodes!.compactMap { barcode in
  42 + if (SwiftMobileScannerPlugin.scanWindow != nil) {
  43 + if (SwiftMobileScannerPlugin.isBarcodeInScanWindow(barcode: barcode, imageSize: image.size)) {
18 return barcode.data 44 return barcode.data
  45 + } else {
  46 + return nil
  47 + }
  48 + } else {
  49 + return barcode.data
  50 + }
19 } 51 }
20 if (!barcodesMap.isEmpty) { 52 if (!barcodesMap.isEmpty) {
21 - barcodeHandler.publishEvent(["name": "barcode", "data": barcodesMap, "image": FlutterStandardTypedData(bytes: image.jpegData(compressionQuality: 0.8)!)]) 53 + barcodeHandler.publishEvent(["name": "barcode", "data": barcodesMap, "image": FlutterStandardTypedData(bytes: image.jpegData(compressionQuality: 0.8)!), "width": image.size.width, "height": image.size.height])
22 } 54 }
23 } else if (error != nil){ 55 } else if (error != nil){
24 barcodeHandler.publishEvent(["name": "error", "data": error!.localizedDescription]) 56 barcodeHandler.publishEvent(["name": "error", "data": error!.localizedDescription])
@@ -49,6 +81,8 @@ public class SwiftMobileScannerPlugin: NSObject, FlutterPlugin { @@ -49,6 +81,8 @@ public class SwiftMobileScannerPlugin: NSObject, FlutterPlugin {
49 toggleTorch(call, result) 81 toggleTorch(call, result)
50 case "analyzeImage": 82 case "analyzeImage":
51 analyzeImage(call, result) 83 analyzeImage(call, result)
  84 + case "updateScanWindow":
  85 + updateScanWindow(call, result)
52 default: 86 default:
53 result(FlutterMethodNotImplemented) 87 result(FlutterMethodNotImplemented)
54 } 88 }
@@ -123,6 +157,28 @@ public class SwiftMobileScannerPlugin: NSObject, FlutterPlugin { @@ -123,6 +157,28 @@ public class SwiftMobileScannerPlugin: NSObject, FlutterPlugin {
123 result(nil) 157 result(nil)
124 } 158 }
125 159
  160 + /// Toggles the torch
  161 + func updateScanWindow(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) {
  162 + let scanWindowData: Array? = (call.arguments as? [String: Any])?["rect"] as? [CGFloat]
  163 + SwiftMobileScannerPlugin.scanWindow = scanWindowData
  164 +
  165 + result(nil)
  166 + }
  167 +
  168 + static func arrayToRect(scanWindowData: [CGFloat]?) -> CGRect? {
  169 + if (scanWindowData == nil) {
  170 + return nil
  171 + }
  172 +
  173 + let minX = scanWindowData![0]
  174 + let minY = scanWindowData![1]
  175 +
  176 + let width = scanWindowData![2] - minX
  177 + let height = scanWindowData![3] - minY
  178 +
  179 + return CGRect(x: minX, y: minY, width: width, height: height)
  180 + }
  181 +
126 /// Analyzes a single image 182 /// Analyzes a single image
127 private func analyzeImage(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { 183 private func analyzeImage(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) {
128 let uiImage = UIImage(contentsOfFile: call.arguments as? String ?? "") 184 let uiImage = UIImage(contentsOfFile: call.arguments as? String ?? "")
  1 +import 'dart:math' as math;
  2 +
1 import 'package:flutter/material.dart'; 3 import 'package:flutter/material.dart';
2 import 'package:mobile_scanner/mobile_scanner.dart'; 4 import 'package:mobile_scanner/mobile_scanner.dart';
3 5
@@ -147,3 +149,79 @@ WiFi? toWiFi(Map? data) { @@ -147,3 +149,79 @@ WiFi? toWiFi(Map? data) {
147 return null; 149 return null;
148 } 150 }
149 } 151 }
  152 +
  153 +Size applyBoxFit(BoxFit fit, Size input, Size output) {
  154 + if (input.height <= 0.0 ||
  155 + input.width <= 0.0 ||
  156 + output.height <= 0.0 ||
  157 + output.width <= 0.0) {
  158 + return Size.zero;
  159 + }
  160 +
  161 + Size destination;
  162 +
  163 + final inputAspectRatio = input.width / input.height;
  164 + final outputAspectRatio = output.width / output.height;
  165 +
  166 + switch (fit) {
  167 + case BoxFit.fill:
  168 + destination = output;
  169 + break;
  170 + case BoxFit.contain:
  171 + if (outputAspectRatio > inputAspectRatio) {
  172 + destination = Size(
  173 + input.width * output.height / input.height,
  174 + output.height,
  175 + );
  176 + } else {
  177 + destination = Size(
  178 + output.width,
  179 + input.height * output.width / input.width,
  180 + );
  181 + }
  182 + break;
  183 +
  184 + case BoxFit.cover:
  185 + if (outputAspectRatio > inputAspectRatio) {
  186 + destination = Size(
  187 + output.width,
  188 + input.height * (output.width / input.width),
  189 + );
  190 + } else {
  191 + destination = Size(
  192 + input.width * (output.height / input.height),
  193 + output.height,
  194 + );
  195 + }
  196 + break;
  197 + case BoxFit.fitWidth:
  198 + destination = Size(
  199 + output.width,
  200 + input.height * (output.width / input.width),
  201 + );
  202 + break;
  203 + case BoxFit.fitHeight:
  204 + destination = Size(
  205 + input.width * (output.height / input.height),
  206 + output.height,
  207 + );
  208 + break;
  209 + case BoxFit.none:
  210 + destination = Size(
  211 + math.min(input.width, output.width),
  212 + math.min(input.height, output.height),
  213 + );
  214 + break;
  215 + case BoxFit.scaleDown:
  216 + destination = input;
  217 + if (destination.height > output.height) {
  218 + destination = Size(output.height * inputAspectRatio, output.height);
  219 + }
  220 + if (destination.width > output.width) {
  221 + destination = Size(output.width, output.width / inputAspectRatio);
  222 + }
  223 + break;
  224 + }
  225 +
  226 + return destination;
  227 +}
@@ -34,6 +34,13 @@ class MobileScanner extends StatefulWidget { @@ -34,6 +34,13 @@ class MobileScanner extends StatefulWidget {
34 /// If this is null, a black [ColoredBox] is used as placeholder. 34 /// If this is null, a black [ColoredBox] is used as placeholder.
35 final Widget Function(BuildContext, Widget?)? placeholderBuilder; 35 final Widget Function(BuildContext, Widget?)? placeholderBuilder;
36 36
  37 + /// if set barcodes will only be scanned if they fall within this [Rect]
  38 + /// useful for having a cut-out overlay for example. these [Rect]
  39 + /// coordinates are relative to the widget size, so by how much your
  40 + /// rectangle overlays the actual image can depend on things like the
  41 + /// [BoxFit]
  42 + final Rect? scanWindow;
  43 +
37 /// Create a new [MobileScanner] using the provided [controller] 44 /// Create a new [MobileScanner] using the provided [controller]
38 /// and [onBarcodeDetected] callback. 45 /// and [onBarcodeDetected] callback.
39 const MobileScanner({ 46 const MobileScanner({
@@ -43,6 +50,7 @@ class MobileScanner extends StatefulWidget { @@ -43,6 +50,7 @@ class MobileScanner extends StatefulWidget {
43 @Deprecated('Use onScannerStarted() instead.') this.onStart, 50 @Deprecated('Use onScannerStarted() instead.') this.onStart,
44 this.onScannerStarted, 51 this.onScannerStarted,
45 this.placeholderBuilder, 52 this.placeholderBuilder,
  53 + this.scanWindow,
46 super.key, 54 super.key,
47 }); 55 });
48 56
@@ -117,8 +125,64 @@ class _MobileScannerState extends State<MobileScanner> @@ -117,8 +125,64 @@ class _MobileScannerState extends State<MobileScanner>
117 } 125 }
118 } 126 }
119 127
  128 + /// the [scanWindow] rect will be relative and scaled to the [widgetSize] not the texture. so it is possible,
  129 + /// depending on the [fit], for the [scanWindow] to partially or not at all overlap the [textureSize]
  130 + ///
  131 + /// since when using a [BoxFit] the content will always be centered on its parent. we can convert the rect
  132 + /// to be relative to the texture.
  133 + ///
  134 + /// since the textures size and the actuall image (on the texture size) might not be the same, we also need to
  135 + /// calculate the scanWindow in terms of percentages of the texture, not pixels.
  136 + Rect calculateScanWindowRelativeToTextureInPercentage(
  137 + BoxFit fit,
  138 + Rect scanWindow,
  139 + Size textureSize,
  140 + Size widgetSize,
  141 + ) {
  142 + /// map the texture size to get its new size after fitted to screen
  143 + final fittedTextureSize = applyBoxFit(fit, textureSize, widgetSize);
  144 +
  145 + /// create a new rectangle that represents the texture on the screen
  146 + final minX = widgetSize.width / 2 - fittedTextureSize.destination.width / 2;
  147 + final minY =
  148 + widgetSize.height / 2 - fittedTextureSize.destination.height / 2;
  149 + final textureWindow = Offset(minX, minY) & fittedTextureSize.destination;
  150 +
  151 + /// create a new scan window and with only the area of the rect intersecting the texture window
  152 + final scanWindowInTexture = scanWindow.intersect(textureWindow);
  153 +
  154 + /// update the scanWindow left and top to be relative to the texture not the widget
  155 + final newLeft = scanWindowInTexture.left - textureWindow.left;
  156 + final newTop = scanWindowInTexture.top - textureWindow.top;
  157 + final newWidth = scanWindowInTexture.width;
  158 + final newHeight = scanWindowInTexture.height;
  159 +
  160 + /// new scanWindow that is adapted to the boxfit and relative to the texture
  161 + final windowInTexture = Rect.fromLTWH(newLeft, newTop, newWidth, newHeight);
  162 +
  163 + /// get the scanWindow as a percentage of the texture
  164 + final percentageLeft =
  165 + windowInTexture.left / fittedTextureSize.destination.width;
  166 + final percentageTop =
  167 + windowInTexture.top / fittedTextureSize.destination.height;
  168 + final percentageRight =
  169 + windowInTexture.right / fittedTextureSize.destination.width;
  170 + final percentagebottom =
  171 + windowInTexture.bottom / fittedTextureSize.destination.height;
  172 +
  173 + /// this rectangle can be send to native code and used to cut out a rectangle of the scan image
  174 + return Rect.fromLTRB(
  175 + percentageLeft,
  176 + percentageTop,
  177 + percentageRight,
  178 + percentagebottom,
  179 + );
  180 + }
  181 +
120 @override 182 @override
121 Widget build(BuildContext context) { 183 Widget build(BuildContext context) {
  184 + return LayoutBuilder(
  185 + builder: (context, constraints) {
122 return ValueListenableBuilder<MobileScannerArguments?>( 186 return ValueListenableBuilder<MobileScannerArguments?>(
123 valueListenable: _controller.startArguments, 187 valueListenable: _controller.startArguments,
124 builder: (context, value, child) { 188 builder: (context, value, child) {
@@ -127,6 +191,16 @@ class _MobileScannerState extends State<MobileScanner> @@ -127,6 +191,16 @@ class _MobileScannerState extends State<MobileScanner>
127 const ColoredBox(color: Colors.black); 191 const ColoredBox(color: Colors.black);
128 } 192 }
129 193
  194 + if (widget.scanWindow != null) {
  195 + final window = calculateScanWindowRelativeToTextureInPercentage(
  196 + widget.fit,
  197 + widget.scanWindow!,
  198 + value.size,
  199 + Size(constraints.maxWidth, constraints.maxHeight),
  200 + );
  201 + _controller.updateScanWindow(window);
  202 + }
  203 +
130 return ClipRect( 204 return ClipRect(
131 child: LayoutBuilder( 205 child: LayoutBuilder(
132 builder: (_, constraints) { 206 builder: (_, constraints) {
@@ -148,6 +222,8 @@ class _MobileScannerState extends State<MobileScanner> @@ -148,6 +222,8 @@ class _MobileScannerState extends State<MobileScanner>
148 ); 222 );
149 }, 223 },
150 ); 224 );
  225 + },
  226 + );
151 } 227 }
152 228
153 @override 229 @override
@@ -126,6 +126,15 @@ class MobileScannerController { @@ -126,6 +126,15 @@ class MobileScannerController {
126 arguments['speed'] = detectionSpeed.index; 126 arguments['speed'] = detectionSpeed.index;
127 arguments['timeout'] = detectionTimeoutMs; 127 arguments['timeout'] = detectionTimeoutMs;
128 128
  129 + /* if (scanWindow != null) {
  130 + arguments['scanWindow'] = [
  131 + scanWindow!.left,
  132 + scanWindow!.top,
  133 + scanWindow!.right,
  134 + scanWindow!.bottom,
  135 + ];
  136 + } */
  137 +
129 if (formats != null) { 138 if (formats != null) {
130 if (Platform.isAndroid) { 139 if (Platform.isAndroid) {
131 arguments['formats'] = formats!.map((e) => e.index).toList(); 140 arguments['formats'] = formats!.map((e) => e.index).toList();
@@ -317,6 +326,8 @@ class MobileScannerController { @@ -317,6 +326,8 @@ class MobileScannerController {
317 BarcodeCapture( 326 BarcodeCapture(
318 barcodes: parsed, 327 barcodes: parsed,
319 image: event['image'] as Uint8List?, 328 image: event['image'] as Uint8List?,
  329 + width: event['width'] as double?,
  330 + height: event['height'] as double?,
320 ), 331 ),
321 ); 332 );
322 break; 333 break;
@@ -353,4 +364,10 @@ class MobileScannerController { @@ -353,4 +364,10 @@ class MobileScannerController {
353 throw UnimplementedError(name as String?); 364 throw UnimplementedError(name as String?);
354 } 365 }
355 } 366 }
  367 +
  368 + /// updates the native scanwindow
  369 + Future<void> updateScanWindow(Rect window) async {
  370 + final data = [window.left, window.top, window.right, window.bottom];
  371 + await _methodChannel.invokeMethod('updateScanWindow', {'rect': data});
  372 + }
356 } 373 }
@@ -12,8 +12,14 @@ class BarcodeCapture { @@ -12,8 +12,14 @@ class BarcodeCapture {
12 12
13 final Uint8List? image; 13 final Uint8List? image;
14 14
  15 + final double? width;
  16 +
  17 + final double? height;
  18 +
15 BarcodeCapture({ 19 BarcodeCapture({
16 required this.barcodes, 20 required this.barcodes,
17 this.image, 21 this.image,
  22 + this.width,
  23 + this.height,
18 }); 24 });
19 } 25 }
1 import AVFoundation 1 import AVFoundation
2 import FlutterMacOS 2 import FlutterMacOS
3 import Vision 3 import Vision
  4 +import UIKit
4 5
5 public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, FlutterTexture, AVCaptureVideoDataOutputSampleBufferDelegate { 6 public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, FlutterTexture, AVCaptureVideoDataOutputSampleBufferDelegate {
6 7
@@ -21,6 +22,9 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, @@ -21,6 +22,9 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler,
21 // Image to be sent to the texture 22 // Image to be sent to the texture
22 var latestBuffer: CVImageBuffer! 23 var latestBuffer: CVImageBuffer!
23 24
  25 + // optional window to limit scan search
  26 + var scanWindow: CGRect?
  27 +
24 28
25 // var analyzeMode: Int = 0 29 // var analyzeMode: Int = 0
26 var analyzing: Bool = false 30 var analyzing: Bool = false
@@ -57,6 +61,8 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, @@ -57,6 +61,8 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler,
57 // switchAnalyzeMode(call, result) 61 // switchAnalyzeMode(call, result)
58 case "stop": 62 case "stop":
59 stop(result) 63 stop(result)
  64 + case "updateScanWindow":
  65 + updateScanWindow(call)
60 default: 66 default:
61 result(FlutterMethodNotImplemented) 67 result(FlutterMethodNotImplemented)
62 } 68 }
@@ -110,6 +116,13 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, @@ -110,6 +116,13 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler,
110 if error == nil { 116 if error == nil {
111 if let results = request.results as? [VNBarcodeObservation] { 117 if let results = request.results as? [VNBarcodeObservation] {
112 for barcode in results { 118 for barcode in results {
  119 + if scanWindow != nil {
  120 + let match = isbarCodeInScanWindow(scanWindow!, barcode, buffer!.image)
  121 + if (!match) {
  122 + continue
  123 + }
  124 + }
  125 +
113 let barcodeType = String(barcode.symbology.rawValue).replacingOccurrences(of: "VNBarcodeSymbology", with: "") 126 let barcodeType = String(barcode.symbology.rawValue).replacingOccurrences(of: "VNBarcodeSymbology", with: "")
114 let event: [String: Any?] = ["name": "barcodeMac", "data" : ["payload": barcode.payloadStringValue, "symbology": barcodeType]] 127 let event: [String: Any?] = ["name": "barcodeMac", "data" : ["payload": barcode.payloadStringValue, "symbology": barcodeType]]
115 self.sink?(event) 128 self.sink?(event)
@@ -158,6 +171,38 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, @@ -158,6 +171,38 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler,
158 } 171 }
159 } 172 }
160 173
  174 + func updateScanWindow(_ call: FlutterMethodCall) {
  175 + let argReader = MapArgumentReader(call.arguments as? [String: Any])
  176 + let scanWindowData: Array? = argReader.floatArray(key: "rect")
  177 +
  178 + if (scanWindowData == nil) {
  179 + return
  180 + }
  181 +
  182 + let minX = scanWindowData![0]
  183 + let minY = scanWindowData![1]
  184 +
  185 + let width = scanWindowData![2] - minX
  186 + let height = scanWindowData![3] - minY
  187 +
  188 + scanWindow = CGRect(x: minX, y: minY, width: width, height: height)
  189 + }
  190 +
  191 + func isbarCodeInScanWindow(_ scanWindow: CGRect, _ barcode: Barcode, _ inputImage: UIImage) -> Bool {
  192 + let barcodeBoundingBox = barcode.frame
  193 +
  194 + let imageWidth = inputImage.size.width;
  195 + let imageHeight = inputImage.size.height;
  196 +
  197 + let minX = scanWindow.minX * imageWidth
  198 + let minY = scanWindow.minY * imageHeight
  199 + let width = scanWindow.width * imageWidth
  200 + let height = scanWindow.height * imageHeight
  201 +
  202 + let scaledScanWindow = CGRect(x: minX, y: minY, width: width, height: height)
  203 + return scaledScanWindow.contains(barcodeBoundingBox)
  204 + }
  205 +
161 func start(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { 206 func start(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) {
162 if (device != nil) { 207 if (device != nil) {
163 result(FlutterError(code: "MobileScanner", 208 result(FlutterError(code: "MobileScanner",
@@ -319,4 +364,8 @@ class MapArgumentReader { @@ -319,4 +364,8 @@ class MapArgumentReader {
319 return args?[key] as? [String] 364 return args?[key] as? [String]
320 } 365 }
321 366
  367 + func floatArray(key: String) -> [CGFloat]? {
  368 + return args?[key] as? [CGFloat]
  369 + }
  370 +
322 } 371 }