feat(qr): rounded finder overlay, dim mask, green lock, settle delay before pop

This commit is contained in:
klockky
2026-04-06 15:43:33 +03:00
parent 55313abb12
commit 57fbaece03
2 changed files with 404 additions and 48 deletions
@@ -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
View File
@@ -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: