Showing
4 changed files
with
157 additions
and
106 deletions
| 1 | import 'dart:async'; | 1 | import 'dart:async'; |
| 2 | 2 | ||
| 3 | import 'package:flutter/material.dart'; | 3 | import 'package:flutter/material.dart'; |
| 4 | +import 'package:flutter/services.dart'; | ||
| 4 | import 'package:mobile_scanner/mobile_scanner.dart'; | 5 | import 'package:mobile_scanner/mobile_scanner.dart'; |
| 5 | import 'package:mobile_scanner_example/picklist/classes/barcode_at_center.dart'; | 6 | import 'package:mobile_scanner_example/picklist/classes/barcode_at_center.dart'; |
| 6 | - | ||
| 7 | import 'package:mobile_scanner_example/picklist/widgets/crosshair.dart'; | 7 | import 'package:mobile_scanner_example/picklist/widgets/crosshair.dart'; |
| 8 | import 'package:mobile_scanner_example/scanner_error_widget.dart'; | 8 | import 'package:mobile_scanner_example/scanner_error_widget.dart'; |
| 9 | 9 | ||
| 10 | +// This sample implements picklist functionality. | ||
| 11 | +// The scanning can temporarily be suspended by the user by touching the screen. | ||
| 12 | +// When the scanning is active, the crosshair turns red. | ||
| 13 | +// When the scanning is suspended, the crosshair turns green. | ||
| 14 | +// A barcode has to touch the center of viewfinder to be scanned. | ||
| 15 | +// Therefore the Crosshair widget needs to be placed at the center of the | ||
| 16 | +// MobileScanner widget to visually line up. | ||
| 10 | class BarcodeScannerPicklist extends StatefulWidget { | 17 | class BarcodeScannerPicklist extends StatefulWidget { |
| 11 | const BarcodeScannerPicklist({super.key}); | 18 | const BarcodeScannerPicklist({super.key}); |
| 12 | 19 | ||
| @@ -14,113 +21,109 @@ class BarcodeScannerPicklist extends StatefulWidget { | @@ -14,113 +21,109 @@ class BarcodeScannerPicklist extends StatefulWidget { | ||
| 14 | State<BarcodeScannerPicklist> createState() => _BarcodeScannerPicklistState(); | 21 | State<BarcodeScannerPicklist> createState() => _BarcodeScannerPicklistState(); |
| 15 | } | 22 | } |
| 16 | 23 | ||
| 17 | -class _BarcodeScannerPicklistState extends State<BarcodeScannerPicklist> | ||
| 18 | - with WidgetsBindingObserver { | 24 | +class _BarcodeScannerPicklistState extends State<BarcodeScannerPicklist> { |
| 19 | final _mobileScannerController = MobileScannerController( | 25 | final _mobileScannerController = MobileScannerController( |
| 26 | + // The controller is started from the initState method. | ||
| 20 | autoStart: false, | 27 | autoStart: false, |
| 21 | - useNewCameraSelector: true, | 28 | + // The know the placing of the barcodes, we need to know the size of the |
| 29 | + // canvas they are placed on. Unfortunately the only known reliable way | ||
| 30 | + // to get the dimensions, is to receive the complete image from the native | ||
| 31 | + // side. | ||
| 32 | + // https://github.com/juliansteenbakker/mobile_scanner/issues/1183 | ||
| 33 | + returnImage: true, | ||
| 22 | ); | 34 | ); |
| 23 | - StreamSubscription<Object?>? _barcodesSubscription; | ||
| 24 | 35 | ||
| 36 | + // On this subscription the barcodes are received. | ||
| 37 | + StreamSubscription<Object?>? _subscription; | ||
| 38 | + | ||
| 39 | + // This boolean indicates if the detection of barcodes is enabled or | ||
| 40 | + // temporarily suspended. | ||
| 25 | final _scannerEnabled = ValueNotifier(true); | 41 | final _scannerEnabled = ValueNotifier(true); |
| 26 | 42 | ||
| 27 | - bool barcodeDetected = false; | 43 | + // This boolean is used to prevent multiple pops. |
| 44 | + var _validBarcodeFound = false; | ||
| 28 | 45 | ||
| 29 | @override | 46 | @override |
| 30 | void initState() { | 47 | void initState() { |
| 31 | - WidgetsBinding.instance.addObserver(this); | ||
| 32 | - _barcodesSubscription = _mobileScannerController.barcodes.listen( | ||
| 33 | - _handleBarcodes, | ||
| 34 | - ); | 48 | + // Enable and disable scanning on the native side, so we don't get a stream |
| 49 | + // of images when not needed. This also improves the behavior (false | ||
| 50 | + // positives) when the user switches quickly to another barcode after | ||
| 51 | + // enabling the scanner by releasing the finger. | ||
| 52 | + _scannerEnabled.addListener(() { | ||
| 53 | + _scannerEnabled.value | ||
| 54 | + ? _mobileScannerController.updateScanWindow(null) | ||
| 55 | + : _mobileScannerController.updateScanWindow(Rect.zero); | ||
| 56 | + }); | ||
| 57 | + // Lock to portrait (may not work on iPad with multitasking). | ||
| 58 | + SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); | ||
| 59 | + // Get a stream subscription and listen to received barcodes. | ||
| 60 | + _subscription = _mobileScannerController.barcodes.listen(_handleBarcodes); | ||
| 35 | super.initState(); | 61 | super.initState(); |
| 62 | + // Start the controller to start scanning. | ||
| 36 | unawaited(_mobileScannerController.start()); | 63 | unawaited(_mobileScannerController.start()); |
| 37 | } | 64 | } |
| 38 | 65 | ||
| 39 | @override | 66 | @override |
| 40 | - void didChangeAppLifecycleState(AppLifecycleState state) { | ||
| 41 | - if (!_mobileScannerController.value.isInitialized) { | ||
| 42 | - return; | ||
| 43 | - } | ||
| 44 | - | ||
| 45 | - switch (state) { | ||
| 46 | - case AppLifecycleState.detached: | ||
| 47 | - case AppLifecycleState.hidden: | ||
| 48 | - case AppLifecycleState.paused: | ||
| 49 | - return; | ||
| 50 | - case AppLifecycleState.resumed: | ||
| 51 | - _barcodesSubscription = | ||
| 52 | - _mobileScannerController.barcodes.listen(_handleBarcodes); | ||
| 53 | - | ||
| 54 | - unawaited(_mobileScannerController.start()); | ||
| 55 | - case AppLifecycleState.inactive: | ||
| 56 | - unawaited(_barcodesSubscription?.cancel()); | ||
| 57 | - _barcodesSubscription = null; | ||
| 58 | - unawaited(_mobileScannerController.stop()); | ||
| 59 | - } | ||
| 60 | - } | ||
| 61 | - | ||
| 62 | - @override | ||
| 63 | void dispose() { | 67 | void dispose() { |
| 64 | - WidgetsBinding.instance.removeObserver(this); | ||
| 65 | - unawaited(_barcodesSubscription?.cancel()); | ||
| 66 | - _barcodesSubscription = null; | 68 | + // Cancel the stream subscription. |
| 69 | + unawaited(_subscription?.cancel()); | ||
| 70 | + _subscription = null; | ||
| 67 | super.dispose(); | 71 | super.dispose(); |
| 72 | + // Dispose the controller. | ||
| 68 | _mobileScannerController.dispose(); | 73 | _mobileScannerController.dispose(); |
| 69 | } | 74 | } |
| 70 | 75 | ||
| 71 | - void _handleBarcodes(BarcodeCapture capture) { | ||
| 72 | - if (!_scannerEnabled.value) { | 76 | + // Check the list of barcodes only if scannerEnables is true. |
| 77 | + // Only take the barcode that is at the center of the image. | ||
| 78 | + // Return the barcode found to the calling page with the help of the | ||
| 79 | + // navigator. | ||
| 80 | + void _handleBarcodes(BarcodeCapture barcodeCapture) { | ||
| 81 | + // Discard all events when the scanner is disabled or when already a valid | ||
| 82 | + // barcode is found. | ||
| 83 | + if (!_scannerEnabled.value || _validBarcodeFound) { | ||
| 73 | return; | 84 | return; |
| 74 | } | 85 | } |
| 75 | - | ||
| 76 | - for (final barcode in capture.barcodes) { | ||
| 77 | - if (isBarcodeAtCenterOfImage( | ||
| 78 | - cameraOutputSize: _mobileScannerController.value.size, | ||
| 79 | - barcode: barcode, | ||
| 80 | - )) { | ||
| 81 | - if (!barcodeDetected) { | ||
| 82 | - barcodeDetected = true; | ||
| 83 | - Navigator.of(context).pop(barcode); | ||
| 84 | - } | ||
| 85 | - return; | ||
| 86 | - } | 86 | + final barcode = findBarcodeAtCenter(barcodeCapture); |
| 87 | + if (barcode != null) { | ||
| 88 | + _validBarcodeFound = true; | ||
| 89 | + Navigator.of(context).pop(barcode); | ||
| 87 | } | 90 | } |
| 88 | } | 91 | } |
| 89 | 92 | ||
| 90 | @override | 93 | @override |
| 91 | Widget build(BuildContext context) { | 94 | Widget build(BuildContext context) { |
| 92 | - return Scaffold( | ||
| 93 | - appBar: AppBar(title: const Text('Picklist scanner')), | ||
| 94 | - backgroundColor: Colors.black, | ||
| 95 | - body: StreamBuilder( | ||
| 96 | - stream: _mobileScannerController.barcodes, | ||
| 97 | - builder: (context, snapshot) { | ||
| 98 | - return Listener( | ||
| 99 | - behavior: HitTestBehavior.opaque, | ||
| 100 | - onPointerDown: (_) => _scannerEnabled.value = false, | ||
| 101 | - onPointerUp: (_) => _scannerEnabled.value = true, | ||
| 102 | - onPointerCancel: (_) => _scannerEnabled.value = true, | ||
| 103 | - child: Stack( | ||
| 104 | - fit: StackFit.expand, | ||
| 105 | - children: [ | ||
| 106 | - MobileScanner( | ||
| 107 | - controller: _mobileScannerController, | ||
| 108 | - errorBuilder: (context, error, child) => | ||
| 109 | - ScannerErrorWidget(error: error), | ||
| 110 | - fit: BoxFit.contain, | ||
| 111 | - ), | ||
| 112 | - ValueListenableBuilder( | ||
| 113 | - valueListenable: _scannerEnabled, | ||
| 114 | - builder: (context, value, child) { | ||
| 115 | - return Crosshair( | ||
| 116 | - scannerEnabled: value, | ||
| 117 | - ); | ||
| 118 | - }, | ||
| 119 | - ), | ||
| 120 | - ], | ||
| 121 | - ), | ||
| 122 | - ); | ||
| 123 | - }, | 95 | + return PopScope( |
| 96 | + onPopInvokedWithResult: (didPop, result) { | ||
| 97 | + // Reset the page orientation to the system default values, when this page is popped | ||
| 98 | + if (!didPop) { | ||
| 99 | + return; | ||
| 100 | + } | ||
| 101 | + SystemChrome.setPreferredOrientations(<DeviceOrientation>[]); | ||
| 102 | + }, | ||
| 103 | + child: Scaffold( | ||
| 104 | + appBar: AppBar(title: const Text('Picklist scanner')), | ||
| 105 | + backgroundColor: Colors.black, | ||
| 106 | + body: Listener( | ||
| 107 | + // Detect if the user touches the screen and disable/enable the scanner accordingly | ||
| 108 | + behavior: HitTestBehavior.opaque, | ||
| 109 | + onPointerDown: (_) => _scannerEnabled.value = false, | ||
| 110 | + onPointerUp: (_) => _scannerEnabled.value = true, | ||
| 111 | + onPointerCancel: (_) => _scannerEnabled.value = true, | ||
| 112 | + // A stack containing the image feed and the crosshair | ||
| 113 | + // The location of the crosshair must be at the center of the MobileScanner, otherwise the detection area and the visual representation do not line up. | ||
| 114 | + child: Stack( | ||
| 115 | + fit: StackFit.expand, | ||
| 116 | + children: [ | ||
| 117 | + MobileScanner( | ||
| 118 | + controller: _mobileScannerController, | ||
| 119 | + errorBuilder: (context, error, child) => | ||
| 120 | + ScannerErrorWidget(error: error), | ||
| 121 | + fit: BoxFit.contain, | ||
| 122 | + ), | ||
| 123 | + Crosshair(_scannerEnabled), | ||
| 124 | + ], | ||
| 125 | + ), | ||
| 126 | + ), | ||
| 124 | ), | 127 | ), |
| 125 | ); | 128 | ); |
| 126 | } | 129 | } |
| 1 | import 'package:flutter/material.dart'; | 1 | import 'package:flutter/material.dart'; |
| 2 | import 'package:mobile_scanner/mobile_scanner.dart'; | 2 | import 'package:mobile_scanner/mobile_scanner.dart'; |
| 3 | 3 | ||
| 4 | -bool isBarcodeAtCenterOfImage({ | ||
| 5 | - required Size cameraOutputSize, | ||
| 6 | - required Barcode barcode, | 4 | +/// This function finds the barcode that touches the center of the |
| 5 | +/// image. If no barcode is found that touches the center, null is returned. | ||
| 6 | +/// See [_BarcodeScannerPicklistState] and the returnImage option for more info. | ||
| 7 | +/// | ||
| 8 | +/// https://github.com/juliansteenbakker/mobile_scanner/issues/1183 | ||
| 9 | +Barcode? findBarcodeAtCenter(BarcodeCapture barcodeCapture) { | ||
| 10 | + final imageSize = barcodeCapture.size; | ||
| 11 | + for (final barcode in barcodeCapture.barcodes) { | ||
| 12 | + if (_isPolygonTouchingTheCenter( | ||
| 13 | + imageSize: imageSize, | ||
| 14 | + polygon: barcode.corners, | ||
| 15 | + )) { | ||
| 16 | + return barcode; | ||
| 17 | + } | ||
| 18 | + } | ||
| 19 | + return null; | ||
| 20 | +} | ||
| 21 | + | ||
| 22 | +/// Check if the polygon, represented by a list of offsets, touches the center of | ||
| 23 | +/// an image when the size of the image is given. | ||
| 24 | +bool _isPolygonTouchingTheCenter({ | ||
| 25 | + required Size imageSize, | ||
| 26 | + required List<Offset> polygon, | ||
| 7 | }) { | 27 | }) { |
| 8 | final centerOfCameraOutput = Offset( | 28 | final centerOfCameraOutput = Offset( |
| 9 | - cameraOutputSize.width / 2, | ||
| 10 | - cameraOutputSize.height / 2, | 29 | + imageSize.width / 2, |
| 30 | + imageSize.height / 2, | ||
| 11 | ); | 31 | ); |
| 12 | - debugPrint(cameraOutputSize.toString()); | ||
| 13 | return _isPointInPolygon( | 32 | return _isPointInPolygon( |
| 14 | point: centerOfCameraOutput, | 33 | point: centerOfCameraOutput, |
| 15 | - polygon: barcode.corners, | 34 | + polygon: polygon, |
| 16 | ); | 35 | ); |
| 17 | } | 36 | } |
| 18 | 37 | ||
| 19 | -//This is what chatGPT came up with. | ||
| 20 | -//https://en.wikipedia.org/wiki/Point_in_polygon | 38 | +/// Credits to chatGPT: |
| 39 | +/// Checks if a given [point] is inside the [polygon] boundaries. | ||
| 40 | +/// | ||
| 41 | +/// Parameters: | ||
| 42 | +/// - [point]: The `Offset` (usually represents a point in 2D space) to check. | ||
| 43 | +/// - [polygon]: A List of `Offset` representing the vertices of the polygon. | ||
| 44 | +/// | ||
| 45 | +/// Returns: | ||
| 46 | +/// - A boolean value: `true` if the point is inside the polygon, or `false` otherwise. | ||
| 47 | +/// | ||
| 48 | +/// Uses the ray-casting algorithm based on the Jordan curve theorem. | ||
| 21 | bool _isPointInPolygon({ | 49 | bool _isPointInPolygon({ |
| 22 | required Offset point, | 50 | required Offset point, |
| 23 | required List<Offset> polygon, | 51 | required List<Offset> polygon, |
| 24 | }) { | 52 | }) { |
| 25 | - int i; | ||
| 26 | - int j = polygon.length - 1; | ||
| 27 | - bool inside = false; | 53 | + // Initial variables: |
| 54 | + int i; // Loop variable for current vertex | ||
| 55 | + int j = polygon.length - | ||
| 56 | + 1; // Last vertex index, initialized to the last vertex of the polygon | ||
| 57 | + bool inside = false; // Boolean flag initialized to false | ||
| 28 | 58 | ||
| 59 | + // Loop through each edge of the polygon | ||
| 29 | for (i = 0; i < polygon.length; j = i++) { | 60 | for (i = 0; i < polygon.length; j = i++) { |
| 61 | + // Check if point's y-coordinate is within the y-boundaries of the edge | ||
| 30 | if (((polygon[i].dy > point.dy) != (polygon[j].dy > point.dy)) && | 62 | if (((polygon[i].dy > point.dy) != (polygon[j].dy > point.dy)) && |
| 63 | + // Check if the point's x-coordinate is to the left of the edge | ||
| 31 | (point.dx < | 64 | (point.dx < |
| 32 | - (polygon[j].dx - polygon[i].dx) * | ||
| 33 | - (point.dy - polygon[i].dy) / | ||
| 34 | - (polygon[j].dy - polygon[i].dy) + | 65 | + (polygon[j].dx - |
| 66 | + polygon[i] | ||
| 67 | + .dx) * // Horizontal distance between the vertices of the edge | ||
| 68 | + (point.dy - | ||
| 69 | + polygon[i] | ||
| 70 | + .dy) / // Scale factor based on the y-distance of the point to the lower vertex | ||
| 71 | + (polygon[j].dy - | ||
| 72 | + polygon[i] | ||
| 73 | + .dy) + // Vertical distance between the vertices of the edge | ||
| 35 | polygon[i].dx)) { | 74 | polygon[i].dx)) { |
| 75 | + // Horizontal position of the lower vertex | ||
| 76 | + // If the ray intersects the polygon edge, invert the inside flag | ||
| 36 | inside = !inside; | 77 | inside = !inside; |
| 37 | } | 78 | } |
| 38 | } | 79 | } |
| 80 | + // Return the status of the inside flag which tells if the point is inside the polygon or not | ||
| 39 | return inside; | 81 | return inside; |
| 40 | } | 82 | } |
| @@ -15,7 +15,7 @@ class _PicklistResultState extends State<PicklistResult> { | @@ -15,7 +15,7 @@ class _PicklistResultState extends State<PicklistResult> { | ||
| 15 | @override | 15 | @override |
| 16 | Widget build(BuildContext context) { | 16 | Widget build(BuildContext context) { |
| 17 | return Scaffold( | 17 | return Scaffold( |
| 18 | - appBar: AppBar(title: const Text('Picklist mode')), | 18 | + appBar: AppBar(title: const Text('Picklist result')), |
| 19 | body: SafeArea( | 19 | body: SafeArea( |
| 20 | child: Padding( | 20 | child: Padding( |
| 21 | padding: const EdgeInsets.symmetric(horizontal: 16.0), | 21 | padding: const EdgeInsets.symmetric(horizontal: 16.0), |
| 1 | +import 'package:flutter/foundation.dart'; | ||
| 1 | import 'package:flutter/material.dart'; | 2 | import 'package:flutter/material.dart'; |
| 2 | 3 | ||
| 3 | class Crosshair extends StatelessWidget { | 4 | class Crosshair extends StatelessWidget { |
| 4 | - const Crosshair({ | 5 | + const Crosshair( |
| 6 | + this.scannerEnabled, { | ||
| 5 | super.key, | 7 | super.key, |
| 6 | - required this.scannerEnabled, | ||
| 7 | }); | 8 | }); |
| 8 | 9 | ||
| 9 | - final bool scannerEnabled; | 10 | + final ValueListenable<bool> scannerEnabled; |
| 10 | 11 | ||
| 11 | @override | 12 | @override |
| 12 | Widget build(BuildContext context) { | 13 | Widget build(BuildContext context) { |
| 13 | - return Center( | ||
| 14 | - child: Icon( | ||
| 15 | - Icons.close, | ||
| 16 | - color: scannerEnabled ? Colors.red : Colors.green, | ||
| 17 | - ), | 14 | + return ValueListenableBuilder( |
| 15 | + valueListenable: scannerEnabled, | ||
| 16 | + builder: (context, value, child) { | ||
| 17 | + return Center( | ||
| 18 | + child: Icon( | ||
| 19 | + Icons.close, | ||
| 20 | + color: scannerEnabled.value ? Colors.red : Colors.green, | ||
| 21 | + ), | ||
| 22 | + ); | ||
| 23 | + }, | ||
| 18 | ); | 24 | ); |
| 19 | } | 25 | } |
| 20 | } | 26 | } |
-
Please register or login to post a comment