feat(qr): rounded finder overlay, dim mask, green lock, settle delay before pop
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
@@ -12,23 +16,42 @@ class WebQrScanScreen extends StatefulWidget {
|
||||
|
||||
class _WebQrScanScreenState extends State<WebQrScanScreen> {
|
||||
final MobileScannerController _controller = MobileScannerController();
|
||||
static const _settleAfterDetect = Duration(milliseconds: 720);
|
||||
|
||||
bool _handled = false;
|
||||
String? _armedRaw;
|
||||
Timer? _completeTimer;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_completeTimer?.cancel();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _completeScan() {
|
||||
if (!mounted || _handled) return;
|
||||
final value = _armedRaw;
|
||||
if (value == null || value.isEmpty) return;
|
||||
_handled = true;
|
||||
_completeTimer?.cancel();
|
||||
_completeTimer = null;
|
||||
unawaited(_controller.stop());
|
||||
if (mounted) Navigator.of(context).pop<String>(value);
|
||||
}
|
||||
|
||||
void _onDetect(BarcodeCapture capture) {
|
||||
if (_handled) return;
|
||||
final barcodes = capture.barcodes;
|
||||
if (barcodes.isEmpty) return;
|
||||
final raw = barcodes.first.rawValue;
|
||||
if (raw == null || raw.isEmpty) return;
|
||||
_handled = true;
|
||||
_controller.stop();
|
||||
if (mounted) Navigator.of(context).pop<String>(raw);
|
||||
|
||||
if (_armedRaw != raw) {
|
||||
_armedRaw = raw;
|
||||
_completeTimer?.cancel();
|
||||
_completeTimer = Timer(_settleAfterDetect, _completeScan);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -68,50 +91,383 @@ class _WebQrScanScreenState extends State<WebQrScanScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
MobileScanner(
|
||||
controller: _controller,
|
||||
onDetect: _onDetect,
|
||||
errorBuilder: (context, error) {
|
||||
return Center(
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final layoutSize = Size(constraints.maxWidth, constraints.maxHeight);
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
MobileScanner(
|
||||
controller: _controller,
|
||||
onDetect: _onDetect,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
error.errorDetails?.message ?? 'Камера недоступна',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
_TelegramStyleFinderOverlay(
|
||||
layoutSize: layoutSize,
|
||||
controller: _controller,
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 48,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(
|
||||
error.errorDetails?.message ?? 'Камера недоступна',
|
||||
'Наведите камеру на QR-код на экране компьютера',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 15),
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
shadows: const [
|
||||
Shadow(
|
||||
blurRadius: 8,
|
||||
color: Colors.black54,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 48,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(
|
||||
'Наведите камеру на QR-код на экране компьютера',
|
||||
textAlign: TextAlign.center,
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
shadows: const [
|
||||
Shadow(
|
||||
blurRadius: 8,
|
||||
color: Colors.black54,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TelegramStyleFinderOverlay extends StatefulWidget {
|
||||
const _TelegramStyleFinderOverlay({
|
||||
required this.layoutSize,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
final Size layoutSize;
|
||||
final MobileScannerController controller;
|
||||
|
||||
@override
|
||||
State<_TelegramStyleFinderOverlay> createState() =>
|
||||
_TelegramStyleFinderOverlayState();
|
||||
}
|
||||
|
||||
class _TelegramStyleFinderOverlayState extends State<_TelegramStyleFinderOverlay>
|
||||
with SingleTickerProviderStateMixin {
|
||||
static const _animDuration = Duration(milliseconds: 320);
|
||||
static const _snapPx = 14.0;
|
||||
static const _frameCornerRadius = 16.0;
|
||||
static const _qrBoundsPadding = 14.0;
|
||||
|
||||
late AnimationController _ac;
|
||||
late CurvedAnimation _curved;
|
||||
Rect _fromR = Rect.zero;
|
||||
Rect _toR = Rect.zero;
|
||||
StreamSubscription<BarcodeCapture>? _barcodeSub;
|
||||
bool _qrInView = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final d = _defaultFinderRect(widget.layoutSize);
|
||||
_fromR = d;
|
||||
_toR = d;
|
||||
_ac = AnimationController(vsync: this, duration: _animDuration);
|
||||
_curved = CurvedAnimation(parent: _ac, curve: Curves.easeOutCubic);
|
||||
_ac.addListener(() => setState(() {}));
|
||||
_barcodeSub = widget.controller.barcodes.listen(_onBarcodeFrame);
|
||||
widget.controller.addListener(_onControllerUpdate);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _TelegramStyleFinderOverlay oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.layoutSize != widget.layoutSize) {
|
||||
final d = _defaultFinderRect(widget.layoutSize);
|
||||
_fromR = d;
|
||||
_toR = d;
|
||||
_ac.value = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onControllerUpdate);
|
||||
unawaited(_barcodeSub?.cancel());
|
||||
_curved.dispose();
|
||||
_ac.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onControllerUpdate() {
|
||||
final v = widget.controller.value;
|
||||
if (!v.isInitialized || !v.isRunning || v.error != null) {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
void _onBarcodeFrame(BarcodeCapture cap) {
|
||||
if (!mounted) return;
|
||||
final v = widget.controller.value;
|
||||
if (!v.isInitialized || !v.isRunning || v.error != null) return;
|
||||
|
||||
final defaultRect = _defaultFinderRect(widget.layoutSize);
|
||||
final mapped = _targetFinderRectFromCapture(cap, v);
|
||||
final inView = mapped != null;
|
||||
if (inView != _qrInView) {
|
||||
setState(() => _qrInView = inView);
|
||||
}
|
||||
final next = mapped ?? defaultRect;
|
||||
if (_rectClose(_toR, next) && _ac.isCompleted) {
|
||||
return;
|
||||
}
|
||||
_animateTo(next);
|
||||
}
|
||||
|
||||
Rect _defaultFinderRect(Size s) {
|
||||
final side = math.min(s.width, s.height) * 0.68;
|
||||
final left = (s.width - side) / 2;
|
||||
final top = (s.height - side) / 2 - s.height * 0.06;
|
||||
return Rect.fromLTWH(left, top, side, side);
|
||||
}
|
||||
|
||||
bool _rectClose(Rect a, Rect b) {
|
||||
return (a.left - b.left).abs() < _snapPx &&
|
||||
(a.top - b.top).abs() < _snapPx &&
|
||||
(a.width - b.width).abs() < _snapPx &&
|
||||
(a.height - b.height).abs() < _snapPx;
|
||||
}
|
||||
|
||||
Rect _interpolatedFinderRect() {
|
||||
if (_fromR.isEmpty || _toR.isEmpty) {
|
||||
return _defaultFinderRect(widget.layoutSize);
|
||||
}
|
||||
return Rect.lerp(_fromR, _toR, _curved.value)!;
|
||||
}
|
||||
|
||||
void _animateTo(Rect target) {
|
||||
if (_rectClose(_toR, target) && _ac.isCompleted) {
|
||||
return;
|
||||
}
|
||||
double t;
|
||||
if (_ac.status == AnimationStatus.completed) {
|
||||
t = 1.0;
|
||||
} else if (_ac.status == AnimationStatus.dismissed) {
|
||||
t = 0.0;
|
||||
} else {
|
||||
t = _curved.value;
|
||||
}
|
||||
_fromR = Rect.lerp(_fromR, _toR, t)!;
|
||||
_toR = target;
|
||||
_ac.forward(from: 0);
|
||||
}
|
||||
|
||||
Rect? _targetFinderRectFromCapture(
|
||||
BarcodeCapture? cap,
|
||||
MobileScannerState scannerState,
|
||||
) {
|
||||
if (cap == null || cap.barcodes.isEmpty || widget.layoutSize.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final b = cap.barcodes.first;
|
||||
if (b.corners.length < 4) return null;
|
||||
|
||||
final refSize = !cap.size.isEmpty
|
||||
? cap.size
|
||||
: (!scannerState.size.isEmpty ? scannerState.size : Size.zero);
|
||||
if (refSize.isEmpty) return null;
|
||||
|
||||
final mapped = _mapBarcodeCornersToLayout(
|
||||
b.corners.take(4).toList(),
|
||||
refSize,
|
||||
widget.layoutSize,
|
||||
scannerState.deviceOrientation,
|
||||
);
|
||||
if (mapped.length < 4) return null;
|
||||
return _axisSquareAroundCorners(
|
||||
mapped,
|
||||
widget.layoutSize,
|
||||
padding: _qrBoundsPadding,
|
||||
);
|
||||
}
|
||||
|
||||
static Rect _axisSquareAroundCorners(
|
||||
List<Offset> corners,
|
||||
Size layout, {
|
||||
required double padding,
|
||||
}) {
|
||||
var minX = double.infinity;
|
||||
var maxX = double.negativeInfinity;
|
||||
var minY = double.infinity;
|
||||
var maxY = double.negativeInfinity;
|
||||
for (final o in corners) {
|
||||
minX = math.min(minX, o.dx);
|
||||
maxX = math.max(maxX, o.dx);
|
||||
minY = math.min(minY, o.dy);
|
||||
maxY = math.max(maxY, o.dy);
|
||||
}
|
||||
final bw = maxX - minX + 2 * padding;
|
||||
final bh = maxY - minY + 2 * padding;
|
||||
final side = math.max(bw, bh);
|
||||
final cx = (minX + maxX) / 2;
|
||||
final cy = (minY + maxY) / 2;
|
||||
var r = Rect.fromCenter(center: Offset(cx, cy), width: side, height: side);
|
||||
r = r.intersect(Rect.fromLTWH(0, 0, layout.width, layout.height));
|
||||
if (r.isEmpty) {
|
||||
return Rect.fromLTWH(0, 0, layout.shortestSide * 0.5, layout.shortestSide * 0.5);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
static RRect _finderRRect(Rect rect, double maxRadius) {
|
||||
final r = math.min(
|
||||
maxRadius,
|
||||
math.min(rect.width, rect.height) * 0.14,
|
||||
);
|
||||
return RRect.fromRectAndRadius(rect, Radius.circular(r));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<MobileScannerState>(
|
||||
valueListenable: widget.controller,
|
||||
builder: (context, state, _) {
|
||||
if (!state.isInitialized || !state.isRunning || state.error != null) {
|
||||
return const SizedBox.expand();
|
||||
}
|
||||
|
||||
final finderRect = _interpolatedFinderRect();
|
||||
final rrect = _finderRRect(finderRect, _frameCornerRadius);
|
||||
final frameColor =
|
||||
_qrInView ? const Color(0xFF4ADE80) : Colors.white;
|
||||
return IgnorePointer(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
CustomPaint(
|
||||
size: widget.layoutSize,
|
||||
painter: _ScannerDimOutsideRRectPainter(
|
||||
hole: rrect,
|
||||
dimColor: Colors.black.withValues(alpha: 0.58),
|
||||
),
|
||||
),
|
||||
CustomPaint(
|
||||
size: widget.layoutSize,
|
||||
painter: _FinderRoundedSquareStrokePainter(
|
||||
rrect: rrect,
|
||||
color: frameColor,
|
||||
strokeWidth: 3.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScannerDimOutsideRRectPainter extends CustomPainter {
|
||||
_ScannerDimOutsideRRectPainter({
|
||||
required this.hole,
|
||||
required this.dimColor,
|
||||
});
|
||||
|
||||
final RRect hole;
|
||||
final Color dimColor;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final outer = Path()
|
||||
..addRect(Rect.fromLTWH(0, 0, size.width, size.height));
|
||||
final inner = Path()..addRRect(hole);
|
||||
final mask = Path.combine(PathOperation.difference, outer, inner);
|
||||
canvas.drawPath(mask, Paint()..color = dimColor);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _ScannerDimOutsideRRectPainter oldDelegate) {
|
||||
if (oldDelegate.dimColor != dimColor) return true;
|
||||
return oldDelegate.hole != hole;
|
||||
}
|
||||
}
|
||||
|
||||
class _FinderRoundedSquareStrokePainter extends CustomPainter {
|
||||
_FinderRoundedSquareStrokePainter({
|
||||
required this.rrect,
|
||||
required this.color,
|
||||
required this.strokeWidth,
|
||||
});
|
||||
|
||||
final RRect rrect;
|
||||
final Color color;
|
||||
final double strokeWidth;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final shadowPaint = Paint()
|
||||
..color = Colors.black.withValues(alpha: 0.45)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth + 2.5
|
||||
..strokeCap = StrokeCap.round
|
||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3);
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth
|
||||
..strokeCap = StrokeCap.round;
|
||||
canvas.drawRRect(rrect, shadowPaint);
|
||||
canvas.drawRRect(rrect, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _FinderRoundedSquareStrokePainter oldDelegate) {
|
||||
return oldDelegate.rrect != rrect ||
|
||||
oldDelegate.color != color ||
|
||||
oldDelegate.strokeWidth != strokeWidth;
|
||||
}
|
||||
}
|
||||
|
||||
List<Offset> _mapBarcodeCornersToLayout(
|
||||
List<Offset> barcodeCorners,
|
||||
Size cameraPreviewSize,
|
||||
Size layoutSize,
|
||||
DeviceOrientation deviceOrientation,
|
||||
) {
|
||||
if (barcodeCorners.length < 4 || cameraPreviewSize.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final isLandscape = deviceOrientation == DeviceOrientation.landscapeLeft ||
|
||||
deviceOrientation == DeviceOrientation.landscapeRight;
|
||||
final cam = isLandscape ? cameraPreviewSize.flipped : cameraPreviewSize;
|
||||
|
||||
final wr = layoutSize.width / cam.width;
|
||||
final hr = layoutSize.height / cam.height;
|
||||
final ratio = math.max(wr, hr);
|
||||
final hPad = (cam.width * ratio - layoutSize.width) / 2;
|
||||
final vPad = (cam.height * ratio - layoutSize.height) / 2;
|
||||
|
||||
return [
|
||||
for (final o in barcodeCorners)
|
||||
Offset(
|
||||
o.dx * ratio - hPad,
|
||||
o.dy * ratio - vPad,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+8
-8
@@ -21,10 +21,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
version: "1.4.0"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -313,18 +313,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.19"
|
||||
version: "0.12.17"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
version: "0.11.1"
|
||||
material_symbols_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -638,10 +638,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.10"
|
||||
version: "0.7.7"
|
||||
timezone:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
Reference in New Issue
Block a user