Actualizacion del programa

This commit is contained in:
2026-05-23 01:40:39 -06:00
parent 458af32fcf
commit c6a1a67469
132 changed files with 11009 additions and 168 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,262 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../core/app_colors.dart';
import '../../database/db_helper.dart';
class AdminStatsScreen extends StatefulWidget {
const AdminStatsScreen({super.key});
@override State<AdminStatsScreen> createState() => _AdminStatsScreenState();
}
class _AdminStatsScreenState extends State<AdminStatsScreen> {
Map<String, dynamic> _stats = {};
List<Map<String, dynamic>> _byColonia = [];
List<Map<String, dynamic>> _byRoute = [];
List<Map<String, dynamic>> _byWeek = [];
bool _loading = true;
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final s = await DbHelper.getAdminStats();
final bc = await DbHelper.getReportesByColonia();
final br = await DbHelper.getIncidentesByRoute();
final bw = await DbHelper.getRatingByWeek();
if (mounted) setState(() {
_stats = s; _byColonia = bc; _byRoute = br; _byWeek = bw; _loading = false;
});
}
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
title: const Text('Dashboard de Estadisticas'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
actions: [IconButton(icon: const Icon(Icons.refresh), onPressed: _load)],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(padding: const EdgeInsets.all(14), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
// KPIs
Row(children: [
_KpiCard('Reportes', '${_stats['total_reportes']}',
Icons.report, AppColors.naranjaAlerta),
const SizedBox(width: 8),
_KpiCard('Calificacion Prom.',
(_stats['avg_rating'] as double? ?? 0).toStringAsFixed(1),
Icons.star, Colors.amber),
]),
const SizedBox(height: 8),
Row(children: [
_KpiCard('Alertas Activas', '${_stats['alertas_activas']}',
Icons.warning, AppColors.rojoError),
const SizedBox(width: 8),
_KpiCard('Conductores', '${_stats['total_conductores']}',
Icons.person, AppColors.moradoConductor),
]),
const SizedBox(height: 20),
// Calificacion por semana (línea)
if (_byWeek.isNotEmpty) ...[
_SectionTitle('Calificacion promedio semanal'),
const SizedBox(height: 8),
Card(child: Padding(padding: const EdgeInsets.all(16),
child: SizedBox(height: 180,
child: LineChart(LineChartData(
minY: 1, maxY: 5,
titlesData: FlTitlesData(
leftTitles: AxisTitles(sideTitles: SideTitles(
showTitles: true, interval: 1,
getTitlesWidget: (v,_) => Text(v.toInt().toString(),
style: const TextStyle(fontSize: 10)))),
bottomTitles: AxisTitles(sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (v, _) {
final idx = v.toInt();
if (idx < 0 || idx >= _byWeek.length) return const SizedBox();
return Text('S${_byWeek.length - idx}',
style: const TextStyle(fontSize: 9));
})),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
gridData: FlGridData(drawHorizontalLine: true, horizontalInterval: 1),
borderData: FlBorderData(show: true,
border: Border.all(color: Colors.grey.shade300)),
lineBarsData: [LineChartBarData(
spots: _byWeek.reversed.toList().asMap().entries.map((e) =>
FlSpot(e.key.toDouble(),
(e.value['promedio'] as num? ?? 0).toDouble().clamp(1.0, 5.0))).toList(),
isCurved: true,
color: AppColors.verdeAdmin,
barWidth: 3,
belowBarData: BarAreaData(show: true,
color: AppColors.verdeAdmin.withOpacity(0.1)),
dotData: const FlDotData(show: true),
)],
))))),
const SizedBox(height: 20),
],
// Reportes por colonia (barras horizontales)
if (_byColonia.isNotEmpty) ...[
_SectionTitle('Reportes por colonia (Top 10)'),
const SizedBox(height: 8),
Card(child: Padding(padding: const EdgeInsets.all(16),
child: SizedBox(height: 240,
child: BarChart(BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: (_byColonia.map((c) => (c['total'] as int? ?? 0).toDouble())
.reduce((a,b)=>a>b?a:b) * 1.2),
titlesData: FlTitlesData(
bottomTitles: AxisTitles(sideTitles: SideTitles(
showTitles: true, reservedSize: 32,
getTitlesWidget: (v, _) {
final i = v.toInt();
if (i < 0 || i >= _byColonia.length) return const SizedBox();
final name = (_byColonia[i]['colonia'] as String? ?? '');
return Transform.rotate(angle: -0.5,
child: Text(name.length > 8 ? '${name.substring(0,8)}.' : name,
style: const TextStyle(fontSize: 8)));
})),
leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: true,
getTitlesWidget: (v,_) => Text(v.toInt().toString(),
style: const TextStyle(fontSize: 9)))),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
barGroups: _byColonia.asMap().entries.map((e) => BarChartGroupData(
x: e.key,
barRods: [
BarChartRodData(
toY: (e.value['total'] as int? ?? 0).toDouble(),
color: AppColors.guindaPrimary,
width: 16, borderRadius: BorderRadius.circular(4)),
BarChartRodData(
toY: (e.value['resueltos'] as int? ?? 0).toDouble(),
color: AppColors.verdeExito,
width: 16, borderRadius: BorderRadius.circular(4)),
],
)).toList(),
gridData: const FlGridData(drawHorizontalLine: true),
borderData: FlBorderData(show: true,
border: Border.all(color: Colors.grey.shade300)),
))))),
Padding(padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(children: [
_Legend(AppColors.guindaPrimary, 'Total reportes'),
const SizedBox(width: 16),
_Legend(AppColors.verdeExito, 'Resueltos'),
])),
const SizedBox(height: 20),
],
// Rutas con más incidentes
if (_byRoute.isNotEmpty) ...[
_SectionTitle('Rutas con mas incidentes'),
const SizedBox(height: 8),
Card(child: Padding(padding: const EdgeInsets.all(16),
child: SizedBox(height: 200,
child: BarChart(BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: (_byRoute.map((r) => (r['total'] as int? ?? 0).toDouble())
.reduce((a,b)=>a>b?a:b) * 1.3),
titlesData: FlTitlesData(
bottomTitles: AxisTitles(sideTitles: SideTitles(
showTitles: true, reservedSize: 28,
getTitlesWidget: (v, _) {
final i = v.toInt();
if (i < 0 || i >= _byRoute.length) return const SizedBox();
return Text((_byRoute[i]['route_id'] as String? ?? '').replaceAll('RUTA-','R'),
style: const TextStyle(fontSize: 9));
})),
leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: true,
getTitlesWidget: (v,_) => Text(v.toInt().toString(),
style: const TextStyle(fontSize: 9)))),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
barGroups: _byRoute.asMap().entries.map((e) => BarChartGroupData(
x: e.key,
barRods: [BarChartRodData(
toY: (e.value['total'] as int? ?? 0).toDouble(),
gradient: const LinearGradient(
colors: [AppColors.naranjaAlerta, AppColors.rojoError],
begin: Alignment.bottomCenter, end: Alignment.topCenter),
width: 20, borderRadius: BorderRadius.circular(4))],
)).toList(),
gridData: const FlGridData(drawHorizontalLine: true),
borderData: FlBorderData(show: true,
border: Border.all(color: Colors.grey.shade300)),
))))),
const SizedBox(height: 20),
],
// Colonias más problemáticas (lista)
_SectionTitle('Colonias mas problematicas'),
const SizedBox(height: 8),
Card(child: Column(children: [
..._byColonia.take(5).map((c) {
final total = (c['total'] as int? ?? 0);
final resueltos = (c['resueltos'] as int? ?? 0);
final pct = total > 0 ? resueltos / total : 0.0;
return ListTile(dense: true,
leading: CircleAvatar(radius: 16,
backgroundColor: total > 3 ? AppColors.rojoError.withOpacity(0.15)
: AppColors.naranjaAlerta.withOpacity(0.15),
child: Text('$total', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold,
color: total > 3 ? AppColors.rojoError : AppColors.naranjaAlerta))),
title: Text(c['colonia'] as String? ?? '',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)),
subtitle: LinearProgressIndicator(value: pct,
backgroundColor: Colors.grey.shade200,
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.verdeExito)),
trailing: Text('${(pct*100).toInt()}% resuelto',
style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
);
}),
])),
const SizedBox(height: 30),
])),
);
}
class _KpiCard extends StatelessWidget {
final String label, value; final IconData icon; final Color color;
const _KpiCard(this.label, this.value, this.icon, this.color);
@override
Widget build(BuildContext context) => Expanded(child: Card(child: Padding(
padding: const EdgeInsets.all(14), child: Row(children: [
CircleAvatar(radius: 22, backgroundColor: color.withOpacity(0.12),
child: Icon(icon, color: color, size: 22)),
const SizedBox(width: 10),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(value, style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: color)),
Text(label, style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
]),
]))));
}
class _SectionTitle extends StatelessWidget {
final String title;
const _SectionTitle(this.title);
@override
Widget build(BuildContext context) => Text(title,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15, color: AppColors.verdeAdmin));
}
class _Legend extends StatelessWidget {
final Color color; final String label;
const _Legend(this.color, this.label);
@override
Widget build(BuildContext context) => Row(mainAxisSize: MainAxisSize.min, children: [
Container(width: 12, height: 12, decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(2))),
const SizedBox(width: 4),
Text(label, style: const TextStyle(fontSize: 11, color: AppColors.grisTexto)),
]);
}

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(); }
}

View File

@@ -0,0 +1,244 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:share_plus/share_plus.dart';
import '../../core/app_colors.dart';
import '../../database/db_helper.dart';
class ExportPdfScreen extends StatefulWidget {
const ExportPdfScreen({super.key});
@override State<ExportPdfScreen> createState() => _ExportPdfScreenState();
}
class _ExportPdfScreenState extends State<ExportPdfScreen> {
bool _generating = false;
String? _lastPath;
pw.TableRow _pdfRow(String label, String value, {bool isHeader = false}) =>
pw.TableRow(
decoration: isHeader ? pw.BoxDecoration(color: PdfColors.grey100) : null,
children: [
pw.Padding(padding: const pw.EdgeInsets.all(6),
child: pw.Text(label, style: pw.TextStyle(
fontSize: 10, fontWeight: isHeader ? pw.FontWeight.bold : null))),
pw.Padding(padding: const pw.EdgeInsets.all(6),
child: pw.Text(value, style: pw.TextStyle(
fontSize: 10, fontWeight: pw.FontWeight.bold))),
]);
Future<void> _generatePdf() async {
setState(() => _generating = true);
try {
final stats = await DbHelper.getAdminStats();
final colonias = await DbHelper.getReportesByColonia();
final incidentes = await DbHelper.getIncidentesByRoute();
final reviews = await DbHelper.getReviewSummaryByColonia();
final now = DateTime.now();
const meses = ['','Enero','Febrero','Marzo','Abril','Mayo','Junio',
'Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'];
final pdf = pw.Document();
pdf.addPage(pw.MultiPage(
pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.all(32),
header: (ctx) => pw.Column(children: [
pw.Row(mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [
pw.Column(crossAxisAlignment: pw.CrossAxisAlignment.start, children: [
pw.Text('H. AYUNTAMIENTO DE CELAYA', style: pw.TextStyle(
fontSize: 14, fontWeight: pw.FontWeight.bold,
color: PdfColor.fromHex('6D1E3A'))),
pw.Text('Direccion de Servicios Publicos',
style: const pw.TextStyle(fontSize: 11, color: PdfColors.grey700)),
pw.Text('Sistema de Recoleccion de Residuos',
style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey600)),
]),
pw.Column(crossAxisAlignment: pw.CrossAxisAlignment.end, children: [
pw.Text('REPORTE MENSUAL', style: pw.TextStyle(
fontSize: 12, fontWeight: pw.FontWeight.bold,
color: PdfColor.fromHex('6D1E3A'))),
pw.Text('${meses[now.month]} ${now.year}',
style: const pw.TextStyle(fontSize: 11)),
pw.Text('Generado: ${now.day}/${now.month}/${now.year}',
style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey600)),
]),
]),
pw.Divider(color: PdfColor.fromHex('C9A84C'), thickness: 2),
pw.SizedBox(height: 8),
]),
build: (ctx) => [
pw.Text('RESUMEN EJECUTIVO', style: pw.TextStyle(
fontSize: 13, fontWeight: pw.FontWeight.bold,
color: PdfColor.fromHex('6D1E3A'))),
pw.SizedBox(height: 8),
pw.Table(
border: pw.TableBorder.all(color: PdfColors.grey300),
columnWidths: {0: const pw.FlexColumnWidth(2), 1: const pw.FlexColumnWidth(1)},
children: [
_pdfRow('Total de reportes ciudadanos', '${stats["total_reportes"]}', isHeader: true),
_pdfRow('Total de resenas recibidas', '${stats["total_reviews"]}'),
_pdfRow('Calificacion promedio',
'${(stats["avg_rating"] as double? ?? 0).toStringAsFixed(2)} / 5.0'),
_pdfRow('Alertas activas', '${stats["alertas_activas"]}'),
_pdfRow('Conductores', '${stats["total_conductores"]}'),
]),
pw.SizedBox(height: 20),
if (colonias.isNotEmpty) ...[
pw.Text('REPORTES POR COLONIA', style: pw.TextStyle(
fontSize: 13, fontWeight: pw.FontWeight.bold,
color: PdfColor.fromHex('6D1E3A'))),
pw.SizedBox(height: 8),
pw.Table(
border: pw.TableBorder.all(color: PdfColors.grey300),
columnWidths: {
0: const pw.FlexColumnWidth(3), 1: const pw.FlexColumnWidth(1),
2: const pw.FlexColumnWidth(1), 3: const pw.FlexColumnWidth(1),
},
children: [
pw.TableRow(
decoration: pw.BoxDecoration(color: PdfColor.fromHex('6D1E3A')),
children: ['Colonia','Total','Resueltos','Pendientes'].map((h) =>
pw.Padding(padding: const pw.EdgeInsets.all(6),
child: pw.Text(h, style: pw.TextStyle(color: PdfColors.white,
fontWeight: pw.FontWeight.bold, fontSize: 10)))).toList()),
...colonias.map((c) {
final total = c['total'] as int? ?? 0;
final res = c['resueltos'] as int? ?? 0;
return pw.TableRow(children: [
c['colonia'] as String? ?? '', '$total', '$res', '${total - res}',
].map((v) => pw.Padding(padding: const pw.EdgeInsets.all(5),
child: pw.Text(v, style: const pw.TextStyle(fontSize: 9)))).toList());
}),
]),
pw.SizedBox(height: 20),
],
if (incidentes.isNotEmpty) ...[
pw.Text('INCIDENTES POR RUTA', style: pw.TextStyle(
fontSize: 13, fontWeight: pw.FontWeight.bold,
color: PdfColor.fromHex('6D1E3A'))),
pw.SizedBox(height: 8),
pw.Table(
border: pw.TableBorder.all(color: PdfColors.grey300),
columnWidths: {0: const pw.FlexColumnWidth(2), 1: const pw.FlexColumnWidth(1)},
children: [
pw.TableRow(decoration: pw.BoxDecoration(color: PdfColor.fromHex('6D1E3A')),
children: ['Ruta','Incidentes'].map((h) => pw.Padding(
padding: const pw.EdgeInsets.all(6),
child: pw.Text(h, style: pw.TextStyle(color: PdfColors.white,
fontWeight: pw.FontWeight.bold, fontSize: 10)))).toList()),
...incidentes.map((r) => pw.TableRow(children: [
r['route_id'] as String? ?? '', '${r["total"]}',
].map((v) => pw.Padding(padding: const pw.EdgeInsets.all(5),
child: pw.Text(v, style: const pw.TextStyle(fontSize: 9)))).toList())),
]),
pw.SizedBox(height: 20),
],
if (reviews.isNotEmpty) ...[
pw.Text('CALIFICACIONES POR COLONIA', style: pw.TextStyle(
fontSize: 13, fontWeight: pw.FontWeight.bold,
color: PdfColor.fromHex('6D1E3A'))),
pw.SizedBox(height: 8),
pw.Table(
border: pw.TableBorder.all(color: PdfColors.grey300),
columnWidths: {0: const pw.FlexColumnWidth(3),
1: const pw.FlexColumnWidth(1), 2: const pw.FlexColumnWidth(1)},
children: [
pw.TableRow(decoration: pw.BoxDecoration(color: PdfColor.fromHex('6D1E3A')),
children: ['Colonia','Promedio','Total'].map((h) => pw.Padding(
padding: const pw.EdgeInsets.all(6),
child: pw.Text(h, style: pw.TextStyle(color: PdfColors.white,
fontWeight: pw.FontWeight.bold, fontSize: 10)))).toList()),
...reviews.map((r) => pw.TableRow(children: [
r['colonia'] as String? ?? '',
'${(r["promedio"] as num? ?? 0).toStringAsFixed(1)}/5',
'${r["total"]}',
].map((v) => pw.Padding(padding: const pw.EdgeInsets.all(5),
child: pw.Text(v, style: const pw.TextStyle(fontSize: 9)))).toList())),
]),
pw.SizedBox(height: 20),
],
pw.Divider(color: PdfColor.fromHex('C9A84C')),
pw.Text('Celaya Limpia - H. Ayuntamiento de Celaya, Gto. - ${now.year}',
style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey500),
textAlign: pw.TextAlign.center),
],
));
// Guardar en directorio temporal y compartir con share_plus
final bytes = await pdf.save();
final dir = await getTemporaryDirectory();
final file = File('${dir.path}/reporte_celaya_${now.month}_${now.year}.pdf');
await file.writeAsBytes(bytes);
setState(() => _lastPath = file.path);
await Share.shareXFiles(
[XFile(file.path, mimeType: 'application/pdf')],
subject: 'Reporte Mensual Celaya Limpia - ${meses[now.month]} ${now.year}',
);
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error al generar PDF: $e'),
backgroundColor: AppColors.rojoError));
}
if (mounted) setState(() => _generating = false);
}
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
title: const Text('Exportar Reporte PDF'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado))),
body: Center(child: Padding(padding: const EdgeInsets.all(32), child: Column(
mainAxisAlignment: MainAxisAlignment.center, children: [
Container(width: 100, height: 100,
decoration: BoxDecoration(color: AppColors.verdeAdmin.withOpacity(0.1),
shape: BoxShape.circle),
child: const Icon(Icons.picture_as_pdf, size: 52, color: AppColors.verdeAdmin)),
const SizedBox(height: 24),
const Text('Reporte Mensual', style: TextStyle(fontSize: 22,
fontWeight: FontWeight.bold, color: AppColors.verdeAdmin)),
const SizedBox(height: 8),
const Text('Genera un PDF con el resumen completo:\nreportes, incidentes y calificaciones.',
textAlign: TextAlign.center,
style: TextStyle(color: AppColors.grisTexto)),
const SizedBox(height: 32),
SizedBox(width: double.infinity, height: 52,
child: ElevatedButton.icon(
onPressed: _generating ? null : _generatePdf,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))),
icon: _generating
? const SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Icon(Icons.download),
label: Text(_generating ? 'Generando...' : 'Generar y Compartir PDF',
style: const TextStyle(fontWeight: FontWeight.bold)))),
if (_lastPath != null) ...[
const SizedBox(height: 16),
Container(padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: AppColors.verdeExito.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.verdeExito.withOpacity(0.3))),
child: Row(children: [
const Icon(Icons.check_circle, color: AppColors.verdeExito, size: 18),
const SizedBox(width: 8),
const Expanded(child: Text('PDF generado correctamente',
style: TextStyle(color: AppColors.verdeExito, fontWeight: FontWeight.w600,
fontSize: 13))),
TextButton(onPressed: _generatePdf,
child: const Text('Compartir de nuevo',
style: TextStyle(fontSize: 11, color: AppColors.verdeAdmin))),
])),
],
]))));
}

