Julian Steenbakker
Committed by GitHub

Merge pull request #49 from juliansteenbakker/web

Feature: web support
1 -<?xml version="1.0" encoding="UTF-8"?>  
2 -<Workspace  
3 - version = "1.0">  
4 - <FileRef  
5 - location = "group:Runner.xcodeproj">  
6 - </FileRef>  
7 - <FileRef  
8 - location = "group:Pods/Pods.xcodeproj">  
9 - </FileRef>  
10 -</Workspace>  
1 -<?xml version="1.0" encoding="UTF-8"?>  
2 -<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">  
3 -<plist version="1.0">  
4 -<dict>  
5 - <key>IDEDidComputeMac32BitWarning</key>  
6 - <true/>  
7 -</dict>  
8 -</plist>  
  1 +<!DOCTYPE html>
  2 +<html>
  3 +<head>
  4 + <!--
  5 + If you are serving your web app in a path other than the root, change the
  6 + href value below to reflect the base path you are serving from.
  7 +
  8 + The path provided below has to start and end with a slash "/" in order for
  9 + it to work correctly.
  10 +
  11 + Fore more details:
  12 + * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
  13 + -->
  14 + <base href="/">
  15 +
  16 + <meta charset="UTF-8">
  17 + <meta content="IE=Edge" http-equiv="X-UA-Compatible">
  18 + <meta name="description" content="A new Flutter project.">
  19 +
  20 + <!-- iOS meta tags & icons -->
  21 + <meta name="apple-mobile-web-app-capable" content="yes">
  22 + <meta name="apple-mobile-web-app-status-bar-style" content="black">
  23 + <meta name="apple-mobile-web-app-title" content="example">
  24 + <link rel="apple-touch-icon" href="icons/Icon-192.png">
  25 +
  26 + <!-- Favicon -->
  27 + <link rel="icon" type="image/png" href="favicon.png"/>
  28 +
  29 + <title>example</title>
  30 + <link rel="manifest" href="manifest.json">
  31 +<!-- <script src="https://cdn.jsdelivr.net/npm/qr-scanner@1.4.1/qr-scanner.min.js"></script>-->
  32 + <script src="https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js"></script>
  33 +</head>
  34 +<body>
  35 + <!-- This script installs service_worker.js to provide PWA functionality to
  36 + application. For more information, see:
  37 + https://developers.google.com/web/fundamentals/primers/service-workers -->
  38 + <script>
  39 + if ('serviceWorker' in navigator) {
  40 + window.addEventListener('flutter-first-frame', function () {
  41 + navigator.serviceWorker.register('flutter_service_worker.js');
  42 + });
  43 + }
  44 + </script>
  45 + <script src="main.dart.js" type="application/javascript"></script>
  46 +</body>
  47 +</html>
  1 +{
  2 + "name": "Mobile Scanner Example",
  3 + "short_name": "mobile_scanner_example",
  4 + "start_url": ".",
  5 + "display": "standalone",
  6 + "background_color": "#0175C2",
  7 + "theme_color": "#0175C2",
  8 + "description": "A barcode and qr code scanner example.",
  9 + "orientation": "portrait-primary",
  10 + "prefer_related_applications": false,
  11 + "icons": [
  12 + {
  13 + "src": "icons/Icon-192.png",
  14 + "sizes": "192x192",
  15 + "type": "image/png"
  16 + },
  17 + {
  18 + "src": "icons/Icon-512.png",
  19 + "sizes": "512x512",
  20 + "type": "image/png"
  21 + }
  22 + ]
  23 +}
  1 +import 'dart:async';
  2 +
  3 +import 'package:flutter/material.dart';
  4 +import 'package:flutter/services.dart';
  5 +import 'package:flutter_web_plugins/flutter_web_plugins.dart';
  6 +import 'package:mobile_scanner/mobile_scanner.dart';
  7 +import 'package:mobile_scanner/src/web/jsqr.dart';
  8 +import 'dart:html' as html;
  9 +import 'dart:ui' as ui;
  10 +
  11 +import 'package:mobile_scanner/src/web/media.dart';
  12 +
  13 +/// This plugin is the web implementation of mobile_scanner.
  14 +/// It only supports QR codes.
  15 +class MobileScannerWebPlugin {
  16 + static void registerWith(Registrar registrar) {
  17 + PluginEventChannel event = PluginEventChannel(
  18 + 'dev.steenbakker.mobile_scanner/scanner/event',
  19 + const StandardMethodCodec(),
  20 + registrar);
  21 + MethodChannel channel = MethodChannel(
  22 + 'dev.steenbakker.mobile_scanner/scanner/method',
  23 + const StandardMethodCodec(),
  24 + registrar);
  25 + final MobileScannerWebPlugin instance = MobileScannerWebPlugin();
  26 + WidgetsFlutterBinding.ensureInitialized();
  27 +
  28 + channel.setMethodCallHandler(instance.handleMethodCall);
  29 + event.setController(instance.controller);
  30 + }
  31 +
  32 + // Controller to send events back to the framework
  33 + StreamController controller = StreamController();
  34 +
  35 + // The video stream. Will be initialized later to see which camera needs to be used.
  36 + html.MediaStream? _localStream;
  37 + html.VideoElement video = html.VideoElement();
  38 +
  39 + // ID of the video feed
  40 + String viewID = 'WebScanner-${DateTime.now().millisecondsSinceEpoch}';
  41 +
  42 + // Determine wether device has flas
  43 + bool hasFlash = false;
  44 +
  45 + // Timer used to capture frames to be analyzed
  46 + Timer? _frameInterval;
  47 +
  48 + html.DivElement vidDiv = html.DivElement();
  49 +
  50 + /// Handle incomming messages
  51 + Future<dynamic> handleMethodCall(MethodCall call) async {
  52 + switch (call.method) {
  53 + case 'start':
  54 + return await _start(call.arguments);
  55 + case 'torch':
  56 + return await _torch(call.arguments);
  57 + case 'stop':
  58 + return await cancel();
  59 + default:
  60 + throw PlatformException(
  61 + code: 'Unimplemented',
  62 + details: "The mobile_scanner plugin for web doesn't implement "
  63 + "the method '${call.method}'");
  64 + }
  65 + }
  66 +
  67 + /// Can enable or disable the flash if available
  68 + Future<void> _torch(arguments) async {
  69 + if (hasFlash) {
  70 + final track = _localStream?.getVideoTracks();
  71 + await track!.first.applyConstraints({
  72 + 'advanced': {'torch': arguments == 1}
  73 + });
  74 + } else {
  75 + controller.addError('Device has no flash');
  76 + }
  77 + }
  78 +
  79 + /// Starts the video stream and the scanner
  80 + Future<Map> _start(arguments) async {
  81 + vidDiv.children = [video];
  82 +
  83 + final CameraFacing cameraFacing =
  84 + arguments['cameraFacing'] ?? CameraFacing.front;
  85 +
  86 + // See https://github.com/flutter/flutter/issues/41563
  87 + // ignore: UNDEFINED_PREFIXED_NAME
  88 + ui.platformViewRegistry.registerViewFactory(
  89 + viewID,
  90 + (int id) => vidDiv
  91 + ..style.width = '100%'
  92 + ..style.height = '100%');
  93 +
  94 + // Check if stream is running
  95 + if (_localStream != null) {
  96 + return {
  97 + 'ViewID': viewID,
  98 + 'videoWidth': video.videoWidth,
  99 + 'videoHeight': video.videoHeight
  100 + };
  101 + }
  102 +
  103 + try {
  104 + // Check if browser supports multiple camera's and set if supported
  105 + Map? capabilities =
  106 + html.window.navigator.mediaDevices?.getSupportedConstraints();
  107 + if (capabilities != null && capabilities['facingMode']) {
  108 + UserMediaOptions constraints = UserMediaOptions(
  109 + video: VideoOptions(
  110 + facingMode:
  111 + (cameraFacing == CameraFacing.front ? 'user' : 'environment'),
  112 + width: {'ideal': 4096},
  113 + height: {'ideal': 2160},
  114 + ));
  115 +
  116 + _localStream =
  117 + await html.window.navigator.getUserMedia(video: constraints);
  118 + } else {
  119 + _localStream = await html.window.navigator.getUserMedia(video: true);
  120 + }
  121 +
  122 + video.srcObject = _localStream;
  123 +
  124 + // TODO: fix flash light. See https://github.com/dart-lang/sdk/issues/48533
  125 + // final track = _localStream?.getVideoTracks();
  126 + // if (track != null) {
  127 + // final imageCapture = html.ImageCapture(track.first);
  128 + // final photoCapabilities = await imageCapture.getPhotoCapabilities();
  129 + // }
  130 +
  131 + // required to tell iOS safari we don't want fullscreen
  132 + video.setAttribute('playsinline', 'true');
  133 +
  134 + await video.play();
  135 +
  136 + // Then capture a frame to be analyzed every 200 miliseconds
  137 + _frameInterval =
  138 + Timer.periodic(const Duration(milliseconds: 200), (timer) {
  139 + _captureFrame();
  140 + });
  141 +
  142 + return {
  143 + 'ViewID': viewID,
  144 + 'videoWidth': video.videoWidth,
  145 + 'videoHeight': video.videoHeight,
  146 + 'torchable': hasFlash
  147 + };
  148 + } catch (e) {
  149 + throw PlatformException(code: 'MobileScannerWeb', message: e.toString());
  150 + }
  151 + }
  152 +
  153 + /// Check if any camera's are available
  154 + static Future<bool> cameraAvailable() async {
  155 + final sources =
  156 + await html.window.navigator.mediaDevices!.enumerateDevices();
  157 + for (final e in sources) {
  158 + if (e.kind == 'videoinput') {
  159 + return true;
  160 + }
  161 + }
  162 + return false;
  163 + }
  164 +
  165 + /// Stops the video feed and analyzer
  166 + Future<void> cancel() async {
  167 + try {
  168 + // Stop the camera stream
  169 + _localStream!.getTracks().forEach((track) {
  170 + if (track.readyState == 'live') {
  171 + track.stop();
  172 + }
  173 + });
  174 + } catch (e) {
  175 + debugPrint('Failed to stop stream: $e');
  176 + }
  177 +
  178 + video.srcObject = null;
  179 + _localStream = null;
  180 + _frameInterval?.cancel();
  181 + _frameInterval = null;
  182 + }
  183 +
  184 + /// Captures a frame and analyzes it for QR codes
  185 + Future<dynamic> _captureFrame() async {
  186 + if (_localStream == null) return null;
  187 + final canvas =
  188 + html.CanvasElement(width: video.videoWidth, height: video.videoHeight);
  189 + final ctx = canvas.context2D;
  190 +
  191 + ctx.drawImage(video, 0, 0);
  192 + final imgData = ctx.getImageData(0, 0, canvas.width!, canvas.height!);
  193 +
  194 + final code = jsQR(imgData.data, canvas.width, canvas.height);
  195 + if (code != null) {
  196 + controller.add({'name': 'barcodeWeb', 'data': code.data});
  197 + }
  198 + }
  199 +}
  1 +import 'package:flutter/foundation.dart';
