diff --git a/lib/android/qr_scanner/qr_scanner_overlay_view.dart b/lib/android/qr_scanner/qr_scanner_overlay_view.dart new file mode 100644 index 00000000..6bd87c42 --- /dev/null +++ b/lib/android/qr_scanner/qr_scanner_overlay_view.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; + +import 'qr_scanner_scan_status.dart'; +import 'qr_scanner_util.dart'; + +/// Return the rounded rect which represents the scanner area for the background +/// overlay and the stroke +RRect _getScannerAreaRRect(Size size) { + double scannerAreaWidth = getScannerAreaWidth(size); + var scannerAreaRect = Rect.fromCenter( + center: Offset(size.width / 2, size.height / 2), + width: scannerAreaWidth, + height: scannerAreaWidth); + + return RRect.fromRectAndRadius( + scannerAreaRect, const Radius.circular(scannerAreaRadius)); +} + +// CustomPainter which strokes the scannerArea +class _ScannerAreaStrokePainter extends CustomPainter { + final Color _strokeColor; + + _ScannerAreaStrokePainter(this._strokeColor) : super(); + + @override + void paint(Canvas canvas, Size size) { + Paint paint = Paint() + ..color = _strokeColor + ..style = PaintingStyle.stroke + ..strokeWidth = 3.0; + + Path path = Path()..addRRect(_getScannerAreaRRect(size)); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// clips the scanner area rounded rect of specific Size +class _ScannerAreaClipper extends CustomClipper { + @override + Path getClip(Size size) { + return Path() + ..addRect(Rect.fromLTWH(0, 0, size.width, size.height)) + ..addRRect(_getScannerAreaRRect(size)) + ..fillType = PathFillType.evenOdd; + } + + @override + bool shouldReclip(covariant CustomClipper oldClipper) => true; +} + +class QRScannerOverlay extends StatelessWidget { + final ScanStatus status; + final Size screenSize; + + const QRScannerOverlay({ + super.key, + required this.status, + required this.screenSize, + }); + + @override + Widget build(BuildContext context) { + var size = screenSize; + + return Stack(children: [ + /// clip scanner area "hole" into a darkened background + ClipPath( + clipper: _ScannerAreaClipper(), + child: Opacity( + opacity: 0.6, + child: ColoredBox( + color: Colors.black, + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: const [Spacer()], + )))), + + /// draw a stroke around the scanner area + Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + CustomPaint( + painter: _ScannerAreaStrokePainter(status == ScanStatus.error + ? Colors.red.shade400 + : Colors.green.shade400), + ), + ], + ), + + /// extra icon when successful scan occurred + if (status == ScanStatus.success) + Positioned.fromRect( + rect: Rect.fromCenter( + center: Offset(size.width / 2, size.height / 2), + width: size.width, + height: size.height), + child: Icon( + Icons.check_circle, + size: 200, + color: Colors.green.shade400, + )), + ]); + } +} diff --git a/lib/android/qr_scanner/qr_scanner_scan_status.dart b/lib/android/qr_scanner/qr_scanner_scan_status.dart new file mode 100644 index 00000000..60652863 --- /dev/null +++ b/lib/android/qr_scanner/qr_scanner_scan_status.dart @@ -0,0 +1,5 @@ +/// Status of the scanning process +/// scanning - showing preview and scanning it for finding credential QR code +/// error - a QR code has been found but is not a credential +/// success - a QR code has been found and is a usable credential +enum ScanStatus { scanning, error, success } diff --git a/lib/android/qr_scanner/qr_scanner_ui_view.dart b/lib/android/qr_scanner/qr_scanner_ui_view.dart new file mode 100644 index 00000000..2f8cda14 --- /dev/null +++ b/lib/android/qr_scanner/qr_scanner_ui_view.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; + +import 'qr_scanner_scan_status.dart'; +import 'qr_scanner_util.dart'; + +class QRScannerUI extends StatelessWidget { + final ScanStatus status; + final Size screenSize; + + const QRScannerUI({ + super.key, + required this.status, + required this.screenSize, + }); + + @override + Widget build(BuildContext context) { + var scannerAreaWidth = getScannerAreaWidth(screenSize); + + return Stack(children: [ + /// instruction text under the scanner area + Positioned.fromRect( + rect: Rect.fromCenter( + center: Offset(screenSize.width / 2, + screenSize.height + scannerAreaWidth / 2.0 + 8.0), + width: screenSize.width, + height: screenSize.height), + child: Text( + status != ScanStatus.error + ? 'Point your camera at a QR code to scan it' + : 'Invalid QR code', + style: const TextStyle(color: Colors.white), + textAlign: TextAlign.center, + ), + ), + + /// button for manual entry + Positioned.fromRect( + rect: Rect.fromCenter( + center: Offset(screenSize.width / 2, + screenSize.height + scannerAreaWidth / 2.0 + 80.0), + width: screenSize.width, + height: screenSize.height), + child: Column( + children: [ + const Text( + 'No QR code?', + textScaleFactor: 0.7, + style: TextStyle(color: Colors.white), + ), + OutlinedButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Enter manually', + style: TextStyle(color: Colors.white))), + ], + ), + ), + ]); + } +} diff --git a/lib/android/qr_scanner/qr_scanner_util.dart b/lib/android/qr_scanner/qr_scanner_util.dart new file mode 100644 index 00000000..1e8e5793 --- /dev/null +++ b/lib/android/qr_scanner/qr_scanner_util.dart @@ -0,0 +1,5 @@ +import 'dart:ui'; + +const double scannerAreaRadius = 40.0; + +double getScannerAreaWidth(Size size) => size.width - scannerAreaRadius; diff --git a/lib/android/qr_scanner/qr_scanner_view.dart b/lib/android/qr_scanner/qr_scanner_view.dart index e96b4e18..3ebbb615 100755 --- a/lib/android/qr_scanner/qr_scanner_view.dart +++ b/lib/android/qr_scanner/qr_scanner_view.dart @@ -1,93 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:qrscanner_zxing/qrscanner_zxing_view.dart'; import '../../oath/models.dart'; +import 'qr_scanner_overlay_view.dart'; +import 'qr_scanner_scan_status.dart'; +import 'qr_scanner_ui_view.dart'; -/// Status of view state -enum _ScanStatus { looking, error, success } - -class OverlayClipper extends CustomClipper { - /// helper method to calculate position of the rect - Rect _getOverlayRect(Size size, double width) => Rect.fromCenter( - center: Offset(size.width / 2, size.height / 2), - width: width, - height: width); - - @override - Path getClip(Size size) { - const r = 40.0; - var w = size.width - 40; - return Path() - ..addRect(Rect.fromLTWH(0, 0, size.width, size.height)) - ..addRRect(RRect.fromRectXY(_getOverlayRect(size, w), r, r)) - ..fillType = PathFillType.evenOdd; - } - - @override - bool shouldReclip(covariant CustomClipper oldClipper) => true; -} - -class _MobileScannerWrapper extends StatelessWidget { - final Function(String) onDetect; - final _ScanStatus status; - - const _MobileScannerWrapper({ - required this.onDetect, - required this.status, - }); - - @override - Widget build(BuildContext context) { - var backgroundColor = status == _ScanStatus.looking - ? Colors.white - : status == _ScanStatus.error - ? Colors.red.shade900 - : Colors.green.shade900; - - var size = MediaQuery.of(context).size; - var positionRect = Rect.fromCenter( - center: Offset(size.width / 2, size.height / 2 - 51), - width: size.width - 38, - height: size.width - 38); - - return Stack(children: [ - QRScannerZxingView( - marginPct: 20, - onDetect: (barCode) { - onDetect.call(barCode); - }), - ClipPath( - clipper: OverlayClipper(), - child: Opacity( - opacity: 0.3, - child: ColoredBox( - color: backgroundColor, - child: Column( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: const [Spacer()], - )))), - if (status == _ScanStatus.success) - Positioned.fromRect( - rect: positionRect, - child: Icon( - Icons.check_circle, - size: 200, - color: Colors.green.shade400, - )), - if (status == _ScanStatus.error) - Positioned.fromRect( - rect: positionRect, - child: Icon( - Icons.error, - size: 200, - color: Colors.red.shade400, - )), - ]); - } -} - +/// Shows Camera preview, overlay and UI +/// Handles user interactions class QrScannerView extends StatefulWidget { const QrScannerView({super.key}); @@ -101,12 +22,12 @@ class _QrScannerViewState extends State { // will be used later // ignore: unused_field CredentialData? _credentialData; - _ScanStatus _status = _ScanStatus.looking; + ScanStatus _status = ScanStatus.scanning; void setError() { _credentialData = null; _scannedString = null; - _status = _ScanStatus.error; + _status = ScanStatus.error; Future.delayed(const Duration(milliseconds: 2000), () { resetError(); @@ -117,12 +38,12 @@ class _QrScannerViewState extends State { setState(() { _credentialData = null; _scannedString = null; - _status = _ScanStatus.looking; + _status = ScanStatus.scanning; }); } void handleResult(String barCode) { - if (_status != _ScanStatus.looking) { + if (_status != ScanStatus.scanning) { // on success and error ignore reported codes return; } @@ -132,7 +53,7 @@ class _QrScannerViewState extends State { var parsedCredential = CredentialData.fromUri(Uri.parse(barCode)); _credentialData = parsedCredential; _scannedString = barCode; - _status = _ScanStatus.success; + _status = ScanStatus.success; final navigator = Navigator.of(context); Future.delayed(const Duration(milliseconds: 800), () { @@ -152,68 +73,56 @@ class _QrScannerViewState extends State { }); } + @override + void initState() { + super.initState(); + _status = ScanStatus.scanning; + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); + } + + @override + void dispose() { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, + overlays: SystemUiOverlay.values); + super.dispose(); + } + @override Widget build(BuildContext context) { return SafeArea( child: Scaffold( - appBar: AppBar( - title: const Text('Scan QR code'), - leading: BackButton( - onPressed: () { - Navigator.of(context).pop(); - }, - ), - ), - body: Stack(children: [ - _MobileScannerWrapper( - status: _status, - onDetect: (scannedData) => handleResult(scannedData), - ), - Padding( - padding: - const EdgeInsets.symmetric(vertical: 32, horizontal: 32), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Column(children: [ - const SizedBox( - height: 32, - ), - if (_status == _ScanStatus.looking) - Text('Looking for a code...', - style: Theme.of(context) - .textTheme - .titleLarge - ?.copyWith(color: Colors.black)), - if (_status == _ScanStatus.success) - Text('Found a valid code', - style: Theme.of(context) - .textTheme - .titleLarge - ?.copyWith(color: Colors.white)), - if (_status == _ScanStatus.error) - Text('This code is not valid, try again.', - style: Theme.of(context) - .textTheme - .titleLarge - ?.copyWith(color: Colors.white)), - ]), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - MaterialButton( - color: Colors.white38, - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text('Add manually'), - ) - ], - ) - ], - )), - ]))); + resizeToAvoidBottomInset: false, + extendBodyBehindAppBar: true, + extendBody: true, + appBar: AppBar( + title: const Text( + 'Add account', + style: TextStyle(color: Colors.white), + ), + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + elevation: 0, + leading: BackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ), + body: Stack( + children: [ + QRScannerZxingView( + marginPct: 50, + onDetect: (scannedData) => handleResult(scannedData)), + QRScannerOverlay( + status: _status, + screenSize: MediaQuery.of(context).size, + ), + QRScannerUI( + status: _status, + screenSize: MediaQuery.of(context).size, + ) + ], + ), + )); } }