View File

@@ -0,0 +1,179 @@
import 'package:flutter/material.dart';
import '../../core/app_colors.dart';
import '../../database/db_helper.dart';
class ManageConductorsScreen extends StatefulWidget {
const ManageConductorsScreen({super.key});
@override State<ManageConductorsScreen> createState() => _ManageConductorsScreenState();
}
class _ManageConductorsScreenState extends State<ManageConductorsScreen> {
List<Map<String, dynamic>> _conductores = [];
bool _loading = true;
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final c = await DbHelper.getConductoresConMeta();
if (mounted) setState(() { _conductores = c; _loading = false; });
}
Future<void> _showFormDialog({Map<String, dynamic>? existing}) async {
final nombreCtrl = TextEditingController(text: existing?['nombre'] ?? '');
final emailCtrl = TextEditingController(text: existing?['email'] ?? '');
final passCtrl = TextEditingController();
final notasCtrl = TextEditingController(text: existing?['notas'] ?? '');
bool activo = (existing?['activo'] as int? ?? 1) == 1;
bool obscure = true;
await showDialog(context: context, builder: (ctx) => StatefulBuilder(
builder: (ctx, setSt) => AlertDialog(
title: Text(existing == null ? 'Nuevo Conductor' : 'Editar Conductor'),
content: SingleChildScrollView(child: Column(mainAxisSize: MainAxisSize.min, children: [
TextField(controller: nombreCtrl,
decoration: const InputDecoration(labelText: 'Nombre completo',
prefixIcon: Icon(Icons.person_outline), border: OutlineInputBorder())),
const SizedBox(height: 10),
TextField(controller: emailCtrl, keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(labelText: 'Correo electronico',
prefixIcon: Icon(Icons.email_outlined), border: OutlineInputBorder())),
const SizedBox(height: 10),
if (existing == null)
TextField(controller: passCtrl, obscureText: obscure,
decoration: InputDecoration(labelText: 'Contrasena',
prefixIcon: const Icon(Icons.lock_outline), border: const OutlineInputBorder(),
suffixIcon: IconButton(icon: Icon(obscure ? Icons.visibility_off : Icons.visibility),
onPressed: () => setSt(() => obscure = !obscure)))),
if (existing == null) const SizedBox(height: 10),
TextField(controller: notasCtrl, maxLines: 2,
decoration: const InputDecoration(labelText: 'Notas internas (opcional)',
border: OutlineInputBorder())),
const SizedBox(height: 10),
if (existing != null)
SwitchListTile(value: activo, dense: true,
title: Text(activo ? 'Conductor Activo' : 'Conductor Inactivo',
style: TextStyle(color: activo ? AppColors.verdeAdmin : AppColors.rojoError,
fontWeight: FontWeight.bold)),
activeColor: AppColors.verdeAdmin,
onChanged: (v) => setSt(() => activo = v)),
])),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancelar')),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppColors.verdeAdmin,
foregroundColor: Colors.white),
onPressed: () async {
if (nombreCtrl.text.trim().isEmpty || emailCtrl.text.trim().isEmpty) return;
if (existing == null) {
if (passCtrl.text.length < 6) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('La contrasena debe tener al menos 6 caracteres'),
backgroundColor: AppColors.rojoError));
return;
}
await DbHelper.insertConductor(nombreCtrl.text.trim(),
emailCtrl.text.trim().toLowerCase(), passCtrl.text);
} else {
await DbHelper.updateConductor(existing['id'], nombreCtrl.text.trim(),
emailCtrl.text.trim().toLowerCase());
await DbHelper.updateConductorMeta(existing['id'], activo, notasCtrl.text.trim());
}
if (ctx.mounted) Navigator.pop(ctx);
await _load();
},
child: Text(existing == null ? 'Crear' : 'Guardar')),
])));
}
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
title: Text('Conductores (${_conductores.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: 'Nuevo conductor',
onPressed: () => _showFormDialog()),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _conductores.isEmpty
? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
const Icon(Icons.person_off, color: AppColors.grisTexto, size: 48),
const SizedBox(height: 12),
const Text('Sin conductores registrados',
style: TextStyle(color: AppColors.grisTexto)),
const SizedBox(height: 16),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(backgroundColor: AppColors.verdeAdmin,
foregroundColor: Colors.white),
onPressed: () => _showFormDialog(),
icon: const Icon(Icons.add), label: const Text('Agregar primer conductor')),
]))
: ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: _conductores.length,
itemBuilder: (_, i) {
final c = _conductores[i];
final activo = (c['activo'] as int? ?? 1) == 1;
final incidentes = c['total_incidentes'] as int? ?? 0;
return Card(
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10),
side: BorderSide(color: activo
? AppColors.verdeAdmin.withOpacity(0.3)
: AppColors.rojoError.withOpacity(0.3))),
child: Padding(padding: const EdgeInsets.all(14), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
CircleAvatar(radius: 22,
backgroundColor: activo
? AppColors.verdeAdmin.withOpacity(0.15)
: Colors.grey.shade200,
child: Icon(Icons.person,
color: activo ? AppColors.verdeAdmin : AppColors.grisTexto, size: 24)),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(c['nombre'] ?? '', style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 14)),
Text(c['email'] ?? '', style: const TextStyle(
color: AppColors.grisTexto, fontSize: 12)),
])),
Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: activo ? AppColors.verdeAdmin.withOpacity(0.1)
: AppColors.rojoError.withOpacity(0.1),
borderRadius: BorderRadius.circular(10)),
child: Text(activo ? 'Activo' : 'Inactivo',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold,
color: activo ? AppColors.verdeAdmin : AppColors.rojoError))),
IconButton(icon: const Icon(Icons.edit_outlined, size: 18),
onPressed: () => _showFormDialog(existing: c)),
]),
if (incidentes > 0 || (c['notas'] as String?)?.isNotEmpty == true) ...[
const Divider(height: 16),
if (incidentes > 0)
Row(children: [
Icon(Icons.warning_amber, size: 14,
color: incidentes > 3 ? AppColors.rojoError : AppColors.naranjaAlerta),
const SizedBox(width: 4),
Text('$incidentes incidente${incidentes != 1 ? 's' : ''} historico${incidentes != 1 ? 's' : ''}',
style: TextStyle(fontSize: 12,
color: incidentes > 3 ? AppColors.rojoError : AppColors.naranjaAlerta)),
]),
if ((c['notas'] as String?)?.isNotEmpty == true) ...[
const SizedBox(height: 4),
Text('Notas: ${c['notas']}',
style: const TextStyle(fontSize: 11, color: AppColors.grisTexto,
fontStyle: FontStyle.italic)),
],
],
])));
}),
);
}

View File

@@ -0,0 +1,212 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/app_colors.dart';
import '../../data/celaya_colonias.dart';
import '../../data/colonies_data.dart';
import '../../database/db_helper.dart';
import '../../models/models.dart';
import '../../models/route_model.dart';
import '../../services/auth_service.dart';
class AddDomicilioScreen extends StatefulWidget {
final DomicilioModel? editing;
const AddDomicilioScreen({super.key, this.editing});
@override State<AddDomicilioScreen> createState() => _AddDomicilioScreenState();
}
class _AddDomicilioScreenState extends State<AddDomicilioScreen> {
final _calleCtrl = TextEditingController();
final _aliasCtrl = TextEditingController(text: 'Casa');
String? _coloniaSeleccionada;
ColonyModel? _coloniaData;
bool _loading = false;
String _searchQuery = '';
@override
void initState() {
super.initState();
if (widget.editing != null) {
_calleCtrl.text = widget.editing!.calle;
_aliasCtrl.text = widget.editing!.alias;
_coloniaSeleccionada = widget.editing!.colonia;
_coloniaData = getColonyByName(widget.editing!.colonia);
}
}
List<String> get _filteredColonias {
if (_searchQuery.isEmpty) return celayaColonias;
return celayaColonias
.where((c) => c.toLowerCase().contains(_searchQuery.toLowerCase()))
.toList();
}
Future<void> _guardar() async {
if (_calleCtrl.text.trim().isEmpty || _coloniaSeleccionada == null) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Por favor completa todos los campos'),
backgroundColor: AppColors.rojoError));
return;
}
setState(() => _loading = true);
final auth = context.read<AuthService>();
// 1. Buscar primero en colonies_data (rutas predefinidas)
final staticData = getColonyByName(_coloniaSeleccionada!);
String routeId = staticData?.routeId ?? '';
String horario = staticData?.horarioEstimado ?? '';
// 2. Si no hay match estático, buscar en route_definitions del admin
if (routeId.isEmpty) {
final routeDefs = await DbHelper.getAllRouteDefinitions();
for (final rd in routeDefs) {
if (rd.colonias.any((c) =>
c.toLowerCase() == _coloniaSeleccionada!.toLowerCase())) {
routeId = rd.routeId;
horario = '${_turnoLabel(rd.turno)} (${rd.horaInicio}${rd.horaFin})';
break;
}
}
}
// 3. Fallback si no se encontró
if (routeId.isEmpty) {
routeId = 'RUTA-01';
horario = 'Matutino (06:0008:00)';
}
if (widget.editing != null) {
await DbHelper.deleteDomicilio(widget.editing!.id!);
}
final dom = DomicilioModel(
userId: auth.currentUser!.id!,
alias: _aliasCtrl.text.trim(),
calle: _calleCtrl.text.trim(),
colonia: _coloniaSeleccionada!,
routeId: routeId,
horarioEstimado: horario,
);
await DbHelper.insertDomicilio(dom);
await auth.reloadDomicilios();
if (!mounted) return;
setState(() => _loading = false);
Navigator.pop(context, true);
}
String _turnoLabel(String t) =>
t == 'MATUTINO' ? 'Matutino' : t == 'VESPERTINO' ? 'Vespertino' : 'Nocturno';
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: Text(widget.editing != null ? 'Editar Domicilio' : 'Agregar Domicilio'),
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: [
// Alias
TextField(
controller: _aliasCtrl,
decoration: const InputDecoration(
labelText: 'Alias (ej. Casa, Trabajo, Familia)',
prefixIcon: Icon(Icons.label_outline, color: AppColors.guindaPrimary),
border: OutlineInputBorder(), filled: true, fillColor: Colors.white),
),
const SizedBox(height: 12),
// Calle
TextField(
controller: _calleCtrl,
decoration: const InputDecoration(
labelText: 'Calle y número',
prefixIcon: Icon(Icons.signpost_outlined, color: AppColors.guindaPrimary),
border: OutlineInputBorder(), filled: true, fillColor: Colors.white),
),
const SizedBox(height: 16),
const Text('Colonia', style: TextStyle(fontWeight: FontWeight.bold,
color: AppColors.guindaPrimary, fontSize: 15)),
const SizedBox(height: 8),
// Buscador de colonias
TextField(
onChanged: (v) => setState(() => _searchQuery = v),
decoration: const InputDecoration(
hintText: 'Buscar colonia...',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(), filled: true, fillColor: Colors.white,
isDense: true,
),
),
const SizedBox(height: 8),
// Lista de colonias
Container(
height: 240,
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 isSelected = c == _coloniaSeleccionada;
return ListTile(
dense: true,
title: Text(c, style: TextStyle(fontSize: 13,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? AppColors.guindaPrimary : AppColors.negroTexto)),
trailing: isSelected
? const Icon(Icons.check_circle, color: AppColors.guindaPrimary, size: 18)
: null,
tileColor: isSelected ? AppColors.guindaPrimary.withOpacity(0.08) : null,
onTap: () {
setState(() {
_coloniaSeleccionada = c;
_coloniaData = getColonyByName(c);
});
},
);
},
),
),
if (_coloniaData != null) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.guindaPrimary.withOpacity(0.08),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.guindaPrimary.withOpacity(0.3))),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Ruta asignada: ${_coloniaData!.routeId}',
style: const TextStyle(fontWeight: FontWeight.bold,
color: AppColors.guindaPrimary, fontSize: 13)),
Text('Horario: ${_coloniaData!.horarioEstimado}',
style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)),
]),
),
],
const SizedBox(height: 24),
SizedBox(width: double.infinity, height: 50,
child: ElevatedButton.icon(
onPressed: _loading ? null : _guardar,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.guindaPrimary, 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: Text(widget.editing != null ? 'ACTUALIZAR' : 'GUARDAR DOMICILIO',
style: const TextStyle(fontWeight: FontWeight.bold)))),
]),
),
);
}
@override void dispose() { _calleCtrl.dispose(); _aliasCtrl.dispose(); super.dispose(); }
}

View File

@@ -0,0 +1,175 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:camera/camera.dart';
import 'package:tflite_flutter/tflite_flutter.dart';
import 'package:image/image.dart' as img;
import '../../core/app_colors.dart';
List<CameraDescription> _cameras = [];
class AiCameraScreen extends StatefulWidget {
const AiCameraScreen({super.key});
@override State<AiCameraScreen> createState() => _AiCameraScreenState();
}
class _AiCameraScreenState extends State<AiCameraScreen> {
CameraController? _cam;
Interpreter? _interpreter;
bool _processing = false;
String _result = 'Apunta a un residuo y toca el botón';
String _confidence = '';
bool _modelLoaded = false;
// 0=Orgánico, 1=Inorgánico (según waste_classification_model)
final _labels = ['Residuo Organico', 'Residuo Inorganico'];
final _labelColors = [AppColors.verdeExito, AppColors.naranjaAlerta];
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
try {
_cameras = await availableCameras();
} catch (_) {}
await _initCamera();
await _loadModel();
}
Future<void> _initCamera() async {
if (_cameras.isEmpty) return;
_cam = CameraController(_cameras[0], ResolutionPreset.medium, enableAudio: false);
try {
await _cam!.initialize();
if (mounted) setState(() {});
} catch (_) {}
}
Future<void> _loadModel() async {
try {
_interpreter = await Interpreter.fromAsset('assets/models/waste_model.tflite');
setState(() => _modelLoaded = true);
} catch (e) {
setState(() => _result = 'Modelo no encontrado.\nAgrega waste_model.tflite a assets/models/');
}
}
Future<void> _classify() async {
if (_cam == null || !_cam!.value.isInitialized || _processing || !_modelLoaded) return;
setState(() { _processing = true; _result = 'Analizando...'; _confidence = ''; });
try {
final pic = await _cam!.takePicture();
final raw = await File(pic.path).readAsBytes();
img.Image? decoded = img.decodeImage(raw);
if (decoded == null) throw Exception('No se pudo decodificar');
final resized = img.copyResize(decoded, width: 150, height: 150);
var input = List.generate(1, (_) =>
List.generate(150, (_) => List.generate(150, (_) => List.generate(3, (_) => 0.0))));
for (int y = 0; y < 150; y++) {
for (int x = 0; x < 150; x++) {
final px = resized.getPixel(x, y);
input[0][y][x][0] = px.r / 255.0;
input[0][y][x][1] = px.g / 255.0;
input[0][y][x][2] = px.b / 255.0;
}
}
var output = List.filled(2, 0.0).reshape([1, 2]);
_interpreter!.run(input, output);
final pred = List<double>.from(output[0]);
final maxIdx = pred[0] > pred[1] ? 0 : 1;
final conf = pred[maxIdx] * 100;
await File(pic.path).delete();
setState(() {
_result = _labels[maxIdx];
_confidence = 'Confianza: ${conf.toStringAsFixed(1)}%';
});
} catch (e) {
setState(() => _result = 'Error en análisis');
} finally {
setState(() => _processing = false);
}
}
@override
void dispose() {
_cam?.dispose();
_interpreter?.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final resultColor = _result.contains('Orgánico') ? AppColors.verdeExito
: _result.contains('Inorgánico') ? AppColors.naranjaAlerta
: AppColors.guindaPrimary;
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: const Text('Clasificador IA de Residuos'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
),
body: Column(children: [
// Visor cámara
Expanded(flex: 4,
child: Container(margin: const EdgeInsets.all(14),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.guindaPrimary, width: 3)),
child: _cam != null && _cam!.value.isInitialized
? CameraPreview(_cam!)
: const Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.camera_alt, color: Colors.white54, size: 48),
SizedBox(height: 8),
Text('Iniciando cámara...', style: TextStyle(color: Colors.white54)),
])),
),
),
// Panel resultado
Expanded(flex: 2,
child: Container(width: double.infinity,
decoration: BoxDecoration(color: AppColors.guindaPrimary.withOpacity(0.06),
borderRadius: const BorderRadius.vertical(top: Radius.circular(28))),
padding: const EdgeInsets.all(20),
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Text(_result, textAlign: TextAlign.center,
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: resultColor)),
if (_confidence.isNotEmpty) ...[
const SizedBox(height: 6),
Text(_confidence, style: const TextStyle(fontSize: 16, color: Colors.black54, fontWeight: FontWeight.w500)),
],
const SizedBox(height: 16),
if (!_modelLoaded)
Container(padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: Colors.orange.shade50, borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.shade300)),
child: const Text(' Para usar la IA, coloca waste_model.tflite en assets/models/',
textAlign: TextAlign.center, style: TextStyle(fontSize: 11))),
if (_modelLoaded)
SizedBox(width: double.infinity, height: 50,
child: ElevatedButton.icon(
onPressed: _processing ? null : _classify,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))),
icon: _processing
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Icon(Icons.center_focus_strong),
label: Text(_processing ? 'Procesando...' : 'Escanear Residuo',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
)),
]),
),
),
]),
);
}
}

