50fe9a9d36
App móvil para validar pases QR contra base-admin-web con login Cognito, escáner en tiempo real, feedback auditivo/háptico y README completo.
590 lines
17 KiB
Dart
590 lines
17 KiB
Dart
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<ScannerScreen> createState() => _ScannerScreenState();
|
|
}
|
|
|
|
class _ScannerScreenState extends State<ScannerScreen>
|
|
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<double> _scanLineAnimation;
|
|
late final Animation<double> _scaleAnimation;
|
|
late final Animation<Offset> _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<Offset>(
|
|
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<void> _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<void> _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<void> _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;
|
|
}
|
|
} |