improve QRScanner views for responsiveness

This commit is contained in:
Adam Velebil 2023-09-21 11:40:41 +02:00
parent 1f72862c0b
commit d25a1fddb1
No known key found for this signature in database
GPG Key ID: C9B1E4A3CBBD2E10
5 changed files with 221 additions and 214 deletions

View File

@ -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<Path> {
/// Clips a hole into the background.
/// The clipped area is acquired through passed in rectangle provider.
class _OverlayClipper extends CustomClipper<Path> {
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<Path> 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<Path> oldClipper) => false;
}

View File

@ -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),
)),
],
)
])
],
),
),
]);
);
}
}

View File

@ -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)
],
),
)
],
);
}
}

View File

@ -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;

View File

@ -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<QrScannerView> {
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<QrScannerView> {
child: QRScannerOverlay(
status: _status,
screenSize: screenSize,
overlayWidgetKey: overlayWidgetKey,
)),
Visibility(
visible: _permissionsGranted,
child: QRScannerUI(
status: _status,
screenSize: screenSize,
overlayWidgetKey: overlayWidgetKey,
)),
Visibility(
visible: _previewInitialized && !_permissionsGranted,