View File

@@ -0,0 +1,270 @@
import 'package:flutter/material.dart';
import '../../core/app_colors.dart';
// ── Árbol de respuestas predefinidas ──────────────────────────────────────
class _ChatNode {
final String text;
final List<_ChatOption> options;
final bool isAnswer;
const _ChatNode(this.text, this.options, {this.isAnswer = false});
}
class _ChatOption {
final String label;
final _ChatNode next;
const _ChatOption(this.label, this.next);
}
final _chatTree = _ChatNode('Hola, soy el asistente de Celaya Limpia. ¿En que te puedo ayudar?', [
_ChatOption('Separacion de residuos', _ChatNode('¿Que quieres saber sobre separacion?', [
_ChatOption('Como separo mi basura', _ChatNode(
'Separa tus residuos en 3 grupos:\n\n'
'ORGANICOS (bolsa verde):\nRestos de comida, cascara de huevo, pasto, hojas.\n\n'
'INORGANICOS reciclables (bolsa azul):\nPET, latas, carton limpio, vidrio.\n\n'
'NO reciclables (bolsa negra):\nPanales, papel sanitario, colillas, chicles.',
[], isAnswer: true)),
_ChatOption('Que NO debo mezclar', _ChatNode(
'NUNCA mezcles:\n\n'
'- Pilas o baterias con basura comun\n'
'- Aceite de cocina (contamina el agua)\n'
'- Medicamentos vencidos\n'
'- Jeringas o material medico\n'
'- Electronicos con basura doméstica\n\n'
'Estos requieren manejo especial.',
[], isAnswer: true)),
_ChatOption('Que hago con el aceite', _ChatNode(
'El aceite de cocina usado NO va a la basura ni al drenaje.\n\n'
'1. Dejalo enfriar completamente\n'
'2. Guardalo en botella de PET cerrada\n'
'3. Llevalo a los puntos de acopio del Ayuntamiento de Celaya\n\n'
'El aceite reciclado se convierte en biodiesel.',
[], isAnswer: true)),
])),
_ChatOption('Residuos especiales', _ChatNode('¿Que tipo de residuo especial tienes?', [
_ChatOption('Donde dejo electronicos', _ChatNode(
'Los aparatos electronicos (celulares, computadoras, focos ahorradores) '
'son residuos RAEE.\n\n'
'Puntos de acopio en Celaya:\n'
'- Tiendas de electronica\n'
'- Centros comerciales con contenedores especiales\n'
'- Eventos de recoleccion del municipio\n\n'
'NUNCA los tires a la basura comun.',
[], isAnswer: true)),
_ChatOption('Que hago con medicamentos', _ChatNode(
'Los medicamentos vencidos son residuos peligrosos.\n\n'
'- Llevalos a farmacias que tengan programa de devolucion\n'
'- Algunos hospitales los reciben\n'
'- Nunca los tires al drenaje ni a la basura comun\n\n'
'Contaminar el agua con medicamentos afecta a toda la comunidad.',
[], isAnswer: true)),
_ChatOption('Que hago con pilas y baterias', _ChatNode(
'Las pilas y baterias contienen metales pesados toxicos.\n\n'
'Depositalas en:\n'
'- Supermercados (contenedores naranjas)\n'
'- Tiendas de electronica\n'
'- Oficinas del Ayuntamiento de Celaya\n\n'
'1 pila puede contaminar 600,000 litros de agua.',
[], isAnswer: true)),
])),
_ChatOption('Sobre el servicio de recoleccion', _ChatNode('¿Que necesitas saber?', [
_ChatOption('Cuando debo sacar la basura', _ChatNode(
'Celaya Limpia te notificara:\n\n'
'1. Cuando el camion salga del relleno sanitario\n'
'2. Cuando este a 30 minutos\n'
'3. A 15 minutos: este es el momento de sacar tus bolsas\n\n'
'NO saques la basura antes del aviso de 15 minutos. '
'Atrae fauna nociva y obstruye la acera.',
[], isAnswer: true)),
_ChatOption('El camion no paso', _ChatNode(
'Si el camion no paso en tu horario habitual:\n\n'
'1. Revisa las alertas en la app (puede haber un retraso o incidente)\n'
'2. Guarda tu basura bien cerrada\n'
'3. Reporta la incidencia desde la seccion "Reportar"\n\n'
'El administrador revisara tu reporte y te mantendra informado.',
[], isAnswer: true)),
_ChatOption('Como califico el servicio', _ChatNode(
'Despues de que el camion pase por tu zona, '
'la app te mostrara una notificacion para calificar.\n\n'
'Puedes dar de 1 a 5 estrellas y dejar un comentario.\n\n'
'Tus calificaciones ayudan al Ayuntamiento a identificar '
'colonias con problemas y mejorar el servicio.',
[], isAnswer: true)),
])),
_ChatOption('Denuncia o emergencia', _ChatNode(
'Para situaciones urgentes:\n\n'
'- Reporte de incidencias: usa la seccion "Reportar" en la app\n'
'- Emergencias: llama al 911\n'
'- Ayuntamiento de Celaya: (461) 614-8000\n'
'- SEMARNAT Guanajuato: (477) 717-2600\n\n'
'Para basura clandestina o tiraderos ilegales, reportalo al municipio.',
[], isAnswer: true)),
]);
// ── Pantalla del chatbot ──────────────────────────────────────────────────
class ChatbotScreen extends StatefulWidget {
const ChatbotScreen({super.key});
@override State<ChatbotScreen> createState() => _ChatbotScreenState();
}
class _ChatbotScreenState extends State<ChatbotScreen> {
final List<_Message> _messages = [];
_ChatNode _current = _chatTree;
final _scroll = ScrollController();
@override
void initState() {
super.initState();
// Mensaje inicial
_messages.add(_Message(text: _chatTree.text, isBot: true));
}
void _handleOption(_ChatOption option) {
setState(() {
// Mensaje del usuario
_messages.add(_Message(text: option.label, isBot: false));
// Ir al siguiente nodo
_current = option.next;
_messages.add(_Message(text: _current.text, isBot: true,
isAnswer: _current.isAnswer));
});
Future.delayed(const Duration(milliseconds: 100), () {
_scroll.animateTo(_scroll.position.maxScrollExtent,
duration: const Duration(milliseconds: 300), curve: Curves.easeOut);
});
}
void _reset() {
setState(() {
_messages.clear();
_current = _chatTree;
_messages.add(_Message(text: _chatTree.text, isBot: true));
});
}
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: const Row(children: [
CircleAvatar(radius: 14,
backgroundColor: Colors.white24,
child: Icon(Icons.smart_toy, color: AppColors.dorado, size: 18)),
SizedBox(width: 8),
Text('Asistente Celaya Limpia'),
]),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
actions: [
IconButton(icon: const Icon(Icons.refresh), tooltip: 'Reiniciar',
onPressed: _reset),
],
),
body: Column(children: [
// Mensajes
Expanded(
child: ListView.builder(
controller: _scroll,
padding: const EdgeInsets.all(12),
itemCount: _messages.length,
itemBuilder: (_, i) => _MessageBubble(msg: _messages[i]),
),
),
// Opciones del nodo actual
if (_current.options.isNotEmpty)
Container(
color: Colors.white,
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Selecciona una opcion:',
style: TextStyle(fontSize: 11, color: AppColors.grisTexto,
fontWeight: FontWeight.w500)),
const SizedBox(height: 8),
Wrap(spacing: 8, runSpacing: 8,
children: _current.options.map((opt) =>
ActionChip(
label: Text(opt.label, style: const TextStyle(fontSize: 12)),
backgroundColor: AppColors.guindaPrimary.withOpacity(0.1),
side: const BorderSide(color: AppColors.guindaPrimary),
labelStyle: const TextStyle(color: AppColors.guindaPrimary),
onPressed: () => _handleOption(opt),
)).toList()),
],
),
)
else
// Botón de reiniciar al llegar a una respuesta final
Container(
color: Colors.white,
padding: const EdgeInsets.all(12),
child: SizedBox(width: double.infinity,
child: OutlinedButton.icon(
onPressed: _reset,
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.guindaPrimary,
side: const BorderSide(color: AppColors.guindaPrimary)),
icon: const Icon(Icons.arrow_back, size: 16),
label: const Text('Hacer otra pregunta'))),
),
]),
);
@override void dispose() { _scroll.dispose(); super.dispose(); }
}
class _Message {
final String text;
final bool isBot;
final bool isAnswer;
const _Message({required this.text, required this.isBot, this.isAnswer = false});
}
class _MessageBubble extends StatelessWidget {
final _Message msg;
const _MessageBubble({super.key, required this.msg});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: msg.isBot ? MainAxisAlignment.start : MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (msg.isBot) ...[
CircleAvatar(radius: 16,
backgroundColor: AppColors.guindaPrimary,
child: const Icon(Icons.smart_toy, color: Colors.white, size: 16)),
const SizedBox(width: 8),
],
Flexible(child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: msg.isBot
? (msg.isAnswer ? Colors.green.shade50 : Colors.white)
: AppColors.guindaPrimary,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: Radius.circular(msg.isBot ? 4 : 16),
bottomRight: Radius.circular(msg.isBot ? 16 : 4),
),
border: msg.isBot ? Border.all(
color: msg.isAnswer
? Colors.green.shade200
: Colors.grey.shade200) : null,
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.06),
blurRadius: 4, offset: const Offset(0, 2))],
),
child: Text(msg.text,
style: TextStyle(fontSize: 13, height: 1.5,
color: msg.isBot ? AppColors.negroTexto : Colors.white)),
)),
if (!msg.isBot) const SizedBox(width: 8),
],
),
);
}
}

View File

@@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
import '../../core/app_colors.dart';
import 'ai_camera_screen.dart';
class CitizenGuiaScreen extends StatelessWidget {
const CitizenGuiaScreen({super.key});
static const _cats = [
_Cat(Icons.grass,Color(0xFF2E7D32),'Orgánicos','Restos de comida, jardín','🟢 Bolsa Verde',[
'Frutas y verduras','Cáscaras de huevo','Posos de café y té',
'Restos de comida preparada','Pasto y hojas','Cáscaras de semillas'],
['Aceites en exceso','Carnes en grandes cantidades']),
_Cat(Icons.recycling,Color(0xFF1565C0),'Reciclables','Papel, plástico, vidrio, metal','🔵 Bolsa Azul',[
'Botellas PET','Latas de aluminio','Cartón y papel limpio',
'Vidrio (botellas, frascos)','Periódico y revistas'],
['Vidrio roto sin envolver','Papel sucio o mojado','Unicel']),
_Cat(Icons.delete,Color(0xFF757575),'No Reciclables','Residuos que no se reusan','⚫ Bolsa Negra',[
'Pañales desechables','Toallas sanitarias','Papel higiénico usado',
'Colillas de cigarro','Cerámica rota'],['Baterías','Medicamentos','Aceite usado']),
_Cat(Icons.warning_amber,Color(0xFFC62828),'Peligrosos','Requieren manejo especial','🔴 Separado',[
'Agujas y jeringas','Medicamentos vencidos','Pilas y baterías',
'Aceite de cocina usado','Pintura y solventes'],[],isWarn:true),
_Cat(Icons.devices_other,Color(0xFFE65100),'Electrónicos (RAEE)','Aparatos electrónicos','🟠 Punto de acopio',[
'Celulares viejos','Computadoras','Televisiones',
'Focos ahorradores','Cables y cargadores'],[],isSpecial:true),
];
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(automaticallyImplyLeading:false,
backgroundColor:AppColors.guindaPrimary, foregroundColor:Colors.white,
title:const Text('Guía de Separación'),
actions:[IconButton(icon:const Icon(Icons.camera_alt),
tooltip:'Clasificar con IA',
onPressed:()=>Navigator.push(context,MaterialPageRoute(builder:(_)=>const AiCameraScreen())))],
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
child:Container(height:4,color:AppColors.dorado))),
body:Column(children:[
Container(width:double.infinity,
color:AppColors.verdeExito.withOpacity(0.1),
padding:const EdgeInsets.symmetric(horizontal:16,vertical:8),
child:Row(children:[
const Icon(Icons.offline_bolt,color:AppColors.verdeExito,size:16),
const SizedBox(width:6),
const Text('Disponible sin conexión a internet',
style:TextStyle(color:AppColors.verdeExito,fontSize:12,fontWeight:FontWeight.w500)),
const Spacer(),
TextButton.icon(icon:const Icon(Icons.camera_alt,size:14),
label:const Text('Clasificar IA',style:TextStyle(fontSize:12)),
style:TextButton.styleFrom(foregroundColor:AppColors.guindaPrimary),
onPressed:()=>Navigator.push(context,MaterialPageRoute(builder:(_)=>const AiCameraScreen()))),
])),
// Importancia de separar
Container(margin:const EdgeInsets.fromLTRB(12,8,12,0),
padding:const EdgeInsets.all(12),
decoration:BoxDecoration(color:Colors.green.shade50,borderRadius:BorderRadius.circular(8),
border:Border.all(color:Colors.green.shade200)),
child:const Column(crossAxisAlignment:CrossAxisAlignment.start, children:[
Text('¿Por qué separar tu basura?',style:TextStyle(fontWeight:FontWeight.bold,color:Color(0xFF2E7D32))),
SizedBox(height:6),
Text('♻️ El 60% de los residuos en México pueden reciclarse o compostarse, pero solo el 5% lo hace.\n'
'🌱 Separar correctamente reduce la contaminación del suelo y agua, genera empleos verdes '
'y disminuye los gases de efecto invernadero producidos en rellenos sanitarios.',
style:TextStyle(fontSize:12,color:Colors.black87)),
])),
Expanded(child:ListView.builder(
padding:const EdgeInsets.all(12),
itemCount:_cats.length,
itemBuilder:(ctx,i)=>_CatCard(cat:_cats[i]))),
]),
);
}
class _Cat {
final IconData icon; final Color color; final String title, subtitle, bolsa;
final List<String> items, noItems;
final bool isWarn, isSpecial;
const _Cat(this.icon,this.color,this.title,this.subtitle,this.bolsa,
this.items,this.noItems,{this.isWarn=false,this.isSpecial=false});
}
class _CatCard extends StatefulWidget {
final _Cat cat;
const _CatCard({super.key, required this.cat});
@override State<_CatCard> createState() => _CatCardState();
}
class _CatCardState extends State<_CatCard> {
bool _open = false;
@override
Widget build(BuildContext context) {
final c = widget.cat;
return Card(margin:const EdgeInsets.only(bottom:10),elevation:2,
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(10),
side:BorderSide(color:c.color.withOpacity(0.3))),
child:InkWell(borderRadius:BorderRadius.circular(10),
onTap:()=>setState(()=>_open=!_open),
child:Column(children:[
Container(decoration:BoxDecoration(color:c.color.withOpacity(0.1),
borderRadius:BorderRadius.vertical(top:const Radius.circular(10),
bottom:_open?Radius.zero:const Radius.circular(10))),
padding:const EdgeInsets.all(14),
child:Row(children:[
Container(width:40,height:40,decoration:BoxDecoration(color:c.color,borderRadius:BorderRadius.circular(8)),
child:Icon(c.icon,color:Colors.white,size:22)),
const SizedBox(width:10),
Expanded(child:Column(crossAxisAlignment:CrossAxisAlignment.start, children:[
Text(c.title,style:TextStyle(fontWeight:FontWeight.bold,fontSize:15,color:c.color)),
Text(c.subtitle,style:const TextStyle(color:AppColors.grisTexto,fontSize:11)),
Text(c.bolsa,style:TextStyle(fontSize:11,fontWeight:FontWeight.w600,color:c.color)),
])),
Icon(_open?Icons.expand_less:Icons.expand_more,color:c.color),
])),
if (_open) Padding(padding:const EdgeInsets.fromLTRB(14,0,14,14),
child:Column(crossAxisAlignment:CrossAxisAlignment.start, children:[
const SizedBox(height:8),
Text('✅ Qué va aquí:',style:TextStyle(fontWeight:FontWeight.bold,color:c.color,fontSize:12)),
const SizedBox(height:4),
...c.items.map((e)=>Padding(padding:const EdgeInsets.symmetric(vertical:2),
child:Row(children:[Icon(Icons.check_circle_outline,size:13,color:c.color),
const SizedBox(width:6),Text(e,style:const TextStyle(fontSize:12))]))),
if (c.noItems.isNotEmpty) ...[
const SizedBox(height:8),
const Text('❌ NO incluir:',style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.rojoError,fontSize:12)),
...c.noItems.map((e)=>Padding(padding:const EdgeInsets.symmetric(vertical:2),
child:Row(children:[const Icon(Icons.cancel_outlined,size:13,color:AppColors.rojoError),
const SizedBox(width:6),Text(e,style:const TextStyle(fontSize:12,color:AppColors.rojoError))]))),
],
if (c.isSpecial) ...[
const SizedBox(height:8),
Container(padding:const EdgeInsets.all(8),
decoration:BoxDecoration(color:Colors.orange.shade50,borderRadius:BorderRadius.circular(6),
border:Border.all(color:Colors.orange.shade200)),
child:const Text('📍 Lleva a puntos de acopio autorizados por el municipio.',
style:TextStyle(fontSize:11))),
],
if (c.isWarn) ...[
const SizedBox(height:8),
Container(padding:const EdgeInsets.all(8),
decoration:BoxDecoration(color:Colors.red.shade50,borderRadius:BorderRadius.circular(6),
border:Border.all(color:Colors.red.shade200)),
child:const Text('⚠️ NUNCA mezcles residuos peligrosos con basura común.',
style:TextStyle(fontSize:11))),
],
])),
])));
}
}

