Co-authored-by: MENDOZA BALLARDO GAEL RICARDO <gael-meb123@users.noreply.github.com> Co-authored-by: Azareth-Tr <Azareth-Tr@users.noreply.github.com> modificacion de vistas panel admin, login, animaciones y implementacion de mascota
1784 lines
62 KiB
Dart
1784 lines
62 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import '../../core/services/auth_controller.dart';
|
|
import '../../core/theme/app_theme.dart';
|
|
import '../../core/widgets/app_widgets.dart';
|
|
import 'data/admin_service.dart';
|
|
import 'models/admin_driver.dart';
|
|
import 'models/admin_route.dart';
|
|
import 'models/admin_unit.dart';
|
|
import 'models/admin_user.dart';
|
|
import 'providers/admin_providers.dart';
|
|
|
|
class AdminScreen extends ConsumerStatefulWidget {
|
|
const AdminScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<AdminScreen> createState() => _AdminScreenState();
|
|
}
|
|
|
|
class _AdminScreenState extends ConsumerState<AdminScreen>
|
|
with SingleTickerProviderStateMixin {
|
|
late final TabController _tabController;
|
|
int _activeTab = 0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_tabController = TabController(length: 3, vsync: this)
|
|
..addListener(() {
|
|
if (!_tabController.indexIsChanging) {
|
|
setState(() => _activeTab = _tabController.index);
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_tabController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
AdminService get _service => ref.read(adminServiceProvider);
|
|
|
|
void _snack(String msg, {bool error = false}) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(msg),
|
|
backgroundColor: error ? AppTheme.danger : AppTheme.primary,
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _handleAdd() async {
|
|
switch (_activeTab) {
|
|
case 0:
|
|
await _showUserForm();
|
|
break;
|
|
case 1:
|
|
await _showRouteForm();
|
|
break;
|
|
case 2:
|
|
await _showUnitForm();
|
|
break;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: AppTheme.background,
|
|
appBar: AppBar(
|
|
title: const Text('Panel de administración'),
|
|
actions: [
|
|
IconButton(
|
|
tooltip: 'Refrescar',
|
|
icon: const Icon(Icons.refresh),
|
|
onPressed: () {
|
|
ref.invalidate(adminUsersProvider);
|
|
ref.invalidate(adminRoutesProvider);
|
|
ref.invalidate(adminUnitsProvider);
|
|
ref.invalidate(adminDriversProvider);
|
|
},
|
|
),
|
|
IconButton(
|
|
tooltip: 'Cerrar sesión',
|
|
icon: const Icon(Icons.logout),
|
|
onPressed: () async {
|
|
await ref.read(authControllerProvider.notifier).logout();
|
|
if (mounted) context.go('/login');
|
|
},
|
|
),
|
|
],
|
|
bottom: TabBar(
|
|
controller: _tabController,
|
|
indicatorColor: Colors.white,
|
|
labelColor: Colors.white,
|
|
unselectedLabelColor: Colors.white70,
|
|
tabs: const [
|
|
Tab(text: 'Usuarios'),
|
|
Tab(text: 'Rutas'),
|
|
Tab(text: 'Camiones'),
|
|
],
|
|
),
|
|
),
|
|
body: TabBarView(
|
|
controller: _tabController,
|
|
children: const [_UsersTab(), _RoutesTab(), _TrucksTab()],
|
|
),
|
|
floatingActionButton: FloatingActionButton.extended(
|
|
onPressed: _handleAdd,
|
|
backgroundColor: AppTheme.primary,
|
|
icon: const Icon(Icons.add),
|
|
label: Text(
|
|
_activeTab == 0
|
|
? 'Nuevo usuario'
|
|
: _activeTab == 1
|
|
? 'Nueva ruta'
|
|
: 'Nuevo camión',
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── Formulario usuario ──────────────────────────────────────────────────────
|
|
Future<void> _showUserForm({AdminUserModel? user}) async {
|
|
final isEdit = user != null;
|
|
final nombre = TextEditingController(text: user?.name ?? '');
|
|
final email = TextEditingController(text: user?.email ?? '');
|
|
final telefono = TextEditingController(text: user?.phone ?? '');
|
|
final password = TextEditingController();
|
|
String role = user?.role ?? 'citizen';
|
|
final formKey = GlobalKey<FormState>();
|
|
|
|
final saved = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) {
|
|
return StatefulBuilder(
|
|
builder: (ctx, setStateDialog) {
|
|
return AlertDialog(
|
|
backgroundColor: AppTheme.surface,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
|
),
|
|
title: Text(isEdit ? 'Editar usuario' : 'Nuevo usuario'),
|
|
content: Form(
|
|
key: formKey,
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_textField(nombre, 'Nombre', required: true),
|
|
const SizedBox(height: 10),
|
|
_textField(
|
|
email,
|
|
'Email',
|
|
keyboardType: TextInputType.emailAddress,
|
|
),
|
|
const SizedBox(height: 10),
|
|
if (!isEdit) ...[
|
|
_textField(
|
|
telefono,
|
|
'Teléfono',
|
|
keyboardType: TextInputType.phone,
|
|
),
|
|
const SizedBox(height: 10),
|
|
_textField(
|
|
password,
|
|
'Contraseña (mín. 6)',
|
|
obscure: true,
|
|
required: true,
|
|
validator: (v) => (v == null || v.length < 6)
|
|
? 'Mínimo 6 caracteres'
|
|
: null,
|
|
),
|
|
const SizedBox(height: 10),
|
|
],
|
|
DropdownButtonFormField<String>(
|
|
initialValue: role,
|
|
decoration: InputDecoration(
|
|
labelText: 'Rol',
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(
|
|
AppTheme.radiusMd,
|
|
),
|
|
),
|
|
),
|
|
items: const [
|
|
DropdownMenuItem(
|
|
value: 'citizen',
|
|
child: Text('Ciudadano'),
|
|
),
|
|
DropdownMenuItem(
|
|
value: 'driver',
|
|
child: Text('Conductor'),
|
|
),
|
|
DropdownMenuItem(
|
|
value: 'admin',
|
|
child: Text('Administrador'),
|
|
),
|
|
],
|
|
onChanged: (v) {
|
|
if (v != null) setStateDialog(() => role = v);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: const Text('Cancelar'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () async {
|
|
if (!formKey.currentState!.validate()) return;
|
|
try {
|
|
if (isEdit) {
|
|
await _service.updateUser(
|
|
user.id,
|
|
name: nombre.text.trim(),
|
|
email: email.text.trim().isEmpty
|
|
? null
|
|
: email.text.trim(),
|
|
role: role,
|
|
);
|
|
} else {
|
|
if (email.text.trim().isEmpty &&
|
|
telefono.text.trim().isEmpty) {
|
|
_snack('Email o teléfono es requerido', error: true);
|
|
return;
|
|
}
|
|
await _service.createUser(
|
|
name: nombre.text.trim(),
|
|
password: password.text,
|
|
email: email.text.trim().isEmpty
|
|
? null
|
|
: email.text.trim(),
|
|
phone: telefono.text.trim().isEmpty
|
|
? null
|
|
: telefono.text.trim(),
|
|
role: role,
|
|
);
|
|
}
|
|
if (ctx.mounted) Navigator.pop(ctx, true);
|
|
} catch (e) {
|
|
_snack('Error: ${_errMsg(e)}', error: true);
|
|
}
|
|
},
|
|
child: const Text('Guardar'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
if (saved == true) {
|
|
ref.invalidate(adminUsersProvider);
|
|
ref.invalidate(adminDriversProvider);
|
|
_snack(isEdit ? 'Usuario actualizado' : 'Usuario creado');
|
|
}
|
|
}
|
|
|
|
// ── Formulario ruta ─────────────────────────────────────────────────────────
|
|
Future<void> _showRouteForm({AdminRouteModel? route}) async {
|
|
final isEdit = route != null;
|
|
final id = TextEditingController(text: route?.id ?? '');
|
|
final nombre = TextEditingController(text: route?.name ?? '');
|
|
String? turno = route?.turno;
|
|
String status = route?.status ?? 'pendiente';
|
|
int? truckId = route?.truckId;
|
|
final formKey = GlobalKey<FormState>();
|
|
final units = ref
|
|
.read(adminUnitsProvider)
|
|
.maybeWhen(data: (u) => u, orElse: () => <AdminUnitModel>[]);
|
|
|
|
final saved = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) {
|
|
return StatefulBuilder(
|
|
builder: (ctx, setStateDialog) {
|
|
return AlertDialog(
|
|
backgroundColor: AppTheme.surface,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
|
),
|
|
title: Text(isEdit ? 'Editar ruta' : 'Nueva ruta'),
|
|
content: Form(
|
|
key: formKey,
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_textField(
|
|
id,
|
|
'ID (ej. RUTA-01)',
|
|
required: true,
|
|
enabled: !isEdit,
|
|
),
|
|
const SizedBox(height: 10),
|
|
_textField(nombre, 'Nombre'),
|
|
const SizedBox(height: 10),
|
|
DropdownButtonFormField<String?>(
|
|
initialValue: turno,
|
|
decoration: InputDecoration(
|
|
labelText: 'Turno',
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(
|
|
AppTheme.radiusMd,
|
|
),
|
|
),
|
|
),
|
|
items: const [
|
|
DropdownMenuItem<String?>(
|
|
value: null,
|
|
child: Text('—'),
|
|
),
|
|
DropdownMenuItem<String?>(
|
|
value: 'matutino',
|
|
child: Text('Matutino'),
|
|
),
|
|
DropdownMenuItem<String?>(
|
|
value: 'vespertino',
|
|
child: Text('Vespertino'),
|
|
),
|
|
],
|
|
onChanged: (v) => setStateDialog(() => turno = v),
|
|
),
|
|
const SizedBox(height: 10),
|
|
DropdownButtonFormField<String>(
|
|
initialValue: status,
|
|
decoration: InputDecoration(
|
|
labelText: 'Status',
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(
|
|
AppTheme.radiusMd,
|
|
),
|
|
),
|
|
),
|
|
items: const [
|
|
DropdownMenuItem(
|
|
value: 'pendiente',
|
|
child: Text('Pendiente'),
|
|
),
|
|
DropdownMenuItem(
|
|
value: 'en_ruta',
|
|
child: Text('En ruta'),
|
|
),
|
|
DropdownMenuItem(
|
|
value: 'completada',
|
|
child: Text('Completada'),
|
|
),
|
|
DropdownMenuItem(
|
|
value: 'diferida',
|
|
child: Text('Diferida'),
|
|
),
|
|
DropdownMenuItem(
|
|
value: 'reasignada',
|
|
child: Text('Reasignada'),
|
|
),
|
|
],
|
|
onChanged: (v) {
|
|
if (v != null) setStateDialog(() => status = v);
|
|
},
|
|
),
|
|
const SizedBox(height: 10),
|
|
DropdownButtonFormField<int?>(
|
|
initialValue: truckId,
|
|
decoration: InputDecoration(
|
|
labelText: 'Camión asignado',
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(
|
|
AppTheme.radiusMd,
|
|
),
|
|
),
|
|
),
|
|
items: [
|
|
const DropdownMenuItem<int?>(
|
|
value: null,
|
|
child: Text('Sin asignar'),
|
|
),
|
|
...units.map(
|
|
(u) => DropdownMenuItem<int?>(
|
|
value: u.id,
|
|
child: Text('${u.displayPlate} (#${u.id})'),
|
|
),
|
|
),
|
|
],
|
|
onChanged: (v) => setStateDialog(() => truckId = v),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: const Text('Cancelar'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () async {
|
|
if (!formKey.currentState!.validate()) return;
|
|
try {
|
|
if (isEdit) {
|
|
await _service.updateRoute(
|
|
route.id,
|
|
name: nombre.text.trim(),
|
|
truckId: truckId,
|
|
turno: turno,
|
|
status: status,
|
|
);
|
|
} else {
|
|
await _service.createRoute(
|
|
id: id.text.trim(),
|
|
name: nombre.text.trim().isEmpty
|
|
? null
|
|
: nombre.text.trim(),
|
|
truckId: truckId,
|
|
turno: turno,
|
|
status: status,
|
|
);
|
|
}
|
|
if (ctx.mounted) Navigator.pop(ctx, true);
|
|
} catch (e) {
|
|
_snack('Error: ${_errMsg(e)}', error: true);
|
|
}
|
|
},
|
|
child: const Text('Guardar'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
if (saved == true) {
|
|
ref.invalidate(adminRoutesProvider);
|
|
_snack(isEdit ? 'Ruta actualizada' : 'Ruta creada');
|
|
}
|
|
}
|
|
|
|
// ── Formulario camión (unit) ────────────────────────────────────────────────
|
|
Future<void> _showUnitForm({AdminUnitModel? unit}) async {
|
|
final isEdit = unit != null;
|
|
final idCtrl = TextEditingController(text: unit?.id.toString() ?? '');
|
|
final plate = TextEditingController(text: unit?.plate ?? '');
|
|
String status = unit?.status ?? 'active';
|
|
final formKey = GlobalKey<FormState>();
|
|
|
|
final saved = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) {
|
|
return StatefulBuilder(
|
|
builder: (ctx, setStateDialog) {
|
|
return AlertDialog(
|
|
backgroundColor: AppTheme.surface,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
|
),
|
|
title: Text(isEdit ? 'Editar camión' : 'Nuevo camión'),
|
|
content: Form(
|
|
key: formKey,
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_textField(
|
|
idCtrl,
|
|
'ID numérico (ej. 101)',
|
|
keyboardType: TextInputType.number,
|
|
required: true,
|
|
enabled: !isEdit,
|
|
validator: (v) {
|
|
if (v == null || v.trim().isEmpty) return 'Requerido';
|
|
if (int.tryParse(v) == null)
|
|
return 'Debe ser numérico';
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 10),
|
|
_textField(plate, 'Placa'),
|
|
const SizedBox(height: 10),
|
|
DropdownButtonFormField<String>(
|
|
initialValue: status,
|
|
decoration: InputDecoration(
|
|
labelText: 'Estado',
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(
|
|
AppTheme.radiusMd,
|
|
),
|
|
),
|
|
),
|
|
items: const [
|
|
DropdownMenuItem(
|
|
value: 'active',
|
|
child: Text('Activo'),
|
|
),
|
|
DropdownMenuItem(
|
|
value: 'inactive',
|
|
child: Text('Inactivo'),
|
|
),
|
|
DropdownMenuItem(
|
|
value: 'maintenance',
|
|
child: Text('Mantenimiento'),
|
|
),
|
|
],
|
|
onChanged: (v) {
|
|
if (v != null) setStateDialog(() => status = v);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: const Text('Cancelar'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () async {
|
|
if (!formKey.currentState!.validate()) return;
|
|
try {
|
|
if (isEdit) {
|
|
await _service.updateUnit(
|
|
unit.id,
|
|
plate: plate.text.trim().isEmpty
|
|
? null
|
|
: plate.text.trim(),
|
|
status: status,
|
|
);
|
|
} else {
|
|
await _service.createUnit(
|
|
id: int.parse(idCtrl.text.trim()),
|
|
plate: plate.text.trim().isEmpty
|
|
? null
|
|
: plate.text.trim(),
|
|
status: status,
|
|
);
|
|
}
|
|
if (ctx.mounted) Navigator.pop(ctx, true);
|
|
} catch (e) {
|
|
_snack('Error: ${_errMsg(e)}', error: true);
|
|
}
|
|
},
|
|
child: const Text('Guardar'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
if (saved == true) {
|
|
ref.invalidate(adminUnitsProvider);
|
|
ref.invalidate(adminRoutesProvider);
|
|
_snack(isEdit ? 'Camión actualizado' : 'Camión creado');
|
|
}
|
|
}
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
Widget _textField(
|
|
TextEditingController controller,
|
|
String label, {
|
|
TextInputType keyboardType = TextInputType.text,
|
|
bool obscure = false,
|
|
bool required = false,
|
|
bool enabled = true,
|
|
String? Function(String?)? validator,
|
|
}) {
|
|
return TextFormField(
|
|
controller: controller,
|
|
keyboardType: keyboardType,
|
|
obscureText: obscure,
|
|
enabled: enabled,
|
|
validator:
|
|
validator ??
|
|
(required
|
|
? (v) =>
|
|
(v == null || v.trim().isEmpty) ? 'Campo requerido' : null
|
|
: null),
|
|
decoration: InputDecoration(
|
|
labelText: label,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _errMsg(Object e) {
|
|
final s = e.toString();
|
|
return s.length > 220 ? '${s.substring(0, 220)}…' : s;
|
|
}
|
|
}
|
|
|
|
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
|
class _UsersTab extends ConsumerWidget {
|
|
const _UsersTab();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final async = ref.watch(adminUsersProvider);
|
|
return async.when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (e, _) => _ErrorView(
|
|
message: e.toString(),
|
|
onRetry: () => ref.invalidate(adminUsersProvider),
|
|
),
|
|
data: (users) {
|
|
if (users.isEmpty) {
|
|
return const _EmptyView('No hay usuarios registrados.');
|
|
}
|
|
return ListView.separated(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
|
|
itemCount: users.length,
|
|
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
|
itemBuilder: (context, i) {
|
|
final u = users[i];
|
|
return AppCard(
|
|
child: Row(
|
|
children: [
|
|
CircleAvatar(
|
|
backgroundColor: AppTheme.primaryLight,
|
|
foregroundColor: AppTheme.primary,
|
|
child: Text(u.initials),
|
|
),
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
u.displayName,
|
|
style: const TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
if (u.email != null && u.email!.isNotEmpty)
|
|
Text(
|
|
u.email!,
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
if (u.phone != null && u.phone!.isNotEmpty)
|
|
Text(u.phone!, style: const TextStyle(fontSize: 13)),
|
|
const SizedBox(height: 4),
|
|
_roleBadge(u.role),
|
|
],
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(
|
|
Icons.edit_outlined,
|
|
color: AppTheme.primary,
|
|
),
|
|
onPressed: () {
|
|
final state = context
|
|
.findAncestorStateOfType<_AdminScreenState>();
|
|
state?._showUserForm(user: u);
|
|
},
|
|
),
|
|
IconButton(
|
|
icon: const Icon(
|
|
Icons.delete_outline,
|
|
color: AppTheme.danger,
|
|
),
|
|
onPressed: () => _confirmAndDelete(
|
|
context,
|
|
tipo: 'usuario',
|
|
onConfirm: () async {
|
|
await ref.read(adminServiceProvider).deleteUser(u.id);
|
|
ref.invalidate(adminUsersProvider);
|
|
ref.invalidate(adminDriversProvider);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _roleBadge(String role) {
|
|
switch (role) {
|
|
case 'admin':
|
|
return AppStatusBadge.amber('Administrador');
|
|
case 'driver':
|
|
return AppStatusBadge.green('Conductor');
|
|
default:
|
|
return AppStatusBadge.gray('Ciudadano');
|
|
}
|
|
}
|
|
}
|
|
|
|
class _RoutesTab extends ConsumerWidget {
|
|
const _RoutesTab();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final async = ref.watch(adminRoutesProvider);
|
|
final units = ref
|
|
.watch(adminUnitsProvider)
|
|
.maybeWhen(data: (u) => u, orElse: () => <AdminUnitModel>[]);
|
|
|
|
return async.when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (e, _) => _ErrorView(
|
|
message: e.toString(),
|
|
onRetry: () => ref.invalidate(adminRoutesProvider),
|
|
),
|
|
data: (routes) {
|
|
if (routes.isEmpty) {
|
|
return const _EmptyView('No hay rutas registradas.');
|
|
}
|
|
return ListView.separated(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
|
|
itemCount: routes.length,
|
|
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
|
itemBuilder: (context, i) {
|
|
final r = routes[i];
|
|
AdminUnitModel? unit;
|
|
if (r.truckId != null) {
|
|
for (final u in units) {
|
|
if (u.id == r.truckId) {
|
|
unit = u;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return AppCard(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
r.displayName,
|
|
style: const TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
_routeStatusBadge(r.status),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'ID: ${r.id}',
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
if (r.turno != null)
|
|
Text(
|
|
'Turno: ${r.turno}',
|
|
style: const TextStyle(fontSize: 13),
|
|
),
|
|
Text(
|
|
'Camión: ${unit?.displayPlate ?? (r.truckId == null ? 'Sin asignar' : '#${r.truckId}')}',
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
Text(
|
|
'Posición actual: ${r.currentPositionId}/8',
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton.icon(
|
|
onPressed: () {
|
|
final state = context
|
|
.findAncestorStateOfType<_AdminScreenState>();
|
|
state?._showRouteForm(route: r);
|
|
},
|
|
icon: const Icon(Icons.edit_outlined, size: 18),
|
|
label: const Text('Editar'),
|
|
),
|
|
const SizedBox(width: 8),
|
|
TextButton.icon(
|
|
onPressed: () => _confirmAndDelete(
|
|
context,
|
|
tipo: 'ruta',
|
|
onConfirm: () async {
|
|
await ref
|
|
.read(adminServiceProvider)
|
|
.deleteRoute(r.id);
|
|
ref.invalidate(adminRoutesProvider);
|
|
},
|
|
),
|
|
icon: const Icon(Icons.delete_outline, size: 18),
|
|
label: const Text('Eliminar'),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: AppTheme.danger,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _routeStatusBadge(String status) {
|
|
switch (status) {
|
|
case 'en_ruta':
|
|
return AppStatusBadge.amber('En ruta');
|
|
case 'completada':
|
|
return AppStatusBadge.green('Completada');
|
|
case 'diferida':
|
|
return AppStatusBadge.danger('Diferida');
|
|
case 'reasignada':
|
|
return AppStatusBadge.amber('Reasignada');
|
|
default:
|
|
return AppStatusBadge.gray('Pendiente');
|
|
}
|
|
}
|
|
}
|
|
|
|
class _TrucksTab extends ConsumerWidget {
|
|
const _TrucksTab();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final async = ref.watch(adminUnitsProvider);
|
|
final routes = ref
|
|
.watch(adminRoutesProvider)
|
|
.maybeWhen(data: (r) => r, orElse: () => <AdminRouteModel>[]);
|
|
final drivers = ref
|
|
.watch(adminDriversProvider)
|
|
.maybeWhen(data: (d) => d, orElse: () => <AdminDriverModel>[]);
|
|
|
|
return async.when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (e, _) => _ErrorView(
|
|
message: e.toString(),
|
|
onRetry: () => ref.invalidate(adminUnitsProvider),
|
|
),
|
|
data: (units) {
|
|
if (units.isEmpty) {
|
|
return const _EmptyView('No hay camiones registrados.');
|
|
}
|
|
return ListView.separated(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
|
|
itemCount: units.length,
|
|
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
|
itemBuilder: (context, i) {
|
|
final t = units[i];
|
|
AdminRouteModel? assignedRoute;
|
|
for (final r in routes) {
|
|
if (r.truckId == t.id) {
|
|
assignedRoute = r;
|
|
break;
|
|
}
|
|
}
|
|
AdminDriverModel? assignedDriver;
|
|
for (final d in drivers) {
|
|
if (d.unitId == t.id) {
|
|
assignedDriver = d;
|
|
break;
|
|
}
|
|
}
|
|
return AppCard(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
t.displayPlate,
|
|
style: const TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
_unitStatusBadge(t.status),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'ID: #${t.id}',
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
Text(
|
|
'Conductor: ${assignedDriver?.displayName ?? 'Sin asignar'}',
|
|
style: const TextStyle(fontSize: 13),
|
|
),
|
|
Text(
|
|
'Ruta: ${assignedRoute?.displayName ?? 'Sin asignar'}',
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton.icon(
|
|
onPressed: () {
|
|
final state = context
|
|
.findAncestorStateOfType<_AdminScreenState>();
|
|
state?._showUnitForm(unit: t);
|
|
},
|
|
icon: const Icon(Icons.edit_outlined, size: 18),
|
|
label: const Text('Editar'),
|
|
),
|
|
const SizedBox(width: 8),
|
|
TextButton.icon(
|
|
onPressed: () => _confirmAndDelete(
|
|
context,
|
|
tipo: 'camión',
|
|
onConfirm: () async {
|
|
await ref
|
|
.read(adminServiceProvider)
|
|
.deleteUnit(t.id);
|
|
ref.invalidate(adminUnitsProvider);
|
|
ref.invalidate(adminRoutesProvider);
|
|
},
|
|
),
|
|
icon: const Icon(Icons.delete_outline, size: 18),
|
|
label: const Text('Eliminar'),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: AppTheme.danger,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _unitStatusBadge(String status) {
|
|
switch (status) {
|
|
case 'inactive':
|
|
return AppStatusBadge.gray('Inactivo');
|
|
case 'maintenance':
|
|
return AppStatusBadge.amber('Mantenimiento');
|
|
default:
|
|
return AppStatusBadge.green('Activo');
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Shared widgets ────────────────────────────────────────────────────────────
|
|
class _EmptyView extends StatelessWidget {
|
|
const _EmptyView(this.message);
|
|
final String message;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Text(
|
|
message,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(fontSize: 15, color: AppTheme.textSecondary),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
class _ErrorView extends StatelessWidget {
|
|
const _ErrorView({required this.message, required this.onRetry});
|
|
final String message;
|
|
final VoidCallback onRetry;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(Icons.error_outline, color: AppTheme.danger, size: 48),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
message,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary),
|
|
),
|
|
const SizedBox(height: 16),
|
|
ElevatedButton.icon(
|
|
onPressed: onRetry,
|
|
icon: const Icon(Icons.refresh),
|
|
label: const Text('Reintentar'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _confirmAndDelete(
|
|
BuildContext context, {
|
|
required String tipo,
|
|
required Future<void> Function() onConfirm,
|
|
}) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
backgroundColor: AppTheme.surface,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
|
),
|
|
title: Text('Eliminar $tipo'),
|
|
content: Text('¿Deseas eliminar este $tipo?'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx),
|
|
style: TextButton.styleFrom(foregroundColor: AppTheme.textSecondary),
|
|
child: const Text('Cancelar'),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
Navigator.pop(ctx);
|
|
try {
|
|
await onConfirm();
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text('$tipo eliminado')));
|
|
}
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Error: $e'),
|
|
backgroundColor: AppTheme.danger,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
style: TextButton.styleFrom(foregroundColor: AppTheme.danger),
|
|
child: const Text('Eliminar'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── Legacy stubs (no longer used; kept enum to avoid breaking imports) ────────
|
|
enum _LegacyTruckStatus { disponible, enRuta, mantenimiento, detenido }
|
|
|
|
extension TruckStatusX on TruckStatus {
|
|
String get label => switch (this) {
|
|
TruckStatus.disponible => 'Disponible',
|
|
TruckStatus.enRuta => 'En ruta',
|
|
TruckStatus.mantenimiento => 'Mantenimiento',
|
|
TruckStatus.detenido => 'Detenido',
|
|
};
|
|
|
|
AppStatusBadge get badge => switch (this) {
|
|
TruckStatus.disponible => AppStatusBadge.green(label),
|
|
TruckStatus.enRuta => AppStatusBadge.amber(label),
|
|
TruckStatus.mantenimiento => AppStatusBadge.gray(label),
|
|
TruckStatus.detenido => AppStatusBadge.gray(label),
|
|
};
|
|
}
|
|
|
|
class _AdminUser {
|
|
final String id, nombre, apellido, email, telefono;
|
|
const _AdminUser({
|
|
required this.id,
|
|
required this.nombre,
|
|
required this.apellido,
|
|
required this.email,
|
|
required this.telefono,
|
|
});
|
|
String get nombreCompleto => '$nombre $apellido';
|
|
String get iniciales =>
|
|
'${nombre.isNotEmpty ? nombre[0] : ''}${apellido.isNotEmpty ? apellido[0] : ''}'
|
|
.toUpperCase();
|
|
_AdminUser copyWith({
|
|
String? nombre,
|
|
String? apellido,
|
|
String? email,
|
|
String? telefono,
|
|
}) => _AdminUser(
|
|
id: id,
|
|
nombre: nombre ?? this.nombre,
|
|
apellido: apellido ?? this.apellido,
|
|
email: email ?? this.email,
|
|
telefono: telefono ?? this.telefono,
|
|
);
|
|
}
|
|
|
|
class _AdminRoute {
|
|
final String id, nombre, zona;
|
|
final bool activa;
|
|
const _AdminRoute({
|
|
required this.id,
|
|
required this.nombre,
|
|
required this.zona,
|
|
this.activa = true,
|
|
});
|
|
_AdminRoute copyWith({String? nombre, String? zona, bool? activa}) =>
|
|
_AdminRoute(
|
|
id: id,
|
|
nombre: nombre ?? this.nombre,
|
|
zona: zona ?? this.zona,
|
|
activa: activa ?? this.activa,
|
|
);
|
|
}
|
|
|
|
class _AdminTruck {
|
|
final String id, placas, modelo, conductor, rutaId;
|
|
final TruckStatus status;
|
|
const _AdminTruck({
|
|
required this.id,
|
|
required this.placas,
|
|
required this.modelo,
|
|
required this.conductor,
|
|
required this.status,
|
|
required this.rutaId,
|
|
});
|
|
_AdminTruck copyWith({
|
|
String? placas,
|
|
String? modelo,
|
|
String? conductor,
|
|
TruckStatus? status,
|
|
String? rutaId,
|
|
}) => _AdminTruck(
|
|
id: id,
|
|
placas: placas ?? this.placas,
|
|
modelo: modelo ?? this.modelo,
|
|
conductor: conductor ?? this.conductor,
|
|
status: status ?? this.status,
|
|
rutaId: rutaId ?? this.rutaId,
|
|
);
|
|
}
|
|
|
|
// ── Pantalla ──────────────────────────────────────────────────────────────────
|
|
class AdminScreen extends StatefulWidget {
|
|
const AdminScreen({super.key});
|
|
|
|
@override
|
|
State<AdminScreen> createState() => _AdminScreenState();
|
|
}
|
|
|
|
class _AdminScreenState extends State<AdminScreen>
|
|
with SingleTickerProviderStateMixin {
|
|
late final TabController _tabController;
|
|
int _activeTab = 0;
|
|
|
|
final List<_AdminUser> _usuarios = [
|
|
const _AdminUser(
|
|
id: 'u-01',
|
|
nombre: 'Laura',
|
|
apellido: 'Gómez',
|
|
email: 'laura@recolecta.com',
|
|
telefono: '+52 461 987 1234',
|
|
),
|
|
const _AdminUser(
|
|
id: 'u-02',
|
|
nombre: 'Miguel',
|
|
apellido: 'Sánchez',
|
|
email: 'miguel@recolecta.com',
|
|
telefono: '+52 461 123 7890',
|
|
),
|
|
];
|
|
|
|
final List<_AdminRoute> _rutas = [
|
|
const _AdminRoute(id: 'RUTA-01', nombre: 'Ruta Norte', zona: 'Zona Norte'),
|
|
const _AdminRoute(
|
|
id: 'RUTA-02',
|
|
nombre: 'Ruta Sur',
|
|
zona: 'Zona Sur',
|
|
activa: false,
|
|
),
|
|
];
|
|
|
|
final List<_AdminTruck> _camiones = [
|
|
const _AdminTruck(
|
|
id: 't-01',
|
|
placas: 'GTO-101',
|
|
modelo: 'Volvo FH',
|
|
conductor: 'Javier Pérez',
|
|
status: TruckStatus.enRuta,
|
|
rutaId: 'RUTA-01',
|
|
),
|
|
const _AdminTruck(
|
|
id: 't-02',
|
|
placas: 'GTO-103',
|
|
modelo: 'Mercedes 1830',
|
|
conductor: 'Ana Díaz',
|
|
status: TruckStatus.disponible,
|
|
rutaId: 'RUTA-02',
|
|
),
|
|
];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_tabController = TabController(length: 3, vsync: this)
|
|
..addListener(() {
|
|
if (!_tabController.indexIsChanging) {
|
|
setState(() => _activeTab = _tabController.index);
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_tabController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: AppTheme.background,
|
|
appBar: AppBar(
|
|
title: const Text('Panel de administración'),
|
|
bottom: TabBar(
|
|
controller: _tabController,
|
|
indicatorColor: Colors.white,
|
|
labelColor: Colors.white,
|
|
unselectedLabelColor: Colors.white70,
|
|
tabs: const [
|
|
Tab(text: 'Usuarios'),
|
|
Tab(text: 'Rutas'),
|
|
Tab(text: 'Camiones'),
|
|
],
|
|
),
|
|
),
|
|
body: TabBarView(
|
|
controller: _tabController,
|
|
children: [_buildUsersTab(), _buildRoutesTab(), _buildTrucksTab()],
|
|
),
|
|
floatingActionButton: FloatingActionButton.extended(
|
|
onPressed: () {
|
|
if (_activeTab == 0)
|
|
_showUserForm();
|
|
else if (_activeTab == 1)
|
|
_showRouteForm();
|
|
else
|
|
_showTruckForm();
|
|
},
|
|
backgroundColor: AppTheme.primary,
|
|
label: Text(
|
|
_activeTab == 0
|
|
? 'Nuevo usuario'
|
|
: _activeTab == 1
|
|
? 'Nueva ruta'
|
|
: 'Nuevo camión',
|
|
),
|
|
icon: const Icon(Icons.add),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── Tab usuarios ────────────────────────────────────────────────────────────
|
|
Widget _buildUsersTab() {
|
|
if (_usuarios.isEmpty) return _emptyState('No hay usuarios registrados.');
|
|
return ListView.separated(
|
|
padding: const EdgeInsets.all(16),
|
|
itemCount: _usuarios.length,
|
|
separatorBuilder: (_, i) => const SizedBox(height: 12),
|
|
itemBuilder: (context, i) {
|
|
final u = _usuarios[i];
|
|
return AppCard(
|
|
child: Row(
|
|
children: [
|
|
CircleAvatar(
|
|
backgroundColor: AppTheme.primaryLight,
|
|
foregroundColor: AppTheme.primary,
|
|
child: Text(u.iniciales),
|
|
),
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
u.nombreCompleto,
|
|
style: const TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
u.email,
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
Text(u.telefono, style: const TextStyle(fontSize: 13)),
|
|
],
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.edit_outlined, color: AppTheme.primary),
|
|
onPressed: () => _showUserForm(user: u),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.delete_outline, color: AppTheme.danger),
|
|
onPressed: () => _confirmDelete(
|
|
'usuario',
|
|
() => setState(
|
|
() => _usuarios.removeWhere((x) => x.id == u.id),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
// ── Tab rutas ───────────────────────────────────────────────────────────────
|
|
Widget _buildRoutesTab() {
|
|
if (_rutas.isEmpty) return _emptyState('No hay rutas registradas.');
|
|
return ListView.separated(
|
|
padding: const EdgeInsets.all(16),
|
|
itemCount: _rutas.length,
|
|
separatorBuilder: (_, i) => const SizedBox(height: 12),
|
|
itemBuilder: (context, i) {
|
|
final r = _rutas[i];
|
|
return AppCard(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
r.nombre,
|
|
style: const TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
r.activa
|
|
? AppStatusBadge.green('Activa')
|
|
: AppStatusBadge.gray('Inactiva'),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
r.zona,
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton.icon(
|
|
onPressed: () => _showRouteForm(route: r),
|
|
icon: const Icon(Icons.edit_outlined, size: 18),
|
|
label: const Text('Editar'),
|
|
),
|
|
const SizedBox(width: 8),
|
|
TextButton.icon(
|
|
onPressed: () => _confirmDelete(
|
|
'ruta',
|
|
() => setState(
|
|
() => _rutas.removeWhere((x) => x.id == r.id),
|
|
),
|
|
),
|
|
icon: const Icon(Icons.delete_outline, size: 18),
|
|
label: const Text('Eliminar'),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: AppTheme.danger,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
// ── Tab camiones ────────────────────────────────────────────────────────────
|
|
Widget _buildTrucksTab() {
|
|
if (_camiones.isEmpty) return _emptyState('No hay camiones registrados.');
|
|
return ListView.separated(
|
|
padding: const EdgeInsets.all(16),
|
|
itemCount: _camiones.length,
|
|
separatorBuilder: (_, i) => const SizedBox(height: 12),
|
|
itemBuilder: (context, i) {
|
|
final t = _camiones[i];
|
|
final ruta = _rutas.firstWhere(
|
|
(r) => r.id == t.rutaId,
|
|
orElse: () => const _AdminRoute(id: '', nombre: 'Sin ruta', zona: ''),
|
|
);
|
|
return AppCard(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
t.placas,
|
|
style: const TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
t.status.badge,
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'${t.modelo} · ${t.conductor}',
|
|
style: const TextStyle(fontSize: 13),
|
|
),
|
|
Text(
|
|
'Ruta: ${ruta.nombre}',
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton.icon(
|
|
onPressed: () => _showTruckForm(truck: t),
|
|
icon: const Icon(Icons.edit_outlined, size: 18),
|
|
label: const Text('Editar'),
|
|
),
|
|
const SizedBox(width: 8),
|
|
TextButton.icon(
|
|
onPressed: () => _confirmDelete(
|
|
'camión',
|
|
() => setState(
|
|
() => _camiones.removeWhere((x) => x.id == t.id),
|
|
),
|
|
),
|
|
icon: const Icon(Icons.delete_outline, size: 18),
|
|
label: const Text('Eliminar'),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: AppTheme.danger,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _emptyState(String msg) => Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Text(
|
|
msg,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(fontSize: 15, color: AppTheme.textSecondary),
|
|
),
|
|
),
|
|
);
|
|
|
|
// ── Confirmación de borrado ─────────────────────────────────────────────────
|
|
void _confirmDelete(String tipo, VoidCallback onConfirm) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
backgroundColor: AppTheme.surface,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
|
),
|
|
title: Text('Eliminar $tipo'),
|
|
content: Text('¿Deseas eliminar este $tipo?'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: AppTheme.textSecondary,
|
|
),
|
|
child: const Text('Cancelar'),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
onConfirm();
|
|
Navigator.pop(ctx);
|
|
},
|
|
style: TextButton.styleFrom(foregroundColor: AppTheme.danger),
|
|
child: const Text('Eliminar'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── Formulario usuario ──────────────────────────────────────────────────────
|
|
void _showUserForm({_AdminUser? user}) {
|
|
final nombreCtrl = TextEditingController(text: user?.nombre);
|
|
final apellidoCtrl = TextEditingController(text: user?.apellido);
|
|
final emailCtrl = TextEditingController(text: user?.email);
|
|
final telefonoCtrl = TextEditingController(text: user?.telefono);
|
|
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: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
TextField(
|
|
controller: nombreCtrl,
|
|
decoration: const InputDecoration(labelText: 'Nombre'),
|
|
),
|
|
TextField(
|
|
controller: apellidoCtrl,
|
|
decoration: const InputDecoration(labelText: 'Apellido'),
|
|
),
|
|
TextField(
|
|
controller: emailCtrl,
|
|
decoration: const InputDecoration(labelText: 'Correo'),
|
|
keyboardType: TextInputType.emailAddress,
|
|
),
|
|
TextField(
|
|
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: () {
|
|
final nuevo = _AdminUser(
|
|
id: user?.id ?? 'u-${DateTime.now().millisecondsSinceEpoch}',
|
|
nombre: nombreCtrl.text.trim(),
|
|
apellido: apellidoCtrl.text.trim(),
|
|
email: emailCtrl.text.trim(),
|
|
telefono: telefonoCtrl.text.trim(),
|
|
);
|
|
setState(() {
|
|
if (user == null) {
|
|
_usuarios.add(nuevo);
|
|
} else {
|
|
final idx = _usuarios.indexWhere((x) => x.id == user.id);
|
|
if (idx >= 0) _usuarios[idx] = nuevo;
|
|
}
|
|
});
|
|
Navigator.pop(ctx);
|
|
},
|
|
child: Text(user == null ? 'Crear' : 'Guardar'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── Formulario ruta ─────────────────────────────────────────────────────────
|
|
void _showRouteForm({_AdminRoute? route}) {
|
|
final nombreCtrl = TextEditingController(text: route?.nombre);
|
|
final zonaCtrl = TextEditingController(text: route?.zona);
|
|
bool activa = route?.activa ?? true;
|
|
showDialog(
|
|
context: context,
|
|
builder: (ctx) => StatefulBuilder(
|
|
builder: (ctx, setInner) => AlertDialog(
|
|
backgroundColor: AppTheme.surface,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
|
),
|
|
title: Text(route == null ? 'Nueva ruta' : 'Editar ruta'),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
TextField(
|
|
controller: nombreCtrl,
|
|
decoration: const InputDecoration(labelText: 'Nombre de ruta'),
|
|
),
|
|
TextField(
|
|
controller: zonaCtrl,
|
|
decoration: const InputDecoration(labelText: 'Zona'),
|
|
),
|
|
Row(
|
|
children: [
|
|
const Expanded(child: Text('Ruta activa')),
|
|
Switch.adaptive(
|
|
value: activa,
|
|
onChanged: (v) => setInner(() => activa = v),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: AppTheme.textSecondary,
|
|
),
|
|
child: const Text('Cancelar'),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
final nueva = _AdminRoute(
|
|
id: route?.id ?? 'r-${DateTime.now().millisecondsSinceEpoch}',
|
|
nombre: nombreCtrl.text.trim(),
|
|
zona: zonaCtrl.text.trim(),
|
|
activa: activa,
|
|
);
|
|
setState(() {
|
|
if (route == null) {
|
|
_rutas.add(nueva);
|
|
} else {
|
|
final idx = _rutas.indexWhere((x) => x.id == route.id);
|
|
if (idx >= 0) _rutas[idx] = nueva;
|
|
}
|
|
});
|
|
Navigator.pop(ctx);
|
|
},
|
|
child: Text(route == null ? 'Crear' : 'Guardar'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── Formulario camión ───────────────────────────────────────────────────────
|
|
void _showTruckForm({_AdminTruck? truck}) {
|
|
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 ?? (_rutas.isNotEmpty ? _rutas.first.id : '');
|
|
showDialog(
|
|
context: context,
|
|
builder: (ctx) => StatefulBuilder(
|
|
builder: (ctx, setInner) => AlertDialog(
|
|
backgroundColor: AppTheme.surface,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
|
),
|
|
title: Text(truck == null ? 'Nuevo camión' : 'Editar camión'),
|
|
content: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
TextField(
|
|
controller: placasCtrl,
|
|
decoration: const InputDecoration(labelText: 'Placas'),
|
|
),
|
|
TextField(
|
|
controller: modeloCtrl,
|
|
decoration: const InputDecoration(labelText: 'Modelo'),
|
|
),
|
|
TextField(
|
|
controller: conductorCtrl,
|
|
decoration: const InputDecoration(labelText: 'Conductor'),
|
|
),
|
|
const SizedBox(height: 12),
|
|
DropdownButtonFormField<String>(
|
|
value: selectedRuta.isEmpty ? null : selectedRuta,
|
|
decoration: const InputDecoration(labelText: 'Ruta'),
|
|
items: _rutas
|
|
.map(
|
|
(r) => DropdownMenuItem(
|
|
value: r.id,
|
|
child: Text(r.nombre),
|
|
),
|
|
)
|
|
.toList(),
|
|
onChanged: (v) {
|
|
if (v != null) setInner(() => selectedRuta = v);
|
|
},
|
|
),
|
|
const SizedBox(height: 12),
|
|
DropdownButtonFormField<TruckStatus>(
|
|
value: status,
|
|
decoration: const InputDecoration(labelText: 'Estatus'),
|
|
items: TruckStatus.values
|
|
.map(
|
|
(s) => DropdownMenuItem(value: s, child: Text(s.label)),
|
|
)
|
|
.toList(),
|
|
onChanged: (v) {
|
|
if (v != null) setInner(() => status = v);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: AppTheme.textSecondary,
|
|
),
|
|
child: const Text('Cancelar'),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
final nuevo = _AdminTruck(
|
|
id: truck?.id ?? 't-${DateTime.now().millisecondsSinceEpoch}',
|
|
placas: placasCtrl.text.trim(),
|
|
modelo: modeloCtrl.text.trim(),
|
|
conductor: conductorCtrl.text.trim(),
|
|
status: status,
|
|
rutaId: selectedRuta,
|
|
);
|
|
setState(() {
|
|
if (truck == null) {
|
|
_camiones.add(nuevo);
|
|
} else {
|
|
final idx = _camiones.indexWhere((x) => x.id == truck.id);
|
|
if (idx >= 0) _camiones[idx] = nuevo;
|
|
}
|
|
});
|
|
Navigator.pop(ctx);
|
|
},
|
|
child: Text(truck == null ? 'Crear' : 'Guardar'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|