1 import 'package:flutter/material.dart'; 2 import 'package:flutter/material.dart';
2 import 'package:mobile_scanner/mobile_scanner.dart'; 3 import 'package:mobile_scanner/mobile_scanner.dart';
3 4
@@ -12,7 +13,7 @@ class MobileScanner extends StatefulWidget { @@ -12,7 +13,7 @@ class MobileScanner extends StatefulWidget {
12 /// 13 ///
13 /// [barcode] The barcode object with all information about the scanned code. 14 /// [barcode] The barcode object with all information about the scanned code.
14 /// [args] Information about the state of the MobileScanner widget 15 /// [args] Information about the state of the MobileScanner widget
15 - final Function(Barcode barcode, MobileScannerArguments args)? onDetect; 16 + final Function(Barcode barcode, MobileScannerArguments? args)? onDetect;
16 17
17 /// TODO: Function that gets called when the Widget is initialized. Can be usefull 18 /// TODO: Function that gets called when the Widget is initialized. Can be usefull
18 /// to check wether the device has a torch(flash) or not. 19 /// to check wether the device has a torch(flash) or not.
@@ -78,7 +79,9 @@ class _MobileScannerState extends State<MobileScanner> @@ -78,7 +79,9 @@ class _MobileScannerState extends State<MobileScanner>
78 child: SizedBox( 79 child: SizedBox(
79 width: value.size.width, 80 width: value.size.width,
80 height: value.size.height, 81 height: value.size.height,
81 - child: Texture(textureId: value.textureId), 82 + child: kIsWeb
  83 + ? HtmlElementView(viewType: value.webId!)
  84 + : Texture(textureId: value.textureId!),
82 ), 85 ),
83 ), 86 ),
84 ), 87 ),
@@ -3,14 +3,16 @@ import 'package:flutter/material.dart'; @@ -3,14 +3,16 @@ import 'package:flutter/material.dart';
3 /// Camera args for [CameraView]. 3 /// Camera args for [CameraView].
4 class MobileScannerArguments { 4 class MobileScannerArguments {
5 /// The texture id. 5 /// The texture id.
6 - final int textureId; 6 + final int? textureId;
7 7
8 /// Size of the texture. 8 /// Size of the texture.
9 final Size size; 9 final Size size;
10 10
11 final bool hasTorch; 11 final bool hasTorch;
12 12
  13 + final String? webId;
  14 +
13 /// Create a [MobileScannerArguments]. 15 /// Create a [MobileScannerArguments].
14 MobileScannerArguments( 16 MobileScannerArguments(
15 - {required this.textureId, required this.size, required this.hasTorch}); 17 + {this.textureId, required this.size, required this.hasTorch, this.webId});
16 } 18 }
@@ -2,6 +2,7 @@ import 'dart:async'; @@ -2,6 +2,7 @@ import 'dart:async';
2 import 'dart:io'; 2 import 'dart:io';
3 3
4 import 'package:flutter/cupertino.dart'; 4 import 'package:flutter/cupertino.dart';
  5 +import 'package:flutter/foundation.dart';