View File

@@ -0,0 +1,582 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/app_colors.dart';
import '../../database/db_helper.dart';
import '../../models/models.dart';
import '../../services/auth_service.dart';
import '../../services/route_simulator_service.dart';
import '../../data/routes_data.dart';
import '../../widgets/route_map_widget.dart';
import 'citizen_guia_screen.dart';
import 'citizen_reporte_screen.dart';
import 'add_domicilio_screen.dart';
import 'review_screen.dart';
import 'collection_calendar_screen.dart';
import 'notification_history_screen.dart';
import 'chatbot_screen.dart';
import '../settings_screen.dart';
class CitizenHomeScreen extends StatefulWidget {
const CitizenHomeScreen({super.key});
@override State<CitizenHomeScreen> createState() => _CitizenHomeScreenState();
}
class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
int _tab = 0;
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthService>();
final sim = context.watch<RouteSimulatorService>();
final dom = auth.primaryDomicilio;
final last = dom != null ? sim.getNotificationForRoute(dom.routeId) : null;
final tabs = [
_HomeTab(auth: auth, sim: sim),
const CitizenGuiaScreen(),
const CitizenReporteScreen(),
];
return Scaffold(
backgroundColor: AppColors.grisFondo,
body: Stack(children: [
tabs[_tab],
if (last != null)
Positioned(
top: MediaQuery.of(context).padding.top + 8, left: 0, right: 0,
child: _NotifBanner(notif: last,
onDismiss: () => sim.dismissRouteNotification(dom?.routeId ?? '')),
),
]),
bottomNavigationBar: NavigationBar(
selectedIndex: _tab,
onDestinationSelected: (i) => setState(() => _tab = i),
backgroundColor: Colors.white,
indicatorColor: AppColors.guindaPrimary.withOpacity(0.15),
destinations: const [
NavigationDestination(icon:Icon(Icons.home_outlined),
selectedIcon:Icon(Icons.home,color:AppColors.guindaPrimary),label:'Inicio'),
NavigationDestination(icon:Icon(Icons.eco_outlined),
selectedIcon:Icon(Icons.eco,color:AppColors.guindaPrimary),label:'Guía'),
NavigationDestination(icon:Icon(Icons.report_outlined),
selectedIcon:Icon(Icons.report,color:AppColors.guindaPrimary),label:'Reportar'),
],
),
);
}
}
class _HomeTab extends StatefulWidget {
final AuthService auth;
final RouteSimulatorService sim;
const _HomeTab({required this.auth, required this.sim});
@override State<_HomeTab> createState() => _HomeTabState();
}
class _HomeTabState extends State<_HomeTab> {
RouteStatusModel? _routeStatus;
RouteDefinitionModel? _routeDef;
@override void initState() { super.initState(); _loadStatus(); }
Future<void> _loadStatus() async {
final dom = widget.auth.primaryDomicilio;
if (dom == null) return;
final s = await DbHelper.getRouteStatus(dom.routeId);
final rd = await DbHelper.getRouteDefinitionById(dom.routeId);
if (mounted) setState(() { _routeStatus = s; _routeDef = rd; });
}
bool get _isRouteProblematic {
final s = _routeStatus?.status ?? RouteStatus.enRuta;
return s == RouteStatus.cancelada || s == RouteStatus.fallaMecanica || s == RouteStatus.retrasada;
}
@override
Widget build(BuildContext context) {
final dom = widget.auth.primaryDomicilio;
final allDoms = widget.auth.allDomicilios;
final routeId = dom?.routeId ?? '';
final route = dom != null ? getRouteById(dom.routeId) : null;
final isTruckClose = widget.sim.isTruckClose(routeId);
final isCompleted = widget.sim.isRouteCompleted(routeId);
final needsReview = widget.sim.needsReviewPrompt(routeId);
return RefreshIndicator(
onRefresh: _loadStatus,
child: CustomScrollView(slivers: [
SliverAppBar(expandedHeight: 120, pinned: true,
backgroundColor: AppColors.guindaPrimary,
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
flexibleSpace: FlexibleSpaceBar(background: Container(
color: AppColors.guindaPrimary,
padding: const EdgeInsets.fromLTRB(20, 50, 20, 16),
child: Row(children: [
const Icon(Icons.delete_sweep_rounded, color: AppColors.dorado, size: 30),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center, children: [
Text('Hola, ${widget.auth.currentUser?.nombre.split(' ').first ?? ''}',
style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
const Text('Celaya Limpia', style: TextStyle(color: AppColors.dorado, fontSize: 12)),
])),
IconButton(icon: const Icon(Icons.logout, color: Colors.white70),
onPressed: () async {
await widget.auth.logout();
if (context.mounted) Navigator.pushReplacementNamed(context, '/login');
}),
]),
)),
),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(delegate: SliverChildListDelegate([
// ── Selector de domicilio ────────────────────────────────────
if (allDoms.length > 1) _DomicilioSelector(
auth: widget.auth, onChanged: _loadStatus),
// ── Prompt de calificación ───────────────────────────────────
if (needsReview && dom != null)
_ReviewPromptCard(routeId: routeId, colonia: dom.colonia,
sim: widget.sim),
// ── Estado de ruta (cancelada/falla/retrasada) ───────────────
if (_isRouteProblematic)
_RouteStatusBanner(status: _routeStatus!)
else ...[
// ETA Card
_EtaCard(sim: widget.sim, routeId: routeId, dom: dom, route: route),
const SizedBox(height: 12),
// Información detallada de la ruta (días y horario)
if (_routeDef != null) _RouteInfoCard(routeDef: _routeDef!),
if (_routeDef == null && dom != null) _BasicRouteInfo(dom: dom),
const SizedBox(height: 12),
// Mapa solo cuando camión está cerca (<15 min)
if (isTruckClose && route != null && !isCompleted) ...[
_WarningNoPursue(),
const SizedBox(height: 8),
RouteMapWidget(route: route, simulator: widget.sim, height: 220),
const SizedBox(height: 12),
],
],
// Aviso privacidad
_PrivacyBanner(),
const SizedBox(height: 12),
// Mis domicilios
_DomiciliosCard(auth: widget.auth),
const SizedBox(height: 12),
// Historial notificaciones
if (widget.sim.historyForRoute(routeId).isNotEmpty)
_HistorialCard(sim: widget.sim, routeId: routeId),
const SizedBox(height: 80),
])),
),
]),
);
}
}
// ── Selector de domicilio activo ──────────────────────────────────────────
class _DomicilioSelector extends StatelessWidget {
final AuthService auth; final VoidCallback onChanged;
const _DomicilioSelector({required this.auth, required this.onChanged});
@override
Widget build(BuildContext context) {
return Container(margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.guindaPrimary.withOpacity(0.3)),
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 4)]),
child: DropdownButtonHideUnderline(
child: DropdownButton<int>(
isExpanded: true,
value: auth.primaryDomicilio?.id,
icon: const Icon(Icons.swap_horiz, color: AppColors.guindaPrimary),
items: auth.allDomicilios.map((d) => DropdownMenuItem(
value: d.id,
child: Row(children: [
Icon(d.isPrimary ? Icons.home : Icons.location_on_outlined,
color: AppColors.guindaPrimary, size: 16),
const SizedBox(width: 6),
Expanded(child: Text('${d.alias}${d.colonia}',
style: const TextStyle(fontSize: 13), overflow: TextOverflow.ellipsis)),
]))).toList(),
onChanged: (id) async {
if (id != null) {
await DbHelper.setPrimaryDomicilio(id, auth.currentUser!.id!);
await auth.reloadDomicilios();
onChanged();
}
},
),
));
}
}
// ── Prompt de reseña ──────────────────────────────────────────────────────
class _ReviewPromptCard extends StatelessWidget {
final String routeId, colonia; final RouteSimulatorService sim;
const _ReviewPromptCard({required this.routeId, required this.colonia, required this.sim});
@override
Widget build(BuildContext context) => Card(
color: Colors.amber.shade50,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.amber.shade300, width: 1.5)),
child: Padding(padding: const EdgeInsets.all(14), child: Column(children: [
const Row(children: [
Text('', style: TextStyle(fontSize: 24)),
SizedBox(width: 8),
Expanded(child: Text('¿Cómo estuvo el servicio de hoy?',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14))),
]),
const SizedBox(height: 4),
const Text('El camión pasó por tu colonia. Toma un momento para calificar el servicio.',
style: TextStyle(fontSize: 12, color: AppColors.grisTexto)),
const SizedBox(height: 10),
Row(children: [
Expanded(child: ElevatedButton.icon(
onPressed: () => Navigator.push(context, MaterialPageRoute(
builder: (_) => ReviewScreen(routeId: routeId, colonia: colonia))),
style: ElevatedButton.styleFrom(backgroundColor: Colors.amber,
foregroundColor: Colors.black87),
icon: const Icon(Icons.star, size: 16),
label: const Text('Calificar', style: TextStyle(fontWeight: FontWeight.bold)))),
const SizedBox(width: 8),
TextButton(onPressed: () => sim.clearReviewPrompt(routeId),
child: const Text('Después', style: TextStyle(color: AppColors.grisTexto))),
]),
])));
}
// ── Info detallada de la ruta ─────────────────────────────────────────────
class _RouteInfoCard extends StatelessWidget {
final RouteDefinitionModel routeDef;
const _RouteInfoCard({required this.routeDef});
@override
Widget build(BuildContext context) => Card(
child: Padding(padding: const EdgeInsets.all(14), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
const Row(children: [
Icon(Icons.schedule, color: AppColors.guindaPrimary, size: 16),
SizedBox(width: 6),
Text('Información de tu ruta', style: TextStyle(
fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)),
]),
const Divider(),
Text(routeDef.nombre, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
const SizedBox(height: 4),
Row(children: [
const Icon(Icons.access_time, size: 13, color: AppColors.grisTexto),
const SizedBox(width: 4),
Text('${routeDef.horaInicio}${routeDef.horaFin} (${_turnoLabel(routeDef.turno)})',
style: const TextStyle(fontSize: 12, color: AppColors.negroTexto)),
]),
const SizedBox(height: 4),
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Icon(Icons.calendar_today, size: 13, color: AppColors.grisTexto),
const SizedBox(width: 4),
Expanded(child: Text(
routeDef.dias.map(AppDias.label).join(', '),
style: const TextStyle(fontSize: 12, color: AppColors.negroTexto))),
]),
])));
String _turnoLabel(String t) => t=='MATUTINO'?'🌄 Matutino':t=='VESPERTINO'?'🌅 Vespertino':'🌙 Nocturno';
}
class _BasicRouteInfo extends StatelessWidget {
final DomicilioModel dom;
const _BasicRouteInfo({required this.dom});
@override
Widget build(BuildContext context) => Card(
child: Padding(padding: const EdgeInsets.all(14), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
const Row(children: [
Icon(Icons.schedule, color: AppColors.guindaPrimary, size: 16),
SizedBox(width: 6),
Text('Tu servicio de recolección', style: TextStyle(
fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)),
]),
const Divider(),
Text('Ruta: ${dom.routeId}', style: const TextStyle(fontWeight: FontWeight.w600)),
Text('Horario: ${dom.horarioEstimado}',
style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)),
])));
}
// ── Aviso anti-persecución ────────────────────────────────────────────────
class _WarningNoPursue extends StatelessWidget {
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: Colors.red.shade50, borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade300)),
child: const Row(children: [
Icon(Icons.warning_amber_rounded, color: AppColors.rojoError, size: 20),
SizedBox(width: 8),
Expanded(child: Text(
'⚠️ Ya es momento de sacar tu basura.\n'
'🚫 NO persigas ni interceptes el camión en movimiento.\n'
'✅ Coloca tus bolsas en la acera y espera.',
style: TextStyle(fontSize: 11, color: AppColors.rojoError, fontWeight: FontWeight.w500))),
]));
}
// ── Mis domicilios ────────────────────────────────────────────────────────
class _DomiciliosCard extends StatelessWidget {
final AuthService auth;
const _DomiciliosCard({required this.auth});
@override
Widget build(BuildContext context) {
final doms = auth.allDomicilios;
return Card(child: Padding(padding: const EdgeInsets.all(14), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
const Icon(Icons.home_outlined, color: AppColors.guindaPrimary, size: 16),
const SizedBox(width: 6),
const Expanded(child: Text('Mis Domicilios',
style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.guindaPrimary))),
TextButton.icon(
onPressed: () async {
final result = await Navigator.push(context,
MaterialPageRoute(builder: (_) => const AddDomicilioScreen()));
if (result == true) await auth.reloadDomicilios();
},
icon: const Icon(Icons.add, size: 14),
label: const Text('Agregar', style: TextStyle(fontSize: 12)),
style: TextButton.styleFrom(foregroundColor: AppColors.guindaPrimary)),
]),
const Divider(),
if (doms.isEmpty)
const Text('Sin domicilios registrados',
style: TextStyle(color: AppColors.grisTexto, fontSize: 12))
else
...doms.map((d) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(children: [
Icon(d.isPrimary ? Icons.home : Icons.location_on_outlined,
color: d.isPrimary ? AppColors.guindaPrimary : AppColors.grisTexto, size: 16),
const SizedBox(width: 8),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('${d.alias}${d.colonia}',
style: TextStyle(fontWeight: d.isPrimary ? FontWeight.bold : FontWeight.normal,
fontSize: 12)),
Text(d.calle, style: const TextStyle(color: AppColors.grisTexto, fontSize: 11)),
Text('${d.routeId}${d.horarioEstimado}',
style: const TextStyle(color: AppColors.grisTexto, fontSize: 10)),
])),
if (!d.isPrimary)
IconButton(icon: const Icon(Icons.star_border, size: 16, color: AppColors.dorado),
tooltip: 'Hacer principal',
onPressed: () async {
await DbHelper.setPrimaryDomicilio(d.id!, auth.currentUser!.id!);
await auth.reloadDomicilios();
}),
IconButton(icon: const Icon(Icons.edit_outlined, size: 14, color: AppColors.grisTexto),
onPressed: () async {
final result = await Navigator.push(context, MaterialPageRoute(
builder: (_) => AddDomicilioScreen(editing: d)));
if (result == true) await auth.reloadDomicilios();
}),
if (!d.isPrimary)
IconButton(icon: const Icon(Icons.delete_outline, size: 14, color: AppColors.rojoError),
onPressed: () async {
await DbHelper.deleteDomicilio(d.id!);
await auth.reloadDomicilios();
}),
]))),
])));
}
}
// ── Banner de ruta con problema ───────────────────────────────────────────
class _RouteStatusBanner extends StatelessWidget {
final RouteStatusModel status;
const _RouteStatusBanner({required this.status});
@override
Widget build(BuildContext context) {
final isCancelled = status.status == RouteStatus.cancelada;
final isFalla = status.status == RouteStatus.fallaMecanica;
final isRetrasada = status.status == RouteStatus.retrasada;
final color = isCancelled ? AppColors.rojoError : isFalla ? Colors.red.shade800 : AppColors.naranjaAlerta;
final icon = isCancelled ? Icons.cancel : isFalla ? Icons.build : Icons.access_time;
final titulo = isCancelled ? '❌ Ruta Cancelada Hoy'
: isFalla ? '🔧 Falla Mecánica en Servicio' : '⏱️ Servicio con Retraso';
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Container(width: double.infinity, padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(12)),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(icon, color: Colors.white, size: 26),
const SizedBox(width: 10),
Expanded(child: Text(titulo, style: const TextStyle(color: Colors.white,
fontSize: 17, fontWeight: FontWeight.bold))),
]),
const SizedBox(height: 8),
Text(isCancelled
? 'El servicio no se realizará hoy. Guarda tus residuos para mañana.'
: isFalla
? 'El camión presentó una falla. El Ayuntamiento atiende la situación.'
: 'El camión presenta un retraso. El servicio se realizará con demora.',
style: const TextStyle(color: Colors.white, fontSize: 13)),
])),
if (status.mensaje != null && status.mensaje!.isNotEmpty) ...[
const SizedBox(height: 10),
Container(width: double.infinity, padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withOpacity(0.4))),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(Icons.admin_panel_settings, color: color, size: 16),
const SizedBox(width: 6),
Text('Mensaje del Ayuntamiento', style: TextStyle(
fontWeight: FontWeight.bold, color: color, fontSize: 13)),
]),
const SizedBox(height: 6),
Text(status.mensaje!, style: const TextStyle(fontSize: 13)),
])),
],
const SizedBox(height: 10),
Container(padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300)),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('💡 Recomendaciones:', style: TextStyle(fontWeight: FontWeight.bold,
fontSize: 12, color: AppColors.grisTexto)),
const SizedBox(height: 4),
Text(isCancelled
? '• Guarda tus bolsas en lugar cerrado\n• No dejes residuos en la acera\n• Revisa la app mañana'
: isRetrasada
? '• Espera el aviso de 15 minutos antes de sacar tu basura\n• El camión llegará eventualmente\n• Recibe la notificación en esta app'
: '• Espera confirmación del Ayuntamiento\n• Puede enviarse unidad de reemplazo',
style: const TextStyle(fontSize: 12, color: AppColors.grisTexto)),
])),
const SizedBox(height: 12),
]);
}
}
// ── ETA Card ──────────────────────────────────────────────────────────────
class _EtaCard extends StatelessWidget {
final RouteSimulatorService sim; final String routeId; final dom; final route;
const _EtaCard({required this.sim, required this.routeId, required this.dom, required this.route});
@override
Widget build(BuildContext context) => Container(
decoration: BoxDecoration(
gradient: const LinearGradient(colors:[AppColors.guindaPrimary,AppColors.guindaDark],
begin:Alignment.topLeft,end:Alignment.bottomRight),
borderRadius: BorderRadius.circular(14),
boxShadow: [BoxShadow(color:AppColors.guindaDark.withOpacity(0.4),blurRadius:8,offset:const Offset(0,4))]),
padding: const EdgeInsets.all(18),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children:[
const Icon(Icons.local_shipping,color:AppColors.dorado,size:22),
const SizedBox(width:8),
Expanded(child:Text(route?.name??dom?.routeId??'Tu ruta',
style:const TextStyle(color:AppColors.dorado,fontSize:13,fontWeight:FontWeight.w600))),
]),
const SizedBox(height:8),
Text(sim.getEtaText(routeId),
style:const TextStyle(color:Colors.white,fontSize:16,fontWeight:FontWeight.bold)),
const SizedBox(height:6),
if (dom!=null) Text('${dom.horarioEstimado}',
style:const TextStyle(color:Colors.white60,fontSize:11)),
const SizedBox(height:10),
LinearProgressIndicator(
value:route!=null?(sim.getPositionIndex(routeId)+1)/route.positions.length:0,
backgroundColor:Colors.white24,
valueColor:const AlwaysStoppedAnimation<Color>(AppColors.dorado)),
]));
}
// ── Privacidad ────────────────────────────────────────────────────────────
class _PrivacyBanner extends StatelessWidget {
@override
Widget build(BuildContext context) => Container(
padding:const EdgeInsets.all(10),
decoration:BoxDecoration(color:Colors.amber.shade50,borderRadius:BorderRadius.circular(8),
border:Border.all(color:Colors.amber.shade300)),
child:const Row(children:[
Icon(Icons.shield_outlined,color:Colors.amber,size:18),
SizedBox(width:6),
Expanded(child:Text('🔒 Solo ves la información de tu ruta asignada.',
style:TextStyle(fontSize:11,color:Colors.black87))),
]));
}
// ── Historial notificaciones ──────────────────────────────────────────────
class _HistorialCard extends StatelessWidget {
final RouteSimulatorService sim; final String routeId;
const _HistorialCard({required this.sim, required this.routeId});
@override
Widget build(BuildContext context) {
final notifs = sim.historyForRoute(routeId).take(5).toList();
return Card(child:Padding(padding:const EdgeInsets.all(14),child:Column(
crossAxisAlignment:CrossAxisAlignment.start, children:[
const Text('Alertas recientes',style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.guindaPrimary)),
const Divider(),
...notifs.map((n){
final color = n.event==NotifEvent.truckProximity||n.event==NotifEvent.truckApproaching15min
?AppColors.naranjaAlerta:n.event==NotifEvent.routeCompleted||n.event==NotifEvent.reviewPrompt
?AppColors.verdeExito:n.event==NotifEvent.routeCancelled?AppColors.rojoError:AppColors.azulInfo;
return Padding(padding:const EdgeInsets.symmetric(vertical:3),
child:Row(children:[
Icon(Icons.circle,size:8,color:color),
const SizedBox(width:8),
Expanded(child:Text(n.title,style:const TextStyle(fontSize:12,fontWeight:FontWeight.w500))),
Text('${n.timestamp.hour.toString().padLeft(2,'0')}:${n.timestamp.minute.toString().padLeft(2,'0')}',
style:const TextStyle(fontSize:10,color:AppColors.grisTexto)),
]));
}),
])));
}
}
// ── Notif Banner ──────────────────────────────────────────────────────────
class _NotifBanner extends StatelessWidget {
final AppNotification notif; final VoidCallback onDismiss;
const _NotifBanner({required this.notif, required this.onDismiss});
@override
Widget build(BuildContext context) {
final isUrgent = notif.event==NotifEvent.truckProximity||notif.event==NotifEvent.truckApproaching15min;
final isReview = notif.event==NotifEvent.reviewPrompt;
final color = isUrgent?AppColors.naranjaAlerta
:isReview?Colors.amber.shade700
:notif.event==NotifEvent.routeCancelled?AppColors.rojoError
:notif.event==NotifEvent.gpsLost?Colors.red.shade800
:AppColors.azulInfo;
return Material(color:Colors.transparent,
child:Container(margin:const EdgeInsets.all(12),
decoration:BoxDecoration(color:color,borderRadius:BorderRadius.circular(12),
boxShadow:const[BoxShadow(color:Colors.black26,blurRadius:8,offset:Offset(0,4))]),
child:Padding(padding:const EdgeInsets.all(12),child:Row(children:[
Icon(isReview?Icons.star:Icons.notifications_active,color:Colors.white,size:24),
const SizedBox(width:10),
Expanded(child:Column(crossAxisAlignment:CrossAxisAlignment.start,
mainAxisSize:MainAxisSize.min,children:[
Text(notif.title,style:const TextStyle(color:Colors.white,fontWeight:FontWeight.bold,fontSize:13)),
Text(notif.body,style:const TextStyle(color:Colors.white70,fontSize:11),
maxLines:2,overflow:TextOverflow.ellipsis),
])),
IconButton(icon:const Icon(Icons.close,color:Colors.white,size:18),onPressed:onDismiss),
]))));
}
}

