From 9a1dc8e82b2ad6ce49aba1b8993f95f157a5bfc3 Mon Sep 17 00:00:00 2001 From: klockky Date: Mon, 6 Apr 2026 15:43:33 +0300 Subject: [PATCH] feat(qr): rounded finder overlay, dim mask, green lock, settle delay before pop --- .../screens/profile/web_qr_scan_screen.dart | 436 ++++++++++++++++-- pubspec.lock | 16 +- 2 files changed, 404 insertions(+), 48 deletions(-) diff --git a/lib/frontend/screens/profile/web_qr_scan_screen.dart b/lib/frontend/screens/profile/web_qr_scan_screen.dart index c512811..a859b5a 100644 --- a/lib/frontend/screens/profile/web_qr_scan_screen.dart +++ b/lib/frontend/screens/profile/web_qr_scan_screen.dart @@ -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 { 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(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(raw); + + if (_armedRaw != raw) { + _armedRaw = raw; + _completeTimer?.cancel(); + _completeTimer = Timer(_settleAfterDetect, _completeScan); + } } @override @@ -68,50 +91,383 @@ class _WebQrScanScreenState extends State { ), ], ), - 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? _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 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( + 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 _mapBarcodeCornersToLayout( + List 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, + ), + ]; +} + diff --git a/pubspec.lock b/pubspec.lock index 62a257f..66aac21 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: