From 3a3178eb3b941dfaaef1a2477478857616539938 Mon Sep 17 00:00:00 2001 From: shinra32 Date: Sat, 23 May 2026 00:45:34 -0600 Subject: [PATCH] Co-authored-by: eddgranados12 Co-authored-by: Azareth-Tr Co-authored-by: MENDOZA BALLARDO GAEL RICARDO vistas --- recolecta_app/lib/core/router/app_router.dart | 16 +- .../lib/core/services/auth_controller.dart | 42 +- .../admin/screens/admin_dashboard_screen.dart | 307 +++ .../lib/features/auth/register_page.dart | 171 +- .../notifications/notifications_screen.dart | 20 + .../lib/features/quiz/quiz_screen.dart | 21 + recolecta_app/lib/main.dart | 29 +- recolecta_app/pubspec.yaml | 1 + views_v1/admin_screen.dart | 2443 ----------------- views_v1/alerts_screen.dart | 4 +- views_v1/driver_screen.dart | 1631 ----------- views_v1/login_screen.dart | 50 +- views_v1/map_screen.dart | 12 +- views_v1/profile_screen.dart | 7 +- views_v1/splash_screen.dart | 10 +- 15 files changed, 527 insertions(+), 4237 deletions(-) create mode 100644 recolecta_app/lib/features/admin/screens/admin_dashboard_screen.dart create mode 100644 recolecta_app/lib/features/notifications/notifications_screen.dart create mode 100644 recolecta_app/lib/features/quiz/quiz_screen.dart delete mode 100644 views_v1/admin_screen.dart delete mode 100644 views_v1/driver_screen.dart diff --git a/recolecta_app/lib/core/router/app_router.dart b/recolecta_app/lib/core/router/app_router.dart index d263fee..f5b68a0 100644 --- a/recolecta_app/lib/core/router/app_router.dart +++ b/recolecta_app/lib/core/router/app_router.dart @@ -14,14 +14,9 @@ import 'package:recolecta_app/features/home/citizen_shell.dart'; import 'package:recolecta_app/features/separation_guide/screens/category_detail_screen.dart'; import 'package:recolecta_app/features/separation_guide/screens/separation_guide_screen.dart'; import 'package:recolecta_app/core/services/auth_controller.dart'; - -// Mock Admin Screens -class AdminDashboardScreen extends StatelessWidget { - const AdminDashboardScreen({super.key}); - @override - Widget build(BuildContext context) => - const Scaffold(body: Center(child: Text('Admin Dashboard'))); -} +import '../../features/admin/screens/admin_dashboard_screen.dart'; +import '../../features/notifications/notifications_screen.dart'; +import '../../features/quiz/quiz_screen.dart'; class AdminRouteDetailScreen extends StatelessWidget { const AdminRouteDetailScreen({super.key, required this.routeId}); @@ -142,6 +137,11 @@ final routerProvider = Provider((ref) { ), ], ), + GoRoute( + path: '/notifications', + builder: (context, state) => const NotificationsScreen(), + ), + GoRoute(path: '/quiz', builder: (context, state) => const QuizScreen()), ], ); }); diff --git a/recolecta_app/lib/core/services/auth_controller.dart b/recolecta_app/lib/core/services/auth_controller.dart index 41502f9..0a69f81 100644 --- a/recolecta_app/lib/core/services/auth_controller.dart +++ b/recolecta_app/lib/core/services/auth_controller.dart @@ -1,3 +1,4 @@ +import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../models/auth_state.dart'; @@ -14,28 +15,28 @@ class AuthController extends AsyncNotifier { if (session == null) { return const AuthState.unauthenticated(); } - - return AuthState.authenticated( + final authState = AuthState.authenticated( token: session.token, userRole: session.userRole, routeId: session.routeId, ); + _subscribeIfCitizen(authState); + return authState; } Future login({required String email, required String password}) async { state = const AsyncLoading(); - try { final session = await ref .read(authServiceProvider) .login(email: email, password: password); - state = AsyncData( - AuthState.authenticated( - token: session.token, - userRole: session.userRole, - routeId: session.routeId, - ), + final authState = AuthState.authenticated( + token: session.token, + userRole: session.userRole, + routeId: session.routeId, ); + _subscribeIfCitizen(authState); + state = AsyncData(authState); } catch (error, stackTrace) { state = AsyncError(error, stackTrace); rethrow; @@ -48,18 +49,17 @@ class AuthController extends AsyncNotifier { required String password, }) async { state = const AsyncLoading(); - try { final session = await ref .read(authServiceProvider) .register(email: email, phone: phone, password: password); - state = AsyncData( - AuthState.authenticated( - token: session.token, - userRole: session.userRole, - routeId: session.routeId, - ), + final authState = AuthState.authenticated( + token: session.token, + userRole: session.userRole, + routeId: session.routeId, ); + _subscribeIfCitizen(authState); + state = AsyncData(authState); } catch (error, stackTrace) { state = AsyncError(error, stackTrace); rethrow; @@ -67,7 +67,17 @@ class AuthController extends AsyncNotifier { } Future logout() async { + final previousRouteId = state.value?.routeId; await ref.read(authServiceProvider).logout(); + if (previousRouteId != null) { + FirebaseMessaging.instance.unsubscribeFromTopic('topic_$previousRouteId'); + } state = const AsyncData(AuthState.unauthenticated()); } + + void _subscribeIfCitizen(AuthState authState) { + if (authState.userRole == 'citizen' && authState.routeId != null) { + FirebaseMessaging.instance.subscribeToTopic('topic_${authState.routeId}'); + } + } } diff --git a/recolecta_app/lib/features/admin/screens/admin_dashboard_screen.dart b/recolecta_app/lib/features/admin/screens/admin_dashboard_screen.dart new file mode 100644 index 0000000..63d4e84 --- /dev/null +++ b/recolecta_app/lib/features/admin/screens/admin_dashboard_screen.dart @@ -0,0 +1,307 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:latlong2/latlong.dart'; + +import '../../../../core/constants/auth_constants.dart'; +import '../../../../core/theme/app_theme.dart'; + +const _kRelleno = LatLng(20.5111, -100.9037); + +const _kRouteColors = [ + Colors.blue, + Colors.green, + Colors.orange, + Colors.red, + Colors.purple, + Colors.teal, +]; + +class _RouteData { + const _RouteData({ + required this.routeId, + required this.currentPositionId, + required this.status, + required this.positions, + }); + + final String routeId; + final int currentPositionId; + final String status; + final List positions; +} + +class AdminDashboardScreen extends StatefulWidget { + const AdminDashboardScreen({super.key}); + + @override + State createState() => _AdminDashboardScreenState(); +} + +class _AdminDashboardScreenState extends State { + List<_RouteData> _routes = []; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _loadRoutes(); + } + + Future _loadRoutes() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + const storage = FlutterSecureStorage(); + final token = await storage.read(key: authTokenStorageKey) ?? ''; + + final dio = Dio( + BaseOptions( + baseUrl: const String.fromEnvironment( + 'API_BASE_URL', + defaultValue: 'http://10.0.2.2:8000', + ), + headers: { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + }, + ), + ); + + final routesRes = await dio.get>('/routes'); + final routeList = routesRes.data ?? []; + + final List<_RouteData> loaded = []; + + for (final r in routeList) { + final routeId = (r['route_id'] ?? r['id'] ?? '').toString(); + if (routeId.isEmpty) continue; + + List positions = []; + try { + final posRes = await dio.get>('/routes/$routeId/positions'); + positions = (posRes.data ?? []) + .map((p) => LatLng( + (p['lat'] as num).toDouble(), + (p['lng'] as num).toDouble(), + )) + .toList(); + } catch (_) {} + + loaded.add(_RouteData( + routeId: routeId, + currentPositionId: (r['current_position_id'] as int?) ?? 1, + status: (r['status'] ?? 'pendiente').toString(), + positions: positions, + )); + } + + if (mounted) { + setState(() { + _routes = loaded; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + } + + Marker _buildTruckMarker(_RouteData route, Color color) { + final posIdx = (route.currentPositionId - 1).clamp(0, route.positions.length - 1); + return Marker( + point: route.positions[posIdx], + width: 48, + height: 48, + child: Tooltip( + message: '${route.routeId} · pos ${route.currentPositionId} · ${route.status}', + child: Container( + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + child: const Icon(Icons.local_shipping, color: Colors.white, size: 22), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final routesWithPos = _routes.where((r) => r.positions.isNotEmpty).toList(); + + return Scaffold( + appBar: AppBar( + title: const Text('Panel de Control - Flotilla'), + backgroundColor: AppTheme.primaryDark, + foregroundColor: Colors.white, + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadRoutes, + tooltip: 'Actualizar', + ), + ], + ), + body: Column( + children: [ + Container( + width: double.infinity, + color: AppTheme.amberLight, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: const Row( + children: [ + Icon(Icons.security, color: AppTheme.amber, size: 20), + SizedBox(width: 10), + Expanded( + child: Text( + 'Privilegio Admin: Vista global de coordenadas. No compartir pantalla.', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppTheme.amber, + ), + ), + ), + ], + ), + ), + Expanded( + child: Stack( + children: [ + FlutterMap( + options: const MapOptions( + initialCenter: _kRelleno, + initialZoom: 12.5, + interactionOptions: InteractionOptions( + flags: InteractiveFlag.all, + ), + ), + children: [ + TileLayer( + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.onlineshack.recolecta', + ), + if (routesWithPos.isNotEmpty) ...[ + PolylineLayer( + polylines: [ + for (int i = 0; i < routesWithPos.length; i++) + if (routesWithPos[i].positions.length > 1) + Polyline( + points: routesWithPos[i].positions, + color: _kRouteColors[i % _kRouteColors.length] + .withValues(alpha: 0.7), + strokeWidth: 3.5, + ), + ], + ), + MarkerLayer( + markers: [ + for (int i = 0; i < routesWithPos.length; i++) + _buildTruckMarker( + routesWithPos[i], + _kRouteColors[i % _kRouteColors.length], + ), + ], + ), + ], + ], + ), + if (_isLoading) + const Center(child: CircularProgressIndicator()), + if (_error != null && !_isLoading) + Positioned( + bottom: 16, + left: 16, + right: 16, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.danger.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Sin conexión al backend: $_error', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ), + ), + if (routesWithPos.isNotEmpty) + Positioned( + top: 12, + right: 12, + child: _RouteLegend(routes: routesWithPos), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _RouteLegend extends StatelessWidget { + const _RouteLegend({required this.routes}); + + final List<_RouteData> routes; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.93), + borderRadius: BorderRadius.circular(8), + boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 4)], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < routes.length; i++) + Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: _kRouteColors[i % _kRouteColors.length], + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + '${routes[i].routeId} · pos ${routes[i].currentPositionId}', + style: const TextStyle( + fontSize: 10, + color: AppTheme.textPrimary, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/recolecta_app/lib/features/auth/register_page.dart b/recolecta_app/lib/features/auth/register_page.dart index 4d13124..50bc77c 100644 --- a/recolecta_app/lib/features/auth/register_page.dart +++ b/recolecta_app/lib/features/auth/register_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:dio/dio.dart'; @@ -43,6 +44,7 @@ class _RegisterPageState extends ConsumerState { bool _obscurePass = true; // Paso 2 + final _mapController = MapController(); final _cpCtrl = TextEditingController(); final _calleCtrl = TextEditingController(); Colonia? _selectedColonia; @@ -92,6 +94,7 @@ class _RegisterPageState extends ConsumerState { _passCtrl.dispose(); _calleCtrl.dispose(); _cpCtrl.dispose(); + _mapController.dispose(); super.dispose(); } @@ -119,7 +122,9 @@ class _RegisterPageState extends ConsumerState { 'format': 'json', 'addressdetails': 1, }, - options: Options(headers: {'User-Agent': 'com.onlineshack.recolecta'}), + options: kIsWeb + ? null + : Options(headers: {'User-Agent': 'com.onlineshack.recolecta'}), ); if (response.data != null && response.data['address'] != null) { @@ -194,32 +199,11 @@ class _RegisterPageState extends ConsumerState { FocusScope.of(context).unfocus(); // Cierra el teclado } - Future _register() async { - if (_calleCtrl.text.trim().isEmpty || _selectedColonia == null) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Ingresa tu calle y selecciona una colonia'), - behavior: SnackBarBehavior.floating, - ), - ); - return; - } - - // 1. Registra al usuario - await ref - .read(authControllerProvider.notifier) - .register( - email: _emailCtrl.text.trim(), - phone: _telefonoCtrl.text.trim(), - password: _passCtrl.text, - ); - - // Detenernos si hubo algún error en el auth (ej. contraseña corta) - if (ref.read(authControllerProvider).hasError) return; - - // 2. Guardar la dirección en el backend de forma silenciosa + Future _postAddressInBackground(String calle, String colonia) async { try { const storage = FlutterSecureStorage(); + // Esperar un momento para asegurar que el token se haya guardado + await Future.delayed(const Duration(milliseconds: 500)); final token = await storage.read(key: 'token') ?? ''; if (token.isNotEmpty) { @@ -235,33 +219,51 @@ class _RegisterPageState extends ConsumerState { await dio.post( '/addresses', - data: { - 'label': 'Mi Casa', - 'calle': _calleCtrl.text.trim(), - 'colonia': _selectedColonia!.nombre, - }, + data: {'label': 'Mi Casa', 'calle': calle, 'colonia': colonia}, ); } } catch (e) { debugPrint('Aviso: No se pudo guardar la dirección inicial: $e'); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Error al guardar tu dirección. Inténtalo más tarde.', - ), - backgroundColor: AppTheme.danger, - ), - ); - } - return; // No navegar si falla el guardado de la dirección + } + } + + Future _register() async { + if (_calleCtrl.text.trim().isEmpty || _selectedColonia == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Ingresa tu calle y selecciona una colonia'), + behavior: SnackBarBehavior.floating, + ), + ); + return; } - // 3. Navegar a inicio de manera limpia + // Capturar variables antes del proceso asíncrono + final calle = _calleCtrl.text.trim(); + final colonia = _selectedColonia!.nombre; + + // 1. Registra al usuario + await ref + .read(authControllerProvider.notifier) + .register( + email: _emailCtrl.text.trim(), + phone: _telefonoCtrl.text.trim(), + password: _passCtrl.text, + ); + + // Si el widget ya no está montado, GoRouter nos redirigió automáticamente al Home por éxito. + if (!mounted) { + _postAddressInBackground(calle, colonia); + return; + } + + // Si seguimos aquí, verificar si hubo un error (ej. contraseña corta) + if (ref.read(authControllerProvider).hasError) return; + + // Fallback: guardar dirección y navegar manualmente + await _postAddressInBackground(calle, colonia); if (mounted) { - context.go( - '/home', - ); // ¡Solución al GoException! Navega a la ruta correcta + context.go('/home'); } } @@ -300,6 +302,7 @@ class _RegisterPageState extends ConsumerState { onNext: _nextPage, ), _Step2( + mapController: _mapController, cpCtrl: _cpCtrl, calleCtrl: _calleCtrl, selectedColonia: _selectedColonia, @@ -480,6 +483,7 @@ class _Step1 extends StatelessWidget { // ── Paso 2: Dirección ───────────────────────────────────────────────────────── class _Step2 extends StatelessWidget { + final MapController mapController; final TextEditingController cpCtrl; final TextEditingController calleCtrl; final Colonia? selectedColonia; @@ -494,6 +498,7 @@ class _Step2 extends StatelessWidget { final VoidCallback onRegister; const _Step2({ + required this.mapController, required this.cpCtrl, required this.calleCtrl, required this.selectedColonia, @@ -510,13 +515,19 @@ class _Step2 extends StatelessWidget { @override Widget build(BuildContext context) { - final mapCenter = selectedLocation ?? const LatLng(20.5222, -100.8123); + // Usamos el centro original de la colonia para los límites estáticos + final baseCenter = selectedColonia != null + ? kColoniasCoordinates[selectedColonia!.nombre] ?? + const LatLng(20.5222, -100.8123) + : const LatLng(20.5222, -100.8123); - // Magia de privacidad: Restringir paneo a 1km a la redonda de la colonia + final mapCenter = selectedLocation ?? baseCenter; + + // Magia de privacidad: Restringir paneo a 1km a la redonda usando el centro original final bounds = selectedColonia != null ? LatLngBounds( - LatLng(mapCenter.latitude - 0.01, mapCenter.longitude - 0.01), - LatLng(mapCenter.latitude + 0.01, mapCenter.longitude + 0.01), + LatLng(baseCenter.latitude - 0.01, baseCenter.longitude - 0.01), + LatLng(baseCenter.latitude + 0.01, baseCenter.longitude + 0.01), ) : null; @@ -542,25 +553,31 @@ class _Step2 extends StatelessWidget { Row( children: [ Expanded( - child: RadioListTile( - title: const Text( - 'Casa', - style: TextStyle(fontSize: 14), + child: Material( + color: Colors.transparent, + child: RadioListTile( + title: const Text( + 'Casa', + style: TextStyle(fontSize: 14), + ), + value: 'Casa', + groupValue: tipoInmueble, + onChanged: (v) => onTipoChanged(v!), ), - value: 'Casa', - groupValue: tipoInmueble, - onChanged: (v) => onTipoChanged(v!), ), ), Expanded( - child: RadioListTile( - title: const Text( - 'Negocio', - style: TextStyle(fontSize: 14), + child: Material( + color: Colors.transparent, + child: RadioListTile( + title: const Text( + 'Negocio', + style: TextStyle(fontSize: 14), + ), + value: 'Negocio', + groupValue: tipoInmueble, + onChanged: (v) => onTipoChanged(v!), ), - value: 'Negocio', - groupValue: tipoInmueble, - onChanged: (v) => onTipoChanged(v!), ), ), ], @@ -647,12 +664,12 @@ class _Step2 extends StatelessWidget { ), clipBehavior: Clip.hardEdge, child: FlutterMap( - key: ValueKey(selectedColonia?.nombre ?? 'default'), + mapController: mapController, options: MapOptions( initialCenter: mapCenter, initialZoom: 15.0, cameraConstraint: bounds != null - ? CameraConstraint.contain(bounds: bounds) + ? CameraConstraint.containCenter(bounds: bounds) : const CameraConstraint.unconstrained(), onTap: (_, latlng) => onLocationChanged(latlng), ), @@ -746,15 +763,21 @@ class _Step2 extends StatelessWidget { title: 'Notificaciones Externas', child: Column( children: [ - CheckboxListTile( - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - activeColor: AppTheme.primary, - value: whatsappNotif, - onChanged: onWhatsappChanged, - title: const Text( - 'Recibir alertas del camión vía WhatsApp (Próximamente)', - style: TextStyle(fontSize: 14, color: AppTheme.textPrimary), + Material( + color: Colors.transparent, + child: CheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + activeColor: AppTheme.primary, + value: whatsappNotif, + onChanged: onWhatsappChanged, + title: const Text( + 'Recibir alertas del camión vía WhatsApp (Próximamente)', + style: TextStyle( + fontSize: 14, + color: AppTheme.textPrimary, + ), + ), ), ), ], diff --git a/recolecta_app/lib/features/notifications/notifications_screen.dart b/recolecta_app/lib/features/notifications/notifications_screen.dart new file mode 100644 index 0000000..a1cb270 --- /dev/null +++ b/recolecta_app/lib/features/notifications/notifications_screen.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import '../../core/theme/app_theme.dart'; + +class NotificationsScreen extends StatelessWidget { + const NotificationsScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.background, + appBar: AppBar(title: const Text('Avisos y Alertas')), + body: const Center( + child: Text( + 'Bandeja de entrada de FCM', + style: TextStyle(color: AppTheme.textSecondary), + ), + ), + ); + } +} diff --git a/recolecta_app/lib/features/quiz/quiz_screen.dart b/recolecta_app/lib/features/quiz/quiz_screen.dart new file mode 100644 index 0000000..5850b85 --- /dev/null +++ b/recolecta_app/lib/features/quiz/quiz_screen.dart @@ -0,0 +1,21 @@ +// Vista de Educación y Quiz +import 'package:flutter/material.dart'; +import '../../core/theme/app_theme.dart'; + +class QuizScreen extends StatelessWidget { + const QuizScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.background, + appBar: AppBar(title: const Text('Educación y Quiz')), + body: const Center( + child: Text( + 'Chat IA / Guía de separación offline', + style: TextStyle(color: AppTheme.textSecondary), + ), + ), + ); + } +} diff --git a/recolecta_app/lib/main.dart b/recolecta_app/lib/main.dart index ec7e314..8ffe7d0 100644 --- a/recolecta_app/lib/main.dart +++ b/recolecta_app/lib/main.dart @@ -1,11 +1,36 @@ +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'app/app.dart'; +import 'firebase_options.dart'; + +@pragma('vm:entry-point') +Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { + try { + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); + } catch (_) {} + debugPrint('FCM background: ${message.messageId} | data: ${message.data}'); +} Future main() async { WidgetsFlutterBinding.ensureInitialized(); - await dotenv.load(fileName: '.env'); + try { + await dotenv.load(fileName: 'assets/.env'); + } catch (_) { + // .env no disponible — api_client.dart usa los valores por defecto + } + try { + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); + } on UnsupportedError { + await Firebase.initializeApp(); + } + FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); runApp(const ProviderScope(child: RecolectaApp())); } diff --git a/recolecta_app/pubspec.yaml b/recolecta_app/pubspec.yaml index b2d501a..c295d60 100644 --- a/recolecta_app/pubspec.yaml +++ b/recolecta_app/pubspec.yaml @@ -63,6 +63,7 @@ dev_dependencies: flutter: assets: # - assets/images/ + - assets/.env - assets/data/separation_guide.json # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in diff --git a/views_v1/admin_screen.dart b/views_v1/admin_screen.dart deleted file mode 100644 index 31573d6..0000000 --- a/views_v1/admin_screen.dart +++ /dev/null @@ -1,2443 +0,0 @@ -// ignore_for_file: unused_element - -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../theme/app_theme.dart'; -import '../widgets/widgets.dart' as w; -import '../services/admin_api_service.dart'; - -class AdminProvider extends ChangeNotifier { - final AdminApiService api; - - AdminProvider({required this.api}); - - bool loading = false; - String? error; - List users = []; - List routes = []; - List trucks = []; - - Future loadAll() async { - loading = true; - error = null; - notifyListeners(); - - if (!api.hasBackend) { - _loadSampleData(); - loading = false; - notifyListeners(); - return; - } - - try { - final fetchedUsers = await api.fetchUsers(); - final fetchedRoutes = await api.fetchRoutes(); - final fetchedTrucks = await api.fetchTrucks(); - users = fetchedUsers; - routes = fetchedRoutes; - trucks = fetchedTrucks; - loading = false; - notifyListeners(); - } catch (err) { - error = err.toString(); - _loadSampleData(); - loading = false; - notifyListeners(); - } - } - - void _loadSampleData() { - users = const [ - AdminUser( - id: 'u-01', - nombre: 'Laura', - apellido: 'Gómez', - email: 'laura.gomez@rutaverde.com', - telefono: '+52 461 980 1122', - ), - AdminUser( - id: 'u-02', - nombre: 'Miguel', - apellido: 'Sánchez', - email: 'miguel.sanchez@rutaverde.com', - telefono: '+52 461 980 3344', - ), - ]; - - routes = const [ - AdminRoute( - id: 'r-01', - nombre: 'Ruta Norte', - zona: 'Col. Las Palmas, Col. Primavera', - horario: 'Lun–Vie 7:00–10:00 a.m.', - totalCasas: 98, - activa: true, - ), - AdminRoute( - id: 'r-02', - nombre: 'Ruta Sur', - zona: 'Col. Centro, Col. Obrera', - horario: 'Lun–Sáb 8:00–11:30 a.m.', - totalCasas: 112, - activa: true, - ), - ]; - - trucks = const [ - AdminTruck( - id: 't-01', - placas: 'ABC-1234', - modelo: 'Volvo FH', - conductor: 'Javier Pérez', - status: TruckStatus.enRuta, - rutaId: 'r-01', - ), - AdminTruck( - id: 't-02', - placas: 'DEF-5678', - modelo: 'Mercedes 1830', - conductor: 'Ana Díaz', - status: TruckStatus.disponible, - rutaId: 'r-02', - ), - ]; - } - - Future saveUser(AdminUser user) async { - if (api.hasBackend) { - if (users.any((item) => item.id == user.id)) { - await api.updateUser(user); - } else { - await api.createUser(user); - } - await loadAll(); - return; - } - - final index = users.indexWhere((item) => item.id == user.id); - if (index >= 0) { - users[index] = user; - } else { - users.add(user); - } - notifyListeners(); - } - - Future deleteUser(String id) async { - if (api.hasBackend) { - await api.deleteUser(id); - await loadAll(); - return; - } - users.removeWhere((item) => item.id == id); - notifyListeners(); - } - - Future saveRoute(AdminRoute route) async { - if (api.hasBackend) { - if (routes.any((item) => item.id == route.id)) { - await api.updateRoute(route); - } else { - await api.createRoute(route); - } - await loadAll(); - return; - } - - final index = routes.indexWhere((item) => item.id == route.id); - if (index >= 0) { - routes[index] = route; - } else { - routes.add(route); - } - notifyListeners(); - } - - Future deleteRoute(String id) async { - if (api.hasBackend) { - await api.deleteRoute(id); - await loadAll(); - return; - } - routes.removeWhere((item) => item.id == id); - notifyListeners(); - } - - Future saveTruck(AdminTruck truck) async { - if (api.hasBackend) { - if (trucks.any((item) => item.id == truck.id)) { - await api.updateTruck(truck); - } else { - await api.createTruck(truck); - } - await loadAll(); - return; - } - - final index = trucks.indexWhere((item) => item.id == truck.id); - if (index >= 0) { - trucks[index] = truck; - } else { - trucks.add(truck); - } - notifyListeners(); - } - - Future deleteTruck(String id) async { - if (api.hasBackend) { - await api.deleteTruck(id); - await loadAll(); - return; - } - trucks.removeWhere((item) => item.id == id); - notifyListeners(); - } -} - -extension TruckStatusBadgeX on TruckStatus { - w.StatusBadge get badge { - switch (this) { - case TruckStatus.disponible: - return w.StatusBadge.green(label); - case TruckStatus.enRuta: - return w.StatusBadge.amber(label); - case TruckStatus.mantenimiento: - case TruckStatus.detenido: - return w.StatusBadge.gray(label); - } - } -} - -class DriverModel { - final String id; - final String nombre; - final String apellido; - final String telefono; - final String ruta; - final bool activo; - final int? turnoHora; - - const DriverModel({ - required this.id, - required this.nombre, - required this.apellido, - required this.telefono, - required this.ruta, - this.activo = true, - this.turnoHora, - }); - - String get nombreCompleto => '$nombre $apellido'; - String get iniciales => - '${nombre.isNotEmpty ? nombre[0] : ''}${apellido.isNotEmpty ? apellido[0] : ''}' - .toUpperCase(); -} - -class RouteModel { - final String id; - final String nombre; - final String zona; - final String horario; - final int totalCasas; - final bool activa; - - const RouteModel({ - required this.id, - required this.nombre, - required this.zona, - required this.horario, - required this.totalCasas, - this.activa = true, - }); -} - -Future showAdminUserForm(BuildContext context, {AdminUser? user}) async { - final provider = Provider.of(context, listen: false); - final formKey = GlobalKey(); - final nombreCtrl = TextEditingController(text: user?.nombre); - final apellidoCtrl = TextEditingController(text: user?.apellido); - final emailCtrl = TextEditingController(text: user?.email); - final telefonoCtrl = TextEditingController(text: user?.telefono); - - await showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppTheme.surface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.radiusLg), - ), - title: Text(user == null ? 'Nuevo usuario' : 'Editar usuario'), - content: Form( - key: formKey, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextFormField( - controller: nombreCtrl, - decoration: const InputDecoration(labelText: 'Nombre'), - validator: (value) => - value?.trim().isEmpty == true ? 'Requerido' : null, - ), - TextFormField( - controller: apellidoCtrl, - decoration: const InputDecoration(labelText: 'Apellido'), - validator: (value) => - value?.trim().isEmpty == true ? 'Requerido' : null, - ), - TextFormField( - controller: emailCtrl, - decoration: const InputDecoration(labelText: 'Correo'), - keyboardType: TextInputType.emailAddress, - validator: (value) => - value?.trim().isEmpty == true ? 'Requerido' : null, - ), - TextFormField( - controller: telefonoCtrl, - decoration: const InputDecoration(labelText: 'Teléfono'), - keyboardType: TextInputType.phone, - ), - ], - ), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - style: TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), - child: const Text('Cancelar'), - ), - TextButton( - onPressed: () async { - if (!formKey.currentState!.validate()) return; - final newUser = AdminUser( - id: user?.id ?? 'u-${DateTime.now().millisecondsSinceEpoch}', - nombre: nombreCtrl.text.trim(), - apellido: apellidoCtrl.text.trim(), - email: emailCtrl.text.trim(), - telefono: telefonoCtrl.text.trim(), - ); - await provider.saveUser(newUser); - if (context.mounted) Navigator.pop(ctx); - }, - child: Text(user == null ? 'Crear' : 'Guardar'), - ), - ], - ), - ); -} - -Future showAdminRouteForm(BuildContext context, - {AdminRoute? route}) async { - final provider = Provider.of(context, listen: false); - final formKey = GlobalKey(); - final nombreCtrl = TextEditingController(text: route?.nombre); - final zonaCtrl = TextEditingController(text: route?.zona); - final horarioCtrl = TextEditingController(text: route?.horario); - final totalCasasCtrl = TextEditingController( - text: route != null ? route.totalCasas.toString() : ''); - bool activa = route?.activa ?? true; - - await showDialog( - context: context, - builder: (ctx) => StatefulBuilder( - builder: (context, setState) => AlertDialog( - backgroundColor: AppTheme.surface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.radiusLg), - ), - title: Text(route == null ? 'Nueva ruta' : 'Editar ruta'), - content: Form( - key: formKey, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextFormField( - controller: nombreCtrl, - decoration: - const InputDecoration(labelText: 'Nombre de ruta'), - validator: (value) => - value?.trim().isEmpty == true ? 'Requerido' : null, - ), - TextFormField( - controller: zonaCtrl, - decoration: const InputDecoration(labelText: 'Zona'), - validator: (value) => - value?.trim().isEmpty == true ? 'Requerido' : null, - ), - TextFormField( - controller: horarioCtrl, - decoration: const InputDecoration(labelText: 'Horario'), - validator: (value) => - value?.trim().isEmpty == true ? 'Requerido' : null, - ), - TextFormField( - controller: totalCasasCtrl, - decoration: const InputDecoration(labelText: 'Total casas'), - keyboardType: TextInputType.number, - validator: (value) { - if (value?.trim().isEmpty == true) return 'Requerido'; - return int.tryParse(value!.trim()) == null - ? 'Debe ser un número' - : null; - }, - ), - const SizedBox(height: 12), - Row( - children: [ - const Expanded(child: Text('Ruta activa')), - Switch.adaptive( - value: activa, - onChanged: (value) => setState(() => activa = value), - ), - ], - ), - ], - ), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - style: - TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), - child: const Text('Cancelar'), - ), - TextButton( - onPressed: () async { - if (!formKey.currentState!.validate()) return; - final newRoute = AdminRoute( - id: route?.id ?? 'r-${DateTime.now().millisecondsSinceEpoch}', - nombre: nombreCtrl.text.trim(), - zona: zonaCtrl.text.trim(), - horario: horarioCtrl.text.trim(), - totalCasas: int.parse(totalCasasCtrl.text.trim()), - activa: activa, - ); - await provider.saveRoute(newRoute); - if (context.mounted) Navigator.pop(ctx); - }, - child: Text(route == null ? 'Crear' : 'Guardar'), - ), - ], - ), - ), - ); -} - -Future showAdminTruckForm(BuildContext context, - {AdminTruck? truck}) async { - final provider = Provider.of(context, listen: false); - final formKey = GlobalKey(); - final placasCtrl = TextEditingController(text: truck?.placas); - final modeloCtrl = TextEditingController(text: truck?.modelo); - final conductorCtrl = TextEditingController(text: truck?.conductor); - TruckStatus status = truck?.status ?? TruckStatus.disponible; - String selectedRuta = truck?.rutaId ?? - (provider.routes.isNotEmpty ? provider.routes.first.id : ''); - - await showDialog( - context: context, - builder: (ctx) => StatefulBuilder( - builder: (context, setState) => AlertDialog( - backgroundColor: AppTheme.surface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.radiusLg), - ), - title: Text(truck == null ? 'Nuevo camión' : 'Editar camión'), - content: Form( - key: formKey, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextFormField( - controller: placasCtrl, - decoration: const InputDecoration(labelText: 'Placas'), - validator: (value) => - value?.trim().isEmpty == true ? 'Requerido' : null, - ), - TextFormField( - controller: modeloCtrl, - decoration: const InputDecoration(labelText: 'Modelo'), - validator: (value) => - value?.trim().isEmpty == true ? 'Requerido' : null, - ), - TextFormField( - controller: conductorCtrl, - decoration: const InputDecoration(labelText: 'Conductor'), - validator: (value) => - value?.trim().isEmpty == true ? 'Requerido' : null, - ), - const SizedBox(height: 12), - DropdownButtonFormField( - value: selectedRuta.isEmpty ? null : selectedRuta, - decoration: const InputDecoration(labelText: 'Ruta'), - items: provider.routes - .map((ruta) => DropdownMenuItem( - value: ruta.id, - child: Text(ruta.nombre), - )) - .toList(), - onChanged: (value) { - if (value != null) setState(() => selectedRuta = value); - }, - validator: (value) => - value == null || value.isEmpty ? 'Requerido' : null, - ), - const SizedBox(height: 12), - DropdownButtonFormField( - value: status, - decoration: const InputDecoration(labelText: 'Estatus'), - items: TruckStatus.values - .map((item) => DropdownMenuItem( - value: item, - child: Text(item.label), - )) - .toList(), - onChanged: (value) { - if (value != null) setState(() => status = value); - }, - ), - ], - ), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - style: - TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), - child: const Text('Cancelar'), - ), - TextButton( - onPressed: () async { - if (!formKey.currentState!.validate()) return; - final newTruck = AdminTruck( - id: truck?.id ?? 't-${DateTime.now().millisecondsSinceEpoch}', - placas: placasCtrl.text.trim(), - modelo: modeloCtrl.text.trim(), - conductor: conductorCtrl.text.trim(), - status: status, - rutaId: selectedRuta, - ); - await provider.saveTruck(newTruck); - if (context.mounted) Navigator.pop(ctx); - }, - child: Text(truck == null ? 'Crear' : 'Guardar'), - ), - ], - ), - ), - ); -} - -Future _confirmDeleteUser(BuildContext context, AdminUser user) async { - final provider = Provider.of(context, listen: false); - await showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppTheme.surface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.radiusLg)), - title: const Text('Eliminar usuario'), - content: Text('¿Deseas eliminar a ${user.nombreCompleto}?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - style: TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), - child: const Text('Cancelar'), - ), - TextButton( - onPressed: () async { - await provider.deleteUser(user.id); - if (context.mounted) Navigator.pop(ctx); - }, - style: TextButton.styleFrom(foregroundColor: AppTheme.danger), - child: const Text('Eliminar'), - ), - ], - ), - ); -} - -Future _confirmDeleteRoute(BuildContext context, AdminRoute route) async { - final provider = Provider.of(context, listen: false); - await showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppTheme.surface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.radiusLg)), - title: const Text('Eliminar ruta'), - content: Text('¿Deseas eliminar ${route.nombre}?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - style: TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), - child: const Text('Cancelar'), - ), - TextButton( - onPressed: () async { - await provider.deleteRoute(route.id); - if (context.mounted) Navigator.pop(ctx); - }, - style: TextButton.styleFrom(foregroundColor: AppTheme.danger), - child: const Text('Eliminar'), - ), - ], - ), - ); -} - -Future _confirmDeleteTruck(BuildContext context, AdminTruck truck) async { - final provider = Provider.of(context, listen: false); - await showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppTheme.surface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.radiusLg)), - title: const Text('Eliminar camión'), - content: Text('¿Deseas eliminar ${truck.placas}?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - style: TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), - child: const Text('Cancelar'), - ), - TextButton( - onPressed: () async { - await provider.deleteTruck(truck.id); - if (context.mounted) Navigator.pop(ctx); - }, - style: TextButton.styleFrom(foregroundColor: AppTheme.danger), - child: const Text('Eliminar'), - ), - ], - ), - ); -} - -// ── Pantalla principal de Administrador ─────────────────────────────────────── - -class AdminShell extends StatefulWidget { - const AdminShell({super.key}); - - @override - State createState() => _AdminShellState(); -} - -class _AdminShellState extends State { - int _currentIndex = 0; - - final List _screens = const [ - AdminDashboardScreen(), - AdminUsersScreen(), - AdminRoutesScreen(), - AdminTrucksScreen(), - ]; - - @override - Widget build(BuildContext context) { - return ChangeNotifierProvider( - create: (_) => AdminProvider(api: const AdminApiService())..loadAll(), - child: Consumer( - builder: (context, provider, _) { - return Scaffold( - body: IndexedStack(index: _currentIndex, children: _screens), - floatingActionButton: _buildFab(provider), - bottomNavigationBar: BottomNavigationBar( - currentIndex: _currentIndex, - onTap: (i) => setState(() => _currentIndex = i), - type: BottomNavigationBarType.fixed, - backgroundColor: AppTheme.surface, - selectedItemColor: AppTheme.primary, - unselectedItemColor: AppTheme.textSecondary, - selectedFontSize: 11, - unselectedFontSize: 11, - elevation: 12, - items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.dashboard_outlined), - activeIcon: Icon(Icons.dashboard), - label: 'Resumen', - ), - BottomNavigationBarItem( - icon: Icon(Icons.person_outline), - activeIcon: Icon(Icons.person), - label: 'Usuarios', - ), - BottomNavigationBarItem( - icon: Icon(Icons.route_outlined), - activeIcon: Icon(Icons.route), - label: 'Rutas', - ), - BottomNavigationBarItem( - icon: Icon(Icons.directions_bus_outlined), - activeIcon: Icon(Icons.directions_bus), - label: 'Camiones', - ), - ], - ), - ); - }, - ), - ); - } - - Widget? _buildFab(AdminProvider provider) { - switch (_currentIndex) { - case 1: - return FloatingActionButton.extended( - onPressed: () => showAdminUserForm(context), - icon: const Icon(Icons.add), - label: const Text('Nuevo usuario'), - ); - case 2: - return FloatingActionButton.extended( - onPressed: () => showAdminRouteForm(context), - icon: const Icon(Icons.add), - label: const Text('Nueva ruta'), - ); - case 3: - return FloatingActionButton.extended( - onPressed: () => showAdminTruckForm(context), - icon: const Icon(Icons.add), - label: const Text('Nuevo camión'), - ); - default: - return null; - } - } -} - -// ── Dashboard principal ─────────────────────────────────────────────────────── - -class AdminDashboardScreen extends StatelessWidget { - const AdminDashboardScreen({super.key}); - - @override - Widget build(BuildContext context) { - final provider = context.watch(); - final activos = provider.trucks - .where((truck) => truck.status == TruckStatus.enRuta) - .length; - final disponibles = provider.trucks - .where((truck) => truck.status == TruckStatus.disponible) - .length; - final rutasActivas = provider.routes.where((ruta) => ruta.activa).length; - final totalRutas = provider.routes.length; - final totalUsuarios = provider.users.length; - - return Scaffold( - backgroundColor: AppTheme.background, - appBar: AppBar( - title: Row( - children: [ - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(AppTheme.radiusSm), - ), - child: const Icon(Icons.admin_panel_settings_outlined, - color: Colors.white, size: 18), - ), - const SizedBox(width: 10), - const Text('Administración'), - ], - ), - ), - body: provider.loading - ? const Center(child: CircularProgressIndicator()) - : ListView( - padding: const EdgeInsets.all(16), - children: [ - if (provider.error != null) - Container( - margin: const EdgeInsets.only(bottom: 16), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: AppTheme.dangerLight, - borderRadius: BorderRadius.circular(AppTheme.radiusLg), - ), - child: Text( - 'Error: ${provider.error}', - style: const TextStyle(color: AppTheme.danger), - ), - ), - _WelcomeBanner(), - const SizedBox(height: 16), - w.SectionTitle(title: 'Estado del servicio'), - Row( - children: [ - Expanded( - child: _MetricCard( - icon: Icons.directions_bus_rounded, - label: 'Camiones en ruta', - value: '$activos', - total: '${provider.trucks.length}', - color: AppTheme.primary, - bgColor: AppTheme.primaryLight, - ), - ), - const SizedBox(width: 12), - Expanded( - child: _MetricCard( - icon: Icons.person_outline, - label: 'Usuarios', - value: '$totalUsuarios', - color: AppTheme.blue, - bgColor: AppTheme.blueLight, - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: _MetricCard( - icon: Icons.route_outlined, - label: 'Rutas activas', - value: '$rutasActivas', - total: '$totalRutas', - color: AppTheme.primaryDark, - bgColor: AppTheme.primaryLight, - ), - ), - const SizedBox(width: 12), - Expanded( - child: _MetricCard( - icon: Icons.directions_bus_outlined, - label: 'Disponibles', - value: '$disponibles', - color: AppTheme.amber, - bgColor: AppTheme.amberLight, - ), - ), - ], - ), - const SizedBox(height: 24), - w.SectionTitle( - title: 'Últimos camiones', - action: TextButton( - onPressed: () {}, - style: TextButton.styleFrom( - foregroundColor: AppTheme.primary, - padding: EdgeInsets.zero, - ), - child: const Text('Ver todos', - style: TextStyle( - fontSize: 12, fontWeight: FontWeight.w600)), - ), - ), - ...provider.trucks.take(3).map((truck) => - _TruckSummaryCard(truck: truck, provider: provider)), - const SizedBox(height: 24), - w.SectionTitle(title: 'Acciones rápidas'), - Row( - children: [ - Expanded( - child: _QuickAction( - icon: Icons.person_add_outlined, - label: 'Agregar usuario', - onTap: () => showAdminUserForm(context), - ), - ), - const SizedBox(width: 12), - Expanded( - child: _QuickAction( - icon: Icons.route_outlined, - label: 'Agregar ruta', - onTap: () => showAdminRouteForm(context), - ), - ), - const SizedBox(width: 12), - Expanded( - child: _QuickAction( - icon: Icons.add_business_outlined, - label: 'Agregar camión', - onTap: () => showAdminTruckForm(context), - ), - ), - ], - ), - const SizedBox(height: 24), - ], - ), - ); - } -} - -class _TruckSummaryCard extends StatelessWidget { - final AdminTruck truck; - final AdminProvider provider; - - const _TruckSummaryCard({required this.truck, required this.provider}); - - @override - Widget build(BuildContext context) { - final route = provider.routes.firstWhere( - (route) => route.id == truck.rutaId, - orElse: () => const AdminRoute( - id: '', - nombre: 'Sin ruta', - zona: '', - horario: '', - totalCasas: 0, - ), - ); - - return w.AppCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text(truck.placas, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary)), - ), - truck.status.badge, - ], - ), - const SizedBox(height: 8), - Text('${truck.modelo} · ${truck.conductor}', - style: const TextStyle(fontSize: 13)), - const SizedBox(height: 4), - Text('Ruta: ${route.nombre}', - style: - const TextStyle(fontSize: 12, color: AppTheme.textSecondary)), - ], - ), - ); - } -} - -class AdminUsersScreen extends StatelessWidget { - const AdminUsersScreen({super.key}); - - @override - Widget build(BuildContext context) { - final provider = context.watch(); - return Scaffold( - backgroundColor: AppTheme.background, - appBar: AppBar( - title: const Text('Usuarios'), - actions: [ - IconButton( - icon: const Icon(Icons.add), - onPressed: () => showAdminUserForm(context), - ), - ], - ), - body: provider.loading - ? const Center(child: CircularProgressIndicator()) - : provider.users.isEmpty - ? Center( - child: Text('No hay usuarios registrados aún.', - style: const TextStyle(color: AppTheme.textSecondary)), - ) - : ListView.separated( - padding: const EdgeInsets.all(16), - itemCount: provider.users.length, - separatorBuilder: (_, __) => const SizedBox(height: 12), - itemBuilder: (context, index) { - final user = provider.users[index]; - return w.AppCard( - child: Row( - children: [ - CircleAvatar( - backgroundColor: AppTheme.primaryLight, - foregroundColor: AppTheme.primary, - child: Text(user.iniciales), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(user.nombreCompleto, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600)), - const SizedBox(height: 4), - Text(user.email, - style: const TextStyle( - fontSize: 13, - color: AppTheme.textSecondary)), - const SizedBox(height: 2), - Text(user.telefono, - style: const TextStyle(fontSize: 13)), - ], - ), - ), - IconButton( - icon: const Icon(Icons.edit_outlined, - color: AppTheme.primary), - onPressed: () => - showAdminUserForm(context, user: user), - ), - IconButton( - icon: const Icon(Icons.delete_outline, - color: AppTheme.danger), - onPressed: () => _confirmDeleteUser(context, user), - ), - ], - ), - ); - }, - ), - ); - } -} - -class AdminTrucksScreen extends StatelessWidget { - const AdminTrucksScreen({super.key}); - - @override - Widget build(BuildContext context) { - final provider = context.watch(); - return Scaffold( - backgroundColor: AppTheme.background, - appBar: AppBar( - title: const Text('Camiones'), - actions: [ - IconButton( - icon: const Icon(Icons.add), - onPressed: () => showAdminTruckForm(context), - ), - ], - ), - body: provider.loading - ? const Center(child: CircularProgressIndicator()) - : provider.trucks.isEmpty - ? Center( - child: Text('No hay camiones registrados.', - style: const TextStyle(color: AppTheme.textSecondary)), - ) - : ListView.separated( - padding: const EdgeInsets.all(16), - itemCount: provider.trucks.length, - separatorBuilder: (_, __) => const SizedBox(height: 12), - itemBuilder: (context, index) { - final truck = provider.trucks[index]; - final route = provider.routes.firstWhere( - (route) => route.id == truck.rutaId, - orElse: () => AdminRoute( - id: '', - nombre: 'Sin ruta', - zona: '', - horario: '', - totalCasas: 0, - activa: false, - ), - ); - return w.AppCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text(truck.placas, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600)), - ), - truck.status.badge, - ], - ), - const SizedBox(height: 8), - Text('${truck.modelo} · ${truck.conductor}', - style: const TextStyle(fontSize: 13)), - const SizedBox(height: 4), - Text('Ruta: ${route.nombre}', - style: const TextStyle( - fontSize: 13, color: AppTheme.textSecondary)), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton.icon( - onPressed: () => - showAdminTruckForm(context, truck: truck), - icon: const Icon(Icons.edit_outlined, size: 18), - label: const Text('Editar'), - ), - const SizedBox(width: 8), - TextButton.icon( - onPressed: () => - _confirmDeleteTruck(context, truck), - icon: - const Icon(Icons.delete_outline, size: 18), - label: const Text('Eliminar'), - ), - ], - ), - ], - ), - ); - }, - ), - ); - } -} - -// ── Pantalla de Rutas ───────────────────────────────────────────────────────── - -class AdminRoutesScreen extends StatelessWidget { - const AdminRoutesScreen({super.key}); - - @override - Widget build(BuildContext context) { - final provider = context.watch(); - return Scaffold( - backgroundColor: AppTheme.background, - appBar: AppBar( - title: const Text('Rutas'), - actions: [ - IconButton( - icon: const Icon(Icons.add), - onPressed: () => showAdminRouteForm(context), - ), - ], - ), - body: provider.loading - ? const Center(child: CircularProgressIndicator()) - : provider.routes.isEmpty - ? Center( - child: Text('No hay rutas registradas.', - style: const TextStyle(color: AppTheme.textSecondary)), - ) - : ListView.separated( - padding: const EdgeInsets.all(16), - itemCount: provider.routes.length, - separatorBuilder: (_, __) => const SizedBox(height: 12), - itemBuilder: (context, index) { - final route = provider.routes[index]; - return w.AppCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text(route.nombre, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600)), - ), - w.StatusBadge( - label: route.activa ? 'Activa' : 'Inactiva', - backgroundColor: route.activa - ? AppTheme.primaryLight - : const Color(0xFFF1EFE8), - textColor: route.activa - ? AppTheme.primaryDark - : const Color(0xFF5F5E5A), - ), - ], - ), - const SizedBox(height: 10), - Text(route.zona, - style: const TextStyle( - fontSize: 12, color: AppTheme.textSecondary)), - const SizedBox(height: 6), - Text(route.horario, - style: const TextStyle(fontSize: 13)), - const SizedBox(height: 10), - Text('${route.totalCasas} casas', - style: const TextStyle( - fontSize: 12, color: AppTheme.textSecondary)), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton.icon( - onPressed: () => - showAdminRouteForm(context, route: route), - icon: const Icon(Icons.edit_outlined, size: 18), - label: const Text('Editar'), - ), - const SizedBox(width: 8), - TextButton.icon( - onPressed: () => - _confirmDeleteRoute(context, route), - icon: - const Icon(Icons.delete_outline, size: 18), - label: const Text('Eliminar'), - ), - ], - ), - ], - ), - ); - }, - ), - ); - } -} - -// ── Pantalla de Choferes ────────────────────────────────────────────────────── - -class AdminDriversScreen extends StatefulWidget { - const AdminDriversScreen({super.key}); - - @override - State createState() => _AdminDriversScreenState(); -} - -class _AdminDriversScreenState extends State { - static const List _choferes = [ - DriverModel( - id: 'd-01', - nombre: 'Miguel', - apellido: 'Hernández', - telefono: '+52 461 100 0001', - ruta: 'Ruta Norte', - activo: true, - turnoHora: 7), - DriverModel( - id: 'd-02', - nombre: 'José', - apellido: 'Ramírez', - telefono: '+52 461 100 0002', - ruta: 'Ruta Sur', - activo: true, - turnoHora: 8), - DriverModel( - id: 'd-03', - nombre: 'Luis', - apellido: 'García', - telefono: '+52 461 100 0003', - ruta: 'Ruta Centro', - activo: false, - turnoHora: 9), - DriverModel( - id: 'd-04', - nombre: 'Roberto', - apellido: 'López', - telefono: '+52 461 100 0004', - ruta: 'Ruta Oriente', - activo: false, - turnoHora: 7), - ]; - - @override - Widget build(BuildContext context) { - final activos = _choferes.where((c) => c.activo).toList(); - final inactivos = _choferes.where((c) => !c.activo).toList(); - - return Scaffold( - backgroundColor: AppTheme.background, - appBar: AppBar(title: const Text('Choferes')), - floatingActionButton: FloatingActionButton.extended( - onPressed: () => _mostrarFormularioChofer(context), - backgroundColor: AppTheme.primary, - foregroundColor: Colors.white, - icon: const Icon(Icons.person_add_outlined), - label: const Text('Agregar chofer', - style: TextStyle(fontWeight: FontWeight.w600)), - ), - body: ListView( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), - children: [ - // Tarjetas resumen - Row( - children: [ - Expanded( - child: _MetricCard( - icon: Icons.directions_bus_rounded, - label: 'En servicio', - value: '${activos.length}', - color: AppTheme.primary, - bgColor: AppTheme.primaryLight, - ), - ), - const SizedBox(width: 12), - Expanded( - child: _MetricCard( - icon: Icons.person_off_outlined, - label: 'Inactivos', - value: '${inactivos.length}', - color: AppTheme.textSecondary, - bgColor: const Color(0xFFF1EFE8), - ), - ), - ], - ), - - const SizedBox(height: 20), - - if (activos.isNotEmpty) ...[ - w.SectionTitle(title: 'En servicio hoy'), - ...activos.map((c) => _DriverDetailCard(chofer: c)), - const SizedBox(height: 8), - ], - - if (inactivos.isNotEmpty) ...[ - w.SectionTitle(title: 'Sin turno'), - ...inactivos.map((c) => _DriverDetailCard(chofer: c)), - ], - ], - ), - ); - } - - void _mostrarFormularioChofer(BuildContext context) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: AppTheme.surface, - shape: const RoundedRectangleBorder( - borderRadius: - BorderRadius.vertical(top: Radius.circular(AppTheme.radiusXl)), - ), - builder: (_) => const _DriverFormSheet(), - ); - } -} - -// ── Pantalla de Reportes ────────────────────────────────────────────────────── - -class AdminReportsScreen extends StatelessWidget { - const AdminReportsScreen({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppTheme.background, - appBar: AppBar(title: const Text('Reportes')), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - // Filtro de período - _PeriodSelector(), - - const SizedBox(height: 20), - - // Estadísticas de la semana - w.SectionTitle(title: 'Esta semana'), - _StatsRow(), - - const SizedBox(height: 20), - - // Gráfica de alertas - w.SectionTitle(title: 'Alertas enviadas por día'), - _AlertsBarChart(), - - const SizedBox(height: 20), - - // Top rutas - w.SectionTitle(title: 'Rutas con más actividad'), - _TopRouteTile( - ruta: 'Ruta Norte', alertas: 128, porcentaje: 0.85, posicion: 1), - const SizedBox(height: 8), - _TopRouteTile( - ruta: 'Ruta Sur', alertas: 112, porcentaje: 0.74, posicion: 2), - const SizedBox(height: 8), - _TopRouteTile( - ruta: 'Ruta Centro', alertas: 87, porcentaje: 0.58, posicion: 3), - const SizedBox(height: 8), - _TopRouteTile( - ruta: 'Ruta Oriente', alertas: 43, porcentaje: 0.28, posicion: 4), - - const SizedBox(height: 20), - - // Exportar - w.SectionTitle(title: 'Exportar datos'), - w.MenuTile( - icon: Icons.table_chart_outlined, - title: 'Exportar a Excel', - subtitle: 'Datos del mes actual', - onTap: () {}, - ), - w.MenuTile( - icon: Icons.picture_as_pdf_outlined, - title: 'Generar reporte PDF', - subtitle: 'Resumen ejecutivo', - onTap: () {}, - ), - - const SizedBox(height: 32), - ], - ), - ); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// WIDGETS INTERNOS -// ───────────────────────────────────────────────────────────────────────────── - -class _WelcomeBanner extends StatelessWidget { - @override - Widget build(BuildContext context) { - final hora = DateTime.now().hour; - final saludo = hora < 12 - ? 'Buenos días' - : hora < 18 - ? 'Buenas tardes' - : 'Buenas noches'; - - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [AppTheme.primary, AppTheme.primaryDark], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(AppTheme.radiusLg), - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '$saludo, Admin', - style: const TextStyle( - fontSize: 17, - fontWeight: FontWeight.w700, - color: Colors.white), - ), - const SizedBox(height: 4), - const Text( - 'Servicio de Limpia · Celaya, Gto.', - style: TextStyle(fontSize: 12, color: Colors.white70), - ), - ], - ), - ), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.15), - shape: BoxShape.circle, - ), - child: const Icon(Icons.delete_outline_rounded, - color: Colors.white, size: 28), - ), - ], - ), - ); - } -} - -class _MetricCard extends StatelessWidget { - final IconData icon; - final String label; - final String value; - final String? total; - final Color color; - final Color bgColor; - - const _MetricCard({ - required this.icon, - required this.label, - required this.value, - this.total, - required this.color, - required this.bgColor, - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(AppTheme.radiusLg), - border: Border.all(color: AppTheme.border, width: 0.5), - boxShadow: AppTheme.softShadow, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(10), - ), - child: Icon(icon, color: color, size: 20), - ), - const SizedBox(height: 10), - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - value, - style: TextStyle( - fontSize: 26, - fontWeight: FontWeight.w800, - color: color, - height: 1), - ), - if (total != null) ...[ - const SizedBox(width: 2), - Padding( - padding: const EdgeInsets.only(bottom: 3), - child: Text( - '/$total', - style: const TextStyle( - fontSize: 14, - color: AppTheme.textSecondary, - fontWeight: FontWeight.w500), - ), - ), - ], - ], - ), - const SizedBox(height: 4), - Text(label, - style: const TextStyle( - fontSize: 11, color: AppTheme.textSecondary, height: 1.3)), - ], - ), - ); - } -} - -class _DriverTile extends StatelessWidget { - final DriverModel chofer; - const _DriverTile({required this.chofer}); - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(AppTheme.radiusMd), - border: Border.all(color: AppTheme.border, width: 0.5), - boxShadow: AppTheme.softShadow, - ), - child: Row( - children: [ - // Avatar - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: chofer.activo - ? AppTheme.primaryLight - : const Color(0xFFF1EFE8), - shape: BoxShape.circle, - ), - child: Center( - child: Text( - chofer.iniciales, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - color: chofer.activo - ? AppTheme.primaryDark - : AppTheme.textSecondary), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(chofer.nombreCompleto, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary)), - const SizedBox(height: 2), - Text(chofer.ruta, - style: const TextStyle( - fontSize: 12, color: AppTheme.textSecondary)), - ], - ), - ), - w.StatusBadge( - label: chofer.activo ? 'En servicio' : 'Sin turno', - backgroundColor: - chofer.activo ? AppTheme.primaryLight : const Color(0xFFF1EFE8), - textColor: - chofer.activo ? AppTheme.primaryDark : const Color(0xFF5F5E5A), - ), - ], - ), - ); - } -} - -class _DriverDetailCard extends StatelessWidget { - final DriverModel chofer; - const _DriverDetailCard({required this.chofer}); - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.only(bottom: 10), - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(AppTheme.radiusLg), - border: Border.all(color: AppTheme.border, width: 0.5), - boxShadow: AppTheme.softShadow, - ), - child: Column( - children: [ - // Encabezado - Padding( - padding: const EdgeInsets.all(14), - child: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: chofer.activo - ? AppTheme.primaryLight - : const Color(0xFFF1EFE8), - shape: BoxShape.circle, - border: Border.all( - color: - chofer.activo ? AppTheme.primaryMid : AppTheme.border, - width: 1.5, - ), - ), - child: Center( - child: Text( - chofer.iniciales, - style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.w700, - color: chofer.activo - ? AppTheme.primaryDark - : AppTheme.textSecondary), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(chofer.nombreCompleto, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - color: AppTheme.textPrimary)), - const SizedBox(height: 3), - Row( - children: [ - const Icon(Icons.route_outlined, - size: 13, color: AppTheme.textSecondary), - const SizedBox(width: 4), - Text(chofer.ruta, - style: const TextStyle( - fontSize: 12, color: AppTheme.textSecondary)), - ], - ), - ], - ), - ), - w.StatusBadge( - label: chofer.activo ? 'Activo' : 'Inactivo', - backgroundColor: chofer.activo - ? AppTheme.primaryLight - : const Color(0xFFF1EFE8), - textColor: chofer.activo - ? AppTheme.primaryDark - : const Color(0xFF5F5E5A), - ), - ], - ), - ), - - Divider(color: AppTheme.borderLight, height: 1), - - // Detalles - Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - child: Row( - children: [ - _InfoChip(icon: Icons.phone_outlined, label: chofer.telefono), - const SizedBox(width: 16), - if (chofer.turnoHora != null) - _InfoChip( - icon: Icons.schedule_outlined, - label: 'Turno ${chofer.turnoHora!}:00 a.m.', - ), - ], - ), - ), - - // Acciones - Padding( - padding: const EdgeInsets.fromLTRB(10, 0, 10, 10), - child: Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () {}, - style: OutlinedButton.styleFrom( - foregroundColor: AppTheme.primary, - side: const BorderSide(color: AppTheme.primary), - padding: const EdgeInsets.symmetric(vertical: 10), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.radiusSm), - ), - ), - icon: const Icon(Icons.edit_outlined, size: 16), - label: const Text('Editar', - style: TextStyle( - fontSize: 13, fontWeight: FontWeight.w600)), - ), - ), - const SizedBox(width: 8), - Expanded( - child: OutlinedButton.icon( - onPressed: () {}, - style: OutlinedButton.styleFrom( - foregroundColor: AppTheme.danger, - side: const BorderSide(color: AppTheme.danger), - padding: const EdgeInsets.symmetric(vertical: 10), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.radiusSm), - ), - ), - icon: const Icon(Icons.person_off_outlined, size: 16), - label: const Text('Desactivar', - style: TextStyle( - fontSize: 13, fontWeight: FontWeight.w600)), - ), - ), - ], - ), - ), - ], - ), - ); - } -} - -class _InfoChip extends StatelessWidget { - final IconData icon; - final String label; - const _InfoChip({required this.icon, required this.label}); - - @override - Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 13, color: AppTheme.textSecondary), - const SizedBox(width: 4), - Text(label, - style: - const TextStyle(fontSize: 12, color: AppTheme.textSecondary)), - ], - ); - } -} - -class _QuickAction extends StatelessWidget { - final IconData icon; - final String label; - final VoidCallback onTap; - const _QuickAction( - {required this.icon, required this.label, required this.onTap}); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 16), - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(AppTheme.radiusMd), - border: Border.all(color: AppTheme.border, width: 0.5), - boxShadow: AppTheme.softShadow, - ), - child: Column( - children: [ - Icon(icon, color: AppTheme.primary, size: 24), - const SizedBox(height: 6), - Text(label, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w500, - color: AppTheme.textPrimary, - height: 1.3)), - ], - ), - ), - ); - } -} - -class _IncidentTile extends StatelessWidget { - final IconData icon; - final Color color; - final Color bgColor; - final String titulo; - final String descripcion; - final String hora; - - const _IncidentTile({ - required this.icon, - required this.color, - required this.bgColor, - required this.titulo, - required this.descripcion, - required this.hora, - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(AppTheme.radiusMd), - border: Border.all(color: AppTheme.border, width: 0.5), - boxShadow: AppTheme.softShadow, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 38, - height: 38, - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(10), - ), - child: Icon(icon, color: color, size: 20), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(titulo, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary)), - const SizedBox(height: 3), - Text(descripcion, - style: const TextStyle( - fontSize: 12, color: AppTheme.textSecondary)), - ], - ), - ), - const SizedBox(width: 8), - Text(hora, - style: const TextStyle(fontSize: 11, color: AppTheme.textHint)), - ], - ), - ); - } -} - -// ── Widgets de Rutas ────────────────────────────────────────────────────────── - -class _ResumenRutas extends StatelessWidget { - final int total; - final int activas; - const _ResumenRutas({required this.total, required this.activas}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [AppTheme.primary, AppTheme.primaryDark], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(AppTheme.radiusLg), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _ResumenItem(label: 'Total', value: '$total'), - _Divider(), - _ResumenItem(label: 'Activas', value: '$activas'), - _Divider(), - _ResumenItem(label: 'Inactivas', value: '${total - activas}'), - ], - ), - ); - } -} - -class _ResumenItem extends StatelessWidget { - final String label; - final String value; - const _ResumenItem({required this.label, required this.value}); - - @override - Widget build(BuildContext context) { - return Column( - children: [ - Text(value, - style: const TextStyle( - fontSize: 28, - fontWeight: FontWeight.w800, - color: Colors.white)), - const SizedBox(height: 2), - Text(label, - style: const TextStyle(fontSize: 12, color: Colors.white70)), - ], - ); - } -} - -class _Divider extends StatelessWidget { - @override - Widget build(BuildContext context) { - return Container(width: 1, height: 36, color: Colors.white24); - } -} - -class _RouteCard extends StatelessWidget { - final RouteModel ruta; - const _RouteCard({required this.ruta}); - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.only(bottom: 12), - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(AppTheme.radiusLg), - border: Border.all( - color: ruta.activa ? AppTheme.border : AppTheme.borderLight, - width: 0.5, - ), - boxShadow: AppTheme.softShadow, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(14), - child: Row( - children: [ - Container( - width: 42, - height: 42, - decoration: BoxDecoration( - color: ruta.activa - ? AppTheme.primaryLight - : const Color(0xFFF1EFE8), - borderRadius: BorderRadius.circular(10), - ), - child: Icon( - Icons.route_outlined, - color: - ruta.activa ? AppTheme.primary : AppTheme.textSecondary, - size: 22, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(ruta.nombre, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - color: AppTheme.textPrimary)), - const SizedBox(height: 2), - Text('${ruta.totalCasas} casas', - style: const TextStyle( - fontSize: 12, color: AppTheme.textSecondary)), - ], - ), - ), - w.StatusBadge( - label: ruta.activa ? 'Activa' : 'Inactiva', - backgroundColor: ruta.activa - ? AppTheme.primaryLight - : const Color(0xFFF1EFE8), - textColor: ruta.activa - ? AppTheme.primaryDark - : const Color(0xFF5F5E5A), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(14, 0, 14, 0), - child: Column( - children: [ - _RouteInfoRow( - icon: Icons.location_on_outlined, text: ruta.zona), - const SizedBox(height: 6), - _RouteInfoRow( - icon: Icons.schedule_outlined, text: ruta.horario), - ], - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(10, 12, 10, 10), - child: Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () {}, - style: OutlinedButton.styleFrom( - foregroundColor: AppTheme.primary, - side: const BorderSide(color: AppTheme.primary), - padding: const EdgeInsets.symmetric(vertical: 9), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.radiusSm), - ), - ), - icon: const Icon(Icons.edit_outlined, size: 15), - label: const Text('Editar', - style: TextStyle( - fontSize: 13, fontWeight: FontWeight.w600)), - ), - ), - const SizedBox(width: 8), - Expanded( - child: OutlinedButton.icon( - onPressed: () {}, - style: OutlinedButton.styleFrom( - foregroundColor: AppTheme.blue, - side: const BorderSide(color: AppTheme.blue), - padding: const EdgeInsets.symmetric(vertical: 9), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.radiusSm), - ), - ), - icon: const Icon(Icons.map_outlined, size: 15), - label: const Text('Ver mapa', - style: TextStyle( - fontSize: 13, fontWeight: FontWeight.w600)), - ), - ), - ], - ), - ), - ], - ), - ); - } -} - -class _RouteInfoRow extends StatelessWidget { - final IconData icon; - final String text; - const _RouteInfoRow({required this.icon, required this.text}); - - @override - Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(icon, size: 13, color: AppTheme.textSecondary), - const SizedBox(width: 6), - Expanded( - child: Text(text, - style: const TextStyle( - fontSize: 12, color: AppTheme.textSecondary, height: 1.4)), - ), - ], - ); - } -} - -// ── Widgets de Reportes ─────────────────────────────────────────────────────── - -class _PeriodSelector extends StatefulWidget { - @override - State<_PeriodSelector> createState() => _PeriodSelectorState(); -} - -class _PeriodSelectorState extends State<_PeriodSelector> { - int _selected = 0; - final List _opciones = ['Esta semana', 'Este mes', 'Último mes']; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(AppTheme.radiusMd), - border: Border.all(color: AppTheme.border, width: 0.5), - ), - child: Row( - children: List.generate(_opciones.length, (i) { - final selected = i == _selected; - return Expanded( - child: GestureDetector( - onTap: () => setState(() => _selected = i), - child: AnimatedContainer( - duration: const Duration(milliseconds: 180), - padding: const EdgeInsets.symmetric(vertical: 9), - decoration: BoxDecoration( - color: selected ? AppTheme.primary : Colors.transparent, - borderRadius: BorderRadius.circular(AppTheme.radiusSm), - ), - child: Text( - _opciones[i], - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: selected ? Colors.white : AppTheme.textSecondary), - ), - ), - ), - ); - }), - ), - ); - } -} - -class _StatsRow extends StatelessWidget { - @override - Widget build(BuildContext context) { - return Row( - children: [ - Expanded( - child: _StatBox( - label: 'Alertas\nenviadas', - value: '370', - icon: Icons.notifications_active_outlined), - ), - const SizedBox(width: 8), - Expanded( - child: _StatBox( - label: 'Rutas\ncompletadas', - value: '18', - icon: Icons.check_circle_outline), - ), - const SizedBox(width: 8), - Expanded( - child: _StatBox( - label: 'Nuevos\nusuarios', - value: '24', - icon: Icons.person_add_outlined), - ), - ], - ); - } -} - -class _StatBox extends StatelessWidget { - final String label; - final String value; - final IconData icon; - const _StatBox( - {required this.label, required this.value, required this.icon}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 10), - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(AppTheme.radiusMd), - border: Border.all(color: AppTheme.border, width: 0.5), - boxShadow: AppTheme.softShadow, - ), - child: Column( - children: [ - Icon(icon, color: AppTheme.primary, size: 22), - const SizedBox(height: 6), - Text(value, - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.w800, - color: AppTheme.textPrimary)), - const SizedBox(height: 4), - Text(label, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 10, color: AppTheme.textSecondary, height: 1.4)), - ], - ), - ); - } -} - -class _AlertsBarChart extends StatelessWidget { - // Datos ficticios: alertas por día (Lun–Dom) - static const _data = [52, 38, 71, 45, 60, 87, 17]; - static const _dias = ['L', 'M', 'M', 'J', 'V', 'S', 'D']; - - @override - Widget build(BuildContext context) { - final maxVal = _data.reduce((a, b) => a > b ? a : b).toDouble(); - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(AppTheme.radiusLg), - border: Border.all(color: AppTheme.border, width: 0.5), - boxShadow: AppTheme.softShadow, - ), - child: SizedBox( - height: 120, - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: List.generate(_data.length, (i) { - final pct = _data[i] / maxVal; - final isMax = _data[i] == maxVal.toInt(); - return Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 3), - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (isMax) - Text( - '${_data[i]}', - style: const TextStyle( - fontSize: 10, - fontWeight: FontWeight.w700, - color: AppTheme.primary), - ), - const SizedBox(height: 2), - AnimatedContainer( - duration: const Duration(milliseconds: 600), - height: 80 * pct, - decoration: BoxDecoration( - color: isMax ? AppTheme.primary : AppTheme.primaryLight, - borderRadius: BorderRadius.circular(4), - ), - ), - const SizedBox(height: 6), - Text(_dias[i], - style: const TextStyle( - fontSize: 11, color: AppTheme.textSecondary)), - ], - ), - ), - ); - }), - ), - ), - ); - } -} - -class _TopRouteTile extends StatelessWidget { - final String ruta; - final int alertas; - final double porcentaje; - final int posicion; - - const _TopRouteTile({ - required this.ruta, - required this.alertas, - required this.porcentaje, - required this.posicion, - }); - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(AppTheme.radiusMd), - border: Border.all(color: AppTheme.border, width: 0.5), - boxShadow: AppTheme.softShadow, - ), - child: Column( - children: [ - Row( - children: [ - Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: posicion == 1 - ? AppTheme.primaryLight - : AppTheme.background, - shape: BoxShape.circle, - ), - child: Center( - child: Text( - '#$posicion', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w800, - color: posicion == 1 - ? AppTheme.primary - : AppTheme.textSecondary), - ), - ), - ), - const SizedBox(width: 10), - Expanded( - child: Text(ruta, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary)), - ), - Text('$alertas alertas', - style: const TextStyle( - fontSize: 12, color: AppTheme.textSecondary)), - ], - ), - const SizedBox(height: 8), - ClipRRect( - borderRadius: BorderRadius.circular(AppTheme.radiusFull), - child: LinearProgressIndicator( - value: porcentaje, - minHeight: 6, - backgroundColor: AppTheme.primaryLight, - valueColor: const AlwaysStoppedAnimation(AppTheme.primary), - ), - ), - ], - ), - ); - } -} - -// ── Bottom Sheets (formularios) ─────────────────────────────────────────────── - -class _RouteFormSheet extends StatelessWidget { - const _RouteFormSheet(); - - @override - Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only( - top: 16, - left: 20, - right: 20, - bottom: MediaQuery.of(context).viewInsets.bottom + 24, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 36, - height: 4, - decoration: BoxDecoration( - color: AppTheme.border, - borderRadius: BorderRadius.circular(AppTheme.radiusFull), - ), - ), - ), - const SizedBox(height: 16), - const Text('Nueva ruta', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: AppTheme.textPrimary)), - const SizedBox(height: 20), - w.FormField(label: 'Nombre de la ruta', hint: 'Ej. Ruta Poniente'), - const SizedBox(height: 12), - w.FormField( - label: 'Zona / Colonias', - hint: 'Col. Las Palmas, Col. Primavera…', - maxLines: 2), - const SizedBox(height: 12), - w.FormField(label: 'Horario', hint: 'Ej. Lun–Vie 7:00 – 10:00 a.m.'), - const SizedBox(height: 20), - SizedBox( - width: double.infinity, - height: 50, - child: ElevatedButton( - onPressed: () => Navigator.pop(context), - child: const Text('Guardar ruta'), - ), - ), - ], - ), - ); - } -} - -class _DriverFormSheet extends StatelessWidget { - const _DriverFormSheet(); - - @override - Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only( - top: 16, - left: 20, - right: 20, - bottom: MediaQuery.of(context).viewInsets.bottom + 24, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 36, - height: 4, - decoration: BoxDecoration( - color: AppTheme.border, - borderRadius: BorderRadius.circular(AppTheme.radiusFull), - ), - ), - ), - const SizedBox(height: 16), - const Text('Nuevo chofer', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: AppTheme.textPrimary)), - const SizedBox(height: 20), - Row( - children: [ - Expanded(child: w.FormField(label: 'Nombre', hint: 'Miguel')), - const SizedBox(width: 12), - Expanded( - child: w.FormField(label: 'Apellido', hint: 'Hernández')), - ], - ), - const SizedBox(height: 12), - w.FormField( - label: 'Teléfono', - hint: '+52 461 100 0000', - keyboardType: TextInputType.phone), - const SizedBox(height: 12), - w.FormField(label: 'Ruta asignada', hint: 'Ej. Ruta Norte'), - const SizedBox(height: 20), - SizedBox( - width: double.infinity, - height: 50, - child: ElevatedButton( - onPressed: () => Navigator.pop(context), - child: const Text('Guardar chofer'), - ), - ), - ], - ), - ); - } -} diff --git a/views_v1/alerts_screen.dart b/views_v1/alerts_screen.dart index 268346f..7dd0df5 100644 --- a/views_v1/alerts_screen.dart +++ b/views_v1/alerts_screen.dart @@ -12,7 +12,7 @@ class AlertsScreen extends StatefulWidget { class _AlertsScreenState extends State { // Alerta activa de ejemplo - final AlertaModel _alertaActiva = AlertaModel( + final AlertaModel? _alertaActiva = AlertaModel( id: 'alerta-001', tipo: TipoAlerta.cercana, distanciaMetros: 180, @@ -220,7 +220,7 @@ class _AlertaActivaCard extends StatelessWidget { borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( value: progreso, - backgroundColor: AppTheme.primaryMid.withValues(alpha: 0.4), + backgroundColor: AppTheme.primaryMid.withOpacity(0.4), valueColor: const AlwaysStoppedAnimation(AppTheme.primary), minHeight: 7, ), diff --git a/views_v1/driver_screen.dart b/views_v1/driver_screen.dart deleted file mode 100644 index f85b4f8..0000000 --- a/views_v1/driver_screen.dart +++ /dev/null @@ -1,1631 +0,0 @@ -import 'dart:async'; -import 'package:flutter/material.dart'; -import '../theme/app_theme.dart'; -import '../models/models.dart'; -import '../widgets/widgets.dart' as w; - -// ── Modelo de parada ────────────────────────────────────────────────────────── - -enum EstadoParada { pendiente, enCamino, completada, saltada } - -class StopModel { - final String id; - final String direccion; - final String colonia; - final String referencias; - final int orden; - EstadoParada estado; - - StopModel({ - required this.id, - required this.direccion, - required this.colonia, - required this.referencias, - required this.orden, - this.estado = EstadoParada.pendiente, - }); -} - -// ── Shell principal del Chofer ───────────────────────────────────────────────── - -class DriverShell extends StatefulWidget { - const DriverShell({super.key}); - - @override - State createState() => _DriverShellState(); -} - -class _DriverShellState extends State { - int _currentIndex = 0; - - final List _screens = const [ - DriverRouteScreen(), - DriverStopsScreen(), - DriverHistoryScreen(), - DriverProfileScreen(), - ]; - - @override - Widget build(BuildContext context) { - return Scaffold( - body: IndexedStack(index: _currentIndex, children: _screens), - bottomNavigationBar: BottomNavigationBar( - currentIndex: _currentIndex, - onTap: (i) => setState(() => _currentIndex = i), - type: BottomNavigationBarType.fixed, - backgroundColor: AppTheme.surface, - selectedItemColor: AppTheme.primary, - unselectedItemColor: AppTheme.textSecondary, - selectedFontSize: 11, - unselectedFontSize: 11, - elevation: 12, - items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.map_outlined), - activeIcon: Icon(Icons.map), - label: 'Mi ruta', - ), - BottomNavigationBarItem( - icon: Icon(Icons.list_alt_outlined), - activeIcon: Icon(Icons.list_alt), - label: 'Paradas', - ), - BottomNavigationBarItem( - icon: Icon(Icons.history_outlined), - activeIcon: Icon(Icons.history), - label: 'Historial', - ), - BottomNavigationBarItem( - icon: Icon(Icons.person_outline), - activeIcon: Icon(Icons.person), - label: 'Perfil', - ), - ], - ), - ); - } -} - -// ── Pantalla principal: Mi ruta (estado del turno) ──────────────────────────── - -class DriverRouteScreen extends StatefulWidget { - const DriverRouteScreen({super.key}); - - @override - State createState() => _DriverRouteScreenState(); -} - -class _DriverRouteScreenState extends State { - bool _turnoActivo = false; - bool _enPausa = false; - Timer? _timer; - Duration _duracion = Duration.zero; - - // Datos de ejemplo - final TruckLocation _camion = TruckLocation( - id: 'truck-01', - ruta: 'Ruta Norte', - latitud: 20.5255, - longitud: -100.8220, - ultimaActualizacion: DateTime.now(), - enServicio: true, - ); - - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } - - void _iniciarTurno() { - setState(() { - _turnoActivo = true; - _enPausa = false; - }); - _timer = Timer.periodic(const Duration(seconds: 1), (_) { - if (!_enPausa && mounted) { - setState(() => _duracion += const Duration(seconds: 1)); - } - }); - } - - void _pausarReanudar() { - setState(() => _enPausa = !_enPausa); - } - - void _finalizarTurno() { - showDialog( - context: context, - builder: (_) => AlertDialog( - backgroundColor: AppTheme.surface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.radiusLg)), - title: const Text('Finalizar turno', - style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.w700, - color: AppTheme.textPrimary)), - content: const Text( - '¿Confirmas que has terminado el recorrido de hoy?', - style: - TextStyle(fontSize: 14, color: AppTheme.textSecondary), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - style: TextButton.styleFrom( - foregroundColor: AppTheme.textSecondary), - child: const Text('Cancelar'), - ), - TextButton( - onPressed: () { - Navigator.pop(context); - _timer?.cancel(); - setState(() { - _turnoActivo = false; - _enPausa = false; - _duracion = Duration.zero; - }); - }, - style: - TextButton.styleFrom(foregroundColor: AppTheme.danger), - child: const Text('Finalizar', - style: TextStyle(fontWeight: FontWeight.w600)), - ), - ], - ), - ); - } - - String get _tiempoFormateado { - final h = _duracion.inHours.toString().padLeft(2, '0'); - final m = (_duracion.inMinutes % 60).toString().padLeft(2, '0'); - final s = (_duracion.inSeconds % 60).toString().padLeft(2, '0'); - return '$h:$m:$s'; - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppTheme.background, - appBar: AppBar( - title: Row( - children: [ - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(AppTheme.radiusSm), - ), - child: const Icon(Icons.directions_bus_rounded, - color: Colors.white, size: 18), - ), - const SizedBox(width: 10), - const Text('Panel del chofer'), - ], - ), - actions: [ - if (_turnoActivo) - Container( - margin: const EdgeInsets.only(right: 12), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(AppTheme.radiusFull), - ), - child: Row( - children: [ - Container( - width: 7, - height: 7, - decoration: BoxDecoration( - color: _enPausa - ? Colors.amber - : const Color(0xFF7AFFC5), - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 6), - Text( - _enPausa ? 'Pausado' : 'En servicio', - style: const TextStyle( - fontSize: 12, - color: Colors.white, - fontWeight: FontWeight.w600), - ), - ], - ), - ), - ], - ), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - // ── Datos del chofer ────────────────────────────────────── - _DriverInfoBanner(ruta: _camion.ruta), - - const SizedBox(height: 16), - - // ── Cronómetro / estado de turno ────────────────────────── - _TurnoCronometro( - turnoActivo: _turnoActivo, - enPausa: _enPausa, - tiempo: _tiempoFormateado, - ), - - const SizedBox(height: 16), - - // ── Botones de control ──────────────────────────────────── - if (!_turnoActivo) - SizedBox( - width: double.infinity, - height: 52, - child: ElevatedButton.icon( - onPressed: _iniciarTurno, - icon: const Icon(Icons.play_circle_outline_rounded), - label: const Text('Iniciar turno'), - ), - ) - else - Row( - children: [ - Expanded( - child: SizedBox( - height: 52, - child: OutlinedButton.icon( - onPressed: _pausarReanudar, - style: OutlinedButton.styleFrom( - foregroundColor: AppTheme.amber, - side: const BorderSide(color: AppTheme.amber), - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(AppTheme.radiusMd), - ), - ), - icon: Icon(_enPausa - ? Icons.play_circle_outline_rounded - : Icons.pause_circle_outline_rounded), - label: Text(_enPausa ? 'Reanudar' : 'Pausar', - style: const TextStyle( - fontWeight: FontWeight.w600)), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: SizedBox( - height: 52, - child: ElevatedButton.icon( - onPressed: _finalizarTurno, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.danger), - icon: const Icon(Icons.stop_circle_outlined), - label: const Text('Finalizar', - style: - TextStyle(fontWeight: FontWeight.w600)), - ), - ), - ), - ], - ), - - const SizedBox(height: 24), - - // ── Estadísticas del día ────────────────────────────────── - w.SectionTitle(title: 'Hoy'), - Row( - children: [ - Expanded( - child: _SmallStatCard( - icon: Icons.location_on_outlined, - label: 'Paradas', - value: '14 / 22', - color: AppTheme.primary, - bgColor: AppTheme.primaryLight, - ), - ), - const SizedBox(width: 12), - Expanded( - child: _SmallStatCard( - icon: Icons.notifications_active_outlined, - label: 'Alertas enviadas', - value: '61', - color: AppTheme.blue, - bgColor: AppTheme.blueLight, - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: _SmallStatCard( - icon: Icons.access_time_outlined, - label: 'Tiempo en ruta', - value: _turnoActivo ? _tiempoFormateado : '--:--:--', - color: AppTheme.amber, - bgColor: AppTheme.amberLight, - ), - ), - const SizedBox(width: 12), - Expanded( - child: _SmallStatCard( - icon: Icons.speed_outlined, - label: 'Velocidad prom.', - value: '12 km/h', - color: AppTheme.primaryDark, - bgColor: AppTheme.primaryLight, - ), - ), - ], - ), - - const SizedBox(height: 24), - - // ── Próxima parada ──────────────────────────────────────── - w.SectionTitle(title: 'Próxima parada'), - _ProximaParadaCard(), - - const SizedBox(height: 24), - - // ── Acciones rápidas ────────────────────────────────────── - w.SectionTitle(title: 'Acciones rápidas'), - w.MenuTile( - icon: Icons.report_problem_outlined, - title: 'Reportar incidencia', - subtitle: 'Tráfico, avería, desvío…', - onTap: () => _mostrarReporteIncidencia(context), - ), - w.MenuTile( - icon: Icons.local_gas_station_outlined, - title: 'Registrar carga de combustible', - onTap: () {}, - ), - w.MenuTile( - icon: Icons.phone_in_talk_outlined, - title: 'Contactar a Control', - subtitle: '+52 461 800 0000', - onTap: () {}, - ), - - const SizedBox(height: 32), - ], - ), - ); - } - - void _mostrarReporteIncidencia(BuildContext context) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: AppTheme.surface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(AppTheme.radiusXl)), - ), - builder: (_) => const _IncidentReportSheet(), - ); - } -} - -// ── Pantalla de Paradas ─────────────────────────────────────────────────────── - -class DriverStopsScreen extends StatefulWidget { - const DriverStopsScreen({super.key}); - - @override - State createState() => _DriverStopsScreenState(); -} - -class _DriverStopsScreenState extends State { - late List _paradas; - - @override - void initState() { - super.initState(); - _paradas = [ - StopModel( - id: 's-01', - direccion: 'Av. Insurgentes 245', - colonia: 'Col. Centro', - referencias: 'Casa esquina, portón azul', - orden: 1, - estado: EstadoParada.completada), - StopModel( - id: 's-02', - direccion: 'Calle Morelos 18', - colonia: 'Col. Centro', - referencias: 'Frente a la farmacia', - orden: 2, - estado: EstadoParada.completada), - StopModel( - id: 's-03', - direccion: 'Privada Las Flores 7', - colonia: 'Col. Las Palmas', - referencias: 'Entrada sin número', - orden: 3, - estado: EstadoParada.enCamino), - StopModel( - id: 's-04', - direccion: 'Blvd. Torres Landa 310', - colonia: 'Col. Las Palmas', - referencias: 'Edificio verde', - orden: 4, - estado: EstadoParada.pendiente), - StopModel( - id: 's-05', - direccion: 'Calle Hidalgo 89', - colonia: 'Col. Primavera', - referencias: 'Casa con árbol en la entrada', - orden: 5, - estado: EstadoParada.pendiente), - StopModel( - id: 's-06', - direccion: 'Av. Revolución 440', - colonia: 'Col. Primavera', - referencias: 'Condominio Piso 1', - orden: 6, - estado: EstadoParada.pendiente), - StopModel( - id: 's-07', - direccion: 'Calle Juárez 112', - colonia: 'Col. Los Pinos', - referencias: 'Casa color salmón', - orden: 7, - estado: EstadoParada.saltada), - ]; - } - - void _marcarCompletada(StopModel parada) { - setState(() => parada.estado = EstadoParada.completada); - } - - void _marcarSaltada(StopModel parada) { - setState(() => parada.estado = EstadoParada.saltada); - } - - int get _completadas => - _paradas.where((p) => p.estado == EstadoParada.completada).length; - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppTheme.background, - appBar: AppBar(title: const Text('Paradas de hoy')), - body: Column( - children: [ - // Barra de progreso - _ProgressHeader( - completadas: _completadas, total: _paradas.length), - - // Lista de paradas - Expanded( - child: ListView.builder( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 32), - itemCount: _paradas.length, - itemBuilder: (_, i) => _StopCard( - parada: _paradas[i], - onCompletada: () => _marcarCompletada(_paradas[i]), - onSaltada: () => _marcarSaltada(_paradas[i]), - ), - ), - ), - ], - ), - ); - } -} - -// ── Pantalla de Historial ───────────────────────────────────────────────────── - -class DriverHistoryScreen extends StatelessWidget { - const DriverHistoryScreen({super.key}); - - static const List> _historial = [ - { - 'fecha': 'Hoy', - 'ruta': 'Ruta Norte', - 'duracion': '2h 43min', - 'paradas': '14 / 22', - 'alertas': 61, - 'completada': false, - }, - { - 'fecha': 'Jue 22 may', - 'ruta': 'Ruta Norte', - 'duracion': '3h 12min', - 'paradas': '22 / 22', - 'alertas': 89, - 'completada': true, - }, - { - 'fecha': 'Mié 21 may', - 'ruta': 'Ruta Norte', - 'duracion': '2h 55min', - 'paradas': '22 / 22', - 'alertas': 74, - 'completada': true, - }, - { - 'fecha': 'Mar 20 may', - 'ruta': 'Ruta Norte', - 'duracion': '3h 05min', - 'paradas': '21 / 22', - 'alertas': 68, - 'completada': true, - }, - { - 'fecha': 'Lun 19 may', - 'ruta': 'Ruta Norte', - 'duracion': '2h 48min', - 'paradas': '22 / 22', - 'alertas': 85, - 'completada': true, - }, - ]; - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppTheme.background, - appBar: AppBar(title: const Text('Historial')), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - // Resumen semanal - _WeeklySummaryBanner(), - - const SizedBox(height: 20), - - w.SectionTitle(title: 'Recorridos recientes'), - ..._historial.map((h) => _HistoryCard(data: h)), - - const SizedBox(height: 32), - ], - ), - ); - } -} - -// ── Pantalla de Perfil del Chofer ───────────────────────────────────────────── - -class DriverProfileScreen extends StatelessWidget { - const DriverProfileScreen({super.key}); - - final DriverInfo _chofer = const DriverInfo( - nombre: 'Miguel', - apellido: 'Hernández', - telefono: '+52 461 100 0001', - ruta: 'Ruta Norte', - vehiculo: 'Camión #03 · MXX-483', - turno: '7:00 – 10:00 a.m.', - antiguedad: '3 años', - ); - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppTheme.background, - appBar: AppBar(title: const Text('Mi perfil')), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - // Header - _DriverProfileHeader(chofer: _chofer), - - const SizedBox(height: 20), - - // Mi turno - w.SectionTitle(title: 'Mi turno'), - w.InfoRow( - icon: Icons.route_outlined, - label: 'Ruta asignada', - value: _chofer.ruta), - const SizedBox(height: 8), - w.InfoRow( - icon: Icons.directions_bus_outlined, - label: 'Vehículo', - value: _chofer.vehiculo), - const SizedBox(height: 8), - w.InfoRow( - icon: Icons.schedule_outlined, - label: 'Horario', - value: _chofer.turno), - const SizedBox(height: 8), - w.InfoRow( - icon: Icons.work_outline_rounded, - label: 'Antigüedad', - value: _chofer.antiguedad), - - const SizedBox(height: 20), - - // Cuenta - w.SectionTitle(title: 'Mi cuenta'), - w.MenuTile( - icon: Icons.person_outline, - title: 'Editar datos personales', - onTap: () {}, - ), - w.MenuTile( - icon: Icons.lock_outline, - title: 'Cambiar contraseña', - onTap: () {}, - ), - w.MenuTile( - icon: Icons.phone_outlined, - title: 'Teléfono de emergencia', - subtitle: 'Agregar contacto', - onTap: () {}, - ), - - const SizedBox(height: 16), - - // Soporte - w.SectionTitle(title: 'Soporte'), - w.MenuTile( - icon: Icons.help_outline, - title: 'Manual del operador', - onTap: () {}, - ), - w.MenuTile( - icon: Icons.bug_report_outlined, - title: 'Reportar problema técnico', - onTap: () {}, - ), - - const SizedBox(height: 16), - - // Cerrar sesión - w.MenuTile( - icon: Icons.logout_rounded, - title: 'Cerrar sesión', - iconColor: AppTheme.danger, - titleColor: AppTheme.danger, - trailing: const SizedBox.shrink(), - onTap: () {}, - ), - - const SizedBox(height: 32), - - Center( - child: Text( - 'RutaVerde v1.0.0 · Chofer\nServicio de Limpia · Celaya, Gto.', - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 12, color: AppTheme.textHint, height: 1.6), - ), - ), - - const SizedBox(height: 24), - ], - ), - ); - } -} - -// ── Modelo simple para perfil del chofer ────────────────────────────────────── - -class DriverInfo { - final String nombre; - final String apellido; - final String telefono; - final String ruta; - final String vehiculo; - final String turno; - final String antiguedad; - - const DriverInfo({ - required this.nombre, - required this.apellido, - required this.telefono, - required this.ruta, - required this.vehiculo, - required this.turno, - required this.antiguedad, - }); - - String get nombreCompleto => '$nombre $apellido'; - String get iniciales => - '${nombre.isNotEmpty ? nombre[0] : ''}${apellido.isNotEmpty ? apellido[0] : ''}' - .toUpperCase(); -} - -// ───────────────────────────────────────────────────────────────────────────── -// WIDGETS INTERNOS -// ───────────────────────────────────────────────────────────────────────────── - -// ── Widgets de Mi ruta ──────────────────────────────────────────────────────── - -class _DriverInfoBanner extends StatelessWidget { - final String ruta; - const _DriverInfoBanner({required this.ruta}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [AppTheme.primary, AppTheme.primaryDark], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(AppTheme.radiusLg), - ), - child: Row( - children: [ - Container( - width: 52, - height: 52, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - shape: BoxShape.circle, - ), - child: const Center( - child: Text( - 'MH', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w800, - color: Colors.white), - ), - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Miguel Hernández', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: Colors.white), - ), - const SizedBox(height: 3), - Row( - children: [ - const Icon(Icons.route_outlined, - size: 13, color: Colors.white70), - const SizedBox(width: 4), - Text( - ruta, - style: const TextStyle( - fontSize: 12, color: Colors.white70), - ), - ], - ), - const SizedBox(height: 3), - Row( - children: [ - const Icon(Icons.directions_bus_outlined, - size: 13, color: Colors.white70), - const SizedBox(width: 4), - const Text( - 'Camión #03 · MXX-483', - style: TextStyle(fontSize: 12, color: Colors.white70), - ), - ], - ), - ], - ), - ), - ], - ), - ); - } -} - -class _TurnoCronometro extends StatelessWidget { - final bool turnoActivo; - final bool enPausa; - final String tiempo; - - const _TurnoCronometro({ - required this.turnoActivo, - required this.enPausa, - required this.tiempo, - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(AppTheme.radiusLg), - border: Border.all(color: AppTheme.border, width: 0.5), - boxShadow: AppTheme.softShadow, - ), - child: Column( - children: [ - Text( - turnoActivo ? (enPausa ? 'Turno pausado' : 'Turno activo') : 'Sin turno activo', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: turnoActivo - ? (enPausa ? AppTheme.amber : AppTheme.primary) - : AppTheme.textSecondary), - ), - const SizedBox(height: 10), - Text( - tiempo, - style: TextStyle( - fontSize: 40, - fontWeight: FontWeight.w800, - letterSpacing: 2, - color: turnoActivo - ? (enPausa ? AppTheme.amber : AppTheme.textPrimary) - : AppTheme.textHint), - ), - if (turnoActivo) ...[ - const SizedBox(height: 8), - Text( - enPausa ? 'El GPS sigue activo durante la pausa' : 'GPS activo · Enviando ubicación', - style: const TextStyle( - fontSize: 11, color: AppTheme.textSecondary), - ), - ], - ], - ), - ); - } -} - -class _SmallStatCard extends StatelessWidget { - final IconData icon; - final String label; - final String value; - final Color color; - final Color bgColor; - - const _SmallStatCard({ - required this.icon, - required this.label, - required this.value, - required this.color, - required this.bgColor, - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(AppTheme.radiusLg), - border: Border.all(color: AppTheme.border, width: 0.5), - boxShadow: AppTheme.softShadow, - ), - child: Row( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(10), - ), - child: Icon(icon, color: color, size: 20), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(value, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w800, - color: AppTheme.textPrimary)), - Text(label, - style: const TextStyle( - fontSize: 10, - color: AppTheme.textSecondary, - height: 1.3)), - ], - ), - ), - ], - ), - ); - } -} - -class _ProximaParadaCard extends StatelessWidget { - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(AppTheme.radiusLg), - border: Border.all(color: AppTheme.primary, width: 1.5), - boxShadow: AppTheme.cardShadow, - ), - child: Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: AppTheme.primaryLight, - borderRadius: BorderRadius.circular(10), - ), - child: const Icon(Icons.location_on_rounded, - color: AppTheme.primary, size: 24), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Privada Las Flores 7', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - color: AppTheme.textPrimary), - ), - const SizedBox(height: 2), - const Text( - 'Col. Las Palmas · Parada #3', - style: TextStyle( - fontSize: 12, color: AppTheme.textSecondary), - ), - const SizedBox(height: 5), - w.StatusBadge.green('~3 min'), - ], - ), - ), - IconButton( - onPressed: () {}, - icon: const Icon(Icons.open_in_new_rounded, - color: AppTheme.primary, size: 20), - ), - ], - ), - ); - } -} - -// ── Widgets de Paradas ──────────────────────────────────────────────────────── - -class _ProgressHeader extends StatelessWidget { - final int completadas; - final int total; - const _ProgressHeader({required this.completadas, required this.total}); - - @override - Widget build(BuildContext context) { - final pct = total > 0 ? completadas / total : 0.0; - return Container( - color: AppTheme.primary, - padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '$completadas de $total paradas', - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Colors.white), - ), - Text( - '${(pct * 100).toStringAsFixed(0)}%', - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w800, - color: Colors.white), - ), - ], - ), - const SizedBox(height: 8), - ClipRRect( - borderRadius: BorderRadius.circular(AppTheme.radiusFull), - child: LinearProgressIndicator( - value: pct, - minHeight: 8, - backgroundColor: Colors.white24, - valueColor: - const AlwaysStoppedAnimation(Colors.white), - ), - ), - ], - ), - ); - } -} - -class _StopCard extends StatelessWidget { - final StopModel parada; - final VoidCallback onCompletada; - final VoidCallback onSaltada; - - const _StopCard({ - required this.parada, - required this.onCompletada, - required this.onSaltada, - }); - - Color get _borderColor { - switch (parada.estado) { - case EstadoParada.completada: - return AppTheme.primaryMid; - case EstadoParada.enCamino: - return AppTheme.primary; - case EstadoParada.saltada: - return AppTheme.danger; - case EstadoParada.pendiente: - return AppTheme.border; - } - } - - Color get _iconBg { - switch (parada.estado) { - case EstadoParada.completada: - return AppTheme.primaryLight; - case EstadoParada.enCamino: - return AppTheme.primaryLight; - case EstadoParada.saltada: - return AppTheme.dangerLight; - case EstadoParada.pendiente: - return AppTheme.background; - } - } - - Color get _iconColor { - switch (parada.estado) { - case EstadoParada.completada: - return AppTheme.primary; - case EstadoParada.enCamino: - return AppTheme.primary; - case EstadoParada.saltada: - return AppTheme.danger; - case EstadoParada.pendiente: - return AppTheme.textSecondary; - } - } - - IconData get _icon { - switch (parada.estado) { - case EstadoParada.completada: - return Icons.check_circle_rounded; - case EstadoParada.enCamino: - return Icons.directions_bus_rounded; - case EstadoParada.saltada: - return Icons.cancel_outlined; - case EstadoParada.pendiente: - return Icons.location_on_outlined; - } - } - - String get _etiqueta { - switch (parada.estado) { - case EstadoParada.completada: - return 'Completada'; - case EstadoParada.enCamino: - return 'En camino'; - case EstadoParada.saltada: - return 'Saltada'; - case EstadoParada.pendiente: - return 'Pendiente'; - } - } - - bool get _esPendienteOEnCamino => - parada.estado == EstadoParada.pendiente || - parada.estado == EstadoParada.enCamino; - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.only(bottom: 10), - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(AppTheme.radiusLg), - border: Border.all(color: _borderColor, width: 0.8), - boxShadow: AppTheme.softShadow, - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(14), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Número de orden - Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: _iconBg, - shape: BoxShape.circle, - ), - child: Center( - child: Text( - '${parada.orden}', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w800, - color: _iconColor), - ), - ), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(parada.direccion, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary)), - const SizedBox(height: 2), - Text(parada.colonia, - style: const TextStyle( - fontSize: 12, color: AppTheme.textSecondary)), - if (parada.referencias.isNotEmpty) ...[ - const SizedBox(height: 4), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon(Icons.info_outline_rounded, - size: 12, color: AppTheme.textHint), - const SizedBox(width: 4), - Expanded( - child: Text(parada.referencias, - style: const TextStyle( - fontSize: 11, - color: AppTheme.textHint)), - ), - ], - ), - ], - ], - ), - ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: _iconBg, - borderRadius: BorderRadius.circular(AppTheme.radiusFull), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(_icon, size: 11, color: _iconColor), - const SizedBox(width: 4), - Text(_etiqueta, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: _iconColor)), - ], - ), - ), - ], - ), - ), - - // Acciones (solo si pendiente o en camino) - if (_esPendienteOEnCamino) ...[ - Divider(color: AppTheme.borderLight, height: 1), - Padding( - padding: const EdgeInsets.fromLTRB(10, 8, 10, 10), - child: Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: onCompletada, - style: OutlinedButton.styleFrom( - foregroundColor: AppTheme.primary, - side: const BorderSide(color: AppTheme.primary), - padding: const EdgeInsets.symmetric(vertical: 8), - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(AppTheme.radiusSm), - ), - ), - icon: const Icon(Icons.check_rounded, size: 15), - label: const Text('Completar', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600)), - ), - ), - const SizedBox(width: 8), - Expanded( - child: OutlinedButton.icon( - onPressed: onSaltada, - style: OutlinedButton.styleFrom( - foregroundColor: AppTheme.textSecondary, - side: - const BorderSide(color: AppTheme.border), - padding: const EdgeInsets.symmetric(vertical: 8), - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(AppTheme.radiusSm), - ), - ), - icon: const Icon(Icons.skip_next_rounded, size: 15), - label: const Text('Saltar', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600)), - ), - ), - ], - ), - ), - ], - ], - ), - ); - } -} - -// ── Widgets de Historial ────────────────────────────────────────────────────── - -class _WeeklySummaryBanner extends StatelessWidget { - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [AppTheme.primaryDark, AppTheme.primary], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(AppTheme.radiusLg), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Resumen semanal', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - color: Colors.white), - ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _SumItem(label: 'Turno activo', value: '4/5 días'), - _VertDiv(), - _SumItem(label: 'Alertas', value: '377'), - _VertDiv(), - _SumItem(label: 'Paradas', value: '101 / 110'), - ], - ), - ], - ), - ); - } -} - -class _SumItem extends StatelessWidget { - final String label; - final String value; - const _SumItem({required this.label, required this.value}); - - @override - Widget build(BuildContext context) { - return Column( - children: [ - Text(value, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w800, - color: Colors.white)), - const SizedBox(height: 2), - Text(label, - style: const TextStyle(fontSize: 11, color: Colors.white70)), - ], - ); - } -} - -class _VertDiv extends StatelessWidget { - @override - Widget build(BuildContext context) { - return Container(width: 1, height: 32, color: Colors.white24); - } -} - -class _HistoryCard extends StatelessWidget { - final Map data; - const _HistoryCard({required this.data}); - - @override - Widget build(BuildContext context) { - final completada = data['completada'] as bool; - return Container( - margin: const EdgeInsets.only(bottom: 10), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(AppTheme.radiusLg), - border: Border.all(color: AppTheme.border, width: 0.5), - boxShadow: AppTheme.softShadow, - ), - child: Row( - children: [ - Container( - width: 42, - height: 42, - decoration: BoxDecoration( - color: completada ? AppTheme.primaryLight : AppTheme.amberLight, - borderRadius: BorderRadius.circular(10), - ), - child: Icon( - completada - ? Icons.check_circle_outline_rounded - : Icons.timelapse_rounded, - color: completada ? AppTheme.primary : AppTheme.amber, - size: 22, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(data['fecha'] as String, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: AppTheme.textPrimary)), - const SizedBox(height: 2), - Text(data['ruta'] as String, - style: const TextStyle( - fontSize: 12, color: AppTheme.textSecondary)), - ], - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text(data['duracion'] as String, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: AppTheme.textPrimary)), - const SizedBox(height: 3), - Text( - '${data['paradas']} · ${data['alertas']} alertas', - style: const TextStyle( - fontSize: 11, color: AppTheme.textSecondary), - ), - ], - ), - ], - ), - ); - } -} - -// ── Widgets de Perfil del Chofer ────────────────────────────────────────────── - -class _DriverProfileHeader extends StatelessWidget { - final DriverInfo chofer; - const _DriverProfileHeader({required this.chofer}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [AppTheme.primary, AppTheme.primaryDark], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(AppTheme.radiusLg), - ), - child: Row( - children: [ - Container( - width: 60, - height: 60, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - shape: BoxShape.circle, - border: - Border.all(color: Colors.white38, width: 2), - ), - child: Center( - child: Text( - chofer.iniciales, - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.w800, - color: Colors.white), - ), - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(chofer.nombreCompleto, - style: const TextStyle( - fontSize: 17, - fontWeight: FontWeight.w700, - color: Colors.white)), - const SizedBox(height: 3), - Text(chofer.telefono, - style: const TextStyle( - fontSize: 12, color: Colors.white70)), - const SizedBox(height: 6), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: - BorderRadius.circular(AppTheme.radiusFull), - ), - child: const Text( - 'Operador certificado', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Colors.white), - ), - ), - ], - ), - ), - IconButton( - icon: const Icon(Icons.edit_outlined, - color: Colors.white70, size: 20), - onPressed: () {}, - ), - ], - ), - ); - } -} - -// ── Bottom Sheet: Reporte de incidencia ─────────────────────────────────────── - -class _IncidentReportSheet extends StatefulWidget { - const _IncidentReportSheet(); - - @override - State<_IncidentReportSheet> createState() => _IncidentReportSheetState(); -} - -class _IncidentReportSheetState extends State<_IncidentReportSheet> { - int _tipoSeleccionado = 0; - final List> _tipos = [ - {'icon': Icons.traffic_rounded, 'label': 'Tráfico'}, - {'icon': Icons.build_outlined, 'label': 'Avería'}, - {'icon': Icons.alt_route_rounded, 'label': 'Desvío'}, - {'icon': Icons.warning_amber_rounded, 'label': 'Otro'}, - ]; - - @override - Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only( - top: 16, - left: 20, - right: 20, - bottom: MediaQuery.of(context).viewInsets.bottom + 24, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 36, - height: 4, - decoration: BoxDecoration( - color: AppTheme.border, - borderRadius: - BorderRadius.circular(AppTheme.radiusFull), - ), - ), - ), - const SizedBox(height: 16), - const Text('Reportar incidencia', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: AppTheme.textPrimary)), - const SizedBox(height: 6), - const Text('El control será notificado de inmediato.', - style: TextStyle( - fontSize: 13, color: AppTheme.textSecondary)), - const SizedBox(height: 20), - - // Tipo de incidencia - w.SectionTitle(title: 'Tipo'), - Row( - children: List.generate(_tipos.length, (i) { - final sel = i == _tipoSeleccionado; - return Expanded( - child: GestureDetector( - onTap: () => setState(() => _tipoSeleccionado = i), - child: AnimatedContainer( - duration: const Duration(milliseconds: 160), - margin: EdgeInsets.only(right: i < 3 ? 8 : 0), - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: sel ? AppTheme.primaryLight : AppTheme.surface, - borderRadius: - BorderRadius.circular(AppTheme.radiusMd), - border: Border.all( - color: sel ? AppTheme.primary : AppTheme.border, - width: sel ? 1.5 : 0.5, - ), - ), - child: Column( - children: [ - Icon( - _tipos[i]['icon'] as IconData, - color: sel - ? AppTheme.primary - : AppTheme.textSecondary, - size: 22, - ), - const SizedBox(height: 5), - Text( - _tipos[i]['label'] as String, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: sel - ? AppTheme.primary - : AppTheme.textSecondary), - ), - ], - ), - ), - ), - ); - }), - ), - - const SizedBox(height: 16), - - w.FormField( - label: 'Descripción (opcional)', - hint: 'Cuéntanos qué está pasando…', - maxLines: 3, - ), - - const SizedBox(height: 20), - - SizedBox( - width: double.infinity, - height: 50, - child: ElevatedButton.icon( - onPressed: () => Navigator.pop(context), - icon: const Icon(Icons.send_rounded), - label: const Text('Enviar reporte'), - ), - ), - ], - ), - ); - } -} diff --git a/views_v1/login_screen.dart b/views_v1/login_screen.dart index ca3c4b7..cc4366c 100644 --- a/views_v1/login_screen.dart +++ b/views_v1/login_screen.dart @@ -1,12 +1,8 @@ import 'package:flutter/material.dart'; import '../theme/app_theme.dart'; import '../widgets/widgets.dart' as w; -import 'admin_screen.dart'; -import 'driver_screen.dart'; import 'main_shell.dart'; -enum UserRole { usuario, conductor, administrador } - class LoginScreen extends StatefulWidget { const LoginScreen({super.key}); @@ -18,7 +14,6 @@ class _LoginScreenState extends State { final _formKey = GlobalKey(); final _emailCtrl = TextEditingController(); final _passCtrl = TextEditingController(); - UserRole _selectedRole = UserRole.usuario; bool _obscurePass = true; bool _loading = false; @@ -37,22 +32,11 @@ class _LoginScreenState extends State { setState(() => _loading = false); Navigator.pushAndRemoveUntil( context, - MaterialPageRoute(builder: (_) => _homeForRole()), + MaterialPageRoute(builder: (_) => const MainShell()), (_) => false, ); } - Widget _homeForRole() { - switch (_selectedRole) { - case UserRole.conductor: - return const DriverShell(); - case UserRole.administrador: - return const AdminShell(); - case UserRole.usuario: - return const MainShell(); - } - } - @override Widget build(BuildContext context) { return Scaffold( @@ -135,37 +119,7 @@ class _LoginScreenState extends State { setState(() => _obscurePass = !_obscurePass), ), ), - const SizedBox(height: 16), - DropdownButtonFormField( - initialValue: _selectedRole, - decoration: InputDecoration( - labelText: 'Tipo de usuario', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - ), - contentPadding: - const EdgeInsets.symmetric(horizontal: 14, vertical: 16), - ), - items: const [ - DropdownMenuItem( - value: UserRole.usuario, - child: Text('Usuario'), - ), - DropdownMenuItem( - value: UserRole.conductor, - child: Text('Conductor'), - ), - DropdownMenuItem( - value: UserRole.administrador, - child: Text('Administrador'), - ), - ], - onChanged: (value) { - if (value != null) { - setState(() => _selectedRole = value); - } - }, - ), + const SizedBox(height: 10), Align( alignment: Alignment.centerRight, diff --git a/views_v1/map_screen.dart b/views_v1/map_screen.dart index 60e1c77..16537f6 100644 --- a/views_v1/map_screen.dart +++ b/views_v1/map_screen.dart @@ -33,7 +33,7 @@ class _MapScreenState extends State { enServicio: true, ); - final HouseModel _casa = HouseModel( + final HouseModel _casa = const HouseModel( id: 'casa-01', calle: 'Av. Insurgentes 245', colonia: 'Centro', @@ -45,6 +45,7 @@ class _MapScreenState extends State { Set _markers = {}; Set _circles = {}; + bool _mapLoaded = false; Timer? _refreshTimer; // Distancia simulada (metros) @@ -91,8 +92,8 @@ class _MapScreenState extends State { circleId: const CircleId('radio-alerta'), center: LatLng(_casa.latitud, _casa.longitud), radius: _casa.radioAlertaMetros.toDouble(), - fillColor: AppTheme.blue.withValues(alpha: 0.08), - strokeColor: AppTheme.blue.withValues(alpha: 0.4), + fillColor: AppTheme.blue.withOpacity(0.08), + strokeColor: AppTheme.blue.withOpacity(0.4), strokeWidth: 1, ), }; @@ -135,6 +136,7 @@ class _MapScreenState extends State { mapType: MapType.normal, onMapCreated: (c) { _mapController.complete(c); + setState(() => _mapLoaded = true); }, ), @@ -289,7 +291,7 @@ class _LiveBadgeState extends State<_LiveBadge> decoration: BoxDecoration( shape: BoxShape.circle, color: widget.activo - ? AppTheme.primary.withValues(alpha: 0.5 + _anim.value * 0.5) + ? AppTheme.primary.withOpacity(0.5 + _anim.value * 0.5) : AppTheme.textSecondary, ), ), @@ -356,7 +358,7 @@ class _ArrivalBar extends StatelessWidget { borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( value: progreso, - backgroundColor: AppTheme.primaryMid.withValues(alpha: 0.4), + backgroundColor: AppTheme.primaryMid.withOpacity(0.4), valueColor: const AlwaysStoppedAnimation(AppTheme.primary), minHeight: 6, diff --git a/views_v1/profile_screen.dart b/views_v1/profile_screen.dart index b56350a..f4b1d05 100644 --- a/views_v1/profile_screen.dart +++ b/views_v1/profile_screen.dart @@ -111,7 +111,9 @@ class ProfileScreen extends StatelessWidget { 'RutaVerde v1.0.0\nServicio de Limpia · Celaya, Gto.', textAlign: TextAlign.center, style: const TextStyle( - fontSize: 12, color: AppTheme.textHint, height: 1.6), + fontSize: 12, + color: AppTheme.textHint, + height: 1.6), ), ), @@ -140,8 +142,7 @@ class ProfileScreen extends StatelessWidget { actions: [ TextButton( onPressed: () => Navigator.pop(ctx), - style: - TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), + style: TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), child: const Text('Cancelar'), ), TextButton( diff --git a/views_v1/splash_screen.dart b/views_v1/splash_screen.dart index 540756e..1e7a0dd 100644 --- a/views_v1/splash_screen.dart +++ b/views_v1/splash_screen.dart @@ -67,7 +67,7 @@ class _SplashScreenState extends State width: 90, height: 90, decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.15), + color: Colors.white.withOpacity(0.15), borderRadius: BorderRadius.circular(AppTheme.radiusXl), ), @@ -103,7 +103,7 @@ class _SplashScreenState extends State textAlign: TextAlign.center, style: TextStyle( fontSize: 15, - color: Colors.white.withValues(alpha: 0.82), + color: Colors.white.withOpacity(0.82), height: 1.5, ), ), @@ -189,7 +189,7 @@ class _SplashScreenState extends State 'Servicio de Limpia · Celaya, Gto.', style: TextStyle( fontSize: 12, - color: Colors.white.withValues(alpha: 0.45), + color: Colors.white.withOpacity(0.45), ), ), @@ -214,9 +214,9 @@ class _FeatureChip extends StatelessWidget { return Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.12), + color: Colors.white.withOpacity(0.12), borderRadius: BorderRadius.circular(AppTheme.radiusMd), - border: Border.all(color: Colors.white.withValues(alpha: 0.2)), + border: Border.all(color: Colors.white.withOpacity(0.2)), ), child: Column( children: [