2444 lines
81 KiB
Dart
2444 lines
81 KiB
Dart
// 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: '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<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 (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<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. 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'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|