View File

@@ -0,0 +1,221 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:image_picker/image_picker.dart';
import '../../core/app_colors.dart';
import '../../database/db_helper.dart';
import '../../models/models.dart';
import '../../services/auth_service.dart';
class CitizenReporteScreen extends StatefulWidget {
const CitizenReporteScreen({super.key});
@override State<CitizenReporteScreen> createState() => _CitizenReporteScreenState();
}
class _CitizenReporteScreenState extends State<CitizenReporteScreen> {
String _tipo = 'CAMION_NO_PASO';
final _desc = TextEditingController();
int _calif = 5;
bool _loading = false, _sent = false;
List<ReporteModel> _reportes = [];
File? _foto;
final _picker = ImagePicker();
static const _tipos = {
'CAMION_NO_PASO': 'El camion no paso',
'RETRASO': 'Retraso significativo',
'RESIDUOS_NO_RECOGIDOS': 'Residuos no recogidos',
'OTRO': 'Otro motivo',
};
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final auth = context.read<AuthService>();
if (auth.currentUser == null) return;
final r = await DbHelper.getReportesByUser(auth.currentUser!.id!);
if (mounted) setState(() => _reportes = r);
}
Future<void> _pickImage(ImageSource source) async {
try {
final picked = await _picker.pickImage(source: source, imageQuality: 70, maxWidth: 1024);
if (picked != null && mounted) setState(() => _foto = File(picked.path));
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('No se pudo acceder a la camara: $e'),
backgroundColor: AppColors.rojoError));
}
}
void _showPhotoOptions() {
showModalBottomSheet(context: context, builder: (_) => SafeArea(
child: Column(mainAxisSize: MainAxisSize.min, children: [
ListTile(leading: const Icon(Icons.camera_alt, color: AppColors.guindaPrimary),
title: const Text('Tomar foto'),
onTap: () { Navigator.pop(context); _pickImage(ImageSource.camera); }),
ListTile(leading: const Icon(Icons.photo_library, color: AppColors.guindaPrimary),
title: const Text('Elegir de galeria'),
onTap: () { Navigator.pop(context); _pickImage(ImageSource.gallery); }),
if (_foto != null)
ListTile(leading: const Icon(Icons.delete_outline, color: AppColors.rojoError),
title: const Text('Quitar foto', style: TextStyle(color: AppColors.rojoError)),
onTap: () { Navigator.pop(context); setState(() => _foto = null); }),
])));
}
Future<void> _send() async {
final auth = context.read<AuthService>();
if (auth.currentUser == null || _desc.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Describe el problema'),
backgroundColor: AppColors.rojoError));
return;
}
setState(() => _loading = true);
final db = await DbHelper.database;
await db.insert('reportes', {
'user_id': auth.currentUser!.id,
'tipo': _tipo,
'descripcion': _desc.text.trim(),
'colonia': auth.primaryDomicilio?.colonia ?? '',
'route_id': auth.primaryDomicilio?.routeId ?? '',
'fecha': DateTime.now().toIso8601String(),
'estado': 'PENDIENTE',
'calificacion': _calif,
'foto_path': _foto?.path,
});
await _load();
if (!mounted) return;
setState(() { _loading = false; _sent = true; _desc.clear(); _foto = null; });
await Future.delayed(const Duration(seconds: 2));
if (mounted) setState(() => _sent = false);
}
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(automaticallyImplyLeading: false,
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: const Text('Reportar Incidencia'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado))),
body: _sent
? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
const Icon(Icons.check_circle, color: AppColors.verdeExito, size: 64),
const SizedBox(height: 12),
const Text('Reporte enviado', style: TextStyle(fontSize: 20,
fontWeight: FontWeight.bold, color: AppColors.verdeExito)),
const Text('El Ayuntamiento lo revisara pronto.',
style: TextStyle(color: AppColors.grisTexto)),
]))
: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(children: [
Card(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(padding: const EdgeInsets.all(16), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('Tipo de incidencia', style: TextStyle(
fontWeight: FontWeight.bold, color: AppColors.guindaPrimary, fontSize: 15)),
const SizedBox(height: 8),
..._tipos.entries.map((e) => RadioListTile<String>(dense: true,
value: e.key, groupValue: _tipo,
title: Text(e.value, style: const TextStyle(fontSize: 13)),
activeColor: AppColors.guindaPrimary,
onChanged: (v) => setState(() => _tipo = v!))),
const SizedBox(height: 8),
DropdownButtonFormField<int>(value: _calif,
decoration: const InputDecoration(labelText: 'Calificacion del servicio',
border: OutlineInputBorder()),
items: [5,4,3,2,1].map((n) => DropdownMenuItem(value: n,
child: Text(['Excelente','Bueno','Regular','Malo','Muy malo'][5-n]))).toList(),
onChanged: (v) => setState(() => _calif = v!)),
const SizedBox(height: 10),
TextField(controller: _desc, maxLines: 3,
decoration: const InputDecoration(hintText: 'Describe el problema...',
border: OutlineInputBorder(), filled: true, fillColor: Colors.white)),
const SizedBox(height: 12),
// Foto adjunta
const Text('Foto del incidente (opcional)',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
const SizedBox(height: 8),
GestureDetector(
onTap: _showPhotoOptions,
child: Container(
width: double.infinity, height: _foto != null ? 180 : 80,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: _foto != null
? AppColors.guindaPrimary : Colors.grey.shade300,
style: BorderStyle.solid)),
child: _foto != null
? Stack(children: [
ClipRRect(borderRadius: BorderRadius.circular(8),
child: Image.file(_foto!, fit: BoxFit.cover,
width: double.infinity, height: 180)),
Positioned(top: 8, right: 8,
child: GestureDetector(onTap: () => setState(() => _foto = null),
child: Container(padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: AppColors.rojoError, shape: BoxShape.circle),
child: const Icon(Icons.close, color: Colors.white, size: 16)))),
])
: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
const Icon(Icons.add_a_photo_outlined,
color: AppColors.grisTexto, size: 28),
const SizedBox(height: 4),
const Text('Agregar foto', style: TextStyle(
color: AppColors.grisTexto, fontSize: 12)),
]),
),
),
const SizedBox(height: 14),
SizedBox(width: double.infinity, height: 48,
child: ElevatedButton.icon(
onPressed: _loading ? null : _send,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.guindaPrimary, 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.send),
label: const Text('ENVIAR REPORTE',
style: TextStyle(fontWeight: FontWeight.bold)))),
]))),
if (_reportes.isNotEmpty) ...[
const SizedBox(height: 16),
const Align(alignment: Alignment.centerLeft,
child: Text('Mis Reportes', style: TextStyle(fontWeight: FontWeight.bold,
color: AppColors.guindaPrimary, fontSize: 15))),
const SizedBox(height: 8),
..._reportes.map((r) => Card(margin: const EdgeInsets.only(bottom: 6),
child: ListTile(dense: true,
leading: CircleAvatar(backgroundColor: AppColors.guindaPrimary, radius: 16,
child: const Icon(Icons.report, color: Colors.white, size: 16)),
title: Text(_tipos[r.tipo] ?? r.tipo,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)),
subtitle: Text(r.descripcion, maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11)),
trailing: Container(padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: _estadoColor(r.estado).withOpacity(0.15),
borderRadius: BorderRadius.circular(10)),
child: Text(r.estado, style: TextStyle(fontSize: 9,
color: _estadoColor(r.estado), fontWeight: FontWeight.bold)))))),
],
])),
);
Color _estadoColor(String e) {
switch (e) {
case 'RESUELTO': return AppColors.verdeExito;
case 'EN_REVISION': return AppColors.azulInfo;
default: return AppColors.naranjaAlerta;
}
}
@override void dispose() { _desc.dispose(); super.dispose(); }
}

View File

