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.
This commit is contained in:
Alejandro Lara
2026-06-09 17:03:06 -06:00
commit 50fe9a9d36
83 changed files with 4613 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
/// URL base del API de base-admin-web.
///
/// Configúrala al ejecutar o compilar si necesitas otra IP:
/// `flutter run --dart-define=API_BASE_URL=http://otra-ip:8080`
class ApiConfig {
ApiConfig._();
static const String baseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://10.99.51.6:8080',
);
static String get loginUrl => '$baseUrl/api/auth/login';
static String get scanUrl => '$baseUrl/api/pass-codes/scan';
}
+21
View File
@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'package:qrscanner/screens/login_screen.dart';
import 'package:qrscanner/theme/app_theme.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'QR Scanner',
debugShowCheckedModeBanner: false,
theme: AppTheme.darkTheme,
home: const LoginScreen(),
);
}
}
+64
View File
@@ -0,0 +1,64 @@
import 'package:qrscanner/models/login_response.dart';
class AuthSession {
const AuthSession({
required this.username,
required this.accessToken,
required this.tokenType,
required this.expiresIn,
required this.roles,
this.refreshToken,
this.idToken,
});
factory AuthSession.fromLoginResponse(LoginResponse response) {
return AuthSession(
username: response.username ?? '',
accessToken: response.accessToken ?? '',
refreshToken: response.refreshToken,
idToken: response.idToken,
tokenType: response.tokenType ?? 'Bearer',
expiresIn: response.expiresIn ?? 0,
roles: response.roles,
);
}
factory AuthSession.fromJson(Map<String, dynamic> json) {
return AuthSession(
username: json['username'] as String? ?? '',
accessToken: json['accessToken'] as String? ?? '',
refreshToken: json['refreshToken'] as String?,
idToken: json['idToken'] as String?,
tokenType: json['tokenType'] as String? ?? 'Bearer',
expiresIn: (json['expiresIn'] as num?)?.toInt() ?? 0,
roles: (json['roles'] as List<dynamic>?)
?.map((role) => role.toString())
.toList() ??
const [],
);
}
final String username;
final String accessToken;
final String? refreshToken;
final String? idToken;
final String tokenType;
final int expiresIn;
final List<String> roles;
/// Token usado en las peticiones API (`Authorization: Bearer ...`).
String get authorizationHeader => '$tokenType $accessToken';
bool get isScanner =>
roles.contains('ROLE_SCANNER') || roles.contains('scanners');
Map<String, dynamic> toJson() => {
'username': username,
'accessToken': accessToken,
'refreshToken': refreshToken,
'idToken': idToken,
'tokenType': tokenType,
'expiresIn': expiresIn,
'roles': roles,
};
}
+64
View File
@@ -0,0 +1,64 @@
class LoginResponse {
const LoginResponse({
required this.success,
this.accessToken,
this.refreshToken,
this.idToken,
this.tokenType,
this.expiresIn,
this.username,
this.roles = const [],
this.message,
});
factory LoginResponse.fromJson(Map<String, dynamic> json) {
return LoginResponse(
success: json['success'] == true,
accessToken: json['accessToken'] as String?,
refreshToken: json['refreshToken'] as String?,
idToken: json['idToken'] as String?,
tokenType: json['tokenType'] as String?,
expiresIn: (json['expiresIn'] as num?)?.toInt(),
username: json['username'] as String?,
roles: (json['roles'] as List<dynamic>?)
?.map((role) => role.toString())
.toList() ??
const [],
message: json['message'] as String?,
);
}
final bool success;
final String? accessToken;
final String? refreshToken;
final String? idToken;
final String? tokenType;
final int? expiresIn;
final String? username;
final List<String> roles;
final String? message;
LoginResponse copyWith({
bool? success,
String? accessToken,
String? refreshToken,
String? idToken,
String? tokenType,
int? expiresIn,
String? username,
List<String>? roles,
String? message,
}) {
return LoginResponse(
success: success ?? this.success,
accessToken: accessToken ?? this.accessToken,
refreshToken: refreshToken ?? this.refreshToken,
idToken: idToken ?? this.idToken,
tokenType: tokenType ?? this.tokenType,
expiresIn: expiresIn ?? this.expiresIn,
username: username ?? this.username,
roles: roles ?? this.roles,
message: message ?? this.message,
);
}
}
+31
View File
@@ -0,0 +1,31 @@
class PassCode {
const PassCode({
required this.id,
required this.name,
required this.code,
required this.status,
required this.scanned,
required this.createdAt,
this.scannedAt,
});
factory PassCode.fromJson(Map<String, dynamic> json) {
return PassCode(
id: (json['id'] as num).toInt(),
name: json['name'] as String? ?? '',
code: json['code'] as String? ?? '',
status: json['status'] as String? ?? '',
scanned: json['scanned'] == true,
createdAt: json['createdAt'] as String? ?? '',
scannedAt: json['scannedAt'] as String?,
);
}
final int id;
final String name;
final String code;
final String status;
final bool scanned;
final String createdAt;
final String? scannedAt;
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:qrscanner/models/pass_code.dart';
class ScanResponse {
const ScanResponse({
required this.success,
this.message,
this.passCode,
});
factory ScanResponse.fromJson(Map<String, dynamic> json) {
return ScanResponse(
success: json['success'] == true,
message: json['message'] as String?,
passCode: json['passCode'] is Map<String, dynamic>
? PassCode.fromJson(json['passCode'] as Map<String, dynamic>)
: null,
);
}
final bool success;
final String? message;
final PassCode? passCode;
}
+311
View File
@@ -0,0 +1,311 @@
import 'package:flutter/material.dart';
import 'package:qrscanner/models/auth_session.dart';
import 'package:qrscanner/screens/login_screen.dart';
import 'package:qrscanner/screens/scanner_screen.dart';
import 'package:qrscanner/services/auth_service.dart';
import 'package:qrscanner/theme/app_theme.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({
super.key,
required this.session,
});
final AuthSession session;
Future<void> _logout(BuildContext context) async {
await AuthService().clearSession();
if (!context.mounted) return;
Navigator.of(context).pushReplacement(
PageRouteBuilder<void>(
pageBuilder: (context, animation, secondaryAnimation) =>
const LoginScreen(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(opacity: animation, child: child);
},
transitionDuration: const Duration(milliseconds: 400),
),
);
}
void _openScanner(BuildContext context) {
Navigator.of(context).push(
PageRouteBuilder<void>(
pageBuilder: (context, animation, secondaryAnimation) =>
ScannerScreen(session: session),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.06),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
)),
child: child,
),
);
},
transitionDuration: const Duration(milliseconds: 380),
),
);
}
@override
Widget build(BuildContext context) {
final expiresHours = (session.expiresIn / 3600).round();
return Scaffold(
body: Stack(
children: [
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppTheme.backgroundDark,
Color(0xFF0D1B2A),
Color(0xFF12182B),
],
),
),
),
Positioned(
top: -60,
right: -40,
child: Container(
width: 220,
height: 220,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
AppTheme.primary.withValues(alpha: 0.12),
Colors.transparent,
],
),
),
),
),
SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
child: Row(
children: [
const Spacer(),
IconButton(
icon: const Icon(Icons.logout_rounded),
tooltip: 'Cerrar sesión',
onPressed: () => _logout(context),
),
],
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 110,
height: 110,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(28),
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppTheme.primary, AppTheme.primaryDark],
),
boxShadow: [
BoxShadow(
color: AppTheme.primary.withValues(alpha: 0.35),
blurRadius: 28,
offset: const Offset(0, 10),
),
],
),
child: const Icon(
Icons.qr_code_scanner_rounded,
size: 52,
color: Colors.white,
),
),
const SizedBox(height: 28),
Text(
'Hola, ${session.username}',
style:
Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 10),
Text(
'Listo para validar pases',
style:
Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Colors.white.withValues(alpha: 0.55),
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 8,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Colors.white.withValues(alpha: 0.08),
),
),
child: Text(
'Sesión activa · $expiresHours h',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.45),
fontSize: 13,
),
),
),
const SizedBox(height: 48),
_ScanButton(onPressed: () => _openScanner(context)),
],
),
),
),
],
),
),
],
),
);
}
}
class _ScanButton extends StatefulWidget {
const _ScanButton({required this.onPressed});
final VoidCallback onPressed;
@override
State<_ScanButton> createState() => _ScanButtonState();
}
class _ScanButtonState extends State<_ScanButton>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _glow;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1800),
)..repeat(reverse: true);
_glow = Tween<double>(begin: 0.18, end: 0.38).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _glow,
builder: (context, child) {
return Container(
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: AppTheme.primary.withValues(alpha: _glow.value),
blurRadius: 28,
offset: const Offset(0, 10),
),
],
),
child: child,
);
},
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: widget.onPressed,
borderRadius: BorderRadius.circular(20),
child: Ink(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppTheme.primary, AppTheme.primaryDark],
),
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.qr_code_scanner_rounded,
color: Colors.white,
size: 26,
),
),
const SizedBox(width: 16),
const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Abrir escáner',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
Text(
'Validar código de pase',
style: TextStyle(
color: Color(0xCCFFFFFF),
fontSize: 13,
),
),
],
),
const Spacer(),
const Icon(
Icons.arrow_forward_rounded,
color: Colors.white,
),
],
),
),
),
),
),
);
}
}
+440
View File
@@ -0,0 +1,440 @@
import 'package:flutter/material.dart';
import 'package:qrscanner/config/api_config.dart';
import 'package:qrscanner/models/auth_session.dart';
import 'package:qrscanner/screens/home_screen.dart';
import 'package:qrscanner/services/auth_service.dart';
import 'package:qrscanner/theme/app_theme.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen>
with SingleTickerProviderStateMixin {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _authService = AuthService();
late final AnimationController _animationController;
late final Animation<double> _fadeAnimation;
late final Animation<Offset> _slideAnimation;
bool _obscurePassword = true;
bool _isLoading = false;
bool _rememberMe = false;
String? _errorMessage;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
_fadeAnimation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeOut,
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.08),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutCubic,
));
_animationController.forward();
_checkStoredSession();
}
Future<void> _checkStoredSession() async {
final session = await _authService.loadStoredSession();
if (!mounted || session == null) return;
_goToHome(session);
}
@override
void dispose() {
_animationController.dispose();
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _handleLogin() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final response = await _authService.login(
username: _usernameController.text.trim(),
password: _passwordController.text,
rememberMe: _rememberMe,
);
if (!mounted) return;
if (response.success && response.accessToken != null) {
_goToHome(
AuthSession.fromLoginResponse(
response.copyWith(
username: response.username ?? _usernameController.text.trim(),
),
),
);
return;
}
setState(() {
_errorMessage = response.message ?? 'Credenciales incorrectas';
});
} catch (_) {
if (!mounted) return;
setState(() {
_errorMessage =
'No se pudo conectar al servidor. Revisa API_BASE_URL (${ApiConfig.baseUrl})';
});
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
void _goToHome(AuthSession session) {
Navigator.of(context).pushReplacement(
PageRouteBuilder<void>(
pageBuilder: (context, animation, secondaryAnimation) =>
HomeScreen(session: session),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(opacity: animation, child: child);
},
transitionDuration: const Duration(milliseconds: 400),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
_buildBackground(),
SafeArea(
child: FadeTransition(
opacity: _fadeAnimation,
child: SlideTransition(
position: _slideAnimation,
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Column(
children: [
_buildHeader(),
const SizedBox(height: 40),
_buildLoginCard(),
const SizedBox(height: 24),
_buildFooter(),
],
),
),
),
),
),
),
],
),
);
}
Widget _buildBackground() {
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppTheme.backgroundDark,
Color(0xFF0D1B2A),
Color(0xFF1A1A3E),
],
),
),
child: Stack(
children: [
Positioned(
top: -80,
right: -60,
child: Container(
width: 260,
height: 260,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
AppTheme.primary.withValues(alpha: 0.15),
Colors.transparent,
],
),
),
),
),
Positioned(
bottom: -100,
left: -80,
child: Container(
width: 300,
height: 300,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
AppTheme.accent.withValues(alpha: 0.12),
Colors.transparent,
],
),
),
),
),
],
),
);
}
Widget _buildHeader() {
return Column(
children: [
Container(
width: 88,
height: 88,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(22),
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppTheme.primary, AppTheme.primaryDark],
),
boxShadow: [
BoxShadow(
color: AppTheme.primary.withValues(alpha: 0.35),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: const Icon(
Icons.qr_code_scanner_rounded,
size: 44,
color: Colors.white,
),
),
const SizedBox(height: 20),
Text(
'QR Scanner',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: -0.5,
),
),
const SizedBox(height: 8),
Text(
'Inicia sesión para continuar',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.white.withValues(alpha: 0.55),
),
),
],
);
}
Widget _buildLoginCard() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(28),
decoration: BoxDecoration(
color: AppTheme.surfaceDark.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 32,
offset: const Offset(0, 16),
),
],
),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_errorMessage != null) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.red.withValues(alpha: 0.3)),
),
child: Text(
_errorMessage!,
style: const TextStyle(color: Color(0xFFFF8A80), fontSize: 13),
),
),
const SizedBox(height: 16),
],
TextFormField(
controller: _usernameController,
keyboardType: TextInputType.text,
textInputAction: TextInputAction.next,
autocorrect: false,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: 'Usuario',
prefixIcon: Icon(
Icons.person_outline_rounded,
color: Colors.white.withValues(alpha: 0.5),
),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Ingresa tu usuario';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _handleLogin(),
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: 'Contraseña',
prefixIcon: Icon(
Icons.lock_outline_rounded,
color: Colors.white.withValues(alpha: 0.5),
),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
color: Colors.white.withValues(alpha: 0.5),
),
onPressed: () {
setState(() => _obscurePassword = !_obscurePassword);
},
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Ingresa tu contraseña';
}
return null;
},
),
const SizedBox(height: 12),
Row(
children: [
SizedBox(
height: 36,
child: Checkbox(
value: _rememberMe,
activeColor: AppTheme.primary,
side: BorderSide(
color: Colors.white.withValues(alpha: 0.3),
),
onChanged: (value) {
setState(() => _rememberMe = value ?? false);
},
),
),
Text(
'Recordarme',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
fontSize: 14,
),
),
],
),
const SizedBox(height: 8),
_buildLoginButton(),
const SizedBox(height: 16),
Text(
'API: ${ApiConfig.baseUrl}',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.35),
fontSize: 11,
),
),
],
),
),
);
}
Widget _buildLoginButton() {
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
gradient: const LinearGradient(
colors: [AppTheme.primary, AppTheme.primaryDark],
),
boxShadow: [
BoxShadow(
color: AppTheme.primary.withValues(alpha: 0.3),
blurRadius: 16,
offset: const Offset(0, 6),
),
],
),
child: ElevatedButton(
onPressed: _isLoading ? null : _handleLogin,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent,
shadowColor: Colors.transparent,
disabledBackgroundColor: Colors.transparent,
),
child: _isLoading
? const SizedBox(
height: 22,
width: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
),
)
: const Text(
'Iniciar sesión',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
);
}
Widget _buildFooter() {
return Text(
'POST /api/auth/login',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.35),
fontSize: 12,
),
);
}
}
+590
View File
@@ -0,0 +1,590 @@
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;
}
}
+104
View File
@@ -0,0 +1,104 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:qrscanner/config/api_config.dart';
import 'package:qrscanner/models/auth_session.dart';
import 'package:qrscanner/models/login_response.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AuthService {
static const _sessionKey = 'auth_session';
AuthSession? _currentSession;
AuthSession? get currentSession => _currentSession;
Future<LoginResponse> login({
required String username,
required String password,
bool rememberMe = false,
}) async {
final response = await http.post(
Uri.parse(ApiConfig.loginUrl),
headers: const {'Content-Type': 'application/json'},
body: jsonEncode({
'username': username,
'password': password,
}),
);
final Map<String, dynamic> body =
jsonDecode(response.body) as Map<String, dynamic>;
final loginResponse = LoginResponse.fromJson(body);
final accessToken = loginResponse.accessToken?.trim();
if (response.statusCode == 200 &&
loginResponse.success &&
accessToken != null &&
accessToken.isNotEmpty) {
final session = AuthSession.fromLoginResponse(
loginResponse.copyWith(
username: loginResponse.username ?? username,
accessToken: accessToken,
),
);
if (!session.isScanner) {
_currentSession = null;
await _clearStoredSession();
return const LoginResponse(
success: false,
message: 'El usuario no tiene permisos para escanear.',
);
}
_currentSession = session;
if (rememberMe) {
await _saveSession(session);
} else {
await _clearStoredSession();
}
return loginResponse.copyWith(accessToken: accessToken);
}
return LoginResponse(
success: false,
message: loginResponse.message ??
'No se pudo iniciar sesión (${response.statusCode})',
);
}
Future<void> _saveSession(AuthSession session) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_sessionKey, jsonEncode(session.toJson()));
}
Future<void> _clearStoredSession() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_sessionKey);
}
Future<void> clearSession() async {
_currentSession = null;
await _clearStoredSession();
}
Future<AuthSession?> loadStoredSession() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_sessionKey);
if (raw == null || raw.isEmpty) return null;
final session = AuthSession.fromJson(
jsonDecode(raw) as Map<String, dynamic>,
);
_currentSession = session;
return session;
}
Future<bool> hasStoredSession() async {
final session = await loadStoredSession();
return session != null && session.accessToken.isNotEmpty;
}
}
+72
View File
@@ -0,0 +1,72 @@
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/services.dart';
import 'package:vibration/vibration.dart';
class FeedbackService {
FeedbackService() : _player = AudioPlayer() {
_player.setReleaseMode(ReleaseMode.stop);
}
final AudioPlayer _player;
Future<void> onCodeDetected() async {
await Future.wait([
_play('sounds/scan.wav'),
_vibratePattern(const [0, 40]),
]);
}
Future<void> onSuccess() async {
await Future.wait([
_play('sounds/success.wav'),
_vibratePattern(const [0, 90, 70, 140]),
]);
await HapticFeedback.heavyImpact();
}
Future<void> onWarning() async {
await Future.wait([
_play('sounds/warning.wav'),
_vibratePattern(const [0, 120, 80, 120, 80, 120]),
]);
await HapticFeedback.mediumImpact();
}
Future<void> _play(String asset) async {
try {
await _player.stop();
await _player.play(AssetSource(asset));
} catch (_) {
// Ignore audio errors on devices without speaker routing.
}
}
Future<void> _vibratePattern(List<int> pattern) async {
try {
final hasVibrator = await Vibration.hasVibrator();
if (hasVibrator != true) return;
final hasAmplitude = await Vibration.hasAmplitudeControl();
if (hasAmplitude == true) {
final intensities = pattern
.asMap()
.entries
.map((entry) => entry.key.isOdd ? 180 : 0)
.toList();
await Vibration.vibrate(
pattern: pattern,
intensities: intensities,
);
return;
}
await Vibration.vibrate(pattern: pattern);
} catch (_) {
await HapticFeedback.selectionClick();
}
}
Future<void> dispose() async {
await _player.dispose();
}
}
+27
View File
@@ -0,0 +1,27 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:qrscanner/config/api_config.dart';
import 'package:qrscanner/models/auth_session.dart';
import 'package:qrscanner/models/scan_response.dart';
class PassCodeService {
Future<ScanResponse> scan({
required AuthSession session,
required String code,
}) async {
final response = await http.post(
Uri.parse(ApiConfig.scanUrl),
headers: {
'Content-Type': 'application/json',
'Authorization': session.authorizationHeader,
},
body: jsonEncode({'code': code}),
);
final Map<String, dynamic> body =
jsonDecode(response.body) as Map<String, dynamic>;
return ScanResponse.fromJson(body);
}
}
+59
View File
@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
class AppTheme {
static const Color primary = Color(0xFF00D4AA);
static const Color primaryDark = Color(0xFF00A88A);
static const Color backgroundDark = Color(0xFF0A0E1A);
static const Color surfaceDark = Color(0xFF141B2D);
static const Color accent = Color(0xFF6C63FF);
static const Color success = Color(0xFF22C55E);
static const Color successDark = Color(0xFF16A34A);
static const Color warning = Color(0xFFFBBF24);
static const Color warningDark = Color(0xFFF59E0B);
static ThemeData get darkTheme {
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: ColorScheme.dark(
primary: primary,
secondary: accent,
surface: surfaceDark,
onPrimary: Colors.white,
onSurface: Colors.white,
),
scaffoldBackgroundColor: backgroundDark,
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.white.withValues(alpha: 0.06),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.08)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: primary, width: 1.5),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: Color(0xFFFF6B6B)),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
hintStyle: TextStyle(color: Colors.white.withValues(alpha: 0.4)),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 18),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
),
);
}
}