Julian Steenbakker
Committed by GitHub

Merge pull request #1188 from jsroest/master

feat: Add picklist mode sample to the example app
1 # Uncomment this line to define a global platform for your project 1 # Uncomment this line to define a global platform for your project
2 -# platform :ios, '15.5.0' 2 +platform :ios, '15.5.0'
3 3
4 # CocoaPods analytics sends network stats synchronously affecting flutter build latency. 4 # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
5 ENV['COCOAPODS_DISABLE_STATS'] = 'true' 5 ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@@ -8,6 +8,7 @@ import 'package:mobile_scanner_example/barcode_scanner_simple.dart'; @@ -8,6 +8,7 @@ import 'package:mobile_scanner_example/barcode_scanner_simple.dart';
8 import 'package:mobile_scanner_example/barcode_scanner_window.dart'; 8 import 'package:mobile_scanner_example/barcode_scanner_window.dart';
9 import 'package:mobile_scanner_example/barcode_scanner_zoom.dart'; 9 import 'package:mobile_scanner_example/barcode_scanner_zoom.dart';
10 import 'package:mobile_scanner_example/mobile_scanner_overlay.dart'; 10 import 'package:mobile_scanner_example/mobile_scanner_overlay.dart';
  11 +import 'package:mobile_scanner_example/picklist/picklist_result.dart';