@@ -0,0 +1,253 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import '../../core/app_colors.dart';
import '../../database/db_helper.dart';
import '../../models/models.dart';
import '../../services/auth_service.dart';
class CollectionCalendarScreen extends StatefulWidget {
const CollectionCalendarScreen({super.key});
@override State<CollectionCalendarScreen> createState() => _CollectionCalendarScreenState();
}
class _CollectionCalendarScreenState extends State<CollectionCalendarScreen> {
RouteDefinitionModel? _routeDef;
List<ReviewModel> _myReviews = [];
bool _loading = true;
@override
void initState() { super.initState(); _load(); }
Future<void> _load() async {
final auth = context.read<AuthService>();
final dom = auth.primaryDomicilio;
if (dom != null) {
final rd = await DbHelper.getRouteDefinitionById(dom.routeId);
final rv = await DbHelper.getAllReviews();
final mine = rv.where((r) => r.userId == auth.currentUser?.id).toList();
if (mounted) setState(() { _routeDef = rd; _myReviews = mine; _loading = false; });
} else {
if (mounted) setState(() => _loading = false);
}
}
void _shareSchedule() {
final auth = context.read<AuthService>();
final dom = auth.primaryDomicilio;
if (dom == null) return;
final rd = _routeDef;
final diasStr = rd?.dias.map(_diaLabel).join(', ') ?? 'Lunes, Miércoles y Viernes';
final horario = rd != null ? '${rd.horaInicio}${rd.horaFin}' : dom.horarioEstimado;
Share.share(
'🗑️ Horario de recolección de basura\n'
'📍 Colonia: ${dom.colonia}\n'
'📅 Días: $diasStr\n'
'⏰ Horario: $horario\n'
'🚛 Ruta: ${dom.routeId}\n\n'
'Descarga Celaya Limpia para recibir avisos en tiempo real.',
);
}
String _diaLabel(String d) {
const m = {'LUNES':'Lu','MARTES':'Ma','MIERCOLES':'Mi',
'JUEVES':'Ju','VIERNES':'Vi','SABADO':'Sa','DOMINGO':'Do'};
return m[d] ?? d;
}
// Días del mes actual con marcas de recolección
List<Widget> _buildCalendar() {
final now = DateTime.now();
final first = DateTime(now.year, now.month, 1);
final days = DateTime(now.year, now.month + 1, 0).day;
final dias = _routeDef?.dias ?? [];
const weekDays = ['LUNES','MARTES','MIERCOLES','JUEVES','VIERNES','SABADO','DOMINGO'];
final monthName = ['','Enero','Febrero','Marzo','Abril','Mayo','Junio',
'Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'][now.month];
return [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text('$monthName ${now.year}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16,
color: AppColors.guindaPrimary)),
),
const SizedBox(height: 8),
// Cabeceras días
Row(children: ['Lu','Ma','Mi','Ju','Vi','Sa','Do'].map((d) =>
Expanded(child: Center(child: Text(d, style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 11, color: AppColors.grisTexto))))).toList()),
const SizedBox(height: 4),
// Grilla de días
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 7, childAspectRatio: 1),
itemCount: (first.weekday - 1) + days,
itemBuilder: (_, i) {
if (i < first.weekday - 1) return const SizedBox();
final day = i - (first.weekday - 1) + 1;
final date = DateTime(now.year, now.month, day);
final diaSem = weekDays[date.weekday - 1];
final isCollection = dias.contains(diaSem);
final isToday = day == now.day;
final isPast = date.isBefore(DateTime(now.year, now.month, now.day));
return Container(
margin: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: isCollection
? (isPast ? AppColors.guindaPrimary.withOpacity(0.4) : AppColors.guindaPrimary)
: (isToday ? Colors.grey.shade200 : null),
shape: BoxShape.circle,
border: isToday ? Border.all(color: AppColors.dorado, width: 2) : null,
),
child: Stack(alignment: Alignment.center, children: [
Text('$day', style: TextStyle(
fontSize: 12,
fontWeight: isToday ? FontWeight.bold : FontWeight.normal,
color: isCollection ? Colors.white : AppColors.negroTexto,
)),
if (isCollection)
Positioned(bottom: 2, child: Container(
width: 4, height: 4,
decoration: const BoxDecoration(color: AppColors.dorado, shape: BoxShape.circle),
)),
]),
);
},
),
];
}
@override
Widget build(BuildContext context) {
final auth = context.read<AuthService>();
final dom = auth.primaryDomicilio;
return Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: const Text('Calendario de Recoleccion'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
actions: [
IconButton(icon: const Icon(Icons.share), tooltip: 'Compartir horario',
onPressed: _shareSchedule),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
// Info de la ruta
if (dom != null)
Card(child: Padding(padding: const EdgeInsets.all(14), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
const Row(children: [
Icon(Icons.local_shipping, color: AppColors.guindaPrimary, size: 18),
SizedBox(width: 6),
Text('Tu servicio de recoleccion', style: TextStyle(
fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)),
]),
const Divider(),
Text('Colonia: ${dom.colonia}',
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
Text('Ruta: ${dom.routeId}',
style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)),
if (_routeDef != null) ...[
Text('Dias: ${_routeDef!.dias.map(_diaLabel).join(" · ")}',
style: const TextStyle(fontSize: 12)),
Text('Horario: ${_routeDef!.horaInicio} - ${_routeDef!.horaFin}',
style: const TextStyle(fontSize: 12)),
] else
Text(dom.horarioEstimado,
style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)),
]))),
const SizedBox(height: 16),
// Leyenda
Row(children: [
_Legend(color: AppColors.guindaPrimary, label: 'Dia de recoleccion'),
const SizedBox(width: 12),
_Legend(color: AppColors.dorado, label: 'Punto en dia activo'),
const SizedBox(width: 12),
_Legend(color: Colors.grey.shade200, label: 'Hoy'),
]),
const SizedBox(height: 12),
// Calendario
Card(child: Padding(padding: const EdgeInsets.all(14), child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: _buildCalendar()))),
const SizedBox(height: 16),
// Consejos semanales
Card(color: Colors.blue.shade50,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10),
side: BorderSide(color: Colors.blue.shade200)),
child: Padding(padding: const EdgeInsets.all(14), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
const Row(children: [
Icon(Icons.tips_and_updates, color: AppColors.azulInfo),
SizedBox(width: 8),
Text('Consejo de la semana', style: TextStyle(
fontWeight: FontWeight.bold, color: AppColors.azulInfo, fontSize: 14)),
]),
const SizedBox(height: 8),
Text(_weeklyTip(), style: const TextStyle(fontSize: 13, color: AppColors.negroTexto)),
])),
),
const SizedBox(height: 16),
// Mis calificaciones
if (_myReviews.isNotEmpty) ...[
const Text('Mis calificaciones', style: TextStyle(
fontWeight: FontWeight.bold, fontSize: 15, color: AppColors.guindaPrimary)),
const SizedBox(height: 8),
..._myReviews.take(3).map((r) => Card(margin: const EdgeInsets.only(bottom: 8),
child: ListTile(dense: true,
leading: CircleAvatar(backgroundColor: Colors.amber.shade100,
child: Text('${r.estrellas}', style: const TextStyle(
fontWeight: FontWeight.bold, color: Colors.amber))),
title: Text(r.colonia, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)),
subtitle: Text(r.comentario, maxLines: 1, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11)),
trailing: Text(
'${DateTime.tryParse(r.fecha)?.day}/${DateTime.tryParse(r.fecha)?.month}',
style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
))),
],
const SizedBox(height: 30),
])),
);
}
String _weeklyTip() {
final tips = [
'Separa tus residuos en organicos (restos de comida) e inorganicos (plasticos, metales). Facilita el reciclaje y reduce la contaminacion.',
'Coloca tus bolsas en la acera SOLO cuando recibas el aviso de 15 minutos. Sacarlas antes atrae fauna nociva.',
'El reciclaje de 1 tonelada de papel salva 17 arboles. Dobla tus cajas y periodicos antes de depositarlos.',
'Los aceites usados de cocina NO van a la basura. Llevalos a los puntos de acopio del municipio.',
'Composta tus restos organicos si tienes jardin. Reduce hasta un 40% tu basura y mejora tu suelo.',
'Las pilas y baterias son residuos peligrosos. Depositalas en los contenedores especiales de tiendas.',
'Un celular viejo contiene oro, plata y cobre. Llevalo a un punto RAEE para su reciclaje correcto.',
];
return tips[DateTime.now().weekday % tips.length];
}
}
class _Legend extends StatelessWidget {
final Color color; final String label;
const _Legend({required this.color, required this.label});
@override
Widget build(BuildContext context) => Row(mainAxisSize: MainAxisSize.min, children: [
Container(width: 12, height: 12, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
const SizedBox(width: 4),
Text(label, style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
]);
}

View File

@@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/app_colors.dart';
import '../../database/db_helper.dart';
import '../../services/auth_service.dart';
class NotificationHistoryScreen extends StatefulWidget {
const NotificationHistoryScreen({super.key});
@override State<NotificationHistoryScreen> createState() => _NotificationHistoryScreenState();
}
class _NotificationHistoryScreenState extends State<NotificationHistoryScreen> {
List<Map<String, dynamic>> _notifs = [];
bool _loading = true;
@override
void initState() { super.initState(); _load(); }
Future<void> _load() async {
final auth = context.read<AuthService>();
if (auth.currentUser == null) return;
final n = await DbHelper.getNotifHistory(auth.currentUser!.id!);
await DbHelper.markAllNotifsRead(auth.currentUser!.id!);
if (mounted) setState(() { _notifs = n; _loading = false; });
}
Color _color(String type) {
switch (type) {
case 'truckProximity':
case 'truckApproaching15min': return AppColors.naranjaAlerta;
case 'routeCompleted':
case 'reviewPrompt': return AppColors.verdeExito;
case 'routeCancelled': return AppColors.rojoError;
case 'gpsLost': return Colors.red.shade800;
case 'truckStopped': return AppColors.naranjaAlerta;
default: return AppColors.azulInfo;
}
}
IconData _icon(String type) {
switch (type) {
case 'truckProximity':
case 'truckApproaching15min': return Icons.warning_amber_rounded;
case 'routeCompleted': return Icons.check_circle;
case 'reviewPrompt': return Icons.star;
case 'routeCancelled': return Icons.cancel;
case 'gpsLost': return Icons.gps_off;
default: return Icons.notifications;
}
}
String _timeAgo(String fechaStr) {
final f = DateTime.tryParse(fechaStr);
if (f == null) return '';
final diff = DateTime.now().difference(f);
if (diff.inMinutes < 1) return 'Ahora';
if (diff.inMinutes < 60) return 'Hace ${diff.inMinutes} min';
if (diff.inHours < 24) return 'Hace ${diff.inHours}h';
return '${f.day}/${f.month}/${f.year}';
}
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: const Text('Historial de Alertas'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
actions: [
TextButton(
onPressed: () async {
await DbHelper.markAllNotifsRead(
context.read<AuthService>().currentUser!.id!);
setState(() {});
},
child: const Text('Marcar leídas', style: TextStyle(color: AppColors.dorado, fontSize: 12)),
),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _notifs.isEmpty
? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.notifications_none, color: Colors.grey.shade400, size: 64),
const SizedBox(height: 12),
Text('Sin alertas registradas', style: TextStyle(color: Colors.grey.shade500)),
]))
: ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: _notifs.length,
itemBuilder: (_, i) {
final n = _notifs[i];
final isUnread = (n['leida'] as int?) == 0;
final color = _color(n['event_type'] ?? '');
return Container(
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: isUnread ? color.withOpacity(0.05) : Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isUnread ? color.withOpacity(0.3) : Colors.grey.shade200),
),
child: ListTile(
leading: CircleAvatar(
backgroundColor: color.withOpacity(0.15),
child: Icon(_icon(n['event_type'] ?? ''), color: color, size: 20),
),
title: Text(n['title'] ?? '', style: TextStyle(
fontWeight: isUnread ? FontWeight.bold : FontWeight.normal,
fontSize: 13)),
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(n['body'] ?? '', style: const TextStyle(fontSize: 11), maxLines: 2,
overflow: TextOverflow.ellipsis),
const SizedBox(height: 2),
Text('${n['route_id']} · ${_timeAgo(n['fecha'] ?? '')}',
style: TextStyle(fontSize: 10, color: color.withOpacity(0.7))),
]),
trailing: isUnread
? Container(width: 8, height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle))
: null,
),
);
}),
);
}

View File

@@ -0,0 +1,183 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/app_colors.dart';
import '../../database/db_helper.dart';
import '../../models/models.dart';
import '../../services/auth_service.dart';
import '../../services/route_simulator_service.dart';
class ReviewScreen extends StatefulWidget {
final String routeId;
final String colonia;
const ReviewScreen({super.key, required this.routeId, required this.colonia});
@override State<ReviewScreen> createState() => _ReviewScreenState();
}
class _ReviewScreenState extends State<ReviewScreen> {
int _estrellas = 5;
final _comentCtrl = TextEditingController();
bool _loading = false;
bool _sent = false;
static const _labels = ['', 'Muy malo', 'Malo', 'Regular', 'Bueno', 'Excelente'];
static const _colors = [
Colors.transparent, AppColors.rojoError, AppColors.naranjaAlerta,
Colors.amber, AppColors.verdeExito, AppColors.verdeExito,
];
Future<void> _enviar() async {
final auth = context.read<AuthService>();
if (auth.currentUser == null) return;
// Verificar si ya calificó hoy
final yaCalificado = await DbHelper.hasReviewedRoute(
auth.currentUser!.id!, widget.routeId);
if (yaCalificado && mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Ya calificaste este servicio hoy'),
backgroundColor: AppColors.azulInfo));
return;
}
setState(() => _loading = true);
await DbHelper.insertReview(ReviewModel(
userId: auth.currentUser!.id!,
colonia: widget.colonia,
routeId: widget.routeId,
estrellas: _estrellas,
comentario: _comentCtrl.text.trim().isEmpty
? 'Sin comentario' : _comentCtrl.text.trim(),
fecha: DateTime.now().toIso8601String(),
nombreUsuario: auth.currentUser!.nombre,
));
context.read<RouteSimulatorService>().clearReviewPrompt(widget.routeId);
if (!mounted) return;
setState(() { _loading = false; _sent = true; });
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: const Text('Calificar el Servicio'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
),
body: _sent
? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
const Text('', style: TextStyle(fontSize: 64)),
const SizedBox(height: 16),
const Text('¡Gracias por tu calificación!',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold,
color: AppColors.guindaPrimary)),
const SizedBox(height: 8),
const Text('Tu opinión ayuda a mejorar el servicio\nde recolección en Celaya.',
textAlign: TextAlign.center,
style: TextStyle(color: AppColors.grisTexto)),
const SizedBox(height: 24),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white),
onPressed: () => Navigator.pop(context),
child: const Text('Volver al inicio')),
]))
: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(children: [
// Header
Container(
width: double.infinity, padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.guindaPrimary.withOpacity(0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.guindaPrimary.withOpacity(0.2))),
child: Column(children: [
const Icon(Icons.local_shipping, color: AppColors.guindaPrimary, size: 36),
const SizedBox(height: 8),
Text(widget.routeId, style: const TextStyle(
fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)),
Text(widget.colonia, style: const TextStyle(
color: AppColors.grisTexto, fontSize: 12)),
]),
),
const SizedBox(height: 24),
// Estrellas
const Text('¿Cómo calificarías el servicio de hoy?',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const SizedBox(height: 16),
Row(mainAxisAlignment: MainAxisAlignment.center, children: List.generate(5, (i) {
final star = i + 1;
return GestureDetector(
onTap: () => setState(() => _estrellas = star),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Icon(
_estrellas >= star ? Icons.star : Icons.star_border,
color: _estrellas >= star ? Colors.amber : Colors.grey,
size: 44,
),
),
);
})),
const SizedBox(height: 8),
Text(_labels[_estrellas],
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold,
color: _colors[_estrellas])),
const SizedBox(height: 24),
// Comentario
const Align(alignment: Alignment.centerLeft,
child: Text('Comentario (opcional)',
style: TextStyle(fontWeight: FontWeight.w600))),
const SizedBox(height: 8),
TextField(
controller: _comentCtrl,
maxLines: 4,
maxLength: 200,
decoration: const InputDecoration(
hintText: 'Cuéntanos cómo estuvo el servicio...',
border: OutlineInputBorder(),
filled: true, fillColor: Colors.white),
),
const SizedBox(height: 20),
// Aviso
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 Row(children: [
Icon(Icons.info_outline, color: AppColors.azulInfo, size: 16),
SizedBox(width: 6),
Expanded(child: Text(
'Tu calificación es anónima para otros ciudadanos, '
'pero el Ayuntamiento la usará para mejorar el servicio.',
style: TextStyle(fontSize: 11, color: AppColors.azulInfo))),
]),
),
const SizedBox(height: 24),
SizedBox(width: double.infinity, height: 50,
child: ElevatedButton.icon(
onPressed: _loading ? null : _enviar,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.guindaPrimary, 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.star),
label: const Text('ENVIAR CALIFICACIÓN',
style: TextStyle(fontWeight: FontWeight.bold)))),
]),
),
);
}
@override void dispose() { _comentCtrl.dispose(); super.dispose(); }
}

View File

