diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c9f60b8..b0a631c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -15,7 +15,7 @@ android { defaultConfig { applicationId = "com.example.celaya_limpia" - minSdk = flutter.minSdkVersion + minSdk = 21 targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName diff --git a/assets/models/labels.txt b/assets/models/labels.txt index 476e586..31a15a5 100644 --- a/assets/models/labels.txt +++ b/assets/models/labels.txt @@ -1,2 +1,2 @@ -Orgánico -Inorgánico \ No newline at end of file +Organico +Inorganico diff --git a/lib/data/celaya_colonias.dart b/lib/data/celaya_colonias.dart new file mode 100644 index 0000000..2e370b9 --- /dev/null +++ b/lib/data/celaya_colonias.dart @@ -0,0 +1,243 @@ +// 240 colonias oficiales de Celaya, Guanajuato +const List 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', +]; \ No newline at end of file diff --git a/lib/database/db_helper.dart b/lib/database/db_helper.dart index 41ac9ce..e967dd7 100644 --- a/lib/database/db_helper.dart +++ b/lib/database/db_helper.dart @@ -1,7 +1,6 @@ import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart'; import '../models/models.dart'; -import '../models/route_model.dart'; class DbHelper { static Database? _db; @@ -12,47 +11,75 @@ class DbHelper { } static Future _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); } static Future _onCreate(Database db, int v) async { + // Usuarios await db.execute('''CREATE TABLE users( - id INTEGER PRIMARY KEY AUTOINCREMENT, nombre TEXT NOT NULL, - email TEXT UNIQUE NOT NULL, password TEXT NOT NULL, rol TEXT NOT NULL)'''); + id INTEGER PRIMARY KEY AUTOINCREMENT, + 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( - id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, - calle TEXT NOT NULL, colonia TEXT NOT NULL, route_id TEXT NOT NULL, - horario_estimado TEXT NOT NULL, is_primary INTEGER DEFAULT 1)'''); + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, alias TEXT DEFAULT 'Casa', + 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( - id INTEGER PRIMARY KEY AUTOINCREMENT, conductor_id INTEGER NOT NULL, - route_id TEXT NOT NULL, dia_semana TEXT NOT NULL, turno TEXT NOT NULL)'''); + // Definiciones de rutas creadas por admin + await db.execute('''CREATE TABLE route_definitions( + 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( route_id TEXT PRIMARY KEY, status TEXT NOT NULL, 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( - id INTEGER PRIMARY KEY AUTOINCREMENT, tipo TEXT NOT NULL, - route_id TEXT NOT NULL, mensaje TEXT NOT NULL, - fecha TEXT NOT NULL, resuelta INTEGER DEFAULT 0)'''); + id INTEGER PRIMARY KEY AUTOINCREMENT, + tipo TEXT NOT NULL, route_id TEXT NOT NULL, + mensaje TEXT NOT NULL, fecha TEXT NOT NULL, + resuelta INTEGER DEFAULT 0)'''); + // Reportes ciudadanos await db.execute('''CREATE TABLE reportes( - id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, - tipo TEXT NOT NULL, descripcion TEXT NOT NULL, colonia TEXT NOT NULL, - route_id TEXT, fecha TEXT NOT NULL, estado TEXT DEFAULT 'PENDIENTE', - calificacion INTEGER DEFAULT 5)'''); + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, tipo TEXT NOT NULL, + descripcion TEXT NOT NULL, colonia TEXT NOT NULL, + 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', 'password':'admin123','rol':'ADMINISTRADOR'}); await db.insert('users', {'nombre':'Juan Conductor','email':'conductor@celaya.gob.mx', 'password':'conductor123','rol':'CONDUCTOR'}); } - // ── USERS ────────────────────────────────────────────────────────────── + // ── USERS ──────────────────────────────────────────────────────────────── static Future insertUser(UserModel u) async => (await database).insert('users', u.toMap(), conflictAlgorithm: ConflictAlgorithm.abort); @@ -71,49 +98,71 @@ class DbHelper { return res.map((m) => UserModel.fromMap(m)).toList(); } - // ── DOMICILIOS ───────────────────────────────────────────────────────── - static Future insertDomicilio(DomicilioModel d) async => - (await database).insert('domicilios', d.toMap()); + // ── DOMICILIOS ─────────────────────────────────────────────────────────── + static Future insertDomicilio(DomicilioModel d) async { + 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> 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 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]); + if (res.isEmpty) { + res = await db.query('domicilios', where:'user_id=?', whereArgs:[userId], limit:1); + } return res.isEmpty ? null : DomicilioModel.fromMap(res.first); } - // ── ASIGNACIONES ─────────────────────────────────────────────────────── - static Future upsertAsignacion(AssignmentModel a) async { + static Future setPrimaryDomicilio(int domId, int userId) 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]); - } + await db.update('domicilios', {'is_primary':0}, where:'user_id=?', whereArgs:[userId]); + await db.update('domicilios', {'is_primary':1}, where:'id=?', whereArgs:[domId]); } - static Future> getAsignacionesByConductor(int conductorId) async { - final res = await (await database).query('asignaciones', - where:'conductor_id=?', whereArgs:[conductorId]); - return res.map((m) => AssignmentModel.fromMap(m)).toList(); + static Future deleteDomicilio(int id) async => + (await database).delete('domicilios', where:'id=?', whereArgs:[id]); + + static Future> 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> getAllAsignaciones() async { - final res = await (await database).query('asignaciones'); - return res.map((m) => AssignmentModel.fromMap(m)).toList(); + // ── ROUTE DEFINITIONS ──────────────────────────────────────────────────── + static Future insertRouteDefinition(RouteDefinitionModel r) async => + (await database).insert('route_definitions', r.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace); + + static Future> getAllRouteDefinitions() async { + final res = await (await database).query('route_definitions', orderBy:'route_id ASC'); + return res.map((m) => RouteDefinitionModel.fromMap(m)).toList(); } - // ── ROUTE STATUS ─────────────────────────────────────────────────────── - static Future upsertRouteStatus(RouteStatusModel s) async { - final db = await database; - await db.insert('route_status', s.toMap(), - conflictAlgorithm: ConflictAlgorithm.replace); + static Future 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 updateRouteDefinition(RouteDefinitionModel r) async => + (await database).update('route_definitions', r.toMap(), + where:'route_id=?', whereArgs:[r.routeId]); + + // ── ROUTE STATUS ───────────────────────────────────────────────────────── + static Future upsertRouteStatus(RouteStatusModel s) async => + (await database).insert('route_status', s.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace); + static Future getRouteStatus(String routeId) async { final res = await (await database).query('route_status', where:'route_id=?', whereArgs:[routeId]); @@ -125,11 +174,35 @@ class DbHelper { return res.map((m) => RouteStatusModel.fromMap(m)).toList(); } - // ── ALERTAS ──────────────────────────────────────────────────────────── + // ── ASIGNACIONES ───────────────────────────────────────────────────────── + static Future 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> 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> getAllAsignaciones() async { + final res = await (await database).query('asignaciones'); + return res.map((m) => AssignmentModel.fromMap(m)).toList(); + } + + // ── ALERTAS ────────────────────────────────────────────────────────────── static Future insertAlerta(AlertaModel a) async => (await database).insert('alertas', a.toMap()); - static Future> getAlertas({bool soloNoResueltas = false}) async { + static Future> getAlertas({bool soloNoResueltas=false}) async { final db = await database; final res = soloNoResueltas ? await db.query('alertas', where:'resuelta=0', orderBy:'fecha DESC') @@ -137,49 +210,70 @@ class DbHelper { return res.map((m) => AlertaModel.fromMap(m)).toList(); } + static Future> 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 resolverAlerta(int id) async => (await database).update('alertas', {'resuelta':1}, where:'id=?', whereArgs:[id]); - // ── REPORTES ─────────────────────────────────────────────────────────── + // ── REPORTES ───────────────────────────────────────────────────────────── static Future insertReporte(ReporteModel r) async => (await database).insert('reportes', r.toMap()); + static Future> getAllReportes() async { + final res = await (await database).query('reportes', orderBy:'fecha DESC'); + return res.map((m) => ReporteModel.fromMap(m)).toList(); + } + static Future> getReportesByUser(int userId) async { final res = await (await database).query('reportes', where:'user_id=?', whereArgs:[userId], orderBy:'fecha DESC'); return res.map((m) => ReporteModel.fromMap(m)).toList(); } - static Future> getAllReportes() async { - final res = await (await database).query('reportes', orderBy:'fecha DESC'); - return res.map((m) => ReporteModel.fromMap(m)).toList(); + static Future>> getReportesConUsuario() async { + final db = await database; + 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 updateReporteEstado(int id, String estado) async => (await database).update('reportes', {'estado':estado}, where:'id=?', whereArgs:[id]); - // ── REPORTES CON INFO DE USUARIO ────────────────────────────────────── - static Future>> getReportesConUsuario() async { + // ── REVIEWS ────────────────────────────────────────────────────────────── + static Future insertReview(ReviewModel r) async => + (await database).insert('reviews', r.toMap()); + + static Future> getAllReviews() async { + final res = await (await database).query('reviews', orderBy:'fecha DESC'); + return res.map((m) => ReviewModel.fromMap(m)).toList(); + } + + static Future 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>> getReviewSummaryByColonia() async { final db = await database; 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 - '''); - } - - // ── INCIDENTES CONDUCTOR ─────────────────────────────────────────────── - static Future> 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> getDomiciliosByRoute(String routeId) async { - final res = await (await database).query('domicilios', - where: 'route_id = ?', whereArgs: [routeId]); - return res.map((m) => DomicilioModel.fromMap(m)).toList(); + SELECT colonia, route_id, + AVG(estrellas) as promedio, + COUNT(*) as total, + MIN(estrellas) as min_est, + MAX(estrellas) as max_est + FROM reviews + GROUP BY colonia + ORDER BY promedio ASC'''); } } diff --git a/lib/models/models.dart b/lib/models/models.dart index 5dc4de5..4d9de8a 100644 --- a/lib/models/models.dart +++ b/lib/models/models.dart @@ -4,60 +4,120 @@ class UserModel { final String nombre; final String email; final String password; - final String rol; // CIUDADANO | CONDUCTOR | ADMINISTRADOR + final String rol; UserModel({this.id, required this.nombre, required this.email, required this.password, required this.rol}); Map toMap() => - {'id': id, 'nombre': nombre, 'email': email, 'password': password, 'rol': rol}; + {'id':id,'nombre':nombre,'email':email,'password':password,'rol':rol}; factory UserModel.fromMap(Map m) => UserModel( - id: m['id'], nombre: m['nombre'], email: m['email'], - password: m['password'], rol: m['rol']); + id:m['id'], nombre:m['nombre'], email:m['email'], + password:m['password'], rol:m['rol']); } -// ── DOMICILIO (citizen) ─────────────────────────────────────────────────── +// ── DOMICILIO (User → Domicilio → Zona → Ruta) ──────────────────────────── class DomicilioModel { final int? id; final int userId; + final String alias; // "Casa", "Trabajo", etc. final String calle; - final String colonia; - final String routeId; + final String colonia; // Zona de cobertura + final String routeId; // Ruta asignada final String horarioEstimado; final bool isPrimary; - DomicilioModel({this.id, required this.userId, required this.calle, - required this.colonia, required this.routeId, + DomicilioModel({this.id, required this.userId, this.alias = 'Casa', + required this.calle, required this.colonia, required this.routeId, required this.horarioEstimado, this.isPrimary = true}); - Map toMap() => {'id': id, 'user_id': userId, 'calle': calle, - 'colonia': colonia, 'route_id': routeId, - 'horario_estimado': horarioEstimado, 'is_primary': isPrimary ? 1 : 0}; + Map toMap() => {'id':id,'user_id':userId,'alias':alias, + 'calle':calle,'colonia':colonia,'route_id':routeId, + 'horario_estimado':horarioEstimado,'is_primary':isPrimary?1:0}; factory DomicilioModel.fromMap(Map m) => DomicilioModel( - id: m['id'], userId: m['user_id'], calle: m['calle'], - colonia: m['colonia'], routeId: m['route_id'], - horarioEstimado: m['horario_estimado'], isPrimary: m['is_primary'] == 1); + id:m['id'], userId:m['user_id'], alias:m['alias']??'Casa', + calle:m['calle'], colonia:m['colonia'], routeId:m['route_id'], + 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 dias; // ['LUNES','MIERCOLES','VIERNES'] + final String horaInicio; // '06:00' + final String horaFin; // '08:00' + final String turno; // MATUTINO|VESPERTINO|NOCTURNO + final List 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 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 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 toMap() => {'id':id,'user_id':userId,'colonia':colonia, + 'route_id':routeId,'estrellas':estrellas,'comentario':comentario, + 'fecha':fecha,'nombre_usuario':nombreUsuario}; + + factory ReviewModel.fromMap(Map 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 { final int? id; final int conductorId; final String routeId; final String diaSemana; - final String turno; // MATUTINO | VESPERTINO | NOCTURNO + final String turno; AssignmentModel({this.id, required this.conductorId, required this.routeId, required this.diaSemana, required this.turno}); - Map toMap() => {'id': id, 'conductor_id': conductorId, - 'route_id': routeId, 'dia_semana': diaSemana, 'turno': turno}; + Map toMap() => {'id':id,'conductor_id':conductorId, + 'route_id':routeId,'dia_semana':diaSemana,'turno':turno}; factory AssignmentModel.fromMap(Map m) => AssignmentModel( - id: m['id'], conductorId: m['conductor_id'], routeId: m['route_id'], - diaSemana: m['dia_semana'], turno: m['turno']); + id:m['id'], conductorId:m['conductor_id'], routeId:m['route_id'], + diaSemana:m['dia_semana'], turno:m['turno']); } // ── ROUTE STATUS ────────────────────────────────────────────────────────── @@ -70,32 +130,32 @@ class RouteStatusModel { RouteStatusModel({required this.routeId, required this.status, this.mensaje, required this.updatedAt}); - Map toMap() => {'route_id': routeId, 'status': status, - 'mensaje': mensaje, 'updated_at': updatedAt}; + Map toMap() => {'route_id':routeId,'status':status, + 'mensaje':mensaje,'updated_at':updatedAt}; factory RouteStatusModel.fromMap(Map m) => RouteStatusModel( - routeId: m['route_id'], status: m['status'], - mensaje: m['mensaje'], updatedAt: m['updated_at']); + routeId:m['route_id'], status:m['status'], + mensaje:m['mensaje'], updatedAt:m['updated_at']); } // ── ALERTA ──────────────────────────────────────────────────────────────── class AlertaModel { final int? id; - final String tipo; // GPS_PERDIDO | CAMION_DETENIDO | FALLA_MECANICA + final String tipo; final String routeId; final String mensaje; final String fecha; final bool resuelta; AlertaModel({this.id, required this.tipo, required this.routeId, - required this.mensaje, required this.fecha, this.resuelta = false}); + required this.mensaje, required this.fecha, this.resuelta=false}); - Map toMap() => {'id': id, 'tipo': tipo, 'route_id': routeId, - 'mensaje': mensaje, 'fecha': fecha, 'resuelta': resuelta ? 1 : 0}; + Map toMap() => {'id':id,'tipo':tipo,'route_id':routeId, + 'mensaje':mensaje,'fecha':fecha,'resuelta':resuelta?1:0}; factory AlertaModel.fromMap(Map m) => AlertaModel( - id: m['id'], tipo: m['tipo'], routeId: m['route_id'], - mensaje: m['mensaje'], fecha: m['fecha'], resuelta: m['resuelta'] == 1); + id:m['id'], tipo:m['tipo'], routeId:m['route_id'], + mensaje:m['mensaje'], fecha:m['fecha'], resuelta:m['resuelta']==1); } // ── REPORTE ─────────────────────────────────────────────────────────────── @@ -112,15 +172,15 @@ class ReporteModel { ReporteModel({this.id, required this.userId, required this.tipo, required this.descripcion, required this.colonia, required this.routeId, - required this.fecha, this.estado = 'PENDIENTE', this.calificacion = 5}); + required this.fecha, this.estado='PENDIENTE', this.calificacion=5}); - Map toMap() => {'id': id, 'user_id': userId, 'tipo': tipo, - 'descripcion': descripcion, 'colonia': colonia, 'route_id': routeId, - 'fecha': fecha, 'estado': estado, 'calificacion': calificacion}; + Map toMap() => {'id':id,'user_id':userId,'tipo':tipo, + 'descripcion':descripcion,'colonia':colonia,'route_id':routeId, + 'fecha':fecha,'estado':estado,'calificacion':calificacion}; factory ReporteModel.fromMap(Map m) => ReporteModel( - id: m['id'], userId: m['user_id'], tipo: m['tipo'], - descripcion: m['descripcion'], colonia: m['colonia'], - routeId: m['route_id'] ?? '', fecha: m['fecha'], - estado: m['estado'], calificacion: m['calificacion'] ?? 5); + id:m['id'], userId:m['user_id'], tipo:m['tipo'], + descripcion:m['descripcion'], colonia:m['colonia'], + routeId:m['route_id']??'', fecha:m['fecha'], + estado:m['estado'], calificacion:m['calificacion']??5); } diff --git a/lib/screens/admin/admin_dashboard_screen.dart b/lib/screens/admin/admin_dashboard_screen.dart index 2c07ffc..f8c2d25 100644 --- a/lib/screens/admin/admin_dashboard_screen.dart +++ b/lib/screens/admin/admin_dashboard_screen.dart @@ -6,6 +6,8 @@ import '../../services/route_simulator_service.dart'; import '../../database/db_helper.dart'; import '../../models/models.dart'; import '../../data/routes_data.dart'; +import '../../models/route_model.dart' show ColonyModel; +import 'create_route_screen.dart'; import '../../widgets/route_map_widget.dart'; class AdminDashboardScreen extends StatefulWidget { @@ -27,6 +29,8 @@ class _AdminDashboardScreenState extends State { _AdminReportesTab(), _AdminAssignmentsTab(), _AdminAlertasTab(sim:sim), + _AdminRoutesTab(), + _AdminReviewsTab(), ]; return Scaffold( @@ -51,6 +55,10 @@ class _AdminDashboardScreenState extends State { selectedIcon:Icon(Icons.calendar_month,color:AppColors.verdeAdmin),label:'Asignar'), NavigationDestination(icon:Icon(Icons.warning_outlined), selectedIcon:Icon(Icons.warning,color:AppColors.verdeAdmin),label:'Alertas'), + NavigationDestination(icon:Icon(Icons.route_outlined), + selectedIcon:Icon(Icons.route,color:AppColors.verdeAdmin),label:'Rutas'), + NavigationDestination(icon:Icon(Icons.star_outline), + selectedIcon:Icon(Icons.star,color:AppColors.verdeAdmin),label:'Reseñas'), ], ), ); @@ -784,3 +792,238 @@ class _AdminBanner extends StatelessWidget { IconButton(icon:const Icon(Icons.close,color:Colors.white,size:18),onPressed:onDismiss), ])))); } + +// ── TAB 6: Gestión de Rutas ─────────────────────────────────────────────── +class _AdminRoutesTab extends StatefulWidget { + @override State<_AdminRoutesTab> createState() => _AdminRoutesTabState(); +} + +class _AdminRoutesTabState extends State<_AdminRoutesTab> { + List _routes = []; + bool _loading = true; + + @override void initState() { super.initState(); _load(); } + + Future _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 _reviews = []; + List> _summary = []; + bool _showSummary = false; + bool _loading = true; + + @override void initState() { super.initState(); _load(); } + + Future _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)), + ], + ]))); + }); + } +} diff --git a/lib/screens/admin/create_route_screen.dart b/lib/screens/admin/create_route_screen.dart new file mode 100644 index 0000000..859ef5a --- /dev/null +++ b/lib/screens/admin/create_route_screen.dart @@ -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 createState() => _CreateRouteScreenState(); +} + +class _CreateRouteScreenState extends State { + final _nombreCtrl = TextEditingController(); + final _routeIdCtrl = TextEditingController(); + String _turno = 'MATUTINO'; + String _horaInicio = '06:00'; + String _horaFin = '08:00'; + List _diasSeleccionados = []; + List _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 get _filteredColonias => _searchColonia.isEmpty + ? celayaColonias + : celayaColonias.where((c) => + c.toLowerCase().contains(_searchColonia.toLowerCase())).toList(); + + Future _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 _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(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(); } +} diff --git a/lib/screens/citizen/add_domicilio_screen.dart b/lib/screens/citizen/add_domicilio_screen.dart new file mode 100644 index 0000000..2ef0534 --- /dev/null +++ b/lib/screens/citizen/add_domicilio_screen.dart @@ -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 createState() => _AddDomicilioScreenState(); +} + +class _AddDomicilioScreenState extends State { + 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 get _filteredColonias { + if (_searchQuery.isEmpty) return celayaColonias; + return celayaColonias + .where((c) => c.toLowerCase().contains(_searchQuery.toLowerCase())) + .toList(); + } + + Future _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(); + 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(); } +} diff --git a/lib/screens/citizen/citizen_home_screen.dart b/lib/screens/citizen/citizen_home_screen.dart index b2bf232..bf17b3d 100644 --- a/lib/screens/citizen/citizen_home_screen.dart +++ b/lib/screens/citizen/citizen_home_screen.dart @@ -1,14 +1,16 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../core/app_colors.dart'; -import '../../services/auth_service.dart'; -import '../../services/route_simulator_service.dart'; import '../../database/db_helper.dart'; import '../../models/models.dart'; +import '../../services/auth_service.dart'; +import '../../services/route_simulator_service.dart'; import '../../data/routes_data.dart'; import '../../widgets/route_map_widget.dart'; import 'citizen_guia_screen.dart'; import 'citizen_reporte_screen.dart'; +import 'add_domicilio_screen.dart'; +import 'review_screen.dart'; class CitizenHomeScreen extends StatefulWidget { const CitizenHomeScreen({super.key}); @@ -22,7 +24,7 @@ class _CitizenHomeScreenState extends State { Widget build(BuildContext context) { final auth = context.watch(); final sim = context.watch(); - final dom = auth.primaryDomicilio; // domicilio del ciudadano + final dom = auth.primaryDomicilio; final last = dom != null ? sim.getNotificationForRoute(dom.routeId) : null; final tabs = [ @@ -38,7 +40,8 @@ class _CitizenHomeScreenState extends State { if (last != null) Positioned( 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( @@ -47,19 +50,18 @@ class _CitizenHomeScreenState extends State { backgroundColor: Colors.white, indicatorColor: AppColors.guindaPrimary.withOpacity(0.15), destinations: const [ - NavigationDestination(icon: Icon(Icons.home_outlined), - selectedIcon: Icon(Icons.home, color: AppColors.guindaPrimary), label: 'Inicio'), - NavigationDestination(icon: Icon(Icons.eco_outlined), - selectedIcon: Icon(Icons.eco, color: AppColors.guindaPrimary), label: 'Guía'), - NavigationDestination(icon: Icon(Icons.report_outlined), - selectedIcon: Icon(Icons.report, color: AppColors.guindaPrimary), label: 'Reportar'), + NavigationDestination(icon:Icon(Icons.home_outlined), + selectedIcon:Icon(Icons.home,color:AppColors.guindaPrimary),label:'Inicio'), + NavigationDestination(icon:Icon(Icons.eco_outlined), + selectedIcon:Icon(Icons.eco,color:AppColors.guindaPrimary),label:'Guía'), + NavigationDestination(icon:Icon(Icons.report_outlined), + selectedIcon:Icon(Icons.report,color:AppColors.guindaPrimary),label:'Reportar'), ], ), ); } } -// ── Tab principal (StatefulWidget para cargar status de ruta) ───────────── class _HomeTab extends StatefulWidget { final AuthService auth; final RouteSimulatorService sim; @@ -69,98 +71,90 @@ class _HomeTab extends StatefulWidget { class _HomeTabState extends State<_HomeTab> { RouteStatusModel? _routeStatus; + RouteDefinitionModel? _routeDef; - @override - void initState() { - super.initState(); - _loadStatus(); - } + @override void initState() { super.initState(); _loadStatus(); } Future _loadStatus() async { final dom = widget.auth.primaryDomicilio; if (dom == null) return; 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 { final s = _routeStatus?.status ?? RouteStatus.enRuta; - return s == RouteStatus.cancelada || - s == RouteStatus.fallaMecanica || - s == RouteStatus.retrasada; + return s == RouteStatus.cancelada || s == RouteStatus.fallaMecanica || s == RouteStatus.retrasada; } @override Widget build(BuildContext context) { final dom = widget.auth.primaryDomicilio; + final allDoms = widget.auth.allDomicilios; final routeId = dom?.routeId ?? ''; final route = dom != null ? getRouteById(dom.routeId) : null; - final isTruckClose = widget.sim.isTruckClose(routeId); - final status = _routeStatus?.status ?? RouteStatus.enRuta; + final isTruckClose = widget.sim.isTruckClose(routeId); + final isCompleted = widget.sim.isRouteCompleted(routeId); + final needsReview = widget.sim.needsReviewPrompt(routeId); return RefreshIndicator( onRefresh: _loadStatus, child: CustomScrollView(slivers: [ - SliverAppBar( - expandedHeight: 120, pinned: true, + SliverAppBar(expandedHeight: 120, pinned: true, backgroundColor: AppColors.guindaPrimary, bottom: PreferredSize(preferredSize: const Size.fromHeight(4), child: Container(height: 4, color: AppColors.dorado)), - flexibleSpace: FlexibleSpaceBar( - background: Container( - color: AppColors.guindaPrimary, - padding: const EdgeInsets.fromLTRB(20, 50, 20, 16), - child: Row(children: [ - const Icon(Icons.delete_sweep_rounded, color: AppColors.dorado, size: 30), - const SizedBox(width: 12), - Expanded(child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('Hola, ${widget.auth.currentUser?.nombre.split(' ').first ?? ''}', - style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)), - const Text('Celaya Limpia', style: TextStyle(color: AppColors.dorado, fontSize: 12)), - ], - )), - IconButton( - icon: const Icon(Icons.logout, color: Colors.white70), - onPressed: () async { - await widget.auth.logout(); - if (context.mounted) Navigator.pushReplacementNamed(context, '/login'); - }, - ), - ]), - ), - ), + flexibleSpace: FlexibleSpaceBar(background: Container( + color: AppColors.guindaPrimary, + padding: const EdgeInsets.fromLTRB(20, 50, 20, 16), + child: Row(children: [ + const Icon(Icons.delete_sweep_rounded, color: AppColors.dorado, size: 30), + const SizedBox(width: 12), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, children: [ + Text('Hola, ${widget.auth.currentUser?.nombre.split(' ').first ?? ''}', + style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)), + const Text('Celaya Limpia', style: TextStyle(color: AppColors.dorado, fontSize: 12)), + ])), + IconButton(icon: const Icon(Icons.logout, color: Colors.white70), + onPressed: () async { + await widget.auth.logout(); + if (context.mounted) Navigator.pushReplacementNamed(context, '/login'); + }), + ]), + )), ), SliverPadding( padding: const EdgeInsets.all(16), sliver: SliverList(delegate: SliverChildListDelegate([ - // ── Si la ruta tiene problema → mostrar alerta en vez de ETA/mapa - if (_isRouteProblematic) ...[ - _RouteStatusBanner(status: _routeStatus!), - const SizedBox(height: 12), - ] else ...[ - // ETA Card normal + // ── Selector de domicilio ──────────────────────────────────── + if (allDoms.length > 1) _DomicilioSelector( + auth: widget.auth, onChanged: _loadStatus), + + // ── Prompt de calificación ─────────────────────────────────── + if (needsReview && dom != null) + _ReviewPromptCard(routeId: routeId, colonia: dom.colonia, + sim: widget.sim), + + // ── Estado de ruta (cancelada/falla/retrasada) ─────────────── + if (_isRouteProblematic) + _RouteStatusBanner(status: _routeStatus!) + else ...[ + // ETA Card _EtaCard(sim: widget.sim, routeId: routeId, dom: dom, route: route), const SizedBox(height: 12), - // Mapa solo cuando camión está cerca - if (isTruckClose && route != null) ...[ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.orange.shade50, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.orange.shade300), - ), - child: const Row(children: [ - 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))), - ]), - ), + + // Información detallada de la ruta (días y horario) + if (_routeDef != null) _RouteInfoCard(routeDef: _routeDef!), + if (_routeDef == null && dom != null) _BasicRouteInfo(dom: dom), + + const SizedBox(height: 12), + + // Mapa solo cuando camión está cerca (<15 min) + if (isTruckClose && route != null && !isCompleted) ...[ + _WarningNoPursue(), const SizedBox(height: 8), RouteMapWidget(route: route, simulator: widget.sim, height: 220), const SizedBox(height: 12), @@ -168,81 +162,17 @@ class _HomeTabState extends State<_HomeTab> { ], // Aviso privacidad - 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))), - ]), - ), + _PrivacyBanner(), const SizedBox(height: 12), - // Info domicilio - if (dom != null) - Card(child: Padding( - 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)), - ]), - )), + // Mis domicilios + _DomiciliosCard(auth: widget.auth), + const SizedBox(height: 12), // Historial notificaciones - if (widget.sim.history.isNotEmpty) ...[ - const SizedBox(height: 12), - 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), - ), - ]), - ); - }), - ]), - )), - ], + if (widget.sim.historyForRoute(routeId).isNotEmpty) + _HistorialCard(sim: widget.sim, routeId: routeId), + 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( + isExpanded: true, + value: auth.primaryDomicilio?.id, + icon: const Icon(Icons.swap_horiz, color: AppColors.guindaPrimary), + items: auth.allDomicilios.map((d) => DropdownMenuItem( + value: d.id, + child: Row(children: [ + Icon(d.isPrimary ? Icons.home : Icons.location_on_outlined, + color: AppColors.guindaPrimary, size: 16), + const SizedBox(width: 6), + Expanded(child: Text('${d.alias} — ${d.colonia}', + style: const TextStyle(fontSize: 13), overflow: TextOverflow.ellipsis)), + ]))).toList(), + onChanged: (id) async { + if (id != null) { + await DbHelper.setPrimaryDomicilio(id, auth.currentUser!.id!); + await auth.reloadDomicilios(); + onChanged(); + } + }, + ), + )); + } +} + +// ── Prompt de reseña ────────────────────────────────────────────────────── +class _ReviewPromptCard extends StatelessWidget { + final String routeId, colonia; final RouteSimulatorService sim; + const _ReviewPromptCard({required this.routeId, required this.colonia, required this.sim}); + + @override + Widget build(BuildContext context) => Card( + color: Colors.amber.shade50, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12), + side: BorderSide(color: Colors.amber.shade300, width: 1.5)), + child: Padding(padding: const EdgeInsets.all(14), child: Column(children: [ + const Row(children: [ + Text('⭐', style: TextStyle(fontSize: 24)), + SizedBox(width: 8), + Expanded(child: Text('¿Cómo estuvo el servicio de hoy?', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14))), + ]), + const SizedBox(height: 4), + const Text('El camión pasó por tu colonia. Toma un momento para calificar el servicio.', + style: TextStyle(fontSize: 12, color: AppColors.grisTexto)), + const SizedBox(height: 10), + Row(children: [ + Expanded(child: ElevatedButton.icon( + onPressed: () => Navigator.push(context, MaterialPageRoute( + builder: (_) => ReviewScreen(routeId: routeId, colonia: colonia))), + style: ElevatedButton.styleFrom(backgroundColor: Colors.amber, + foregroundColor: Colors.black87), + icon: const Icon(Icons.star, size: 16), + label: const Text('Calificar', style: TextStyle(fontWeight: FontWeight.bold)))), + const SizedBox(width: 8), + TextButton(onPressed: () => sim.clearReviewPrompt(routeId), + child: const Text('Después', style: TextStyle(color: AppColors.grisTexto))), + ]), + ]))); +} + +// ── Info detallada de la ruta ───────────────────────────────────────────── +class _RouteInfoCard extends StatelessWidget { + final RouteDefinitionModel routeDef; + const _RouteInfoCard({required this.routeDef}); + + @override + Widget build(BuildContext context) => Card( + child: Padding(padding: const EdgeInsets.all(14), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Row(children: [ + Icon(Icons.schedule, color: AppColors.guindaPrimary, size: 16), + SizedBox(width: 6), + Text('Información de tu ruta', style: TextStyle( + fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)), + ]), + const Divider(), + Text(routeDef.nombre, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13)), + const SizedBox(height: 4), + Row(children: [ + const Icon(Icons.access_time, size: 13, color: AppColors.grisTexto), + const SizedBox(width: 4), + Text('${routeDef.horaInicio} — ${routeDef.horaFin} (${_turnoLabel(routeDef.turno)})', + style: const TextStyle(fontSize: 12, color: AppColors.negroTexto)), + ]), + const SizedBox(height: 4), + Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Icon(Icons.calendar_today, size: 13, color: AppColors.grisTexto), + const SizedBox(width: 4), + Expanded(child: Text( + routeDef.dias.map(AppDias.label).join(', '), + style: const TextStyle(fontSize: 12, color: AppColors.negroTexto))), + ]), + ]))); + + String _turnoLabel(String t) => t=='MATUTINO'?'🌄 Matutino':t=='VESPERTINO'?'🌅 Vespertino':'🌙 Nocturno'; +} + +class _BasicRouteInfo extends StatelessWidget { + final DomicilioModel dom; + const _BasicRouteInfo({required this.dom}); + @override + Widget build(BuildContext context) => Card( + child: Padding(padding: const EdgeInsets.all(14), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Row(children: [ + Icon(Icons.schedule, color: AppColors.guindaPrimary, size: 16), + SizedBox(width: 6), + Text('Tu servicio de recolección', style: TextStyle( + fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)), + ]), + const Divider(), + Text('Ruta: ${dom.routeId}', style: const TextStyle(fontWeight: FontWeight.w600)), + Text('Horario: ${dom.horarioEstimado}', + style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)), + ]))); +} + +// ── Aviso anti-persecución ──────────────────────────────────────────────── +class _WarningNoPursue extends StatelessWidget { + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: Colors.red.shade50, borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.shade300)), + child: const Row(children: [ + Icon(Icons.warning_amber_rounded, color: AppColors.rojoError, size: 20), + SizedBox(width: 8), + Expanded(child: Text( + '⚠️ Ya es momento de sacar tu basura.\n' + '🚫 NO persigas ni interceptes el camión en movimiento.\n' + '✅ Coloca tus bolsas en la acera y espera.', + style: TextStyle(fontSize: 11, color: AppColors.rojoError, fontWeight: FontWeight.w500))), + ])); +} + +// ── Mis domicilios ──────────────────────────────────────────────────────── +class _DomiciliosCard extends StatelessWidget { + final AuthService auth; + const _DomiciliosCard({required this.auth}); + + @override + Widget build(BuildContext context) { + final doms = auth.allDomicilios; + return Card(child: Padding(padding: const EdgeInsets.all(14), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + const Icon(Icons.home_outlined, color: AppColors.guindaPrimary, size: 16), + const SizedBox(width: 6), + const Expanded(child: Text('Mis Domicilios', + style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.guindaPrimary))), + TextButton.icon( + onPressed: () async { + final result = await Navigator.push(context, + MaterialPageRoute(builder: (_) => const AddDomicilioScreen())); + if (result == true) await auth.reloadDomicilios(); + }, + icon: const Icon(Icons.add, size: 14), + label: const Text('Agregar', style: TextStyle(fontSize: 12)), + style: TextButton.styleFrom(foregroundColor: AppColors.guindaPrimary)), + ]), + const Divider(), + if (doms.isEmpty) + const Text('Sin domicilios registrados', + style: TextStyle(color: AppColors.grisTexto, fontSize: 12)) + else + ...doms.map((d) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row(children: [ + Icon(d.isPrimary ? Icons.home : Icons.location_on_outlined, + color: d.isPrimary ? AppColors.guindaPrimary : AppColors.grisTexto, size: 16), + const SizedBox(width: 8), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('${d.alias} — ${d.colonia}', + style: TextStyle(fontWeight: d.isPrimary ? FontWeight.bold : FontWeight.normal, + fontSize: 12)), + Text(d.calle, style: const TextStyle(color: AppColors.grisTexto, fontSize: 11)), + Text('${d.routeId} • ${d.horarioEstimado}', + style: const TextStyle(color: AppColors.grisTexto, fontSize: 10)), + ])), + if (!d.isPrimary) + IconButton(icon: const Icon(Icons.star_border, size: 16, color: AppColors.dorado), + tooltip: 'Hacer principal', + onPressed: () async { + await DbHelper.setPrimaryDomicilio(d.id!, auth.currentUser!.id!); + await auth.reloadDomicilios(); + }), + IconButton(icon: const Icon(Icons.edit_outlined, size: 14, color: AppColors.grisTexto), + onPressed: () async { + final result = await Navigator.push(context, MaterialPageRoute( + builder: (_) => AddDomicilioScreen(editing: d))); + if (result == true) await auth.reloadDomicilios(); + }), + if (!d.isPrimary) + IconButton(icon: const Icon(Icons.delete_outline, size: 14, color: AppColors.rojoError), + onPressed: () async { + await DbHelper.deleteDomicilio(d.id!); + await auth.reloadDomicilios(); + }), + ]))), + ]))); +} +} + // ── Banner de ruta con problema ─────────────────────────────────────────── class _RouteStatusBanner extends StatelessWidget { final RouteStatusModel status; @@ -261,188 +408,171 @@ class _RouteStatusBanner extends StatelessWidget { final isCancelled = status.status == RouteStatus.cancelada; final isFalla = status.status == RouteStatus.fallaMecanica; final isRetrasada = status.status == RouteStatus.retrasada; - - final color = isCancelled ? AppColors.rojoError - : isFalla ? Colors.red.shade800 - : AppColors.naranjaAlerta; - - final icon = isCancelled ? Icons.cancel - : isFalla ? Icons.build - : Icons.access_time; - + final color = isCancelled ? AppColors.rojoError : isFalla ? Colors.red.shade800 : AppColors.naranjaAlerta; + final icon = isCancelled ? Icons.cancel : isFalla ? Icons.build : Icons.access_time; final titulo = isCancelled ? '❌ Ruta Cancelada Hoy' - : isFalla ? '🔧 Falla Mecánica en Servicio' - : '⏱️ Servicio con Retraso'; - - 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.'; + : isFalla ? '🔧 Falla Mecánica en Servicio' : '⏱️ Servicio con Retraso'; return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Alerta principal - Container( - 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))], - ), + Container(width: double.infinity, padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(12)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Icon(icon, color: Colors.white, size: 26), const SizedBox(width: 10), - Expanded(child: Text(titulo, - style: const TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold))), + Expanded(child: Text(titulo, style: const TextStyle(color: Colors.white, + fontSize: 17, fontWeight: FontWeight.bold))), ]), - const SizedBox(height: 10), - Text(descripcion, style: const TextStyle(color: Colors.white, fontSize: 13, height: 1.4)), - ]), - ), - - // Mensaje del administrador (posible solución) + const SizedBox(height: 8), + Text(isCancelled + ? 'El servicio no se realizará hoy. Guarda tus residuos para mañana.' + : isFalla + ? 'El camión presentó una falla. El Ayuntamiento atiende la situación.' + : 'El camión presenta un retraso. El servicio se realizará con demora.', + style: const TextStyle(color: Colors.white, fontSize: 13)), + ])), if (status.mensaje != null && status.mensaje!.isNotEmpty) ...[ const SizedBox(height: 10), - Container( - width: double.infinity, - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: color.withOpacity(0.4)), - ), + Container(width: double.infinity, padding: const EdgeInsets.all(14), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withOpacity(0.4))), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Icon(Icons.admin_panel_settings, color: color, size: 16), const SizedBox(width: 6), - Text('Mensaje del Ayuntamiento', - style: TextStyle(fontWeight: FontWeight.bold, color: color, fontSize: 13)), + Text('Mensaje del Ayuntamiento', style: TextStyle( + fontWeight: FontWeight.bold, color: color, fontSize: 13)), ]), const SizedBox(height: 6), - Text(status.mensaje!, - style: const TextStyle(fontSize: 13, color: AppColors.negroTexto, height: 1.4)), - ]), - ), + Text(status.mensaje!, style: const TextStyle(fontSize: 13)), + ])), ], - - // Consejo ciudadano const SizedBox(height: 10), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey.shade300), - ), + Container(padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('💡 Recomendaciones:', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: AppColors.grisTexto)), + const Text('💡 Recomendaciones:', style: TextStyle(fontWeight: FontWeight.bold, + fontSize: 12, color: AppColors.grisTexto)), const SizedBox(height: 4), - if (isCancelled) - const Text('• Guarda tus bolsas en un lugar cerrado\n' - '• No dejes residuos en la acera\n' - '• Revisa la app mañana para el horario actualizado', - style: TextStyle(fontSize: 12, color: AppColors.grisTexto)), - if (isFalla) - const Text('• Espera confirmación del Ayuntamiento\n' - '• Puede enviarse una unidad de reemplazo\n' - '• 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)), - ]), - ), + Text(isCancelled + ? '• Guarda tus bolsas en lugar cerrado\n• No dejes residuos en la acera\n• Revisa la app mañana' + : isRetrasada + ? '• Espera el aviso de 15 minutos antes de sacar tu basura\n• El camión llegará eventualmente\n• Recibe la notificación en esta app' + : '• Espera confirmación del Ayuntamiento\n• Puede enviarse unidad de reemplazo', + style: const TextStyle(fontSize: 12, color: AppColors.grisTexto)), + ])), + const SizedBox(height: 12), ]); } } // ── ETA Card ────────────────────────────────────────────────────────────── class _EtaCard extends StatelessWidget { - final RouteSimulatorService sim; - final String routeId; - final dom; final route; + final RouteSimulatorService sim; final String routeId; final dom; final route; const _EtaCard({required this.sim, required this.routeId, required this.dom, required this.route}); @override Widget build(BuildContext context) => Container( decoration: BoxDecoration( - gradient: const LinearGradient(colors: [AppColors.guindaPrimary, AppColors.guindaDark], - begin: Alignment.topLeft, end: Alignment.bottomRight), + gradient: const LinearGradient(colors:[AppColors.guindaPrimary,AppColors.guindaDark], + begin:Alignment.topLeft,end:Alignment.bottomRight), borderRadius: BorderRadius.circular(14), - boxShadow: [BoxShadow(color: AppColors.guindaDark.withOpacity(0.4), - blurRadius: 8, offset: const Offset(0, 4))], - ), + boxShadow: [BoxShadow(color:AppColors.guindaDark.withOpacity(0.4),blurRadius:8,offset:const Offset(0,4))]), padding: const EdgeInsets.all(18), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - const Icon(Icons.local_shipping, color: AppColors.dorado, size: 22), - const SizedBox(width: 8), - Expanded(child: Text(route?.name ?? 'Ruta asignada', - style: const TextStyle(color: AppColors.dorado, fontSize: 13, fontWeight: FontWeight.w600))), + Row(children:[ + const Icon(Icons.local_shipping,color:AppColors.dorado,size:22), + const SizedBox(width:8), + Expanded(child:Text(route?.name??dom?.routeId??'Tu ruta', + style:const TextStyle(color:AppColors.dorado,fontSize:13,fontWeight:FontWeight.w600))), ]), - const SizedBox(height: 8), + const SizedBox(height:8), Text(sim.getEtaText(routeId), - style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - if (dom != null) - Text('⏰ ${dom.horarioEstimado}', - style: const TextStyle(color: Colors.white60, fontSize: 11)), - const SizedBox(height: 10), + style:const TextStyle(color:Colors.white,fontSize:16,fontWeight:FontWeight.bold)), + const SizedBox(height:6), + if (dom!=null) Text('⏰ ${dom.horarioEstimado}', + style:const TextStyle(color:Colors.white60,fontSize:11)), + const SizedBox(height:10), LinearProgressIndicator( - value: route != null - ? (sim.getPositionIndex(routeId) + 1) / route.positions.length : 0, - backgroundColor: Colors.white24, - valueColor: const AlwaysStoppedAnimation(AppColors.dorado), - ), - ]), - ); + value:route!=null?(sim.getPositionIndex(routeId)+1)/route.positions.length:0, + backgroundColor:Colors.white24, + valueColor:const AlwaysStoppedAnimation(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 { final AppNotification notif; final VoidCallback onDismiss; const _NotifBanner({required this.notif, required this.onDismiss}); @override Widget build(BuildContext context) { - final color = notif.event == NotifEvent.truckProximity - ? AppColors.naranjaAlerta - : notif.event == NotifEvent.routeCompleted - ? AppColors.verdeExito - : notif.event == NotifEvent.routeCancelled - ? AppColors.rojoError - : notif.event == NotifEvent.gpsLost - ? Colors.red.shade800 - : AppColors.azulInfo; - - return Material( - color: Colors.transparent, - child: Container( - margin: const EdgeInsets.all(12), - decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(12), - boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 8, offset: Offset(0, 4))]), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row(children: [ - const Icon(Icons.notifications_active, color: Colors.white, size: 24), - const SizedBox(width: 10), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, children: [ - Text(notif.title, style: const TextStyle(color: Colors.white, - fontWeight: FontWeight.bold, fontSize: 13)), - Text(notif.body, style: const TextStyle(color: Colors.white70, fontSize: 11), - maxLines: 2, overflow: TextOverflow.ellipsis), - ])), - IconButton(icon: const Icon(Icons.close, color: Colors.white, size: 18), - onPressed: onDismiss), - ]), - ), - ), - ); + final isUrgent = notif.event==NotifEvent.truckProximity||notif.event==NotifEvent.truckApproaching15min; + final isReview = notif.event==NotifEvent.reviewPrompt; + final color = isUrgent?AppColors.naranjaAlerta + :isReview?Colors.amber.shade700 + :notif.event==NotifEvent.routeCancelled?AppColors.rojoError + :notif.event==NotifEvent.gpsLost?Colors.red.shade800 + :AppColors.azulInfo; + return Material(color:Colors.transparent, + child:Container(margin:const EdgeInsets.all(12), + decoration:BoxDecoration(color:color,borderRadius:BorderRadius.circular(12), + boxShadow:const[BoxShadow(color:Colors.black26,blurRadius:8,offset:Offset(0,4))]), + child:Padding(padding:const EdgeInsets.all(12),child:Row(children:[ + Icon(isReview?Icons.star:Icons.notifications_active,color:Colors.white,size:24), + const SizedBox(width:10), + Expanded(child:Column(crossAxisAlignment:CrossAxisAlignment.start, + mainAxisSize:MainAxisSize.min,children:[ + Text(notif.title,style:const TextStyle(color:Colors.white,fontWeight:FontWeight.bold,fontSize:13)), + Text(notif.body,style:const TextStyle(color:Colors.white70,fontSize:11), + maxLines:2,overflow:TextOverflow.ellipsis), + ])), + IconButton(icon:const Icon(Icons.close,color:Colors.white,size:18),onPressed:onDismiss), + ])))); } } diff --git a/lib/screens/citizen/review_screen.dart b/lib/screens/citizen/review_screen.dart new file mode 100644 index 0000000..e65623c --- /dev/null +++ b/lib/screens/citizen/review_screen.dart @@ -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 createState() => _ReviewScreenState(); +} + +class _ReviewScreenState extends State { + 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 _enviar() async { + final auth = context.read(); + 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().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(); } +} diff --git a/lib/screens/register_screen.dart b/lib/screens/register_screen.dart index 570b6c7..173b3fe 100644 --- a/lib/screens/register_screen.dart +++ b/lib/screens/register_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../core/app_colors.dart'; import '../data/colonies_data.dart'; +import '../data/celaya_colonias.dart'; import '../models/route_model.dart'; import '../services/auth_service.dart'; @@ -67,8 +68,11 @@ class _RegisterScreenState extends State { border:OutlineInputBorder(),filled:true,fillColor:Colors.white), hint:const Text('Selecciona tu colonia'), value:_colony?.colonia, isExpanded:true, - items:colonyNames.map((n)=>DropdownMenuItem(value:n,child:Text(n,style:const TextStyle(fontSize:13)))).toList(), - onChanged:(v){ if(v!=null) setState(()=>_colony=getColonyByName(v)); }), + items:celayaColonias.map((n)=>DropdownMenuItem(value:n,child:Text(n,style:const TextStyle(fontSize:13)))).toList(), + onChanged:(v){ if(v!=null) setState((){ + _colony = getColonyByName(v) ?? ColonyModel( + colonia:v, routeId:'RUTA-01', horarioEstimado:'Matutino (06:00-08:00)'); +}); }), if (_colony!=null) ...[ const SizedBox(height:10), Container(padding:const EdgeInsets.all(12), diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index 305c93b..f406fe1 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -5,11 +5,13 @@ import '../database/db_helper.dart'; class AuthService extends ChangeNotifier { UserModel? _user; - DomicilioModel? _domicilio; + DomicilioModel? _primaryDomicilio; + List _allDomicilios = []; bool _loading = true; UserModel? get currentUser => _user; - DomicilioModel? get primaryDomicilio => _domicilio; + DomicilioModel? get primaryDomicilio => _primaryDomicilio; + List get allDomicilios => _allDomicilios; bool get isLoggedIn => _user != null; bool get loading => _loading; String get rol => _user?.rol ?? ''; @@ -21,22 +23,28 @@ class AuthService extends ChangeNotifier { final id = p.getInt('user_id'); if (id != null) { _user = await DbHelper.getUserById(id); - if (_user?.rol == 'CIUDADANO') { - _domicilio = await DbHelper.getPrimaryDomicilio(id); - } + if (_user?.rol == 'CIUDADANO') await reloadDomicilios(); } _loading = false; notifyListeners(); } + Future 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 login(String email, String password) async { final user = await DbHelper.getUserByEmail(email.trim().toLowerCase()); if (user == null) return 'Correo no registrado'; if (user.password != password) return 'Contraseña incorrecta'; _user = user; - if (user.rol == 'CIUDADANO') { - _domicilio = await DbHelper.getPrimaryDomicilio(user.id!); - } + if (user.rol == 'CIUDADANO') await reloadDomicilios(); final p = await SharedPreferences.getInstance(); await p.setInt('user_id', user.id!); notifyListeners(); @@ -51,10 +59,11 @@ class AuthService extends ChangeNotifier { final user = UserModel(nombre:nombre.trim(), email:email.trim().toLowerCase(), password:password, rol:'CIUDADANO'); final uid = await DbHelper.insertUser(user); - await DbHelper.insertDomicilio(DomicilioModel(userId:uid, calle:calle.trim(), - colonia:colonia, routeId:routeId, horarioEstimado:horarioEstimado)); + await DbHelper.insertDomicilio(DomicilioModel(userId:uid, alias:'Casa', + calle:calle.trim(), colonia:colonia, routeId:routeId, + horarioEstimado:horarioEstimado, isPrimary:true)); _user = await DbHelper.getUserById(uid); - _domicilio = await DbHelper.getPrimaryDomicilio(uid); + await reloadDomicilios(); final p = await SharedPreferences.getInstance(); await p.setInt('user_id', uid); notifyListeners(); @@ -62,7 +71,7 @@ class AuthService extends ChangeNotifier { } Future logout() async { - _user = null; _domicilio = null; + _user = null; _primaryDomicilio = null; _allDomicilios = []; final p = await SharedPreferences.getInstance(); await p.remove('user_id'); notifyListeners(); diff --git a/lib/services/route_simulator_service.dart b/lib/services/route_simulator_service.dart index 6ff1c5f..821fd68 100644 --- a/lib/services/route_simulator_service.dart +++ b/lib/services/route_simulator_service.dart @@ -5,13 +5,16 @@ import '../models/models.dart'; import '../data/routes_data.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 { final NotifEvent event; final String title; final String body; - final String routeId; // Para filtrar por usuario + final String routeId; final DateTime timestamp; AppNotification({required this.event, required this.title, required this.body, required this.routeId}) @@ -24,8 +27,10 @@ class SimulatorState { bool gpsActive; DateTime lastMoved; bool stoppedAlertSent; - SimulatorState({required this.routeId, this.positionIndex = 0, - this.gpsActive = true, required this.lastMoved, this.stoppedAlertSent = false}); + bool reviewPromptSent; + SimulatorState({required this.routeId, this.positionIndex=0, + this.gpsActive=true, required this.lastMoved, + this.stoppedAlertSent=false, this.reviewPromptSent=false}); } class RouteSimulatorService extends ChangeNotifier { @@ -33,40 +38,40 @@ class RouteSimulatorService extends ChangeNotifier { Timer? _globalTimer; Timer? _gpsMonitorTimer; - AppNotification? _lastNotification; // Admin ve todas + AppNotification? _lastNotification; final List _history = []; - // ── Getters ───────────────────────────────────────────────────────────── - // Admin: ve la última notificación global + // ── Getters ────────────────────────────────────────────────────────────── AppNotification? get lastNotification => _lastNotification; - // Ciudadano/Conductor: solo ve notificaciones de SU ruta + // Ciudadano/Conductor: solo su ruta AppNotification? getNotificationForRoute(String routeId) { if (_lastNotification?.routeId == routeId) return _lastNotification; return null; } List get history => List.unmodifiable(_history); - - // Historial filtrado por ruta List historyForRoute(String routeId) => _history.where((n) => n.routeId == routeId).toList(); + bool needsReviewPrompt(String routeId) => + _states[routeId]?.reviewPromptSent == true; + // ── Inicio ─────────────────────────────────────────────────────────────── void startAllRoutes() { for (final r in routesData) { - _states[r.routeId] = SimulatorState(routeId: r.routeId, lastMoved: DateTime.now()); + _states[r.routeId] = SimulatorState(routeId:r.routeId, lastMoved:DateTime.now()); } _globalTimer?.cancel(); - _globalTimer = Timer.periodic(const Duration(seconds: 30), (_) => _tick()); + _globalTimer = Timer.periodic(const Duration(seconds:30), (_) => _tick()); _gpsMonitorTimer?.cancel(); - _gpsMonitorTimer = Timer.periodic(const Duration(minutes: 5), (_) => _monitorGps()); + _gpsMonitorTimer = Timer.periodic(const Duration(minutes:5), (_) => _monitorGps()); notifyListeners(); } void startRoute(String routeId) { - _states[routeId] = SimulatorState(routeId: routeId, lastMoved: DateTime.now()); - _globalTimer ??= Timer.periodic(const Duration(seconds: 30), (_) => _tick()); + _states[routeId] = SimulatorState(routeId:routeId, lastMoved:DateTime.now()); + _globalTimer ??= Timer.periodic(const Duration(seconds:30), (_) => _tick()); notifyListeners(); } @@ -92,45 +97,62 @@ class RouteSimulatorService extends ChangeNotifier { final diff = DateTime.now().difference(state.lastMoved); if (diff.inMinutes >= 30 && !state.stoppedAlertSent) { state.stoppedAlertSent = true; - _fireAndSaveAlert( - event: NotifEvent.truckStopped, routeId: state.routeId, - title: '⚠️ Camión detenido', - body: 'El camión ${state.routeId} lleva +30 min sin moverse.', - tipo: 'CAMION_DETENIDO', - ); + _fireAndSave(event:NotifEvent.truckStopped, routeId:state.routeId, + title:'⚠️ Camión detenido', + body:'El camión ${state.routeId} lleva +30 min sin moverse. Verifica.', + tipo:'CAMION_DETENIDO'); } } } 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! 🚛', - 'El camión ha salido del Relleno Sanitario rumbo a tu sector.', state.routeId); - } else if (state.positionIndex == 3) { - _fireNotif(NotifEvent.truckProximity, 'Camión Cercano ⚠️', - 'El camión está a menos de 15 minutos. ¡Saca tus bolsas!', state.routeId); - } else if (state.positionIndex == route.positions.length - 1) { - _fireNotif(NotifEvent.routeCompleted, 'Servicio Finalizado 🏁', - 'El camión de ${state.routeId} concluyó su jornada.', state.routeId); + 'El camión ha salido del Relleno Sanitario rumbo a tu sector. ' + 'Prepara tus bolsas pero espera la señal para sacarlas.', state.routeId); + } else if (idx == 2) { + // ~30 min — aviso preventivo + _fireNotif(NotifEvent.truckApproaching15min, '🕐 El camión se acerca', + 'Tu camión recolector está en camino. Tendrás otro aviso cuando esté a ' + '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); } } void _fireNotif(NotifEvent event, String title, String body, String routeId) { - final n = AppNotification(event: event, title: title, body: body, routeId: routeId); + final n = AppNotification(event:event, title:title, body:body, routeId:routeId); _lastNotification = n; _history.insert(0, n); notifyListeners(); } - Future _fireAndSaveAlert({required NotifEvent event, required String routeId, + Future _fireAndSave({required NotifEvent event, required String routeId, required String title, required String body, required String tipo}) async { _fireNotif(event, title, body, routeId); - await DbHelper.insertAlerta(AlertaModel( - tipo: tipo, routeId: routeId, mensaje: body, - fecha: DateTime.now().toIso8601String())); + await DbHelper.insertAlerta(AlertaModel(tipo:tipo, routeId:routeId, + mensaje:body, fecha:DateTime.now().toIso8601String())); } - // Notificación manual (admin cancela, retrasa ruta, etc.) void fireCustomNotification(String title, String body, String routeId, NotifEvent event) { _fireNotif(event, title, body, routeId); } @@ -140,12 +162,10 @@ class RouteSimulatorService extends ChangeNotifier { final state = _states[routeId]; if (state == null) return; state.gpsActive = false; - await _fireAndSaveAlert( - event: NotifEvent.gpsLost, routeId: routeId, - title: '📡 GPS Desactivado', - body: 'Se perdió la señal GPS del camión $routeId.', - tipo: 'GPS_PERDIDO', - ); + await _fireAndSave(event:NotifEvent.gpsLost, routeId:routeId, + title:'📡 GPS Desactivado', + body:'Se perdió la señal GPS del camión $routeId.', + tipo:'GPS_PERDIDO'); notifyListeners(); } @@ -161,6 +181,13 @@ class RouteSimulatorService extends ChangeNotifier { SimulatorState? getState(String routeId) => _states[routeId]; int getPositionIndex(String routeId) => _states[routeId]?.positionIndex ?? 0; 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; String getEtaText(String routeId) { @@ -173,27 +200,28 @@ class RouteSimulatorService extends ChangeNotifier { if (idx >= route.positions.length) return '✅ Servicio finalizado'; switch (idx) { case 0: return '🕐 Ruta por iniciar'; - case 1: return '🚛 Camión en camino'; - case 2: return '🚛 Aprox. 30 min para llegar'; - case 3: return '⚠️ Menos de 15 min — ¡Saca tus bolsas!'; - case 4: return '🔔 El camión está en tu zona'; - case 5: return '✅ Pasando por tu colonia'; - case 6: return '↩️ Regresando al relleno'; + case 1: return '🚛 Camión en camino — mantén tus bolsas adentro'; + case 2: return '🚛 Aprox. 30 min — espera el aviso de 15 min'; + case 3: return '⚠️ ¡15 min! Saca tus bolsas a la acera ahora'; + case 4: return '🔔 El camión está en tu colonia'; + case 5: return '✅ Recogiendo basura en tu zona'; + case 6: return '↩️ Regresando al relleno sanitario'; default: return '🏁 Servicio del día finalizado'; } } - void dismissNotification() { - _lastNotification = null; - notifyListeners(); - } - + void dismissNotification() { _lastNotification = null; notifyListeners(); } void dismissRouteNotification(String routeId) { if (_lastNotification?.routeId == routeId) { _lastNotification = null; notifyListeners(); } } + void clearReviewPrompt(String routeId) { + final state = _states[routeId]; + if (state != null) state.reviewPromptSent = false; + notifyListeners(); + } @override void dispose() {