From 3ef6a9220c7d16fcd8c8434de457395d66211cb4 Mon Sep 17 00:00:00 2001 From: shinra32 Date: Fri, 22 May 2026 20:46:14 -0600 Subject: [PATCH] Co-authored-by: MENDOZA BALLARDO GAEL RICARDO Co-authored-by: Azareth-Tr Co-authored-by: eddgranados12 vistas de mockup --- recolecta_app/lib/app/app.dart | 34 +- .../lib/core/network/api_client.dart | 6 +- .../lib/core/services/auth_controller.dart | 52 +- views/lib/screens/admin_screen.dart | 812 ++++++++++++++++++ views/lib/screens/profile_screen.dart | 19 +- 5 files changed, 882 insertions(+), 41 deletions(-) create mode 100644 views/lib/screens/admin_screen.dart diff --git a/recolecta_app/lib/app/app.dart b/recolecta_app/lib/app/app.dart index e015ee9..4b581a4 100644 --- a/recolecta_app/lib/app/app.dart +++ b/recolecta_app/lib/app/app.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../core/network/api_client.dart'; +import '../core/models/auth_state.dart'; import '../core/services/auth_controller.dart'; import '../core/storage/secure_storage.dart'; import 'bootstrap.dart' as bootstrap; @@ -12,27 +13,28 @@ import '../features/auth/register_page.dart'; import '../features/addresses/new_address_page.dart'; final routerProvider = Provider((ref) { - final authSnapshot = ref.watch(authControllerProvider); - final isAuthenticated = authSnapshot.asData?.value.isAuthenticated ?? false; + // ValueNotifier used as refreshListenable so GoRouter re-evaluates redirect + // without recreating the router (which would unmount widgets mid-request). + final notifier = ValueNotifier(0); + ref.listen>(authControllerProvider, (prev, next) { + notifier.value++; + }); + ref.onDispose(notifier.dispose); return GoRouter( - initialLocation: '/home', + initialLocation: '/login', + refreshListenable: notifier, redirect: (context, state) { + final authSnapshot = ref.read(authControllerProvider); + final isAuthenticated = + authSnapshot.asData?.value.isAuthenticated ?? false; final location = state.matchedLocation; - final isAuthRoute = location == '/login' || location == '/register'; - - if (authSnapshot.isLoading) { - return location == '/login' ? null : '/login'; - } - - if (!isAuthenticated) { - return isAuthRoute ? null : '/login'; - } - - if (isAuthenticated && isAuthRoute) { - return '/home'; - } + final isAuthRoute = + location == '/login' || location == '/register'; + if (authSnapshot.isLoading) return null; + if (!isAuthenticated && !isAuthRoute) return '/login'; + if (isAuthenticated && isAuthRoute) return '/home'; return null; }, routes: [ diff --git a/recolecta_app/lib/core/network/api_client.dart b/recolecta_app/lib/core/network/api_client.dart index 10880cf..43bbe89 100644 --- a/recolecta_app/lib/core/network/api_client.dart +++ b/recolecta_app/lib/core/network/api_client.dart @@ -1,4 +1,5 @@ import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -6,7 +7,10 @@ import '../constants/auth_constants.dart'; import '../storage/secure_storage.dart'; final apiClientProvider = Provider((ref) { - final baseUrl = dotenv.env['API_BASE_URL'] ?? 'http://10.0.2.2:8000'; + final defaultBaseUrl = kIsWeb + ? 'http://localhost:8000' + : 'http://10.0.2.2:8000'; + final baseUrl = dotenv.env['API_BASE_URL'] ?? defaultBaseUrl; final secureStorage = ref.read(secureStorageProvider); final dio = Dio( diff --git a/recolecta_app/lib/core/services/auth_controller.dart b/recolecta_app/lib/core/services/auth_controller.dart index 570432a..41502f9 100644 --- a/recolecta_app/lib/core/services/auth_controller.dart +++ b/recolecta_app/lib/core/services/auth_controller.dart @@ -24,16 +24,22 @@ class AuthController extends AsyncNotifier { Future login({required String email, required String password}) async { state = const AsyncLoading(); - final session = await ref - .read(authServiceProvider) - .login(email: email, password: password); - state = AsyncData( - AuthState.authenticated( - token: session.token, - userRole: session.userRole, - routeId: session.routeId, - ), - ); + + try { + final session = await ref + .read(authServiceProvider) + .login(email: email, password: password); + state = AsyncData( + AuthState.authenticated( + token: session.token, + userRole: session.userRole, + routeId: session.routeId, + ), + ); + } catch (error, stackTrace) { + state = AsyncError(error, stackTrace); + rethrow; + } } Future register({ @@ -42,16 +48,22 @@ class AuthController extends AsyncNotifier { required String password, }) async { state = const AsyncLoading(); - final session = await ref - .read(authServiceProvider) - .register(email: email, phone: phone, password: password); - state = AsyncData( - AuthState.authenticated( - token: session.token, - userRole: session.userRole, - routeId: session.routeId, - ), - ); + + try { + final session = await ref + .read(authServiceProvider) + .register(email: email, phone: phone, password: password); + state = AsyncData( + AuthState.authenticated( + token: session.token, + userRole: session.userRole, + routeId: session.routeId, + ), + ); + } catch (error, stackTrace) { + state = AsyncError(error, stackTrace); + rethrow; + } } Future logout() async { diff --git a/views/lib/screens/admin_screen.dart b/views/lib/screens/admin_screen.dart new file mode 100644 index 0000000..a783cec --- /dev/null +++ b/views/lib/screens/admin_screen.dart @@ -0,0 +1,812 @@ +import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; +import '../widgets/widgets.dart' as w; + +enum TruckStatus { disponible, enRuta, mantenimiento, detenido } + +extension TruckStatusX on TruckStatus { + String get label { + switch (this) { + case TruckStatus.disponible: + return 'Disponible'; + case TruckStatus.enRuta: + return 'En ruta'; + case TruckStatus.mantenimiento: + return 'Mantenimiento'; + case TruckStatus.detenido: + return 'Detenido'; + } + } + + 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: + return w.StatusBadge.gray(label); + case TruckStatus.detenido: + return w.StatusBadge.gray(label); + } + } +} + +class AdminUser { + final String id; + final String nombre; + final String apellido; + final String email; + final String 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, + }) { + return AdminUser( + id: id, + nombre: nombre ?? this.nombre, + apellido: apellido ?? this.apellido, + email: email ?? this.email, + telefono: telefono ?? this.telefono, + ); + } +} + +class AdminRoute { + final String id; + final String nombre; + final String 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, + }) { + return AdminRoute( + id: id, + nombre: nombre ?? this.nombre, + zona: zona ?? this.zona, + activa: activa ?? this.activa, + ); + } +} + +class AdminTruck { + final String id; + final String placas; + final String modelo; + final String conductor; + final TruckStatus status; + final String rutaId; + + 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, + }) { + return AdminTruck( + id: id, + placas: placas ?? this.placas, + modelo: modelo ?? this.modelo, + conductor: conductor ?? this.conductor, + status: status ?? this.status, + rutaId: rutaId ?? this.rutaId, + ); + } +} + +class AdminScreen extends StatefulWidget { + const AdminScreen({super.key}); + + @override + State createState() => _AdminScreenState(); +} + +class _AdminScreenState extends State + with SingleTickerProviderStateMixin { + late final TabController _tabController; + int _activeTab = 0; + + final List _usuarios = [ + const AdminUser( + id: 'user-01', + nombre: 'Laura', + apellido: 'Gómez', + email: 'laura.gomez@rutaverde.com', + telefono: '+52 461 987 1234', + ), + const AdminUser( + id: 'user-02', + nombre: 'Miguel', + apellido: 'Sánchez', + email: 'miguel.sanchez@rutaverde.com', + telefono: '+52 461 123 7890', + ), + ]; + + final List _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 _camiones = [ + const AdminTruck( + id: 'truck-01', + placas: 'ABC-1234', + modelo: 'Volvo FH', + conductor: 'Javier Pérez', + status: TruckStatus.enRuta, + rutaId: 'ruta-01', + ), + const AdminTruck( + id: 'truck-02', + placas: 'DEF-5678', + 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) return; + 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: AppTheme.primary, + 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(); + } + }, + label: Text(_activeTab == 0 + ? 'Nuevo usuario' + : _activeTab == 1 + ? 'Nueva ruta' + : 'Nuevo camión'), + icon: const Icon(Icons.add), + ), + ); + } + + Widget _buildUsersTab() { + if (_usuarios.isEmpty) { + return _buildEmptyState('No hay usuarios registrados aún.'); + } + + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: _usuarios.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final user = _usuarios[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: () => _showUserForm(user: user), + ), + IconButton( + icon: const Icon(Icons.delete_outline, color: AppTheme.danger), + onPressed: () => _confirmDeleteUser(user), + ), + ], + ), + ); + }, + ); + } + + Widget _buildRoutesTab() { + if (_rutas.isEmpty) { + return _buildEmptyState('No hay rutas registradas aún.'); + } + + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: _rutas.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final ruta = _rutas[index]; + return w.AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text(ruta.nombre, + style: const TextStyle( + fontSize: 15, fontWeight: FontWeight.w600)), + ), + Text(ruta.activa ? 'Activa' : 'Inactiva', + style: TextStyle( + fontSize: 13, + color: ruta.activa + ? AppTheme.primary + : AppTheme.textSecondary)), + ], + ), + const SizedBox(height: 8), + Text('Zona ${ruta.zona}', + style: const TextStyle( + fontSize: 13, color: AppTheme.textSecondary)), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + onPressed: () => _showRouteForm(route: ruta), + icon: const Icon(Icons.edit_outlined, size: 18), + label: const Text('Editar'), + ), + const SizedBox(width: 8), + TextButton.icon( + onPressed: () => _confirmDeleteRoute(ruta), + icon: const Icon(Icons.delete_outline, size: 18), + label: const Text('Eliminar'), + ), + ], + ), + ], + ), + ); + }, + ); + } + + Widget _buildTrucksTab() { + if (_camiones.isEmpty) { + return _buildEmptyState('No hay camiones registrados aún.'); + } + + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: _camiones.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final truck = _camiones[index]; + final route = _rutas.firstWhere( + (route) => route.id == truck.rutaId, + orElse: () => + const AdminRoute(id: 'none', nombre: 'Sin ruta', zona: ''), + ); + + 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: () => _showTruckForm(truck: truck), + icon: const Icon(Icons.edit_outlined, size: 18), + label: const Text('Editar'), + ), + const SizedBox(width: 8), + TextButton.icon( + onPressed: () => _confirmDeleteTruck(truck), + icon: const Icon(Icons.delete_outline, size: 18), + label: const Text('Eliminar'), + ), + ], + ), + ], + ), + ); + }, + ); + } + + Widget _buildEmptyState(String message) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text(message, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 15, + color: AppTheme.textSecondary, + )), + ), + ); + } + + void _confirmDeleteUser(AdminUser user) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppTheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusLg)), + title: const Text('Eliminar usuario'), + content: const Text('¿Deseas eliminar este usuario?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + style: + TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), + child: const Text('Cancelar'), + ), + TextButton( + onPressed: () { + setState( + () => _usuarios.removeWhere((item) => item.id == user.id)); + Navigator.pop(ctx); + }, + style: TextButton.styleFrom(foregroundColor: AppTheme.danger), + child: const Text('Eliminar'), + ), + ], + ), + ); + } + + void _confirmDeleteRoute(AdminRoute route) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppTheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusLg)), + title: const Text('Eliminar ruta'), + content: const Text('¿Deseas eliminar esta ruta?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + style: + TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), + child: const Text('Cancelar'), + ), + TextButton( + onPressed: () { + setState(() => _rutas.removeWhere((item) => item.id == route.id)); + Navigator.pop(ctx); + }, + style: TextButton.styleFrom(foregroundColor: AppTheme.danger), + child: const Text('Eliminar'), + ), + ], + ), + ); + } + + void _confirmDeleteTruck(AdminTruck truck) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppTheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusLg)), + title: const Text('Eliminar camión'), + content: const Text('¿Deseas eliminar este camión?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + style: + TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), + child: const Text('Cancelar'), + ), + TextButton( + onPressed: () { + setState( + () => _camiones.removeWhere((item) => item.id == truck.id)); + Navigator.pop(ctx); + }, + style: TextButton.styleFrom(foregroundColor: AppTheme.danger), + child: const Text('Eliminar'), + ), + ], + ), + ); + } + + void _showUserForm({AdminUser? user}) { + final formKey = GlobalKey(); + final nombreCtrl = TextEditingController(text: user?.nombre); + final apellidoCtrl = TextEditingController(text: user?.apellido); + final emailCtrl = TextEditingController(text: user?.email); + final telefonoCtrl = TextEditingController(text: user?.telefono); + + 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: () { + if (!formKey.currentState!.validate()) return; + final nuevo = AdminUser( + id: user?.id ?? 'user-${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 index = + _usuarios.indexWhere((item) => item.id == user.id); + if (index >= 0) _usuarios[index] = nuevo; + } + }); + Navigator.pop(ctx); + }, + child: Text(user == null ? 'Crear' : 'Guardar'), + ), + ], + ), + ); + } + + void _showRouteForm({AdminRoute? route}) { + final formKey = GlobalKey(); + final nombreCtrl = TextEditingController(text: route?.nombre); + final zonaCtrl = TextEditingController(text: route?.zona); + bool activa = route?.activa ?? true; + + showDialog( + context: context, + builder: (ctx) => 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, + ), + 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: () { + if (!formKey.currentState!.validate()) return; + final nueva = AdminRoute( + id: route?.id ?? + 'ruta-${DateTime.now().millisecondsSinceEpoch}', + nombre: nombreCtrl.text.trim(), + zona: zonaCtrl.text.trim(), + activa: activa, + ); + setState(() { + if (route == null) { + _rutas.add(nueva); + } else { + final index = + _rutas.indexWhere((item) => item.id == route.id); + if (index >= 0) _rutas[index] = nueva; + } + }); + Navigator.pop(ctx); + }, + child: Text(route == null ? 'Crear' : 'Guardar'), + ), + ], + ), + ); + } + + void _showTruckForm({AdminTruck? truck}) { + final formKey = GlobalKey(); + final placasCtrl = TextEditingController(text: truck?.placas); + final modeloCtrl = TextEditingController(text: truck?.modelo); + final conductorCtrl = TextEditingController(text: truck?.conductor); + TruckStatus status = truck?.status ?? TruckStatus.disponible; + String selectedRuta = + truck?.rutaId ?? (_rutas.isNotEmpty ? _rutas.first.id : ''); + + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppTheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + ), + title: Text(truck == null ? 'Nuevo camión' : 'Editar camión'), + content: Form( + key: formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: placasCtrl, + decoration: const InputDecoration(labelText: 'Placas'), + validator: (value) => + value?.trim().isEmpty == true ? 'Requerido' : null, + ), + TextFormField( + controller: modeloCtrl, + decoration: const InputDecoration(labelText: 'Modelo'), + validator: (value) => + value?.trim().isEmpty == true ? 'Requerido' : null, + ), + TextFormField( + controller: conductorCtrl, + decoration: const InputDecoration(labelText: 'Conductor'), + validator: (value) => + value?.trim().isEmpty == true ? 'Requerido' : null, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + value: selectedRuta.isEmpty ? null : selectedRuta, + decoration: const InputDecoration(labelText: 'Ruta'), + items: _rutas + .map((ruta) => DropdownMenuItem( + value: ruta.id, + child: Text(ruta.nombre), + )) + .toList(), + onChanged: (value) { + if (value != null) { + selectedRuta = value; + } + }, + validator: (value) => + value == null || value.isEmpty ? 'Requerido' : null, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + value: status, + decoration: const InputDecoration(labelText: 'Estatus'), + items: TruckStatus.values + .map((item) => DropdownMenuItem( + value: item, + child: Text(item.label), + )) + .toList(), + onChanged: (value) { + if (value != null) { + status = value; + } + }, + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + style: + TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), + child: const Text('Cancelar'), + ), + TextButton( + onPressed: () { + if (!formKey.currentState!.validate()) return; + final nuevo = AdminTruck( + id: truck?.id ?? + 'truck-${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 index = + _camiones.indexWhere((item) => item.id == truck.id); + if (index >= 0) _camiones[index] = nuevo; + } + }); + Navigator.pop(ctx); + }, + child: Text(truck == null ? 'Crear' : 'Guardar'), + ), + ], + ), + ); + } +} diff --git a/views/lib/screens/profile_screen.dart b/views/lib/screens/profile_screen.dart index f4b1d05..f148335 100644 --- a/views/lib/screens/profile_screen.dart +++ b/views/lib/screens/profile_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../theme/app_theme.dart'; import '../models/models.dart'; import '../widgets/widgets.dart' as w; +import 'admin_screen.dart'; import 'splash_screen.dart'; class ProfileScreen extends StatelessWidget { @@ -70,6 +71,17 @@ class ProfileScreen extends StatelessWidget { subtitle: 'Claro', onTap: () {}, ), + w.MenuTile( + icon: Icons.admin_panel_settings_outlined, + title: 'Panel de administración', + subtitle: 'Gestiona usuarios, rutas y camiones', + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const AdminScreen()), + ); + }, + ), const SizedBox(height: 16), @@ -111,9 +123,7 @@ class ProfileScreen extends StatelessWidget { 'RutaVerde v1.0.0\nServicio de Limpia · Celaya, Gto.', textAlign: TextAlign.center, style: const TextStyle( - fontSize: 12, - color: AppTheme.textHint, - height: 1.6), + fontSize: 12, color: AppTheme.textHint, height: 1.6), ), ), @@ -142,7 +152,8 @@ class ProfileScreen extends StatelessWidget { actions: [ TextButton( onPressed: () => Navigator.pop(ctx), - style: TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), + style: + TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), child: const Text('Cancelar'), ), TextButton(