Avance de la aplicacion

This commit is contained in:
2026-05-22 20:43:49 -06:00
parent 37e83a8226
commit 458af32fcf
13 changed files with 1918 additions and 463 deletions

View File

@@ -6,6 +6,8 @@ import '../../services/route_simulator_service.dart';
import '../../database/db_helper.dart';
import '../../models/models.dart';
import '../../data/routes_data.dart';
import '../../models/route_model.dart' show ColonyModel;
import 'create_route_screen.dart';
import '../../widgets/route_map_widget.dart';
class AdminDashboardScreen extends StatefulWidget {
@@ -27,6 +29,8 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
_AdminReportesTab(),
_AdminAssignmentsTab(),
_AdminAlertasTab(sim:sim),
_AdminRoutesTab(),
_AdminReviewsTab(),
];
return Scaffold(
@@ -51,6 +55,10 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
selectedIcon:Icon(Icons.calendar_month,color:AppColors.verdeAdmin),label:'Asignar'),
NavigationDestination(icon:Icon(Icons.warning_outlined),
selectedIcon:Icon(Icons.warning,color:AppColors.verdeAdmin),label:'Alertas'),
NavigationDestination(icon:Icon(Icons.route_outlined),
selectedIcon:Icon(Icons.route,color:AppColors.verdeAdmin),label:'Rutas'),
NavigationDestination(icon:Icon(Icons.star_outline),
selectedIcon:Icon(Icons.star,color:AppColors.verdeAdmin),label:'Reseñas'),
],
),
);
@@ -784,3 +792,238 @@ class _AdminBanner extends StatelessWidget {
IconButton(icon:const Icon(Icons.close,color:Colors.white,size:18),onPressed:onDismiss),
]))));
}
// ── TAB 6: Gestión de Rutas ───────────────────────────────────────────────
class _AdminRoutesTab extends StatefulWidget {
@override State<_AdminRoutesTab> createState() => _AdminRoutesTabState();
}
class _AdminRoutesTabState extends State<_AdminRoutesTab> {
List<RouteDefinitionModel> _routes = [];
bool _loading = true;
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final r = await DbHelper.getAllRouteDefinitions();
if (mounted) setState(() { _routes = r; _loading = false; });
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(automaticallyImplyLeading: false,
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
title: Text('Rutas del Sistema (${_routes.length})'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
actions: [
IconButton(icon: const Icon(Icons.refresh), onPressed: _load),
IconButton(
icon: const Icon(Icons.add_circle_outline),
tooltip: 'Nueva ruta',
onPressed: () async {
final ok = await Navigator.push(context, MaterialPageRoute(
builder: (_) => const CreateRouteScreen()));
if (ok == true) await _load();
}),
]),
body: _loading
? const Center(child: CircularProgressIndicator())
: _routes.isEmpty
? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
const Icon(Icons.route, color: AppColors.grisTexto, size: 48),
const SizedBox(height: 12),
const Text('No hay rutas creadas', style: TextStyle(color: AppColors.grisTexto)),
const SizedBox(height: 16),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(backgroundColor: AppColors.verdeAdmin,
foregroundColor: Colors.white),
onPressed: () async {
final ok = await Navigator.push(context, MaterialPageRoute(
builder: (_) => const CreateRouteScreen()));
if (ok == true) await _load();
},
icon: const Icon(Icons.add), label: const Text('Crear primera ruta')),
]))
: ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: _routes.length,
itemBuilder: (_, i) {
final r = _routes[i];
final turnoEmoji = r.turno == 'MATUTINO' ? '🌄'
: r.turno == 'VESPERTINO' ? '🌅' : '🌙';
return Card(margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10),
side: BorderSide(color: AppColors.verdeAdmin.withOpacity(0.3))),
child: Padding(padding: const EdgeInsets.all(14), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Expanded(child: Text('${r.routeId}${r.nombre}',
style: const TextStyle(fontWeight: FontWeight.bold,
fontSize: 14, color: AppColors.verdeAdmin))),
IconButton(icon: const Icon(Icons.edit_outlined, size: 18),
onPressed: () async {
final ok = await Navigator.push(context, MaterialPageRoute(
builder: (_) => CreateRouteScreen(editing: r)));
if (ok == true) await _load();
}),
]),
const SizedBox(height: 4),
Row(children: [
Text('$turnoEmoji ${r.turno}${r.horaInicio}${r.horaFin}',
style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)),
]),
const SizedBox(height: 4),
Text(r.dias.map(AppDias.label).join(', '),
style: const TextStyle(fontSize: 11, color: AppColors.grisTexto)),
const SizedBox(height: 6),
// Colonias
Text('📍 ${r.colonias.length} colonias:',
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12)),
const SizedBox(height: 4),
Wrap(spacing: 4, runSpacing: 4, children: r.colonias.take(8).map((c) =>
Container(padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(color: AppColors.verdeAdmin.withOpacity(0.1),
borderRadius: BorderRadius.circular(8)),
child: Text(c, style: const TextStyle(fontSize: 10,
color: AppColors.verdeAdmin)))).toList()),
if (r.colonias.length > 8)
Text(' ...y ${r.colonias.length - 8} más',
style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
])));
}),
);
}
// ── TAB 7: Reseñas y calificaciones ──────────────────────────────────────
class _AdminReviewsTab extends StatefulWidget {
@override State<_AdminReviewsTab> createState() => _AdminReviewsTabState();
}
class _AdminReviewsTabState extends State<_AdminReviewsTab> {
List<ReviewModel> _reviews = [];
List<Map<String, dynamic>> _summary = [];
bool _showSummary = false;
bool _loading = true;
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final r = await DbHelper.getAllReviews();
final s = await DbHelper.getReviewSummaryByColonia();
if (mounted) setState(() { _reviews = r; _summary = s; _loading = false; });
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(automaticallyImplyLeading: false,
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
title: Text(_showSummary ? 'Calificaciones por Colonia' : 'Reseñas Ciudadanas'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
actions: [
IconButton(
icon: Icon(_showSummary ? Icons.list : Icons.bar_chart),
tooltip: _showSummary ? 'Ver reseñas' : 'Ver por colonia',
onPressed: () => setState(() => _showSummary = !_showSummary)),
IconButton(icon: const Icon(Icons.refresh), onPressed: _load),
]),
body: _loading
? const Center(child: CircularProgressIndicator())
: _showSummary ? _buildSummary() : _buildReviews(),
);
Widget _buildSummary() {
if (_summary.isEmpty) return const Center(
child: Text('Sin calificaciones aún', style: TextStyle(color: AppColors.grisTexto)));
return Column(children: [
// Header explicativo
Container(margin: const EdgeInsets.all(12), padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: Colors.blue.shade50, borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.shade200)),
child: const Row(children: [
Icon(Icons.info_outline, color: AppColors.azulInfo, size: 16),
SizedBox(width: 6),
Expanded(child: Text(
'Colonias ordenadas de menor a mayor calificación. '
'Las primeras requieren atención prioritaria.',
style: TextStyle(fontSize: 11, color: AppColors.azulInfo))),
])),
Expanded(child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: _summary.length,
itemBuilder: (_, i) {
final s = _summary[i];
final prom = (s['promedio'] as num).toDouble();
final total = s['total'] as int;
final colonia = s['colonia'] as String;
final routeId = s['route_id'] as String;
final color = prom >= 4.5 ? AppColors.verdeExito
: prom >= 3.5 ? Colors.amber.shade700
: prom >= 2.5 ? AppColors.naranjaAlerta
: AppColors.rojoError;
final emoji = prom >= 4.5 ? '🟢' : prom >= 3.5 ? '🟡' : prom >= 2.5 ? '🟠' : '🔴';
return Card(margin: const EdgeInsets.only(bottom: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10),
side: BorderSide(color: color.withOpacity(0.3))),
child: Padding(padding: const EdgeInsets.all(12), child: Row(children: [
Container(width: 6, height: 50,
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(3))),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(colonia, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
Text('$emoji $routeId$total reseña${total != 1 ? "s" : ""}',
style: const TextStyle(fontSize: 11, color: AppColors.grisTexto)),
])),
Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
Text(prom.toStringAsFixed(1),
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: color)),
Row(children: List.generate(5, (j) =>
Icon(j < prom.round() ? Icons.star : Icons.star_border,
color: Colors.amber, size: 12))),
]),
])));
})),
]);
}
Widget _buildReviews() {
if (_reviews.isEmpty) return const Center(
child: Text('Sin reseñas aún', style: TextStyle(color: AppColors.grisTexto)));
return ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: _reviews.length,
itemBuilder: (_, i) {
final r = _reviews[i];
final fecha = DateTime.tryParse(r.fecha);
final fechaStr = fecha != null
? '${fecha.day}/${fecha.month}/${fecha.year} ${fecha.hour.toString().padLeft(2,'0')}:${fecha.minute.toString().padLeft(2,'0')}'
: r.fecha;
return Card(margin: const EdgeInsets.only(bottom: 8),
child: Padding(padding: const EdgeInsets.all(12), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
CircleAvatar(backgroundColor: AppColors.guindaPrimary.withOpacity(0.1), radius: 18,
child: Text('${r.estrellas}', style: const TextStyle(fontSize: 11))),
const SizedBox(width: 10),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(r.nombreUsuario, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
Text('${r.colonia}${r.routeId}',
style: const TextStyle(color: AppColors.grisTexto, fontSize: 11)),
])),
Text(fechaStr, style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
]),
const SizedBox(height: 8),
Row(children: List.generate(5, (j) =>
Icon(j < r.estrellas ? Icons.star : Icons.star_border,
color: Colors.amber, size: 16))),
if (r.comentario.isNotEmpty && r.comentario != 'Sin comentario') ...[
const SizedBox(height: 6),
Text('"${r.comentario}"',
style: const TextStyle(fontSize: 12, fontStyle: FontStyle.italic,
color: AppColors.negroTexto)),
],
])));
});
}
}

