Estado base antes de integración Cognito.
Incluye admin web Spring Boot, API JWT para escáner y app Flutter qrscanner con login y escaneo de pases.
This commit is contained in:
@@ -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';
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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,
|
||||
});
|
||||
|
||||
factory AuthSession.fromLoginResponse(LoginResponse response) {
|
||||
return AuthSession(
|
||||
username: response.username ?? '',
|
||||
accessToken: response.accessToken ?? '',
|
||||
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? ?? '',
|
||||
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 tokenType;
|
||||
final int expiresIn;
|
||||
final List<String> roles;
|
||||
|
||||
String get authorizationHeader => '$tokenType $accessToken';
|
||||
|
||||
bool get isScanner => roles.contains('ROLE_SCANNER');
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'username': username,
|
||||
'accessToken': accessToken,
|
||||
'tokenType': tokenType,
|
||||
'expiresIn': expiresIn,
|
||||
'roles': roles,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
class LoginResponse {
|
||||
const LoginResponse({
|
||||
required this.success,
|
||||
this.accessToken,
|
||||
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?,
|
||||
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? tokenType;
|
||||
final int? expiresIn;
|
||||
final String? username;
|
||||
final List<String> roles;
|
||||
final String? message;
|
||||
|
||||
LoginResponse copyWith({
|
||||
bool? success,
|
||||
String? accessToken,
|
||||
String? tokenType,
|
||||
int? expiresIn,
|
||||
String? username,
|
||||
List<String>? roles,
|
||||
String? message,
|
||||
}) {
|
||||
return LoginResponse(
|
||||
success: success ?? this.success,
|
||||
accessToken: accessToken ?? this.accessToken,
|
||||
tokenType: tokenType ?? this.tokenType,
|
||||
expiresIn: expiresIn ?? this.expiresIn,
|
||||
username: username ?? this.username,
|
||||
roles: roles ?? this.roles,
|
||||
message: message ?? this.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
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);
|
||||
|
||||
if (response.statusCode == 200 &&
|
||||
loginResponse.success &&
|
||||
loginResponse.accessToken != null) {
|
||||
final session = AuthSession.fromLoginResponse(
|
||||
loginResponse.copyWith(username: loginResponse.username ?? username),
|
||||
);
|
||||
_currentSession = session;
|
||||
|
||||
if (rememberMe) {
|
||||
await _saveSession(session);
|
||||
} else {
|
||||
await clearSession();
|
||||
}
|
||||
|
||||
return loginResponse;
|
||||
}
|
||||
|
||||
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> clearSession() async {
|
||||
_currentSession = null;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_sessionKey);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user