Julian Steenbakker

feat: add 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/jsqr@1.4.0/dist/jsQR.min.js"></script>
  32 +</head>
  33 +<body>
  34 + <!-- This script installs service_worker.js to provide PWA functionality to
  35 + application. For more information, see:
  36 + https://developers.google.com/web/fundamentals/primers/service-workers -->
  37 + <script>
  38 + if ('serviceWorker' in navigator) {
  39 + window.addEventListener('flutter-first-frame', function () {
  40 + navigator.serviceWorker.register('flutter_service_worker.js');
  41 + });
  42 + }
  43 + </script>
  44 + <script src="main.dart.js" type="application/javascript"></script>
  45 +</body>
  46 +</html>
  1 +{
  2 + "name": "example",
  3 + "short_name": "example",
  4 + "start_url": ".",
  5 + "display": "standalone",
  6 + "background_color": "#0175C2",
  7 + "theme_color": "#0175C2",
  8 + "description": "A new Flutter project.",
  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 '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
4 import 'mobile_scanner_arguments.dart'; 5 import 'mobile_scanner_arguments.dart';
5 6
  7 +import 'web/flutter_qr_web.dart';
  8 +
6 enum Ratio { ratio_4_3, ratio_16_9 } 9 enum Ratio { ratio_4_3, ratio_16_9 }
7 10
8 /// A widget showing a live camera preview. 11 /// A widget showing a live camera preview.
@@ -14,7 +17,7 @@ class MobileScanner extends StatefulWidget { @@ -14,7 +17,7 @@ class MobileScanner extends StatefulWidget {
14 /// 17 ///
15 /// [barcode] The barcode object with all information about the scanned code. 18 /// [barcode] The barcode object with all information about the scanned code.
16 /// [args] Information about the state of the MobileScanner widget 19 /// [args] Information about the state of the MobileScanner widget
17 - final Function(Barcode barcode, MobileScannerArguments args)? onDetect; 20 + final Function(Barcode barcode, MobileScannerArguments? args)? onDetect;
18 21
19 /// TODO: Function that gets called when the Widget is initialized. Can be usefull 22 /// TODO: Function that gets called when the Widget is initialized. Can be usefull
20 /// to check wether the device has a torch(flash) or not. 23 /// to check wether the device has a torch(flash) or not.
@@ -66,34 +69,48 @@ class _MobileScannerState extends State<MobileScanner> @@ -66,34 +69,48 @@ class _MobileScannerState extends State<MobileScanner>
66 69
67 @override 70 @override
68 Widget build(BuildContext context) { 71 Widget build(BuildContext context) {
69 - return LayoutBuilder(builder: (context, BoxConstraints constraints) {  
70 - if (!onScreen) return const Text("Camera Paused.");  
71 - return ValueListenableBuilder(  
72 - valueListenable: controller.args,  
73 - builder: (context, value, child) {  
74 - value = value as MobileScannerArguments?;  
75 - if (value == null) {  
76 - return Container(color: Colors.black);  
77 - } else {  
78 - controller.barcodes.listen(  
79 - (a) => widget.onDetect!(a, value as MobileScannerArguments));  
80 - return ClipRect(  
81 - child: SizedBox(  
82 - width: MediaQuery.of(context).size.width,  
83 - height: MediaQuery.of(context).size.height,  
84 - child: FittedBox(  
85 - fit: widget.fit,  
86 - child: SizedBox(  
87 - width: value.size.width,  
88 - height: value.size.height,  
89 - child: Texture(textureId: value.textureId), 72 + if (kIsWeb) {
  73 + return createWebQrView(
  74 + onDetect: (barcode) => widget.onDetect!(barcode, null),
  75 + cameraFacing: CameraFacing.back,
  76 + );
  77 + } else {
  78 + return LayoutBuilder(builder: (context, BoxConstraints constraints) {
  79 + if (!onScreen) return const Text("Camera Paused.");
  80 + return ValueListenableBuilder(
  81 + valueListenable: controller.args,
  82 + builder: (context, value, child) {
  83 + value = value as MobileScannerArguments?;
  84 + if (value == null) {
  85 + return Container(color: Colors.black);
  86 + } else {
  87 + controller.barcodes.listen(
  88 + (a) =>
  89 + widget.onDetect!(a, value as MobileScannerArguments));
  90 + return ClipRect(
  91 + child: SizedBox(
  92 + width: MediaQuery
  93 + .of(context)
  94 + .size
  95 + .width,
  96 + height: MediaQuery
  97 + .of(context)
  98 + .size
  99 + .height,
  100 + child: FittedBox(
  101 + fit: widget.fit,
  102 + child: SizedBox(
  103 + width: value.size.width,
  104 + height: value.size.height,
  105 + child: Texture(textureId: value.textureId),
  106 + ),
90 ), 107 ),
91 ), 108 ),
92 - ),  
93 - );  
94 - }  
95 - });  
96 - }); 109 + );
  110 + }
  111 + });
  112 + });
  113 + }
97 } 114 }
98 115
99 @override 116 @override
  1 +// import 'package:flutter/material.dart';
  2 +// import 'package:qr_code_scanner/src/types/camera.dart';
  3 +//
  4 +// Widget createWebQrView({onPlatformViewCreated, CameraFacing? cameraFacing}) =>
  5 +// const SizedBox();
  1 +// ignore_for_file: avoid_web_libraries_in_flutter
  2 +
  3 +import 'dart:async';
  4 +import 'dart:core';
  5 +import 'dart:html' as html;
  6 +import 'dart:js_util';
  7 +import 'dart:ui' as ui;
  8 +
  9 +import 'package:flutter/material.dart';
  10 +
  11 +import '../../mobile_scanner.dart';
  12 +import 'jsqr.dart';
  13 +import 'media.dart';
  14 +
  15 +/// Even though it has been highly modified, the origial implementation has been
  16 +/// adopted from https://github.com:treeder/jsqr_flutter
  17 +///
  18 +/// Copyright 2020 @treeder
  19 +/// Copyright 2021 The one with the braid
  20 +
  21 +class WebQrView extends StatefulWidget {
  22 + final Function(Barcode) onDetect;
  23 + final CameraFacing? cameraFacing;
  24 +
  25 + const WebQrView(
  26 + {Key? key,
  27 + required this.onDetect,
  28 + this.cameraFacing = CameraFacing.front})
  29 + : super(key: key);
  30 +
  31 + @override
  32 + _WebQrViewState createState() => _WebQrViewState();
  33 +
  34 + static html.DivElement vidDiv =
  35 + html.DivElement(); // need a global for the registerViewFactory
  36 +
  37 + static Future<bool> cameraAvailable() async {
  38 + final sources =
  39 + await html.window.navigator.mediaDevices!.enumerateDevices();
  40 + // List<String> vidIds = [];
  41 + var hasCam = false;
  42 + for (final e in sources) {
  43 + if (e.kind == 'videoinput') {
  44 + // vidIds.add(e['deviceId']);
  45 + hasCam = true;
  46 + }
  47 + }
  48 + return hasCam;
  49 + }
  50 +}
  51 +
  52 +class _WebQrViewState extends State<WebQrView> {
  53 + html.MediaStream? _localStream;
  54 + // html.CanvasElement canvas;
  55 + // html.CanvasRenderingContext2D ctx;
  56 + bool _currentlyProcessing = false;
  57 +
  58 + // QRViewControllerWeb? _controller;
  59 +
  60 + late Size _size = const Size(0, 0);
  61 + Timer? timer;
  62 + String? code;
  63 + String? _errorMsg;
  64 + html.VideoElement video = html.VideoElement();
  65 + String viewID = 'QRVIEW-' + DateTime.now().millisecondsSinceEpoch.toString();
  66 +
  67 + final StreamController<Barcode> _scanUpdateController =
  68 + StreamController<Barcode>();
  69 + late CameraFacing facing;
  70 +
  71 + Timer? _frameIntervall;
  72 +
  73 + @override
  74 + void initState() {
  75 + super.initState();
  76 +
  77 + facing = widget.cameraFacing ?? CameraFacing.front;
  78 +
  79 + // video = html.VideoElement();
  80 + WebQrView.vidDiv.children = [video];
  81 + // ignore: UNDEFINED_PREFIXED_NAME
  82 + ui.platformViewRegistry
  83 + .registerViewFactory(viewID, (int id) => WebQrView.vidDiv);
  84 + // giving JavaScipt some time to process the DOM changes
  85 + Timer(const Duration(milliseconds: 500), () {
  86 + start();
  87 + });
  88 + }
  89 +
  90 + Future start() async {
  91 + await _makeCall();
  92 + _frameIntervall?.cancel();
  93 + _frameIntervall =
  94 + Timer.periodic(const Duration(milliseconds: 200), (timer) {
  95 + _captureFrame2();
  96 + });
  97 + }
  98 +
  99 + void cancel() {
  100 + if (timer != null) {
  101 + timer!.cancel();
  102 + timer = null;
  103 + }
  104 + if (_currentlyProcessing) {
  105 + _stopStream();
  106 + }
  107 + }
  108 +
  109 + @override
  110 + void dispose() {
  111 + cancel();
  112 + super.dispose();
  113 + }
  114 +
  115 + // Platform messages are asynchronous, so we initialize in an async method.
  116 + Future<void> _makeCall() async {
  117 + if (_localStream != null) {
  118 + return;
  119 + }
  120 +
  121 + try {
  122 + var constraints = UserMediaOptions(
  123 + video: VideoOptions(
  124 + facingMode: (facing == CameraFacing.front ? 'user' : 'environment'),
  125 + ));
  126 + // dart style, not working properly:
  127 + // var stream =
  128 + // await html.window.navigator.mediaDevices.getUserMedia(constraints);
  129 + // straight JS:
  130 + var stream = await promiseToFuture(getUserMedia(constraints));
  131 + _localStream = stream;
  132 + video.srcObject = _localStream;
  133 + video.setAttribute('playsinline',
  134 + 'true'); // required to tell iOS safari we don't want fullscreen
  135 + // if (_controller == null) {
  136 + // _controller = QRViewControllerWeb(this);
  137 + // widget.onPlatformViewCreated(_controller!);
  138 + // }
  139 + await video.play();
  140 + } catch (e) {
  141 + cancel();
  142 + setState(() {
  143 + _errorMsg = e.toString();
  144 + });
  145 + return;
  146 + }
  147 + if (!mounted) return;
  148 +
  149 + setState(() {
  150 + _currentlyProcessing = true;
  151 + });
  152 + }
  153 +
  154 + Future<void> _stopStream() async {
  155 + try {
  156 + // await _localStream.dispose();
  157 + _localStream!.getTracks().forEach((track) {
  158 + if (track.readyState == 'live') {
  159 + track.stop();
  160 + }
  161 + });
  162 + // video.stop();
  163 + video.srcObject = null;
  164 + _localStream = null;
  165 + // _localRenderer.srcObject = null;
  166 + // ignore: empty_catches
  167 + } catch (e) {}
  168 + }
  169 +
  170 + Future<dynamic> _captureFrame2() async {
  171 + if (_localStream == null) {
  172 + return null;
  173 + }
  174 + final canvas =
  175 + html.CanvasElement(width: video.videoWidth, height: video.videoHeight);
  176 + final ctx = canvas.context2D;
  177 + // canvas.width = video.videoWidth;
  178 + // canvas.height = video.videoHeight;
  179 + ctx.drawImage(video, 0, 0);
  180 + final imgData = ctx.getImageData(0, 0, canvas.width!, canvas.height!);
  181 +
  182 + final size =
  183 + Size(canvas.width?.toDouble() ?? 0, canvas.height?.toDouble() ?? 0);
  184 + if (size != _size) {
  185 + setState(() {
  186 + _setCanvasSize(size);
  187 + });
  188 + }
  189 + final code = jsQR(imgData.data, canvas.width, canvas.height);
  190 + // ignore: unnecessary_null_comparison
  191 + if (code != null) {
  192 + widget.onDetect(Barcode(rawValue: code.data));
  193 + // print('Barcode: ${code.data}');
  194 + // _scanUpdateController
  195 + // .add(Barcode(rawValue: code.data));
  196 + }
  197 + }
  198 +
  199 + @override
  200 + Widget build(BuildContext context) {
  201 + if (_errorMsg != null) {
  202 + return Center(child: Text(_errorMsg!));
  203 + }
  204 + if (_localStream == null) {
  205 + return const Center(child: CircularProgressIndicator());
  206 + }
  207 + return LayoutBuilder(
  208 + builder: (context, constraints) {
  209 + var zoom = 1.0;
  210 +
  211 + if (_size.height != 0) zoom = constraints.maxHeight / _size.height;
  212 +
  213 + if (_size.width != 0) {
  214 + final horizontalZoom = constraints.maxWidth / _size.width;
  215 + if (horizontalZoom > zoom) {
  216 + zoom = horizontalZoom;
  217 + }
  218 + }
  219 +
  220 + return SizedBox(
  221 + width: constraints.maxWidth,
  222 + height: constraints.maxHeight,
  223 + child: Center(
  224 + child: SizedBox.fromSize(
  225 + size: _size,
  226 + child: Transform.scale(
  227 + alignment: Alignment.center,
  228 + scale: zoom,
  229 + child: HtmlElementView(viewType: viewID),
  230 + ),
  231 + ),
  232 + ),
  233 + );
  234 + },
  235 + );
  236 + }
  237 +
  238 + void _setCanvasSize(ui.Size size) {
  239 + setState(() {
  240 + _size = size;
  241 + });
  242 + }
  243 +}
  244 +//
  245 +// class QRViewControllerWeb implements QRViewController {
  246 +// final _WebQrViewState _state;
  247 +//
  248 +// QRViewControllerWeb(this._state);
  249 +// @override
  250 +// void dispose() => _state.cancel();
  251 +//
  252 +// @override
  253 +// Future<CameraFacing> flipCamera() async {
  254 +// // TODO: improve error handling
  255 +// _state.facing = _state.facing == CameraFacing.front
  256 +// ? CameraFacing.back
  257 +// : CameraFacing.front;
  258 +// await _state.start();
  259 +// return _state.facing;
  260 +// }
  261 +//
  262 +// @override
  263 +// Future<CameraFacing> getCameraInfo() async {
  264 +// return _state.facing;
  265 +// }
  266 +//
  267 +// @override
  268 +// Future<bool?> getFlashStatus() async {
  269 +// // TODO: flash is simply not supported by JavaScipt. To avoid issuing applications, we always return it to be off.
  270 +// return false;
  271 +// }
  272 +//
  273 +// @override
  274 +// Future<SystemFeatures> getSystemFeatures() {
  275 +// // TODO: implement getSystemFeatures
  276 +// throw UnimplementedError();
  277 +// }
  278 +//
  279 +// @override
  280 +// // TODO: implement hasPermissions. Blocking: WebQrView.cameraAvailable() returns a Future<bool> whereas a bool is required
  281 +// bool get hasPermissions => throw UnimplementedError();
  282 +//
  283 +// @override
  284 +// Future<void> pauseCamera() {
  285 +// // TODO: implement pauseCamera
  286 +// throw UnimplementedError();
  287 +// }
  288 +//
  289 +// @override
  290 +// Future<void> resumeCamera() {
  291 +// // TODO: implement resumeCamera
  292 +// throw UnimplementedError();
  293 +// }
  294 +//
  295 +// @override
  296 +// Stream<Barcode> get scannedDataStream => _state._scanUpdateController.stream;
  297 +//
  298 +// @override
  299 +// Future<void> stopCamera() {
  300 +// // TODO: implement stopCamera
  301 +// throw UnimplementedError();
  302 +// }
  303 +//
  304 +// @override
  305 +// Future<void> toggleFlash() async {
  306 +// // TODO: flash is simply not supported by JavaScipt
  307 +// return;
  308 +// }
  309 +//
  310 +// @override
  311 +// Future<void> scanInvert(bool isScanInvert) {
  312 +// // TODO: implement scanInvert
  313 +// throw UnimplementedError();
  314 +// }
  315 +// }
  316 +
  317 +Widget createWebQrView({required Function(Barcode) onDetect, CameraFacing? cameraFacing}) =>
  318 + WebQrView(
  319 + onDetect: onDetect,
  320 + cameraFacing: cameraFacing,
  321 + );
  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 +
  26 + external factory VideoOptions(
  27 + {String? facingMode, DeviceIdOptions? deviceId});
  28 +}
  29 +
  30 +@JS()
  31 +@anonymous
  32 +class DeviceIdOptions {
  33 + external String get exact;
  34 +
  35 + external factory DeviceIdOptions({String? exact});
  36 +}
@@ -8,6 +8,7 @@ environment: @@ -8,6 +8,7 @@ environment:
8 flutter: ">=2.5.0" 8 flutter: ">=2.5.0"
9 9
10 dependencies: 10 dependencies:
  11 + js: ^0.6.4
11 flutter: 12 flutter:
12 sdk: flutter 13 sdk: flutter
13 14