Files
Base-QR-FlutterApp/lib/screens/scanner_screen.dart
T
Alejandro Lara 50fe9a9d36 Initial commit: QR Scanner Flutter app
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.
2026-06-09 17:03:06 -06:00

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;
}
}