Files
hackathon-innovaflow5.0-cdf…/views_v1/admin_screen.dart
2026-05-22 23:53:00 -06:00

2444 lines
81 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<AdminUser> users = [];
List<AdminRoute> routes = [];
List<AdminTruck> trucks = [];
Future<void> 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: 'LunVie 7:0010:00 a.m.',
totalCasas: 98,
activa: true,
),
AdminRoute(
id: 'r-02',
nombre: 'Ruta Sur',
zona: 'Col. Centro, Col. Obrera',
horario: 'LunSáb 8:0011: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<void> 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<void> deleteUser(String id) async {
if (api.hasBackend) {
await api.deleteUser(id);
await loadAll();
return;
}
users.removeWhere((item) => item.id == id);
notifyListeners();
}
Future<void> 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<void> deleteRoute(String id) async {
if (api.hasBackend) {
await api.deleteRoute(id);
await loadAll();
return;
}
routes.removeWhere((item) => item.id == id);
notifyListeners();
}
Future<void> 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<void> 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<void> showAdminUserForm(BuildContext context, {AdminUser? user}) async {
final provider = Provider.of<AdminProvider>(context, listen: false);
final formKey = GlobalKey<FormState>();
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<void> showAdminRouteForm(BuildContext context,
{AdminRoute? route}) async {
final provider = Provider.of<AdminProvider>(context, listen: false);
final formKey = GlobalKey<FormState>();
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<void> showAdminTruckForm(BuildContext context,
{AdminTruck? truck}) async {
final provider = Provider.of<AdminProvider>(context, listen: false);
final formKey = GlobalKey<FormState>();
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<String>(
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<TruckStatus>(
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<void> _confirmDeleteUser(BuildContext context, AdminUser user) async {
final provider = Provider.of<AdminProvider>(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<void> _confirmDeleteRoute(BuildContext context, AdminRoute route) async {
final provider = Provider.of<AdminProvider>(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<void> _confirmDeleteTruck(BuildContext context, AdminTruck truck) async {
final provider = Provider.of<AdminProvider>(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<AdminShell> createState() => _AdminShellState();
}
class _AdminShellState extends State<AdminShell> {
int _currentIndex = 0;
final List<Widget> _screens = const [
AdminDashboardScreen(),
AdminUsersScreen(),
AdminRoutesScreen(),
AdminTrucksScreen(),
];
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider<AdminProvider>(
create: (_) => AdminProvider(api: const AdminApiService())..loadAll(),
child: Consumer<AdminProvider>(
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<AdminProvider>();
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<AdminProvider>();
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<AdminProvider>();
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<AdminProvider>();
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<AdminDriversScreen> createState() => _AdminDriversScreenState();
}
class _AdminDriversScreenState extends State<AdminDriversScreen> {
static const List<DriverModel> _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<String> _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 (LunDom)
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<Color>(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. LunVie 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'),
),
),
],
),
);
}
}