From d25a1fddb1a4b0cc80efd6264f3e81386301e15c Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Thu, 21 Sep 2023 11:40:41 +0200 Subject: [PATCH] improve QRScanner views for responsiveness --- .../qr_scanner/qr_scanner_overlay_view.dart | 190 ++++++++++-------- .../qr_scanner_permissions_view.dart | 106 +++++----- .../qr_scanner/qr_scanner_ui_view.dart | 113 ++++++----- lib/android/qr_scanner/qr_scanner_util.dart | 21 -- lib/android/qr_scanner/qr_scanner_view.dart | 5 +- 5 files changed, 221 insertions(+), 214 deletions(-) delete mode 100644 lib/android/qr_scanner/qr_scanner_util.dart diff --git a/lib/android/qr_scanner/qr_scanner_overlay_view.dart b/lib/android/qr_scanner/qr_scanner_overlay_view.dart index 80590e09..f7032a90 100644 --- a/lib/android/qr_scanner/qr_scanner_overlay_view.dart +++ b/lib/android/qr_scanner/qr_scanner_overlay_view.dart @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 Yubico. + * Copyright (C) 2022-2023 Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,114 +14,142 @@ * limitations under the License. */ +import 'dart:math'; + 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); +class QRScannerOverlay extends StatelessWidget { + final ScanStatus status; + final Size screenSize; + final GlobalKey overlayWidgetKey; - return RRect.fromRectAndRadius( - scannerAreaRect, const Radius.circular(scannerAreaRadius)); + const QRScannerOverlay( + {super.key, + required this.status, + required this.screenSize, + required this.overlayWidgetKey}); + + RRect getOverlayRRect(Size size) { + final renderBox = + overlayWidgetKey.currentContext?.findRenderObject() as RenderBox; + final renderObjectSize = renderBox.size; + final renderObjectOffset = renderBox.globalToLocal(Offset.zero); + + final double shorterEdge = + min(renderObjectSize.width, renderObjectSize.height); + + var top = (size.height - shorterEdge) / 2 - 32; + + if (top + renderObjectOffset.dy < 0) { + top = -renderObjectOffset.dy; + } + + return RRect.fromRectAndRadius( + Rect.fromLTWH( + (size.width - shorterEdge) / 2, top, shorterEdge, shorterEdge), + const Radius.circular(10)); + } + + @override + Widget build(BuildContext context) { + overlayRectProvider(Size size) { + return getOverlayRRect(size); + } + + return Stack(fit: StackFit.expand, children: [ + /// clip scanner area "hole" into a darkened background + ClipPath( + clipper: _OverlayClipper(overlayRectProvider), + child: const Opacity( + opacity: 0.6, + child: ColoredBox( + color: Colors.black, + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [Spacer()], + ), + ), + ), + ), + + /// draw a stroke around the scanner area + CustomPaint( + painter: _OverlayPainter(status, overlayRectProvider), + ), + ]); + } } -// CustomPainter which strokes the scannerArea -class _ScannerAreaStrokePainter extends CustomPainter { - final Color _strokeColor; +/// Paints a colored stroke and status icon. +/// The stroke area is acquired through passed in rectangle provider. +/// The color is computed from the scan status. +class _OverlayPainter extends CustomPainter { + final ScanStatus _status; + final Function(Size) _rectProvider; - _ScannerAreaStrokePainter(this._strokeColor) : super(); + _OverlayPainter(this._status, this._rectProvider) : super(); @override void paint(Canvas canvas, Size size) { + final color = _status == ScanStatus.error + ? Colors.red.shade400 + : Colors.green.shade400; Paint paint = Paint() - ..color = _strokeColor + ..color = color ..style = PaintingStyle.stroke ..strokeWidth = 3.0; - Path path = Path()..addRRect(_getScannerAreaRRect(size)); + final RRect overlayRRect = _rectProvider(size); + + Path path = Path()..addRRect(overlayRRect); canvas.drawPath(path, paint); + + if (_status == ScanStatus.success) { + const icon = Icons.check_circle; + final iconSize = + overlayRRect.width < 150 ? overlayRRect.width - 5.0 : 150.0; + TextPainter iconPainter = TextPainter( + textDirection: TextDirection.rtl, + textAlign: TextAlign.center, + ); + iconPainter.text = TextSpan( + text: String.fromCharCode(icon.codePoint), + style: TextStyle( + fontSize: iconSize, + fontFamily: icon.fontFamily, + color: color.withAlpha(240), + )); + iconPainter.layout(); + iconPainter.paint( + canvas, + overlayRRect.center.translate(-iconSize / 2, -iconSize / 2), + ); + } } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } -/// clips the scanner area rounded rect of specific Size -class _ScannerAreaClipper extends CustomClipper { +/// Clips a hole into the background. +/// The clipped area is acquired through passed in rectangle provider. +class _OverlayClipper extends CustomClipper { + final Function(Size) _rectProvider; + + _OverlayClipper(this._rectProvider); + @override Path getClip(Size size) { return Path() ..addRect(Rect.fromLTWH(0, 0, size.width, size.height)) - ..addRRect(_getScannerAreaRRect(size)) + ..addRRect(_rectProvider(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: const Opacity( - opacity: 0.6, - child: ColoredBox( - color: Colors.black, - child: Column( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [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, - )), - ]); - } + bool shouldReclip(covariant CustomClipper oldClipper) => false; } diff --git a/lib/android/qr_scanner/qr_scanner_permissions_view.dart b/lib/android/qr_scanner/qr_scanner_permissions_view.dart index 30bdb050..935c0520 100644 --- a/lib/android/qr_scanner/qr_scanner_permissions_view.dart +++ b/lib/android/qr_scanner/qr_scanner_permissions_view.dart @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 Yubico. + * Copyright (C) 2022-2023 Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,7 +18,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'qr_scanner_scan_status.dart'; -import 'qr_scanner_util.dart'; class QRScannerPermissionsUI extends StatelessWidget { final ScanStatus status; @@ -34,71 +33,62 @@ class QRScannerPermissionsUI extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final 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: Padding( - padding: const EdgeInsets.symmetric(horizontal: 36), - child: Text( + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0), + child: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Text( l10n.p_need_camera_permission, 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), - width: screenSize.width, - height: screenSize.height), - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Column( + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - Text( - l10n.q_have_account_info, - textScaleFactor: 0.7, - style: const TextStyle(color: Colors.white), - ), - OutlinedButton( - onPressed: () { - Navigator.of(context).pop(''); - }, - child: Text( - l10n.s_enter_manually, + Column( + children: [ + Text( + l10n.q_have_account_info, + textScaleFactor: 0.7, style: const TextStyle(color: Colors.white), - )), - ], - ), - Column( - children: [ - Text( - l10n.q_want_to_scan, - textScaleFactor: 0.7, - style: const TextStyle(color: Colors.white), + ), + OutlinedButton( + onPressed: () { + Navigator.of(context).pop(''); + }, + child: Text( + l10n.s_enter_manually, + style: const TextStyle(color: Colors.white), + )), + ], ), - OutlinedButton( - onPressed: () { - onPermissionRequest(); - }, - child: Text( - l10n.s_review_permissions, + Column( + children: [ + Text( + l10n.q_want_to_scan, + textScaleFactor: 0.7, style: const TextStyle(color: Colors.white), - )), - ], - ) - ]), + ), + OutlinedButton( + onPressed: () { + onPermissionRequest(); + }, + child: Text( + l10n.s_review_permissions, + style: const TextStyle(color: Colors.white), + )), + ], + ) + ]) + ], + ), ), - ]); + ); } } diff --git a/lib/android/qr_scanner/qr_scanner_ui_view.dart b/lib/android/qr_scanner/qr_scanner_ui_view.dart index 440f1caf..45c396e8 100644 --- a/lib/android/qr_scanner/qr_scanner_ui_view.dart +++ b/lib/android/qr_scanner/qr_scanner_ui_view.dart @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 Yubico. + * Copyright (C) 2022-2023 Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,69 +19,76 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import '../keys.dart' as keys; import 'qr_scanner_scan_status.dart'; -import 'qr_scanner_util.dart'; class QRScannerUI extends StatelessWidget { final ScanStatus status; final Size screenSize; + final GlobalKey overlayWidgetKey; - const QRScannerUI({ - super.key, - required this.status, - required this.screenSize, - }); + const QRScannerUI( + {super.key, + required this.status, + required this.screenSize, + required this.overlayWidgetKey}); @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final 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: Padding( - padding: const EdgeInsets.all(4.0), - child: Text( - status != ScanStatus.error - ? l10n.l_point_camera_scan - : l10n.l_invalid_qr, - 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: [ - Text( - l10n.q_no_qr, - textScaleFactor: 0.7, - style: const TextStyle(color: Colors.white), - ), - OutlinedButton( - onPressed: () { - Navigator.of(context).pop(''); - }, - key: keys.manualEntryButton, + return Stack( + fit: StackFit.expand, + children: [ + SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only( + left: 16, right: 16, top: 0, bottom: 0), + child: SizedBox( + // other widgets can find the RenderObject of this + // widget by its key value and query its size and offset. + key: overlayWidgetKey, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 0.0), child: Text( - l10n.s_enter_manually, + status != ScanStatus.error + ? l10n.l_point_camera_scan + : l10n.l_invalid_qr, style: const TextStyle(color: Colors.white), - )), - ], - ), - ), - ]); + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 16), + Column( + children: [ + Text( + l10n.q_no_qr, + textScaleFactor: 0.7, + style: const TextStyle(color: Colors.white), + ), + OutlinedButton( + onPressed: () { + Navigator.of(context).pop(''); + }, + key: keys.manualEntryButton, + child: Text( + l10n.s_enter_manually, + style: const TextStyle(color: Colors.white), + )), + ], + ), + const SizedBox(height: 8) + ], + ), + ) + ], + ); } } diff --git a/lib/android/qr_scanner/qr_scanner_util.dart b/lib/android/qr_scanner/qr_scanner_util.dart deleted file mode 100644 index c8f10b3b..00000000 --- a/lib/android/qr_scanner/qr_scanner_util.dart +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright (C) 2022 Yubico. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -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 56ca3db2..9ca1e0c8 100755 --- a/lib/android/qr_scanner/qr_scanner_view.dart +++ b/lib/android/qr_scanner/qr_scanner_view.dart @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 Yubico. + * Copyright (C) 2022-2023 Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -103,6 +103,7 @@ class _QrScannerViewState extends State { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final screenSize = MediaQuery.of(context).size; + final overlayWidgetKey = GlobalKey(); return Scaffold( resizeToAvoidBottomInset: false, extendBodyBehindAppBar: true, @@ -153,12 +154,14 @@ class _QrScannerViewState extends State { child: QRScannerOverlay( status: _status, screenSize: screenSize, + overlayWidgetKey: overlayWidgetKey, )), Visibility( visible: _permissionsGranted, child: QRScannerUI( status: _status, screenSize: screenSize, + overlayWidgetKey: overlayWidgetKey, )), Visibility( visible: _previewInitialized && !_permissionsGranted,