Avance de la aplicacion
This commit is contained in:
@@ -15,7 +15,7 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.example.celaya_limpia"
|
applicationId = "com.example.celaya_limpia"
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = 21
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
Orgánico
|
Organico
|
||||||
Inorgánico
|
Inorganico
|
||||||
|
|||||||
243
lib/data/celaya_colonias.dart
Normal file
243
lib/data/celaya_colonias.dart
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
// 240 colonias oficiales de Celaya, Guanajuato
|
||||||
|
const List<String> celayaColonias = [
|
||||||
|
'10 de Abril',
|
||||||
|
'10 de mayo',
|
||||||
|
'15 de Mayo',
|
||||||
|
'3 Guerras',
|
||||||
|
'Alameda',
|
||||||
|
'Álamos',
|
||||||
|
'Álamos Oriente',
|
||||||
|
'Alfredo Vázquez Bonfil',
|
||||||
|
'Américas del Bajío',
|
||||||
|
'Arboledas de Camargo',
|
||||||
|
'Arboledas del Campestre',
|
||||||
|
'Arcada Alameda',
|
||||||
|
'Baalam Residencial',
|
||||||
|
'Benito Juárez',
|
||||||
|
'Bosques de la Alameda',
|
||||||
|
'Bosques del Sol',
|
||||||
|
'Brisas del Carmen',
|
||||||
|
'Bugambilias',
|
||||||
|
'Calesa',
|
||||||
|
'Camargo',
|
||||||
|
'Campestre Celaya',
|
||||||
|
'Canal de Camargo',
|
||||||
|
'Canal de Labradores',
|
||||||
|
'Capitales de Europa',
|
||||||
|
'Celaya Centro',
|
||||||
|
'Ciudadela',
|
||||||
|
'Ciudad Industrial',
|
||||||
|
'Claustros de Arboledas',
|
||||||
|
'Conjunto Habitacional Girasoles',
|
||||||
|
'Cuauhtémoc',
|
||||||
|
'Del Bosque',
|
||||||
|
'Del Parque',
|
||||||
|
'Del Valle',
|
||||||
|
'Don Gu',
|
||||||
|
'Dos Plazas',
|
||||||
|
'Ejidal',
|
||||||
|
'El Atrio',
|
||||||
|
'El Campanario',
|
||||||
|
'El Campanario Residencial',
|
||||||
|
'El Cantar',
|
||||||
|
'El Dorado',
|
||||||
|
'El Haba',
|
||||||
|
'El Junco Residencial',
|
||||||
|
'El Olivar',
|
||||||
|
'El Panamericano',
|
||||||
|
'El Paraíso de los Ángeles',
|
||||||
|
'El Vergel',
|
||||||
|
'Emeteria Valencia',
|
||||||
|
'Emiliano Zapata',
|
||||||
|
'Emiliano Zapata Sur',
|
||||||
|
'Enrique Colunga',
|
||||||
|
'Esmeralda',
|
||||||
|
'Exelaris',
|
||||||
|
'Felipe Ángeles',
|
||||||
|
'Floresta del Sur',
|
||||||
|
'FOVISSSTE',
|
||||||
|
'Galaxias del Parque',
|
||||||
|
'Geo Villas Los Sauces',
|
||||||
|
'Gobernadores',
|
||||||
|
'Granada',
|
||||||
|
'Gran Clase',
|
||||||
|
'Guadalupe',
|
||||||
|
'Guanajuato',
|
||||||
|
'Hacienda del Bosque',
|
||||||
|
'Hacienda del Sol',
|
||||||
|
'Hidalgo',
|
||||||
|
'Imperial',
|
||||||
|
'Independencia',
|
||||||
|
'Industriales',
|
||||||
|
'Jacarandas',
|
||||||
|
'Jardines de Celaya 1a Secc',
|
||||||
|
'Jardines de Celaya 2a Secc',
|
||||||
|
'Jardines de Celaya 3a Secc',
|
||||||
|
'Jardines del Centro',
|
||||||
|
'Jardines del Sur',
|
||||||
|
'José Suárez Irigoyen',
|
||||||
|
'Juan Pablo II',
|
||||||
|
'Karina',
|
||||||
|
'La Campiña',
|
||||||
|
'La Capilla',
|
||||||
|
'La Cruz',
|
||||||
|
'La Escondida',
|
||||||
|
'La Favorita',
|
||||||
|
'La Fundación',
|
||||||
|
'La Herradura',
|
||||||
|
'La Joya',
|
||||||
|
'La Misión',
|
||||||
|
'La Purísima',
|
||||||
|
'Las Alamedas',
|
||||||
|
'Las Américas',
|
||||||
|
'Las Arboledas',
|
||||||
|
'Las Arenas',
|
||||||
|
'Las Aves',
|
||||||
|
'Las Brisas',
|
||||||
|
'Las Carretas',
|
||||||
|
'Las Casas',
|
||||||
|
'Las Delicias',
|
||||||
|
'Las Flores',
|
||||||
|
'Las Fuentes',
|
||||||
|
'Las Insurgentes',
|
||||||
|
'La Soledad',
|
||||||
|
'Latinoamericana',
|
||||||
|
'La Trinidad',
|
||||||
|
'Lázaro Cárdenas',
|
||||||
|
'Lindavista',
|
||||||
|
'López Portillo',
|
||||||
|
'Los Ángeles',
|
||||||
|
'Los Frailes',
|
||||||
|
'Los Impresionistas',
|
||||||
|
'Los Lagos',
|
||||||
|
'Los Laureles',
|
||||||
|
'Los Naranjos',
|
||||||
|
'Los Olivos Residencial',
|
||||||
|
'Los Pinos',
|
||||||
|
'Los Pirules',
|
||||||
|
'Los Pirules Don Gu',
|
||||||
|
'Los Portones',
|
||||||
|
'Los Santos',
|
||||||
|
'Los Sauces',
|
||||||
|
'Los Tules',
|
||||||
|
'Los Veintes',
|
||||||
|
'Magno Residencial',
|
||||||
|
'Mediterráneo',
|
||||||
|
'México',
|
||||||
|
'Miguel Alemán',
|
||||||
|
'Misión de La Esperanza',
|
||||||
|
'Misión Santa Fe',
|
||||||
|
'Moctezuma',
|
||||||
|
'Monte Blanco',
|
||||||
|
'Nat Tha Hi',
|
||||||
|
'Nueva Santa María',
|
||||||
|
'Nueva Terraza',
|
||||||
|
'Nuevo Celaya',
|
||||||
|
'Nuevo Tecnológico',
|
||||||
|
'Obrero Mundial',
|
||||||
|
'Oro',
|
||||||
|
'Palas Atenea',
|
||||||
|
'Palma Real',
|
||||||
|
'Parque Central',
|
||||||
|
'Parque Verde',
|
||||||
|
'Pedregal del Junco',
|
||||||
|
'Porta Maggiore',
|
||||||
|
'Portones de la Hacienda',
|
||||||
|
'Praderas del Bosque',
|
||||||
|
'Praderas de Santa Julia',
|
||||||
|
'Praderas de Santa Lucía',
|
||||||
|
'Prados el Naranjal',
|
||||||
|
'Privada Ciruelo',
|
||||||
|
'Privada del Pedregal',
|
||||||
|
'Privada del Real',
|
||||||
|
'Privada el Sauz',
|
||||||
|
'Progreso Solidaridad',
|
||||||
|
'Providencia',
|
||||||
|
'Puerta Grande',
|
||||||
|
'Puertas del Sol',
|
||||||
|
'Puertas de Santa María',
|
||||||
|
'Puesta del Sol',
|
||||||
|
'Punta Norte',
|
||||||
|
'Quinta Santa María',
|
||||||
|
'Raquet Club Cross',
|
||||||
|
'Real de Celaya',
|
||||||
|
'Real de San Antonio',
|
||||||
|
'Recursos Hidráulicos',
|
||||||
|
'Reforma',
|
||||||
|
'Reforma',
|
||||||
|
'Residencial Las Margaritas',
|
||||||
|
'Residencial Las Praderas',
|
||||||
|
'Residencial Paraíso',
|
||||||
|
'Residencial San Pablo',
|
||||||
|
'Residencial Santiago',
|
||||||
|
'Residencial Tecnológico',
|
||||||
|
'Residencial Xochipilli',
|
||||||
|
'Resurrección',
|
||||||
|
'Revolución',
|
||||||
|
'Rinconada del Bosque',
|
||||||
|
'Rinconada Laureles',
|
||||||
|
'Rinconada Los Álamos',
|
||||||
|
'Rinconada San Jorge',
|
||||||
|
'Rincón de Cantarranas',
|
||||||
|
'Riveras del Campestre',
|
||||||
|
'Rosalinda',
|
||||||
|
'San Andrés',
|
||||||
|
'San Antonio',
|
||||||
|
'San Antonio',
|
||||||
|
'San Francisco',
|
||||||
|
'San Gabriel',
|
||||||
|
'San José de Torres',
|
||||||
|
'San Juan',
|
||||||
|
'San Juan de Dios',
|
||||||
|
'San Juanico',
|
||||||
|
'San Juanico 1a Secc',
|
||||||
|
'San Juanico 2a Secc',
|
||||||
|
'San Martín de Camargo',
|
||||||
|
'San Miguel',
|
||||||
|
'San Rafael',
|
||||||
|
'San Román',
|
||||||
|
'Santa Anita',
|
||||||
|
'Santa Bárbara',
|
||||||
|
'Santa Cecilia',
|
||||||
|
'Santa Fe de los Naranjos',
|
||||||
|
'Santa Isabel',
|
||||||
|
'Santa María',
|
||||||
|
'Santa María',
|
||||||
|
'Santa Rita',
|
||||||
|
'Santa Teresa',
|
||||||
|
'Santiaguito',
|
||||||
|
'Suiza',
|
||||||
|
'Tahi',
|
||||||
|
'Tierras Negras',
|
||||||
|
'Tierra y Libertad',
|
||||||
|
'Tres Lunas',
|
||||||
|
'Valle de La Primavera',
|
||||||
|
'Valle de los Naranjos III Sección',
|
||||||
|
'Valle de los Naranjos II Sección',
|
||||||
|
'Valle del Real',
|
||||||
|
'Valle Hermoso',
|
||||||
|
'Valle Naranjos',
|
||||||
|
'Ventanales de Santa María',
|
||||||
|
'Villa Arbolada',
|
||||||
|
'Villa de Celaya',
|
||||||
|
'Villa de los Álamos',
|
||||||
|
'Villa de los Reyes',
|
||||||
|
'Villa Jardín',
|
||||||
|
'Villas de Benavente',
|
||||||
|
'Villas de Benavente II',
|
||||||
|
'Villas de La Esperanza',
|
||||||
|
'Villas de La Hacienda',
|
||||||
|
'Villas del Bajío',
|
||||||
|
'Villas del Palmar',
|
||||||
|
'Villas del Paraíso',
|
||||||
|
'Villas del Rocío',
|
||||||
|
'Villas del Romeral',
|
||||||
|
'Villas del Tenis',
|
||||||
|
'Villas Reales',
|
||||||
|
'Villas Vicenza',
|
||||||
|
'Viñas de La Herradura',
|
||||||
|
'Virgen del Refugio',
|
||||||
|
'Zempoala',
|
||||||
|
'Zona de Oro',
|
||||||
|
'Zona de Oro del Bajío',
|
||||||
|
];
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'package:sqflite/sqflite.dart';
|
import 'package:sqflite/sqflite.dart';
|
||||||
import 'package:path/path.dart';
|
import 'package:path/path.dart';
|
||||||
import '../models/models.dart';
|
import '../models/models.dart';
|
||||||
import '../models/route_model.dart';
|
|
||||||
|
|
||||||
class DbHelper {
|
class DbHelper {
|
||||||
static Database? _db;
|
static Database? _db;
|
||||||
@@ -12,47 +11,75 @@ class DbHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static Future<Database> _initDb() async {
|
static Future<Database> _initDb() async {
|
||||||
final path = join(await getDatabasesPath(), 'celaya_v2.db');
|
final path = join(await getDatabasesPath(), 'celaya_v3.db');
|
||||||
return openDatabase(path, version: 1, onCreate: _onCreate);
|
return openDatabase(path, version: 1, onCreate: _onCreate);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> _onCreate(Database db, int v) async {
|
static Future<void> _onCreate(Database db, int v) async {
|
||||||
|
// Usuarios
|
||||||
await db.execute('''CREATE TABLE users(
|
await db.execute('''CREATE TABLE users(
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT, nombre TEXT NOT NULL,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
email TEXT UNIQUE NOT NULL, password TEXT NOT NULL, rol TEXT NOT NULL)''');
|
nombre TEXT NOT NULL, email TEXT UNIQUE NOT NULL,
|
||||||
|
password TEXT NOT NULL, rol TEXT NOT NULL)''');
|
||||||
|
|
||||||
|
// Domicilios (User → Domicilio → Colonia/Zona → Ruta)
|
||||||
await db.execute('''CREATE TABLE domicilios(
|
await db.execute('''CREATE TABLE domicilios(
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
calle TEXT NOT NULL, colonia TEXT NOT NULL, route_id TEXT NOT NULL,
|
user_id INTEGER NOT NULL, alias TEXT DEFAULT 'Casa',
|
||||||
horario_estimado TEXT NOT NULL, is_primary INTEGER DEFAULT 1)''');
|
calle TEXT NOT NULL, colonia TEXT NOT NULL,
|
||||||
|
route_id TEXT NOT NULL, horario_estimado TEXT NOT NULL,
|
||||||
|
is_primary INTEGER DEFAULT 0)''');
|
||||||
|
|
||||||
await db.execute('''CREATE TABLE asignaciones(
|
// Definiciones de rutas creadas por admin
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT, conductor_id INTEGER NOT NULL,
|
await db.execute('''CREATE TABLE route_definitions(
|
||||||
route_id TEXT NOT NULL, dia_semana TEXT NOT NULL, turno TEXT NOT NULL)''');
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
route_id TEXT UNIQUE NOT NULL, nombre TEXT NOT NULL,
|
||||||
|
dias TEXT NOT NULL, hora_inicio TEXT NOT NULL,
|
||||||
|
hora_fin TEXT NOT NULL, turno TEXT NOT NULL,
|
||||||
|
colonias TEXT NOT NULL, activa INTEGER DEFAULT 1)''');
|
||||||
|
|
||||||
|
// Estado de rutas
|
||||||
await db.execute('''CREATE TABLE route_status(
|
await db.execute('''CREATE TABLE route_status(
|
||||||
route_id TEXT PRIMARY KEY, status TEXT NOT NULL,
|
route_id TEXT PRIMARY KEY, status TEXT NOT NULL,
|
||||||
mensaje TEXT, updated_at TEXT)''');
|
mensaje TEXT, updated_at TEXT)''');
|
||||||
|
|
||||||
|
// Asignaciones conductor
|
||||||
|
await db.execute('''CREATE TABLE asignaciones(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
conductor_id INTEGER NOT NULL, route_id TEXT NOT NULL,
|
||||||
|
dia_semana TEXT NOT NULL, turno TEXT NOT NULL)''');
|
||||||
|
|
||||||
|
// Alertas del sistema
|
||||||
await db.execute('''CREATE TABLE alertas(
|
await db.execute('''CREATE TABLE alertas(
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT, tipo TEXT NOT NULL,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
route_id TEXT NOT NULL, mensaje TEXT NOT NULL,
|
tipo TEXT NOT NULL, route_id TEXT NOT NULL,
|
||||||
fecha TEXT NOT NULL, resuelta INTEGER DEFAULT 0)''');
|
mensaje TEXT NOT NULL, fecha TEXT NOT NULL,
|
||||||
|
resuelta INTEGER DEFAULT 0)''');
|
||||||
|
|
||||||
|
// Reportes ciudadanos
|
||||||
await db.execute('''CREATE TABLE reportes(
|
await db.execute('''CREATE TABLE reportes(
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
tipo TEXT NOT NULL, descripcion TEXT NOT NULL, colonia TEXT NOT NULL,
|
user_id INTEGER NOT NULL, tipo TEXT NOT NULL,
|
||||||
route_id TEXT, fecha TEXT NOT NULL, estado TEXT DEFAULT 'PENDIENTE',
|
descripcion TEXT NOT NULL, colonia TEXT NOT NULL,
|
||||||
calificacion INTEGER DEFAULT 5)''');
|
route_id TEXT, fecha TEXT NOT NULL,
|
||||||
|
estado TEXT DEFAULT 'PENDIENTE', calificacion INTEGER DEFAULT 5)''');
|
||||||
|
|
||||||
// Seed: admin y conductor demo
|
// RESEÑAS del servicio
|
||||||
|
await db.execute('''CREATE TABLE reviews(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL, colonia TEXT NOT NULL,
|
||||||
|
route_id TEXT NOT NULL, estrellas INTEGER NOT NULL,
|
||||||
|
comentario TEXT NOT NULL, fecha TEXT NOT NULL,
|
||||||
|
nombre_usuario TEXT DEFAULT 'Ciudadano')''');
|
||||||
|
|
||||||
|
// Seed: admin y conductor
|
||||||
await db.insert('users', {'nombre':'Administrador','email':'admin@celaya.gob.mx',
|
await db.insert('users', {'nombre':'Administrador','email':'admin@celaya.gob.mx',
|
||||||
'password':'admin123','rol':'ADMINISTRADOR'});
|
'password':'admin123','rol':'ADMINISTRADOR'});
|
||||||
await db.insert('users', {'nombre':'Juan Conductor','email':'conductor@celaya.gob.mx',
|
await db.insert('users', {'nombre':'Juan Conductor','email':'conductor@celaya.gob.mx',
|
||||||
'password':'conductor123','rol':'CONDUCTOR'});
|
'password':'conductor123','rol':'CONDUCTOR'});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── USERS ──────────────────────────────────────────────────────────────
|
// ── USERS ────────────────────────────────────────────────────────────────
|
||||||
static Future<int> insertUser(UserModel u) async =>
|
static Future<int> insertUser(UserModel u) async =>
|
||||||
(await database).insert('users', u.toMap(), conflictAlgorithm: ConflictAlgorithm.abort);
|
(await database).insert('users', u.toMap(), conflictAlgorithm: ConflictAlgorithm.abort);
|
||||||
|
|
||||||
@@ -71,49 +98,71 @@ class DbHelper {
|
|||||||
return res.map((m) => UserModel.fromMap(m)).toList();
|
return res.map((m) => UserModel.fromMap(m)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── DOMICILIOS ─────────────────────────────────────────────────────────
|
// ── DOMICILIOS ───────────────────────────────────────────────────────────
|
||||||
static Future<int> insertDomicilio(DomicilioModel d) async =>
|
static Future<int> insertDomicilio(DomicilioModel d) async {
|
||||||
(await database).insert('domicilios', d.toMap());
|
final db = await database;
|
||||||
|
// Si es el primero del usuario, marcarlo como primario
|
||||||
|
final existing = await db.query('domicilios', where:'user_id=?', whereArgs:[d.userId]);
|
||||||
|
final isPrimary = existing.isEmpty ? 1 : (d.isPrimary ? 1 : 0);
|
||||||
|
return db.insert('domicilios', {...d.toMap(), 'is_primary': isPrimary});
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<List<DomicilioModel>> getDomiciliosByUser(int userId) async {
|
||||||
|
final res = await (await database).query('domicilios',
|
||||||
|
where:'user_id=?', whereArgs:[userId], orderBy:'is_primary DESC, id ASC');
|
||||||
|
return res.map((m) => DomicilioModel.fromMap(m)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
static Future<DomicilioModel?> getPrimaryDomicilio(int userId) async {
|
static Future<DomicilioModel?> getPrimaryDomicilio(int userId) async {
|
||||||
final res = await (await database).query('domicilios',
|
final db = await database;
|
||||||
|
var res = await db.query('domicilios',
|
||||||
where:'user_id=? AND is_primary=1', whereArgs:[userId]);
|
where:'user_id=? AND is_primary=1', whereArgs:[userId]);
|
||||||
|
if (res.isEmpty) {
|
||||||
|
res = await db.query('domicilios', where:'user_id=?', whereArgs:[userId], limit:1);
|
||||||
|
}
|
||||||
return res.isEmpty ? null : DomicilioModel.fromMap(res.first);
|
return res.isEmpty ? null : DomicilioModel.fromMap(res.first);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ASIGNACIONES ───────────────────────────────────────────────────────
|
static Future<void> setPrimaryDomicilio(int domId, int userId) async {
|
||||||
static Future<void> upsertAsignacion(AssignmentModel a) async {
|
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final ex = await db.query('asignaciones',
|
await db.update('domicilios', {'is_primary':0}, where:'user_id=?', whereArgs:[userId]);
|
||||||
where:'conductor_id=? AND dia_semana=?',
|
await db.update('domicilios', {'is_primary':1}, where:'id=?', whereArgs:[domId]);
|
||||||
whereArgs:[a.conductorId, a.diaSemana]);
|
|
||||||
if (ex.isEmpty) {
|
|
||||||
await db.insert('asignaciones', a.toMap());
|
|
||||||
} else {
|
|
||||||
await db.update('asignaciones', {'route_id':a.routeId,'turno':a.turno},
|
|
||||||
where:'conductor_id=? AND dia_semana=?',
|
|
||||||
whereArgs:[a.conductorId, a.diaSemana]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<List<AssignmentModel>> getAsignacionesByConductor(int conductorId) async {
|
static Future<void> deleteDomicilio(int id) async =>
|
||||||
final res = await (await database).query('asignaciones',
|
(await database).delete('domicilios', where:'id=?', whereArgs:[id]);
|
||||||
where:'conductor_id=?', whereArgs:[conductorId]);
|
|
||||||
return res.map((m) => AssignmentModel.fromMap(m)).toList();
|
static Future<List<DomicilioModel>> getDomiciliosByRoute(String routeId) async {
|
||||||
|
final res = await (await database).query('domicilios',
|
||||||
|
where:'route_id=?', whereArgs:[routeId]);
|
||||||
|
return res.map((m) => DomicilioModel.fromMap(m)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<List<AssignmentModel>> getAllAsignaciones() async {
|
// ── ROUTE DEFINITIONS ────────────────────────────────────────────────────
|
||||||
final res = await (await database).query('asignaciones');
|
static Future<int> insertRouteDefinition(RouteDefinitionModel r) async =>
|
||||||
return res.map((m) => AssignmentModel.fromMap(m)).toList();
|
(await database).insert('route_definitions', r.toMap(),
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace);
|
||||||
|
|
||||||
|
static Future<List<RouteDefinitionModel>> getAllRouteDefinitions() async {
|
||||||
|
final res = await (await database).query('route_definitions', orderBy:'route_id ASC');
|
||||||
|
return res.map((m) => RouteDefinitionModel.fromMap(m)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ROUTE STATUS ───────────────────────────────────────────────────────
|
static Future<RouteDefinitionModel?> getRouteDefinitionById(String routeId) async {
|
||||||
static Future<void> upsertRouteStatus(RouteStatusModel s) async {
|
final res = await (await database).query('route_definitions',
|
||||||
final db = await database;
|
where:'route_id=?', whereArgs:[routeId]);
|
||||||
await db.insert('route_status', s.toMap(),
|
return res.isEmpty ? null : RouteDefinitionModel.fromMap(res.first);
|
||||||
conflictAlgorithm: ConflictAlgorithm.replace);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<void> updateRouteDefinition(RouteDefinitionModel r) async =>
|
||||||
|
(await database).update('route_definitions', r.toMap(),
|
||||||
|
where:'route_id=?', whereArgs:[r.routeId]);
|
||||||
|
|
||||||
|
// ── ROUTE STATUS ─────────────────────────────────────────────────────────
|
||||||
|
static Future<void> upsertRouteStatus(RouteStatusModel s) async =>
|
||||||
|
(await database).insert('route_status', s.toMap(),
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace);
|
||||||
|
|
||||||
static Future<RouteStatusModel?> getRouteStatus(String routeId) async {
|
static Future<RouteStatusModel?> getRouteStatus(String routeId) async {
|
||||||
final res = await (await database).query('route_status',
|
final res = await (await database).query('route_status',
|
||||||
where:'route_id=?', whereArgs:[routeId]);
|
where:'route_id=?', whereArgs:[routeId]);
|
||||||
@@ -125,11 +174,35 @@ class DbHelper {
|
|||||||
return res.map((m) => RouteStatusModel.fromMap(m)).toList();
|
return res.map((m) => RouteStatusModel.fromMap(m)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ALERTAS ────────────────────────────────────────────────────────────
|
// ── ASIGNACIONES ─────────────────────────────────────────────────────────
|
||||||
|
static Future<void> upsertAsignacion(AssignmentModel a) async {
|
||||||
|
final db = await database;
|
||||||
|
final ex = await db.query('asignaciones',
|
||||||
|
where:'conductor_id=? AND dia_semana=?', whereArgs:[a.conductorId, a.diaSemana]);
|
||||||
|
if (ex.isEmpty) {
|
||||||
|
await db.insert('asignaciones', a.toMap());
|
||||||
|
} else {
|
||||||
|
await db.update('asignaciones', {'route_id':a.routeId,'turno':a.turno},
|
||||||
|
where:'conductor_id=? AND dia_semana=?', whereArgs:[a.conductorId, a.diaSemana]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<List<AssignmentModel>> getAsignacionesByConductor(int id) async {
|
||||||
|
final res = await (await database).query('asignaciones',
|
||||||
|
where:'conductor_id=?', whereArgs:[id]);
|
||||||
|
return res.map((m) => AssignmentModel.fromMap(m)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<List<AssignmentModel>> getAllAsignaciones() async {
|
||||||
|
final res = await (await database).query('asignaciones');
|
||||||
|
return res.map((m) => AssignmentModel.fromMap(m)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ALERTAS ──────────────────────────────────────────────────────────────
|
||||||
static Future<int> insertAlerta(AlertaModel a) async =>
|
static Future<int> insertAlerta(AlertaModel a) async =>
|
||||||
(await database).insert('alertas', a.toMap());
|
(await database).insert('alertas', a.toMap());
|
||||||
|
|
||||||
static Future<List<AlertaModel>> getAlertas({bool soloNoResueltas = false}) async {
|
static Future<List<AlertaModel>> getAlertas({bool soloNoResueltas=false}) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final res = soloNoResueltas
|
final res = soloNoResueltas
|
||||||
? await db.query('alertas', where:'resuelta=0', orderBy:'fecha DESC')
|
? await db.query('alertas', where:'resuelta=0', orderBy:'fecha DESC')
|
||||||
@@ -137,49 +210,70 @@ class DbHelper {
|
|||||||
return res.map((m) => AlertaModel.fromMap(m)).toList();
|
return res.map((m) => AlertaModel.fromMap(m)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<List<AlertaModel>> getIncidentesConductor() async {
|
||||||
|
final res = await (await database).query('alertas',
|
||||||
|
where:"tipo LIKE 'INCIDENTE_%'", orderBy:'fecha DESC');
|
||||||
|
return res.map((m) => AlertaModel.fromMap(m)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
static Future<void> resolverAlerta(int id) async =>
|
static Future<void> resolverAlerta(int id) async =>
|
||||||
(await database).update('alertas', {'resuelta':1}, where:'id=?', whereArgs:[id]);
|
(await database).update('alertas', {'resuelta':1}, where:'id=?', whereArgs:[id]);
|
||||||
|
|
||||||
// ── REPORTES ───────────────────────────────────────────────────────────
|
// ── REPORTES ─────────────────────────────────────────────────────────────
|
||||||
static Future<int> insertReporte(ReporteModel r) async =>
|
static Future<int> insertReporte(ReporteModel r) async =>
|
||||||
(await database).insert('reportes', r.toMap());
|
(await database).insert('reportes', r.toMap());
|
||||||
|
|
||||||
|
static Future<List<ReporteModel>> getAllReportes() async {
|
||||||
|
final res = await (await database).query('reportes', orderBy:'fecha DESC');
|
||||||
|
return res.map((m) => ReporteModel.fromMap(m)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
static Future<List<ReporteModel>> getReportesByUser(int userId) async {
|
static Future<List<ReporteModel>> getReportesByUser(int userId) async {
|
||||||
final res = await (await database).query('reportes',
|
final res = await (await database).query('reportes',
|
||||||
where:'user_id=?', whereArgs:[userId], orderBy:'fecha DESC');
|
where:'user_id=?', whereArgs:[userId], orderBy:'fecha DESC');
|
||||||
return res.map((m) => ReporteModel.fromMap(m)).toList();
|
return res.map((m) => ReporteModel.fromMap(m)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<List<ReporteModel>> getAllReportes() async {
|
static Future<List<Map<String, dynamic>>> getReportesConUsuario() async {
|
||||||
final res = await (await database).query('reportes', orderBy:'fecha DESC');
|
final db = await database;
|
||||||
return res.map((m) => ReporteModel.fromMap(m)).toList();
|
return db.rawQuery('''
|
||||||
|
SELECT r.*, u.nombre as user_nombre, u.email as user_email
|
||||||
|
FROM reportes r LEFT JOIN users u ON r.user_id = u.id
|
||||||
|
ORDER BY r.fecha DESC''');
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> updateReporteEstado(int id, String estado) async =>
|
static Future<void> updateReporteEstado(int id, String estado) async =>
|
||||||
(await database).update('reportes', {'estado':estado}, where:'id=?', whereArgs:[id]);
|
(await database).update('reportes', {'estado':estado}, where:'id=?', whereArgs:[id]);
|
||||||
|
|
||||||
// ── REPORTES CON INFO DE USUARIO ──────────────────────────────────────
|
// ── REVIEWS ──────────────────────────────────────────────────────────────
|
||||||
static Future<List<Map<String, dynamic>>> getReportesConUsuario() async {
|
static Future<int> insertReview(ReviewModel r) async =>
|
||||||
|
(await database).insert('reviews', r.toMap());
|
||||||
|
|
||||||
|
static Future<List<ReviewModel>> getAllReviews() async {
|
||||||
|
final res = await (await database).query('reviews', orderBy:'fecha DESC');
|
||||||
|
return res.map((m) => ReviewModel.fromMap(m)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> hasReviewedRoute(int userId, String routeId) async {
|
||||||
|
// Verifica si el usuario ya calificó esta ruta hoy
|
||||||
|
final today = DateTime.now().toIso8601String().substring(0, 10);
|
||||||
|
final res = await (await database).query('reviews',
|
||||||
|
where:"user_id=? AND route_id=? AND fecha LIKE '$today%'",
|
||||||
|
whereArgs:[userId, routeId]);
|
||||||
|
return res.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promedio por colonia para el admin
|
||||||
|
static Future<List<Map<String, dynamic>>> getReviewSummaryByColonia() async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
return db.rawQuery('''
|
return db.rawQuery('''
|
||||||
SELECT r.*, u.nombre as user_nombre, u.email as user_email
|
SELECT colonia, route_id,
|
||||||
FROM reportes r
|
AVG(estrellas) as promedio,
|
||||||
LEFT JOIN users u ON r.user_id = u.id
|
COUNT(*) as total,
|
||||||
ORDER BY r.fecha DESC
|
MIN(estrellas) as min_est,
|
||||||
''');
|
MAX(estrellas) as max_est
|
||||||
}
|
FROM reviews
|
||||||
|
GROUP BY colonia
|
||||||
// ── INCIDENTES CONDUCTOR ───────────────────────────────────────────────
|
ORDER BY promedio ASC''');
|
||||||
static Future<List<AlertaModel>> getIncidentesConductor() async {
|
|
||||||
final res = await (await database).query('alertas',
|
|
||||||
where: "tipo LIKE 'INCIDENTE_%'", orderBy: 'fecha DESC');
|
|
||||||
return res.map((m) => AlertaModel.fromMap(m)).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DOMICILIOS POR RUTA ────────────────────────────────────────────────
|
|
||||||
static Future<List<DomicilioModel>> getDomiciliosByRoute(String routeId) async {
|
|
||||||
final res = await (await database).query('domicilios',
|
|
||||||
where: 'route_id = ?', whereArgs: [routeId]);
|
|
||||||
return res.map((m) => DomicilioModel.fromMap(m)).toList();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,60 +4,120 @@ class UserModel {
|
|||||||
final String nombre;
|
final String nombre;
|
||||||
final String email;
|
final String email;
|
||||||
final String password;
|
final String password;
|
||||||
final String rol; // CIUDADANO | CONDUCTOR | ADMINISTRADOR
|
final String rol;
|
||||||
|
|
||||||
UserModel({this.id, required this.nombre, required this.email,
|
UserModel({this.id, required this.nombre, required this.email,
|
||||||
required this.password, required this.rol});
|
required this.password, required this.rol});
|
||||||
|
|
||||||
Map<String, dynamic> toMap() =>
|
Map<String, dynamic> 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<String, dynamic> m) => UserModel(
|
factory UserModel.fromMap(Map<String, dynamic> m) => UserModel(
|
||||||
id: m['id'], nombre: m['nombre'], email: m['email'],
|
id:m['id'], nombre:m['nombre'], email:m['email'],
|
||||||
password: m['password'], rol: m['rol']);
|
password:m['password'], rol:m['rol']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── DOMICILIO (citizen) ───────────────────────────────────────────────────
|
// ── DOMICILIO (User → Domicilio → Zona → Ruta) ────────────────────────────
|
||||||
class DomicilioModel {
|
class DomicilioModel {
|
||||||
final int? id;
|
final int? id;
|
||||||
final int userId;
|
final int userId;
|
||||||
|
final String alias; // "Casa", "Trabajo", etc.
|
||||||
final String calle;
|
final String calle;
|
||||||
final String colonia;
|
final String colonia; // Zona de cobertura
|
||||||
final String routeId;
|
final String routeId; // Ruta asignada
|
||||||
final String horarioEstimado;
|
final String horarioEstimado;
|
||||||
final bool isPrimary;
|
final bool isPrimary;
|
||||||
|
|
||||||
DomicilioModel({this.id, required this.userId, required this.calle,
|
DomicilioModel({this.id, required this.userId, this.alias = 'Casa',
|
||||||
required this.colonia, required this.routeId,
|
required this.calle, required this.colonia, required this.routeId,
|
||||||
required this.horarioEstimado, this.isPrimary = true});
|
required this.horarioEstimado, this.isPrimary = true});
|
||||||
|
|
||||||
Map<String, dynamic> toMap() => {'id': id, 'user_id': userId, 'calle': calle,
|
Map<String, dynamic> toMap() => {'id':id,'user_id':userId,'alias':alias,
|
||||||
'colonia': colonia, 'route_id': routeId,
|
'calle':calle,'colonia':colonia,'route_id':routeId,
|
||||||
'horario_estimado': horarioEstimado, 'is_primary': isPrimary ? 1 : 0};
|
'horario_estimado':horarioEstimado,'is_primary':isPrimary?1:0};
|
||||||
|
|
||||||
factory DomicilioModel.fromMap(Map<String, dynamic> m) => DomicilioModel(
|
factory DomicilioModel.fromMap(Map<String, dynamic> m) => DomicilioModel(
|
||||||
id: m['id'], userId: m['user_id'], calle: m['calle'],
|
id:m['id'], userId:m['user_id'], alias:m['alias']??'Casa',
|
||||||
colonia: m['colonia'], routeId: m['route_id'],
|
calle:m['calle'], colonia:m['colonia'], routeId:m['route_id'],
|
||||||
horarioEstimado: m['horario_estimado'], isPrimary: m['is_primary'] == 1);
|
horarioEstimado:m['horario_estimado'], isPrimary:m['is_primary']==1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ASSIGNMENT (driver schedule) ──────────────────────────────────────────
|
// ── RUTA DINÁMICA (creada por admin) ──────────────────────────────────────
|
||||||
|
class RouteDefinitionModel {
|
||||||
|
final int? id;
|
||||||
|
final String routeId;
|
||||||
|
final String nombre;
|
||||||
|
final List<String> dias; // ['LUNES','MIERCOLES','VIERNES']
|
||||||
|
final String horaInicio; // '06:00'
|
||||||
|
final String horaFin; // '08:00'
|
||||||
|
final String turno; // MATUTINO|VESPERTINO|NOCTURNO
|
||||||
|
final List<String> colonias; // colonias que cubre
|
||||||
|
final bool activa;
|
||||||
|
|
||||||
|
RouteDefinitionModel({this.id, required this.routeId, required this.nombre,
|
||||||
|
required this.dias, required this.horaInicio, required this.horaFin,
|
||||||
|
required this.turno, required this.colonias, this.activa = true});
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() => {
|
||||||
|
'id':id,'route_id':routeId,'nombre':nombre,
|
||||||
|
'dias':dias.join(','),'hora_inicio':horaInicio,'hora_fin':horaFin,
|
||||||
|
'turno':turno,'colonias':colonias.join('|'),'activa':activa?1:0,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory RouteDefinitionModel.fromMap(Map<String, dynamic> m) =>
|
||||||
|
RouteDefinitionModel(
|
||||||
|
id:m['id'], routeId:m['route_id'], nombre:m['nombre'],
|
||||||
|
dias:(m['dias']??'').toString().split(',').where((s)=>s.isNotEmpty).toList(),
|
||||||
|
horaInicio:m['hora_inicio']??'06:00', horaFin:m['hora_fin']??'08:00',
|
||||||
|
turno:m['turno']??'MATUTINO',
|
||||||
|
colonias:(m['colonias']??'').toString().split('|').where((s)=>s.isNotEmpty).toList(),
|
||||||
|
activa:m['activa']==1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RESEÑA DEL SERVICIO ───────────────────────────────────────────────────
|
||||||
|
class ReviewModel {
|
||||||
|
final int? id;
|
||||||
|
final int userId;
|
||||||
|
final String colonia;
|
||||||
|
final String routeId;
|
||||||
|
final int estrellas; // 1-5
|
||||||
|
final String comentario;
|
||||||
|
final String fecha;
|
||||||
|
final String nombreUsuario;
|
||||||
|
|
||||||
|
ReviewModel({this.id, required this.userId, required this.colonia,
|
||||||
|
required this.routeId, required this.estrellas, required this.comentario,
|
||||||
|
required this.fecha, this.nombreUsuario = ''});
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() => {'id':id,'user_id':userId,'colonia':colonia,
|
||||||
|
'route_id':routeId,'estrellas':estrellas,'comentario':comentario,
|
||||||
|
'fecha':fecha,'nombre_usuario':nombreUsuario};
|
||||||
|
|
||||||
|
factory ReviewModel.fromMap(Map<String, dynamic> m) => ReviewModel(
|
||||||
|
id:m['id'], userId:m['user_id'], colonia:m['colonia'],
|
||||||
|
routeId:m['route_id'], estrellas:m['estrellas'],
|
||||||
|
comentario:m['comentario']??'', fecha:m['fecha'],
|
||||||
|
nombreUsuario:m['nombre_usuario']??'Ciudadano');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ASSIGNMENT ────────────────────────────────────────────────────────────
|
||||||
class AssignmentModel {
|
class AssignmentModel {
|
||||||
final int? id;
|
final int? id;
|
||||||
final int conductorId;
|
final int conductorId;
|
||||||
final String routeId;
|
final String routeId;
|
||||||
final String diaSemana;
|
final String diaSemana;
|
||||||
final String turno; // MATUTINO | VESPERTINO | NOCTURNO
|
final String turno;
|
||||||
|
|
||||||
AssignmentModel({this.id, required this.conductorId, required this.routeId,
|
AssignmentModel({this.id, required this.conductorId, required this.routeId,
|
||||||
required this.diaSemana, required this.turno});
|
required this.diaSemana, required this.turno});
|
||||||
|
|
||||||
Map<String, dynamic> toMap() => {'id': id, 'conductor_id': conductorId,
|
Map<String, dynamic> toMap() => {'id':id,'conductor_id':conductorId,
|
||||||
'route_id': routeId, 'dia_semana': diaSemana, 'turno': turno};
|
'route_id':routeId,'dia_semana':diaSemana,'turno':turno};
|
||||||
|
|
||||||
factory AssignmentModel.fromMap(Map<String, dynamic> m) => AssignmentModel(
|
factory AssignmentModel.fromMap(Map<String, dynamic> m) => AssignmentModel(
|
||||||
id: m['id'], conductorId: m['conductor_id'], routeId: m['route_id'],
|
id:m['id'], conductorId:m['conductor_id'], routeId:m['route_id'],
|
||||||
diaSemana: m['dia_semana'], turno: m['turno']);
|
diaSemana:m['dia_semana'], turno:m['turno']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ROUTE STATUS ──────────────────────────────────────────────────────────
|
// ── ROUTE STATUS ──────────────────────────────────────────────────────────
|
||||||
@@ -70,32 +130,32 @@ class RouteStatusModel {
|
|||||||
RouteStatusModel({required this.routeId, required this.status,
|
RouteStatusModel({required this.routeId, required this.status,
|
||||||
this.mensaje, required this.updatedAt});
|
this.mensaje, required this.updatedAt});
|
||||||
|
|
||||||
Map<String, dynamic> toMap() => {'route_id': routeId, 'status': status,
|
Map<String, dynamic> toMap() => {'route_id':routeId,'status':status,
|
||||||
'mensaje': mensaje, 'updated_at': updatedAt};
|
'mensaje':mensaje,'updated_at':updatedAt};
|
||||||
|
|
||||||
factory RouteStatusModel.fromMap(Map<String, dynamic> m) => RouteStatusModel(
|
factory RouteStatusModel.fromMap(Map<String, dynamic> m) => RouteStatusModel(
|
||||||
routeId: m['route_id'], status: m['status'],
|
routeId:m['route_id'], status:m['status'],
|
||||||
mensaje: m['mensaje'], updatedAt: m['updated_at']);
|
mensaje:m['mensaje'], updatedAt:m['updated_at']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ALERTA ────────────────────────────────────────────────────────────────
|
// ── ALERTA ────────────────────────────────────────────────────────────────
|
||||||
class AlertaModel {
|
class AlertaModel {
|
||||||
final int? id;
|
final int? id;
|
||||||
final String tipo; // GPS_PERDIDO | CAMION_DETENIDO | FALLA_MECANICA
|
final String tipo;
|
||||||
final String routeId;
|
final String routeId;
|
||||||
final String mensaje;
|
final String mensaje;
|
||||||
final String fecha;
|
final String fecha;
|
||||||
final bool resuelta;
|
final bool resuelta;
|
||||||
|
|
||||||
AlertaModel({this.id, required this.tipo, required this.routeId,
|
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<String, dynamic> toMap() => {'id': id, 'tipo': tipo, 'route_id': routeId,
|
Map<String, dynamic> toMap() => {'id':id,'tipo':tipo,'route_id':routeId,
|
||||||
'mensaje': mensaje, 'fecha': fecha, 'resuelta': resuelta ? 1 : 0};
|
'mensaje':mensaje,'fecha':fecha,'resuelta':resuelta?1:0};
|
||||||
|
|
||||||
factory AlertaModel.fromMap(Map<String, dynamic> m) => AlertaModel(
|
factory AlertaModel.fromMap(Map<String, dynamic> m) => AlertaModel(
|
||||||
id: m['id'], tipo: m['tipo'], routeId: m['route_id'],
|
id:m['id'], tipo:m['tipo'], routeId:m['route_id'],
|
||||||
mensaje: m['mensaje'], fecha: m['fecha'], resuelta: m['resuelta'] == 1);
|
mensaje:m['mensaje'], fecha:m['fecha'], resuelta:m['resuelta']==1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── REPORTE ───────────────────────────────────────────────────────────────
|
// ── REPORTE ───────────────────────────────────────────────────────────────
|
||||||
@@ -112,15 +172,15 @@ class ReporteModel {
|
|||||||
|
|
||||||
ReporteModel({this.id, required this.userId, required this.tipo,
|
ReporteModel({this.id, required this.userId, required this.tipo,
|
||||||
required this.descripcion, required this.colonia, required this.routeId,
|
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<String, dynamic> toMap() => {'id': id, 'user_id': userId, 'tipo': tipo,
|
Map<String, dynamic> toMap() => {'id':id,'user_id':userId,'tipo':tipo,
|
||||||
'descripcion': descripcion, 'colonia': colonia, 'route_id': routeId,
|
'descripcion':descripcion,'colonia':colonia,'route_id':routeId,
|
||||||
'fecha': fecha, 'estado': estado, 'calificacion': calificacion};
|
'fecha':fecha,'estado':estado,'calificacion':calificacion};
|
||||||
|
|
||||||
factory ReporteModel.fromMap(Map<String, dynamic> m) => ReporteModel(
|
factory ReporteModel.fromMap(Map<String, dynamic> m) => ReporteModel(
|
||||||
id: m['id'], userId: m['user_id'], tipo: m['tipo'],
|
id:m['id'], userId:m['user_id'], tipo:m['tipo'],
|
||||||
descripcion: m['descripcion'], colonia: m['colonia'],
|
descripcion:m['descripcion'], colonia:m['colonia'],
|
||||||
routeId: m['route_id'] ?? '', fecha: m['fecha'],
|
routeId:m['route_id']??'', fecha:m['fecha'],
|
||||||
estado: m['estado'], calificacion: m['calificacion'] ?? 5);
|
estado:m['estado'], calificacion:m['calificacion']??5);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import '../../services/route_simulator_service.dart';
|
|||||||
import '../../database/db_helper.dart';
|
import '../../database/db_helper.dart';
|
||||||
import '../../models/models.dart';
|
import '../../models/models.dart';
|
||||||
import '../../data/routes_data.dart';
|
import '../../data/routes_data.dart';
|
||||||
|
import '../../models/route_model.dart' show ColonyModel;
|
||||||
|
import 'create_route_screen.dart';
|
||||||
import '../../widgets/route_map_widget.dart';
|
import '../../widgets/route_map_widget.dart';
|
||||||
|
|
||||||
class AdminDashboardScreen extends StatefulWidget {
|
class AdminDashboardScreen extends StatefulWidget {
|
||||||
@@ -27,6 +29,8 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
|||||||
_AdminReportesTab(),
|
_AdminReportesTab(),
|
||||||
_AdminAssignmentsTab(),
|
_AdminAssignmentsTab(),
|
||||||
_AdminAlertasTab(sim:sim),
|
_AdminAlertasTab(sim:sim),
|
||||||
|
_AdminRoutesTab(),
|
||||||
|
_AdminReviewsTab(),
|
||||||
];
|
];
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -51,6 +55,10 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
|||||||
selectedIcon:Icon(Icons.calendar_month,color:AppColors.verdeAdmin),label:'Asignar'),
|
selectedIcon:Icon(Icons.calendar_month,color:AppColors.verdeAdmin),label:'Asignar'),
|
||||||
NavigationDestination(icon:Icon(Icons.warning_outlined),
|
NavigationDestination(icon:Icon(Icons.warning_outlined),
|
||||||
selectedIcon:Icon(Icons.warning,color:AppColors.verdeAdmin),label:'Alertas'),
|
selectedIcon:Icon(Icons.warning,color:AppColors.verdeAdmin),label:'Alertas'),
|
||||||
|
NavigationDestination(icon:Icon(Icons.route_outlined),
|
||||||
|
selectedIcon:Icon(Icons.route,color:AppColors.verdeAdmin),label:'Rutas'),
|
||||||
|
NavigationDestination(icon:Icon(Icons.star_outline),
|
||||||
|
selectedIcon:Icon(Icons.star,color:AppColors.verdeAdmin),label:'Reseñas'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -784,3 +792,238 @@ class _AdminBanner extends StatelessWidget {
|
|||||||
IconButton(icon:const Icon(Icons.close,color:Colors.white,size:18),onPressed:onDismiss),
|
IconButton(icon:const Icon(Icons.close,color:Colors.white,size:18),onPressed:onDismiss),
|
||||||
]))));
|
]))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── TAB 6: Gestión de Rutas ───────────────────────────────────────────────
|
||||||
|
class _AdminRoutesTab extends StatefulWidget {
|
||||||
|
@override State<_AdminRoutesTab> createState() => _AdminRoutesTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminRoutesTabState extends State<_AdminRoutesTab> {
|
||||||
|
List<RouteDefinitionModel> _routes = [];
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override void initState() { super.initState(); _load(); }
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
final r = await DbHelper.getAllRouteDefinitions();
|
||||||
|
if (mounted) setState(() { _routes = r; _loading = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Scaffold(
|
||||||
|
appBar: AppBar(automaticallyImplyLeading: false,
|
||||||
|
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
|
||||||
|
title: Text('Rutas del Sistema (${_routes.length})'),
|
||||||
|
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||||
|
child: Container(height: 4, color: AppColors.dorado)),
|
||||||
|
actions: [
|
||||||
|
IconButton(icon: const Icon(Icons.refresh), onPressed: _load),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add_circle_outline),
|
||||||
|
tooltip: 'Nueva ruta',
|
||||||
|
onPressed: () async {
|
||||||
|
final ok = await Navigator.push(context, MaterialPageRoute(
|
||||||
|
builder: (_) => const CreateRouteScreen()));
|
||||||
|
if (ok == true) await _load();
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
body: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _routes.isEmpty
|
||||||
|
? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||||
|
const Icon(Icons.route, color: AppColors.grisTexto, size: 48),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text('No hay rutas creadas', style: TextStyle(color: AppColors.grisTexto)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: AppColors.verdeAdmin,
|
||||||
|
foregroundColor: Colors.white),
|
||||||
|
onPressed: () async {
|
||||||
|
final ok = await Navigator.push(context, MaterialPageRoute(
|
||||||
|
builder: (_) => const CreateRouteScreen()));
|
||||||
|
if (ok == true) await _load();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.add), label: const Text('Crear primera ruta')),
|
||||||
|
]))
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
itemCount: _routes.length,
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final r = _routes[i];
|
||||||
|
final turnoEmoji = r.turno == 'MATUTINO' ? '🌄'
|
||||||
|
: r.turno == 'VESPERTINO' ? '🌅' : '🌙';
|
||||||
|
return Card(margin: const EdgeInsets.only(bottom: 10),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10),
|
||||||
|
side: BorderSide(color: AppColors.verdeAdmin.withOpacity(0.3))),
|
||||||
|
child: Padding(padding: const EdgeInsets.all(14), child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Row(children: [
|
||||||
|
Expanded(child: Text('${r.routeId} — ${r.nombre}',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 14, color: AppColors.verdeAdmin))),
|
||||||
|
IconButton(icon: const Icon(Icons.edit_outlined, size: 18),
|
||||||
|
onPressed: () async {
|
||||||
|
final ok = await Navigator.push(context, MaterialPageRoute(
|
||||||
|
builder: (_) => CreateRouteScreen(editing: r)));
|
||||||
|
if (ok == true) await _load();
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(children: [
|
||||||
|
Text('$turnoEmoji ${r.turno} • ${r.horaInicio}–${r.horaFin}',
|
||||||
|
style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(r.dias.map(AppDias.label).join(', '),
|
||||||
|
style: const TextStyle(fontSize: 11, color: AppColors.grisTexto)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
// Colonias
|
||||||
|
Text('📍 ${r.colonias.length} colonias:',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Wrap(spacing: 4, runSpacing: 4, children: r.colonias.take(8).map((c) =>
|
||||||
|
Container(padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(color: AppColors.verdeAdmin.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8)),
|
||||||
|
child: Text(c, style: const TextStyle(fontSize: 10,
|
||||||
|
color: AppColors.verdeAdmin)))).toList()),
|
||||||
|
if (r.colonias.length > 8)
|
||||||
|
Text(' ...y ${r.colonias.length - 8} más',
|
||||||
|
style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
|
||||||
|
])));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TAB 7: Reseñas y calificaciones ──────────────────────────────────────
|
||||||
|
class _AdminReviewsTab extends StatefulWidget {
|
||||||
|
@override State<_AdminReviewsTab> createState() => _AdminReviewsTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminReviewsTabState extends State<_AdminReviewsTab> {
|
||||||
|
List<ReviewModel> _reviews = [];
|
||||||
|
List<Map<String, dynamic>> _summary = [];
|
||||||
|
bool _showSummary = false;
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override void initState() { super.initState(); _load(); }
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
final r = await DbHelper.getAllReviews();
|
||||||
|
final s = await DbHelper.getReviewSummaryByColonia();
|
||||||
|
if (mounted) setState(() { _reviews = r; _summary = s; _loading = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Scaffold(
|
||||||
|
appBar: AppBar(automaticallyImplyLeading: false,
|
||||||
|
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
|
||||||
|
title: Text(_showSummary ? 'Calificaciones por Colonia' : 'Reseñas Ciudadanas'),
|
||||||
|
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||||
|
child: Container(height: 4, color: AppColors.dorado)),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(_showSummary ? Icons.list : Icons.bar_chart),
|
||||||
|
tooltip: _showSummary ? 'Ver reseñas' : 'Ver por colonia',
|
||||||
|
onPressed: () => setState(() => _showSummary = !_showSummary)),
|
||||||
|
IconButton(icon: const Icon(Icons.refresh), onPressed: _load),
|
||||||
|
]),
|
||||||
|
body: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _showSummary ? _buildSummary() : _buildReviews(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _buildSummary() {
|
||||||
|
if (_summary.isEmpty) return const Center(
|
||||||
|
child: Text('Sin calificaciones aún', style: TextStyle(color: AppColors.grisTexto)));
|
||||||
|
return Column(children: [
|
||||||
|
// Header explicativo
|
||||||
|
Container(margin: const EdgeInsets.all(12), padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(color: Colors.blue.shade50, borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.blue.shade200)),
|
||||||
|
child: const Row(children: [
|
||||||
|
Icon(Icons.info_outline, color: AppColors.azulInfo, size: 16),
|
||||||
|
SizedBox(width: 6),
|
||||||
|
Expanded(child: Text(
|
||||||
|
'Colonias ordenadas de menor a mayor calificación. '
|
||||||
|
'Las primeras requieren atención prioritaria.',
|
||||||
|
style: TextStyle(fontSize: 11, color: AppColors.azulInfo))),
|
||||||
|
])),
|
||||||
|
Expanded(child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
itemCount: _summary.length,
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final s = _summary[i];
|
||||||
|
final prom = (s['promedio'] as num).toDouble();
|
||||||
|
final total = s['total'] as int;
|
||||||
|
final colonia = s['colonia'] as String;
|
||||||
|
final routeId = s['route_id'] as String;
|
||||||
|
final color = prom >= 4.5 ? AppColors.verdeExito
|
||||||
|
: prom >= 3.5 ? Colors.amber.shade700
|
||||||
|
: prom >= 2.5 ? AppColors.naranjaAlerta
|
||||||
|
: AppColors.rojoError;
|
||||||
|
final emoji = prom >= 4.5 ? '🟢' : prom >= 3.5 ? '🟡' : prom >= 2.5 ? '🟠' : '🔴';
|
||||||
|
return Card(margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10),
|
||||||
|
side: BorderSide(color: color.withOpacity(0.3))),
|
||||||
|
child: Padding(padding: const EdgeInsets.all(12), child: Row(children: [
|
||||||
|
Container(width: 6, height: 50,
|
||||||
|
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(3))),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text(colonia, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
|
||||||
|
Text('$emoji $routeId • $total reseña${total != 1 ? "s" : ""}',
|
||||||
|
style: const TextStyle(fontSize: 11, color: AppColors.grisTexto)),
|
||||||
|
])),
|
||||||
|
Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
|
||||||
|
Text(prom.toStringAsFixed(1),
|
||||||
|
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: color)),
|
||||||
|
Row(children: List.generate(5, (j) =>
|
||||||
|
Icon(j < prom.round() ? Icons.star : Icons.star_border,
|
||||||
|
color: Colors.amber, size: 12))),
|
||||||
|
]),
|
||||||
|
])));
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildReviews() {
|
||||||
|
if (_reviews.isEmpty) return const Center(
|
||||||
|
child: Text('Sin reseñas aún', style: TextStyle(color: AppColors.grisTexto)));
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
itemCount: _reviews.length,
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final r = _reviews[i];
|
||||||
|
final fecha = DateTime.tryParse(r.fecha);
|
||||||
|
final fechaStr = fecha != null
|
||||||
|
? '${fecha.day}/${fecha.month}/${fecha.year} ${fecha.hour.toString().padLeft(2,'0')}:${fecha.minute.toString().padLeft(2,'0')}'
|
||||||
|
: r.fecha;
|
||||||
|
return Card(margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Padding(padding: const EdgeInsets.all(12), child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Row(children: [
|
||||||
|
CircleAvatar(backgroundColor: AppColors.guindaPrimary.withOpacity(0.1), radius: 18,
|
||||||
|
child: Text('${r.estrellas}⭐', style: const TextStyle(fontSize: 11))),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text(r.nombreUsuario, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
|
||||||
|
Text('${r.colonia} — ${r.routeId}',
|
||||||
|
style: const TextStyle(color: AppColors.grisTexto, fontSize: 11)),
|
||||||
|
])),
|
||||||
|
Text(fechaStr, style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(children: List.generate(5, (j) =>
|
||||||
|
Icon(j < r.estrellas ? Icons.star : Icons.star_border,
|
||||||
|
color: Colors.amber, size: 16))),
|
||||||
|
if (r.comentario.isNotEmpty && r.comentario != 'Sin comentario') ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text('"${r.comentario}"',
|
||||||
|
style: const TextStyle(fontSize: 12, fontStyle: FontStyle.italic,
|
||||||
|
color: AppColors.negroTexto)),
|
||||||
|
],
|
||||||
|
])));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
272
lib/screens/admin/create_route_screen.dart
Normal file
272
lib/screens/admin/create_route_screen.dart
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../core/app_colors.dart';
|
||||||
|
import '../../data/celaya_colonias.dart';
|
||||||
|
import '../../database/db_helper.dart';
|
||||||
|
import '../../models/models.dart';
|
||||||
|
|
||||||
|
class CreateRouteScreen extends StatefulWidget {
|
||||||
|
final RouteDefinitionModel? editing;
|
||||||
|
const CreateRouteScreen({super.key, this.editing});
|
||||||
|
@override State<CreateRouteScreen> createState() => _CreateRouteScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CreateRouteScreenState extends State<CreateRouteScreen> {
|
||||||
|
final _nombreCtrl = TextEditingController();
|
||||||
|
final _routeIdCtrl = TextEditingController();
|
||||||
|
String _turno = 'MATUTINO';
|
||||||
|
String _horaInicio = '06:00';
|
||||||
|
String _horaFin = '08:00';
|
||||||
|
List<String> _diasSeleccionados = [];
|
||||||
|
List<String> _coloniasSeleccionadas = [];
|
||||||
|
String _searchColonia = '';
|
||||||
|
bool _loading = false;
|
||||||
|
|
||||||
|
static const _diasGrupoA = ['LUNES', 'MIERCOLES', 'VIERNES'];
|
||||||
|
static const _diasGrupoB = ['MARTES', 'JUEVES', 'SABADO'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.editing != null) {
|
||||||
|
final e = widget.editing!;
|
||||||
|
_nombreCtrl.text = e.nombre;
|
||||||
|
_routeIdCtrl.text = e.routeId;
|
||||||
|
_turno = e.turno;
|
||||||
|
_horaInicio = e.horaInicio;
|
||||||
|
_horaFin = e.horaFin;
|
||||||
|
_diasSeleccionados = List.from(e.dias);
|
||||||
|
_coloniasSeleccionadas = List.from(e.colonias);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> get _filteredColonias => _searchColonia.isEmpty
|
||||||
|
? celayaColonias
|
||||||
|
: celayaColonias.where((c) =>
|
||||||
|
c.toLowerCase().contains(_searchColonia.toLowerCase())).toList();
|
||||||
|
|
||||||
|
Future<void> _guardar() async {
|
||||||
|
if (_nombreCtrl.text.trim().isEmpty) {
|
||||||
|
_snack('Ingresa un nombre para la ruta', isError: true); return; }
|
||||||
|
if (_routeIdCtrl.text.trim().isEmpty) {
|
||||||
|
_snack('Ingresa el ID de la ruta (ej. RUTA-16)', isError: true); return; }
|
||||||
|
if (_diasSeleccionados.isEmpty) {
|
||||||
|
_snack('Selecciona al menos un día', isError: true); return; }
|
||||||
|
if (_coloniasSeleccionadas.isEmpty) {
|
||||||
|
_snack('Selecciona al menos una colonia', isError: true); return; }
|
||||||
|
|
||||||
|
setState(() => _loading = true);
|
||||||
|
final route = RouteDefinitionModel(
|
||||||
|
id: widget.editing?.id,
|
||||||
|
routeId: _routeIdCtrl.text.trim().toUpperCase(),
|
||||||
|
nombre: _nombreCtrl.text.trim(),
|
||||||
|
dias: _diasSeleccionados,
|
||||||
|
horaInicio: _horaInicio,
|
||||||
|
horaFin: _horaFin,
|
||||||
|
turno: _turno,
|
||||||
|
colonias: _coloniasSeleccionadas,
|
||||||
|
);
|
||||||
|
await DbHelper.insertRouteDefinition(route);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _loading = false);
|
||||||
|
_snack('Ruta guardada correctamente');
|
||||||
|
Navigator.pop(context, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _snack(String msg, {bool isError = false}) =>
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||||
|
content: Text(msg),
|
||||||
|
backgroundColor: isError ? AppColors.rojoError : AppColors.verdeExito));
|
||||||
|
|
||||||
|
Future<TimeOfDay?> _pickTime(String current) async {
|
||||||
|
final parts = current.split(':');
|
||||||
|
return showTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialTime: TimeOfDay(hour: int.parse(parts[0]), minute: int.parse(parts[1])),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _timeLabel(TimeOfDay t) =>
|
||||||
|
'${t.hour.toString().padLeft(2,'0')}:${t.minute.toString().padLeft(2,'0')}';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppColors.grisFondo,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
|
||||||
|
title: Text(widget.editing != null ? 'Editar Ruta' : 'Nueva Ruta'),
|
||||||
|
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||||
|
child: Container(height: 4, color: AppColors.dorado)),
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
|
||||||
|
// Info básica
|
||||||
|
_section('Información de la ruta'),
|
||||||
|
_field(_routeIdCtrl, 'ID de Ruta (ej. RUTA-16)', Icons.tag),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_field(_nombreCtrl, 'Nombre descriptivo', Icons.route),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Turno
|
||||||
|
_section('Turno de operación'),
|
||||||
|
Row(children: ['MATUTINO','VESPERTINO','NOCTURNO'].map((t) =>
|
||||||
|
Expanded(child: RadioListTile<String>(dense: true, value: t,
|
||||||
|
groupValue: _turno,
|
||||||
|
title: Text(_turnoLabel(t), style: const TextStyle(fontSize: 12)),
|
||||||
|
activeColor: AppColors.verdeAdmin,
|
||||||
|
onChanged: (v) => setState(() => _turno = v!)))
|
||||||
|
).toList()),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Horario
|
||||||
|
_section('Horario de servicio'),
|
||||||
|
Row(children: [
|
||||||
|
Expanded(child: _timeButton('Hora inicio', _horaInicio, () async {
|
||||||
|
final t = await _pickTime(_horaInicio);
|
||||||
|
if (t != null) setState(() => _horaInicio = _timeLabel(t));
|
||||||
|
})),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: _timeButton('Hora fin', _horaFin, () async {
|
||||||
|
final t = await _pickTime(_horaFin);
|
||||||
|
if (t != null) setState(() => _horaFin = _timeLabel(t));
|
||||||
|
})),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Días
|
||||||
|
_section('Días de operación'),
|
||||||
|
Container(padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(color: Colors.blue.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.blue.shade200)),
|
||||||
|
child: const Text(
|
||||||
|
'📅 Selecciona Grupo A (L/M/V) o Grupo B (M/J/S), o días individuales.',
|
||||||
|
style: TextStyle(fontSize: 12, color: AppColors.azulInfo)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(children: [
|
||||||
|
Expanded(child: OutlinedButton(
|
||||||
|
onPressed: () => setState(() => _diasSeleccionados = List.from(_diasGrupoA)),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.verdeAdmin,
|
||||||
|
side: const BorderSide(color: AppColors.verdeAdmin)),
|
||||||
|
child: const Text('Grupo A\nL/M/V', textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 11)))),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: OutlinedButton(
|
||||||
|
onPressed: () => setState(() => _diasSeleccionados = List.from(_diasGrupoB)),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.moradoConductor,
|
||||||
|
side: const BorderSide(color: AppColors.moradoConductor)),
|
||||||
|
child: const Text('Grupo B\nM/J/S', textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 11)))),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(spacing: 6, runSpacing: 6, children: AppDias.todos.map((dia) {
|
||||||
|
final sel = _diasSeleccionados.contains(dia);
|
||||||
|
return FilterChip(
|
||||||
|
label: Text(AppDias.label(dia), style: TextStyle(fontSize: 11,
|
||||||
|
color: sel ? Colors.white : AppColors.negroTexto)),
|
||||||
|
selected: sel,
|
||||||
|
selectedColor: AppColors.verdeAdmin,
|
||||||
|
checkmarkColor: Colors.white,
|
||||||
|
onSelected: (v) => setState(() {
|
||||||
|
if (v) _diasSeleccionados.add(dia);
|
||||||
|
else _diasSeleccionados.remove(dia);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}).toList()),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Colonias
|
||||||
|
_section('Colonias que cubre (${_coloniasSeleccionadas.length} seleccionadas)'),
|
||||||
|
TextField(
|
||||||
|
onChanged: (v) => setState(() => _searchColonia = v),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Buscar colonia de Celaya...',
|
||||||
|
prefixIcon: Icon(Icons.search), border: OutlineInputBorder(),
|
||||||
|
filled: true, fillColor: Colors.white, isDense: true),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(height: 220,
|
||||||
|
decoration: BoxDecoration(color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.grey.shade300)),
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: _filteredColonias.length,
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final c = _filteredColonias[i];
|
||||||
|
final sel = _coloniasSeleccionadas.contains(c);
|
||||||
|
return CheckboxListTile(dense: true,
|
||||||
|
title: Text(c, style: const TextStyle(fontSize: 12)),
|
||||||
|
value: sel,
|
||||||
|
activeColor: AppColors.verdeAdmin,
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
onChanged: (v) => setState(() {
|
||||||
|
if (v == true) _coloniasSeleccionadas.add(c);
|
||||||
|
else _coloniasSeleccionadas.remove(c);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_coloniasSeleccionadas.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(spacing: 4, runSpacing: 4, children: _coloniasSeleccionadas.map((c) =>
|
||||||
|
Chip(label: Text(c, style: const TextStyle(fontSize: 10)),
|
||||||
|
backgroundColor: AppColors.verdeAdmin.withOpacity(0.1),
|
||||||
|
deleteIconColor: AppColors.verdeAdmin,
|
||||||
|
onDeleted: () => setState(() => _coloniasSeleccionadas.remove(c)))).toList()),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
SizedBox(width: double.infinity, height: 50,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: _loading ? null : _guardar,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||||
|
icon: _loading
|
||||||
|
? const SizedBox(width: 18, height: 18,
|
||||||
|
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||||
|
: const Icon(Icons.save),
|
||||||
|
label: const Text('GUARDAR RUTA', style: TextStyle(fontWeight: FontWeight.bold)))),
|
||||||
|
const SizedBox(height: 30),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _section(String title) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Text(title, style: const TextStyle(fontWeight: FontWeight.bold,
|
||||||
|
color: AppColors.verdeAdmin, fontSize: 15)));
|
||||||
|
|
||||||
|
Widget _field(TextEditingController ctrl, String label, IconData icon) =>
|
||||||
|
TextField(controller: ctrl,
|
||||||
|
decoration: InputDecoration(labelText: label,
|
||||||
|
prefixIcon: Icon(icon, color: AppColors.verdeAdmin),
|
||||||
|
border: const OutlineInputBorder(), filled: true, fillColor: Colors.white));
|
||||||
|
|
||||||
|
Widget _timeButton(String label, String value, VoidCallback onTap) =>
|
||||||
|
InkWell(onTap: onTap,
|
||||||
|
child: Container(padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.grey.shade400)),
|
||||||
|
child: Row(children: [
|
||||||
|
const Icon(Icons.access_time, color: AppColors.verdeAdmin, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text(label, style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
|
||||||
|
Text(value, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
|
]),
|
||||||
|
])));
|
||||||
|
|
||||||
|
String _turnoLabel(String t) => t == 'MATUTINO' ? '🌄 Matutino'
|
||||||
|
: t == 'VESPERTINO' ? '🌅 Vespertino' : '🌙 Nocturno';
|
||||||
|
|
||||||
|
@override void dispose() { _nombreCtrl.dispose(); _routeIdCtrl.dispose(); super.dispose(); }
|
||||||
|
}
|
||||||
189
lib/screens/citizen/add_domicilio_screen.dart
Normal file
189
lib/screens/citizen/add_domicilio_screen.dart
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../core/app_colors.dart';
|
||||||
|
import '../../data/celaya_colonias.dart';
|
||||||
|
import '../../data/colonies_data.dart';
|
||||||
|
import '../../database/db_helper.dart';
|
||||||
|
import '../../models/models.dart';
|
||||||
|
import '../../models/route_model.dart';
|
||||||
|
import '../../services/auth_service.dart';
|
||||||
|
|
||||||
|
class AddDomicilioScreen extends StatefulWidget {
|
||||||
|
final DomicilioModel? editing;
|
||||||
|
const AddDomicilioScreen({super.key, this.editing});
|
||||||
|
@override State<AddDomicilioScreen> createState() => _AddDomicilioScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddDomicilioScreenState extends State<AddDomicilioScreen> {
|
||||||
|
final _calleCtrl = TextEditingController();
|
||||||
|
final _aliasCtrl = TextEditingController(text: 'Casa');
|
||||||
|
String? _coloniaSeleccionada;
|
||||||
|
ColonyModel? _coloniaData;
|
||||||
|
bool _loading = false;
|
||||||
|
String _searchQuery = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.editing != null) {
|
||||||
|
_calleCtrl.text = widget.editing!.calle;
|
||||||
|
_aliasCtrl.text = widget.editing!.alias;
|
||||||
|
_coloniaSeleccionada = widget.editing!.colonia;
|
||||||
|
_coloniaData = getColonyByName(widget.editing!.colonia);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> get _filteredColonias {
|
||||||
|
if (_searchQuery.isEmpty) return celayaColonias;
|
||||||
|
return celayaColonias
|
||||||
|
.where((c) => c.toLowerCase().contains(_searchQuery.toLowerCase()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _guardar() async {
|
||||||
|
if (_calleCtrl.text.trim().isEmpty || _coloniaSeleccionada == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||||
|
content: Text('Por favor completa todos los campos'),
|
||||||
|
backgroundColor: AppColors.rojoError));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _loading = true);
|
||||||
|
|
||||||
|
final auth = context.read<AuthService>();
|
||||||
|
final routeData = getColonyByName(_coloniaSeleccionada!);
|
||||||
|
final routeId = routeData?.routeId ?? 'RUTA-01';
|
||||||
|
final horario = routeData?.horarioEstimado ?? 'Matutino (06:00-08:00)';
|
||||||
|
|
||||||
|
if (widget.editing != null) {
|
||||||
|
// Editar existente — eliminar y volver a insertar
|
||||||
|
await DbHelper.deleteDomicilio(widget.editing!.id!);
|
||||||
|
}
|
||||||
|
|
||||||
|
final dom = DomicilioModel(
|
||||||
|
userId: auth.currentUser!.id!,
|
||||||
|
alias: _aliasCtrl.text.trim(),
|
||||||
|
calle: _calleCtrl.text.trim(),
|
||||||
|
colonia: _coloniaSeleccionada!,
|
||||||
|
routeId: routeId,
|
||||||
|
horarioEstimado: horario,
|
||||||
|
);
|
||||||
|
await DbHelper.insertDomicilio(dom);
|
||||||
|
await auth.reloadDomicilios();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _loading = false);
|
||||||
|
Navigator.pop(context, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppColors.grisFondo,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
|
||||||
|
title: Text(widget.editing != null ? 'Editar Domicilio' : 'Agregar Domicilio'),
|
||||||
|
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||||
|
child: Container(height: 4, color: AppColors.dorado)),
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
// Alias
|
||||||
|
TextField(
|
||||||
|
controller: _aliasCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Alias (ej. Casa, Trabajo, Familia)',
|
||||||
|
prefixIcon: Icon(Icons.label_outline, color: AppColors.guindaPrimary),
|
||||||
|
border: OutlineInputBorder(), filled: true, fillColor: Colors.white),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// Calle
|
||||||
|
TextField(
|
||||||
|
controller: _calleCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Calle y número',
|
||||||
|
prefixIcon: Icon(Icons.signpost_outlined, color: AppColors.guindaPrimary),
|
||||||
|
border: OutlineInputBorder(), filled: true, fillColor: Colors.white),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('Colonia', style: TextStyle(fontWeight: FontWeight.bold,
|
||||||
|
color: AppColors.guindaPrimary, fontSize: 15)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// Buscador de colonias
|
||||||
|
TextField(
|
||||||
|
onChanged: (v) => setState(() => _searchQuery = v),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Buscar colonia...',
|
||||||
|
prefixIcon: Icon(Icons.search),
|
||||||
|
border: OutlineInputBorder(), filled: true, fillColor: Colors.white,
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// Lista de colonias
|
||||||
|
Container(
|
||||||
|
height: 240,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white, borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.grey.shade300)),
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: _filteredColonias.length,
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final c = _filteredColonias[i];
|
||||||
|
final isSelected = c == _coloniaSeleccionada;
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
title: Text(c, style: TextStyle(fontSize: 13,
|
||||||
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||||
|
color: isSelected ? AppColors.guindaPrimary : AppColors.negroTexto)),
|
||||||
|
trailing: isSelected
|
||||||
|
? const Icon(Icons.check_circle, color: AppColors.guindaPrimary, size: 18)
|
||||||
|
: null,
|
||||||
|
tileColor: isSelected ? AppColors.guindaPrimary.withOpacity(0.08) : null,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_coloniaSeleccionada = c;
|
||||||
|
_coloniaData = getColonyByName(c);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_coloniaData != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.guindaPrimary.withOpacity(0.08),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: AppColors.guindaPrimary.withOpacity(0.3))),
|
||||||
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text('Ruta asignada: ${_coloniaData!.routeId}',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold,
|
||||||
|
color: AppColors.guindaPrimary, fontSize: 13)),
|
||||||
|
Text('Horario: ${_coloniaData!.horarioEstimado}',
|
||||||
|
style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(width: double.infinity, height: 50,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: _loading ? null : _guardar,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||||
|
icon: _loading
|
||||||
|
? const SizedBox(width: 18, height: 18,
|
||||||
|
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||||
|
: const Icon(Icons.save),
|
||||||
|
label: Text(widget.editing != null ? 'ACTUALIZAR' : 'GUARDAR DOMICILIO',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold)))),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override void dispose() { _calleCtrl.dispose(); _aliasCtrl.dispose(); super.dispose(); }
|
||||||
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.dart';
|
||||||
import '../../services/auth_service.dart';
|
|
||||||
import '../../services/route_simulator_service.dart';
|
|
||||||
import '../../database/db_helper.dart';
|
import '../../database/db_helper.dart';
|
||||||
import '../../models/models.dart';
|
import '../../models/models.dart';
|
||||||
|
import '../../services/auth_service.dart';
|
||||||
|
import '../../services/route_simulator_service.dart';
|
||||||
import '../../data/routes_data.dart';
|
import '../../data/routes_data.dart';
|
||||||
import '../../widgets/route_map_widget.dart';
|
import '../../widgets/route_map_widget.dart';
|
||||||
import 'citizen_guia_screen.dart';
|
import 'citizen_guia_screen.dart';
|
||||||
import 'citizen_reporte_screen.dart';
|
import 'citizen_reporte_screen.dart';
|
||||||
|
import 'add_domicilio_screen.dart';
|
||||||
|
import 'review_screen.dart';
|
||||||
|
|
||||||
class CitizenHomeScreen extends StatefulWidget {
|
class CitizenHomeScreen extends StatefulWidget {
|
||||||
const CitizenHomeScreen({super.key});
|
const CitizenHomeScreen({super.key});
|
||||||
@@ -22,7 +24,7 @@ class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final auth = context.watch<AuthService>();
|
final auth = context.watch<AuthService>();
|
||||||
final sim = context.watch<RouteSimulatorService>();
|
final sim = context.watch<RouteSimulatorService>();
|
||||||
final dom = auth.primaryDomicilio; // domicilio del ciudadano
|
final dom = auth.primaryDomicilio;
|
||||||
final last = dom != null ? sim.getNotificationForRoute(dom.routeId) : null;
|
final last = dom != null ? sim.getNotificationForRoute(dom.routeId) : null;
|
||||||
|
|
||||||
final tabs = [
|
final tabs = [
|
||||||
@@ -38,7 +40,8 @@ class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
|
|||||||
if (last != null)
|
if (last != null)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: MediaQuery.of(context).padding.top + 8, left: 0, right: 0,
|
top: MediaQuery.of(context).padding.top + 8, left: 0, right: 0,
|
||||||
child: _NotifBanner(notif: last, onDismiss: () => sim.dismissRouteNotification(dom?.routeId ?? '')),
|
child: _NotifBanner(notif: last,
|
||||||
|
onDismiss: () => sim.dismissRouteNotification(dom?.routeId ?? '')),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
@@ -47,19 +50,18 @@ class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
|
|||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
indicatorColor: AppColors.guindaPrimary.withOpacity(0.15),
|
indicatorColor: AppColors.guindaPrimary.withOpacity(0.15),
|
||||||
destinations: const [
|
destinations: const [
|
||||||
NavigationDestination(icon: Icon(Icons.home_outlined),
|
NavigationDestination(icon:Icon(Icons.home_outlined),
|
||||||
selectedIcon: Icon(Icons.home, color: AppColors.guindaPrimary), label: 'Inicio'),
|
selectedIcon:Icon(Icons.home,color:AppColors.guindaPrimary),label:'Inicio'),
|
||||||
NavigationDestination(icon: Icon(Icons.eco_outlined),
|
NavigationDestination(icon:Icon(Icons.eco_outlined),
|
||||||
selectedIcon: Icon(Icons.eco, color: AppColors.guindaPrimary), label: 'Guía'),
|
selectedIcon:Icon(Icons.eco,color:AppColors.guindaPrimary),label:'Guía'),
|
||||||
NavigationDestination(icon: Icon(Icons.report_outlined),
|
NavigationDestination(icon:Icon(Icons.report_outlined),
|
||||||
selectedIcon: Icon(Icons.report, color: AppColors.guindaPrimary), label: 'Reportar'),
|
selectedIcon:Icon(Icons.report,color:AppColors.guindaPrimary),label:'Reportar'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tab principal (StatefulWidget para cargar status de ruta) ─────────────
|
|
||||||
class _HomeTab extends StatefulWidget {
|
class _HomeTab extends StatefulWidget {
|
||||||
final AuthService auth;
|
final AuthService auth;
|
||||||
final RouteSimulatorService sim;
|
final RouteSimulatorService sim;
|
||||||
@@ -69,98 +71,90 @@ class _HomeTab extends StatefulWidget {
|
|||||||
|
|
||||||
class _HomeTabState extends State<_HomeTab> {
|
class _HomeTabState extends State<_HomeTab> {
|
||||||
RouteStatusModel? _routeStatus;
|
RouteStatusModel? _routeStatus;
|
||||||
|
RouteDefinitionModel? _routeDef;
|
||||||
|
|
||||||
@override
|
@override void initState() { super.initState(); _loadStatus(); }
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_loadStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadStatus() async {
|
Future<void> _loadStatus() async {
|
||||||
final dom = widget.auth.primaryDomicilio;
|
final dom = widget.auth.primaryDomicilio;
|
||||||
if (dom == null) return;
|
if (dom == null) return;
|
||||||
final s = await DbHelper.getRouteStatus(dom.routeId);
|
final s = await DbHelper.getRouteStatus(dom.routeId);
|
||||||
if (mounted) setState(() => _routeStatus = s);
|
final rd = await DbHelper.getRouteDefinitionById(dom.routeId);
|
||||||
|
if (mounted) setState(() { _routeStatus = s; _routeDef = rd; });
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get _isRouteProblematic {
|
bool get _isRouteProblematic {
|
||||||
final s = _routeStatus?.status ?? RouteStatus.enRuta;
|
final s = _routeStatus?.status ?? RouteStatus.enRuta;
|
||||||
return s == RouteStatus.cancelada ||
|
return s == RouteStatus.cancelada || s == RouteStatus.fallaMecanica || s == RouteStatus.retrasada;
|
||||||
s == RouteStatus.fallaMecanica ||
|
|
||||||
s == RouteStatus.retrasada;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final dom = widget.auth.primaryDomicilio;
|
final dom = widget.auth.primaryDomicilio;
|
||||||
|
final allDoms = widget.auth.allDomicilios;
|
||||||
final routeId = dom?.routeId ?? '';
|
final routeId = dom?.routeId ?? '';
|
||||||
final route = dom != null ? getRouteById(dom.routeId) : null;
|
final route = dom != null ? getRouteById(dom.routeId) : null;
|
||||||
final isTruckClose = widget.sim.isTruckClose(routeId);
|
final isTruckClose = widget.sim.isTruckClose(routeId);
|
||||||
final status = _routeStatus?.status ?? RouteStatus.enRuta;
|
final isCompleted = widget.sim.isRouteCompleted(routeId);
|
||||||
|
final needsReview = widget.sim.needsReviewPrompt(routeId);
|
||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: _loadStatus,
|
onRefresh: _loadStatus,
|
||||||
child: CustomScrollView(slivers: [
|
child: CustomScrollView(slivers: [
|
||||||
SliverAppBar(
|
SliverAppBar(expandedHeight: 120, pinned: true,
|
||||||
expandedHeight: 120, pinned: true,
|
|
||||||
backgroundColor: AppColors.guindaPrimary,
|
backgroundColor: AppColors.guindaPrimary,
|
||||||
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||||
child: Container(height: 4, color: AppColors.dorado)),
|
child: Container(height: 4, color: AppColors.dorado)),
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
flexibleSpace: FlexibleSpaceBar(background: Container(
|
||||||
background: Container(
|
color: AppColors.guindaPrimary,
|
||||||
color: AppColors.guindaPrimary,
|
padding: const EdgeInsets.fromLTRB(20, 50, 20, 16),
|
||||||
padding: const EdgeInsets.fromLTRB(20, 50, 20, 16),
|
child: Row(children: [
|
||||||
child: Row(children: [
|
const Icon(Icons.delete_sweep_rounded, color: AppColors.dorado, size: 30),
|
||||||
const Icon(Icons.delete_sweep_rounded, color: AppColors.dorado, size: 30),
|
const SizedBox(width: 12),
|
||||||
const SizedBox(width: 12),
|
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Expanded(child: Column(
|
mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Text('Hola, ${widget.auth.currentUser?.nombre.split(' ').first ?? ''}',
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
|
||||||
children: [
|
const Text('Celaya Limpia', style: TextStyle(color: AppColors.dorado, fontSize: 12)),
|
||||||
Text('Hola, ${widget.auth.currentUser?.nombre.split(' ').first ?? ''}',
|
])),
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
|
IconButton(icon: const Icon(Icons.logout, color: Colors.white70),
|
||||||
const Text('Celaya Limpia', style: TextStyle(color: AppColors.dorado, fontSize: 12)),
|
onPressed: () async {
|
||||||
],
|
await widget.auth.logout();
|
||||||
)),
|
if (context.mounted) Navigator.pushReplacementNamed(context, '/login');
|
||||||
IconButton(
|
}),
|
||||||
icon: const Icon(Icons.logout, color: Colors.white70),
|
]),
|
||||||
onPressed: () async {
|
)),
|
||||||
await widget.auth.logout();
|
|
||||||
if (context.mounted) Navigator.pushReplacementNamed(context, '/login');
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
sliver: SliverList(delegate: SliverChildListDelegate([
|
sliver: SliverList(delegate: SliverChildListDelegate([
|
||||||
|
|
||||||
// ── Si la ruta tiene problema → mostrar alerta en vez de ETA/mapa
|
// ── Selector de domicilio ────────────────────────────────────
|
||||||
if (_isRouteProblematic) ...[
|
if (allDoms.length > 1) _DomicilioSelector(
|
||||||
_RouteStatusBanner(status: _routeStatus!),
|
auth: widget.auth, onChanged: _loadStatus),
|
||||||
const SizedBox(height: 12),
|
|
||||||
] else ...[
|
// ── Prompt de calificación ───────────────────────────────────
|
||||||
// ETA Card normal
|
if (needsReview && dom != null)
|
||||||
|
_ReviewPromptCard(routeId: routeId, colonia: dom.colonia,
|
||||||
|
sim: widget.sim),
|
||||||
|
|
||||||
|
// ── Estado de ruta (cancelada/falla/retrasada) ───────────────
|
||||||
|
if (_isRouteProblematic)
|
||||||
|
_RouteStatusBanner(status: _routeStatus!)
|
||||||
|
else ...[
|
||||||
|
// ETA Card
|
||||||
_EtaCard(sim: widget.sim, routeId: routeId, dom: dom, route: route),
|
_EtaCard(sim: widget.sim, routeId: routeId, dom: dom, route: route),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
// Mapa solo cuando camión está cerca
|
|
||||||
if (isTruckClose && route != null) ...[
|
// Información detallada de la ruta (días y horario)
|
||||||
Container(
|
if (_routeDef != null) _RouteInfoCard(routeDef: _routeDef!),
|
||||||
padding: const EdgeInsets.all(10),
|
if (_routeDef == null && dom != null) _BasicRouteInfo(dom: dom),
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.orange.shade50,
|
const SizedBox(height: 12),
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
border: Border.all(color: Colors.orange.shade300),
|
// Mapa solo cuando camión está cerca (<15 min)
|
||||||
),
|
if (isTruckClose && route != null && !isCompleted) ...[
|
||||||
child: const Row(children: [
|
_WarningNoPursue(),
|
||||||
Icon(Icons.location_on, color: Colors.orange, size: 18),
|
|
||||||
SizedBox(width: 6),
|
|
||||||
Expanded(child: Text('📍 El camión está cerca — mapa activado',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.orange, fontSize: 12))),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
RouteMapWidget(route: route, simulator: widget.sim, height: 220),
|
RouteMapWidget(route: route, simulator: widget.sim, height: 220),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -168,81 +162,17 @@ class _HomeTabState extends State<_HomeTab> {
|
|||||||
],
|
],
|
||||||
|
|
||||||
// Aviso privacidad
|
// Aviso privacidad
|
||||||
Container(
|
_PrivacyBanner(),
|
||||||
padding: const EdgeInsets.all(10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.amber.shade50, borderRadius: BorderRadius.circular(8),
|
|
||||||
border: Border.all(color: Colors.amber.shade300),
|
|
||||||
),
|
|
||||||
child: const Row(children: [
|
|
||||||
Icon(Icons.shield_outlined, color: Colors.amber, size: 18),
|
|
||||||
SizedBox(width: 6),
|
|
||||||
Expanded(child: Text('🔒 Solo ves la información de tu ruta asignada.',
|
|
||||||
style: TextStyle(fontSize: 11, color: Colors.black87))),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Info domicilio
|
// Mis domicilios
|
||||||
if (dom != null)
|
_DomiciliosCard(auth: widget.auth),
|
||||||
Card(child: Padding(
|
const SizedBox(height: 12),
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
||||||
const Row(children: [
|
|
||||||
Icon(Icons.location_on, color: AppColors.guindaPrimary, size: 16),
|
|
||||||
SizedBox(width: 6),
|
|
||||||
Text('Mi Domicilio', style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)),
|
|
||||||
]),
|
|
||||||
const Divider(),
|
|
||||||
Text(dom.calle, style: const TextStyle(fontSize: 13)),
|
|
||||||
Text('${dom.colonia} — ${dom.routeId}',
|
|
||||||
style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)),
|
|
||||||
Text(dom.horarioEstimado,
|
|
||||||
style: const TextStyle(color: AppColors.grisTexto, fontSize: 11)),
|
|
||||||
]),
|
|
||||||
)),
|
|
||||||
|
|
||||||
// Historial notificaciones
|
// Historial notificaciones
|
||||||
if (widget.sim.history.isNotEmpty) ...[
|
if (widget.sim.historyForRoute(routeId).isNotEmpty)
|
||||||
const SizedBox(height: 12),
|
_HistorialCard(sim: widget.sim, routeId: routeId),
|
||||||
Card(child: Padding(
|
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
||||||
const Text('Alertas recientes',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)),
|
|
||||||
const Divider(),
|
|
||||||
...widget.sim.history.take(4).map((n) {
|
|
||||||
final color = n.event == NotifEvent.truckProximity
|
|
||||||
? AppColors.naranjaAlerta
|
|
||||||
: n.event == NotifEvent.routeCompleted
|
|
||||||
? AppColors.verdeExito
|
|
||||||
: n.event == NotifEvent.routeCancelled
|
|
||||||
? AppColors.rojoError
|
|
||||||
: AppColors.azulInfo;
|
|
||||||
final icon = n.event == NotifEvent.truckProximity
|
|
||||||
? Icons.warning_amber
|
|
||||||
: n.event == NotifEvent.routeCompleted
|
|
||||||
? Icons.check_circle
|
|
||||||
: n.event == NotifEvent.routeCancelled
|
|
||||||
? Icons.cancel
|
|
||||||
: Icons.local_shipping;
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
child: Row(children: [
|
|
||||||
Icon(icon, size: 14, color: color),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Expanded(child: Text(n.title,
|
|
||||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500))),
|
|
||||||
Text(
|
|
||||||
'${n.timestamp.hour.toString().padLeft(2, '0')}:${n.timestamp.minute.toString().padLeft(2, '0')}',
|
|
||||||
style: const TextStyle(fontSize: 10, color: AppColors.grisTexto),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
)),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 80),
|
const SizedBox(height: 80),
|
||||||
])),
|
])),
|
||||||
),
|
),
|
||||||
@@ -251,6 +181,223 @@ class _HomeTabState extends State<_HomeTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Selector de domicilio activo ──────────────────────────────────────────
|
||||||
|
class _DomicilioSelector extends StatelessWidget {
|
||||||
|
final AuthService auth; final VoidCallback onChanged;
|
||||||
|
const _DomicilioSelector({required this.auth, required this.onChanged});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: AppColors.guindaPrimary.withOpacity(0.3)),
|
||||||
|
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 4)]),
|
||||||
|
child: DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton<int>(
|
||||||
|
isExpanded: true,
|
||||||
|
value: auth.primaryDomicilio?.id,
|
||||||
|
icon: const Icon(Icons.swap_horiz, color: AppColors.guindaPrimary),
|
||||||
|
items: auth.allDomicilios.map((d) => DropdownMenuItem(
|
||||||
|
value: d.id,
|
||||||
|
child: Row(children: [
|
||||||
|
Icon(d.isPrimary ? Icons.home : Icons.location_on_outlined,
|
||||||
|
color: AppColors.guindaPrimary, size: 16),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(child: Text('${d.alias} — ${d.colonia}',
|
||||||
|
style: const TextStyle(fontSize: 13), overflow: TextOverflow.ellipsis)),
|
||||||
|
]))).toList(),
|
||||||
|
onChanged: (id) async {
|
||||||
|
if (id != null) {
|
||||||
|
await DbHelper.setPrimaryDomicilio(id, auth.currentUser!.id!);
|
||||||
|
await auth.reloadDomicilios();
|
||||||
|
onChanged();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Prompt de reseña ──────────────────────────────────────────────────────
|
||||||
|
class _ReviewPromptCard extends StatelessWidget {
|
||||||
|
final String routeId, colonia; final RouteSimulatorService sim;
|
||||||
|
const _ReviewPromptCard({required this.routeId, required this.colonia, required this.sim});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Card(
|
||||||
|
color: Colors.amber.shade50,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12),
|
||||||
|
side: BorderSide(color: Colors.amber.shade300, width: 1.5)),
|
||||||
|
child: Padding(padding: const EdgeInsets.all(14), child: Column(children: [
|
||||||
|
const Row(children: [
|
||||||
|
Text('⭐', style: TextStyle(fontSize: 24)),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Expanded(child: Text('¿Cómo estuvo el servicio de hoy?',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14))),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
const Text('El camión pasó por tu colonia. Toma un momento para calificar el servicio.',
|
||||||
|
style: TextStyle(fontSize: 12, color: AppColors.grisTexto)),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(children: [
|
||||||
|
Expanded(child: ElevatedButton.icon(
|
||||||
|
onPressed: () => Navigator.push(context, MaterialPageRoute(
|
||||||
|
builder: (_) => ReviewScreen(routeId: routeId, colonia: colonia))),
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.amber,
|
||||||
|
foregroundColor: Colors.black87),
|
||||||
|
icon: const Icon(Icons.star, size: 16),
|
||||||
|
label: const Text('Calificar', style: TextStyle(fontWeight: FontWeight.bold)))),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
TextButton(onPressed: () => sim.clearReviewPrompt(routeId),
|
||||||
|
child: const Text('Después', style: TextStyle(color: AppColors.grisTexto))),
|
||||||
|
]),
|
||||||
|
])));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Info detallada de la ruta ─────────────────────────────────────────────
|
||||||
|
class _RouteInfoCard extends StatelessWidget {
|
||||||
|
final RouteDefinitionModel routeDef;
|
||||||
|
const _RouteInfoCard({required this.routeDef});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Card(
|
||||||
|
child: Padding(padding: const EdgeInsets.all(14), child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
const Row(children: [
|
||||||
|
Icon(Icons.schedule, color: AppColors.guindaPrimary, size: 16),
|
||||||
|
SizedBox(width: 6),
|
||||||
|
Text('Información de tu ruta', style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)),
|
||||||
|
]),
|
||||||
|
const Divider(),
|
||||||
|
Text(routeDef.nombre, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(children: [
|
||||||
|
const Icon(Icons.access_time, size: 13, color: AppColors.grisTexto),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text('${routeDef.horaInicio} — ${routeDef.horaFin} (${_turnoLabel(routeDef.turno)})',
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppColors.negroTexto)),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
const Icon(Icons.calendar_today, size: 13, color: AppColors.grisTexto),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(child: Text(
|
||||||
|
routeDef.dias.map(AppDias.label).join(', '),
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppColors.negroTexto))),
|
||||||
|
]),
|
||||||
|
])));
|
||||||
|
|
||||||
|
String _turnoLabel(String t) => t=='MATUTINO'?'🌄 Matutino':t=='VESPERTINO'?'🌅 Vespertino':'🌙 Nocturno';
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BasicRouteInfo extends StatelessWidget {
|
||||||
|
final DomicilioModel dom;
|
||||||
|
const _BasicRouteInfo({required this.dom});
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Card(
|
||||||
|
child: Padding(padding: const EdgeInsets.all(14), child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
const Row(children: [
|
||||||
|
Icon(Icons.schedule, color: AppColors.guindaPrimary, size: 16),
|
||||||
|
SizedBox(width: 6),
|
||||||
|
Text('Tu servicio de recolección', style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)),
|
||||||
|
]),
|
||||||
|
const Divider(),
|
||||||
|
Text('Ruta: ${dom.routeId}', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
Text('Horario: ${dom.horarioEstimado}',
|
||||||
|
style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)),
|
||||||
|
])));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Aviso anti-persecución ────────────────────────────────────────────────
|
||||||
|
class _WarningNoPursue extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(color: Colors.red.shade50, borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.red.shade300)),
|
||||||
|
child: const Row(children: [
|
||||||
|
Icon(Icons.warning_amber_rounded, color: AppColors.rojoError, size: 20),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Expanded(child: Text(
|
||||||
|
'⚠️ Ya es momento de sacar tu basura.\n'
|
||||||
|
'🚫 NO persigas ni interceptes el camión en movimiento.\n'
|
||||||
|
'✅ Coloca tus bolsas en la acera y espera.',
|
||||||
|
style: TextStyle(fontSize: 11, color: AppColors.rojoError, fontWeight: FontWeight.w500))),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mis domicilios ────────────────────────────────────────────────────────
|
||||||
|
class _DomiciliosCard extends StatelessWidget {
|
||||||
|
final AuthService auth;
|
||||||
|
const _DomiciliosCard({required this.auth});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final doms = auth.allDomicilios;
|
||||||
|
return Card(child: Padding(padding: const EdgeInsets.all(14), child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Row(children: [
|
||||||
|
const Icon(Icons.home_outlined, color: AppColors.guindaPrimary, size: 16),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
const Expanded(child: Text('Mis Domicilios',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.guindaPrimary))),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
final result = await Navigator.push(context,
|
||||||
|
MaterialPageRoute(builder: (_) => const AddDomicilioScreen()));
|
||||||
|
if (result == true) await auth.reloadDomicilios();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.add, size: 14),
|
||||||
|
label: const Text('Agregar', style: TextStyle(fontSize: 12)),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: AppColors.guindaPrimary)),
|
||||||
|
]),
|
||||||
|
const Divider(),
|
||||||
|
if (doms.isEmpty)
|
||||||
|
const Text('Sin domicilios registrados',
|
||||||
|
style: TextStyle(color: AppColors.grisTexto, fontSize: 12))
|
||||||
|
else
|
||||||
|
...doms.map((d) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(children: [
|
||||||
|
Icon(d.isPrimary ? Icons.home : Icons.location_on_outlined,
|
||||||
|
color: d.isPrimary ? AppColors.guindaPrimary : AppColors.grisTexto, size: 16),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text('${d.alias} — ${d.colonia}',
|
||||||
|
style: TextStyle(fontWeight: d.isPrimary ? FontWeight.bold : FontWeight.normal,
|
||||||
|
fontSize: 12)),
|
||||||
|
Text(d.calle, style: const TextStyle(color: AppColors.grisTexto, fontSize: 11)),
|
||||||
|
Text('${d.routeId} • ${d.horarioEstimado}',
|
||||||
|
style: const TextStyle(color: AppColors.grisTexto, fontSize: 10)),
|
||||||
|
])),
|
||||||
|
if (!d.isPrimary)
|
||||||
|
IconButton(icon: const Icon(Icons.star_border, size: 16, color: AppColors.dorado),
|
||||||
|
tooltip: 'Hacer principal',
|
||||||
|
onPressed: () async {
|
||||||
|
await DbHelper.setPrimaryDomicilio(d.id!, auth.currentUser!.id!);
|
||||||
|
await auth.reloadDomicilios();
|
||||||
|
}),
|
||||||
|
IconButton(icon: const Icon(Icons.edit_outlined, size: 14, color: AppColors.grisTexto),
|
||||||
|
onPressed: () async {
|
||||||
|
final result = await Navigator.push(context, MaterialPageRoute(
|
||||||
|
builder: (_) => AddDomicilioScreen(editing: d)));
|
||||||
|
if (result == true) await auth.reloadDomicilios();
|
||||||
|
}),
|
||||||
|
if (!d.isPrimary)
|
||||||
|
IconButton(icon: const Icon(Icons.delete_outline, size: 14, color: AppColors.rojoError),
|
||||||
|
onPressed: () async {
|
||||||
|
await DbHelper.deleteDomicilio(d.id!);
|
||||||
|
await auth.reloadDomicilios();
|
||||||
|
}),
|
||||||
|
]))),
|
||||||
|
])));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Banner de ruta con problema ───────────────────────────────────────────
|
// ── Banner de ruta con problema ───────────────────────────────────────────
|
||||||
class _RouteStatusBanner extends StatelessWidget {
|
class _RouteStatusBanner extends StatelessWidget {
|
||||||
final RouteStatusModel status;
|
final RouteStatusModel status;
|
||||||
@@ -261,188 +408,171 @@ class _RouteStatusBanner extends StatelessWidget {
|
|||||||
final isCancelled = status.status == RouteStatus.cancelada;
|
final isCancelled = status.status == RouteStatus.cancelada;
|
||||||
final isFalla = status.status == RouteStatus.fallaMecanica;
|
final isFalla = status.status == RouteStatus.fallaMecanica;
|
||||||
final isRetrasada = status.status == RouteStatus.retrasada;
|
final isRetrasada = status.status == RouteStatus.retrasada;
|
||||||
|
final color = isCancelled ? AppColors.rojoError : isFalla ? Colors.red.shade800 : AppColors.naranjaAlerta;
|
||||||
final color = isCancelled ? AppColors.rojoError
|
final icon = isCancelled ? Icons.cancel : isFalla ? Icons.build : Icons.access_time;
|
||||||
: isFalla ? Colors.red.shade800
|
|
||||||
: AppColors.naranjaAlerta;
|
|
||||||
|
|
||||||
final icon = isCancelled ? Icons.cancel
|
|
||||||
: isFalla ? Icons.build
|
|
||||||
: Icons.access_time;
|
|
||||||
|
|
||||||
final titulo = isCancelled ? '❌ Ruta Cancelada Hoy'
|
final titulo = isCancelled ? '❌ Ruta Cancelada Hoy'
|
||||||
: isFalla ? '🔧 Falla Mecánica en Servicio'
|
: isFalla ? '🔧 Falla Mecánica en Servicio' : '⏱️ Servicio con Retraso';
|
||||||
: '⏱️ Servicio con Retraso';
|
|
||||||
|
|
||||||
final descripcion = isCancelled
|
|
||||||
? 'El servicio de recolección de tu colonia no se realizará hoy. Favor de guardar tus residuos para la próxima jornada.'
|
|
||||||
: isFalla
|
|
||||||
? 'El camión asignado a tu sector presentó una falla mecánica. El Ayuntamiento está atendiendo la situación.'
|
|
||||||
: 'El camión de tu sector presenta un retraso en su recorrido. El servicio se realizará, pero con demora.';
|
|
||||||
|
|
||||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
// Alerta principal
|
Container(width: double.infinity, padding: const EdgeInsets.all(16),
|
||||||
Container(
|
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(12)),
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: color,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
boxShadow: [BoxShadow(color: color.withOpacity(0.4), blurRadius: 8, offset: const Offset(0, 4))],
|
|
||||||
),
|
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
Row(children: [
|
Row(children: [
|
||||||
Icon(icon, color: Colors.white, size: 26),
|
Icon(icon, color: Colors.white, size: 26),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(child: Text(titulo,
|
Expanded(child: Text(titulo, style: const TextStyle(color: Colors.white,
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold))),
|
fontSize: 17, fontWeight: FontWeight.bold))),
|
||||||
]),
|
]),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 8),
|
||||||
Text(descripcion, style: const TextStyle(color: Colors.white, fontSize: 13, height: 1.4)),
|
Text(isCancelled
|
||||||
]),
|
? 'El servicio no se realizará hoy. Guarda tus residuos para mañana.'
|
||||||
),
|
: isFalla
|
||||||
|
? 'El camión presentó una falla. El Ayuntamiento atiende la situación.'
|
||||||
// Mensaje del administrador (posible solución)
|
: 'El camión presenta un retraso. El servicio se realizará con demora.',
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 13)),
|
||||||
|
])),
|
||||||
if (status.mensaje != null && status.mensaje!.isNotEmpty) ...[
|
if (status.mensaje != null && status.mensaje!.isNotEmpty) ...[
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Container(
|
Container(width: double.infinity, padding: const EdgeInsets.all(14),
|
||||||
width: double.infinity,
|
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10),
|
||||||
padding: const EdgeInsets.all(14),
|
border: Border.all(color: color.withOpacity(0.4))),
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
border: Border.all(color: color.withOpacity(0.4)),
|
|
||||||
),
|
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
Row(children: [
|
Row(children: [
|
||||||
Icon(Icons.admin_panel_settings, color: color, size: 16),
|
Icon(Icons.admin_panel_settings, color: color, size: 16),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text('Mensaje del Ayuntamiento',
|
Text('Mensaje del Ayuntamiento', style: TextStyle(
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, color: color, fontSize: 13)),
|
fontWeight: FontWeight.bold, color: color, fontSize: 13)),
|
||||||
]),
|
]),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(status.mensaje!,
|
Text(status.mensaje!, style: const TextStyle(fontSize: 13)),
|
||||||
style: const TextStyle(fontSize: 13, color: AppColors.negroTexto, height: 1.4)),
|
])),
|
||||||
]),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
|
|
||||||
// Consejo ciudadano
|
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Container(
|
Container(padding: const EdgeInsets.all(12),
|
||||||
padding: const EdgeInsets.all(12),
|
decoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(8),
|
||||||
decoration: BoxDecoration(
|
border: Border.all(color: Colors.grey.shade300)),
|
||||||
color: Colors.grey.shade100,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
border: Border.all(color: Colors.grey.shade300),
|
|
||||||
),
|
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
const Text('💡 Recomendaciones:',
|
const Text('💡 Recomendaciones:', style: TextStyle(fontWeight: FontWeight.bold,
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: AppColors.grisTexto)),
|
fontSize: 12, color: AppColors.grisTexto)),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
if (isCancelled)
|
Text(isCancelled
|
||||||
const Text('• Guarda tus bolsas en un lugar cerrado\n'
|
? '• Guarda tus bolsas en lugar cerrado\n• No dejes residuos en la acera\n• Revisa la app mañana'
|
||||||
'• No dejes residuos en la acera\n'
|
: isRetrasada
|
||||||
'• Revisa la app mañana para el horario actualizado',
|
? '• Espera el aviso de 15 minutos antes de sacar tu basura\n• El camión llegará eventualmente\n• Recibe la notificación en esta app'
|
||||||
style: TextStyle(fontSize: 12, color: AppColors.grisTexto)),
|
: '• Espera confirmación del Ayuntamiento\n• Puede enviarse unidad de reemplazo',
|
||||||
if (isFalla)
|
style: const TextStyle(fontSize: 12, color: AppColors.grisTexto)),
|
||||||
const Text('• Espera confirmación del Ayuntamiento\n'
|
])),
|
||||||
'• Puede enviarse una unidad de reemplazo\n'
|
const SizedBox(height: 12),
|
||||||
'• Revisa las alertas en esta pantalla',
|
|
||||||
style: TextStyle(fontSize: 12, color: AppColors.grisTexto)),
|
|
||||||
if (isRetrasada)
|
|
||||||
const Text('• Tu basura será recogida hoy, con demora\n'
|
|
||||||
'• Puedes sacar tus bolsas cuando recibas la alerta\n'
|
|
||||||
'• Recibirás notificación cuando el camión se acerque',
|
|
||||||
style: TextStyle(fontSize: 12, color: AppColors.grisTexto)),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ETA Card ──────────────────────────────────────────────────────────────
|
// ── ETA Card ──────────────────────────────────────────────────────────────
|
||||||
class _EtaCard extends StatelessWidget {
|
class _EtaCard extends StatelessWidget {
|
||||||
final RouteSimulatorService sim;
|
final RouteSimulatorService sim; final String routeId; final dom; final route;
|
||||||
final String routeId;
|
|
||||||
final dom; final route;
|
|
||||||
const _EtaCard({required this.sim, required this.routeId, required this.dom, required this.route});
|
const _EtaCard({required this.sim, required this.routeId, required this.dom, required this.route});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Container(
|
Widget build(BuildContext context) => Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: const LinearGradient(colors: [AppColors.guindaPrimary, AppColors.guindaDark],
|
gradient: const LinearGradient(colors:[AppColors.guindaPrimary,AppColors.guindaDark],
|
||||||
begin: Alignment.topLeft, end: Alignment.bottomRight),
|
begin:Alignment.topLeft,end:Alignment.bottomRight),
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
boxShadow: [BoxShadow(color: AppColors.guindaDark.withOpacity(0.4),
|
boxShadow: [BoxShadow(color:AppColors.guindaDark.withOpacity(0.4),blurRadius:8,offset:const Offset(0,4))]),
|
||||||
blurRadius: 8, offset: const Offset(0, 4))],
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.all(18),
|
padding: const EdgeInsets.all(18),
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
Row(children: [
|
Row(children:[
|
||||||
const Icon(Icons.local_shipping, color: AppColors.dorado, size: 22),
|
const Icon(Icons.local_shipping,color:AppColors.dorado,size:22),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width:8),
|
||||||
Expanded(child: Text(route?.name ?? 'Ruta asignada',
|
Expanded(child:Text(route?.name??dom?.routeId??'Tu ruta',
|
||||||
style: const TextStyle(color: AppColors.dorado, fontSize: 13, fontWeight: FontWeight.w600))),
|
style:const TextStyle(color:AppColors.dorado,fontSize:13,fontWeight:FontWeight.w600))),
|
||||||
]),
|
]),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height:8),
|
||||||
Text(sim.getEtaText(routeId),
|
Text(sim.getEtaText(routeId),
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
|
style:const TextStyle(color:Colors.white,fontSize:16,fontWeight:FontWeight.bold)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height:6),
|
||||||
if (dom != null)
|
if (dom!=null) Text('⏰ ${dom.horarioEstimado}',
|
||||||
Text('⏰ ${dom.horarioEstimado}',
|
style:const TextStyle(color:Colors.white60,fontSize:11)),
|
||||||
style: const TextStyle(color: Colors.white60, fontSize: 11)),
|
const SizedBox(height:10),
|
||||||
const SizedBox(height: 10),
|
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
value: route != null
|
value:route!=null?(sim.getPositionIndex(routeId)+1)/route.positions.length:0,
|
||||||
? (sim.getPositionIndex(routeId) + 1) / route.positions.length : 0,
|
backgroundColor:Colors.white24,
|
||||||
backgroundColor: Colors.white24,
|
valueColor:const AlwaysStoppedAnimation<Color>(AppColors.dorado)),
|
||||||
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.dorado),
|
]));
|
||||||
),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Banner notificación ───────────────────────────────────────────────────
|
// ── Privacidad ────────────────────────────────────────────────────────────
|
||||||
|
class _PrivacyBanner extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Container(
|
||||||
|
padding:const EdgeInsets.all(10),
|
||||||
|
decoration:BoxDecoration(color:Colors.amber.shade50,borderRadius:BorderRadius.circular(8),
|
||||||
|
border:Border.all(color:Colors.amber.shade300)),
|
||||||
|
child:const Row(children:[
|
||||||
|
Icon(Icons.shield_outlined,color:Colors.amber,size:18),
|
||||||
|
SizedBox(width:6),
|
||||||
|
Expanded(child:Text('🔒 Solo ves la información de tu ruta asignada.',
|
||||||
|
style:TextStyle(fontSize:11,color:Colors.black87))),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Historial notificaciones ──────────────────────────────────────────────
|
||||||
|
class _HistorialCard extends StatelessWidget {
|
||||||
|
final RouteSimulatorService sim; final String routeId;
|
||||||
|
const _HistorialCard({required this.sim, required this.routeId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final notifs = sim.historyForRoute(routeId).take(5).toList();
|
||||||
|
return Card(child:Padding(padding:const EdgeInsets.all(14),child:Column(
|
||||||
|
crossAxisAlignment:CrossAxisAlignment.start, children:[
|
||||||
|
const Text('Alertas recientes',style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.guindaPrimary)),
|
||||||
|
const Divider(),
|
||||||
|
...notifs.map((n){
|
||||||
|
final color = n.event==NotifEvent.truckProximity||n.event==NotifEvent.truckApproaching15min
|
||||||
|
?AppColors.naranjaAlerta:n.event==NotifEvent.routeCompleted||n.event==NotifEvent.reviewPrompt
|
||||||
|
?AppColors.verdeExito:n.event==NotifEvent.routeCancelled?AppColors.rojoError:AppColors.azulInfo;
|
||||||
|
return Padding(padding:const EdgeInsets.symmetric(vertical:3),
|
||||||
|
child:Row(children:[
|
||||||
|
Icon(Icons.circle,size:8,color:color),
|
||||||
|
const SizedBox(width:8),
|
||||||
|
Expanded(child:Text(n.title,style:const TextStyle(fontSize:12,fontWeight:FontWeight.w500))),
|
||||||
|
Text('${n.timestamp.hour.toString().padLeft(2,'0')}:${n.timestamp.minute.toString().padLeft(2,'0')}',
|
||||||
|
style:const TextStyle(fontSize:10,color:AppColors.grisTexto)),
|
||||||
|
]));
|
||||||
|
}),
|
||||||
|
])));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Notif Banner ──────────────────────────────────────────────────────────
|
||||||
class _NotifBanner extends StatelessWidget {
|
class _NotifBanner extends StatelessWidget {
|
||||||
final AppNotification notif; final VoidCallback onDismiss;
|
final AppNotification notif; final VoidCallback onDismiss;
|
||||||
const _NotifBanner({required this.notif, required this.onDismiss});
|
const _NotifBanner({required this.notif, required this.onDismiss});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final color = notif.event == NotifEvent.truckProximity
|
final isUrgent = notif.event==NotifEvent.truckProximity||notif.event==NotifEvent.truckApproaching15min;
|
||||||
? AppColors.naranjaAlerta
|
final isReview = notif.event==NotifEvent.reviewPrompt;
|
||||||
: notif.event == NotifEvent.routeCompleted
|
final color = isUrgent?AppColors.naranjaAlerta
|
||||||
? AppColors.verdeExito
|
:isReview?Colors.amber.shade700
|
||||||
: notif.event == NotifEvent.routeCancelled
|
:notif.event==NotifEvent.routeCancelled?AppColors.rojoError
|
||||||
? AppColors.rojoError
|
:notif.event==NotifEvent.gpsLost?Colors.red.shade800
|
||||||
: notif.event == NotifEvent.gpsLost
|
:AppColors.azulInfo;
|
||||||
? Colors.red.shade800
|
return Material(color:Colors.transparent,
|
||||||
: AppColors.azulInfo;
|
child:Container(margin:const EdgeInsets.all(12),
|
||||||
|
decoration:BoxDecoration(color:color,borderRadius:BorderRadius.circular(12),
|
||||||
return Material(
|
boxShadow:const[BoxShadow(color:Colors.black26,blurRadius:8,offset:Offset(0,4))]),
|
||||||
color: Colors.transparent,
|
child:Padding(padding:const EdgeInsets.all(12),child:Row(children:[
|
||||||
child: Container(
|
Icon(isReview?Icons.star:Icons.notifications_active,color:Colors.white,size:24),
|
||||||
margin: const EdgeInsets.all(12),
|
const SizedBox(width:10),
|
||||||
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(12),
|
Expanded(child:Column(crossAxisAlignment:CrossAxisAlignment.start,
|
||||||
boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 8, offset: Offset(0, 4))]),
|
mainAxisSize:MainAxisSize.min,children:[
|
||||||
child: Padding(
|
Text(notif.title,style:const TextStyle(color:Colors.white,fontWeight:FontWeight.bold,fontSize:13)),
|
||||||
padding: const EdgeInsets.all(12),
|
Text(notif.body,style:const TextStyle(color:Colors.white70,fontSize:11),
|
||||||
child: Row(children: [
|
maxLines:2,overflow:TextOverflow.ellipsis),
|
||||||
const Icon(Icons.notifications_active, color: Colors.white, size: 24),
|
])),
|
||||||
const SizedBox(width: 10),
|
IconButton(icon:const Icon(Icons.close,color:Colors.white,size:18),onPressed:onDismiss),
|
||||||
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),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
183
lib/screens/citizen/review_screen.dart
Normal file
183
lib/screens/citizen/review_screen.dart
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../core/app_colors.dart';
|
||||||
|
import '../../database/db_helper.dart';
|
||||||
|
import '../../models/models.dart';
|
||||||
|
import '../../services/auth_service.dart';
|
||||||
|
import '../../services/route_simulator_service.dart';
|
||||||
|
|
||||||
|
class ReviewScreen extends StatefulWidget {
|
||||||
|
final String routeId;
|
||||||
|
final String colonia;
|
||||||
|
const ReviewScreen({super.key, required this.routeId, required this.colonia});
|
||||||
|
@override State<ReviewScreen> createState() => _ReviewScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReviewScreenState extends State<ReviewScreen> {
|
||||||
|
int _estrellas = 5;
|
||||||
|
final _comentCtrl = TextEditingController();
|
||||||
|
bool _loading = false;
|
||||||
|
bool _sent = false;
|
||||||
|
|
||||||
|
static const _labels = ['', 'Muy malo', 'Malo', 'Regular', 'Bueno', 'Excelente'];
|
||||||
|
static const _colors = [
|
||||||
|
Colors.transparent, AppColors.rojoError, AppColors.naranjaAlerta,
|
||||||
|
Colors.amber, AppColors.verdeExito, AppColors.verdeExito,
|
||||||
|
];
|
||||||
|
|
||||||
|
Future<void> _enviar() async {
|
||||||
|
final auth = context.read<AuthService>();
|
||||||
|
if (auth.currentUser == null) return;
|
||||||
|
|
||||||
|
// Verificar si ya calificó hoy
|
||||||
|
final yaCalificado = await DbHelper.hasReviewedRoute(
|
||||||
|
auth.currentUser!.id!, widget.routeId);
|
||||||
|
if (yaCalificado && mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||||
|
content: Text('Ya calificaste este servicio hoy'),
|
||||||
|
backgroundColor: AppColors.azulInfo));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _loading = true);
|
||||||
|
await DbHelper.insertReview(ReviewModel(
|
||||||
|
userId: auth.currentUser!.id!,
|
||||||
|
colonia: widget.colonia,
|
||||||
|
routeId: widget.routeId,
|
||||||
|
estrellas: _estrellas,
|
||||||
|
comentario: _comentCtrl.text.trim().isEmpty
|
||||||
|
? 'Sin comentario' : _comentCtrl.text.trim(),
|
||||||
|
fecha: DateTime.now().toIso8601String(),
|
||||||
|
nombreUsuario: auth.currentUser!.nombre,
|
||||||
|
));
|
||||||
|
|
||||||
|
context.read<RouteSimulatorService>().clearReviewPrompt(widget.routeId);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() { _loading = false; _sent = true; });
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppColors.grisFondo,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
|
||||||
|
title: const Text('Calificar el Servicio'),
|
||||||
|
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||||
|
child: Container(height: 4, color: AppColors.dorado)),
|
||||||
|
),
|
||||||
|
body: _sent
|
||||||
|
? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||||
|
const Text('⭐', style: TextStyle(fontSize: 64)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('¡Gracias por tu calificación!',
|
||||||
|
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold,
|
||||||
|
color: AppColors.guindaPrimary)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text('Tu opinión ayuda a mejorar el servicio\nde recolección en Celaya.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: AppColors.grisTexto)),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Volver al inicio')),
|
||||||
|
]))
|
||||||
|
: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(children: [
|
||||||
|
// Header
|
||||||
|
Container(
|
||||||
|
width: double.infinity, padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.guindaPrimary.withOpacity(0.08),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: AppColors.guindaPrimary.withOpacity(0.2))),
|
||||||
|
child: Column(children: [
|
||||||
|
const Icon(Icons.local_shipping, color: AppColors.guindaPrimary, size: 36),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(widget.routeId, style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)),
|
||||||
|
Text(widget.colonia, style: const TextStyle(
|
||||||
|
color: AppColors.grisTexto, fontSize: 12)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Estrellas
|
||||||
|
const Text('¿Cómo calificarías el servicio de hoy?',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(mainAxisAlignment: MainAxisAlignment.center, children: List.generate(5, (i) {
|
||||||
|
final star = i + 1;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => setState(() => _estrellas = star),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||||
|
child: Icon(
|
||||||
|
_estrellas >= star ? Icons.star : Icons.star_border,
|
||||||
|
color: _estrellas >= star ? Colors.amber : Colors.grey,
|
||||||
|
size: 44,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
})),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(_labels[_estrellas],
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold,
|
||||||
|
color: _colors[_estrellas])),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Comentario
|
||||||
|
const Align(alignment: Alignment.centerLeft,
|
||||||
|
child: Text('Comentario (opcional)',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w600))),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: _comentCtrl,
|
||||||
|
maxLines: 4,
|
||||||
|
maxLength: 200,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Cuéntanos cómo estuvo el servicio...',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
filled: true, fillColor: Colors.white),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Aviso
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(color: Colors.blue.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.blue.shade200)),
|
||||||
|
child: const Row(children: [
|
||||||
|
Icon(Icons.info_outline, color: AppColors.azulInfo, size: 16),
|
||||||
|
SizedBox(width: 6),
|
||||||
|
Expanded(child: Text(
|
||||||
|
'Tu calificación es anónima para otros ciudadanos, '
|
||||||
|
'pero el Ayuntamiento la usará para mejorar el servicio.',
|
||||||
|
style: TextStyle(fontSize: 11, color: AppColors.azulInfo))),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
SizedBox(width: double.infinity, height: 50,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: _loading ? null : _enviar,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||||
|
icon: _loading
|
||||||
|
? const SizedBox(width: 18, height: 18,
|
||||||
|
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||||
|
: const Icon(Icons.star),
|
||||||
|
label: const Text('ENVIAR CALIFICACIÓN',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold)))),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override void dispose() { _comentCtrl.dispose(); super.dispose(); }
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../core/app_colors.dart';
|
import '../core/app_colors.dart';
|
||||||
import '../data/colonies_data.dart';
|
import '../data/colonies_data.dart';
|
||||||
|
import '../data/celaya_colonias.dart';
|
||||||
import '../models/route_model.dart';
|
import '../models/route_model.dart';
|
||||||
import '../services/auth_service.dart';
|
import '../services/auth_service.dart';
|
||||||
|
|
||||||
@@ -67,8 +68,11 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
border:OutlineInputBorder(),filled:true,fillColor:Colors.white),
|
border:OutlineInputBorder(),filled:true,fillColor:Colors.white),
|
||||||
hint:const Text('Selecciona tu colonia'),
|
hint:const Text('Selecciona tu colonia'),
|
||||||
value:_colony?.colonia, isExpanded:true,
|
value:_colony?.colonia, isExpanded:true,
|
||||||
items:colonyNames.map((n)=>DropdownMenuItem(value:n,child:Text(n,style:const TextStyle(fontSize:13)))).toList(),
|
items:celayaColonias.map((n)=>DropdownMenuItem(value:n,child:Text(n,style:const TextStyle(fontSize:13)))).toList(),
|
||||||
onChanged:(v){ if(v!=null) setState(()=>_colony=getColonyByName(v)); }),
|
onChanged:(v){ if(v!=null) setState((){
|
||||||
|
_colony = getColonyByName(v) ?? ColonyModel(
|
||||||
|
colonia:v, routeId:'RUTA-01', horarioEstimado:'Matutino (06:00-08:00)');
|
||||||
|
}); }),
|
||||||
if (_colony!=null) ...[
|
if (_colony!=null) ...[
|
||||||
const SizedBox(height:10),
|
const SizedBox(height:10),
|
||||||
Container(padding:const EdgeInsets.all(12),
|
Container(padding:const EdgeInsets.all(12),
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import '../database/db_helper.dart';
|
|||||||
|
|
||||||
class AuthService extends ChangeNotifier {
|
class AuthService extends ChangeNotifier {
|
||||||
UserModel? _user;
|
UserModel? _user;
|
||||||
DomicilioModel? _domicilio;
|
DomicilioModel? _primaryDomicilio;
|
||||||
|
List<DomicilioModel> _allDomicilios = [];
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
|
|
||||||
UserModel? get currentUser => _user;
|
UserModel? get currentUser => _user;
|
||||||
DomicilioModel? get primaryDomicilio => _domicilio;
|
DomicilioModel? get primaryDomicilio => _primaryDomicilio;
|
||||||
|
List<DomicilioModel> get allDomicilios => _allDomicilios;
|
||||||
bool get isLoggedIn => _user != null;
|
bool get isLoggedIn => _user != null;
|
||||||
bool get loading => _loading;
|
bool get loading => _loading;
|
||||||
String get rol => _user?.rol ?? '';
|
String get rol => _user?.rol ?? '';
|
||||||
@@ -21,22 +23,28 @@ class AuthService extends ChangeNotifier {
|
|||||||
final id = p.getInt('user_id');
|
final id = p.getInt('user_id');
|
||||||
if (id != null) {
|
if (id != null) {
|
||||||
_user = await DbHelper.getUserById(id);
|
_user = await DbHelper.getUserById(id);
|
||||||
if (_user?.rol == 'CIUDADANO') {
|
if (_user?.rol == 'CIUDADANO') await reloadDomicilios();
|
||||||
_domicilio = await DbHelper.getPrimaryDomicilio(id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_loading = false;
|
_loading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> reloadDomicilios() async {
|
||||||
|
if (_user == null) return;
|
||||||
|
_allDomicilios = await DbHelper.getDomiciliosByUser(_user!.id!);
|
||||||
|
_primaryDomicilio = _allDomicilios.isNotEmpty
|
||||||
|
? _allDomicilios.firstWhere((d) => d.isPrimary,
|
||||||
|
orElse: () => _allDomicilios.first)
|
||||||
|
: null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
Future<String?> login(String email, String password) async {
|
Future<String?> login(String email, String password) async {
|
||||||
final user = await DbHelper.getUserByEmail(email.trim().toLowerCase());
|
final user = await DbHelper.getUserByEmail(email.trim().toLowerCase());
|
||||||
if (user == null) return 'Correo no registrado';
|
if (user == null) return 'Correo no registrado';
|
||||||
if (user.password != password) return 'Contraseña incorrecta';
|
if (user.password != password) return 'Contraseña incorrecta';
|
||||||
_user = user;
|
_user = user;
|
||||||
if (user.rol == 'CIUDADANO') {
|
if (user.rol == 'CIUDADANO') await reloadDomicilios();
|
||||||
_domicilio = await DbHelper.getPrimaryDomicilio(user.id!);
|
|
||||||
}
|
|
||||||
final p = await SharedPreferences.getInstance();
|
final p = await SharedPreferences.getInstance();
|
||||||
await p.setInt('user_id', user.id!);
|
await p.setInt('user_id', user.id!);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -51,10 +59,11 @@ class AuthService extends ChangeNotifier {
|
|||||||
final user = UserModel(nombre:nombre.trim(),
|
final user = UserModel(nombre:nombre.trim(),
|
||||||
email:email.trim().toLowerCase(), password:password, rol:'CIUDADANO');
|
email:email.trim().toLowerCase(), password:password, rol:'CIUDADANO');
|
||||||
final uid = await DbHelper.insertUser(user);
|
final uid = await DbHelper.insertUser(user);
|
||||||
await DbHelper.insertDomicilio(DomicilioModel(userId:uid, calle:calle.trim(),
|
await DbHelper.insertDomicilio(DomicilioModel(userId:uid, alias:'Casa',
|
||||||
colonia:colonia, routeId:routeId, horarioEstimado:horarioEstimado));
|
calle:calle.trim(), colonia:colonia, routeId:routeId,
|
||||||
|
horarioEstimado:horarioEstimado, isPrimary:true));
|
||||||
_user = await DbHelper.getUserById(uid);
|
_user = await DbHelper.getUserById(uid);
|
||||||
_domicilio = await DbHelper.getPrimaryDomicilio(uid);
|
await reloadDomicilios();
|
||||||
final p = await SharedPreferences.getInstance();
|
final p = await SharedPreferences.getInstance();
|
||||||
await p.setInt('user_id', uid);
|
await p.setInt('user_id', uid);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -62,7 +71,7 @@ class AuthService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
_user = null; _domicilio = null;
|
_user = null; _primaryDomicilio = null; _allDomicilios = [];
|
||||||
final p = await SharedPreferences.getInstance();
|
final p = await SharedPreferences.getInstance();
|
||||||
await p.remove('user_id');
|
await p.remove('user_id');
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ import '../models/models.dart';
|
|||||||
import '../data/routes_data.dart';
|
import '../data/routes_data.dart';
|
||||||
import '../database/db_helper.dart';
|
import '../database/db_helper.dart';
|
||||||
|
|
||||||
enum NotifEvent { routeStart, truckProximity, routeCompleted, gpsLost, truckStopped, routeCancelled, none }
|
enum NotifEvent {
|
||||||
|
routeStart, truckProximity, truckApproaching15min, routeCompleted,
|
||||||
|
gpsLost, truckStopped, routeCancelled, reviewPrompt, none
|
||||||
|
}
|
||||||
|
|
||||||
class AppNotification {
|
class AppNotification {
|
||||||
final NotifEvent event;
|
final NotifEvent event;
|
||||||
final String title;
|
final String title;
|
||||||
final String body;
|
final String body;
|
||||||
final String routeId; // Para filtrar por usuario
|
final String routeId;
|
||||||
final DateTime timestamp;
|
final DateTime timestamp;
|
||||||
AppNotification({required this.event, required this.title,
|
AppNotification({required this.event, required this.title,
|
||||||
required this.body, required this.routeId})
|
required this.body, required this.routeId})
|
||||||
@@ -24,8 +27,10 @@ class SimulatorState {
|
|||||||
bool gpsActive;
|
bool gpsActive;
|
||||||
DateTime lastMoved;
|
DateTime lastMoved;
|
||||||
bool stoppedAlertSent;
|
bool stoppedAlertSent;
|
||||||
SimulatorState({required this.routeId, this.positionIndex = 0,
|
bool reviewPromptSent;
|
||||||
this.gpsActive = true, required this.lastMoved, this.stoppedAlertSent = false});
|
SimulatorState({required this.routeId, this.positionIndex=0,
|
||||||
|
this.gpsActive=true, required this.lastMoved,
|
||||||
|
this.stoppedAlertSent=false, this.reviewPromptSent=false});
|
||||||
}
|
}
|
||||||
|
|
||||||
class RouteSimulatorService extends ChangeNotifier {
|
class RouteSimulatorService extends ChangeNotifier {
|
||||||
@@ -33,40 +38,40 @@ class RouteSimulatorService extends ChangeNotifier {
|
|||||||
Timer? _globalTimer;
|
Timer? _globalTimer;
|
||||||
Timer? _gpsMonitorTimer;
|
Timer? _gpsMonitorTimer;
|
||||||
|
|
||||||
AppNotification? _lastNotification; // Admin ve todas
|
AppNotification? _lastNotification;
|
||||||
final List<AppNotification> _history = [];
|
final List<AppNotification> _history = [];
|
||||||
|
|
||||||
// ── Getters ─────────────────────────────────────────────────────────────
|
// ── Getters ──────────────────────────────────────────────────────────────
|
||||||
// Admin: ve la última notificación global
|
|
||||||
AppNotification? get lastNotification => _lastNotification;
|
AppNotification? get lastNotification => _lastNotification;
|
||||||
|
|
||||||
// Ciudadano/Conductor: solo ve notificaciones de SU ruta
|
// Ciudadano/Conductor: solo su ruta
|
||||||
AppNotification? getNotificationForRoute(String routeId) {
|
AppNotification? getNotificationForRoute(String routeId) {
|
||||||
if (_lastNotification?.routeId == routeId) return _lastNotification;
|
if (_lastNotification?.routeId == routeId) return _lastNotification;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<AppNotification> get history => List.unmodifiable(_history);
|
List<AppNotification> get history => List.unmodifiable(_history);
|
||||||
|
|
||||||
// Historial filtrado por ruta
|
|
||||||
List<AppNotification> historyForRoute(String routeId) =>
|
List<AppNotification> historyForRoute(String routeId) =>
|
||||||
_history.where((n) => n.routeId == routeId).toList();
|
_history.where((n) => n.routeId == routeId).toList();
|
||||||
|
|
||||||
|
bool needsReviewPrompt(String routeId) =>
|
||||||
|
_states[routeId]?.reviewPromptSent == true;
|
||||||
|
|
||||||
// ── Inicio ───────────────────────────────────────────────────────────────
|
// ── Inicio ───────────────────────────────────────────────────────────────
|
||||||
void startAllRoutes() {
|
void startAllRoutes() {
|
||||||
for (final r in routesData) {
|
for (final r in routesData) {
|
||||||
_states[r.routeId] = SimulatorState(routeId: r.routeId, lastMoved: DateTime.now());
|
_states[r.routeId] = SimulatorState(routeId:r.routeId, lastMoved:DateTime.now());
|
||||||
}
|
}
|
||||||
_globalTimer?.cancel();
|
_globalTimer?.cancel();
|
||||||
_globalTimer = Timer.periodic(const Duration(seconds: 30), (_) => _tick());
|
_globalTimer = Timer.periodic(const Duration(seconds:30), (_) => _tick());
|
||||||
_gpsMonitorTimer?.cancel();
|
_gpsMonitorTimer?.cancel();
|
||||||
_gpsMonitorTimer = Timer.periodic(const Duration(minutes: 5), (_) => _monitorGps());
|
_gpsMonitorTimer = Timer.periodic(const Duration(minutes:5), (_) => _monitorGps());
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void startRoute(String routeId) {
|
void startRoute(String routeId) {
|
||||||
_states[routeId] = SimulatorState(routeId: routeId, lastMoved: DateTime.now());
|
_states[routeId] = SimulatorState(routeId:routeId, lastMoved:DateTime.now());
|
||||||
_globalTimer ??= Timer.periodic(const Duration(seconds: 30), (_) => _tick());
|
_globalTimer ??= Timer.periodic(const Duration(seconds:30), (_) => _tick());
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,45 +97,62 @@ class RouteSimulatorService extends ChangeNotifier {
|
|||||||
final diff = DateTime.now().difference(state.lastMoved);
|
final diff = DateTime.now().difference(state.lastMoved);
|
||||||
if (diff.inMinutes >= 30 && !state.stoppedAlertSent) {
|
if (diff.inMinutes >= 30 && !state.stoppedAlertSent) {
|
||||||
state.stoppedAlertSent = true;
|
state.stoppedAlertSent = true;
|
||||||
_fireAndSaveAlert(
|
_fireAndSave(event:NotifEvent.truckStopped, routeId:state.routeId,
|
||||||
event: NotifEvent.truckStopped, routeId: state.routeId,
|
title:'⚠️ Camión detenido',
|
||||||
title: '⚠️ Camión detenido',
|
body:'El camión ${state.routeId} lleva +30 min sin moverse. Verifica.',
|
||||||
body: 'El camión ${state.routeId} lleva +30 min sin moverse.',
|
tipo:'CAMION_DETENIDO');
|
||||||
tipo: 'CAMION_DETENIDO',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _checkNotification(SimulatorState state, RouteModel route) {
|
void _checkNotification(SimulatorState state, RouteModel route) {
|
||||||
if (state.positionIndex == 1) {
|
final idx = state.positionIndex;
|
||||||
|
final total = route.positions.length;
|
||||||
|
|
||||||
|
if (idx == 1) {
|
||||||
|
// Ruta iniciada
|
||||||
_fireNotif(NotifEvent.routeStart, '¡Ruta Iniciada! 🚛',
|
_fireNotif(NotifEvent.routeStart, '¡Ruta Iniciada! 🚛',
|
||||||
'El camión ha salido del Relleno Sanitario rumbo a tu sector.', state.routeId);
|
'El camión ha salido del Relleno Sanitario rumbo a tu sector. '
|
||||||
} else if (state.positionIndex == 3) {
|
'Prepara tus bolsas pero espera la señal para sacarlas.', state.routeId);
|
||||||
_fireNotif(NotifEvent.truckProximity, 'Camión Cercano ⚠️',
|
} else if (idx == 2) {
|
||||||
'El camión está a menos de 15 minutos. ¡Saca tus bolsas!', state.routeId);
|
// ~30 min — aviso preventivo
|
||||||
} else if (state.positionIndex == route.positions.length - 1) {
|
_fireNotif(NotifEvent.truckApproaching15min, '🕐 El camión se acerca',
|
||||||
_fireNotif(NotifEvent.routeCompleted, 'Servicio Finalizado 🏁',
|
'Tu camión recolector está en camino. Tendrás otro aviso cuando esté a '
|
||||||
'El camión de ${state.routeId} concluyó su jornada.', state.routeId);
|
'15 minutos. ⚠️ No saques la basura todavía — espera el aviso.', state.routeId);
|
||||||
|
} else if (idx == 3) {
|
||||||
|
// ~15 min — MOMENTO de sacar la basura
|
||||||
|
_fireNotif(NotifEvent.truckProximity, '⚠️ ¡Saca tus bolsas AHORA!',
|
||||||
|
'El camión llega en aprox. 15 minutos a tu colonia. '
|
||||||
|
'Este es el momento de sacar tus bolsas a la acera. '
|
||||||
|
'🚫 No persigas ni interceptes la unidad.', state.routeId);
|
||||||
|
} else if (idx == total - 2) {
|
||||||
|
// Pasando por la zona
|
||||||
|
_fireNotif(NotifEvent.truckProximity, '✅ El camión está en tu zona',
|
||||||
|
'El camión recolector está pasando por tu colonia. '
|
||||||
|
'Si ya sacaste tus bolsas, el servicio está en curso.', state.routeId);
|
||||||
|
} else if (idx == total - 1) {
|
||||||
|
// Servicio finalizado → prompt de reseña
|
||||||
|
state.reviewPromptSent = true;
|
||||||
|
_fireNotif(NotifEvent.reviewPrompt, '🌟 ¿Cómo fue el servicio?',
|
||||||
|
'¡El camión concluyó su jornada! Ayúdanos calificando el servicio '
|
||||||
|
'de recolección de hoy. Tu opinión mejora el servicio.', state.routeId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _fireNotif(NotifEvent event, String title, String body, String 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;
|
_lastNotification = n;
|
||||||
_history.insert(0, n);
|
_history.insert(0, n);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _fireAndSaveAlert({required NotifEvent event, required String routeId,
|
Future<void> _fireAndSave({required NotifEvent event, required String routeId,
|
||||||
required String title, required String body, required String tipo}) async {
|
required String title, required String body, required String tipo}) async {
|
||||||
_fireNotif(event, title, body, routeId);
|
_fireNotif(event, title, body, routeId);
|
||||||
await DbHelper.insertAlerta(AlertaModel(
|
await DbHelper.insertAlerta(AlertaModel(tipo:tipo, routeId:routeId,
|
||||||
tipo: tipo, routeId: routeId, mensaje: body,
|
mensaje:body, fecha:DateTime.now().toIso8601String()));
|
||||||
fecha: DateTime.now().toIso8601String()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notificación manual (admin cancela, retrasa ruta, etc.)
|
|
||||||
void fireCustomNotification(String title, String body, String routeId, NotifEvent event) {
|
void fireCustomNotification(String title, String body, String routeId, NotifEvent event) {
|
||||||
_fireNotif(event, title, body, routeId);
|
_fireNotif(event, title, body, routeId);
|
||||||
}
|
}
|
||||||
@@ -140,12 +162,10 @@ class RouteSimulatorService extends ChangeNotifier {
|
|||||||
final state = _states[routeId];
|
final state = _states[routeId];
|
||||||
if (state == null) return;
|
if (state == null) return;
|
||||||
state.gpsActive = false;
|
state.gpsActive = false;
|
||||||
await _fireAndSaveAlert(
|
await _fireAndSave(event:NotifEvent.gpsLost, routeId:routeId,
|
||||||
event: NotifEvent.gpsLost, routeId: routeId,
|
title:'📡 GPS Desactivado',
|
||||||
title: '📡 GPS Desactivado',
|
body:'Se perdió la señal GPS del camión $routeId.',
|
||||||
body: 'Se perdió la señal GPS del camión $routeId.',
|
tipo:'GPS_PERDIDO');
|
||||||
tipo: 'GPS_PERDIDO',
|
|
||||||
);
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +181,13 @@ class RouteSimulatorService extends ChangeNotifier {
|
|||||||
SimulatorState? getState(String routeId) => _states[routeId];
|
SimulatorState? getState(String routeId) => _states[routeId];
|
||||||
int getPositionIndex(String routeId) => _states[routeId]?.positionIndex ?? 0;
|
int getPositionIndex(String routeId) => _states[routeId]?.positionIndex ?? 0;
|
||||||
bool isTruckClose(String routeId) => getPositionIndex(routeId) >= 3;
|
bool isTruckClose(String routeId) => getPositionIndex(routeId) >= 3;
|
||||||
|
bool isRouteCompleted(String routeId) {
|
||||||
|
final state = _states[routeId];
|
||||||
|
if (state == null) return false;
|
||||||
|
final route = getRouteById(routeId);
|
||||||
|
if (route == null) return false;
|
||||||
|
return state.positionIndex >= route.positions.length - 1;
|
||||||
|
}
|
||||||
bool isGpsActive(String routeId) => _states[routeId]?.gpsActive ?? true;
|
bool isGpsActive(String routeId) => _states[routeId]?.gpsActive ?? true;
|
||||||
|
|
||||||
String getEtaText(String routeId) {
|
String getEtaText(String routeId) {
|
||||||
@@ -173,27 +200,28 @@ class RouteSimulatorService extends ChangeNotifier {
|
|||||||
if (idx >= route.positions.length) return '✅ Servicio finalizado';
|
if (idx >= route.positions.length) return '✅ Servicio finalizado';
|
||||||
switch (idx) {
|
switch (idx) {
|
||||||
case 0: return '🕐 Ruta por iniciar';
|
case 0: return '🕐 Ruta por iniciar';
|
||||||
case 1: return '🚛 Camión en camino';
|
case 1: return '🚛 Camión en camino — mantén tus bolsas adentro';
|
||||||
case 2: return '🚛 Aprox. 30 min para llegar';
|
case 2: return '🚛 Aprox. 30 min — espera el aviso de 15 min';
|
||||||
case 3: return '⚠️ Menos de 15 min — ¡Saca tus bolsas!';
|
case 3: return '⚠️ ¡15 min! Saca tus bolsas a la acera ahora';
|
||||||
case 4: return '🔔 El camión está en tu zona';
|
case 4: return '🔔 El camión está en tu colonia';
|
||||||
case 5: return '✅ Pasando por tu colonia';
|
case 5: return '✅ Recogiendo basura en tu zona';
|
||||||
case 6: return '↩️ Regresando al relleno';
|
case 6: return '↩️ Regresando al relleno sanitario';
|
||||||
default: return '🏁 Servicio del día finalizado';
|
default: return '🏁 Servicio del día finalizado';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void dismissNotification() {
|
void dismissNotification() { _lastNotification = null; notifyListeners(); }
|
||||||
_lastNotification = null;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
void dismissRouteNotification(String routeId) {
|
void dismissRouteNotification(String routeId) {
|
||||||
if (_lastNotification?.routeId == routeId) {
|
if (_lastNotification?.routeId == routeId) {
|
||||||
_lastNotification = null;
|
_lastNotification = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
void clearReviewPrompt(String routeId) {
|
||||||
|
final state = _states[routeId];
|
||||||
|
if (state != null) state.reviewPromptSent = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
|||||||
Reference in New Issue
Block a user