11 12
12 void main() { 13 void main() {
13 runApp( 14 runApp(
@@ -91,6 +92,11 @@ class MyHome extends StatelessWidget { @@ -91,6 +92,11 @@ class MyHome extends StatelessWidget {
91 'Analyze image from file', 92 'Analyze image from file',
92 const BarcodeScannerAnalyzeImage(), 93 const BarcodeScannerAnalyzeImage(),
93 ), 94 ),
  95 + _buildItem(
  96 + context,
  97 + 'Picklist mode',
  98 + const PicklistResult(),
  99 + ),
94 ], 100 ],
95 ), 101 ),
96 ), 102 ),
  1 +import 'dart:async';
  2 +
  3 +import 'package:flutter/material.dart';
  4 +import 'package:flutter/services.dart';
  5 +import 'package:mobile_scanner/mobile_scanner.dart';
  6 +import 'package:mobile_scanner_example/picklist/classes/barcode_at_center.dart';
  7 +import 'package:mobile_scanner_example/picklist/widgets/crosshair.dart';
  8 +import 'package:mobile_scanner_example/scanner_error_widget.dart';
  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.
  17 +class BarcodeScannerPicklist extends StatefulWidget {
  18 + const BarcodeScannerPicklist({super.key});
  19 +
  20 + @override
  21 + State<BarcodeScannerPicklist> createState() => _BarcodeScannerPicklistState();
  22 +}
  23 +
  24 +class _BarcodeScannerPicklistState extends State<BarcodeScannerPicklist> {
  25 + final _mobileScannerController = MobileScannerController(
  26 + // The controller is started from the initState method.
  27 + autoStart: false,
  28 + );
  29 +
  30 + final orientation = DeviceOrientation.portraitUp;
  31 +
  32 + // On this subscription the barcodes are received.
  33 + StreamSubscription<Object?>? _subscription;
  34 +
  35 + // This boolean indicates if the detection of barcodes is enabled or
  36 + // temporarily suspended.
  37 + final _scannerEnabled = ValueNotifier(true);
  38 +
  39 + // This boolean is used to prevent multiple pops.
  40 + var _validBarcodeFound = false;
  41 +
  42 + @override
  43 + void initState() {
  44 + // Lock to portrait (may not work on iPad with multitasking).
  45 + SystemChrome.setPreferredOrientations([orientation]);
  46 + // Get a stream subscription and listen to received barcodes.
  47 + _subscription = _mobileScannerController.barcodes.listen(_handleBarcodes);
  48 + super.initState();
  49 + // Start the controller to start scanning.
  50 + unawaited(_mobileScannerController.start());
  51 + }
  52 +
  53 + @override
  54 + void dispose() {
  55 + // Cancel the stream subscription.
  56 + unawaited(_subscription?.cancel());
  57 + _subscription = null;
  58 + super.dispose();
  59 + // Dispose the controller.
  60 + _mobileScannerController.dispose();
  61 + }
  62 +
  63 + // Check the list of barcodes only if scannerEnables is true.
  64 + // Only take the barcode that is at the center of the image.
  65 + // Return the barcode found to the calling page with the help of the
  66 + // navigator.
  67 + void _handleBarcodes(BarcodeCapture barcodeCapture) {
  68 + // Discard all events when the scanner is disabled or when already a valid
  69 + // barcode is found.
  70 + if (!_scannerEnabled.value || _validBarcodeFound) {
  71 + return;
  72 + }
  73 + final barcode = findBarcodeAtCenter(barcodeCapture, orientation);
  74 + if (barcode != null) {
  75 + _validBarcodeFound = true;
  76 + Navigator.of(context).pop(barcode);
  77 + }
  78 + }
  79 +
  80 + @override
  81 + Widget build(BuildContext context) {
  82 + return PopScope(
  83 + onPopInvokedWithResult: (didPop, result) {
  84 + // Reset the page orientation to the system default values, when this page is popped
  85 + if (!didPop) {
  86 + return;
  87 + }
  88 + SystemChrome.setPreferredOrientations(<DeviceOrientation>[]);
  89 + },
  90 + child: Scaffold(
  91 + appBar: AppBar(title: const Text('Picklist scanner')),
  92 + backgroundColor: Colors.black,
  93 + body: Listener(
  94 + // Detect if the user touches the screen and disable/enable the scanner accordingly
  95 + behavior: HitTestBehavior.opaque,
  96 + onPointerDown: (_) => _scannerEnabled.value = false,
  97 + onPointerUp: (_) => _scannerEnabled.value = true,
  98 + onPointerCancel: (_) => _scannerEnabled.value = true,
  99 + // A stack containing the image feed and the crosshair
  100 + // 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.
  101 + child: Stack(
  102 + fit: StackFit.expand,
  103 + children: [
  104 + MobileScanner(
  105 + controller: _mobileScannerController,
  106 + errorBuilder: (context, error, child) =>
  107 + ScannerErrorWidget(error: error),
  108 + fit: BoxFit.contain,
  109 + ),
  110 + Crosshair(_scannerEnabled),
  111 + ],
  112 + ),
  113 + ),
  114 + ),
  115 + );
  116 + }
  117 +}
  1 +import 'package:flutter/services.dart';
  2 +import 'package:mobile_scanner/mobile_scanner.dart';
  3 +import 'package:mobile_scanner_example/picklist/classes/fix_coordinate_space.dart';
  4 +
  5 +/// This function finds the barcode that touches the center of the
  6 +/// image. If no barcode is found that touches the center, null is returned.
  7 +/// See [_BarcodeScannerPicklistState] and the returnImage option for more info.
  8 +///
  9 +/// https://github.com/juliansteenbakker/mobile_scanner/issues/1183
  10 +Barcode? findBarcodeAtCenter(
  11 + BarcodeCapture barcodeCapture,
  12 + DeviceOrientation orientation,
  13 +) {
  14 + final imageSize = fixPortraitLandscape(barcodeCapture.size, orientation);
  15 + for (final barcode in barcodeCapture.barcodes) {
  16 + final corners = fixCorners(barcode.corners);
  17 + if (_isPolygonTouchingTheCenter(
  18 + imageSize: imageSize,
  19 + polygon: corners,
  20 + )) {
  21 + return barcode;
  22 + }
  23 + }
  24 + return null;
  25 +}
  26 +
  27 +/// Check if the polygon, represented by a list of offsets, touches the center of
  28 +/// an image when the size of the image is given.
  29 +bool _isPolygonTouchingTheCenter({
  30 + required Size imageSize,
  31 + required List<Offset> polygon,
  32 +}) {
  33 + final centerOfCameraOutput = Offset(
  34 + imageSize.width / 2,
  35 + imageSize.height / 2,
  36 + );
  37 + return _isPointInPolygon(
  38 + point: centerOfCameraOutput,
  39 + polygon: polygon,
  40 + );
  41 +}
  42 +
  43 +/// Credits to chatGPT:
  44 +/// Checks if a given [point] is inside the [polygon] boundaries.
  45 +///
  46 +/// Parameters:
  47 +/// - [point]: The `Offset` (usually represents a point in 2D space) to check.
  48 +/// - [polygon]: A List of `Offset` representing the vertices of the polygon.
  49 +///
  50 +/// Returns:
  51 +/// - A boolean value: `true` if the point is inside the polygon, or `false` otherwise.
  52 +///
  53 +/// Uses the ray-casting algorithm based on the Jordan curve theorem.
  54 +bool _isPointInPolygon({
  55 + required Offset point,
  56 + required List<Offset> polygon,
  57 +}) {
  58 + // Initial variables:
  59 + int i; // Loop variable for current vertex
  60 + int j = polygon.length -
  61 + 1; // Last vertex index, initialized to the last vertex of the polygon
  62 + bool inside = false; // Boolean flag initialized to false
  63 +
  64 + // Loop through each edge of the polygon
  65 + for (i = 0; i < polygon.length; j = i++) {
  66 + // Check if point's y-coordinate is within the y-boundaries of the edge
  67 + if (((polygon[i].dy > point.dy) != (polygon[j].dy > point.dy)) &&
  68 + // Check if the point's x-coordinate is to the left of the edge
  69 + (point.dx <
  70 + (polygon[j].dx -
  71 + polygon[i]
  72 + .dx) * // Horizontal distance between the vertices of the edge
  73 + (point.dy -
  74 + polygon[i]
  75 + .dy) / // Scale factor based on the y-distance of the point to the lower vertex
  76 + (polygon[j].dy -
  77 + polygon[i]
  78 + .dy) + // Vertical distance between the vertices of the edge
  79 + polygon[i].dx)) {
  80 + // Horizontal position of the lower vertex
  81 + // If the ray intersects the polygon edge, invert the inside flag
  82 + inside = !inside;
  83 + }
  84 + }
  85 + // Return the status of the inside flag which tells if the point is inside the polygon or not
  86 + return inside;
  87 +}
  1 +import 'package:flutter/services.dart';
  2 +
  3 +Size fixPortraitLandscape(
  4 + Size imageSize,
  5 + DeviceOrientation orientation,
  6 +) {
  7 + switch (orientation) {
  8 + case DeviceOrientation.portraitUp:
  9 + case DeviceOrientation.portraitDown:
  10 + return Size(imageSize.shortestSide, imageSize.longestSide);
  11 + case DeviceOrientation.landscapeLeft:
  12 + case DeviceOrientation.landscapeRight:
  13 + return Size(imageSize.longestSide, imageSize.shortestSide);
  14 + }
  15 +}
  16 +
  17 +List<Offset> fixCorners(List<Offset> corners) {
  18 + // Clone the original list to avoid side-effects
  19 + final sorted = List<Offset>.from(corners);
  20 +
  21 + sorted.sort((a, b) {
  22 + // Prioritize y-axis (dy), and within that, the x-axis (dx)
  23 + int compare = a.dy.compareTo(b.dy);
  24 + if (compare == 0) {
  25 + compare = a.dx.compareTo(b.dx);
  26 + }
  27 + return compare;
  28 + });
  29 +
  30 + final topLeft = sorted.first; // smallest x, smallest y
  31 + final topRight = sorted[1]; // larger x, smaller y
  32 + final bottomLeft = sorted[2]; // smaller x, larger y
  33 + final bottomRight = sorted.last; // larger x, larger y
  34 +
  35 + return [topLeft, topRight, bottomRight, bottomLeft];
  36 +}
  1 +import 'package:flutter/material.dart';
  2 +import 'package:mobile_scanner/mobile_scanner.dart';
  3 +import 'package:mobile_scanner_example/picklist/barcode_scanner_picklist.dart';
  4 +
  5 +class PicklistResult extends StatefulWidget {
  6 + const PicklistResult({super.key});
  7 +
  8 + @override
  9 + State<PicklistResult> createState() => _PicklistResultState();
  10 +}
  11 +
  12 +class _PicklistResultState extends State<PicklistResult> {
  13 + String barcode = 'Scan Something!';
  14 +
  15 + @override
  16 + Widget build(BuildContext context) {
  17 + return Scaffold(
  18 + appBar: AppBar(title: const Text('Picklist result')),
  19 + body: SafeArea(
  20 + child: Padding(
  21 + padding: const EdgeInsets.symmetric(horizontal: 16.0),
  22 + child: Center(
  23 + child: Column(
  24 + mainAxisAlignment: MainAxisAlignment.center,
  25 + children: [
  26 + Text(barcode),
  27 + ElevatedButton(
  28 + onPressed: () async {
  29 + final scannedBarcode =
  30 + await Navigator.of(context).push<Barcode>(
  31 + MaterialPageRoute(
  32 + builder: (context) => const BarcodeScannerPicklist(),
  33 + ),
  34 + );
  35 + setState(
  36 + () {
  37 + if (scannedBarcode == null) {
  38 + barcode = 'Scan Something!';
  39 + return;
  40 + }
  41 + if (scannedBarcode.displayValue == null) {
  42 + barcode = '>>binary<<';
  43 + return;
  44 + }
  45 + barcode = scannedBarcode.displayValue!;
  46 + },
  47 + );
  48 + },
  49 + child: const Text('Scan'),
  50 + ),
  51 + ],
  52 + ),
  53 + ),
  54 + ),
  55 + ),
  56 + );
  57 + }
  58 +}
  1 +import 'package:flutter/foundation.dart';
  2 +import 'package:flutter/material.dart';
  3 +
  4 +class Crosshair extends StatelessWidget {
  5 + const Crosshair(
  6 + this.scannerEnabled, {
  7 + super.key,
  8 + });
  9 +
  10 + final ValueListenable<bool> scannerEnabled;
  11 +
  12 + @override
  13 + Widget build(BuildContext context) {
  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 + },
  24 + );
  25 + }
  26 +}