diff --git a/example/web/favicon.png b/example/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/example/web/favicon.png differ diff --git a/example/web/icons/Icon-192.png b/example/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/example/web/icons/Icon-192.png differ diff --git a/example/web/icons/Icon-512.png b/example/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/example/web/icons/Icon-512.png differ diff --git a/example/web/index.html b/example/web/index.html new file mode 100644 index 0000000..f243f37 --- /dev/null +++ b/example/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + example + + + + + + + + + diff --git a/example/web/manifest.json b/example/web/manifest.json new file mode 100644 index 0000000..8c01291 --- /dev/null +++ b/example/web/manifest.json @@ -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" + } + ] +} diff --git a/lib/src/web/flutter_qr_stub.dart b/lib/src/web/flutter_qr_stub.dart new file mode 100644 index 0000000..1193cf4 --- /dev/null +++ b/lib/src/web/flutter_qr_stub.dart @@ -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(); diff --git a/lib/src/web/flutter_qr_web.dart b/lib/src/web/flutter_qr_web.dart new file mode 100644 index 0000000..22896ae --- /dev/null +++ b/lib/src/web/flutter_qr_web.dart @@ -0,0 +1,334 @@ +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 cameraAvailable() async { + final sources = + await html.window.navigator.mediaDevices!.enumerateDevices(); + // List 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 { + html.MediaStream? _localStream; + // html.CanvasElement canvas; + // html.CanvasRenderingContext2D ctx; + bool _currentlyProcessing = false; + html.MediaRecorder? _mediaRecorder; + + 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 _scanUpdateController = + StreamController(); + late CameraFacing facing; + + Timer? _frameIntervall; + + final _canvasKey = GlobalKey(); + + double _scaleFactor = 1; + + @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 _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 _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 _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)); + } + } + + Future _captureImage() 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); + var dataUrl = canvas.toDataUrl('image/jpeg', 0.9); + return dataUrl; + } + + @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 flipCamera() async { + // TODO: improve error handling + _state.facing = _state.facing == CameraFacing.front + ? CameraFacing.back + : CameraFacing.front; + await _state.start(); + return _state.facing; + } + + @override + Future getCameraInfo() async { + return _state.facing; + } + + @override + Future 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 getSystemFeatures() { + // TODO: implement getSystemFeatures + throw UnimplementedError(); + } + + @override + // TODO: implement hasPermissions. Blocking: WebQrView.cameraAvailable() returns a Future whereas a bool is required + bool get hasPermissions => throw UnimplementedError(); + + @override + Future pauseCamera() { + // TODO: implement pauseCamera + throw UnimplementedError(); + } + + @override + Future resumeCamera() { + // TODO: implement resumeCamera + throw UnimplementedError(); + } + + @override + Stream get scannedDataStream => _state._scanUpdateController.stream; + + @override + Future stopCamera() { + // TODO: implement stopCamera + throw UnimplementedError(); + } + + @override + Future toggleFlash() async { + // TODO: flash is simply not supported by JavaScipt + return; + } +} + +Widget createWebQrView({onPlatformViewCreated, CameraFacing? cameraFacing}) => + WebQrView( + onPlatformViewCreated: onPlatformViewCreated, + cameraFacing: cameraFacing, + ); diff --git a/lib/src/web/jsqr.dart b/lib/src/web/jsqr.dart new file mode 100644 index 0000000..0c7a36f --- /dev/null +++ b/lib/src/web/jsqr.dart @@ -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; +} diff --git a/lib/src/web/media.dart b/lib/src/web/media.dart new file mode 100644 index 0000000..f1a4175 --- /dev/null +++ b/lib/src/web/media.dart @@ -0,0 +1,35 @@ +// 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 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}); +}