View File

@@ -0,0 +1,272 @@
import 'package:flutter/material.dart';
import '../../core/app_colors.dart';
import '../../data/celaya_colonias.dart';
import '../../database/db_helper.dart';
import '../../models/models.dart';
class CreateRouteScreen extends StatefulWidget {
final RouteDefinitionModel? editing;
const CreateRouteScreen({super.key, this.editing});
@override State<CreateRouteScreen> createState() => _CreateRouteScreenState();
}
class _CreateRouteScreenState extends State<CreateRouteScreen> {
final _nombreCtrl = TextEditingController();
final _routeIdCtrl = TextEditingController();
String _turno = 'MATUTINO';
String _horaInicio = '06:00';
String _horaFin = '08:00';
List<String> _diasSeleccionados = [];
List<String> _coloniasSeleccionadas = [];
String _searchColonia = '';
bool _loading = false;
static const _diasGrupoA = ['LUNES', 'MIERCOLES', 'VIERNES'];
static const _diasGrupoB = ['MARTES', 'JUEVES', 'SABADO'];
@override
void initState() {
super.initState();
if (widget.editing != null) {
final e = widget.editing!;
_nombreCtrl.text = e.nombre;
_routeIdCtrl.text = e.routeId;
_turno = e.turno;
_horaInicio = e.horaInicio;
_horaFin = e.horaFin;
_diasSeleccionados = List.from(e.dias);
_coloniasSeleccionadas = List.from(e.colonias);
}
}
List<String> get _filteredColonias => _searchColonia.isEmpty
? celayaColonias
: celayaColonias.where((c) =>
c.toLowerCase().contains(_searchColonia.toLowerCase())).toList();
Future<void> _guardar() async {
if (_nombreCtrl.text.trim().isEmpty) {
_snack('Ingresa un nombre para la ruta', isError: true); return; }
if (_routeIdCtrl.text.trim().isEmpty) {
_snack('Ingresa el ID de la ruta (ej. RUTA-16)', isError: true); return; }
if (_diasSeleccionados.isEmpty) {
_snack('Selecciona al menos un día', isError: true); return; }
if (_coloniasSeleccionadas.isEmpty) {
_snack('Selecciona al menos una colonia', isError: true); return; }
setState(() => _loading = true);
final route = RouteDefinitionModel(
id: widget.editing?.id,
routeId: _routeIdCtrl.text.trim().toUpperCase(),
nombre: _nombreCtrl.text.trim(),
dias: _diasSeleccionados,
horaInicio: _horaInicio,
horaFin: _horaFin,
turno: _turno,
colonias: _coloniasSeleccionadas,
);
await DbHelper.insertRouteDefinition(route);
if (!mounted) return;
setState(() => _loading = false);
_snack('Ruta guardada correctamente');
Navigator.pop(context, true);
}
void _snack(String msg, {bool isError = false}) =>
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(msg),
backgroundColor: isError ? AppColors.rojoError : AppColors.verdeExito));
Future<TimeOfDay?> _pickTime(String current) async {
final parts = current.split(':');
return showTimePicker(
context: context,
initialTime: TimeOfDay(hour: int.parse(parts[0]), minute: int.parse(parts[1])),
);
}
String _timeLabel(TimeOfDay t) =>
'${t.hour.toString().padLeft(2,'0')}:${t.minute.toString().padLeft(2,'0')}';
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
title: Text(widget.editing != null ? 'Editar Ruta' : 'Nueva Ruta'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// Info básica
_section('Información de la ruta'),
_field(_routeIdCtrl, 'ID de Ruta (ej. RUTA-16)', Icons.tag),
const SizedBox(height: 12),
_field(_nombreCtrl, 'Nombre descriptivo', Icons.route),
const SizedBox(height: 16),
// Turno
_section('Turno de operación'),
Row(children: ['MATUTINO','VESPERTINO','NOCTURNO'].map((t) =>
Expanded(child: RadioListTile<String>(dense: true, value: t,
groupValue: _turno,
title: Text(_turnoLabel(t), style: const TextStyle(fontSize: 12)),
activeColor: AppColors.verdeAdmin,
onChanged: (v) => setState(() => _turno = v!)))
).toList()),
const SizedBox(height: 8),
// Horario
_section('Horario de servicio'),
Row(children: [
Expanded(child: _timeButton('Hora inicio', _horaInicio, () async {
final t = await _pickTime(_horaInicio);
if (t != null) setState(() => _horaInicio = _timeLabel(t));
})),
const SizedBox(width: 12),
Expanded(child: _timeButton('Hora fin', _horaFin, () async {
final t = await _pickTime(_horaFin);
if (t != null) setState(() => _horaFin = _timeLabel(t));
})),
]),
const SizedBox(height: 16),
// Días
_section('Días de operación'),
Container(padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.shade200)),
child: const Text(
'📅 Selecciona Grupo A (L/M/V) o Grupo B (M/J/S), o días individuales.',
style: TextStyle(fontSize: 12, color: AppColors.azulInfo)),
),
const SizedBox(height: 8),
Row(children: [
Expanded(child: OutlinedButton(
onPressed: () => setState(() => _diasSeleccionados = List.from(_diasGrupoA)),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.verdeAdmin,
side: const BorderSide(color: AppColors.verdeAdmin)),
child: const Text('Grupo A\nL/M/V', textAlign: TextAlign.center,
style: TextStyle(fontSize: 11)))),
const SizedBox(width: 8),
Expanded(child: OutlinedButton(
onPressed: () => setState(() => _diasSeleccionados = List.from(_diasGrupoB)),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.moradoConductor,
side: const BorderSide(color: AppColors.moradoConductor)),
child: const Text('Grupo B\nM/J/S', textAlign: TextAlign.center,
style: TextStyle(fontSize: 11)))),
]),
const SizedBox(height: 8),
Wrap(spacing: 6, runSpacing: 6, children: AppDias.todos.map((dia) {
final sel = _diasSeleccionados.contains(dia);
return FilterChip(
label: Text(AppDias.label(dia), style: TextStyle(fontSize: 11,
color: sel ? Colors.white : AppColors.negroTexto)),
selected: sel,
selectedColor: AppColors.verdeAdmin,
checkmarkColor: Colors.white,
onSelected: (v) => setState(() {
if (v) _diasSeleccionados.add(dia);
else _diasSeleccionados.remove(dia);
}),
);
}).toList()),
const SizedBox(height: 16),
// Colonias
_section('Colonias que cubre (${_coloniasSeleccionadas.length} seleccionadas)'),
TextField(
onChanged: (v) => setState(() => _searchColonia = v),
decoration: const InputDecoration(
hintText: 'Buscar colonia de Celaya...',
prefixIcon: Icon(Icons.search), border: OutlineInputBorder(),
filled: true, fillColor: Colors.white, isDense: true),
),
const SizedBox(height: 8),
Container(height: 220,
decoration: BoxDecoration(color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300)),
child: ListView.builder(
itemCount: _filteredColonias.length,
itemBuilder: (_, i) {
final c = _filteredColonias[i];
final sel = _coloniasSeleccionadas.contains(c);
return CheckboxListTile(dense: true,
title: Text(c, style: const TextStyle(fontSize: 12)),
value: sel,
activeColor: AppColors.verdeAdmin,
controlAffinity: ListTileControlAffinity.leading,
onChanged: (v) => setState(() {
if (v == true) _coloniasSeleccionadas.add(c);
else _coloniasSeleccionadas.remove(c);
}),
);
},
),
),
if (_coloniasSeleccionadas.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(spacing: 4, runSpacing: 4, children: _coloniasSeleccionadas.map((c) =>
Chip(label: Text(c, style: const TextStyle(fontSize: 10)),
backgroundColor: AppColors.verdeAdmin.withOpacity(0.1),
deleteIconColor: AppColors.verdeAdmin,
onDeleted: () => setState(() => _coloniasSeleccionadas.remove(c)))).toList()),
],
const SizedBox(height: 24),
SizedBox(width: double.infinity, height: 50,
child: ElevatedButton.icon(
onPressed: _loading ? null : _guardar,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
icon: _loading
? const SizedBox(width: 18, height: 18,
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Icon(Icons.save),
label: const Text('GUARDAR RUTA', style: TextStyle(fontWeight: FontWeight.bold)))),
const SizedBox(height: 30),
]),
),
);
}
Widget _section(String title) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(title, style: const TextStyle(fontWeight: FontWeight.bold,
color: AppColors.verdeAdmin, fontSize: 15)));
Widget _field(TextEditingController ctrl, String label, IconData icon) =>
TextField(controller: ctrl,
decoration: InputDecoration(labelText: label,
prefixIcon: Icon(icon, color: AppColors.verdeAdmin),
border: const OutlineInputBorder(), filled: true, fillColor: Colors.white));
Widget _timeButton(String label, String value, VoidCallback onTap) =>
InkWell(onTap: onTap,
child: Container(padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade400)),
child: Row(children: [
const Icon(Icons.access_time, color: AppColors.verdeAdmin, size: 18),
const SizedBox(width: 8),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
Text(value, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
]),
])));
String _turnoLabel(String t) => t == 'MATUTINO' ? '🌄 Matutino'
: t == 'VESPERTINO' ? '🌅 Vespertino' : '🌙 Nocturno';
@override void dispose() { _nombreCtrl.dispose(); _routeIdCtrl.dispose(); super.dispose(); }
}