@@ -0,0 +1,456 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/app_colors.dart';
import '../../services/auth_service.dart';
import '../../services/route_simulator_service.dart';
import '../../database/db_helper.dart';
import '../../models/models.dart';
import '../../data/routes_data.dart';
import '../../widgets/route_map_widget.dart';
import '../settings_screen.dart';
class DriverHomeScreen extends StatefulWidget {
const DriverHomeScreen({super.key});
@override State<DriverHomeScreen> createState() => _DriverHomeScreenState();
}
class _DriverHomeScreenState extends State<DriverHomeScreen> {
int _tab = 0;
List<AssignmentModel> _assignments = [];
String? _todayRouteId;
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final auth = context.read<AuthService>();
if (auth.currentUser == null) return;
final list = await DbHelper.getAsignacionesByConductor(auth.currentUser!.id!);
final today = _todayDia();
setState(() {
_assignments = list;
final match = list.where((a) => a.diaSemana == today);
_todayRouteId = match.isNotEmpty ? match.first.routeId : null;
});
if (_todayRouteId != null) {
context.read<RouteSimulatorService>().startRoute(_todayRouteId!);
}
}
String _todayDia() {
const d = ['','LUNES','MARTES','MIERCOLES','JUEVES','VIERNES','SABADO','DOMINGO'];
return d[DateTime.now().weekday];
}
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthService>();
final sim = context.watch<RouteSimulatorService>();
final route = _todayRouteId != null ? getRouteById(_todayRouteId!) : null;
// Solo notificaciones de la ruta actual del conductor
final lastNotif = _todayRouteId != null
? sim.getNotificationForRoute(_todayRouteId!) : null;
final tabs = [
_DriverMainTab(auth:auth, sim:sim, route:route,
assignments:_assignments, todayRouteId:_todayRouteId, onRefresh:_load),
if (route != null) _DriverMapTab(route:route, sim:sim)
else const Center(child:Text('Sin ruta hoy')),
_DriverReportesTab(conductorId:auth.currentUser?.id, todayRouteId:_todayRouteId),
];
return Scaffold(
body: Stack(children:[
tabs[_tab],
if (lastNotif != null)
Positioned(top:MediaQuery.of(context).padding.top+8, left:0, right:0,
child:_NotifBanner(notif:lastNotif,
onDismiss:()=>sim.dismissRouteNotification(_todayRouteId??''))),
]),
bottomNavigationBar: NavigationBar(
selectedIndex: _tab,
onDestinationSelected: (i) => setState(()=>_tab=i),
backgroundColor: Colors.white,
indicatorColor: AppColors.moradoConductor.withOpacity(0.15),
destinations: const [
NavigationDestination(icon:Icon(Icons.dashboard_outlined),
selectedIcon:Icon(Icons.dashboard,color:AppColors.moradoConductor),label:'Mi Ruta'),
NavigationDestination(icon:Icon(Icons.map_outlined),
selectedIcon:Icon(Icons.map,color:AppColors.moradoConductor),label:'Mapa'),
NavigationDestination(icon:Icon(Icons.report_problem_outlined),
selectedIcon:Icon(Icons.report_problem,color:AppColors.moradoConductor),label:'Incidente'),
],
),
);
}
}
// ── Tab principal ─────────────────────────────────────────────────────────
class _DriverMainTab extends StatefulWidget {
final AuthService auth; final RouteSimulatorService sim;
final route; final assignments; final todayRouteId; final VoidCallback onRefresh;
const _DriverMainTab({required this.auth, required this.sim, required this.route,
required this.assignments, required this.todayRouteId, required this.onRefresh});
@override State<_DriverMainTab> createState() => _DriverMainTabState();
}
class _DriverMainTabState extends State<_DriverMainTab> {
List<ReporteModel> _ciudadanoReportes = [];
@override void initState() { super.initState(); _loadReportes(); }
Future<void> _loadReportes() async {
if (widget.todayRouteId == null) return;
final all = await DbHelper.getAllReportes();
final filtered = all.where((r) => r.routeId == widget.todayRouteId).toList();
if (mounted) setState(() => _ciudadanoReportes = filtered.take(5).toList());
}
@override
Widget build(BuildContext context) {
final posIdx = widget.todayRouteId != null
? widget.sim.getPositionIndex(widget.todayRouteId!) : 0;
final gpsOk = widget.todayRouteId != null
? widget.sim.isGpsActive(widget.todayRouteId!) : true;
return CustomScrollView(slivers:[
SliverAppBar(pinned:true, backgroundColor:AppColors.moradoConductor, foregroundColor:Colors.white,
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
child:Container(height:4,color:AppColors.dorado)),
title:Text('Conductor: ${widget.auth.currentUser?.nombre.split(' ').first ?? ''}',
style:const TextStyle(fontSize:16,fontWeight:FontWeight.bold)),
actions:[IconButton(icon:const Icon(Icons.logout),
onPressed:()async{ await widget.auth.logout();
if(context.mounted) Navigator.pushReplacementNamed(context,'/login');})]),
SliverPadding(padding:const EdgeInsets.all(14),sliver:SliverList(delegate:SliverChildListDelegate([
// Ruta de hoy
Card(color:AppColors.moradoConductor.withOpacity(0.08),
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(12),
side:BorderSide(color:AppColors.moradoConductor.withOpacity(0.3))),
child:Padding(padding:const EdgeInsets.all(14),child:Column(
crossAxisAlignment:CrossAxisAlignment.start, children:[
Row(children:[
const Icon(Icons.today,color:AppColors.moradoConductor),
const SizedBox(width:8),
Text('Hoy — ${_todayLabel()}',
style:const TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor,fontSize:15)),
]),
const Divider(),
if (widget.route != null)...[
Text(widget.route.name,style:const TextStyle(fontWeight:FontWeight.bold,fontSize:14)),
Text('Camión ${widget.route.truckId} • Turno: ${widget.route.turno}',
style:const TextStyle(color:AppColors.grisTexto,fontSize:12)),
const SizedBox(height:8),
Row(children:[
Icon(gpsOk?Icons.gps_fixed:Icons.gps_off,
color:gpsOk?AppColors.verdeExito:AppColors.rojoError,size:16),
const SizedBox(width:4),
Text(gpsOk?'GPS Activo':'⚠️ GPS Desactivado',
style:TextStyle(color:gpsOk?AppColors.verdeExito:AppColors.rojoError,
fontWeight:FontWeight.bold,fontSize:12)),
const Spacer(),
Text('Posición ${posIdx+1}/8',style:const TextStyle(color:AppColors.grisTexto,fontSize:12)),
]),
const SizedBox(height:8),
LinearProgressIndicator(value:(posIdx+1)/8,
backgroundColor:Colors.grey.shade300,
valueColor:const AlwaysStoppedAnimation<Color>(AppColors.moradoConductor)),
const SizedBox(height:6),
Text(widget.sim.getEtaText(widget.todayRouteId??''),
style:const TextStyle(fontSize:13,fontWeight:FontWeight.w500)),
] else
const Text('⚠️ Sin ruta asignada hoy.',style:TextStyle(color:AppColors.rojoError)),
]))),
const SizedBox(height:10),
// Instrucciones
Card(child:Padding(padding:const EdgeInsets.all(12),child:Column(
crossAxisAlignment:CrossAxisAlignment.start, children:[
const Text('📋 Instrucciones de Ruta',
style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor)),
const Divider(),
const Text('• Sigue la ruta asignada sin desviaciones\n'
'• Mantén el GPS activo en todo momento\n'
'• Reporta incidentes desde "Incidente"\n'
'• Si hay problema, el admin decidirá si se cancela o retrasa',
style:TextStyle(fontSize:12,color:AppColors.grisTexto)),
]))),
const SizedBox(height:10),
// Reportes ciudadanos de SU ruta
if (_ciudadanoReportes.isNotEmpty) ...[
Card(color:Colors.orange.shade50,
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(10),
side:BorderSide(color:Colors.orange.shade200)),
child:Padding(padding:const EdgeInsets.all(12),child:Column(
crossAxisAlignment:CrossAxisAlignment.start, children:[
const Row(children:[
Icon(Icons.people,color:AppColors.naranjaAlerta,size:16),
SizedBox(width:6),
Text('Reportes de tu ruta hoy',
style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.naranjaAlerta,fontSize:13)),
]),
const Divider(),
..._ciudadanoReportes.map((r)=>Padding(
padding:const EdgeInsets.symmetric(vertical:3),
child:Row(children:[
const Icon(Icons.person_outline,size:12,color:AppColors.grisTexto),
const SizedBox(width:4),
Expanded(child:Text(r.descripcion,style:const TextStyle(fontSize:11),
maxLines:1,overflow:TextOverflow.ellipsis)),
]))),
]))),
const SizedBox(height:10),
],
// Horario LMV / MJS
Card(child:Padding(padding:const EdgeInsets.all(12),child:Column(
crossAxisAlignment:CrossAxisAlignment.start, children:[
const Text('Mi Horario',
style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor)),
const Divider(),
if (widget.assignments.isEmpty)
const Text('Sin asignaciones. Contacta al administrador.',
style:TextStyle(color:AppColors.grisTexto,fontSize:12))
else ...[
_scheduleGroup(widget.assignments,'LUNES','MIERCOLES','VIERNES',
'Lunes, Miércoles y Viernes'),
const SizedBox(height:8),
_scheduleGroup(widget.assignments,'MARTES','JUEVES','SABADO',
'Martes, Jueves y Sábado'),
],
]))),
const SizedBox(height:80),
]))),
]);
}
Widget _scheduleGroup(List<AssignmentModel> all, String d1, String d2, String d3, String label) {
AssignmentModel? found;
for (final dia in [d1,d2,d3]) {
try { found = all.firstWhere((a)=>a.diaSemana==dia); break; } catch(_){}
}
return Container(padding:const EdgeInsets.all(10),
decoration:BoxDecoration(color:AppColors.moradoConductor.withOpacity(0.06),
borderRadius:BorderRadius.circular(8),
border:Border.all(color:AppColors.moradoConductor.withOpacity(0.2))),
child:Row(children:[
const Icon(Icons.calendar_today,size:14,color:AppColors.moradoConductor),
const SizedBox(width:6),
Expanded(child:Text(label,style:const TextStyle(fontWeight:FontWeight.w600,fontSize:12))),
if (found!=null)
Container(padding:const EdgeInsets.symmetric(horizontal:8,vertical:3),
decoration:BoxDecoration(color:AppColors.moradoConductor,borderRadius:BorderRadius.circular(8)),
child:Text('${found.routeId}${found.turno}',
style:const TextStyle(fontSize:11,color:Colors.white,fontWeight:FontWeight.bold)))
else
const Text('Sin asignar',style:TextStyle(fontSize:11,color:AppColors.grisTexto)),
]));
}
String _todayLabel() {
const d=['','Lunes','Martes','Miércoles','Jueves','Viernes','Sábado','Domingo'];
return d[DateTime.now().weekday];
}
}
// ── Tab mapa ──────────────────────────────────────────────────────────────
class _DriverMapTab extends StatelessWidget {
final route; final sim;
const _DriverMapTab({required this.route, required this.sim});
@override
Widget build(BuildContext context) => Scaffold(
appBar:AppBar(automaticallyImplyLeading:false,
backgroundColor:AppColors.moradoConductor,foregroundColor:Colors.white,
title:Text(route.name,style:const TextStyle(fontSize:13)),
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
child:Container(height:4,color:AppColors.dorado))),
body:DriverRouteMap(route:route,simulator:sim));
}
// ── Tab reporte incidente — usa routeId actual ────────────────────────────
class _DriverReportesTab extends StatefulWidget {
final int? conductorId;
final String? todayRouteId; // Ruta actual del conductor
const _DriverReportesTab({required this.conductorId, required this.todayRouteId});
@override State<_DriverReportesTab> createState() => _DriverReportesTabState();
}
class _DriverReportesTabState extends State<_DriverReportesTab> {
String _tipo = 'INCIDENTE_LLANTA';
final _desc = TextEditingController();
bool _loading = false, _sent = false;
List<AlertaModel> _misIncidentes = [];
static const _tipos = {
'INCIDENTE_LLANTA': '🔧 Llanta ponchada',
'INCIDENTE_MECANICA': '🔥 Falla mecánica',
'INCIDENTE_ACCIDENTE': '🚑 Accidente',
'INCIDENTE_CAMINO': '🚧 Camino bloqueado',
'INCIDENTE_COMBUSTIBLE':'⛽ Sin combustible',
'INCIDENTE_OTRO': '📝 Otro',
};
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final all = await DbHelper.getAlertas();
// Solo incidentes de la ruta actual del conductor
final mine = all.where((a) =>
a.tipo.startsWith('INCIDENTE_') &&
a.routeId == (widget.todayRouteId ?? '')).toList();
if (mounted) setState(() => _misIncidentes = mine);
}
Future<void> _enviar() async {
if (widget.todayRouteId == null) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content:Text('No tienes ruta asignada hoy'),
backgroundColor:AppColors.rojoError)); return;
}
if (_desc.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content:Text('Describe el incidente'),backgroundColor:AppColors.rojoError)); return;
}
setState(()=>_loading=true);
// Guardar el incidente asociado a la RUTA ACTUAL
await DbHelper.insertAlerta(AlertaModel(
tipo: _tipo,
routeId: widget.todayRouteId!, // ← ID de la ruta actual, no del conductor
mensaje: '${_tipos[_tipo]}: ${_desc.text.trim()}',
fecha: DateTime.now().toIso8601String(),
));
await _load();
if (!mounted) return;
setState(() { _loading=false; _sent=true; });
_desc.clear();
await Future.delayed(const Duration(seconds:2));
if (mounted) setState(()=>_sent=false);
}
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor:AppColors.grisFondo,
appBar:AppBar(automaticallyImplyLeading:false,
backgroundColor:AppColors.moradoConductor,foregroundColor:Colors.white,
title:const Text('Reportar Incidente'),
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
child:Container(height:4,color:AppColors.dorado))),
body: _sent
? const Center(child:Column(mainAxisAlignment:MainAxisAlignment.center,children:[
Icon(Icons.check_circle,color:AppColors.verdeExito,size:64),
SizedBox(height:12),
Text('¡Incidente reportado!',style:TextStyle(fontSize:18,fontWeight:FontWeight.bold,color:AppColors.verdeExito)),
Text('El administrador será notificado.',style:TextStyle(color:AppColors.grisTexto)),
]))
: SingleChildScrollView(padding:const EdgeInsets.all(16),child:Column(children:[
// Info ruta actual
if (widget.todayRouteId != null)
Container(margin:const EdgeInsets.only(bottom:12),
padding:const EdgeInsets.all(10),
decoration:BoxDecoration(color:AppColors.moradoConductor.withOpacity(0.08),
borderRadius:BorderRadius.circular(8),
border:Border.all(color:AppColors.moradoConductor.withOpacity(0.3))),
child:Row(children:[
const Icon(Icons.route,color:AppColors.moradoConductor,size:16),
const SizedBox(width:6),
Text('Incidente en: ${widget.todayRouteId}',
style:const TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor,fontSize:13)),
]))
else
Container(margin:const EdgeInsets.only(bottom:12),
padding:const EdgeInsets.all(10),
decoration:BoxDecoration(color:Colors.orange.shade50,borderRadius:BorderRadius.circular(8)),
child:const Text('⚠️ No tienes ruta asignada hoy',
style:TextStyle(color:AppColors.naranjaAlerta,fontWeight:FontWeight.bold))),
Card(shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(12)),
child:Padding(padding:const EdgeInsets.all(16),child:Column(
crossAxisAlignment:CrossAxisAlignment.start, children:[
const Text('Tipo de incidente',
style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor,fontSize:15)),
const SizedBox(height:8),
..._tipos.entries.map((e)=>RadioListTile<String>(dense:true,
value:e.key,groupValue:_tipo,
title:Text(e.value,style:const TextStyle(fontSize:13)),
activeColor:AppColors.moradoConductor,
onChanged:(v)=>setState(()=>_tipo=v!))),
const SizedBox(height:10),
const Text('Descripción',style:TextStyle(fontWeight:FontWeight.w600,fontSize:13)),
const SizedBox(height:6),
TextField(controller:_desc,maxLines:3,
decoration:const InputDecoration(hintText:'Describe qué pasó...',
border:OutlineInputBorder(),filled:true,fillColor:Colors.white)),
const SizedBox(height:12),
Container(padding:const EdgeInsets.all(10),
decoration:BoxDecoration(color:Colors.orange.shade50,
borderRadius:BorderRadius.circular(8),
border:Border.all(color:Colors.orange.shade200)),
child:const Text(
'⚠️ El administrador verá este incidente en tu ruta actual '
'y decidirá si continúa, se retrasa o se cancela.',
style:TextStyle(fontSize:11,color:Colors.black87))),
const SizedBox(height:14),
SizedBox(width:double.infinity,height:48,
child:ElevatedButton.icon(
onPressed:(_loading||widget.todayRouteId==null)?null:_enviar,
style:ElevatedButton.styleFrom(backgroundColor:AppColors.moradoConductor,
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.send),
label:const Text('ENVIAR INCIDENTE',style:TextStyle(fontWeight:FontWeight.bold)))),
]))),
if (_misIncidentes.isNotEmpty)...[
const SizedBox(height:16),
const Align(alignment:Alignment.centerLeft,
child:Text('Mis incidentes de hoy',
style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor,fontSize:14))),
const SizedBox(height:8),
..._misIncidentes.take(5).map((a)=>Card(margin:const EdgeInsets.only(bottom:6),
child:ListTile(dense:true,
leading:CircleAvatar(backgroundColor:AppColors.moradoConductor,radius:16,
child:const Icon(Icons.warning,color:Colors.white,size:14)),
title:Text(_tipos[a.tipo]??a.tipo,
style:const TextStyle(fontSize:12,fontWeight:FontWeight.w600)),
subtitle:Text(a.mensaje,maxLines:1,overflow:TextOverflow.ellipsis,
style:const TextStyle(fontSize:11)),
trailing:Icon(a.resuelta?Icons.check_circle:Icons.pending,
color:a.resuelta?AppColors.verdeExito:AppColors.naranjaAlerta,size:18)))),
],
])),
);
@override void dispose(){ _desc.dispose(); super.dispose(); }
}
// ── Notif banner conductor ────────────────────────────────────────────────
class _NotifBanner extends StatelessWidget {
final AppNotification notif; final VoidCallback onDismiss;
const _NotifBanner({required this.notif, required this.onDismiss});
@override
Widget build(BuildContext context) {
final color = notif.event==NotifEvent.gpsLost?Colors.red.shade800
:notif.event==NotifEvent.truckStopped?AppColors.naranjaAlerta
:notif.event==NotifEvent.routeCancelled?AppColors.rojoError
:AppColors.moradoConductor;
return Material(color:Colors.transparent,
child:Container(margin:const EdgeInsets.all(10),
decoration:BoxDecoration(color:color,borderRadius:BorderRadius.circular(12),
boxShadow:const[BoxShadow(color:Colors.black26,blurRadius:6)]),
child:Padding(padding:const EdgeInsets.all(12),child:Row(children:[
const Icon(Icons.notification_important,color:Colors.white,size:22),
const SizedBox(width:8),
Expanded(child:Column(crossAxisAlignment:CrossAxisAlignment.start,
mainAxisSize:MainAxisSize.min,children:[
Text(notif.title,style:const TextStyle(color:Colors.white,fontWeight:FontWeight.bold,fontSize:13)),
Text(notif.body,style:const TextStyle(color:Colors.white70,fontSize:11),
maxLines:2,overflow:TextOverflow.ellipsis),
])),
IconButton(icon:const Icon(Icons.close,color:Colors.white,size:18),onPressed:onDismiss),
]))));
}
}

View File

