diff --git a/views_v1/admin_screen.dart b/views_v1/admin_screen.dart new file mode 100644 index 0000000..31573d6 --- /dev/null +++ b/views_v1/admin_screen.dart @@ -0,0 +1,2443 @@ +// 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 new file mode 100644 index 0000000..268346f --- /dev/null +++ b/views_v1/alerts_screen.dart @@ -0,0 +1,388 @@ +import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; +import '../models/models.dart'; +import '../widgets/widgets.dart' as w; + +class AlertsScreen extends StatefulWidget { + const AlertsScreen({super.key}); + + @override + State createState() => _AlertsScreenState(); +} + +class _AlertsScreenState extends State { + // Alerta activa de ejemplo + final AlertaModel _alertaActiva = AlertaModel( + id: 'alerta-001', + tipo: TipoAlerta.cercana, + distanciaMetros: 180, + fecha: DateTime.now(), + direccionCasa: 'Av. Insurgentes 245', + leida: false, + ); + + // Historial de ejemplo + final List _historial = [ + AlertaModel( + id: 'h-001', + tipo: TipoAlerta.cercana, + distanciaMetros: 200, + fecha: DateTime.now().subtract(const Duration(hours: 1)), + direccionCasa: 'Av. Insurgentes 245', + leida: true, + ), + AlertaModel( + id: 'h-002', + tipo: TipoAlerta.cercana, + distanciaMetros: 200, + fecha: DateTime.now().subtract(const Duration(days: 2, hours: 2)), + direccionCasa: 'Av. Insurgentes 245', + leida: true, + ), + AlertaModel( + id: 'h-003', + tipo: TipoAlerta.cercana, + distanciaMetros: 200, + fecha: DateTime.now().subtract(const Duration(days: 4, hours: 1, minutes: 30)), + direccionCasa: 'Av. Insurgentes 245', + leida: true, + ), + AlertaModel( + id: 'h-004', + tipo: TipoAlerta.cercana, + distanciaMetros: 200, + fecha: DateTime.now().subtract(const Duration(days: 7, hours: 3)), + direccionCasa: 'Av. Insurgentes 245', + leida: true, + ), + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.background, + appBar: AppBar( + title: const Text('Alertas'), + actions: [ + TextButton( + onPressed: () {}, + child: const Text('Limpiar', + style: TextStyle(color: Colors.white, fontSize: 13)), + ), + ], + ), + body: RefreshIndicator( + color: AppTheme.primary, + onRefresh: () async { + await Future.delayed(const Duration(milliseconds: 800)); + }, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + // ── Alerta activa ─────────────────────────────────────────── + if (_alertaActiva != null) ...[ + _AlertaActivaCard(alerta: _alertaActiva!), + const SizedBox(height: 20), + ], + + // ── Historial ──────────────────────────────────────────────── + if (_historial.isEmpty) + _EmptyState() + else ...[ + w.SectionTitle(title: 'Historial de alertas'), + ..._historial.map((a) => _AlertaHistorialItem(alerta: a)), + ], + ], + ), + ), + ); + } +} + +// ── Tarjeta de alerta activa ────────────────────────────────────────────────── +class _AlertaActivaCard extends StatelessWidget { + final AlertaModel alerta; + const _AlertaActivaCard({required this.alerta}); + + @override + Widget build(BuildContext context) { + final progreso = (1 - (alerta.distanciaMetros / 400)).clamp(0.0, 1.0); + + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: AppTheme.primaryLight, + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + border: Border.all(color: AppTheme.primaryMid), + boxShadow: AppTheme.softShadow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppTheme.primary, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.notifications_active, + color: Colors.white, size: 22), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('¡El camión está cerca!', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppTheme.primaryDark)), + Text(alerta.fechaFormateada, + style: const TextStyle( + fontSize: 12, color: AppTheme.primary)), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppTheme.primary, + borderRadius: BorderRadius.circular(20), + ), + child: const Text('Ahora', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.white)), + ), + ], + ), + + const SizedBox(height: 16), + + // Distancia + Text( + 'El camión se encuentra a', + style: const TextStyle( + fontSize: 13, color: AppTheme.primaryDark), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + alerta.distanciaTexto, + style: const TextStyle( + fontSize: 36, + fontWeight: FontWeight.w700, + color: AppTheme.primary, + height: 1.1), + ), + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Text( + 'de tu casa en ${alerta.direccionCasa}', + style: const TextStyle( + fontSize: 13, color: AppTheme.primaryDark), + ), + ), + ], + ), + + const SizedBox(height: 14), + + // Tiempo estimado + Row( + children: [ + const Text('Llegada estimada:', + style: TextStyle( + fontSize: 12, color: AppTheme.primaryDark)), + const SizedBox(width: 6), + Text( + alerta.tiempoEstimadoTexto, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppTheme.primary), + ), + ], + ), + + const SizedBox(height: 8), + + // Barra de progreso + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: progreso, + backgroundColor: AppTheme.primaryMid.withValues(alpha: 0.4), + valueColor: const AlwaysStoppedAnimation(AppTheme.primary), + minHeight: 7, + ), + ), + + const SizedBox(height: 4), + + Row( + children: const [ + Text('Lejos', + style: TextStyle(fontSize: 10, color: AppTheme.primary)), + Spacer(), + Text('Tu casa', + style: TextStyle(fontSize: 10, color: AppTheme.primary)), + ], + ), + + const SizedBox(height: 14), + + // Botón ver en mapa + GestureDetector( + onTap: () {}, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: AppTheme.primary, + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.map_outlined, color: Colors.white, size: 16), + SizedBox(width: 6), + Text('Ver en el mapa', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.white)), + ], + ), + ), + ), + ], + ), + ); + } +} + +// ── Ítem de historial ───────────────────────────────────────────────────────── +class _AlertaHistorialItem extends StatelessWidget { + final AlertaModel alerta; + const _AlertaHistorialItem({required this.alerta}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 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: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppTheme.background, + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.notifications_outlined, + color: AppTheme.textSecondary, size: 18), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Camión a ${alerta.distanciaTexto}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppTheme.textPrimary), + ), + const SizedBox(height: 2), + Text(alerta.fechaFormateada, + style: const TextStyle( + fontSize: 12, color: AppTheme.textSecondary)), + ], + ), + ), + _EtiquetaDia(texto: alerta.etiquetaFecha), + ], + ), + ); + } +} + +// ── Etiqueta de día ─────────────────────────────────────────────────────────── +class _EtiquetaDia extends StatelessWidget { + final String texto; + const _EtiquetaDia({required this.texto}); + + @override + Widget build(BuildContext context) { + final esHoy = texto == 'Hoy'; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: esHoy ? AppTheme.primaryLight : AppTheme.background, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + texto, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: esHoy ? AppTheme.primaryDark : AppTheme.textSecondary, + ), + ), + ); + } +} + +// ── Estado vacío ────────────────────────────────────────────────────────────── +class _EmptyState extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 60), + child: Column( + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppTheme.primaryLight, + shape: BoxShape.circle, + ), + child: const Icon(Icons.notifications_outlined, + color: AppTheme.primary, size: 34), + ), + const SizedBox(height: 16), + const Text('Sin alertas por ahora', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary)), + const SizedBox(height: 6), + const Text( + 'Te notificaremos cuando el camión\nesté cerca de tu casa.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, color: AppTheme.textSecondary, height: 1.5), + ), + ], + ), + ); + } +} diff --git a/views_v1/driver_screen.dart b/views_v1/driver_screen.dart new file mode 100644 index 0000000..f85b4f8 --- /dev/null +++ b/views_v1/driver_screen.dart @@ -0,0 +1,1631 @@ +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/house_screen.dart b/views_v1/house_screen.dart new file mode 100644 index 0000000..8410fc4 --- /dev/null +++ b/views_v1/house_screen.dart @@ -0,0 +1,495 @@ +import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; +import '../models/models.dart'; +import '../widgets/widgets.dart' as w; + +class MyHouseScreen extends StatefulWidget { + const MyHouseScreen({super.key}); + + @override + State createState() => _MyHouseScreenState(); +} + +class _MyHouseScreenState extends State { + HouseModel _casa = const HouseModel( + id: 'casa-01', + calle: 'Av. Insurgentes 245', + colonia: 'Centro', + codigoPostal: '38000', + latitud: 20.5226, + longitud: -100.8191, + radioAlertaMetros: 200, + alertaCercana: true, + alertaMedia: false, + recordatorioDiario: true, + ); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.background, + appBar: AppBar( + title: const Text('Mi casa'), + actions: [ + IconButton( + icon: const Icon(Icons.edit_outlined), + onPressed: () => _mostrarEditarDireccion(context), + tooltip: 'Editar dirección', + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + // ── Tarjeta de la casa ────────────────────────────────────── + _CasaCard(casa: _casa), + + const SizedBox(height: 16), + + // ── Configuración de radio ────────────────────────────────── + w.SectionTitle(title: 'Radio de alerta'), + _RadioAlertaCard( + radioActual: _casa.radioAlertaMetros, + onChanged: (v) => setState(() { + _casa = _casa.copyWith(radioAlertaMetros: v); + }), + ), + + const SizedBox(height: 16), + + // ── Notificaciones ────────────────────────────────────────── + w.SectionTitle(title: 'Notificaciones'), + _NotificacionesCard( + casa: _casa, + onAlertaCercanaChanged: (v) => + setState(() => _casa = _casa.copyWith(alertaCercana: v)), + onAlertaMediaChanged: (v) => + setState(() => _casa = _casa.copyWith(alertaMedia: v)), + onRecordatorioChanged: (v) => + setState(() => _casa = _casa.copyWith(recordatorioDiario: v)), + ), + + const SizedBox(height: 16), + + // ── Horario estimado ──────────────────────────────────────── + w.SectionTitle(title: 'Horario del camión'), + _HorarioCard(), + + const SizedBox(height: 16), + + // ── Agregar otra casa ─────────────────────────────────────── + GestureDetector( + onTap: () => _mostrarAgregarCasa(context), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + border: Border.all( + color: AppTheme.primaryMid, + width: 1, + style: BorderStyle.solid), + boxShadow: AppTheme.softShadow, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Icon(Icons.add_home_outlined, + color: AppTheme.primary, size: 20), + SizedBox(width: 8), + Text('Agregar otra dirección', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppTheme.primary)), + ], + ), + ), + ), + + const SizedBox(height: 24), + ], + ), + ); + } + + void _mostrarEditarDireccion(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: AppTheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(AppTheme.radiusXl)), + ), + builder: (_) => _EditarDireccionSheet(casa: _casa), + ); + } + + void _mostrarAgregarCasa(BuildContext context) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Funcionalidad próximamente disponible'), + behavior: SnackBarBehavior.floating, + backgroundColor: AppTheme.primary, + ), + ); + } +} + +// ── Tarjeta principal de la casa ────────────────────────────────────────────── +class _CasaCard extends StatelessWidget { + final HouseModel casa; + const _CasaCard({required this.casa}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + border: Border.all(color: AppTheme.primaryMid, width: 0.8), + boxShadow: AppTheme.softShadow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: AppTheme.primaryLight, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.home_outlined, + color: AppTheme.primary, size: 24), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(casa.alias, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary)), + const SizedBox(height: 2), + w.StatusBadge.green( + casa.activa ? 'Activa' : 'Inactiva'), + ], + ), + ), + IconButton( + icon: const Icon(Icons.more_vert, + color: AppTheme.textSecondary, size: 20), + onPressed: () {}, + ), + ], + ), + + const SizedBox(height: 14), + const Divider(color: AppTheme.borderLight), + const SizedBox(height: 10), + + // Detalles + _DetailRow( + icon: Icons.location_on_outlined, + text: casa.direccionCompleta, + ), + const SizedBox(height: 8), + _DetailRow( + icon: Icons.my_location_outlined, + text: + '${casa.latitud.toStringAsFixed(4)}, ${casa.longitud.toStringAsFixed(4)}', + ), + const SizedBox(height: 8), + _DetailRow( + icon: Icons.radar_outlined, + text: 'Alerta a ${casa.radioAlertaMetros} m de distancia', + ), + ], + ), + ); + } +} + +class _DetailRow extends StatelessWidget { + final IconData icon; + final String text; + const _DetailRow({required this.icon, required this.text}); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 15, color: AppTheme.textSecondary), + const SizedBox(width: 8), + Expanded( + child: Text(text, + style: const TextStyle( + fontSize: 13, color: AppTheme.textSecondary, height: 1.4)), + ), + ], + ); + } +} + +// ── Radio de alerta ─────────────────────────────────────────────────────────── +class _RadioAlertaCard extends StatelessWidget { + final int radioActual; + final ValueChanged onChanged; + const _RadioAlertaCard({required this.radioActual, required this.onChanged}); + + @override + Widget build(BuildContext context) { + 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: Column( + children: [200, 400, 600].map((dist) { + final selected = dist == radioActual; + return GestureDetector( + onTap: () => onChanged(dist), + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + decoration: BoxDecoration( + color: selected ? AppTheme.primaryLight : AppTheme.background, + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + border: Border.all( + color: selected ? AppTheme.primary : AppTheme.border, + width: selected ? 1.5 : 0.5, + ), + ), + child: Row( + children: [ + Icon( + selected + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + color: selected ? AppTheme.primary : AppTheme.border, + size: 18, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + '$dist metros', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: selected + ? AppTheme.primaryDark + : AppTheme.textPrimary, + ), + ), + ), + if (selected) + Text( + dist == 200 + ? '~2-3 min' + : dist == 400 + ? '~4-5 min' + : '~6-8 min', + style: const TextStyle( + fontSize: 12, + color: AppTheme.primary, + fontWeight: FontWeight.w500), + ), + ], + ), + ), + ); + }).toList(), + ), + ); + } +} + +// ── Notificaciones ──────────────────────────────────────────────────────────── +class _NotificacionesCard extends StatelessWidget { + final HouseModel casa; + final ValueChanged onAlertaCercanaChanged; + final ValueChanged onAlertaMediaChanged; + final ValueChanged onRecordatorioChanged; + + const _NotificacionesCard({ + required this.casa, + required this.onAlertaCercanaChanged, + required this.onAlertaMediaChanged, + required this.onRecordatorioChanged, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + 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: [ + w.LabeledSwitch( + label: 'Alerta cuando el camión esté cerca', + value: casa.alertaCercana, + onChanged: onAlertaCercanaChanged, + ), + const Divider(height: 1, color: AppTheme.borderLight), + w.LabeledSwitch( + label: 'Alerta a distancia media', + value: casa.alertaMedia, + onChanged: onAlertaMediaChanged, + ), + const Divider(height: 1, color: AppTheme.borderLight), + w.LabeledSwitch( + label: 'Recordatorio diario del horario', + value: casa.recordatorioDiario, + onChanged: onRecordatorioChanged, + ), + ], + ), + ); + } +} + +// ── Horario del camión ──────────────────────────────────────────────────────── +class _HorarioCard extends StatelessWidget { + final List<_HorarioDia> _dias = const [ + _HorarioDia(dia: 'Lunes', hora: '8:00 – 10:00 a.m.', activo: true), + _HorarioDia(dia: 'Martes', hora: '8:00 – 10:00 a.m.', activo: true), + _HorarioDia(dia: 'Miércoles', hora: 'Sin servicio', activo: false), + _HorarioDia(dia: 'Jueves', hora: '8:00 – 10:00 a.m.', activo: true), + _HorarioDia(dia: 'Viernes', hora: '8:00 – 10:00 a.m.', activo: true), + _HorarioDia(dia: 'Sábado', hora: '9:00 – 11:00 a.m.', activo: true), + _HorarioDia(dia: 'Domingo', hora: 'Sin servicio', activo: false), + ]; + + @override + Widget build(BuildContext context) { + 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: Column( + children: _dias.map((d) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 7), + child: Row( + children: [ + Text(d.dia, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: d.activo + ? AppTheme.textPrimary + : AppTheme.textSecondary)), + const Spacer(), + Text(d.hora, + style: TextStyle( + fontSize: 13, + color: d.activo + ? AppTheme.primary + : AppTheme.textSecondary)), + ], + ), + ); + }).toList(), + ), + ); + } +} + +class _HorarioDia { + final String dia; + final String hora; + final bool activo; + const _HorarioDia( + {required this.dia, required this.hora, required this.activo}); +} + +// ── Sheet de editar dirección ───────────────────────────────────────────────── +class _EditarDireccionSheet extends StatelessWidget { + final HouseModel casa; + const _EditarDireccionSheet({required this.casa}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + left: 24, right: 24, top: 24, + bottom: MediaQuery.of(context).viewInsets.bottom + 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Handle + Center( + child: Container( + width: 36, height: 4, + decoration: BoxDecoration( + color: AppTheme.border, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + const SizedBox(height: 20), + + const Text('Editar dirección', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary)), + const SizedBox(height: 20), + + w.FormField( + label: 'Calle y número', initialValue: casa.calle), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + flex: 3, + child: w.FormField( + label: 'Colonia', initialValue: casa.colonia), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: w.FormField( + label: 'C.P.', initialValue: casa.codigoPostal), + ), + ], + ), + const SizedBox(height: 24), + + SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + onPressed: () => Navigator.pop(context), + child: const Text('Guardar cambios'), + ), + ), + ], + ), + ); + } +} diff --git a/views_v1/login_screen.dart b/views_v1/login_screen.dart new file mode 100644 index 0000000..ca3c4b7 --- /dev/null +++ b/views_v1/login_screen.dart @@ -0,0 +1,290 @@ +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}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _formKey = GlobalKey(); + final _emailCtrl = TextEditingController(); + final _passCtrl = TextEditingController(); + UserRole _selectedRole = UserRole.usuario; + bool _obscurePass = true; + bool _loading = false; + + @override + void dispose() { + _emailCtrl.dispose(); + _passCtrl.dispose(); + super.dispose(); + } + + Future _login() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _loading = true); + await Future.delayed(const Duration(seconds: 1)); // Simular petición + if (!mounted) return; + setState(() => _loading = false); + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (_) => _homeForRole()), + (_) => 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( + backgroundColor: AppTheme.background, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + iconTheme: const IconThemeData(color: AppTheme.textPrimary), + title: const Text( + 'Iniciar sesión', + style: TextStyle(color: AppTheme.textPrimary, fontSize: 16), + ), + ), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + + // ── Encabezado ───────────────────────────────────────── + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppTheme.primaryLight, + borderRadius: + BorderRadius.circular(AppTheme.radiusMd), + ), + child: const Icon(Icons.delete_outline_rounded, + color: AppTheme.primary, size: 26), + ), + const SizedBox(width: 14), + const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('RutaVerde', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary)), + Text('Bienvenido de nuevo', + style: TextStyle( + fontSize: 13, + color: AppTheme.textSecondary)), + ], + ), + ], + ), + + const SizedBox(height: 32), + + // ── Formulario ───────────────────────────────────────── + w.FormField( + label: 'Correo electrónico', + hint: 'tu@correo.com', + controller: _emailCtrl, + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 16), + w.FormField( + label: 'Contraseña', + hint: '••••••••', + controller: _passCtrl, + obscureText: _obscurePass, + suffix: IconButton( + icon: Icon( + _obscurePass + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + size: 18, + color: AppTheme.textSecondary, + ), + onPressed: () => + 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, + child: TextButton( + onPressed: () {}, + style: TextButton.styleFrom( + foregroundColor: AppTheme.primary), + child: const Text('¿Olvidaste tu contraseña?', + style: TextStyle(fontSize: 13)), + ), + ), + + const SizedBox(height: 24), + + // ── Botón ingresar ────────────────────────────────────── + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: _loading ? null : _login, + child: _loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Text('Ingresar'), + ), + ), + + const SizedBox(height: 28), + + // ── Divisor ───────────────────────────────────────────── + Row( + children: [ + const Expanded(child: Divider(color: AppTheme.border)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text('o', + style: TextStyle( + fontSize: 13, color: AppTheme.textSecondary)), + ), + const Expanded(child: Divider(color: AppTheme.border)), + ], + ), + + const SizedBox(height: 20), + + // ── Continuar con Google ──────────────────────────────── + _SocialButton( + icon: Icons.g_mobiledata_rounded, + label: 'Continuar con Google', + onTap: () {}, + ), + + const SizedBox(height: 36), + + // ── Crear cuenta ──────────────────────────────────────── + Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('¿No tienes cuenta? ', + style: TextStyle( + fontSize: 13, color: AppTheme.textSecondary)), + GestureDetector( + onTap: () => Navigator.pop(context), + child: const Text('Regístrate', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppTheme.primary)), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _SocialButton extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + + const _SocialButton( + {required this.icon, required this.label, required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 13), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + border: Border.all(color: AppTheme.border), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 22, color: AppTheme.textPrimary), + const SizedBox(width: 10), + Text(label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppTheme.textPrimary)), + ], + ), + ), + ); + } +} diff --git a/views_v1/main_shell.dart b/views_v1/main_shell.dart new file mode 100644 index 0000000..cb9eb8d --- /dev/null +++ b/views_v1/main_shell.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import '../widgets/widgets.dart' as w; +import 'map_screen.dart'; +import 'alerts_screen.dart'; +import 'house_screen.dart'; +import 'profile_screen.dart'; + +class MainShell extends StatefulWidget { + const MainShell({super.key}); + + @override + State createState() => _MainShellState(); +} + +class _MainShellState extends State { + int _currentIndex = 0; + + final List _screens = const [ + MapScreen(), + AlertsScreen(), + MyHouseScreen(), + ProfileScreen(), + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: IndexedStack( + index: _currentIndex, + children: _screens, + ), + bottomNavigationBar: w.AppBottomNav( + currentIndex: _currentIndex, + onTap: (i) => setState(() => _currentIndex = i), + ), + ); + } +} diff --git a/views_v1/map_screen.dart b/views_v1/map_screen.dart new file mode 100644 index 0000000..60e1c77 --- /dev/null +++ b/views_v1/map_screen.dart @@ -0,0 +1,381 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import '../theme/app_theme.dart'; +import '../models/models.dart'; +import '../widgets/widgets.dart' as w; + +class MapScreen extends StatefulWidget { + const MapScreen({super.key}); + + @override + State createState() => _MapScreenState(); +} + +class _MapScreenState extends State { + final Completer _mapController = Completer(); + + // Coordenadas de ejemplo — Celaya, Gto. + static const LatLng _casaPos = LatLng(20.5226, -100.8191); + static const LatLng _camionPos = LatLng(20.5255, -100.8220); + static const CameraPosition _camaraInicial = CameraPosition( + target: LatLng(20.5240, -100.8205), + zoom: 15.5, + ); + + // Datos de ejemplo del camión + final TruckLocation _camion = TruckLocation( + id: 'truck-01', + ruta: 'Ruta Norte', + latitud: _camionPos.latitude, + longitud: _camionPos.longitude, + ultimaActualizacion: DateTime.now().subtract(const Duration(seconds: 28)), + enServicio: true, + ); + + final HouseModel _casa = HouseModel( + id: 'casa-01', + calle: 'Av. Insurgentes 245', + colonia: 'Centro', + codigoPostal: '38000', + latitud: _casaPos.latitude, + longitud: _casaPos.longitude, + radioAlertaMetros: 200, + ); + + Set _markers = {}; + Set _circles = {}; + Timer? _refreshTimer; + + // Distancia simulada (metros) + double get _distanciaMetros => 380; + int get _minutosEstimados => 8; + + @override + void initState() { + super.initState(); + _buildMapElements(); + // Simular actualización de posición cada 30s + _refreshTimer = Timer.periodic(const Duration(seconds: 30), (_) { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _refreshTimer?.cancel(); + super.dispose(); + } + + void _buildMapElements() { + _markers = { + Marker( + markerId: const MarkerId('camion'), + position: LatLng(_camion.latitud, _camion.longitud), + icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueGreen), + infoWindow: InfoWindow( + title: 'Camión · ${_camion.ruta}', + snippet: _camion.tiempoActualizacion, + ), + ), + Marker( + markerId: const MarkerId('casa'), + position: LatLng(_casa.latitud, _casa.longitud), + icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueBlue), + infoWindow: InfoWindow(title: _casa.alias, snippet: _casa.calle), + ), + }; + + _circles = { + Circle( + 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), + strokeWidth: 1, + ), + }; + } + + Future _centrarMapa() async { + final controller = await _mapController.future; + await controller.animateCamera( + CameraUpdate.newCameraPosition(_camaraInicial), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.background, + appBar: AppBar( + title: const Text('Rastreo en vivo'), + actions: [ + IconButton( + icon: const Icon(Icons.my_location), + onPressed: _centrarMapa, + tooltip: 'Centrar mapa', + ), + ], + ), + body: Column( + children: [ + // ── Mapa ───────────────────────────────────────────────────── + Expanded( + flex: 5, + child: Stack( + children: [ + GoogleMap( + initialCameraPosition: _camaraInicial, + markers: _markers, + circles: _circles, + myLocationButtonEnabled: false, + zoomControlsEnabled: false, + mapType: MapType.normal, + onMapCreated: (c) { + _mapController.complete(c); + }, + ), + + // Indicador "En vivo" + Positioned( + top: 14, + right: 14, + child: _LiveBadge(activo: _camion.enServicio), + ), + + // Actualización + Positioned( + top: 14, + left: 14, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: AppTheme.softShadow, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.refresh, + size: 14, color: AppTheme.textSecondary), + const SizedBox(width: 4), + Text( + _camion.tiempoActualizacion, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary), + ), + ], + ), + ), + ), + ], + ), + ), + + // ── Panel inferior ──────────────────────────────────────────── + Expanded( + flex: 3, + child: Container( + decoration: BoxDecoration( + color: AppTheme.background, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppTheme.radiusXl)), + boxShadow: AppTheme.cardShadow, + ), + child: Column( + children: [ + // Handle + Container( + margin: const EdgeInsets.symmetric(vertical: 10), + width: 36, + height: 4, + decoration: BoxDecoration( + color: AppTheme.border, + borderRadius: BorderRadius.circular(4), + ), + ), + + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + // Camión + w.InfoRow( + icon: Icons.delete_outline_rounded, + label: '${_camion.ruta} · ${_camion.tiempoActualizacion}', + value: 'Camión a ${_distanciaMetros.toStringAsFixed(0)} m', + trailing: w.StatusBadge.amber('~$_minutosEstimados min'), + ), + const SizedBox(height: 10), + // Casa + w.InfoRow( + icon: Icons.home_outlined, + label: _casa.direccionCompleta, + value: _casa.alias, + trailing: w.StatusBadge.green('Activa'), + ), + const SizedBox(height: 12), + // Barra de progreso de llegada + _ArrivalBar( + distanciaActual: _distanciaMetros, + distanciaTotal: 1000, + minutos: _minutosEstimados, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +// ── Badge "En vivo" ─────────────────────────────────────────────────────────── +class _LiveBadge extends StatefulWidget { + final bool activo; + const _LiveBadge({required this.activo}); + + @override + State<_LiveBadge> createState() => _LiveBadgeState(); +} + +class _LiveBadgeState extends State<_LiveBadge> + with SingleTickerProviderStateMixin { + late AnimationController _anim; + + @override + void initState() { + super.initState(); + _anim = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(reverse: true); + } + + @override + void dispose() { + _anim.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: AppTheme.softShadow, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedBuilder( + animation: _anim, + builder: (_, __) => Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: widget.activo + ? AppTheme.primary.withValues(alpha: 0.5 + _anim.value * 0.5) + : AppTheme.textSecondary, + ), + ), + ), + const SizedBox(width: 5), + Text( + widget.activo ? 'En vivo' : 'Sin servicio', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: widget.activo ? AppTheme.primary : AppTheme.textSecondary, + ), + ), + ], + ), + ); + } +} + +// ── Barra de llegada estimada ───────────────────────────────────────────────── +class _ArrivalBar extends StatelessWidget { + final double distanciaActual; + final double distanciaTotal; + final int minutos; + + const _ArrivalBar({ + required this.distanciaActual, + required this.distanciaTotal, + required this.minutos, + }); + + @override + Widget build(BuildContext context) { + final progreso = + ((distanciaTotal - distanciaActual) / distanciaTotal).clamp(0.0, 1.0); + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppTheme.primaryLight, + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + border: Border.all(color: AppTheme.primaryMid, width: 0.5), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('Llegada estimada', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppTheme.primaryDark)), + const Spacer(), + Text('~$minutos min', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppTheme.primary)), + ], + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: progreso, + backgroundColor: AppTheme.primaryMid.withValues(alpha: 0.4), + valueColor: + const AlwaysStoppedAnimation(AppTheme.primary), + minHeight: 6, + ), + ), + const SizedBox(height: 4), + Row( + children: const [ + Text('Ahora', + style: TextStyle( + fontSize: 10, color: AppTheme.primaryDark)), + Spacer(), + Text('Tu casa', + style: TextStyle( + fontSize: 10, color: AppTheme.primaryDark)), + ], + ), + ], + ), + ); + } +} diff --git a/views_v1/profile_screen.dart b/views_v1/profile_screen.dart new file mode 100644 index 0000000..b56350a --- /dev/null +++ b/views_v1/profile_screen.dart @@ -0,0 +1,233 @@ +import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; +import '../models/models.dart'; +import '../widgets/widgets.dart' as w; +import 'splash_screen.dart'; + +class ProfileScreen extends StatelessWidget { + const ProfileScreen({super.key}); + + final UserModel _usuario = const UserModel( + id: 'user-01', + nombre: 'Carlos', + apellido: 'Martínez', + email: 'carlos@ejemplo.com', + telefono: '+52 461 123 4567', + ); + + @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: [ + // ── Avatar y datos ───────────────────────────────────────── + _ProfileHeader(usuario: _usuario), + + const SizedBox(height: 20), + + // ── Mi cuenta ────────────────────────────────────────────── + w.SectionTitle(title: 'Mi cuenta'), + w.MenuTile( + icon: Icons.person_outline, + title: 'Editar perfil', + subtitle: '${_usuario.nombre} ${_usuario.apellido}', + onTap: () {}, + ), + w.MenuTile( + icon: Icons.lock_outline, + title: 'Cambiar contraseña', + onTap: () {}, + ), + w.MenuTile( + icon: Icons.phone_outlined, + title: 'Teléfono', + subtitle: _usuario.telefono, + onTap: () {}, + ), + + const SizedBox(height: 16), + + // ── Configuración ────────────────────────────────────────── + w.SectionTitle(title: 'Configuración'), + w.MenuTile( + icon: Icons.calendar_month_outlined, + title: 'Horario del camión', + subtitle: 'Ruta Norte · Celaya', + onTap: () {}, + ), + w.MenuTile( + icon: Icons.language_outlined, + title: 'Idioma', + subtitle: 'Español', + onTap: () {}, + ), + w.MenuTile( + icon: Icons.dark_mode_outlined, + title: 'Tema', + subtitle: 'Claro', + onTap: () {}, + ), + + const SizedBox(height: 16), + + // ── Soporte ──────────────────────────────────────────────── + w.SectionTitle(title: 'Soporte'), + w.MenuTile( + icon: Icons.help_outline, + title: 'Ayuda y preguntas frecuentes', + onTap: () {}, + ), + w.MenuTile( + icon: Icons.bug_report_outlined, + title: 'Reportar un problema', + onTap: () {}, + ), + w.MenuTile( + icon: Icons.info_outline, + title: 'Acerca de la app', + subtitle: 'Versión 1.0.0', + 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: () => _confirmarCerrarSesion(context), + ), + + const SizedBox(height: 32), + + Center( + child: Text( + 'RutaVerde v1.0.0\nServicio de Limpia · Celaya, Gto.', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 12, color: AppTheme.textHint, height: 1.6), + ), + ), + + const SizedBox(height: 24), + ], + ), + ); + } + + void _confirmarCerrarSesion(BuildContext context) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppTheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusLg)), + title: const Text('Cerrar sesión', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary)), + content: const Text( + '¿Estás seguro de que deseas cerrar sesión?', + style: TextStyle(fontSize: 14, color: AppTheme.textSecondary), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + style: + TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), + child: const Text('Cancelar'), + ), + TextButton( + onPressed: () { + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (_) => const SplashScreen()), + (_) => false, + ); + }, + style: TextButton.styleFrom(foregroundColor: AppTheme.danger), + child: const Text('Cerrar sesión', + style: TextStyle(fontWeight: FontWeight.w600)), + ), + ], + ), + ); + } +} + +// ── Encabezado de perfil ────────────────────────────────────────────────────── +class _ProfileHeader extends StatelessWidget { + final UserModel usuario; + const _ProfileHeader({required this.usuario}); + + @override + Widget build(BuildContext context) { + 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: Row( + children: [ + // Avatar con iniciales + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: AppTheme.primaryLight, + shape: BoxShape.circle, + border: Border.all(color: AppTheme.primaryMid, width: 1.5), + ), + child: Center( + child: Text( + usuario.iniciales, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppTheme.primaryDark), + ), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + usuario.nombreCompleto, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary), + ), + const SizedBox(height: 2), + Text( + usuario.email, + style: const TextStyle( + fontSize: 13, color: AppTheme.textSecondary), + ), + const SizedBox(height: 6), + w.StatusBadge.green('Cuenta activa'), + ], + ), + ), + IconButton( + icon: const Icon(Icons.edit_outlined, + color: AppTheme.primary, size: 20), + onPressed: () {}, + ), + ], + ), + ); + } +} diff --git a/views_v1/register_screen.dart b/views_v1/register_screen.dart new file mode 100644 index 0000000..172f62d --- /dev/null +++ b/views_v1/register_screen.dart @@ -0,0 +1,541 @@ +import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; +import '../widgets/widgets.dart' as w; +import 'main_shell.dart'; + +class RegisterScreen extends StatefulWidget { + const RegisterScreen({super.key}); + + @override + State createState() => _RegisterScreenState(); +} + +class _RegisterScreenState extends State { + final _pageController = PageController(); + int _currentPage = 0; + bool _loading = false; + + // Paso 1 + final _nombreCtrl = TextEditingController(); + final _apellidoCtrl = TextEditingController(); + final _emailCtrl = TextEditingController(); + final _telefonoCtrl = TextEditingController(); + final _passCtrl = TextEditingController(); + bool _obscurePass = true; + + // Paso 2 + final _calleCtrl = TextEditingController(); + final _coloniaCtrl = TextEditingController(); + final _cpCtrl = TextEditingController(); + int _radioAlerta = 200; + + @override + void dispose() { + _pageController.dispose(); + _nombreCtrl.dispose(); _apellidoCtrl.dispose(); + _emailCtrl.dispose(); _telefonoCtrl.dispose(); _passCtrl.dispose(); + _calleCtrl.dispose(); _coloniaCtrl.dispose(); _cpCtrl.dispose(); + super.dispose(); + } + + void _nextPage() { + _pageController.nextPage( + duration: const Duration(milliseconds: 350), + curve: Curves.easeInOut, + ); + setState(() => _currentPage = 1); + } + + Future _register() async { + setState(() => _loading = true); + await Future.delayed(const Duration(seconds: 1)); + if (!mounted) return; + setState(() => _loading = false); + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (_) => const MainShell()), + (_) => false, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.background, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + iconTheme: const IconThemeData(color: AppTheme.textPrimary), + title: Text( + _currentPage == 0 ? 'Crear cuenta' : 'Mi dirección', + style: const TextStyle(color: AppTheme.textPrimary, fontSize: 16), + ), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(4), + child: _StepIndicator(current: _currentPage, total: 2), + ), + ), + body: PageView( + controller: _pageController, + physics: const NeverScrollableScrollPhysics(), + children: [ + _Step1( + nombreCtrl: _nombreCtrl, + apellidoCtrl: _apellidoCtrl, + emailCtrl: _emailCtrl, + telefonoCtrl: _telefonoCtrl, + passCtrl: _passCtrl, + obscurePass: _obscurePass, + onTogglePass: () => setState(() => _obscurePass = !_obscurePass), + onNext: _nextPage, + ), + _Step2( + calleCtrl: _calleCtrl, + coloniaCtrl: _coloniaCtrl, + cpCtrl: _cpCtrl, + radioAlerta: _radioAlerta, + onRadioChanged: (v) => setState(() => _radioAlerta = v), + onRegister: _register, + loading: _loading, + ), + ], + ), + ); + } +} + +// ── Indicador de pasos ──────────────────────────────────────────────────────── +class _StepIndicator extends StatelessWidget { + final int current; + final int total; + + const _StepIndicator({required this.current, required this.total}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6), + child: Row( + children: List.generate(total, (i) { + final active = i <= current; + return Expanded( + child: Container( + margin: EdgeInsets.only(right: i < total - 1 ? 6 : 0), + height: 4, + decoration: BoxDecoration( + color: active ? AppTheme.primary : AppTheme.border, + borderRadius: BorderRadius.circular(4), + ), + ), + ); + }), + ), + ); + } +} + +// ── Paso 1: Datos personales ────────────────────────────────────────────────── +class _Step1 extends StatelessWidget { + final TextEditingController nombreCtrl, apellidoCtrl, emailCtrl, + telefonoCtrl, passCtrl; + final bool obscurePass; + final VoidCallback onTogglePass; + final VoidCallback onNext; + + const _Step1({ + required this.nombreCtrl, required this.apellidoCtrl, + required this.emailCtrl, required this.telefonoCtrl, + required this.passCtrl, required this.obscurePass, + required this.onTogglePass, required this.onNext, + }); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + + // ── Sección personal ────────────────────────────────────────── + _FormCard( + icon: Icons.person_outline, + title: 'Información personal', + child: Column( + children: [ + Row( + children: [ + Expanded( + child: w.FormField( + label: 'Nombre', + hint: 'Carlos', + controller: nombreCtrl, + ), + ), + const SizedBox(width: 12), + Expanded( + child: w.FormField( + label: 'Apellido', + hint: 'Martínez', + controller: apellidoCtrl, + ), + ), + ], + ), + const SizedBox(height: 14), + w.FormField( + label: 'Correo electrónico', + hint: 'tu@correo.com', + controller: emailCtrl, + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 14), + w.FormField( + label: 'Teléfono', + hint: '+52 461 123 4567', + controller: telefonoCtrl, + keyboardType: TextInputType.phone, + ), + const SizedBox(height: 14), + w.FormField( + label: 'Contraseña', + hint: '••••••••', + controller: passCtrl, + obscureText: obscurePass, + suffix: IconButton( + icon: Icon( + obscurePass + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + size: 18, color: AppTheme.textSecondary, + ), + onPressed: onTogglePass, + ), + ), + ], + ), + ), + + const SizedBox(height: 28), + + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: onNext, + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Text('Siguiente'), + SizedBox(width: 8), + Icon(Icons.arrow_forward, size: 18), + ], + ), + ), + ), + + const SizedBox(height: 20), + Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('¿Ya tienes cuenta? ', + style: TextStyle( + fontSize: 13, color: AppTheme.textSecondary)), + GestureDetector( + onTap: () => Navigator.pop(context), + child: const Text('Inicia sesión', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppTheme.primary)), + ), + ], + ), + ), + ], + ), + ); + } +} + +// ── Paso 2: Dirección ───────────────────────────────────────────────────────── +class _Step2 extends StatelessWidget { + final TextEditingController calleCtrl, coloniaCtrl, cpCtrl; + final int radioAlerta; + final ValueChanged onRadioChanged; + final VoidCallback onRegister; + final bool loading; + + const _Step2({ + required this.calleCtrl, required this.coloniaCtrl, required this.cpCtrl, + required this.radioAlerta, required this.onRadioChanged, + required this.onRegister, required this.loading, + }); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + + _FormCard( + icon: Icons.home_outlined, + title: 'Dirección de tu casa', + child: Column( + children: [ + w.FormField( + label: 'Calle y número', + hint: 'Av. Insurgentes 245', + controller: calleCtrl, + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + flex: 3, + child: w.FormField( + label: 'Colonia', + hint: 'Centro', + controller: coloniaCtrl, + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: w.FormField( + label: 'C.P.', + hint: '38000', + controller: cpCtrl, + keyboardType: TextInputType.number, + ), + ), + ], + ), + const SizedBox(height: 14), + + // Usar ubicación actual + GestureDetector( + onTap: () {}, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 11, horizontal: 14), + decoration: BoxDecoration( + color: AppTheme.primaryLight, + borderRadius: + BorderRadius.circular(AppTheme.radiusSm), + border: Border.all( + color: AppTheme.primaryMid, width: 0.5), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.my_location, + color: AppTheme.primary, size: 18), + SizedBox(width: 8), + Text('Usar mi ubicación actual', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppTheme.primaryDark)), + ], + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 16), + + _FormCard( + icon: Icons.notifications_outlined, + title: 'Distancia de alerta', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Te avisamos cuando el camión esté a esta distancia de tu casa.', + style: TextStyle( + fontSize: 13, color: AppTheme.textSecondary, + height: 1.4), + ), + const SizedBox(height: 14), + ...([200, 400, 600]).map((dist) => _RadioOption( + value: dist, + groupValue: radioAlerta, + label: '$dist metros', + sublabel: dist == 200 + ? 'Alerta muy temprana (~2-3 min)' + : dist == 400 + ? 'Alerta temprana (~4-5 min)' + : 'Alerta anticipada (~6-8 min)', + onChanged: onRadioChanged, + )), + ], + ), + ), + + const SizedBox(height: 28), + + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: loading ? null : onRegister, + child: loading + ? const SizedBox( + width: 20, height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check, size: 18), + SizedBox(width: 8), + Text('Registrarme'), + ], + ), + ), + ), + + const SizedBox(height: 16), + Center( + child: Text( + 'Al registrarte aceptas los Términos de Servicio\ny la Política de Privacidad.', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 11, color: AppTheme.textSecondary, height: 1.5), + ), + ), + ], + ), + ); + } +} + +// ── Tarjeta de formulario ───────────────────────────────────────────────────── +class _FormCard extends StatelessWidget { + final IconData icon; + final String title; + final Widget child; + + const _FormCard( + {required this.icon, required this.title, required this.child}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + 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: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: AppTheme.primary, size: 18), + const SizedBox(width: 8), + Text(title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary)), + ], + ), + const SizedBox(height: 16), + child, + ], + ), + ); + } +} + +// ── Opción radio ────────────────────────────────────────────────────────────── +class _RadioOption extends StatelessWidget { + final int value; + final int groupValue; + final String label; + final String sublabel; + final ValueChanged onChanged; + + const _RadioOption({ + required this.value, required this.groupValue, + required this.label, required this.sublabel, required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final selected = value == groupValue; + return GestureDetector( + onTap: () => onChanged(value), + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + decoration: BoxDecoration( + color: selected ? AppTheme.primaryLight : AppTheme.background, + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + border: Border.all( + color: selected ? AppTheme.primary : AppTheme.border, + width: selected ? 1.5 : 0.5, + ), + ), + child: Row( + children: [ + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: selected ? AppTheme.primary : AppTheme.border, + width: 2, + ), + ), + child: selected + ? Center( + child: Container( + width: 8, height: 8, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: AppTheme.primary, + ), + ), + ) + : null, + ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: selected + ? AppTheme.primaryDark + : AppTheme.textPrimary)), + Text(sublabel, + style: TextStyle( + fontSize: 11, + color: selected + ? AppTheme.primary + : AppTheme.textSecondary)), + ], + ), + ], + ), + ), + ); + } +} diff --git a/views_v1/splash_screen.dart b/views_v1/splash_screen.dart new file mode 100644 index 0000000..540756e --- /dev/null +++ b/views_v1/splash_screen.dart @@ -0,0 +1,237 @@ +import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; +import 'login_screen.dart'; +import 'register_screen.dart'; + +class SplashScreen extends StatefulWidget { + const SplashScreen({super.key}); + + @override + State createState() => _SplashScreenState(); +} + +class _SplashScreenState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _fadeIn; + late Animation _slideUp; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + ); + _fadeIn = Tween(begin: 0, end: 1).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeOut), + ); + _slideUp = Tween( + begin: const Offset(0, 0.3), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [AppTheme.primary, AppTheme.primaryDark], + ), + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Column( + children: [ + const Spacer(flex: 2), + + // ── Ícono de la app ───────────────────────────────────── + FadeTransition( + opacity: _fadeIn, + child: Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: + BorderRadius.circular(AppTheme.radiusXl), + ), + child: const Icon( + Icons.delete_outline_rounded, + size: 46, + color: Colors.white, + ), + ), + ), + + const SizedBox(height: 24), + + // ── Nombre y descripción ──────────────────────────────── + SlideTransition( + position: _slideUp, + child: FadeTransition( + opacity: _fadeIn, + child: Column( + children: [ + const Text( + 'RutaVerde', + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.w700, + color: Colors.white, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 10), + Text( + 'Sigue en tiempo real el camión de basura\ny recibe alertas cuando esté cerca.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 15, + color: Colors.white.withValues(alpha: 0.82), + height: 1.5, + ), + ), + ], + ), + ), + ), + + const Spacer(flex: 3), + + // ── Características rápidas ───────────────────────────── + FadeTransition( + opacity: _fadeIn, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _FeatureChip( + icon: Icons.location_on_outlined, + label: 'Rastreo en vivo', + ), + _FeatureChip( + icon: Icons.notifications_outlined, + label: 'Alertas', + ), + _FeatureChip( + icon: Icons.home_outlined, + label: 'Tu dirección', + ), + ], + ), + ), + + const SizedBox(height: 40), + + // ── Botones ───────────────────────────────────────────── + FadeTransition( + opacity: _fadeIn, + child: Column( + children: [ + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: AppTheme.primaryDark, + minimumSize: const Size(double.infinity, 52), + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(AppTheme.radiusMd), + ), + textStyle: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const RegisterScreen(), + ), + ); + }, + child: const Text('Crear cuenta'), + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const LoginScreen(), + ), + ); + }, + child: const Text('Ya tengo cuenta'), + ), + ], + ), + ), + + const SizedBox(height: 24), + + Text( + 'Servicio de Limpia · Celaya, Gto.', + style: TextStyle( + fontSize: 12, + color: Colors.white.withValues(alpha: 0.45), + ), + ), + + const SizedBox(height: 16), + ], + ), + ), + ), + ), + ); + } +} + +class _FeatureChip extends StatelessWidget { + final IconData icon; + final String label; + + const _FeatureChip({required this.icon, required this.label}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + border: Border.all(color: Colors.white.withValues(alpha: 0.2)), + ), + child: Column( + children: [ + Icon(icon, color: Colors.white, size: 22), + const SizedBox(height: 5), + Text( + label, + style: const TextStyle( + fontSize: 11, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +}