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

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