@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../core/app_colors.dart';
import '../services/auth_service.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _emailCtrl = TextEditingController();
final _passCtrl = TextEditingController();
bool _loading = false, _obscure = true;
Future<void> _login() async {
if (_emailCtrl.text.isEmpty || _passCtrl.text.isEmpty) {
_snack('Llena todos los campos', isError: true); return;
}
setState(() => _loading = true);
final err = await context.read<AuthService>().login(_emailCtrl.text, _passCtrl.text);
if (!mounted) return;
setState(() => _loading = false);
if (err != null) { _snack(err, isError: true); return; }
final rol = context.read<AuthService>().rol;
switch (rol) {
case 'ADMINISTRADOR': Navigator.pushReplacementNamed(context, '/admin'); break;
case 'CONDUCTOR': Navigator.pushReplacementNamed(context, '/driver'); break;
default: Navigator.pushReplacementNamed(context, '/home'); break;
}
}
void _snack(String msg, {bool isError = false}) => ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content:Text(msg),
backgroundColor: isError ? AppColors.rojoError : AppColors.verdeExito));
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.grisFondo,
body: SingleChildScrollView(child: Column(children: [
Container(width:double.infinity, color:AppColors.guindaPrimary,
padding:const EdgeInsets.only(top:60,bottom:28),
child:Column(children:[
Container(width:84,height:84,
decoration:BoxDecoration(color:Colors.white12,shape:BoxShape.circle,
border:Border.all(color:AppColors.dorado,width:2.5)),
child:const Icon(Icons.delete_sweep_rounded,size:44,color:AppColors.dorado)),
const SizedBox(height:14),
const Text('H. AYUNTAMIENTO DE CELAYA',
style:TextStyle(color:Colors.white,fontSize:13,fontWeight:FontWeight.bold,letterSpacing:1.2)),
const SizedBox(height:4),
const Text('Sistema de Recolección de Residuos',
style:TextStyle(color:AppColors.dorado,fontSize:13)),
])),
Container(height:4,color:AppColors.dorado),
Padding(padding:const EdgeInsets.all(24), child:Card(elevation:4,
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(12)),
child:Padding(padding:const EdgeInsets.all(24), child:Column(
crossAxisAlignment:CrossAxisAlignment.start, children:[
const Text('Iniciar Sesión',style:TextStyle(fontSize:20,
fontWeight:FontWeight.bold,color:AppColors.guindaPrimary)),
const SizedBox(height:16),
// Accesos rápidos demo
Container(padding:const EdgeInsets.all(10),
decoration:BoxDecoration(color:Colors.blue.shade50,borderRadius:BorderRadius.circular(8)),
child:const Column(crossAxisAlignment:CrossAxisAlignment.start, children:[
Text('Demo rápido:',style:TextStyle(fontWeight:FontWeight.bold,fontSize:12,color:AppColors.azulInfo)),
Text('Admin: admin@celaya.gob.mx / admin123',style:TextStyle(fontSize:11)),
Text('Conductor: conductor@celaya.gob.mx / conductor123',style:TextStyle(fontSize:11)),
])),
const SizedBox(height:16),
TextField(controller:_emailCtrl,keyboardType:TextInputType.emailAddress,
decoration:const InputDecoration(labelText:'Correo electrónico',
prefixIcon:Icon(Icons.email_outlined,color:AppColors.guindaPrimary),
border:OutlineInputBorder())),
const SizedBox(height:12),
TextField(controller:_passCtrl,obscureText:_obscure,
decoration:InputDecoration(labelText:'Contraseña',
prefixIcon:const Icon(Icons.lock_outline,color:AppColors.guindaPrimary),
border:const OutlineInputBorder(),
suffixIcon:IconButton(icon:Icon(_obscure?Icons.visibility_off:Icons.visibility),
onPressed:()=>setState(()=>_obscure=!_obscure)))),
const SizedBox(height:20),
SizedBox(width:double.infinity,height:50,
child:ElevatedButton(onPressed:_loading?null:_login,
style:ElevatedButton.styleFrom(backgroundColor:AppColors.guindaPrimary,
foregroundColor:Colors.white,shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(8))),
child:_loading?const CircularProgressIndicator(color:Colors.white,strokeWidth:2)
:const Text('ENTRAR',style:TextStyle(fontWeight:FontWeight.bold,letterSpacing:1)))),
const SizedBox(height:12),
const Divider(),
const SizedBox(height:12),
SizedBox(width:double.infinity,height:50,
child:OutlinedButton(onPressed:()=>Navigator.pushNamed(context,'/register'),
style:OutlinedButton.styleFrom(foregroundColor:AppColors.guindaPrimary,
side:const BorderSide(color:AppColors.guindaPrimary),
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(8))),
child:const Text('CREAR CUENTA CIUDADANO',style:TextStyle(fontWeight:FontWeight.bold)))),
])))),
const Padding(padding:EdgeInsets.only(bottom:20),
child:Text('Gobierno Municipal de Celaya • Guanajuato',
style:TextStyle(color:AppColors.grisTexto,fontSize:11))),
])),
);
@override void dispose() { _emailCtrl.dispose(); _passCtrl.dispose(); super.dispose(); }
}

View File

@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../core/app_colors.dart';
class OnboardingScreen extends StatefulWidget {
const OnboardingScreen({super.key});
@override State<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends State<OnboardingScreen> {
final _ctrl = PageController();
int _page = 0;
static const _pages = [
_OnboardPage(icon:Icons.delete_sweep_rounded, color:AppColors.guindaPrimary,
title:'Bienvenido a Celaya Limpia',
subtitle:'El sistema de recoleccion inteligente del H. Ayuntamiento de Celaya.',
desc:'Recibe alertas en tiempo real, conoce tu horario y ayuda a mantener tu ciudad limpia.'),
_OnboardPage(icon:Icons.notifications_active, color:AppColors.azulInfo,
title:'Alertas inteligentes',
subtitle:'Te avisamos exactamente cuando debes sacar tu basura.',
desc:'Recibiras 3 alertas:\n\n30 min antes: el camion esta en camino.\n15 min antes: saca tus bolsas a la acera.\nAl pasar: confirma que fue recogida.\n\nNunca mas pierdas al camion recolector.'),
_OnboardPage(icon:Icons.recycling, color:AppColors.verdeExito,
title:'Guia de separacion',
subtitle:'Aprende a separar correctamente tus residuos.',
desc:'Bolsa verde: organicos (comida, jardin)\nBolsa azul: reciclables (PET, latas)\nBolsa negra: no reciclables\n\nUsa la camara IA para identificar si un residuo es organico o inorganico al instante.'),
_OnboardPage(icon:Icons.star, color:Colors.amber,
title:'Tu opinion importa',
subtitle:'Califica el servicio y ayuda a mejorarlo.',
desc:'Despues de cada recoleccion podras:\n\nCalificar de 1 a 5 estrellas\nDejar comentarios al Ayuntamiento\nReportar incidencias con foto\n\nTus reportes son atendidos por el equipo municipal.'),
];
Future<void> _finish() async {
final p = await SharedPreferences.getInstance();
await p.setBool('onboarding_done', true);
if (!mounted) return;
Navigator.pushReplacementNamed(context, '/login');
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(children: [
PageView.builder(controller:_ctrl, onPageChanged:(p)=>setState(()=>_page=p),
itemCount:_pages.length,
itemBuilder:(_,i)=>_PageContent(page:_pages[i])),
Positioned(bottom:120, left:0, right:0,
child:Row(mainAxisAlignment:MainAxisAlignment.center,
children:List.generate(_pages.length,(i)=>AnimatedContainer(
duration:const Duration(milliseconds:250),
margin:const EdgeInsets.symmetric(horizontal:4),
width:_page==i?24:8, height:8,
decoration:BoxDecoration(
color:_page==i?_pages[i].color:Colors.grey.shade300,
borderRadius:BorderRadius.circular(4)))))),
Positioned(bottom:40, left:24, right:24,
child:Row(children:[
if (_page>0)
TextButton(onPressed:()=>_ctrl.previousPage(
duration:const Duration(milliseconds:300),curve:Curves.easeOut),
child:const Text('Atras',style:TextStyle(color:AppColors.grisTexto)))
else
TextButton(onPressed:_finish,
child:const Text('Omitir',style:TextStyle(color:AppColors.grisTexto))),
const Spacer(),
ElevatedButton(
onPressed:_page<_pages.length-1
?()=>_ctrl.nextPage(duration:const Duration(milliseconds:300),curve:Curves.easeOut)
:_finish,
style:ElevatedButton.styleFrom(
backgroundColor:_pages[_page].color, foregroundColor:Colors.white,
padding:const EdgeInsets.symmetric(horizontal:28,vertical:12),
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(25))),
child:Text(_page<_pages.length-1?'Siguiente':'Comenzar',
style:const TextStyle(fontWeight:FontWeight.bold))),
])),
]));
}
@override void dispose(){ _ctrl.dispose(); super.dispose(); }
}
class _OnboardPage {
final IconData icon; final Color color;
final String title, subtitle, desc;
const _OnboardPage({required this.icon,required this.color,
required this.title,required this.subtitle,required this.desc});
}
class _PageContent extends StatelessWidget {
final _OnboardPage page;
const _PageContent({super.key, required this.page});
@override
Widget build(BuildContext context) => Container(
decoration:BoxDecoration(gradient:LinearGradient(
begin:Alignment.topCenter, end:Alignment.bottomCenter,
colors:[page.color, page.color.withOpacity(0.85), Colors.white],
stops:const[0,0.4,0.7])),
child:SafeArea(child:Column(children:[
const SizedBox(height:48),
Container(width:120,height:120,
decoration:BoxDecoration(color:Colors.white.withOpacity(0.2),shape:BoxShape.circle,
border:Border.all(color:Colors.white.withOpacity(0.5),width:2)),
child:Icon(page.icon,size:60,color:Colors.white)),
const SizedBox(height:28),
Padding(padding:const EdgeInsets.symmetric(horizontal:32),child:Column(children:[
Text(page.title,textAlign:TextAlign.center,
style:const TextStyle(fontSize:24,fontWeight:FontWeight.bold,color:Colors.white)),
const SizedBox(height:10),
Text(page.subtitle,textAlign:TextAlign.center,
style:TextStyle(fontSize:14,color:Colors.white.withOpacity(0.9))),
])),
const SizedBox(height:32),
Expanded(child:Container(margin:const EdgeInsets.symmetric(horizontal:20),
padding:const EdgeInsets.all(22),
decoration:BoxDecoration(color:Colors.white,borderRadius:BorderRadius.circular(20),
boxShadow:[BoxShadow(color:page.color.withOpacity(0.2),blurRadius:20,offset:const Offset(0,8))]),
child:Text(page.desc,textAlign:TextAlign.left,
style:const TextStyle(fontSize:13,height:1.7,color:AppColors.negroTexto)))),
const SizedBox(height:110),
])));
}

View File

@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../core/app_colors.dart';
import '../data/colonies_data.dart';
import '../data/celaya_colonias.dart';
import '../models/route_model.dart';
import '../services/auth_service.dart';
class RegisterScreen extends StatefulWidget {
const RegisterScreen({super.key});
@override State<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends State<RegisterScreen> {
final _nombre = TextEditingController();
final _email = TextEditingController();
final _pass = TextEditingController();
final _calle = TextEditingController();
ColonyModel? _colony;
bool _loading = false, _obscure = true;
Future<void> _register() async {
if ([_nombre,_email,_pass,_calle].any((c)=>c.text.trim().isEmpty) || _colony==null) {
_snack('Completa todos los campos', isError:true); return; }
if (_pass.text.length < 6) { _snack('Contraseña mínimo 6 caracteres', isError:true); return; }
setState(()=>_loading=true);
final err = await context.read<AuthService>().register(
nombre:_nombre.text, email:_email.text, password:_pass.text,
calle:_calle.text, colonia:_colony!.colonia,
routeId:_colony!.routeId, horarioEstimado:_colony!.horarioEstimado);
if (!mounted) return;
setState(()=>_loading=false);
if (err!=null) { _snack(err,isError:true); return; }
Navigator.pushReplacementNamed(context, '/home');
}
void _snack(String msg,{bool isError=false}) => ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content:Text(msg),
backgroundColor:isError?AppColors.rojoError:AppColors.verdeExito));
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(backgroundColor:AppColors.guindaPrimary,foregroundColor:Colors.white,
title:const Text('Registro Ciudadano'),
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
child:Container(height:4,color:AppColors.dorado))),
body: SingleChildScrollView(padding:const EdgeInsets.all(20), child:Column(children:[
_field(_nombre,'Nombre completo',Icons.badge_outlined),
const SizedBox(height:12),
_field(_email,'Correo electrónico',Icons.email_outlined,type:TextInputType.emailAddress),
const SizedBox(height:12),
TextField(controller:_pass,obscureText:_obscure,
decoration:InputDecoration(labelText:'Contraseña (mín. 6)',
prefixIcon:const Icon(Icons.lock_outline,color:AppColors.guindaPrimary),
border:const OutlineInputBorder(),filled:true,fillColor:Colors.white,
suffixIcon:IconButton(icon:Icon(_obscure?Icons.visibility_off:Icons.visibility),
onPressed:()=>setState(()=>_obscure=!_obscure)))),
const SizedBox(height:20),
const Align(alignment:Alignment.centerLeft,
child:Text('Domicilio',style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.guindaPrimary,fontSize:16))),
const SizedBox(height:10),
_field(_calle,'Calle y número',Icons.signpost_outlined),
const SizedBox(height:12),
DropdownButtonFormField<String>(
decoration:const InputDecoration(labelText:'Colonia',
prefixIcon:Icon(Icons.location_city,color:AppColors.guindaPrimary),
border:OutlineInputBorder(),filled:true,fillColor:Colors.white),
hint:const Text('Selecciona tu colonia'),
value:_colony?.colonia, isExpanded:true,
items:celayaColonias.map((n)=>DropdownMenuItem(value:n,child:Text(n,style:const TextStyle(fontSize:13)))).toList(),
onChanged:(v){ if(v!=null) setState((){
_colony = getColonyByName(v) ?? ColonyModel(
colonia:v, routeId:'RUTA-01', horarioEstimado:'Matutino (06:00-08:00)');
}); }),
if (_colony!=null) ...[
const SizedBox(height:10),
Container(padding:const EdgeInsets.all(12),
decoration:BoxDecoration(color:AppColors.guindaPrimary.withOpacity(0.08),
borderRadius:BorderRadius.circular(8),
border:Border.all(color:AppColors.guindaPrimary.withOpacity(0.3))),
child:Column(crossAxisAlignment:CrossAxisAlignment.start,children:[
Text('Ruta: ${_colony!.routeId}',style:const TextStyle(color:AppColors.guindaPrimary,fontWeight:FontWeight.bold)),
Text('Horario: ${_colony!.horarioEstimado}',style:const TextStyle(color:AppColors.grisTexto,fontSize:12)),
]))],
const SizedBox(height:24),
SizedBox(width:double.infinity,height:50,
child:ElevatedButton(onPressed:_loading?null:_register,
style:ElevatedButton.styleFrom(backgroundColor:AppColors.guindaPrimary,
foregroundColor:Colors.white,shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(8))),
child:_loading?const CircularProgressIndicator(color:Colors.white,strokeWidth:2)
:const Text('REGISTRARME',style:TextStyle(fontWeight:FontWeight.bold,letterSpacing:1)))),
const SizedBox(height:20),
])),
);
Widget _field(TextEditingController c, String label, IconData icon,
{TextInputType type=TextInputType.text}) =>
TextField(controller:c,keyboardType:type,
decoration:InputDecoration(labelText:label,
prefixIcon:Icon(icon,color:AppColors.guindaPrimary),
border:const OutlineInputBorder(),filled:true,fillColor:Colors.white));
@override void dispose(){ _nombre.dispose();_email.dispose();_pass.dispose();_calle.dispose();super.dispose(); }
}

View File

@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../core/app_colors.dart';
import '../services/theme_service.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context) {
final theme = context.watch<ThemeService>();
return Scaffold(
appBar: AppBar(title: const Text('Configuracion'),
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado))),
body: ListView(padding: const EdgeInsets.all(16), children: [
Card(child: Column(children: [
const Padding(padding: EdgeInsets.all(14),
child: Row(children: [
Icon(Icons.palette_outlined, color: AppColors.guindaPrimary),
SizedBox(width: 8),
Text('Apariencia', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15)),
])),
const Divider(height: 1),
SwitchListTile(
value: theme.isDark,
onChanged: (_) => theme.toggle(),
secondary: Icon(theme.isDark ? Icons.dark_mode : Icons.light_mode,
color: theme.isDark ? Colors.amber : AppColors.guindaPrimary),
title: Text(theme.isDark ? 'Modo oscuro activo' : 'Modo claro activo'),
subtitle: const Text('Util para rutas nocturnas'),
activeColor: AppColors.guindaPrimary,
),
])),
const SizedBox(height: 12),
Card(child: Column(children: [
const Padding(padding: EdgeInsets.all(14),
child: Row(children: [
Icon(Icons.info_outline, color: AppColors.guindaPrimary),
SizedBox(width: 8),
Text('Acerca de', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15)),
])),
const Divider(height: 1),
const ListTile(leading: Icon(Icons.location_city), title: Text('H. Ayuntamiento de Celaya'),
subtitle: Text('Guanajuato, Mexico')),
const ListTile(leading: Icon(Icons.code), title: Text('Version 2.0.0'),
subtitle: Text('Sistema Integral de Recoleccion de Residuos')),
])),
]),
);
}
}

View File

@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../core/app_colors.dart';
import '../services/auth_service.dart';
import '../services/route_simulator_service.dart';
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@override State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> {
@override
void initState() { super.initState(); _go(); }
Future<void> _go() async {
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return;
final auth = context.read<AuthService>();
context.read<RouteSimulatorService>().startAllRoutes();
if (auth.isLoggedIn) {
_navigate(auth.rol);
} else {
Navigator.pushReplacementNamed(context, '/login');
}
}
void _navigate(String rol) {
switch (rol) {
case 'ADMINISTRADOR': Navigator.pushReplacementNamed(context, '/admin'); break;
case 'CONDUCTOR': Navigator.pushReplacementNamed(context, '/driver'); break;
default: Navigator.pushReplacementNamed(context, '/home'); break;
}
}
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.guindaPrimary,
body: Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Container(width:100,height:100,
decoration:BoxDecoration(color:Colors.white12,shape:BoxShape.circle,
border:Border.all(color:AppColors.dorado,width:3)),
child:const Icon(Icons.delete_sweep_rounded,size:52,color:AppColors.dorado)),
const SizedBox(height:20),
const Text('CELAYA LIMPIA',style:TextStyle(color:Colors.white,fontSize:26,
fontWeight:FontWeight.bold,letterSpacing:2)),
const SizedBox(height:4),
const Text('H. Ayuntamiento de Celaya',style:TextStyle(color:Colors.white60,fontSize:13)),
const SizedBox(height:40),
const CircularProgressIndicator(valueColor:AlwaysStoppedAnimation<Color>(AppColors.dorado)),
])),
);
}