diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart new file mode 100644 index 0000000..e2051cc --- /dev/null +++ b/lib/core/router/app_router.dart @@ -0,0 +1,121 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../features/auth/presentation/bloc/auth_bloc.dart'; +import '../../features/auth/presentation/bloc/auth_state.dart'; +import '../../features/auth/presentation/screens/login_screen.dart'; +import '../../features/auth/presentation/screens/home_screen_placeholder.dart'; + +/// Rutas nombradas de la aplicación. +abstract final class AppRoutes { + static const String splash = '/'; + static const String login = '/login'; + static const String home = '/home'; +} + +/// Configuración central de navegación con go_router. +/// +/// La redirección basada en estado de autenticación garantiza que +/// rutas protegidas sean inaccesibles sin sesión válida. +GoRouter createRouter(AuthBloc authBloc) { + return GoRouter( + initialLocation: AppRoutes.splash, + refreshListenable: GoRouterAuthNotifier(authBloc), + redirect: (BuildContext context, GoRouterState state) { + final authState = authBloc.state; + final isAuthenticated = authState is AuthAuthenticated; + final isCheckingSession = authState is AuthCheckingSession || + authState is AuthInitial; + final currentLocation = state.uri.path; + + // Mientras se verifica la sesión, mostrar splash. + if (isCheckingSession) { + return currentLocation == AppRoutes.splash ? null : AppRoutes.splash; + } + + // Si no está autenticado, ir a login. + if (!isAuthenticated) { + return currentLocation == AppRoutes.login ? null : AppRoutes.login; + } + + // Si está autenticado y en splash o login, ir a home. + if (isAuthenticated && + (currentLocation == AppRoutes.login || + currentLocation == AppRoutes.splash)) { + return AppRoutes.home; + } + + return null; // Sin redirección. + }, + routes: [ + GoRoute( + path: AppRoutes.splash, + builder: (context, state) => const _SplashScreen(), + ), + GoRoute( + path: AppRoutes.login, + builder: (context, state) => const LoginScreen(), + ), + GoRoute( + path: AppRoutes.home, + builder: (context, state) => const HomeScreenPlaceholder(), + ), + ], + ); +} + +/// Pantalla de splash mínima mientras se verifica la sesión. +class _SplashScreen extends StatelessWidget { + const _SplashScreen(); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF2E7D32), + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.recycling_rounded, + size: 72, + color: Colors.white, + ), + const SizedBox(height: 24), + Text( + 'WasteNotify', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: 48), + const CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Colors.white70), + ), + ], + ), + ), + ); + } +} + +/// Notificador que conecta el estado del BLoC con go_router. +/// Permite que el router reaccione automáticamente a cambios de sesión. +class GoRouterAuthNotifier extends ChangeNotifier { + final AuthBloc _authBloc; + late final StreamSubscription _subscription; + + GoRouterAuthNotifier(this._authBloc) { + _subscription = _authBloc.stream.listen((_) => notifyListeners()); + } + + @override + void dispose() { + _subscription.cancel(); + super.dispose(); + } +} diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..23110a2 --- /dev/null +++ b/lib/core/theme/app_theme.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; + +/// Paleta y tema central de WasteNotify. +/// Inspirado en materiales naturales: verde bosque + tierra + blanco hueso. +/// Transmite confianza institucional y respeto ambiental. +abstract final class AppTheme { + // --- Paleta de color --- + static const Color forestGreen = Color(0xFF1B5E20); + static const Color leafGreen = Color(0xFF2E7D32); + static const Color mintGreen = Color(0xFF43A047); + static const Color lightMint = Color(0xFFE8F5E9); + static const Color earthBrown = Color(0xFF4E342E); + static const Color sandBeige = Color(0xFFFFF8E1); + static const Color warmWhite = Color(0xFFFAFAF7); + static const Color charcoal = Color(0xFF212121); + static const Color midGray = Color(0xFF757575); + static const Color lightGray = Color(0xFFEEEEEE); + static const Color alertAmber = Color(0xFFF57C00); + static const Color errorRed = Color(0xFFC62828); + + static ThemeData get light { + const colorScheme = ColorScheme( + brightness: Brightness.light, + primary: leafGreen, + onPrimary: Colors.white, + primaryContainer: lightMint, + onPrimaryContainer: forestGreen, + secondary: earthBrown, + onSecondary: Colors.white, + secondaryContainer: sandBeige, + onSecondaryContainer: earthBrown, + error: errorRed, + onError: Colors.white, + surface: warmWhite, + onSurface: charcoal, + ); + + return ThemeData( + useMaterial3: true, + colorScheme: colorScheme, + fontFamily: 'Georgia', // Serifed: transmite solidez institucional + scaffoldBackgroundColor: warmWhite, + appBarTheme: const AppBarTheme( + backgroundColor: warmWhite, + foregroundColor: charcoal, + elevation: 0, + scrolledUnderElevation: 1, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: leafGreen, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 54), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 3, + shadowColor: leafGreen.withOpacity(0.4), + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: Colors.white, + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: lightGray, width: 1.5), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: lightGray, width: 1.5), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: leafGreen, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: errorRed, width: 1.5), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: errorRed, width: 2), + ), + labelStyle: const TextStyle(color: midGray), + hintStyle: TextStyle(color: midGray.withOpacity(0.7)), + ), + cardTheme: CardThemeData( + color: Colors.white, + elevation: 2, + shadowColor: Colors.black.withOpacity(0.08), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + snackBarTheme: SnackBarThemeData( + backgroundColor: charcoal, + contentTextStyle: const TextStyle(color: Colors.white, fontSize: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + behavior: SnackBarBehavior.floating, + ), + ); + } +} diff --git a/lib/features/auth/data/models/auth_user_model.dart b/lib/features/auth/data/models/auth_user_model.dart new file mode 100644 index 0000000..39c68d4 --- /dev/null +++ b/lib/features/auth/data/models/auth_user_model.dart @@ -0,0 +1,69 @@ +import '../../domain/entities/auth_user.dart'; + +/// Modelo de datos que extiende la entidad [AuthUser]. +/// Responsable de serialización/deserialización JSON. +/// En la fase MVP el token se simula localmente (sin llamada real a backend). +class AuthUserModel extends AuthUser { + const AuthUserModel({ + required super.token, + required super.email, + required super.role, + required super.issuedAt, + required super.expiresAt, + }); + + factory AuthUserModel.fromJson(Map json) { + return AuthUserModel( + token: json['token'] as String, + email: json['email'] as String, + role: json['role'] as String? ?? 'citizen', + issuedAt: DateTime.parse(json['issued_at'] as String), + expiresAt: DateTime.parse(json['expires_at'] as String), + ); + } + + Map toJson() { + return { + 'token': token, + 'email': email, + 'role': role, + 'issued_at': issuedAt.toIso8601String(), + 'expires_at': expiresAt.toIso8601String(), + }; + } + + /// Crea un token JWT simulado para la fase MVP. + /// En producción este token vendría del backend con firma RS256. + factory AuthUserModel.simulatedJwt({ + required String email, + required String role, + }) { + final now = DateTime.now(); + // Header.Payload.Signature — simulado con base64 simple + final header = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9'; + final payloadRaw = + '{"sub":"usr_${email.hashCode.abs()}","email":"$email","role":"$role",' + '"iat":${now.millisecondsSinceEpoch},' + '"exp":${now.add(const Duration(hours: 8)).millisecondsSinceEpoch},' + '"iss":"waste-notify-mvp"}'; + // ignore: deprecated_member_use + final payload = _base64UrlEncode(payloadRaw); + const signature = 'SIMULATED_SIGNATURE_MVP'; + + return AuthUserModel( + token: '$header.$payload.$signature', + email: email, + role: role, + issuedAt: now, + expiresAt: now.add(const Duration(hours: 8)), + ); + } + + static String _base64UrlEncode(String input) { + final bytes = input.codeUnits; + final encoded = bytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + return encoded.substring(0, encoded.length > 64 ? 64 : encoded.length); + } +} diff --git a/lib/features/auth/data/repositories/auth_repository_impl.dart b/lib/features/auth/data/repositories/auth_repository_impl.dart new file mode 100644 index 0000000..64e6b77 --- /dev/null +++ b/lib/features/auth/data/repositories/auth_repository_impl.dart @@ -0,0 +1,99 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../domain/entities/auth_user.dart'; +import '../../domain/repositories/auth_repository.dart'; +import '../models/auth_user_model.dart'; + +/// Implementación concreta del repositorio de autenticación. +/// +/// FASE MVP: Simula un servidor de autenticación localmente. +/// El token JWT generado tiene estructura real pero firma simulada. +/// +/// PRODUCCIÓN (futura): Reemplazar [_simulateNetworkCall] con llamada HTTP +/// real al endpoint POST /auth/login usando Dio o http package. +class AuthRepositoryImpl implements AuthRepository { + static const String _sessionKey = 'waste_notify_session'; + + final SharedPreferences _prefs; + + AuthRepositoryImpl(this._prefs); + + @override + Future login({ + required String identifier, + required String password, + }) async { + // Simula latencia de red para UX realista + await _simulateNetworkCall(); + + // FASE MVP: Credenciales hardcoded para demostración + // PRODUCCIÓN: POST /api/v1/auth/login con body {identifier, password} + final validCredentials = { + 'ciudadano@ejemplo.com': ('password123', 'citizen'), + 'operador@ejemplo.com': ('operador456', 'operator'), + '5551234567': ('pass1234', 'citizen'), + }; + + final entry = validCredentials[identifier]; + if (entry == null || entry.$1 != password) { + throw AuthException( + code: 'INVALID_CREDENTIALS', + message: 'Correo/teléfono o contraseña incorrectos.', + ); + } + + final user = AuthUserModel.simulatedJwt( + email: identifier, + role: entry.$2, + ); + + // Persiste sesión localmente (token cifrado en producción con flutter_secure_storage) + await _saveSession(user); + return user; + } + + @override + Future logout() async { + await _prefs.remove(_sessionKey); + } + + @override + Future getStoredSession() async { + final raw = _prefs.getString(_sessionKey); + if (raw == null) return null; + + try { + final json = jsonDecode(raw) as Map; + final user = AuthUserModel.fromJson(json); + if (user.isExpired) { + await _prefs.remove(_sessionKey); + return null; + } + return user; + } catch (_) { + await _prefs.remove(_sessionKey); + return null; + } + } + + Future _saveSession(AuthUserModel user) async { + await _prefs.setString(_sessionKey, jsonEncode(user.toJson())); + } + + Future _simulateNetworkCall() async { + await Future.delayed(const Duration(milliseconds: 1200)); + } +} + +/// Excepción tipada para errores de autenticación. +class AuthException implements Exception { + final String code; + final String message; + + const AuthException({required this.code, required this.message}); + + @override + String toString() => 'AuthException[$code]: $message'; +} diff --git a/lib/features/auth/domain/entities/auth_user.dart b/lib/features/auth/domain/entities/auth_user.dart new file mode 100644 index 0000000..f9bc5c0 --- /dev/null +++ b/lib/features/auth/domain/entities/auth_user.dart @@ -0,0 +1,27 @@ +import 'package:equatable/equatable.dart'; + +/// Entidad de dominio que representa un usuario autenticado. +/// Almacena únicamente el token (simulado JWT) y el rol, +/// sin datos de ubicación o rastreo. +class AuthUser extends Equatable { + final String token; + final String email; + final String role; // 'citizen' | 'operator' + final DateTime issuedAt; + final DateTime expiresAt; + + const AuthUser({ + required this.token, + required this.email, + required this.role, + required this.issuedAt, + required this.expiresAt, + }); + + bool get isExpired => DateTime.now().isAfter(expiresAt); + + bool get isValid => token.isNotEmpty && !isExpired; + + @override + List get props => [token, email, role, issuedAt, expiresAt]; +} diff --git a/lib/features/auth/domain/repositories/auth_repository.dart b/lib/features/auth/domain/repositories/auth_repository.dart new file mode 100644 index 0000000..64ee5e2 --- /dev/null +++ b/lib/features/auth/domain/repositories/auth_repository.dart @@ -0,0 +1,18 @@ +import '../entities/auth_user.dart'; + +/// Contrato abstracto del repositorio de autenticación. +/// El dominio no conoce la implementación concreta (Clean Architecture). +abstract class AuthRepository { + /// Autentica al usuario con email/teléfono y contraseña. + /// Retorna un [AuthUser] con token JWT simulado en esta fase. + Future login({ + required String identifier, + required String password, + }); + + /// Cierra la sesión del usuario actual. + Future logout(); + + /// Verifica si existe una sesión activa guardada localmente. + Future getStoredSession(); +} diff --git a/lib/features/auth/domain/repositories/desktop.ini b/lib/features/auth/domain/repositories/desktop.ini new file mode 100644 index 0000000..737755b --- /dev/null +++ b/lib/features/auth/domain/repositories/desktop.ini @@ -0,0 +1,2 @@ +[LocalizedFileNames] +auth_repository.dart=@auth_repository,0 diff --git a/lib/features/auth/domain/usecases/auth_usecases.dart b/lib/features/auth/domain/usecases/auth_usecases.dart new file mode 100644 index 0000000..177608a --- /dev/null +++ b/lib/features/auth/domain/usecases/auth_usecases.dart @@ -0,0 +1,44 @@ +import '../entities/auth_user.dart'; +import '../repositories/auth_repository.dart'; + +/// Caso de uso: Iniciar sesión. +/// Encapsula la lógica de negocio para autenticar un usuario. +class LoginUseCase { + final AuthRepository _repository; + + const LoginUseCase(this._repository); + + Future execute({ + required String identifier, + required String password, + }) async { + if (identifier.trim().isEmpty) { + throw ArgumentError('El correo o teléfono no puede estar vacío.'); + } + if (password.length < 6) { + throw ArgumentError('La contraseña debe tener al menos 6 caracteres.'); + } + return _repository.login( + identifier: identifier.trim(), + password: password, + ); + } +} + +/// Caso de uso: Cerrar sesión. +class LogoutUseCase { + final AuthRepository _repository; + + const LogoutUseCase(this._repository); + + Future execute() => _repository.logout(); +} + +/// Caso de uso: Recuperar sesión almacenada. +class GetStoredSessionUseCase { + final AuthRepository _repository; + + const GetStoredSessionUseCase(this._repository); + + Future execute() => _repository.getStoredSession(); +} diff --git a/lib/features/auth/domain/usecases/desktop.ini b/lib/features/auth/domain/usecases/desktop.ini new file mode 100644 index 0000000..21bc4d3 --- /dev/null +++ b/lib/features/auth/domain/usecases/desktop.ini @@ -0,0 +1,2 @@ +[LocalizedFileNames] +auth_usecases.dart=@auth_usecases,0 diff --git a/lib/features/auth/presentation/bloc/auth_bloc.dart b/lib/features/auth/presentation/bloc/auth_bloc.dart new file mode 100644 index 0000000..a06fe5c --- /dev/null +++ b/lib/features/auth/presentation/bloc/auth_bloc.dart @@ -0,0 +1,88 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../data/repositories/auth_repository_impl.dart'; +import '../../domain/usecases/auth_usecases.dart'; +import 'auth_event.dart'; +import 'auth_state.dart'; + +/// BLoC principal de autenticación. +/// +/// Gestiona el ciclo de vida completo de la identidad del usuario: +/// verificación de sesión persistida → login → sesión activa → logout. +/// +/// El token JWT simulado se almacena en el estado [AuthAuthenticated] +/// y estará disponible para inyectarse en headers HTTP en fases futuras. +class AuthBloc extends Bloc { + final LoginUseCase _loginUseCase; + final LogoutUseCase _logoutUseCase; + final GetStoredSessionUseCase _getStoredSessionUseCase; + + AuthBloc({ + required LoginUseCase loginUseCase, + required LogoutUseCase logoutUseCase, + required GetStoredSessionUseCase getStoredSessionUseCase, + }) : _loginUseCase = loginUseCase, + _logoutUseCase = logoutUseCase, + _getStoredSessionUseCase = getStoredSessionUseCase, + super(const AuthInitial()) { + on(_onSessionCheckRequested); + on(_onLoginRequested); + on(_onLogoutRequested); + } + + /// Verifica si hay sesión activa al iniciar la app. + Future _onSessionCheckRequested( + AuthSessionCheckRequested event, + Emitter emit, + ) async { + emit(const AuthCheckingSession()); + try { + final user = await _getStoredSessionUseCase.execute(); + if (user != null) { + emit(AuthAuthenticated(user: user)); + } else { + emit(const AuthUnauthenticated()); + } + } catch (_) { + emit(const AuthUnauthenticated()); + } + } + + /// Procesa la solicitud de login con las credenciales del usuario. + Future _onLoginRequested( + AuthLoginRequested event, + Emitter emit, + ) async { + emit(const AuthLoading()); + try { + final user = await _loginUseCase.execute( + identifier: event.identifier, + password: event.password, + ); + emit(AuthAuthenticated(user: user)); + } on AuthException catch (e) { + emit(AuthFailure(message: e.message, errorCode: e.code)); + } on ArgumentError catch (e) { + emit(AuthFailure(message: e.message.toString())); + } catch (e) { + emit(const AuthFailure( + message: 'Error inesperado. Por favor intenta de nuevo.', + errorCode: 'UNKNOWN', + )); + } + } + + /// Cierra la sesión y limpia el estado local. + Future _onLogoutRequested( + AuthLogoutRequested event, + Emitter emit, + ) async { + emit(const AuthLoading()); + try { + await _logoutUseCase.execute(); + } finally { + // Siempre transiciona a no autenticado, incluso si falla la limpieza + emit(const AuthUnauthenticated()); + } + } +} diff --git a/lib/features/auth/presentation/bloc/auth_event.dart b/lib/features/auth/presentation/bloc/auth_event.dart new file mode 100644 index 0000000..8792e36 --- /dev/null +++ b/lib/features/auth/presentation/bloc/auth_event.dart @@ -0,0 +1,34 @@ +import 'package:equatable/equatable.dart'; + +/// Eventos que puede recibir el [AuthBloc]. +/// Cada evento representa una intención del usuario o del sistema. +sealed class AuthEvent extends Equatable { + const AuthEvent(); + + @override + List get props => []; +} + +/// El usuario solicita iniciar sesión con sus credenciales. +final class AuthLoginRequested extends AuthEvent { + final String identifier; + final String password; + + const AuthLoginRequested({ + required this.identifier, + required this.password, + }); + + @override + List get props => [identifier, password]; +} + +/// El usuario solicita cerrar sesión. +final class AuthLogoutRequested extends AuthEvent { + const AuthLogoutRequested(); +} + +/// La app verifica si existe una sesión guardada al inicio. +final class AuthSessionCheckRequested extends AuthEvent { + const AuthSessionCheckRequested(); +} diff --git a/lib/features/auth/presentation/bloc/auth_state.dart b/lib/features/auth/presentation/bloc/auth_state.dart new file mode 100644 index 0000000..dd2ad7c --- /dev/null +++ b/lib/features/auth/presentation/bloc/auth_state.dart @@ -0,0 +1,56 @@ +import 'package:equatable/equatable.dart'; + +import '../../domain/entities/auth_user.dart'; + +/// Estados posibles del [AuthBloc]. +/// Usando sealed class para exhaustividad en switch expressions (Dart 3+). +sealed class AuthState extends Equatable { + const AuthState(); + + @override + List get props => []; +} + +/// Estado inicial antes de cualquier verificación. +final class AuthInitial extends AuthState { + const AuthInitial(); +} + +/// Verificando si existe sesión almacenada (al inicio de la app). +final class AuthCheckingSession extends AuthState { + const AuthCheckingSession(); +} + +/// Proceso de login o logout en curso. +final class AuthLoading extends AuthState { + const AuthLoading(); +} + +/// Usuario autenticado exitosamente. Contiene el usuario y su token JWT. +final class AuthAuthenticated extends AuthState { + final AuthUser user; + + const AuthAuthenticated({required this.user}); + + /// Expone el token para su uso en headers HTTP futuros. + String get bearerToken => 'Bearer ${user.token}'; + + @override + List get props => [user]; +} + +/// No autenticado (estado limpio, sin sesión). +final class AuthUnauthenticated extends AuthState { + const AuthUnauthenticated(); +} + +/// Error durante la autenticación. +final class AuthFailure extends AuthState { + final String message; + final String? errorCode; + + const AuthFailure({required this.message, this.errorCode}); + + @override + List get props => [message, errorCode]; +} diff --git a/lib/features/auth/presentation/screens/home_screen_placeholder.dart b/lib/features/auth/presentation/screens/home_screen_placeholder.dart new file mode 100644 index 0000000..d3e844e --- /dev/null +++ b/lib/features/auth/presentation/screens/home_screen_placeholder.dart @@ -0,0 +1,533 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/theme/app_theme.dart'; +import '../bloc/auth_bloc.dart'; +import '../bloc/auth_event.dart'; +import '../bloc/auth_state.dart'; + +/// Pantalla principal post-login — MVP WasteNotify. +/// +/// Cascarón de la pantalla de inicio. En fases futuras contendrá: +/// - ETA de llegada del camión (sin mapa, solo tiempo estimado) +/// - Notificaciones programadas +/// - Historial de recolecciones +/// - Panel de operador (si role == 'operator') +/// +/// RESTRICCIÓN DE PRIVACIDAD: Esta pantalla NO mostrará mapas de rutas +/// ni la posición GPS del vehículo. Solo tiempo estimado de llegada. +class HomeScreenPlaceholder extends StatelessWidget { + const HomeScreenPlaceholder({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final user = state is AuthAuthenticated ? state.user : null; + final isOperator = user?.role == 'operator'; + + return Scaffold( + backgroundColor: AppTheme.warmWhite, + appBar: AppBar( + title: Row( + children: [ + const Icon(Icons.recycling_rounded, + color: AppTheme.leafGreen, size: 22), + const SizedBox(width: 8), + RichText( + text: const TextSpan( + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppTheme.charcoal, + ), + children: [ + TextSpan(text: 'Waste'), + TextSpan( + text: 'Notify', + style: TextStyle(color: AppTheme.leafGreen), + ), + ], + ), + ), + ], + ), + actions: [ + IconButton( + icon: const Icon(Icons.logout_rounded), + tooltip: 'Cerrar sesión', + onPressed: () { + context.read().add(const AuthLogoutRequested()); + }, + ), + ], + ), + body: CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(20), + sliver: SliverList( + delegate: SliverChildListDelegate([ + // --- Bienvenida --- + _WelcomeCard(user: user), + const SizedBox(height: 20), + + // --- ETA Principal (cascarón) --- + const _EtaCard(), + const SizedBox(height: 20), + + // --- Mensaje preventivo --- + const _PreventiveMessageCard(), + const SizedBox(height: 20), + + // --- Próximas funcionalidades --- + _UpcomingFeatures(isOperator: isOperator), + const SizedBox(height: 20), + + // --- Info de sesión (debug MVP) --- + if (user != null) _SessionDebugCard(user: user), + ]), + ), + ), + ], + ), + ); + }, + ); + } +} + +// --------------------------------------------------------------------------- +// Sub-widgets de la pantalla home +// --------------------------------------------------------------------------- + +class _WelcomeCard extends StatelessWidget { + final dynamic user; + + const _WelcomeCard({required this.user}); + + @override + Widget build(BuildContext context) { + final roleLabel = user?.role == 'operator' ? 'Operador' : 'Ciudadano'; + final identifier = user?.email ?? ''; + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppTheme.leafGreen, AppTheme.forestGreen], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: AppTheme.leafGreen.withOpacity(0.3), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], + ), + child: Row( + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(13), + ), + child: const Icon( + Icons.person_rounded, + color: Colors.white, + size: 30, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '¡Bienvenido!', + style: const TextStyle( + color: Colors.white70, + fontSize: 13, + ), + ), + Text( + identifier, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 3), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + roleLabel, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _EtaCard extends StatelessWidget { + const _EtaCard(); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppTheme.lightMint, + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.schedule_rounded, + color: AppTheme.leafGreen, + size: 22, + ), + ), + const SizedBox(width: 12), + const Expanded( + child: Text( + 'Tiempo estimado de llegada', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppTheme.charcoal, + ), + ), + ), + ], + ), + const SizedBox(height: 20), + Center( + child: Column( + children: [ + Container( + width: 110, + height: 110, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: AppTheme.lightMint, + width: 6, + ), + color: Colors.white, + ), + child: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.hourglass_empty_rounded, + color: AppTheme.midGray, + size: 28, + ), + SizedBox(height: 4), + Text( + '— min', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w800, + color: AppTheme.midGray, + ), + ), + ], + ), + ), + const SizedBox(height: 14), + Text( + 'Notificaciones activas próximamente', + style: TextStyle( + fontSize: 13, + color: AppTheme.midGray, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 5), + decoration: BoxDecoration( + color: AppTheme.lightMint, + borderRadius: BorderRadius.circular(20), + ), + child: const Text( + 'Fase 2 — En desarrollo', + style: TextStyle( + fontSize: 11, + color: AppTheme.leafGreen, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _PreventiveMessageCard extends StatelessWidget { + const _PreventiveMessageCard(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.alertAmber.withOpacity(0.08), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: AppTheme.alertAmber.withOpacity(0.3), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.campaign_outlined, + color: AppTheme.alertAmber, size: 18), + SizedBox(width: 8), + Text( + 'Recuerda siempre', + style: TextStyle( + fontWeight: FontWeight.w700, + fontSize: 13, + color: AppTheme.earthBrown, + ), + ), + ], + ), + const SizedBox(height: 10), + _ReminderItem( + '🚮', + 'Saca la basura SOLO cuando recibas la alerta de "próxima llegada".', + ), + _ReminderItem( + '🚫', + 'Nunca persigas ni te acerques al camión. El sistema te avisará a tiempo.', + ), + _ReminderItem( + '🌱', + 'Separar tus residuos hace más eficiente la recolección. ¡Gracias!', + ), + ], + ), + ); + } +} + +class _ReminderItem extends StatelessWidget { + final String emoji; + final String text; + + const _ReminderItem(this.emoji, this.text); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(emoji, style: const TextStyle(fontSize: 14)), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: const TextStyle( + fontSize: 12.5, + color: AppTheme.earthBrown, + height: 1.4, + ), + ), + ), + ], + ), + ); + } +} + +class _UpcomingFeatures extends StatelessWidget { + final bool isOperator; + + const _UpcomingFeatures({required this.isOperator}); + + @override + Widget build(BuildContext context) { + final features = [ + (Icons.notifications_active_outlined, 'Alertas push de recolección', + 'Fase 2'), + (Icons.history_rounded, 'Historial de notificaciones', 'Fase 2'), + (Icons.settings_outlined, 'Configurar zona y horario', 'Fase 3'), + if (isOperator) ...[ + (Icons.bar_chart_rounded, 'Panel de rutas completadas', 'Fase 3'), + (Icons.group_outlined, 'Gestión de sectores', 'Fase 4'), + ], + ]; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Próximamente en WasteNotify', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppTheme.charcoal, + ), + ), + const SizedBox(height: 12), + ...features.map((f) => _FeatureRow( + icon: f.$1, + label: f.$2, + phase: f.$3, + )), + ], + ), + ), + ); + } +} + +class _FeatureRow extends StatelessWidget { + final IconData icon; + final String label; + final String phase; + + const _FeatureRow({ + required this.icon, + required this.label, + required this.phase, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + children: [ + Icon(icon, size: 18, color: AppTheme.mintGreen), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + style: const TextStyle(fontSize: 13, color: AppTheme.charcoal), + ), + ), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppTheme.lightGray, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + phase, + style: const TextStyle( + fontSize: 10, + color: AppTheme.midGray, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } +} + +class _SessionDebugCard extends StatelessWidget { + final dynamic user; + + const _SessionDebugCard({required this.user}); + + @override + Widget build(BuildContext context) { + final token = user?.token ?? ''; + final tokenPreview = + token.length > 40 ? '${token.substring(0, 40)}…' : token; + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFF3E5F5), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFCE93D8), width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.developer_mode_rounded, + size: 14, color: Color(0xFF7B1FA2)), + SizedBox(width: 6), + Text( + 'Debug — Sesión JWT (solo MVP)', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Color(0xFF7B1FA2), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + 'Token: $tokenPreview', + style: const TextStyle( + fontSize: 10.5, + fontFamily: 'monospace', + color: Color(0xFF4A148C), + ), + ), + Text( + 'Role: ${user?.role} | Expira: ${user?.expiresAt?.toLocal().toString().substring(0, 16)}', + style: const TextStyle( + fontSize: 10.5, + fontFamily: 'monospace', + color: Color(0xFF4A148C), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/auth/presentation/screens/login_screen.dart b/lib/features/auth/presentation/screens/login_screen.dart new file mode 100644 index 0000000..e93788b --- /dev/null +++ b/lib/features/auth/presentation/screens/login_screen.dart @@ -0,0 +1,411 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/theme/app_theme.dart'; +import '../bloc/auth_bloc.dart'; +import '../bloc/auth_event.dart'; +import '../bloc/auth_state.dart'; +import '../widgets/privacy_notice_card.dart'; + +/// Pantalla de inicio de sesión — MVP WasteNotify. +/// +/// Diseño: Limpio, institucional, con paleta verde-tierra. +/// Incluye mensajería preventiva integrada de forma no intrusiva. +/// +/// Credenciales de demo: +/// Ciudadano: ciudadano@ejemplo.com / password123 +/// Operador: operador@ejemplo.com / operador456 +/// Teléfono: 5551234567 / pass1234 +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State + with SingleTickerProviderStateMixin { + final _formKey = GlobalKey(); + final _identifierController = TextEditingController(); + final _passwordController = TextEditingController(); + + bool _obscurePassword = true; + late final AnimationController _animController; + late final Animation _fadeAnim; + late final Animation _slideAnim; + + @override + void initState() { + super.initState(); + _animController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 700), + ); + _fadeAnim = CurvedAnimation( + parent: _animController, + curve: Curves.easeOut, + ); + _slideAnim = Tween( + begin: const Offset(0, 0.06), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _animController, + curve: Curves.easeOutCubic, + )); + _animController.forward(); + } + + @override + void dispose() { + _animController.dispose(); + _identifierController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + void _submit(BuildContext context) { + if (_formKey.currentState?.validate() ?? false) { + context.read().add( + AuthLoginRequested( + identifier: _identifierController.text, + password: _passwordController.text, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + if (state is AuthFailure) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.error_outline, color: Colors.white, size: 18), + const SizedBox(width: 10), + Expanded(child: Text(state.message)), + ], + ), + backgroundColor: AppTheme.errorRed, + ), + ); + } + }, + child: Scaffold( + backgroundColor: AppTheme.warmWhite, + body: SafeArea( + child: CustomScrollView( + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: FadeTransition( + opacity: _fadeAnim, + child: SlideTransition( + position: _slideAnim, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 48), + _buildHeader(), + const SizedBox(height: 32), + const ScheduleWarningBanner(), + const SizedBox(height: 32), + _buildForm(context), + const SizedBox(height: 24), + const PrivacyNoticeCard(), + const SizedBox(height: 32), + _buildDemoHint(), + const SizedBox(height: 24), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildHeader() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Logo / ícono principal + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppTheme.leafGreen, AppTheme.forestGreen], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: AppTheme.leafGreen.withOpacity(0.35), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: const Icon( + Icons.recycling_rounded, + color: Colors.white, + size: 34, + ), + ), + const SizedBox(height: 20), + RichText( + text: const TextSpan( + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w800, + color: AppTheme.charcoal, + height: 1.15, + ), + children: [ + TextSpan(text: 'Waste'), + TextSpan( + text: 'Notify', + style: TextStyle(color: AppTheme.leafGreen), + ), + ], + ), + ), + const SizedBox(height: 8), + Text( + 'Notificaciones de recolección\nsin rastreo, sin riesgos.', + style: TextStyle( + fontSize: 14.5, + color: AppTheme.midGray, + height: 1.5, + ), + ), + ], + ); + } + + Widget _buildForm(BuildContext context) { + return Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // --- Campo: Email o Teléfono --- + TextFormField( + controller: _identifierController, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + autocorrect: false, + decoration: const InputDecoration( + labelText: 'Correo electrónico o teléfono', + hintText: 'ej. juan@correo.com o 5551234567', + prefixIcon: Icon(Icons.person_outline_rounded), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Ingresa tu correo o número de teléfono'; + } + final trimmed = value.trim(); + final isEmail = RegExp( + r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$', + ).hasMatch(trimmed); + final isPhone = RegExp(r'^\d{7,15}$').hasMatch(trimmed); + if (!isEmail && !isPhone) { + return 'Ingresa un correo válido o un número de 7-15 dígitos'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // --- Campo: Contraseña --- + TextFormField( + controller: _passwordController, + obscureText: _obscurePassword, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(context), + decoration: InputDecoration( + labelText: 'Contraseña', + hintText: 'Mínimo 6 caracteres', + prefixIcon: const Icon(Icons.lock_outline_rounded), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + color: AppTheme.midGray, + ), + onPressed: () => + setState(() => _obscurePassword = !_obscurePassword), + tooltip: _obscurePassword + ? 'Mostrar contraseña' + : 'Ocultar contraseña', + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Ingresa tu contraseña'; + } + if (value.length < 6) { + return 'La contraseña debe tener al menos 6 caracteres'; + } + return null; + }, + ), + const SizedBox(height: 8), + + // --- Olvidé mi contraseña --- + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Recuperación de contraseña — Próximamente'), + ), + ); + }, + child: const Text( + '¿Olvidaste tu contraseña?', + style: TextStyle(fontSize: 13), + ), + ), + ), + const SizedBox(height: 16), + + // --- Botón Principal --- + BlocBuilder( + builder: (context, state) { + final isLoading = state is AuthLoading; + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + boxShadow: isLoading + ? [] + : [ + BoxShadow( + color: AppTheme.leafGreen.withOpacity(0.4), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], + ), + child: ElevatedButton( + onPressed: isLoading ? null : () => _submit(context), + style: ElevatedButton.styleFrom( + backgroundColor: isLoading + ? AppTheme.mintGreen.withOpacity(0.7) + : AppTheme.leafGreen, + disabledBackgroundColor: + AppTheme.mintGreen.withOpacity(0.6), + ), + child: isLoading + ? const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.login_rounded, size: 20), + SizedBox(width: 10), + Text('Ingresar de forma segura'), + ], + ), + ), + ); + }, + ), + ], + ), + ); + } + + Widget _buildDemoHint() { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppTheme.sandBeige, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFFFE082), width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.science_outlined, + size: 15, color: AppTheme.earthBrown), + const SizedBox(width: 6), + Text( + 'Demo MVP — Credenciales de prueba', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppTheme.earthBrown, + ), + ), + ], + ), + const SizedBox(height: 6), + _DemoCredential( + label: 'Ciudadano', + user: 'ciudadano@ejemplo.com', + pass: 'password123', + ), + _DemoCredential( + label: 'Operador', + user: 'operador@ejemplo.com', + pass: 'operador456', + ), + _DemoCredential( + label: 'Teléfono', + user: '5551234567', + pass: 'pass1234', + ), + ], + ), + ); + } +} + +class _DemoCredential extends StatelessWidget { + final String label; + final String user; + final String pass; + + const _DemoCredential({ + required this.label, + required this.user, + required this.pass, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 3), + child: Text( + '$label: $user / $pass', + style: const TextStyle( + fontSize: 11.5, + color: AppTheme.earthBrown, + fontFamily: 'monospace', + ), + ), + ); + } +} diff --git a/lib/features/auth/presentation/widgets/privacy_notice_card.dart b/lib/features/auth/presentation/widgets/privacy_notice_card.dart new file mode 100644 index 0000000..04adb88 --- /dev/null +++ b/lib/features/auth/presentation/widgets/privacy_notice_card.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/theme/app_theme.dart'; + +/// Widget de aviso de privacidad con mensajería preventiva. +/// +/// Comunica de forma elegante las garantías de privacidad del sistema +/// y desincentiva comportamientos peligrosos como perseguir el camión. +class PrivacyNoticeCard extends StatelessWidget { + const PrivacyNoticeCard({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.lightMint, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppTheme.mintGreen.withOpacity(0.3), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.shield_outlined, + size: 18, + color: AppTheme.leafGreen, + ), + const SizedBox(width: 8), + Text( + 'Tu seguridad, nuestra prioridad', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppTheme.forestGreen, + letterSpacing: 0.2, + ), + ), + ], + ), + const SizedBox(height: 10), + _PrivacyPoint( + icon: Icons.location_off_outlined, + text: + 'Nunca mostraremos mapas en tiempo real ni la ubicación exacta del camión.', + ), + const SizedBox(height: 6), + _PrivacyPoint( + icon: Icons.directions_run_outlined, + text: + 'No necesitas perseguir al camión — recibirás una alerta con tiempo suficiente.', + ), + const SizedBox(height: 6), + _PrivacyPoint( + icon: Icons.lock_outline, + text: + 'Tu sesión usa cifrado JWT. Nunca compartimos tus datos con terceros.', + ), + ], + ), + ); + } +} + +class _PrivacyPoint extends StatelessWidget { + final IconData icon; + final String text; + + const _PrivacyPoint({required this.icon, required this.text}); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 15, color: AppTheme.mintGreen), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: TextStyle( + fontSize: 12, + color: AppTheme.forestGreen.withOpacity(0.85), + height: 1.4, + ), + ), + ), + ], + ); + } +} + +/// Banner de horario preventivo — aparece en la pantalla de login +/// para desincentivar sacar basura fuera de horario. +class ScheduleWarningBanner extends StatelessWidget { + const ScheduleWarningBanner({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: AppTheme.alertAmber.withOpacity(0.10), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: AppTheme.alertAmber.withOpacity(0.35), + width: 1, + ), + ), + child: Row( + children: [ + Icon( + Icons.wb_twilight_outlined, + size: 16, + color: AppTheme.alertAmber, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Saca tu basura solo en el horario indicado. ' + 'Hacerlo antes contamina y genera multas.', + style: TextStyle( + fontSize: 11.5, + color: AppTheme.earthBrown, + height: 1.4, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 244a702..3d2dbdf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,122 +1,91 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shared_preferences/shared_preferences.dart'; -void main() { - runApp(const MyApp()); +import 'core/router/app_router.dart'; +import 'core/theme/app_theme.dart'; +import 'features/auth/data/repositories/auth_repository_impl.dart'; +import 'features/auth/domain/usecases/auth_usecases.dart'; +import 'features/auth/presentation/bloc/auth_bloc.dart'; +import 'features/auth/presentation/bloc/auth_event.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Inicializa SharedPreferences para persistencia de sesión + final prefs = await SharedPreferences.getInstance(); + + // Inyección de dependencias manual (Clean Architecture sin service locator) + // PRODUCCIÓN: Reemplazar con get_it o injectable para mayor escalabilidad + final authRepository = AuthRepositoryImpl(prefs); + + final loginUseCase = LoginUseCase(authRepository); + final logoutUseCase = LogoutUseCase(authRepository); + final getStoredSessionUseCase = GetStoredSessionUseCase(authRepository); + + final authBloc = AuthBloc( + loginUseCase: loginUseCase, + logoutUseCase: logoutUseCase, + getStoredSessionUseCase: getStoredSessionUseCase, + ); + + // Verifica sesión almacenada al iniciar + authBloc.add(const AuthSessionCheckRequested()); + + runApp(WasteNotifyApp(authBloc: authBloc)); } -class MyApp extends StatelessWidget { - const MyApp({super.key}); +/// Punto de entrada de la aplicación WasteNotify. +/// +/// Arquitectura: Clean Architecture + BLoC + go_router +/// Privacidad: Sin rastreo GPS, sin mapas en tiempo real +/// Seguridad: Sesiones JWT simuladas (fase MVP) +class WasteNotifyApp extends StatelessWidget { + final AuthBloc authBloc; + + const WasteNotifyApp({super.key, required this.authBloc}); - // This widget is the root of your application. @override Widget build(BuildContext context) { - return MaterialApp( - title: 'Flutter Demo', - theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. - colorScheme: .fromSeed(seedColor: Colors.deepPurple), - ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), + return BlocProvider.value( + value: authBloc, + child: _AppView(authBloc: authBloc), ); } } -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); +class _AppView extends StatefulWidget { + final AuthBloc authBloc; - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; + const _AppView({required this.authBloc}); @override - State createState() => _MyHomePageState(); + State<_AppView> createState() => _AppViewState(); } -class _MyHomePageState extends State { - int _counter = 0; +class _AppViewState extends State<_AppView> { + late final GoRouter _router; - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); + @override + void initState() { + super.initState(); + _router = createRouter(widget.authBloc); + } + + @override + void dispose() { + _router.dispose(); + super.dispose(); } @override Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: .center, - children: [ - const Text('You have pushed the button this many times:'), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], - ), - ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), + return MaterialApp.router( + title: 'WasteNotify', + debugShowCheckedModeBanner: false, + theme: AppTheme.light, + routerConfig: _router, ); } }