Avance de la aplicacion

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

View File

@@ -15,7 +15,7 @@ android {
defaultConfig { defaultConfig {
applicationId = "com.example.celaya_limpia" applicationId = "com.example.celaya_limpia"
minSdk = flutter.minSdkVersion minSdk = 21
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName

View File

@@ -1,2 +1,2 @@
Orgánico Organico
Inorgánico Inorganico

View File

@@ -0,0 +1,243 @@
// 240 colonias oficiales de Celaya, Guanajuato
const List<String> celayaColonias = [
'10 de Abril',
'10 de mayo',
'15 de Mayo',
'3 Guerras',
'Alameda',
'Álamos',
'Álamos Oriente',
'Alfredo Vázquez Bonfil',
'Américas del Bajío',
'Arboledas de Camargo',
'Arboledas del Campestre',
'Arcada Alameda',
'Baalam Residencial',
'Benito Juárez',
'Bosques de la Alameda',
'Bosques del Sol',
'Brisas del Carmen',
'Bugambilias',
'Calesa',
'Camargo',
'Campestre Celaya',
'Canal de Camargo',
'Canal de Labradores',
'Capitales de Europa',
'Celaya Centro',
'Ciudadela',
'Ciudad Industrial',
'Claustros de Arboledas',
'Conjunto Habitacional Girasoles',
'Cuauhtémoc',
'Del Bosque',
'Del Parque',
'Del Valle',
'Don Gu',
'Dos Plazas',
'Ejidal',
'El Atrio',
'El Campanario',
'El Campanario Residencial',
'El Cantar',
'El Dorado',
'El Haba',
'El Junco Residencial',
'El Olivar',
'El Panamericano',
'El Paraíso de los Ángeles',
'El Vergel',
'Emeteria Valencia',
'Emiliano Zapata',
'Emiliano Zapata Sur',
'Enrique Colunga',
'Esmeralda',
'Exelaris',
'Felipe Ángeles',
'Floresta del Sur',
'FOVISSSTE',
'Galaxias del Parque',
'Geo Villas Los Sauces',
'Gobernadores',
'Granada',
'Gran Clase',
'Guadalupe',
'Guanajuato',
'Hacienda del Bosque',
'Hacienda del Sol',
'Hidalgo',
'Imperial',
'Independencia',
'Industriales',
'Jacarandas',
'Jardines de Celaya 1a Secc',
'Jardines de Celaya 2a Secc',
'Jardines de Celaya 3a Secc',
'Jardines del Centro',
'Jardines del Sur',
'José Suárez Irigoyen',
'Juan Pablo II',
'Karina',
'La Campiña',
'La Capilla',
'La Cruz',
'La Escondida',
'La Favorita',
'La Fundación',
'La Herradura',
'La Joya',
'La Misión',
'La Purísima',
'Las Alamedas',
'Las Américas',
'Las Arboledas',
'Las Arenas',
'Las Aves',
'Las Brisas',
'Las Carretas',
'Las Casas',
'Las Delicias',
'Las Flores',
'Las Fuentes',
'Las Insurgentes',
'La Soledad',
'Latinoamericana',
'La Trinidad',
'Lázaro Cárdenas',
'Lindavista',
'López Portillo',
'Los Ángeles',
'Los Frailes',
'Los Impresionistas',
'Los Lagos',
'Los Laureles',
'Los Naranjos',
'Los Olivos Residencial',
'Los Pinos',
'Los Pirules',
'Los Pirules Don Gu',
'Los Portones',
'Los Santos',
'Los Sauces',
'Los Tules',
'Los Veintes',
'Magno Residencial',
'Mediterráneo',
'México',
'Miguel Alemán',
'Misión de La Esperanza',
'Misión Santa Fe',
'Moctezuma',
'Monte Blanco',
'Nat Tha Hi',
'Nueva Santa María',
'Nueva Terraza',
'Nuevo Celaya',
'Nuevo Tecnológico',
'Obrero Mundial',
'Oro',
'Palas Atenea',
'Palma Real',
'Parque Central',
'Parque Verde',
'Pedregal del Junco',
'Porta Maggiore',
'Portones de la Hacienda',
'Praderas del Bosque',
'Praderas de Santa Julia',
'Praderas de Santa Lucía',
'Prados el Naranjal',
'Privada Ciruelo',
'Privada del Pedregal',
'Privada del Real',
'Privada el Sauz',
'Progreso Solidaridad',
'Providencia',
'Puerta Grande',
'Puertas del Sol',
'Puertas de Santa María',
'Puesta del Sol',
'Punta Norte',
'Quinta Santa María',
'Raquet Club Cross',
'Real de Celaya',
'Real de San Antonio',
'Recursos Hidráulicos',
'Reforma',
'Reforma',
'Residencial Las Margaritas',
'Residencial Las Praderas',
'Residencial Paraíso',
'Residencial San Pablo',
'Residencial Santiago',
'Residencial Tecnológico',
'Residencial Xochipilli',
'Resurrección',
'Revolución',
'Rinconada del Bosque',
'Rinconada Laureles',
'Rinconada Los Álamos',
'Rinconada San Jorge',
'Rincón de Cantarranas',
'Riveras del Campestre',
'Rosalinda',
'San Andrés',
'San Antonio',
'San Antonio',
'San Francisco',
'San Gabriel',
'San José de Torres',
'San Juan',
'San Juan de Dios',
'San Juanico',
'San Juanico 1a Secc',
'San Juanico 2a Secc',
'San Martín de Camargo',
'San Miguel',
'San Rafael',
'San Román',
'Santa Anita',
'Santa Bárbara',
'Santa Cecilia',
'Santa Fe de los Naranjos',
'Santa Isabel',
'Santa María',
'Santa María',
'Santa Rita',
'Santa Teresa',
'Santiaguito',
'Suiza',
'Tahi',
'Tierras Negras',
'Tierra y Libertad',
'Tres Lunas',
'Valle de La Primavera',
'Valle de los Naranjos III Sección',
'Valle de los Naranjos II Sección',
'Valle del Real',
'Valle Hermoso',
'Valle Naranjos',
'Ventanales de Santa María',
'Villa Arbolada',
'Villa de Celaya',
'Villa de los Álamos',
'Villa de los Reyes',
'Villa Jardín',
'Villas de Benavente',
'Villas de Benavente II',
'Villas de La Esperanza',
'Villas de La Hacienda',
'Villas del Bajío',
'Villas del Palmar',
'Villas del Paraíso',
'Villas del Rocío',
'Villas del Romeral',
'Villas del Tenis',
'Villas Reales',
'Villas Vicenza',
'Viñas de La Herradura',
'Virgen del Refugio',
'Zempoala',
'Zona de Oro',
'Zona de Oro del Bajío',
];

View File

@@ -1,7 +1,6 @@
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import '../models/models.dart'; import '../models/models.dart';
import '../models/route_model.dart';
class DbHelper { class DbHelper {
static Database? _db; static Database? _db;
@@ -12,47 +11,75 @@ class DbHelper {
} }
static Future<Database> _initDb() async { static Future<Database> _initDb() async {
final path = join(await getDatabasesPath(), 'celaya_v2.db'); final path = join(await getDatabasesPath(), 'celaya_v3.db');
return openDatabase(path, version: 1, onCreate: _onCreate); return openDatabase(path, version: 1, onCreate: _onCreate);
} }
static Future<void> _onCreate(Database db, int v) async { static Future<void> _onCreate(Database db, int v) async {
// Usuarios
await db.execute('''CREATE TABLE users( await db.execute('''CREATE TABLE users(
id INTEGER PRIMARY KEY AUTOINCREMENT, nombre TEXT NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL, password TEXT NOT NULL, rol TEXT NOT NULL)'''); nombre TEXT NOT NULL, email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL, rol TEXT NOT NULL)''');
// Domicilios (User → Domicilio → Colonia/Zona → Ruta)
await db.execute('''CREATE TABLE domicilios( await db.execute('''CREATE TABLE domicilios(
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT,
calle TEXT NOT NULL, colonia TEXT NOT NULL, route_id TEXT NOT NULL, user_id INTEGER NOT NULL, alias TEXT DEFAULT 'Casa',
horario_estimado TEXT NOT NULL, is_primary INTEGER DEFAULT 1)'''); calle TEXT NOT NULL, colonia TEXT NOT NULL,
route_id TEXT NOT NULL, horario_estimado TEXT NOT NULL,
is_primary INTEGER DEFAULT 0)''');
await db.execute('''CREATE TABLE asignaciones( // Definiciones de rutas creadas por admin
id INTEGER PRIMARY KEY AUTOINCREMENT, conductor_id INTEGER NOT NULL, await db.execute('''CREATE TABLE route_definitions(
route_id TEXT NOT NULL, dia_semana TEXT NOT NULL, turno TEXT NOT NULL)'''); id INTEGER PRIMARY KEY AUTOINCREMENT,
route_id TEXT UNIQUE NOT NULL, nombre TEXT NOT NULL,
dias TEXT NOT NULL, hora_inicio TEXT NOT NULL,
hora_fin TEXT NOT NULL, turno TEXT NOT NULL,
colonias TEXT NOT NULL, activa INTEGER DEFAULT 1)''');
// Estado de rutas
await db.execute('''CREATE TABLE route_status( await db.execute('''CREATE TABLE route_status(
route_id TEXT PRIMARY KEY, status TEXT NOT NULL, route_id TEXT PRIMARY KEY, status TEXT NOT NULL,
mensaje TEXT, updated_at TEXT)'''); mensaje TEXT, updated_at TEXT)''');
// Asignaciones conductor
await db.execute('''CREATE TABLE asignaciones(
id INTEGER PRIMARY KEY AUTOINCREMENT,
conductor_id INTEGER NOT NULL, route_id TEXT NOT NULL,
dia_semana TEXT NOT NULL, turno TEXT NOT NULL)''');
// Alertas del sistema
await db.execute('''CREATE TABLE alertas( await db.execute('''CREATE TABLE alertas(
id INTEGER PRIMARY KEY AUTOINCREMENT, tipo TEXT NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT,
route_id TEXT NOT NULL, mensaje TEXT NOT NULL, tipo TEXT NOT NULL, route_id TEXT NOT NULL,
fecha TEXT NOT NULL, resuelta INTEGER DEFAULT 0)'''); mensaje TEXT NOT NULL, fecha TEXT NOT NULL,
resuelta INTEGER DEFAULT 0)''');
// Reportes ciudadanos
await db.execute('''CREATE TABLE reportes( await db.execute('''CREATE TABLE reportes(
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT,
tipo TEXT NOT NULL, descripcion TEXT NOT NULL, colonia TEXT NOT NULL, user_id INTEGER NOT NULL, tipo TEXT NOT NULL,
route_id TEXT, fecha TEXT NOT NULL, estado TEXT DEFAULT 'PENDIENTE', descripcion TEXT NOT NULL, colonia TEXT NOT NULL,
calificacion INTEGER DEFAULT 5)'''); route_id TEXT, fecha TEXT NOT NULL,
estado TEXT DEFAULT 'PENDIENTE', calificacion INTEGER DEFAULT 5)''');
// Seed: admin y conductor demo // RESEÑAS del servicio
await db.execute('''CREATE TABLE reviews(
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, colonia TEXT NOT NULL,
route_id TEXT NOT NULL, estrellas INTEGER NOT NULL,
comentario TEXT NOT NULL, fecha TEXT NOT NULL,
nombre_usuario TEXT DEFAULT 'Ciudadano')''');
// Seed: admin y conductor
await db.insert('users', {'nombre':'Administrador','email':'admin@celaya.gob.mx', await db.insert('users', {'nombre':'Administrador','email':'admin@celaya.gob.mx',
'password':'admin123','rol':'ADMINISTRADOR'}); 'password':'admin123','rol':'ADMINISTRADOR'});
await db.insert('users', {'nombre':'Juan Conductor','email':'conductor@celaya.gob.mx', await db.insert('users', {'nombre':'Juan Conductor','email':'conductor@celaya.gob.mx',
'password':'conductor123','rol':'CONDUCTOR'}); 'password':'conductor123','rol':'CONDUCTOR'});
} }
// ── USERS ────────────────────────────────────────────────────────────── // ── USERS ────────────────────────────────────────────────────────────────
static Future<int> insertUser(UserModel u) async => static Future<int> insertUser(UserModel u) async =>
(await database).insert('users', u.toMap(), conflictAlgorithm: ConflictAlgorithm.abort); (await database).insert('users', u.toMap(), conflictAlgorithm: ConflictAlgorithm.abort);
@@ -71,49 +98,71 @@ class DbHelper {
return res.map((m) => UserModel.fromMap(m)).toList(); return res.map((m) => UserModel.fromMap(m)).toList();
} }
// ── DOMICILIOS ───────────────────────────────────────────────────────── // ── DOMICILIOS ───────────────────────────────────────────────────────────
static Future<int> insertDomicilio(DomicilioModel d) async => static Future<int> insertDomicilio(DomicilioModel d) async {
(await database).insert('domicilios', d.toMap()); final db = await database;
// Si es el primero del usuario, marcarlo como primario
final existing = await db.query('domicilios', where:'user_id=?', whereArgs:[d.userId]);
final isPrimary = existing.isEmpty ? 1 : (d.isPrimary ? 1 : 0);
return db.insert('domicilios', {...d.toMap(), 'is_primary': isPrimary});
}
static Future<List<DomicilioModel>> getDomiciliosByUser(int userId) async {
final res = await (await database).query('domicilios',
where:'user_id=?', whereArgs:[userId], orderBy:'is_primary DESC, id ASC');
return res.map((m) => DomicilioModel.fromMap(m)).toList();
}
static Future<DomicilioModel?> getPrimaryDomicilio(int userId) async { static Future<DomicilioModel?> getPrimaryDomicilio(int userId) async {
final res = await (await database).query('domicilios', final db = await database;
var res = await db.query('domicilios',
where:'user_id=? AND is_primary=1', whereArgs:[userId]); where:'user_id=? AND is_primary=1', whereArgs:[userId]);
if (res.isEmpty) {
res = await db.query('domicilios', where:'user_id=?', whereArgs:[userId], limit:1);
}
return res.isEmpty ? null : DomicilioModel.fromMap(res.first); return res.isEmpty ? null : DomicilioModel.fromMap(res.first);
} }
// ── ASIGNACIONES ─────────────────────────────────────────────────────── static Future<void> setPrimaryDomicilio(int domId, int userId) async {
static Future<void> upsertAsignacion(AssignmentModel a) async {
final db = await database; final db = await database;
final ex = await db.query('asignaciones', await db.update('domicilios', {'is_primary':0}, where:'user_id=?', whereArgs:[userId]);
where:'conductor_id=? AND dia_semana=?', await db.update('domicilios', {'is_primary':1}, where:'id=?', whereArgs:[domId]);
whereArgs:[a.conductorId, a.diaSemana]);
if (ex.isEmpty) {
await db.insert('asignaciones', a.toMap());
} else {
await db.update('asignaciones', {'route_id':a.routeId,'turno':a.turno},
where:'conductor_id=? AND dia_semana=?',
whereArgs:[a.conductorId, a.diaSemana]);
}
} }
static Future<List<AssignmentModel>> getAsignacionesByConductor(int conductorId) async { static Future<void> deleteDomicilio(int id) async =>
final res = await (await database).query('asignaciones', (await database).delete('domicilios', where:'id=?', whereArgs:[id]);
where:'conductor_id=?', whereArgs:[conductorId]);
return res.map((m) => AssignmentModel.fromMap(m)).toList(); static Future<List<DomicilioModel>> getDomiciliosByRoute(String routeId) async {
final res = await (await database).query('domicilios',
where:'route_id=?', whereArgs:[routeId]);
return res.map((m) => DomicilioModel.fromMap(m)).toList();
} }
static Future<List<AssignmentModel>> getAllAsignaciones() async { // ── ROUTE DEFINITIONS ────────────────────────────────────────────────────
final res = await (await database).query('asignaciones'); static Future<int> insertRouteDefinition(RouteDefinitionModel r) async =>
return res.map((m) => AssignmentModel.fromMap(m)).toList(); (await database).insert('route_definitions', r.toMap(),
}
// ── ROUTE STATUS ───────────────────────────────────────────────────────
static Future<void> upsertRouteStatus(RouteStatusModel s) async {
final db = await database;
await db.insert('route_status', s.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace); conflictAlgorithm: ConflictAlgorithm.replace);
static Future<List<RouteDefinitionModel>> getAllRouteDefinitions() async {
final res = await (await database).query('route_definitions', orderBy:'route_id ASC');
return res.map((m) => RouteDefinitionModel.fromMap(m)).toList();
} }
static Future<RouteDefinitionModel?> getRouteDefinitionById(String routeId) async {
final res = await (await database).query('route_definitions',
where:'route_id=?', whereArgs:[routeId]);
return res.isEmpty ? null : RouteDefinitionModel.fromMap(res.first);
}
static Future<void> updateRouteDefinition(RouteDefinitionModel r) async =>
(await database).update('route_definitions', r.toMap(),
where:'route_id=?', whereArgs:[r.routeId]);
// ── ROUTE STATUS ─────────────────────────────────────────────────────────
static Future<void> upsertRouteStatus(RouteStatusModel s) async =>
(await database).insert('route_status', s.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace);
static Future<RouteStatusModel?> getRouteStatus(String routeId) async { static Future<RouteStatusModel?> getRouteStatus(String routeId) async {
final res = await (await database).query('route_status', final res = await (await database).query('route_status',
where:'route_id=?', whereArgs:[routeId]); where:'route_id=?', whereArgs:[routeId]);
@@ -125,7 +174,31 @@ class DbHelper {
return res.map((m) => RouteStatusModel.fromMap(m)).toList(); return res.map((m) => RouteStatusModel.fromMap(m)).toList();
} }
// ── ALERTAS ──────────────────────────────────────────────────────────── // ── ASIGNACIONES ─────────────────────────────────────────────────────────
static Future<void> upsertAsignacion(AssignmentModel a) async {
final db = await database;
final ex = await db.query('asignaciones',
where:'conductor_id=? AND dia_semana=?', whereArgs:[a.conductorId, a.diaSemana]);
if (ex.isEmpty) {
await db.insert('asignaciones', a.toMap());
} else {
await db.update('asignaciones', {'route_id':a.routeId,'turno':a.turno},
where:'conductor_id=? AND dia_semana=?', whereArgs:[a.conductorId, a.diaSemana]);
}
}
static Future<List<AssignmentModel>> getAsignacionesByConductor(int id) async {
final res = await (await database).query('asignaciones',
where:'conductor_id=?', whereArgs:[id]);
return res.map((m) => AssignmentModel.fromMap(m)).toList();
}
static Future<List<AssignmentModel>> getAllAsignaciones() async {
final res = await (await database).query('asignaciones');
return res.map((m) => AssignmentModel.fromMap(m)).toList();
}
// ── ALERTAS ──────────────────────────────────────────────────────────────
static Future<int> insertAlerta(AlertaModel a) async => static Future<int> insertAlerta(AlertaModel a) async =>
(await database).insert('alertas', a.toMap()); (await database).insert('alertas', a.toMap());
@@ -137,49 +210,70 @@ class DbHelper {
return res.map((m) => AlertaModel.fromMap(m)).toList(); return res.map((m) => AlertaModel.fromMap(m)).toList();
} }
static Future<List<AlertaModel>> getIncidentesConductor() async {
final res = await (await database).query('alertas',
where:"tipo LIKE 'INCIDENTE_%'", orderBy:'fecha DESC');
return res.map((m) => AlertaModel.fromMap(m)).toList();
}
static Future<void> resolverAlerta(int id) async => static Future<void> resolverAlerta(int id) async =>
(await database).update('alertas', {'resuelta':1}, where:'id=?', whereArgs:[id]); (await database).update('alertas', {'resuelta':1}, where:'id=?', whereArgs:[id]);
// ── REPORTES ─────────────────────────────────────────────────────────── // ── REPORTES ─────────────────────────────────────────────────────────────
static Future<int> insertReporte(ReporteModel r) async => static Future<int> insertReporte(ReporteModel r) async =>
(await database).insert('reportes', r.toMap()); (await database).insert('reportes', r.toMap());
static Future<List<ReporteModel>> getAllReportes() async {
final res = await (await database).query('reportes', orderBy:'fecha DESC');
return res.map((m) => ReporteModel.fromMap(m)).toList();
}
static Future<List<ReporteModel>> getReportesByUser(int userId) async { static Future<List<ReporteModel>> getReportesByUser(int userId) async {
final res = await (await database).query('reportes', final res = await (await database).query('reportes',
where:'user_id=?', whereArgs:[userId], orderBy:'fecha DESC'); where:'user_id=?', whereArgs:[userId], orderBy:'fecha DESC');
return res.map((m) => ReporteModel.fromMap(m)).toList(); return res.map((m) => ReporteModel.fromMap(m)).toList();
} }
static Future<List<ReporteModel>> getAllReportes() async { static Future<List<Map<String, dynamic>>> getReportesConUsuario() async {
final res = await (await database).query('reportes', orderBy:'fecha DESC'); final db = await database;
return res.map((m) => ReporteModel.fromMap(m)).toList(); return db.rawQuery('''
SELECT r.*, u.nombre as user_nombre, u.email as user_email
FROM reportes r LEFT JOIN users u ON r.user_id = u.id
ORDER BY r.fecha DESC''');
} }
static Future<void> updateReporteEstado(int id, String estado) async => static Future<void> updateReporteEstado(int id, String estado) async =>
(await database).update('reportes', {'estado':estado}, where:'id=?', whereArgs:[id]); (await database).update('reportes', {'estado':estado}, where:'id=?', whereArgs:[id]);
// ── REPORTES CON INFO DE USUARIO ────────────────────────────────────── // ── REVIEWS ──────────────────────────────────────────────────────────────
static Future<List<Map<String, dynamic>>> getReportesConUsuario() async { static Future<int> insertReview(ReviewModel r) async =>
(await database).insert('reviews', r.toMap());
static Future<List<ReviewModel>> getAllReviews() async {
final res = await (await database).query('reviews', orderBy:'fecha DESC');
return res.map((m) => ReviewModel.fromMap(m)).toList();
}
static Future<bool> hasReviewedRoute(int userId, String routeId) async {
// Verifica si el usuario ya calificó esta ruta hoy
final today = DateTime.now().toIso8601String().substring(0, 10);
final res = await (await database).query('reviews',
where:"user_id=? AND route_id=? AND fecha LIKE '$today%'",
whereArgs:[userId, routeId]);
return res.isNotEmpty;
}
// Promedio por colonia para el admin
static Future<List<Map<String, dynamic>>> getReviewSummaryByColonia() async {
final db = await database; final db = await database;
return db.rawQuery(''' return db.rawQuery('''
SELECT r.*, u.nombre as user_nombre, u.email as user_email SELECT colonia, route_id,
FROM reportes r AVG(estrellas) as promedio,
LEFT JOIN users u ON r.user_id = u.id COUNT(*) as total,
ORDER BY r.fecha DESC MIN(estrellas) as min_est,
'''); MAX(estrellas) as max_est
} FROM reviews
GROUP BY colonia
// ── INCIDENTES CONDUCTOR ─────────────────────────────────────────────── ORDER BY promedio ASC''');
static Future<List<AlertaModel>> getIncidentesConductor() async {
final res = await (await database).query('alertas',
where: "tipo LIKE 'INCIDENTE_%'", orderBy: 'fecha DESC');
return res.map((m) => AlertaModel.fromMap(m)).toList();
}
// ── DOMICILIOS POR RUTA ────────────────────────────────────────────────
static Future<List<DomicilioModel>> getDomiciliosByRoute(String routeId) async {
final res = await (await database).query('domicilios',
where: 'route_id = ?', whereArgs: [routeId]);
return res.map((m) => DomicilioModel.fromMap(m)).toList();
} }
} }

View File

@@ -4,7 +4,7 @@ class UserModel {
final String nombre; final String nombre;
final String email; final String email;
final String password; final String password;
final String rol; // CIUDADANO | CONDUCTOR | ADMINISTRADOR final String rol;
UserModel({this.id, required this.nombre, required this.email, UserModel({this.id, required this.nombre, required this.email,
required this.password, required this.rol}); required this.password, required this.rol});
@@ -17,37 +17,97 @@ class UserModel {
password:m['password'], rol:m['rol']); password:m['password'], rol:m['rol']);
} }
// ── DOMICILIO (citizen) ─────────────────────────────────────────────────── // ── DOMICILIO (User → Domicilio → Zona → Ruta) ────────────────────────────
class DomicilioModel { class DomicilioModel {
final int? id; final int? id;
final int userId; final int userId;
final String alias; // "Casa", "Trabajo", etc.
final String calle; final String calle;
final String colonia; final String colonia; // Zona de cobertura
final String routeId; final String routeId; // Ruta asignada
final String horarioEstimado; final String horarioEstimado;
final bool isPrimary; final bool isPrimary;
DomicilioModel({this.id, required this.userId, required this.calle, DomicilioModel({this.id, required this.userId, this.alias = 'Casa',
required this.colonia, required this.routeId, required this.calle, required this.colonia, required this.routeId,
required this.horarioEstimado, this.isPrimary = true}); required this.horarioEstimado, this.isPrimary = true});
Map<String, dynamic> toMap() => {'id': id, 'user_id': userId, 'calle': calle, Map<String, dynamic> toMap() => {'id':id,'user_id':userId,'alias':alias,
'colonia': colonia, 'route_id': routeId, 'calle':calle,'colonia':colonia,'route_id':routeId,
'horario_estimado':horarioEstimado,'is_primary':isPrimary?1:0}; 'horario_estimado':horarioEstimado,'is_primary':isPrimary?1:0};
factory DomicilioModel.fromMap(Map<String, dynamic> m) => DomicilioModel( factory DomicilioModel.fromMap(Map<String, dynamic> m) => DomicilioModel(
id: m['id'], userId: m['user_id'], calle: m['calle'], id:m['id'], userId:m['user_id'], alias:m['alias']??'Casa',
colonia: m['colonia'], routeId: m['route_id'], calle:m['calle'], colonia:m['colonia'], routeId:m['route_id'],
horarioEstimado:m['horario_estimado'], isPrimary:m['is_primary']==1); horarioEstimado:m['horario_estimado'], isPrimary:m['is_primary']==1);
} }
// ── ASSIGNMENT (driver schedule) ────────────────────────────────────────── // ── RUTA DINÁMICA (creada por admin) ──────────────────────────────────────
class RouteDefinitionModel {
final int? id;
final String routeId;
final String nombre;
final List<String> dias; // ['LUNES','MIERCOLES','VIERNES']
final String horaInicio; // '06:00'
final String horaFin; // '08:00'
final String turno; // MATUTINO|VESPERTINO|NOCTURNO
final List<String> colonias; // colonias que cubre
final bool activa;
RouteDefinitionModel({this.id, required this.routeId, required this.nombre,
required this.dias, required this.horaInicio, required this.horaFin,
required this.turno, required this.colonias, this.activa = true});
Map<String, dynamic> toMap() => {
'id':id,'route_id':routeId,'nombre':nombre,
'dias':dias.join(','),'hora_inicio':horaInicio,'hora_fin':horaFin,
'turno':turno,'colonias':colonias.join('|'),'activa':activa?1:0,
};
factory RouteDefinitionModel.fromMap(Map<String, dynamic> m) =>
RouteDefinitionModel(
id:m['id'], routeId:m['route_id'], nombre:m['nombre'],
dias:(m['dias']??'').toString().split(',').where((s)=>s.isNotEmpty).toList(),
horaInicio:m['hora_inicio']??'06:00', horaFin:m['hora_fin']??'08:00',
turno:m['turno']??'MATUTINO',
colonias:(m['colonias']??'').toString().split('|').where((s)=>s.isNotEmpty).toList(),
activa:m['activa']==1,
);
}
// ── RESEÑA DEL SERVICIO ───────────────────────────────────────────────────
class ReviewModel {
final int? id;
final int userId;
final String colonia;
final String routeId;
final int estrellas; // 1-5
final String comentario;
final String fecha;
final String nombreUsuario;
ReviewModel({this.id, required this.userId, required this.colonia,
required this.routeId, required this.estrellas, required this.comentario,
required this.fecha, this.nombreUsuario = ''});
Map<String, dynamic> toMap() => {'id':id,'user_id':userId,'colonia':colonia,
'route_id':routeId,'estrellas':estrellas,'comentario':comentario,
'fecha':fecha,'nombre_usuario':nombreUsuario};
factory ReviewModel.fromMap(Map<String, dynamic> m) => ReviewModel(
id:m['id'], userId:m['user_id'], colonia:m['colonia'],
routeId:m['route_id'], estrellas:m['estrellas'],
comentario:m['comentario']??'', fecha:m['fecha'],
nombreUsuario:m['nombre_usuario']??'Ciudadano');
}
// ── ASSIGNMENT ────────────────────────────────────────────────────────────
class AssignmentModel { class AssignmentModel {
final int? id; final int? id;
final int conductorId; final int conductorId;
final String routeId; final String routeId;
final String diaSemana; final String diaSemana;
final String turno; // MATUTINO | VESPERTINO | NOCTURNO final String turno;
AssignmentModel({this.id, required this.conductorId, required this.routeId, AssignmentModel({this.id, required this.conductorId, required this.routeId,
required this.diaSemana, required this.turno}); required this.diaSemana, required this.turno});
@@ -81,7 +141,7 @@ class RouteStatusModel {
// ── ALERTA ──────────────────────────────────────────────────────────────── // ── ALERTA ────────────────────────────────────────────────────────────────
class AlertaModel { class AlertaModel {
final int? id; final int? id;
final String tipo; // GPS_PERDIDO | CAMION_DETENIDO | FALLA_MECANICA final String tipo;
final String routeId; final String routeId;
final String mensaje; final String mensaje;
final String fecha; final String fecha;

View File

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

View File

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

View File

@@ -0,0 +1,189 @@
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>();
final routeData = getColonyByName(_coloniaSeleccionada!);
final routeId = routeData?.routeId ?? 'RUTA-01';
final horario = routeData?.horarioEstimado ?? 'Matutino (06:00-08:00)';
if (widget.editing != null) {
// Editar existente — eliminar y volver a insertar
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);
}
@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

@@ -1,14 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../core/app_colors.dart'; import '../../core/app_colors.dart';
import '../../services/auth_service.dart';
import '../../services/route_simulator_service.dart';
import '../../database/db_helper.dart'; import '../../database/db_helper.dart';
import '../../models/models.dart'; import '../../models/models.dart';
import '../../services/auth_service.dart';
import '../../services/route_simulator_service.dart';
import '../../data/routes_data.dart'; import '../../data/routes_data.dart';
import '../../widgets/route_map_widget.dart'; import '../../widgets/route_map_widget.dart';
import 'citizen_guia_screen.dart'; import 'citizen_guia_screen.dart';
import 'citizen_reporte_screen.dart'; import 'citizen_reporte_screen.dart';
import 'add_domicilio_screen.dart';
import 'review_screen.dart';
class CitizenHomeScreen extends StatefulWidget { class CitizenHomeScreen extends StatefulWidget {
const CitizenHomeScreen({super.key}); const CitizenHomeScreen({super.key});
@@ -22,7 +24,7 @@ class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final auth = context.watch<AuthService>(); final auth = context.watch<AuthService>();
final sim = context.watch<RouteSimulatorService>(); final sim = context.watch<RouteSimulatorService>();
final dom = auth.primaryDomicilio; // domicilio del ciudadano final dom = auth.primaryDomicilio;
final last = dom != null ? sim.getNotificationForRoute(dom.routeId) : null; final last = dom != null ? sim.getNotificationForRoute(dom.routeId) : null;
final tabs = [ final tabs = [
@@ -38,7 +40,8 @@ class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
if (last != null) if (last != null)
Positioned( Positioned(
top: MediaQuery.of(context).padding.top + 8, left: 0, right: 0, top: MediaQuery.of(context).padding.top + 8, left: 0, right: 0,
child: _NotifBanner(notif: last, onDismiss: () => sim.dismissRouteNotification(dom?.routeId ?? '')), child: _NotifBanner(notif: last,
onDismiss: () => sim.dismissRouteNotification(dom?.routeId ?? '')),
), ),
]), ]),
bottomNavigationBar: NavigationBar( bottomNavigationBar: NavigationBar(
@@ -59,7 +62,6 @@ class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
} }
} }
// ── Tab principal (StatefulWidget para cargar status de ruta) ─────────────
class _HomeTab extends StatefulWidget { class _HomeTab extends StatefulWidget {
final AuthService auth; final AuthService auth;
final RouteSimulatorService sim; final RouteSimulatorService sim;
@@ -69,98 +71,90 @@ class _HomeTab extends StatefulWidget {
class _HomeTabState extends State<_HomeTab> { class _HomeTabState extends State<_HomeTab> {
RouteStatusModel? _routeStatus; RouteStatusModel? _routeStatus;
RouteDefinitionModel? _routeDef;
@override @override void initState() { super.initState(); _loadStatus(); }
void initState() {
super.initState();
_loadStatus();
}
Future<void> _loadStatus() async { Future<void> _loadStatus() async {
final dom = widget.auth.primaryDomicilio; final dom = widget.auth.primaryDomicilio;
if (dom == null) return; if (dom == null) return;
final s = await DbHelper.getRouteStatus(dom.routeId); final s = await DbHelper.getRouteStatus(dom.routeId);
if (mounted) setState(() => _routeStatus = s); final rd = await DbHelper.getRouteDefinitionById(dom.routeId);
if (mounted) setState(() { _routeStatus = s; _routeDef = rd; });
} }
bool get _isRouteProblematic { bool get _isRouteProblematic {
final s = _routeStatus?.status ?? RouteStatus.enRuta; final s = _routeStatus?.status ?? RouteStatus.enRuta;
return s == RouteStatus.cancelada || return s == RouteStatus.cancelada || s == RouteStatus.fallaMecanica || s == RouteStatus.retrasada;
s == RouteStatus.fallaMecanica ||
s == RouteStatus.retrasada;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final dom = widget.auth.primaryDomicilio; final dom = widget.auth.primaryDomicilio;
final allDoms = widget.auth.allDomicilios;
final routeId = dom?.routeId ?? ''; final routeId = dom?.routeId ?? '';
final route = dom != null ? getRouteById(dom.routeId) : null; final route = dom != null ? getRouteById(dom.routeId) : null;
final isTruckClose = widget.sim.isTruckClose(routeId); final isTruckClose = widget.sim.isTruckClose(routeId);
final status = _routeStatus?.status ?? RouteStatus.enRuta; final isCompleted = widget.sim.isRouteCompleted(routeId);
final needsReview = widget.sim.needsReviewPrompt(routeId);
return RefreshIndicator( return RefreshIndicator(
onRefresh: _loadStatus, onRefresh: _loadStatus,
child: CustomScrollView(slivers: [ child: CustomScrollView(slivers: [
SliverAppBar( SliverAppBar(expandedHeight: 120, pinned: true,
expandedHeight: 120, pinned: true,
backgroundColor: AppColors.guindaPrimary, backgroundColor: AppColors.guindaPrimary,
bottom: PreferredSize(preferredSize: const Size.fromHeight(4), bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)), child: Container(height: 4, color: AppColors.dorado)),
flexibleSpace: FlexibleSpaceBar( flexibleSpace: FlexibleSpaceBar(background: Container(
background: Container(
color: AppColors.guindaPrimary, color: AppColors.guindaPrimary,
padding: const EdgeInsets.fromLTRB(20, 50, 20, 16), padding: const EdgeInsets.fromLTRB(20, 50, 20, 16),
child: Row(children: [ child: Row(children: [
const Icon(Icons.delete_sweep_rounded, color: AppColors.dorado, size: 30), const Icon(Icons.delete_sweep_rounded, color: AppColors.dorado, size: 30),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded(child: Column( Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Hola, ${widget.auth.currentUser?.nombre.split(' ').first ?? ''}', Text('Hola, ${widget.auth.currentUser?.nombre.split(' ').first ?? ''}',
style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)), style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
const Text('Celaya Limpia', style: TextStyle(color: AppColors.dorado, fontSize: 12)), const Text('Celaya Limpia', style: TextStyle(color: AppColors.dorado, fontSize: 12)),
], ])),
)), IconButton(icon: const Icon(Icons.logout, color: Colors.white70),
IconButton(
icon: const Icon(Icons.logout, color: Colors.white70),
onPressed: () async { onPressed: () async {
await widget.auth.logout(); await widget.auth.logout();
if (context.mounted) Navigator.pushReplacementNamed(context, '/login'); if (context.mounted) Navigator.pushReplacementNamed(context, '/login');
}, }),
),
]), ]),
), )),
),
), ),
SliverPadding( SliverPadding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
sliver: SliverList(delegate: SliverChildListDelegate([ sliver: SliverList(delegate: SliverChildListDelegate([
// ── Si la ruta tiene problema → mostrar alerta en vez de ETA/mapa // ── Selector de domicilio ────────────────────────────────────
if (_isRouteProblematic) ...[ if (allDoms.length > 1) _DomicilioSelector(
_RouteStatusBanner(status: _routeStatus!), auth: widget.auth, onChanged: _loadStatus),
const SizedBox(height: 12),
] else ...[ // ── Prompt de calificación ───────────────────────────────────
// ETA Card normal 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), _EtaCard(sim: widget.sim, routeId: routeId, dom: dom, route: route),
const SizedBox(height: 12), const SizedBox(height: 12),
// Mapa solo cuando camión está cerca
if (isTruckClose && route != null) ...[ // Información detallada de la ruta (días y horario)
Container( if (_routeDef != null) _RouteInfoCard(routeDef: _routeDef!),
padding: const EdgeInsets.all(10), if (_routeDef == null && dom != null) _BasicRouteInfo(dom: dom),
decoration: BoxDecoration(
color: Colors.orange.shade50, const SizedBox(height: 12),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.shade300), // Mapa solo cuando camión está cerca (<15 min)
), if (isTruckClose && route != null && !isCompleted) ...[
child: const Row(children: [ _WarningNoPursue(),
Icon(Icons.location_on, color: Colors.orange, size: 18),
SizedBox(width: 6),
Expanded(child: Text('📍 El camión está cerca — mapa activado',
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.orange, fontSize: 12))),
]),
),
const SizedBox(height: 8), const SizedBox(height: 8),
RouteMapWidget(route: route, simulator: widget.sim, height: 220), RouteMapWidget(route: route, simulator: widget.sim, height: 220),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -168,81 +162,17 @@ class _HomeTabState extends State<_HomeTab> {
], ],
// Aviso privacidad // Aviso privacidad
Container( _PrivacyBanner(),
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))),
]),
),
const SizedBox(height: 12), const SizedBox(height: 12),
// Info domicilio // Mis domicilios
if (dom != null) _DomiciliosCard(auth: widget.auth),
Card(child: Padding( const SizedBox(height: 12),
padding: const EdgeInsets.all(14),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Row(children: [
Icon(Icons.location_on, color: AppColors.guindaPrimary, size: 16),
SizedBox(width: 6),
Text('Mi Domicilio', style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)),
]),
const Divider(),
Text(dom.calle, style: const TextStyle(fontSize: 13)),
Text('${dom.colonia}${dom.routeId}',
style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)),
Text(dom.horarioEstimado,
style: const TextStyle(color: AppColors.grisTexto, fontSize: 11)),
]),
)),
// Historial notificaciones // Historial notificaciones
if (widget.sim.history.isNotEmpty) ...[ if (widget.sim.historyForRoute(routeId).isNotEmpty)
const SizedBox(height: 12), _HistorialCard(sim: widget.sim, routeId: routeId),
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(),
...widget.sim.history.take(4).map((n) {
final color = n.event == NotifEvent.truckProximity
? AppColors.naranjaAlerta
: n.event == NotifEvent.routeCompleted
? AppColors.verdeExito
: n.event == NotifEvent.routeCancelled
? AppColors.rojoError
: AppColors.azulInfo;
final icon = n.event == NotifEvent.truckProximity
? Icons.warning_amber
: n.event == NotifEvent.routeCompleted
? Icons.check_circle
: n.event == NotifEvent.routeCancelled
? Icons.cancel
: Icons.local_shipping;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 6),
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),
),
]),
);
}),
]),
)),
],
const SizedBox(height: 80), const SizedBox(height: 80),
])), ])),
), ),
@@ -251,6 +181,223 @@ class _HomeTabState extends State<_HomeTab> {
} }
} }
// ── 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 ─────────────────────────────────────────── // ── Banner de ruta con problema ───────────────────────────────────────────
class _RouteStatusBanner extends StatelessWidget { class _RouteStatusBanner extends StatelessWidget {
final RouteStatusModel status; final RouteStatusModel status;
@@ -261,111 +408,68 @@ class _RouteStatusBanner extends StatelessWidget {
final isCancelled = status.status == RouteStatus.cancelada; final isCancelled = status.status == RouteStatus.cancelada;
final isFalla = status.status == RouteStatus.fallaMecanica; final isFalla = status.status == RouteStatus.fallaMecanica;
final isRetrasada = status.status == RouteStatus.retrasada; final isRetrasada = status.status == RouteStatus.retrasada;
final color = isCancelled ? AppColors.rojoError : isFalla ? Colors.red.shade800 : AppColors.naranjaAlerta;
final color = isCancelled ? AppColors.rojoError final icon = isCancelled ? Icons.cancel : isFalla ? Icons.build : Icons.access_time;
: isFalla ? Colors.red.shade800
: AppColors.naranjaAlerta;
final icon = isCancelled ? Icons.cancel
: isFalla ? Icons.build
: Icons.access_time;
final titulo = isCancelled ? '❌ Ruta Cancelada Hoy' final titulo = isCancelled ? '❌ Ruta Cancelada Hoy'
: isFalla ? '🔧 Falla Mecánica en Servicio' : isFalla ? '🔧 Falla Mecánica en Servicio' : '⏱️ Servicio con Retraso';
: '⏱️ Servicio con Retraso';
final descripcion = isCancelled
? 'El servicio de recolección de tu colonia no se realizará hoy. Favor de guardar tus residuos para la próxima jornada.'
: isFalla
? 'El camión asignado a tu sector presentó una falla mecánica. El Ayuntamiento está atendiendo la situación.'
: 'El camión de tu sector presenta un retraso en su recorrido. El servicio se realizará, pero con demora.';
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// Alerta principal Container(width: double.infinity, padding: const EdgeInsets.all(16),
Container( decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(12)),
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(12),
boxShadow: [BoxShadow(color: color.withOpacity(0.4), blurRadius: 8, offset: const Offset(0, 4))],
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [ Row(children: [
Icon(icon, color: Colors.white, size: 26), Icon(icon, color: Colors.white, size: 26),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded(child: Text(titulo, Expanded(child: Text(titulo, style: const TextStyle(color: Colors.white,
style: const TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold))), fontSize: 17, fontWeight: FontWeight.bold))),
]), ]),
const SizedBox(height: 10), const SizedBox(height: 8),
Text(descripcion, style: const TextStyle(color: Colors.white, fontSize: 13, height: 1.4)), 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.'
// Mensaje del administrador (posible solució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) ...[ if (status.mensaje != null && status.mensaje!.isNotEmpty) ...[
const SizedBox(height: 10), const SizedBox(height: 10),
Container( Container(width: double.infinity, padding: const EdgeInsets.all(14),
width: double.infinity, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10),
padding: const EdgeInsets.all(14), border: Border.all(color: color.withOpacity(0.4))),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withOpacity(0.4)),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [ Row(children: [
Icon(Icons.admin_panel_settings, color: color, size: 16), Icon(Icons.admin_panel_settings, color: color, size: 16),
const SizedBox(width: 6), const SizedBox(width: 6),
Text('Mensaje del Ayuntamiento', Text('Mensaje del Ayuntamiento', style: TextStyle(
style: TextStyle(fontWeight: FontWeight.bold, color: color, fontSize: 13)), fontWeight: FontWeight.bold, color: color, fontSize: 13)),
]), ]),
const SizedBox(height: 6), const SizedBox(height: 6),
Text(status.mensaje!, Text(status.mensaje!, style: const TextStyle(fontSize: 13)),
style: const TextStyle(fontSize: 13, color: AppColors.negroTexto, height: 1.4)), ])),
]),
),
], ],
// Consejo ciudadano
const SizedBox(height: 10), const SizedBox(height: 10),
Container( Container(padding: const EdgeInsets.all(12),
padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(8),
decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade300)),
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('💡 Recomendaciones:', const Text('💡 Recomendaciones:', style: TextStyle(fontWeight: FontWeight.bold,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: AppColors.grisTexto)), fontSize: 12, color: AppColors.grisTexto)),
const SizedBox(height: 4), const SizedBox(height: 4),
if (isCancelled) Text(isCancelled
const Text('• Guarda tus bolsas en un lugar cerrado\n' ? '• Guarda tus bolsas en lugar cerrado\n• No dejes residuos en la acera\n• Revisa la app mañana'
'• No dejes residuos en la acera\n' : isRetrasada
'Revisa la app mañana para el horario actualizado', ? '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'
style: TextStyle(fontSize: 12, color: AppColors.grisTexto)), : '• Espera confirmación del Ayuntamiento\n• Puede enviarse unidad de reemplazo',
if (isFalla) style: const TextStyle(fontSize: 12, color: AppColors.grisTexto)),
const Text('• Espera confirmación del Ayuntamiento\n' ])),
'• Puede enviarse una unidad de reemplazo\n' const SizedBox(height: 12),
'• Revisa las alertas en esta pantalla',
style: TextStyle(fontSize: 12, color: AppColors.grisTexto)),
if (isRetrasada)
const Text('• Tu basura será recogida hoy, con demora\n'
'• Puedes sacar tus bolsas cuando recibas la alerta\n'
'• Recibirás notificación cuando el camión se acerque',
style: TextStyle(fontSize: 12, color: AppColors.grisTexto)),
]),
),
]); ]);
} }
} }
// ── ETA Card ────────────────────────────────────────────────────────────── // ── ETA Card ──────────────────────────────────────────────────────────────
class _EtaCard extends StatelessWidget { class _EtaCard extends StatelessWidget {
final RouteSimulatorService sim; final RouteSimulatorService sim; final String routeId; final dom; final route;
final String routeId;
final dom; final route;
const _EtaCard({required this.sim, required this.routeId, required this.dom, required this.route}); const _EtaCard({required this.sim, required this.routeId, required this.dom, required this.route});
@override @override
@@ -374,75 +478,101 @@ class _EtaCard extends StatelessWidget {
gradient: const LinearGradient(colors:[AppColors.guindaPrimary,AppColors.guindaDark], gradient: const LinearGradient(colors:[AppColors.guindaPrimary,AppColors.guindaDark],
begin:Alignment.topLeft,end:Alignment.bottomRight), begin:Alignment.topLeft,end:Alignment.bottomRight),
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
boxShadow: [BoxShadow(color: AppColors.guindaDark.withOpacity(0.4), boxShadow: [BoxShadow(color:AppColors.guindaDark.withOpacity(0.4),blurRadius:8,offset:const Offset(0,4))]),
blurRadius: 8, offset: const Offset(0, 4))],
),
padding: const EdgeInsets.all(18), padding: const EdgeInsets.all(18),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children:[ Row(children:[
const Icon(Icons.local_shipping,color:AppColors.dorado,size:22), const Icon(Icons.local_shipping,color:AppColors.dorado,size:22),
const SizedBox(width:8), const SizedBox(width:8),
Expanded(child: Text(route?.name ?? 'Ruta asignada', Expanded(child:Text(route?.name??dom?.routeId??'Tu ruta',
style:const TextStyle(color:AppColors.dorado,fontSize:13,fontWeight:FontWeight.w600))), style:const TextStyle(color:AppColors.dorado,fontSize:13,fontWeight:FontWeight.w600))),
]), ]),
const SizedBox(height:8), const SizedBox(height:8),
Text(sim.getEtaText(routeId), Text(sim.getEtaText(routeId),
style:const TextStyle(color:Colors.white,fontSize:16,fontWeight:FontWeight.bold)), style:const TextStyle(color:Colors.white,fontSize:16,fontWeight:FontWeight.bold)),
const SizedBox(height: 8), const SizedBox(height:6),
if (dom != null) if (dom!=null) Text('${dom.horarioEstimado}',
Text('${dom.horarioEstimado}',
style:const TextStyle(color:Colors.white60,fontSize:11)), style:const TextStyle(color:Colors.white60,fontSize:11)),
const SizedBox(height:10), const SizedBox(height:10),
LinearProgressIndicator( LinearProgressIndicator(
value: route != null value:route!=null?(sim.getPositionIndex(routeId)+1)/route.positions.length:0,
? (sim.getPositionIndex(routeId) + 1) / route.positions.length : 0,
backgroundColor:Colors.white24, backgroundColor:Colors.white24,
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.dorado), valueColor:const AlwaysStoppedAnimation<Color>(AppColors.dorado)),
), ]));
]),
);
} }
// ── Banner notificación ─────────────────────────────────────────────────── // ── 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 { class _NotifBanner extends StatelessWidget {
final AppNotification notif; final VoidCallback onDismiss; final AppNotification notif; final VoidCallback onDismiss;
const _NotifBanner({required this.notif, required this.onDismiss}); const _NotifBanner({required this.notif, required this.onDismiss});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final color = notif.event == NotifEvent.truckProximity final isUrgent = notif.event==NotifEvent.truckProximity||notif.event==NotifEvent.truckApproaching15min;
? AppColors.naranjaAlerta final isReview = notif.event==NotifEvent.reviewPrompt;
: notif.event == NotifEvent.routeCompleted final color = isUrgent?AppColors.naranjaAlerta
? AppColors.verdeExito :isReview?Colors.amber.shade700
: notif.event == NotifEvent.routeCancelled :notif.event==NotifEvent.routeCancelled?AppColors.rojoError
? AppColors.rojoError :notif.event==NotifEvent.gpsLost?Colors.red.shade800
: notif.event == NotifEvent.gpsLost
? Colors.red.shade800
:AppColors.azulInfo; :AppColors.azulInfo;
return Material(color:Colors.transparent,
return Material( child:Container(margin:const EdgeInsets.all(12),
color: Colors.transparent,
child: Container(
margin: const EdgeInsets.all(12),
decoration:BoxDecoration(color:color,borderRadius:BorderRadius.circular(12), decoration:BoxDecoration(color:color,borderRadius:BorderRadius.circular(12),
boxShadow:const[BoxShadow(color:Colors.black26,blurRadius:8,offset:Offset(0,4))]), boxShadow:const[BoxShadow(color:Colors.black26,blurRadius:8,offset:Offset(0,4))]),
child: Padding( child:Padding(padding:const EdgeInsets.all(12),child:Row(children:[
padding: const EdgeInsets.all(12), Icon(isReview?Icons.star:Icons.notifications_active,color:Colors.white,size:24),
child: Row(children: [
const Icon(Icons.notifications_active, color: Colors.white, size: 24),
const SizedBox(width:10), const SizedBox(width:10),
Expanded(child:Column(crossAxisAlignment:CrossAxisAlignment.start, Expanded(child:Column(crossAxisAlignment:CrossAxisAlignment.start,
mainAxisSize:MainAxisSize.min,children:[ mainAxisSize:MainAxisSize.min,children:[
Text(notif.title, style: const TextStyle(color: Colors.white, Text(notif.title,style:const TextStyle(color:Colors.white,fontWeight:FontWeight.bold,fontSize:13)),
fontWeight: FontWeight.bold, fontSize: 13)),
Text(notif.body,style:const TextStyle(color:Colors.white70,fontSize:11), Text(notif.body,style:const TextStyle(color:Colors.white70,fontSize:11),
maxLines:2,overflow:TextOverflow.ellipsis), maxLines:2,overflow:TextOverflow.ellipsis),
])), ])),
IconButton(icon: const Icon(Icons.close, color: Colors.white, size: 18), IconButton(icon:const Icon(Icons.close,color:Colors.white,size:18),onPressed:onDismiss),
onPressed: onDismiss), ]))));
]),
),
),
);
} }
} }

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

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../core/app_colors.dart'; import '../core/app_colors.dart';
import '../data/colonies_data.dart'; import '../data/colonies_data.dart';
import '../data/celaya_colonias.dart';
import '../models/route_model.dart'; import '../models/route_model.dart';
import '../services/auth_service.dart'; import '../services/auth_service.dart';
@@ -67,8 +68,11 @@ class _RegisterScreenState extends State<RegisterScreen> {
border:OutlineInputBorder(),filled:true,fillColor:Colors.white), border:OutlineInputBorder(),filled:true,fillColor:Colors.white),
hint:const Text('Selecciona tu colonia'), hint:const Text('Selecciona tu colonia'),
value:_colony?.colonia, isExpanded:true, value:_colony?.colonia, isExpanded:true,
items:colonyNames.map((n)=>DropdownMenuItem(value:n,child:Text(n,style:const TextStyle(fontSize:13)))).toList(), 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)); }), onChanged:(v){ if(v!=null) setState((){
_colony = getColonyByName(v) ?? ColonyModel(
colonia:v, routeId:'RUTA-01', horarioEstimado:'Matutino (06:00-08:00)');
}); }),
if (_colony!=null) ...[ if (_colony!=null) ...[
const SizedBox(height:10), const SizedBox(height:10),
Container(padding:const EdgeInsets.all(12), Container(padding:const EdgeInsets.all(12),

View File

@@ -5,11 +5,13 @@ import '../database/db_helper.dart';
class AuthService extends ChangeNotifier { class AuthService extends ChangeNotifier {
UserModel? _user; UserModel? _user;
DomicilioModel? _domicilio; DomicilioModel? _primaryDomicilio;
List<DomicilioModel> _allDomicilios = [];
bool _loading = true; bool _loading = true;
UserModel? get currentUser => _user; UserModel? get currentUser => _user;
DomicilioModel? get primaryDomicilio => _domicilio; DomicilioModel? get primaryDomicilio => _primaryDomicilio;
List<DomicilioModel> get allDomicilios => _allDomicilios;
bool get isLoggedIn => _user != null; bool get isLoggedIn => _user != null;
bool get loading => _loading; bool get loading => _loading;
String get rol => _user?.rol ?? ''; String get rol => _user?.rol ?? '';
@@ -21,22 +23,28 @@ class AuthService extends ChangeNotifier {
final id = p.getInt('user_id'); final id = p.getInt('user_id');
if (id != null) { if (id != null) {
_user = await DbHelper.getUserById(id); _user = await DbHelper.getUserById(id);
if (_user?.rol == 'CIUDADANO') { if (_user?.rol == 'CIUDADANO') await reloadDomicilios();
_domicilio = await DbHelper.getPrimaryDomicilio(id);
}
} }
_loading = false; _loading = false;
notifyListeners(); notifyListeners();
} }
Future<void> reloadDomicilios() async {
if (_user == null) return;
_allDomicilios = await DbHelper.getDomiciliosByUser(_user!.id!);
_primaryDomicilio = _allDomicilios.isNotEmpty
? _allDomicilios.firstWhere((d) => d.isPrimary,
orElse: () => _allDomicilios.first)
: null;
notifyListeners();
}
Future<String?> login(String email, String password) async { Future<String?> login(String email, String password) async {
final user = await DbHelper.getUserByEmail(email.trim().toLowerCase()); final user = await DbHelper.getUserByEmail(email.trim().toLowerCase());
if (user == null) return 'Correo no registrado'; if (user == null) return 'Correo no registrado';
if (user.password != password) return 'Contraseña incorrecta'; if (user.password != password) return 'Contraseña incorrecta';
_user = user; _user = user;
if (user.rol == 'CIUDADANO') { if (user.rol == 'CIUDADANO') await reloadDomicilios();
_domicilio = await DbHelper.getPrimaryDomicilio(user.id!);
}
final p = await SharedPreferences.getInstance(); final p = await SharedPreferences.getInstance();
await p.setInt('user_id', user.id!); await p.setInt('user_id', user.id!);
notifyListeners(); notifyListeners();
@@ -51,10 +59,11 @@ class AuthService extends ChangeNotifier {
final user = UserModel(nombre:nombre.trim(), final user = UserModel(nombre:nombre.trim(),
email:email.trim().toLowerCase(), password:password, rol:'CIUDADANO'); email:email.trim().toLowerCase(), password:password, rol:'CIUDADANO');
final uid = await DbHelper.insertUser(user); final uid = await DbHelper.insertUser(user);
await DbHelper.insertDomicilio(DomicilioModel(userId:uid, calle:calle.trim(), await DbHelper.insertDomicilio(DomicilioModel(userId:uid, alias:'Casa',
colonia:colonia, routeId:routeId, horarioEstimado:horarioEstimado)); calle:calle.trim(), colonia:colonia, routeId:routeId,
horarioEstimado:horarioEstimado, isPrimary:true));
_user = await DbHelper.getUserById(uid); _user = await DbHelper.getUserById(uid);
_domicilio = await DbHelper.getPrimaryDomicilio(uid); await reloadDomicilios();
final p = await SharedPreferences.getInstance(); final p = await SharedPreferences.getInstance();
await p.setInt('user_id', uid); await p.setInt('user_id', uid);
notifyListeners(); notifyListeners();
@@ -62,7 +71,7 @@ class AuthService extends ChangeNotifier {
} }
Future<void> logout() async { Future<void> logout() async {
_user = null; _domicilio = null; _user = null; _primaryDomicilio = null; _allDomicilios = [];
final p = await SharedPreferences.getInstance(); final p = await SharedPreferences.getInstance();
await p.remove('user_id'); await p.remove('user_id');
notifyListeners(); notifyListeners();

View File

@@ -5,13 +5,16 @@ import '../models/models.dart';
import '../data/routes_data.dart'; import '../data/routes_data.dart';
import '../database/db_helper.dart'; import '../database/db_helper.dart';
enum NotifEvent { routeStart, truckProximity, routeCompleted, gpsLost, truckStopped, routeCancelled, none } enum NotifEvent {
routeStart, truckProximity, truckApproaching15min, routeCompleted,
gpsLost, truckStopped, routeCancelled, reviewPrompt, none
}
class AppNotification { class AppNotification {
final NotifEvent event; final NotifEvent event;
final String title; final String title;
final String body; final String body;
final String routeId; // Para filtrar por usuario final String routeId;
final DateTime timestamp; final DateTime timestamp;
AppNotification({required this.event, required this.title, AppNotification({required this.event, required this.title,
required this.body, required this.routeId}) required this.body, required this.routeId})
@@ -24,8 +27,10 @@ class SimulatorState {
bool gpsActive; bool gpsActive;
DateTime lastMoved; DateTime lastMoved;
bool stoppedAlertSent; bool stoppedAlertSent;
bool reviewPromptSent;
SimulatorState({required this.routeId, this.positionIndex=0, SimulatorState({required this.routeId, this.positionIndex=0,
this.gpsActive = true, required this.lastMoved, this.stoppedAlertSent = false}); this.gpsActive=true, required this.lastMoved,
this.stoppedAlertSent=false, this.reviewPromptSent=false});
} }
class RouteSimulatorService extends ChangeNotifier { class RouteSimulatorService extends ChangeNotifier {
@@ -33,25 +38,25 @@ class RouteSimulatorService extends ChangeNotifier {
Timer? _globalTimer; Timer? _globalTimer;
Timer? _gpsMonitorTimer; Timer? _gpsMonitorTimer;
AppNotification? _lastNotification; // Admin ve todas AppNotification? _lastNotification;
final List<AppNotification> _history = []; final List<AppNotification> _history = [];
// ── Getters ───────────────────────────────────────────────────────────── // ── Getters ─────────────────────────────────────────────────────────────
// Admin: ve la última notificación global
AppNotification? get lastNotification => _lastNotification; AppNotification? get lastNotification => _lastNotification;
// Ciudadano/Conductor: solo ve notificaciones de SU ruta // Ciudadano/Conductor: solo su ruta
AppNotification? getNotificationForRoute(String routeId) { AppNotification? getNotificationForRoute(String routeId) {
if (_lastNotification?.routeId == routeId) return _lastNotification; if (_lastNotification?.routeId == routeId) return _lastNotification;
return null; return null;
} }
List<AppNotification> get history => List.unmodifiable(_history); List<AppNotification> get history => List.unmodifiable(_history);
// Historial filtrado por ruta
List<AppNotification> historyForRoute(String routeId) => List<AppNotification> historyForRoute(String routeId) =>
_history.where((n) => n.routeId == routeId).toList(); _history.where((n) => n.routeId == routeId).toList();
bool needsReviewPrompt(String routeId) =>
_states[routeId]?.reviewPromptSent == true;
// ── Inicio ─────────────────────────────────────────────────────────────── // ── Inicio ───────────────────────────────────────────────────────────────
void startAllRoutes() { void startAllRoutes() {
for (final r in routesData) { for (final r in routesData) {
@@ -92,26 +97,45 @@ class RouteSimulatorService extends ChangeNotifier {
final diff = DateTime.now().difference(state.lastMoved); final diff = DateTime.now().difference(state.lastMoved);
if (diff.inMinutes >= 30 && !state.stoppedAlertSent) { if (diff.inMinutes >= 30 && !state.stoppedAlertSent) {
state.stoppedAlertSent = true; state.stoppedAlertSent = true;
_fireAndSaveAlert( _fireAndSave(event:NotifEvent.truckStopped, routeId:state.routeId,
event: NotifEvent.truckStopped, routeId: state.routeId,
title:'⚠️ Camión detenido', title:'⚠️ Camión detenido',
body: 'El camión ${state.routeId} lleva +30 min sin moverse.', body:'El camión ${state.routeId} lleva +30 min sin moverse. Verifica.',
tipo: 'CAMION_DETENIDO', tipo:'CAMION_DETENIDO');
);
} }
} }
} }
void _checkNotification(SimulatorState state, RouteModel route) { void _checkNotification(SimulatorState state, RouteModel route) {
if (state.positionIndex == 1) { final idx = state.positionIndex;
final total = route.positions.length;
if (idx == 1) {
// Ruta iniciada
_fireNotif(NotifEvent.routeStart, '¡Ruta Iniciada! 🚛', _fireNotif(NotifEvent.routeStart, '¡Ruta Iniciada! 🚛',
'El camión ha salido del Relleno Sanitario rumbo a tu sector.', state.routeId); 'El camión ha salido del Relleno Sanitario rumbo a tu sector. '
} else if (state.positionIndex == 3) { 'Prepara tus bolsas pero espera la señal para sacarlas.', state.routeId);
_fireNotif(NotifEvent.truckProximity, 'Camión Cercano ⚠️', } else if (idx == 2) {
'El camión está a menos de 15 minutos. ¡Saca tus bolsas!', state.routeId); // ~30 min — aviso preventivo
} else if (state.positionIndex == route.positions.length - 1) { _fireNotif(NotifEvent.truckApproaching15min, '🕐 El camión se acerca',
_fireNotif(NotifEvent.routeCompleted, 'Servicio Finalizado 🏁', 'Tu camión recolector está en camino. Tendrás otro aviso cuando esté a '
'El camión de ${state.routeId} concluyó su jornada.', state.routeId); '15 minutos. ⚠️ No saques la basura todavía — espera el aviso.', state.routeId);
} else if (idx == 3) {
// ~15 min — MOMENTO de sacar la basura
_fireNotif(NotifEvent.truckProximity, '⚠️ ¡Saca tus bolsas AHORA!',
'El camión llega en aprox. 15 minutos a tu colonia. '
'Este es el momento de sacar tus bolsas a la acera. '
'🚫 No persigas ni interceptes la unidad.', state.routeId);
} else if (idx == total - 2) {
// Pasando por la zona
_fireNotif(NotifEvent.truckProximity, '✅ El camión está en tu zona',
'El camión recolector está pasando por tu colonia. '
'Si ya sacaste tus bolsas, el servicio está en curso.', state.routeId);
} else if (idx == total - 1) {
// Servicio finalizado → prompt de reseña
state.reviewPromptSent = true;
_fireNotif(NotifEvent.reviewPrompt, '🌟 ¿Cómo fue el servicio?',
'¡El camión concluyó su jornada! Ayúdanos calificando el servicio '
'de recolección de hoy. Tu opinión mejora el servicio.', state.routeId);
} }
} }
@@ -122,15 +146,13 @@ class RouteSimulatorService extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> _fireAndSaveAlert({required NotifEvent event, required String routeId, Future<void> _fireAndSave({required NotifEvent event, required String routeId,
required String title, required String body, required String tipo}) async { required String title, required String body, required String tipo}) async {
_fireNotif(event, title, body, routeId); _fireNotif(event, title, body, routeId);
await DbHelper.insertAlerta(AlertaModel( await DbHelper.insertAlerta(AlertaModel(tipo:tipo, routeId:routeId,
tipo: tipo, routeId: routeId, mensaje: body, mensaje:body, fecha:DateTime.now().toIso8601String()));
fecha: DateTime.now().toIso8601String()));
} }
// Notificación manual (admin cancela, retrasa ruta, etc.)
void fireCustomNotification(String title, String body, String routeId, NotifEvent event) { void fireCustomNotification(String title, String body, String routeId, NotifEvent event) {
_fireNotif(event, title, body, routeId); _fireNotif(event, title, body, routeId);
} }
@@ -140,12 +162,10 @@ class RouteSimulatorService extends ChangeNotifier {
final state = _states[routeId]; final state = _states[routeId];
if (state == null) return; if (state == null) return;
state.gpsActive = false; state.gpsActive = false;
await _fireAndSaveAlert( await _fireAndSave(event:NotifEvent.gpsLost, routeId:routeId,
event: NotifEvent.gpsLost, routeId: routeId,
title:'📡 GPS Desactivado', title:'📡 GPS Desactivado',
body:'Se perdió la señal GPS del camión $routeId.', body:'Se perdió la señal GPS del camión $routeId.',
tipo: 'GPS_PERDIDO', tipo:'GPS_PERDIDO');
);
notifyListeners(); notifyListeners();
} }
@@ -161,6 +181,13 @@ class RouteSimulatorService extends ChangeNotifier {
SimulatorState? getState(String routeId) => _states[routeId]; SimulatorState? getState(String routeId) => _states[routeId];
int getPositionIndex(String routeId) => _states[routeId]?.positionIndex ?? 0; int getPositionIndex(String routeId) => _states[routeId]?.positionIndex ?? 0;
bool isTruckClose(String routeId) => getPositionIndex(routeId) >= 3; bool isTruckClose(String routeId) => getPositionIndex(routeId) >= 3;
bool isRouteCompleted(String routeId) {
final state = _states[routeId];
if (state == null) return false;
final route = getRouteById(routeId);
if (route == null) return false;
return state.positionIndex >= route.positions.length - 1;
}
bool isGpsActive(String routeId) => _states[routeId]?.gpsActive ?? true; bool isGpsActive(String routeId) => _states[routeId]?.gpsActive ?? true;
String getEtaText(String routeId) { String getEtaText(String routeId) {
@@ -173,27 +200,28 @@ class RouteSimulatorService extends ChangeNotifier {
if (idx >= route.positions.length) return '✅ Servicio finalizado'; if (idx >= route.positions.length) return '✅ Servicio finalizado';
switch (idx) { switch (idx) {
case 0: return '🕐 Ruta por iniciar'; case 0: return '🕐 Ruta por iniciar';
case 1: return '🚛 Camión en camino'; case 1: return '🚛 Camión en camino — mantén tus bolsas adentro';
case 2: return '🚛 Aprox. 30 min para llegar'; case 2: return '🚛 Aprox. 30 min — espera el aviso de 15 min';
case 3: return '⚠️ Menos de 15 min — ¡Saca tus bolsas!'; case 3: return '⚠️ ¡15 min! Saca tus bolsas a la acera ahora';
case 4: return '🔔 El camión está en tu zona'; case 4: return '🔔 El camión está en tu colonia';
case 5: return 'Pasando por tu colonia'; case 5: return 'Recogiendo basura en tu zona';
case 6: return '↩️ Regresando al relleno'; case 6: return '↩️ Regresando al relleno sanitario';
default: return '🏁 Servicio del día finalizado'; default: return '🏁 Servicio del día finalizado';
} }
} }
void dismissNotification() { void dismissNotification() { _lastNotification = null; notifyListeners(); }
_lastNotification = null;
notifyListeners();
}
void dismissRouteNotification(String routeId) { void dismissRouteNotification(String routeId) {
if (_lastNotification?.routeId == routeId) { if (_lastNotification?.routeId == routeId) {
_lastNotification = null; _lastNotification = null;
notifyListeners(); notifyListeners();
} }
} }
void clearReviewPrompt(String routeId) {
final state = _states[routeId];
if (state != null) state.reviewPromptSent = false;
notifyListeners();
}
@override @override
void dispose() { void dispose() {