5 import 'package:flutter/services.dart'; 6 import 'package:flutter/services.dart';
6 import 'package:mobile_scanner/mobile_scanner.dart'; 7 import 'package:mobile_scanner/mobile_scanner.dart';
7 8
@@ -97,6 +98,9 @@ class MobileScannerController { @@ -97,6 +98,9 @@ class MobileScannerController {
97 case 'barcodeMac': 98 case 'barcodeMac':
98 barcodesController.add(Barcode(rawValue: data['payload'])); 99 barcodesController.add(Barcode(rawValue: data['payload']));
99 break; 100 break;
  101 + case 'barcodeWeb':
  102 + barcodesController.add(Barcode(rawValue: data));
  103 + break;
100 default: 104 default:
101 throw UnimplementedError(); 105 throw UnimplementedError();
102 } 106 }
@@ -124,13 +128,15 @@ class MobileScannerController { @@ -124,13 +128,15 @@ class MobileScannerController {
124 // setAnalyzeMode(AnalyzeMode.barcode.index); 128 // setAnalyzeMode(AnalyzeMode.barcode.index);
125 129
126 // Check authorization status 130 // Check authorization status
  131 + if (!kIsWeb) {
127 MobileScannerState state = 132 MobileScannerState state =
128 MobileScannerState.values[await methodChannel.invokeMethod('state')]; 133 MobileScannerState.values[await methodChannel.invokeMethod('state')];
129 switch (state) { 134 switch (state) {
130 case MobileScannerState.undetermined: 135 case MobileScannerState.undetermined:
131 final bool result = await methodChannel.invokeMethod('request'); 136 final bool result = await methodChannel.invokeMethod('request');
132 - state =  
133 - result ? MobileScannerState.authorized : MobileScannerState.denied; 137 + state = result
  138 + ? MobileScannerState.authorized
  139 + : MobileScannerState.denied;
134 break; 140 break;
135 case MobileScannerState.denied: 141 case MobileScannerState.denied:
136 isStarting = false; 142 isStarting = false;
@@ -138,6 +144,7 @@ class MobileScannerController { @@ -138,6 +144,7 @@ class MobileScannerController {
138 case MobileScannerState.authorized: 144 case MobileScannerState.authorized:
139 break; 145 break;
140 } 146 }
  147 + }
141 148
142 cameraFacingState.value = facing; 149 cameraFacingState.value = facing;
143 150
@@ -173,10 +180,19 @@ class MobileScannerController { @@ -173,10 +180,19 @@ class MobileScannerController {
173 } 180 }
174 181
175 hasTorch = startResult['torchable']; 182 hasTorch = startResult['torchable'];
  183 +
  184 + if (kIsWeb) {
  185 + args.value = MobileScannerArguments(
  186 + webId: startResult['ViewID'],
  187 + size: Size(startResult['videoWidth'], startResult['videoHeight']),
  188 + hasTorch: hasTorch);
  189 + } else {
176 args.value = MobileScannerArguments( 190 args.value = MobileScannerArguments(
177 textureId: startResult['textureId'], 191 textureId: startResult['textureId'],
178 size: toSize(startResult['size']), 192 size: toSize(startResult['size']),
179 hasTorch: hasTorch); 193 hasTorch: hasTorch);
  194 + }
  195 +
180 isStarting = false; 196 isStarting = false;
181 } 197 }
182 198
  1 +@JS()
  2 +library jsqr;
  3 +
  4 +import 'package:js/js.dart';
  5 +
  6 +@JS('jsQR')
  7 +external Code? jsQR(var data, int? width, int? height);
  8 +
  9 +@JS()
  10 +class Code {
  11 + external String get data;
  12 +}
  1 +// // This is here because dart doesn't seem to support this properly
  2 +// // https://stackoverflow.com/questions/61161135/adding-support-for-navigator-mediadevices-getusermedia-to-dart
  3 +
  4 +@JS('navigator.mediaDevices')
  5 +library media_devices;
  6 +
  7 +import 'package:js/js.dart';
  8 +
  9 +@JS('getUserMedia')
  10 +external Future<dynamic> getUserMedia(UserMediaOptions constraints);
  11 +
  12 +@JS()
  13 +@anonymous
  14 +class UserMediaOptions {
  15 + external VideoOptions get video;
  16 +
  17 + external factory UserMediaOptions({VideoOptions? video});
  18 +}
  19 +
  20 +@JS()
  21 +@anonymous
  22 +class VideoOptions {
  23 + external String get facingMode;
  24 + // external DeviceIdOptions get deviceId;
  25 + external Map get width;
  26 + external Map get height;
  27 +
  28 + external factory VideoOptions(
  29 + {String? facingMode, DeviceIdOptions? deviceId, Map? width, Map? height});
  30 +}
  31 +
  32 +@JS()
  33 +@anonymous
  34 +class DeviceIdOptions {
  35 + external String get exact;
  36 +
  37 + external factory DeviceIdOptions({String? exact});
  38 +}
  1 +@JS()
  2 +library qrscanner;
  3 +
  4 +import 'package:js/js.dart';
  5 +
  6 +@JS('QrScanner')
  7 +external String scanImage(var data);
  8 +
  9 +@JS()
  10 +class QrScanner {
  11 + external String get scanImage;
  12 +}
@@ -8,8 +8,11 @@ environment: @@ -8,8 +8,11 @@ environment:
8 flutter: ">=1.10.0" 8 flutter: ">=1.10.0"
9 9
10 dependencies: 10 dependencies:
  11 + js: ^0.6.3
11 flutter: 12 flutter:
12 sdk: flutter 13 sdk: flutter
  14 + flutter_web_plugins:
  15 + sdk: flutter
13 16
14 dev_dependencies: 17 dev_dependencies:
15 flutter_test: 18 flutter_test:
@@ -26,3 +29,6 @@ flutter: @@ -26,3 +29,6 @@ flutter:
26 pluginClass: MobileScannerPlugin 29 pluginClass: MobileScannerPlugin
27 macos: 30 macos:
28 pluginClass: MobileScannerPlugin 31 pluginClass: MobileScannerPlugin
  32 + web:
  33 + pluginClass: MobileScannerWebPlugin
  34 + fileName: mobile_scanner_web_plugin.dart