import 'package:flutter/material.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:qrscanner/models/auth_session.dart'; import 'package:qrscanner/models/pass_code.dart'; import 'package:qrscanner/models/scan_response.dart'; import 'package:qrscanner/services/feedback_service.dart'; import 'package:qrscanner/services/pass_code_service.dart'; import 'package:qrscanner/theme/app_theme.dart'; enum _ScanPhase { scanning, processing, success, warning } const double _scanWindowSize = 260; const double _scanWindowRadius = 28; const Alignment _scanWindowAlignment = Alignment(0, -0.12); class ScannerScreen extends StatefulWidget { const ScannerScreen({ super.key, required this.session, }); final AuthSession session; @override State createState() => _ScannerScreenState(); } class _ScannerScreenState extends State with TickerProviderStateMixin { final _scannerController = MobileScannerController( detectionSpeed: DetectionSpeed.normal, detectionTimeoutMs: 500, facing: CameraFacing.back, formats: const [BarcodeFormat.qrCode], autoZoom: true, ); final _passCodeService = PassCodeService(); final _feedbackService = FeedbackService(); _ScanPhase _phase = _ScanPhase.scanning; bool _isHandlingDetection = false; String? _lastScannedCode; String? _message; PassCode? _passCode; late final AnimationController _scanLineController; late final AnimationController _resultController; late final Animation _scanLineAnimation; late final Animation _scaleAnimation; late final Animation _arrowSlide; @override void initState() { super.initState(); _scanLineController = AnimationController( vsync: this, duration: const Duration(milliseconds: 2400), )..repeat(); _scanLineAnimation = CurvedAnimation( parent: _scanLineController, curve: Curves.easeInOut, ); _resultController = AnimationController( vsync: this, duration: const Duration(milliseconds: 700), ); _scaleAnimation = CurvedAnimation( parent: _resultController, curve: Curves.elasticOut, ); _arrowSlide = Tween( begin: const Offset(0, -0.35), end: Offset.zero, ).animate(CurvedAnimation( parent: _resultController, curve: Curves.easeOutBack, )); } Rect _scanWindowRect(Size size) { final center = _scanWindowAlignment.alongSize(size); return Rect.fromCenter( center: center, width: _scanWindowSize, height: _scanWindowSize, ); } @override void dispose() { _scanLineController.dispose(); _resultController.dispose(); _scannerController.dispose(); _feedbackService.dispose(); super.dispose(); } Future _onDetect(BarcodeCapture capture) async { if (_phase != _ScanPhase.scanning || _isHandlingDetection) return; final rawValue = capture.barcodes.firstOrNull?.rawValue?.trim(); if (rawValue == null || rawValue.isEmpty) return; final code = _extractCode(rawValue); if (code.isEmpty || code == _lastScannedCode) return; _isHandlingDetection = true; setState(() { _phase = _ScanPhase.processing; _lastScannedCode = code; _message = null; _passCode = null; }); _scanLineController.stop(); await _scannerController.stop(); await _feedbackService.onCodeDetected(); try { final response = await _passCodeService.scan( session: widget.session, code: code, ); if (!mounted) return; await _showResult(response); } catch (_) { if (!mounted) return; await _showResult( const ScanResponse( success: false, message: 'No se pudo validar el código. Revisa la conexión.', ), ); } finally { _isHandlingDetection = false; } } String _extractCode(String rawValue) { final uri = Uri.tryParse(rawValue); if (uri != null) { final queryCode = uri.queryParameters['code']; if (queryCode != null && queryCode.isNotEmpty) return queryCode; final segments = uri.pathSegments.where((s) => s.isNotEmpty).toList(); if (segments.isNotEmpty) return segments.last; } return rawValue; } Future _showResult(ScanResponse response) async { _resultController.reset(); setState(() { _message = response.message; _passCode = response.passCode; _phase = response.success ? _ScanPhase.success : _ScanPhase.warning; }); if (response.success) { await _feedbackService.onSuccess(); } else { await _feedbackService.onWarning(); } await _resultController.forward(); } Future _scanAgain() async { _resultController.reset(); _isHandlingDetection = false; setState(() { _phase = _ScanPhase.scanning; _lastScannedCode = null; _message = null; _passCode = null; }); _scanLineController.repeat(); await _scannerController.start(); } @override Widget build(BuildContext context) { final isSuccess = _phase == _ScanPhase.success; final isWarning = _phase == _ScanPhase.warning; final overlayColor = isSuccess ? AppTheme.success.withValues(alpha: 0.82) : isWarning ? AppTheme.warning.withValues(alpha: 0.82) : Colors.transparent; return Scaffold( backgroundColor: Colors.black, extendBodyBehindAppBar: true, appBar: AppBar( backgroundColor: Colors.transparent, elevation: 0, leading: IconButton( icon: const Icon(Icons.arrow_back_rounded), onPressed: () => Navigator.of(context).pop(), ), title: const Text('Escanear pase'), centerTitle: true, ), body: Stack( fit: StackFit.expand, children: [ if (_phase == _ScanPhase.scanning || _phase == _ScanPhase.processing) LayoutBuilder( builder: (context, constraints) { final scanRect = _scanWindowRect(constraints.biggest); return MobileScanner( controller: _scannerController, onDetect: _onDetect, scanWindow: scanRect, overlayBuilder: (context, constraints) { if (_phase != _ScanPhase.scanning) { return const SizedBox.shrink(); } return AnimatedBuilder( animation: _scanLineAnimation, builder: (context, child) { return CustomPaint( size: constraints.biggest, painter: _ScannerOverlayPainter( scanRect: scanRect, scanLineProgress: _scanLineAnimation.value, ), ); }, ); }, ); }, ), if (_phase != _ScanPhase.scanning && _phase != _ScanPhase.processing) Container(color: AppTheme.backgroundDark), AnimatedContainer( duration: const Duration(milliseconds: 450), curve: Curves.easeOutCubic, color: overlayColor, ), if (_phase == _ScanPhase.processing) SafeArea( child: Center(child: _buildProcessingCard()), ), if (_phase == _ScanPhase.success || _phase == _ScanPhase.warning) SafeArea( child: Center( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(vertical: 16), child: _buildResultCard(), ), ), ), if (_phase == _ScanPhase.scanning) SafeArea( child: Align( alignment: Alignment.bottomCenter, child: Padding( padding: const EdgeInsets.fromLTRB(32, 0, 32, 28), child: _buildHint(), ), ), ), ], ), ); } Widget _buildProcessingCard() { return Container( margin: const EdgeInsets.symmetric(horizontal: 28), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), decoration: BoxDecoration( color: AppTheme.surfaceDark.withValues(alpha: 0.92), borderRadius: BorderRadius.circular(24), border: Border.all(color: Colors.white.withValues(alpha: 0.08)), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ const SizedBox( width: 42, height: 42, child: CircularProgressIndicator( strokeWidth: 3, color: AppTheme.primary, ), ), const SizedBox(height: 18), Text( 'Validando código...', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), if (_lastScannedCode != null) ...[ const SizedBox(height: 8), Text( _lastScannedCode!, style: TextStyle( color: Colors.white.withValues(alpha: 0.45), letterSpacing: 1.2, ), ), ], ], ), ); } Widget _buildResultCard() { final isSuccess = _phase == _ScanPhase.success; final accent = isSuccess ? AppTheme.success : AppTheme.warning; final accentDark = isSuccess ? AppTheme.successDark : AppTheme.warningDark; return FadeTransition( opacity: _scaleAnimation, child: ScaleTransition( scale: _scaleAnimation, child: Container( margin: const EdgeInsets.symmetric(horizontal: 24), padding: const EdgeInsets.fromLTRB(24, 32, 24, 24), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ accent.withValues(alpha: 0.95), accentDark.withValues(alpha: 0.95), ], ), borderRadius: BorderRadius.circular(28), boxShadow: [ BoxShadow( color: accent.withValues(alpha: 0.45), blurRadius: 32, offset: const Offset(0, 12), ), ], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ SlideTransition( position: _arrowSlide, child: Container( width: 88, height: 88, decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.18), shape: BoxShape.circle, ), child: Icon( isSuccess ? Icons.arrow_downward_rounded : Icons.warning_amber_rounded, size: isSuccess ? 52 : 46, color: Colors.white, ), ), ), const SizedBox(height: 20), Text( isSuccess ? '¡Pase válido!' : 'No se pudo validar', style: const TextStyle( fontSize: 24, fontWeight: FontWeight.bold, color: Colors.white, ), ), if (!isSuccess && (_message?.isNotEmpty ?? false)) ...[ const SizedBox(height: 10), Text( _message!, textAlign: TextAlign.center, style: TextStyle( fontSize: 15, height: 1.4, color: Colors.white.withValues(alpha: 0.92), ), ), ], if (isSuccess && _passCode != null) ...[ const SizedBox(height: 20), Container( width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.18), borderRadius: BorderRadius.circular(16), ), child: Column( children: [ _detailRow('Nombre', _passCode!.name), const SizedBox(height: 8), _detailRow('Código', _passCode!.code), ], ), ), ], const SizedBox(height: 24), SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: _scanAgain, icon: const Icon(Icons.qr_code_scanner_rounded), label: const Text('Escanear otro'), style: FilledButton.styleFrom( backgroundColor: Colors.white, foregroundColor: accentDark, padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), ), ), ), ), ], ), ), ), ); } Widget _detailRow(String label, String value) { return Row( children: [ Text( '$label: ', style: TextStyle( color: Colors.white.withValues(alpha: 0.7), fontSize: 14, ), ), Expanded( child: Text( value, textAlign: TextAlign.end, style: const TextStyle( color: Colors.white, fontWeight: FontWeight.w600, fontSize: 14, ), ), ), ], ); } Widget _buildHint() { return Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: Text( 'Apunta la cámara al código QR del pase', textAlign: TextAlign.center, style: TextStyle( color: Colors.white.withValues(alpha: 0.65), fontSize: 14, ), ), ); } } class _ScannerOverlayPainter extends CustomPainter { _ScannerOverlayPainter({ required this.scanRect, required this.scanLineProgress, }); final Rect scanRect; final double scanLineProgress; static const double _cornerLength = 36; static const double _cornerStroke = 4; @override void paint(Canvas canvas, Size size) { final cutout = RRect.fromRectAndRadius( scanRect, const Radius.circular(_scanWindowRadius), ); final background = Path()..addRect(Offset.zero & size); final hole = Path()..addRRect(cutout); final overlay = Path.combine(PathOperation.difference, background, hole); canvas.drawPath( overlay, Paint()..color = Colors.black.withValues(alpha: 0.55), ); final borderPaint = Paint() ..color = AppTheme.primary.withValues(alpha: 0.35) ..style = PaintingStyle.stroke ..strokeWidth = 1.5; canvas.drawRRect(cutout, borderPaint); final cornerPaint = Paint() ..color = AppTheme.primary ..style = PaintingStyle.stroke ..strokeWidth = _cornerStroke ..strokeCap = StrokeCap.round; _drawCorners(canvas, scanRect, cornerPaint); final lineY = scanRect.top + _scanWindowRadius + (scanRect.height - _scanWindowRadius * 2) * scanLineProgress; final linePaint = Paint() ..shader = LinearGradient( colors: [ AppTheme.primary.withValues(alpha: 0), AppTheme.primary.withValues(alpha: 0.85), AppTheme.primary.withValues(alpha: 0), ], stops: const [0, 0.5, 1], ).createShader( Rect.fromLTWH(scanRect.left + 16, lineY - 1, scanRect.width - 32, 2), ) ..strokeWidth = 2 ..strokeCap = StrokeCap.round; canvas.drawLine( Offset(scanRect.left + 16, lineY), Offset(scanRect.right - 16, lineY), linePaint, ); } void _drawCorners(Canvas canvas, Rect rect, Paint paint) { const r = _scanWindowRadius; const len = _cornerLength; canvas.drawLine( Offset(rect.left + r, rect.top), Offset(rect.left + r + len, rect.top), paint, ); canvas.drawLine( Offset(rect.left, rect.top + r), Offset(rect.left, rect.top + r + len), paint, ); canvas.drawLine( Offset(rect.right - r - len, rect.top), Offset(rect.right - r, rect.top), paint, ); canvas.drawLine( Offset(rect.right, rect.top + r), Offset(rect.right, rect.top + r + len), paint, ); canvas.drawLine( Offset(rect.left + r, rect.bottom), Offset(rect.left + r + len, rect.bottom), paint, ); canvas.drawLine( Offset(rect.left, rect.bottom - r - len), Offset(rect.left, rect.bottom - r), paint, ); canvas.drawLine( Offset(rect.right - r - len, rect.bottom), Offset(rect.right - r, rect.bottom), paint, ); canvas.drawLine( Offset(rect.right, rect.bottom - r - len), Offset(rect.right, rect.bottom - r), paint, ); } @override bool shouldRepaint(covariant _ScannerOverlayPainter oldDelegate) { return oldDelegate.scanRect != scanRect || oldDelegate.scanLineProgress != scanLineProgress; } }