Added web supportflutter-beta
| @@ -132,6 +132,20 @@ In order to use this plugin, add the following to your Info.plist file: | |||||
| <string>This app needs camera access to scan QR codes</string> | <string>This app needs camera access to scan QR codes</string> | ||||
| ``` | ``` | ||||
| ## Web Integration | |||||
| Add this to `web/index.html`: | |||||
| ```html | |||||
| <script src="https://cdn.jsdelivr.net/npm/jsqr@1.3.1/dist/jsQR.min.js"></script> | |||||
| ``` | |||||
| Please note: on web, only QR codes are supported. Other barcodes and 2D codes cannot be scanned. | |||||
| Web support is in very earlt stage. Features such as flash, pause or resume are not implemented. Moreover, the camera | |||||
| preview does not respect the surrounding constraints. This is not at last due to Flutter's early state of platform views | |||||
| on web. | |||||
| ## Flip Camera (Back/Front) | ## Flip Camera (Back/Front) | ||||
| The default camera is the back camera. | The default camera is the back camera. | ||||
| ```dart | ```dart | ||||
| @@ -0,0 +1,46 @@ | |||||
| <!DOCTYPE html> | |||||
| <html> | |||||
| <head> | |||||
| <!-- | |||||
| If you are serving your web app in a path other than the root, change the | |||||
| href value below to reflect the base path you are serving from. | |||||
| The path provided below has to start and end with a slash "/" in order for | |||||
| it to work correctly. | |||||
| Fore more details: | |||||
| * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base | |||||
| --> | |||||
| <base href="/"> | |||||
| <meta charset="UTF-8"> | |||||
| <meta content="IE=Edge" http-equiv="X-UA-Compatible"> | |||||
| <meta name="description" content="A new Flutter project."> | |||||
| <!-- iOS meta tags & icons --> | |||||
| <meta name="apple-mobile-web-app-capable" content="yes"> | |||||
| <meta name="apple-mobile-web-app-status-bar-style" content="black"> | |||||
| <meta name="apple-mobile-web-app-title" content="example"> | |||||
| <link rel="apple-touch-icon" href="icons/Icon-192.png"> | |||||
| <!-- Favicon --> | |||||
| <link rel="icon" type="image/png" href="favicon.png"/> | |||||
| <title>example</title> | |||||
| <link rel="manifest" href="manifest.json"> | |||||
| <script src="https://cdn.jsdelivr.net/npm/jsqr@1.3.1/dist/jsQR.min.js"></script> | |||||
| </head> | |||||
| <body> | |||||
| <!-- This script installs service_worker.js to provide PWA functionality to | |||||
| application. For more information, see: | |||||
| https://developers.google.com/web/fundamentals/primers/service-workers --> | |||||
| <script> | |||||
| if ('serviceWorker' in navigator) { | |||||
| window.addEventListener('flutter-first-frame', function () { | |||||
| navigator.serviceWorker.register('flutter_service_worker.js'); | |||||
| }); | |||||
| } | |||||
| </script> | |||||
| <script src="main.dart.js" type="application/javascript"></script> | |||||
| </body> | |||||
| </html> | |||||
| @@ -0,0 +1,23 @@ | |||||
| { | |||||
| "name": "example", | |||||
| "short_name": "example", | |||||
| "start_url": ".", | |||||
| "display": "standalone", | |||||
| "background_color": "#0175C2", | |||||
| "theme_color": "#0175C2", | |||||
| "description": "A new Flutter project.", | |||||
| "orientation": "portrait-primary", | |||||
| "prefer_related_applications": false, | |||||
| "icons": [ | |||||
| { | |||||
| "src": "icons/Icon-192.png", | |||||
| "sizes": "192x192", | |||||
| "type": "image/png" | |||||
| }, | |||||
| { | |||||
| "src": "icons/Icon-512.png", | |||||
| "sizes": "512x512", | |||||
| "type": "image/png" | |||||
| } | |||||
| ] | |||||
| } | |||||
| @@ -1,5 +1,4 @@ | |||||
| import 'dart:async'; | import 'dart:async'; | ||||
| import 'dart:io'; | |||||
| import 'package:flutter/foundation.dart'; | import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||
| @@ -12,6 +11,9 @@ import 'types/barcode_format.dart'; | |||||
| import 'types/camera.dart'; | import 'types/camera.dart'; | ||||
| import 'types/camera_exception.dart'; | import 'types/camera_exception.dart'; | ||||
| import 'types/features.dart'; | import 'types/features.dart'; | ||||
| import 'web/flutter_qr_stub.dart' | |||||
| // ignore: uri_does_not_exist | |||||
| if (dart.library.html) 'web/flutter_qr_web.dart'; | |||||
| typedef QRViewCreatedCallback = void Function(QRViewController); | typedef QRViewCreatedCallback = void Function(QRViewController); | ||||
| typedef PermissionSetCallback = void Function(QRViewController, bool); | typedef PermissionSetCallback = void Function(QRViewController, bool); | ||||
| @@ -111,28 +113,35 @@ class _QRViewState extends State<QRView> { | |||||
| Widget _getPlatformQrView() { | Widget _getPlatformQrView() { | ||||
| Widget _platformQrView; | Widget _platformQrView; | ||||
| switch (defaultTargetPlatform) { | |||||
| case TargetPlatform.android: | |||||
| _platformQrView = AndroidView( | |||||
| viewType: 'net.touchcapture.qr.flutterqr/qrview', | |||||
| onPlatformViewCreated: _onPlatformViewCreated, | |||||
| creationParams: | |||||
| _QrCameraSettings(cameraFacing: widget.cameraFacing).toMap(), | |||||
| creationParamsCodec: StandardMessageCodec(), | |||||
| ); | |||||
| break; | |||||
| case TargetPlatform.iOS: | |||||
| _platformQrView = UiKitView( | |||||
| viewType: 'net.touchcapture.qr.flutterqr/qrview', | |||||
| onPlatformViewCreated: _onPlatformViewCreated, | |||||
| creationParams: | |||||
| _QrCameraSettings(cameraFacing: widget.cameraFacing).toMap(), | |||||
| creationParamsCodec: StandardMessageCodec(), | |||||
| ); | |||||
| break; | |||||
| default: | |||||
| throw UnsupportedError( | |||||
| "Trying to use the default qrview implementation for $defaultTargetPlatform but there isn't a default one"); | |||||
| if (kIsWeb) { | |||||
| _platformQrView = createWebQrView( | |||||
| onPlatformViewCreated: widget.onQRViewCreated, | |||||
| cameraFacing: widget.cameraFacing, | |||||
| ); | |||||
| } else { | |||||
| switch (defaultTargetPlatform) { | |||||
| case TargetPlatform.android: | |||||
| _platformQrView = AndroidView( | |||||
| viewType: 'net.touchcapture.qr.flutterqr/qrview', | |||||
| onPlatformViewCreated: _onPlatformViewCreated, | |||||
| creationParams: | |||||
| _QrCameraSettings(cameraFacing: widget.cameraFacing).toMap(), | |||||
| creationParamsCodec: StandardMessageCodec(), | |||||
| ); | |||||
| break; | |||||
| case TargetPlatform.iOS: | |||||
| _platformQrView = UiKitView( | |||||
| viewType: 'net.touchcapture.qr.flutterqr/qrview', | |||||
| onPlatformViewCreated: _onPlatformViewCreated, | |||||
| creationParams: | |||||
| _QrCameraSettings(cameraFacing: widget.cameraFacing).toMap(), | |||||
| creationParamsCodec: StandardMessageCodec(), | |||||
| ); | |||||
| break; | |||||
| default: | |||||
| throw UnsupportedError( | |||||
| "Trying to use the default qrview implementation for $defaultTargetPlatform but there isn't a default one"); | |||||
| } | |||||
| } | } | ||||
| return _platformQrView; | return _platformQrView; | ||||
| } | } | ||||
| @@ -309,7 +318,7 @@ class QRViewController { | |||||
| /// Stops the camera and disposes the barcode stream. | /// Stops the camera and disposes the barcode stream. | ||||
| void dispose() { | void dispose() { | ||||
| if (Platform.isIOS) stopCamera(); | |||||
| if (defaultTargetPlatform == TargetPlatform.iOS) stopCamera(); | |||||
| _scanUpdateController.close(); | _scanUpdateController.close(); | ||||
| } | } | ||||
| @@ -0,0 +1,5 @@ | |||||
| import 'package:flutter/widgets.dart'; | |||||
| import 'package:qr_code_scanner/src/types/camera.dart'; | |||||
| Widget createWebQrView({onPlatformViewCreated, CameraFacing? cameraFacing}) => | |||||
| throw UnimplementedError(); | |||||
| @@ -0,0 +1,315 @@ | |||||
| import 'dart:async'; | |||||
| import 'dart:core'; | |||||
| import 'dart:html' as html; | |||||
| import 'dart:js_util'; | |||||
| import 'dart:ui' as ui; | |||||
| import 'package:flutter/cupertino.dart'; | |||||
| import 'package:flutter/material.dart'; | |||||
| import 'package:flutter/widgets.dart'; | |||||
| import '../../qr_code_scanner.dart'; | |||||
| import '../qr_code_scanner.dart'; | |||||
| import '../types/camera.dart'; | |||||
| import 'jsqr.dart'; | |||||
| import 'media.dart'; | |||||
| /// Even though it has been highly modified, the origial implementation has been | |||||
| /// adopted from https://github.com:treeder/jsqr_flutter | |||||
| /// | |||||
| /// Copyright 2020 @treeder | |||||
| /// Copyright 2021 The one with the braid | |||||
| class WebQrView extends StatefulWidget { | |||||
| final QRViewCreatedCallback onPlatformViewCreated; | |||||
| final CameraFacing? cameraFacing; | |||||
| const WebQrView( | |||||
| {Key? key, | |||||
| required this.onPlatformViewCreated, | |||||
| this.cameraFacing = CameraFacing.front}) | |||||
| : super(key: key); | |||||
| @override | |||||
| _WebQrViewState createState() => _WebQrViewState(); | |||||
| static html.DivElement vidDiv = | |||||
| html.DivElement(); // need a global for the registerViewFactory | |||||
| static Future<bool> cameraAvailable() async { | |||||
| final sources = | |||||
| await html.window.navigator.mediaDevices!.enumerateDevices(); | |||||
| // List<String> vidIds = []; | |||||
| var hasCam = false; | |||||
| for (final e in sources) { | |||||
| if (e.kind == 'videoinput') { | |||||
| // vidIds.add(e['deviceId']); | |||||
| hasCam = true; | |||||
| } | |||||
| } | |||||
| return hasCam; | |||||
| } | |||||
| } | |||||
| class _WebQrViewState extends State<WebQrView> { | |||||
| html.MediaStream? _localStream; | |||||
| // html.CanvasElement canvas; | |||||
| // html.CanvasRenderingContext2D ctx; | |||||
| bool _currentlyProcessing = false; | |||||
| QRViewControllerWeb? _controller; | |||||
| late Size _size = Size(0, 0); | |||||
| Timer? timer; | |||||
| String? code; | |||||
| String? _errorMsg; | |||||
| var video; | |||||
| String viewID = 'QRVIEW-' + DateTime.now().millisecondsSinceEpoch.toString(); | |||||
| final StreamController<Barcode> _scanUpdateController = | |||||
| StreamController<Barcode>(); | |||||
| late CameraFacing facing; | |||||
| Timer? _frameIntervall; | |||||
| @override | |||||
| void initState() { | |||||
| super.initState(); | |||||
| facing = widget.cameraFacing ?? CameraFacing.front; | |||||
| video = html.VideoElement(); | |||||
| WebQrView.vidDiv.children = [video]; | |||||
| // ignore: UNDEFINED_PREFIXED_NAME | |||||
| ui.platformViewRegistry | |||||
| .registerViewFactory(viewID, (int id) => WebQrView.vidDiv); | |||||
| // giving JavaScipt some time to process the DOM changes | |||||
| Timer(Duration(milliseconds: 500), () { | |||||
| start(); | |||||
| }); | |||||
| } | |||||
| Future start() async { | |||||
| await _makeCall(); | |||||
| _frameIntervall?.cancel(); | |||||
| _frameIntervall = Timer.periodic(Duration(milliseconds: 200), (timer) { | |||||
| _captureFrame2(); | |||||
| }); | |||||
| } | |||||
| void cancel() { | |||||
| if (timer != null) { | |||||
| timer!.cancel(); | |||||
| timer = null; | |||||
| } | |||||
| if (_currentlyProcessing) { | |||||
| _stopStream(); | |||||
| } | |||||
| } | |||||
| @override | |||||
| void dispose() { | |||||
| cancel(); | |||||
| super.dispose(); | |||||
| } | |||||
| // Platform messages are asynchronous, so we initialize in an async method. | |||||
| Future<void> _makeCall() async { | |||||
| if (_localStream != null) { | |||||
| return; | |||||
| } | |||||
| try { | |||||
| var constraints = UserMediaOptions( | |||||
| video: VideoOptions( | |||||
| facingMode: (facing == CameraFacing.front ? 'user' : 'environment'), | |||||
| )); | |||||
| // dart style, not working properly: | |||||
| // var stream = | |||||
| // await html.window.navigator.mediaDevices.getUserMedia(constraints); | |||||
| // straight JS: | |||||
| var stream = await promiseToFuture(getUserMedia(constraints)); | |||||
| _localStream = stream; | |||||
| video.srcObject = _localStream; | |||||
| video.setAttribute('playsinline', | |||||
| 'true'); // required to tell iOS safari we don't want fullscreen | |||||
| if (_controller == null) { | |||||
| _controller = QRViewControllerWeb(this); | |||||
| widget.onPlatformViewCreated(_controller!); | |||||
| } | |||||
| await video.play(); | |||||
| } catch (e) { | |||||
| cancel(); | |||||
| setState(() { | |||||
| _errorMsg = e.toString(); | |||||
| }); | |||||
| return; | |||||
| } | |||||
| if (!mounted) return; | |||||
| setState(() { | |||||
| _currentlyProcessing = true; | |||||
| }); | |||||
| } | |||||
| Future<void> _stopStream() async { | |||||
| try { | |||||
| // await _localStream.dispose(); | |||||
| _localStream!.getTracks().forEach((track) { | |||||
| if (track.readyState == 'live') { | |||||
| track.stop(); | |||||
| } | |||||
| }); | |||||
| // video.stop(); | |||||
| video.srcObject = null; | |||||
| _localStream = null; | |||||
| // _localRenderer.srcObject = null; | |||||
| // ignore: empty_catches | |||||
| } catch (e) {} | |||||
| } | |||||
| Future<dynamic> _captureFrame2() async { | |||||
| if (_localStream == null) { | |||||
| return null; | |||||
| } | |||||
| final canvas = | |||||
| html.CanvasElement(width: video.videoWidth, height: video.videoHeight); | |||||
| final ctx = canvas.context2D; | |||||
| // canvas.width = video.videoWidth; | |||||
| // canvas.height = video.videoHeight; | |||||
| ctx.drawImage(video, 0, 0); | |||||
| final imgData = ctx.getImageData(0, 0, canvas.width!, canvas.height!); | |||||
| final size = | |||||
| Size(canvas.width?.toDouble() ?? 0, canvas.height?.toDouble() ?? 0); | |||||
| if (size != _size) { | |||||
| setState(() { | |||||
| _setCanvasSize(size); | |||||
| }); | |||||
| } | |||||
| final code = jsQR(imgData.data, canvas.width, canvas.height); | |||||
| // ignore: unnecessary_null_comparison | |||||
| if (code != null) { | |||||
| _scanUpdateController | |||||
| .add(Barcode(code.data, BarcodeFormat.qrcode, code.data.codeUnits)); | |||||
| } | |||||
| } | |||||
| @override | |||||
| Widget build(BuildContext context) { | |||||
| if (_errorMsg != null) { | |||||
| return Center(child: Text(_errorMsg!)); | |||||
| } | |||||
| if (_localStream == null) { | |||||
| return Center(child: CircularProgressIndicator()); | |||||
| } | |||||
| return LayoutBuilder( | |||||
| builder: (context, constraints) { | |||||
| var zoom = 1.0; | |||||
| if (_size.height != 0) zoom = constraints.maxHeight / _size.height; | |||||
| if (_size.width != 0) { | |||||
| final horizontalZoom = constraints.maxWidth / _size.width; | |||||
| if (horizontalZoom > zoom) { | |||||
| zoom = horizontalZoom; | |||||
| } | |||||
| } | |||||
| return SizedBox( | |||||
| width: constraints.maxWidth, | |||||
| height: constraints.maxHeight, | |||||
| child: Center( | |||||
| child: SizedBox.fromSize( | |||||
| size: _size, | |||||
| child: Transform.scale( | |||||
| alignment: Alignment.center, | |||||
| scale: zoom, | |||||
| child: HtmlElementView(viewType: viewID), | |||||
| ), | |||||
| ), | |||||
| ), | |||||
| ); | |||||
| }, | |||||
| ); | |||||
| } | |||||
| void _setCanvasSize(ui.Size size) { | |||||
| setState(() { | |||||
| _size = size; | |||||
| }); | |||||
| } | |||||
| } | |||||
| class QRViewControllerWeb implements QRViewController { | |||||
| final _WebQrViewState _state; | |||||
| QRViewControllerWeb(this._state); | |||||
| @override | |||||
| void dispose() => _state.cancel(); | |||||
| @override | |||||
| Future<CameraFacing> flipCamera() async { | |||||
| // TODO: improve error handling | |||||
| _state.facing = _state.facing == CameraFacing.front | |||||
| ? CameraFacing.back | |||||
| : CameraFacing.front; | |||||
| await _state.start(); | |||||
| return _state.facing; | |||||
| } | |||||
| @override | |||||
| Future<CameraFacing> getCameraInfo() async { | |||||
| return _state.facing; | |||||
| } | |||||
| @override | |||||
| Future<bool?> getFlashStatus() async { | |||||
| // TODO: flash is simply not supported by JavaScipt. To avoid issuing applications, we always return it to be off. | |||||
| return false; | |||||
| } | |||||
| @override | |||||
| Future<SystemFeatures> getSystemFeatures() { | |||||
| // TODO: implement getSystemFeatures | |||||
| throw UnimplementedError(); | |||||
| } | |||||
| @override | |||||
| // TODO: implement hasPermissions. Blocking: WebQrView.cameraAvailable() returns a Future<bool> whereas a bool is required | |||||
| bool get hasPermissions => throw UnimplementedError(); | |||||
| @override | |||||
| Future<void> pauseCamera() { | |||||
| // TODO: implement pauseCamera | |||||
| throw UnimplementedError(); | |||||
| } | |||||
| @override | |||||
| Future<void> resumeCamera() { | |||||
| // TODO: implement resumeCamera | |||||
| throw UnimplementedError(); | |||||
| } | |||||
| @override | |||||
| Stream<Barcode> get scannedDataStream => _state._scanUpdateController.stream; | |||||
| @override | |||||
| Future<void> stopCamera() { | |||||
| // TODO: implement stopCamera | |||||
| throw UnimplementedError(); | |||||
| } | |||||
| @override | |||||
| Future<void> toggleFlash() async { | |||||
| // TODO: flash is simply not supported by JavaScipt | |||||
| return; | |||||
| } | |||||
| } | |||||
| Widget createWebQrView({onPlatformViewCreated, CameraFacing? cameraFacing}) => | |||||
| WebQrView( | |||||
| onPlatformViewCreated: onPlatformViewCreated, | |||||
| cameraFacing: cameraFacing, | |||||
| ); | |||||
| @@ -0,0 +1,12 @@ | |||||
| @JS() | |||||
| library jsqr; | |||||
| import 'package:js/js.dart'; | |||||
| @JS('jsQR') | |||||
| external Code jsQR(var data, int? width, int? height); | |||||
| @JS() | |||||
| class Code { | |||||
| external String get data; | |||||
| } | |||||
| @@ -0,0 +1,36 @@ | |||||
| // This is here because dart doesn't seem to support this properly | |||||
| // https://stackoverflow.com/questions/61161135/adding-support-for-navigator-mediadevices-getusermedia-to-dart | |||||
| @JS('navigator.mediaDevices') | |||||
| library media_devices; | |||||
| import 'package:js/js.dart'; | |||||
| @JS('getUserMedia') | |||||
| external Future<dynamic> getUserMedia(UserMediaOptions constraints); | |||||
| @JS() | |||||
| @anonymous | |||||
| class UserMediaOptions { | |||||
| external VideoOptions get video; | |||||
| external factory UserMediaOptions({VideoOptions? video}); | |||||
| } | |||||
| @JS() | |||||
| @anonymous | |||||
| class VideoOptions { | |||||
| external String get facingMode; | |||||
| // external DeviceIdOptions get deviceId; | |||||
| external factory VideoOptions( | |||||
| {String? facingMode, DeviceIdOptions? deviceId}); | |||||
| } | |||||
| @JS() | |||||
| @anonymous | |||||
| class DeviceIdOptions { | |||||
| external String get exact; | |||||
| external factory DeviceIdOptions({String? exact}); | |||||
| } | |||||
| @@ -11,6 +11,8 @@ environment: | |||||
| dependencies: | dependencies: | ||||
| flutter: | flutter: | ||||
| sdk: flutter | sdk: flutter | ||||
| flutter_web_plugins: | |||||
| sdk: flutter | |||||
| dev_dependencies: | dev_dependencies: | ||||
| pedantic: ^1.11.0 | pedantic: ^1.11.0 | ||||
| @@ -23,3 +25,7 @@ flutter: | |||||
| pluginClass: FlutterQrPlugin | pluginClass: FlutterQrPlugin | ||||
| ios: | ios: | ||||
| pluginClass: FlutterQrPlugin | pluginClass: FlutterQrPlugin | ||||
| # web: | |||||
| # pluginClass: FlutterQrPlugin | |||||
| # fileName: flutter_qr_web.dart | |||||