Actualizacion del programa
This commit is contained in:
@@ -16,13 +16,11 @@ class DbHelper {
|
||||
}
|
||||
|
||||
static Future<void> _onCreate(Database db, int v) async {
|
||||
// Usuarios
|
||||
await db.execute('''CREATE TABLE users(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
nombre TEXT NOT NULL, email TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL, rol TEXT NOT NULL)''');
|
||||
|
||||
// Domicilios (User → Domicilio → Colonia/Zona → Ruta)
|
||||
await db.execute('''CREATE TABLE domicilios(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL, alias TEXT DEFAULT 'Casa',
|
||||
@@ -30,7 +28,6 @@ class DbHelper {
|
||||
route_id TEXT NOT NULL, horario_estimado TEXT NOT NULL,
|
||||
is_primary INTEGER DEFAULT 0)''');
|
||||
|
||||
// Definiciones de rutas creadas por admin
|
||||
await db.execute('''CREATE TABLE route_definitions(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
route_id TEXT UNIQUE NOT NULL, nombre TEXT NOT NULL,
|
||||
@@ -38,33 +35,29 @@ class DbHelper {
|
||||
hora_fin TEXT NOT NULL, turno TEXT NOT NULL,
|
||||
colonias TEXT NOT NULL, activa INTEGER DEFAULT 1)''');
|
||||
|
||||
// Estado de rutas
|
||||
await db.execute('''CREATE TABLE route_status(
|
||||
route_id TEXT PRIMARY KEY, status TEXT NOT NULL,
|
||||
mensaje TEXT, updated_at TEXT)''');
|
||||
|
||||
// Asignaciones conductor
|
||||
await db.execute('''CREATE TABLE asignaciones(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conductor_id INTEGER NOT NULL, route_id TEXT NOT NULL,
|
||||
dia_semana TEXT NOT NULL, turno TEXT NOT NULL)''');
|
||||
|
||||
// Alertas del sistema
|
||||
await db.execute('''CREATE TABLE alertas(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tipo TEXT NOT NULL, route_id TEXT NOT NULL,
|
||||
mensaje TEXT NOT NULL, fecha TEXT NOT NULL,
|
||||
resuelta INTEGER DEFAULT 0)''');
|
||||
|
||||
// Reportes ciudadanos
|
||||
await db.execute('''CREATE TABLE reportes(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL, tipo TEXT NOT NULL,
|
||||
descripcion TEXT NOT NULL, colonia TEXT NOT NULL,
|
||||
route_id TEXT, fecha TEXT NOT NULL,
|
||||
estado TEXT DEFAULT 'PENDIENTE', calificacion INTEGER DEFAULT 5)''');
|
||||
estado TEXT DEFAULT 'PENDIENTE', calificacion INTEGER DEFAULT 5,
|
||||
foto_path TEXT)''');
|
||||
|
||||
// RESEÑAS del servicio
|
||||
await db.execute('''CREATE TABLE reviews(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL, colonia TEXT NOT NULL,
|
||||
@@ -72,11 +65,22 @@ class DbHelper {
|
||||
comentario TEXT NOT NULL, fecha TEXT NOT NULL,
|
||||
nombre_usuario TEXT DEFAULT 'Ciudadano')''');
|
||||
|
||||
// Seed: admin y conductor
|
||||
await db.execute('''CREATE TABLE notification_history(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER, route_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL, title TEXT NOT NULL,
|
||||
body TEXT NOT NULL, fecha TEXT NOT NULL,
|
||||
leida INTEGER DEFAULT 0)''');
|
||||
|
||||
await db.execute('''CREATE TABLE user_meta(
|
||||
user_id INTEGER PRIMARY KEY, activo INTEGER DEFAULT 1,
|
||||
notas TEXT)''');
|
||||
|
||||
await db.insert('users', {'nombre':'Administrador','email':'admin@celaya.gob.mx',
|
||||
'password':'admin123','rol':'ADMINISTRADOR'});
|
||||
await db.insert('users', {'nombre':'Juan Conductor','email':'conductor@celaya.gob.mx',
|
||||
'password':'conductor123','rol':'CONDUCTOR'});
|
||||
final conductorId = await db.insert('users', {'nombre':'Juan Conductor',
|
||||
'email':'conductor@celaya.gob.mx','password':'conductor123','rol':'CONDUCTOR'});
|
||||
await db.insert('user_meta', {'user_id': conductorId, 'activo': 1});
|
||||
}
|
||||
|
||||
// ── USERS ────────────────────────────────────────────────────────────────
|
||||
@@ -101,7 +105,6 @@ class DbHelper {
|
||||
// ── DOMICILIOS ───────────────────────────────────────────────────────────
|
||||
static Future<int> insertDomicilio(DomicilioModel d) async {
|
||||
final db = await database;
|
||||
// Si es el primero del usuario, marcarlo como primario
|
||||
final existing = await db.query('domicilios', where:'user_id=?', whereArgs:[d.userId]);
|
||||
final isPrimary = existing.isEmpty ? 1 : (d.isPrimary ? 1 : 0);
|
||||
return db.insert('domicilios', {...d.toMap(), 'is_primary': isPrimary});
|
||||
@@ -255,7 +258,6 @@ class DbHelper {
|
||||
}
|
||||
|
||||
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%'",
|
||||
@@ -263,7 +265,6 @@ class DbHelper {
|
||||
return res.isNotEmpty;
|
||||
}
|
||||
|
||||
// Promedio por colonia para el admin
|
||||
static Future<List<Map<String, dynamic>>> getReviewSummaryByColonia() async {
|
||||
final db = await database;
|
||||
return db.rawQuery('''
|
||||
@@ -272,8 +273,116 @@ class DbHelper {
|
||||
COUNT(*) as total,
|
||||
MIN(estrellas) as min_est,
|
||||
MAX(estrellas) as max_est
|
||||
FROM reviews GROUP BY colonia ORDER BY promedio ASC''');
|
||||
}
|
||||
|
||||
// ── NOTIFICATION HISTORY ─────────────────────────────────────────────────
|
||||
static Future<int> insertNotifHistory({
|
||||
int? userId, required String routeId, required String eventType,
|
||||
required String title, required String body,
|
||||
}) async => (await database).insert('notification_history', {
|
||||
'user_id': userId, 'route_id': routeId, 'event_type': eventType,
|
||||
'title': title, 'body': body,
|
||||
'fecha': DateTime.now().toIso8601String(), 'leida': 0,
|
||||
});
|
||||
|
||||
static Future<List<Map<String, dynamic>>> getNotifHistory(int userId) async =>
|
||||
(await database).query('notification_history',
|
||||
where: 'user_id IS NULL OR user_id = ?', whereArgs: [userId],
|
||||
orderBy: 'fecha DESC', limit: 50);
|
||||
|
||||
static Future<int> countUnreadNotifs(int userId) async {
|
||||
final res = await (await database).rawQuery(
|
||||
'SELECT COUNT(*) as c FROM notification_history WHERE (user_id IS NULL OR user_id=?) AND leida=0',
|
||||
[userId]);
|
||||
return (res.first['c'] as int? ?? 0);
|
||||
}
|
||||
|
||||
static Future<void> markAllNotifsRead(int userId) async =>
|
||||
(await database).update('notification_history', {'leida': 1},
|
||||
where: 'user_id IS NULL OR user_id = ?', whereArgs: [userId]);
|
||||
|
||||
// ── CONDUCTORES CON METADATA ─────────────────────────────────────────────
|
||||
static Future<List<Map<String, dynamic>>> getConductoresConMeta() async {
|
||||
final db = await database;
|
||||
return db.rawQuery('''
|
||||
SELECT u.*, COALESCE(m.activo, 1) as activo, m.notas,
|
||||
(SELECT COUNT(*) FROM alertas a
|
||||
WHERE a.tipo LIKE 'INCIDENTE_%'
|
||||
AND a.route_id IN (
|
||||
SELECT route_id FROM asignaciones WHERE conductor_id = u.id
|
||||
)) as total_incidentes
|
||||
FROM users u
|
||||
LEFT JOIN user_meta m ON m.user_id = u.id
|
||||
WHERE u.rol = 'CONDUCTOR'
|
||||
ORDER BY u.nombre ASC''');
|
||||
}
|
||||
|
||||
static Future<void> updateConductorMeta(int userId, bool activo, String notas) async {
|
||||
final db = await database;
|
||||
final ex = await db.query('user_meta', where:'user_id=?', whereArgs:[userId]);
|
||||
if (ex.isEmpty) {
|
||||
await db.insert('user_meta', {'user_id':userId,'activo':activo?1:0,'notas':notas});
|
||||
} else {
|
||||
await db.update('user_meta', {'activo':activo?1:0,'notas':notas},
|
||||
where:'user_id=?', whereArgs:[userId]);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<int> insertConductor(String nombre, String email, String password) async {
|
||||
final db = await database;
|
||||
final uid = await db.insert('users',
|
||||
{'nombre':nombre,'email':email,'password':password,'rol':'CONDUCTOR'},
|
||||
conflictAlgorithm: ConflictAlgorithm.abort);
|
||||
await db.insert('user_meta', {'user_id':uid,'activo':1});
|
||||
return uid;
|
||||
}
|
||||
|
||||
static Future<void> updateConductor(int id, String nombre, String email) async =>
|
||||
(await database).update('users', {'nombre':nombre,'email':email},
|
||||
where:'id=?', whereArgs:[id]);
|
||||
|
||||
// ── ESTADÍSTICAS ─────────────────────────────────────────────────────────
|
||||
static Future<Map<String, dynamic>> getAdminStats() async {
|
||||
final db = await database;
|
||||
final totalReportes = (await db.rawQuery('SELECT COUNT(*) as c FROM reportes')).first['c'];
|
||||
final totalReviews = (await db.rawQuery('SELECT COUNT(*) as c FROM reviews')).first['c'];
|
||||
final avgRating = (await db.rawQuery('SELECT AVG(estrellas) as a FROM reviews')).first['a'];
|
||||
final totalAlertas = (await db.rawQuery('SELECT COUNT(*) as c FROM alertas WHERE resuelta=0')).first['c'];
|
||||
final totalConductores = (await db.rawQuery(
|
||||
"SELECT COUNT(*) as c FROM users WHERE rol='CONDUCTOR'")).first['c'];
|
||||
return {
|
||||
'total_reportes': totalReportes ?? 0,
|
||||
'total_reviews': totalReviews ?? 0,
|
||||
'avg_rating': (avgRating as num?)?.toDouble() ?? 0.0,
|
||||
'alertas_activas': totalAlertas ?? 0,
|
||||
'total_conductores': totalConductores ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> getReportesByColonia() async {
|
||||
final db = await database;
|
||||
return db.rawQuery('''
|
||||
SELECT colonia, COUNT(*) as total,
|
||||
SUM(CASE WHEN estado='RESUELTO' THEN 1 ELSE 0 END) as resueltos
|
||||
FROM reportes GROUP BY colonia ORDER BY total DESC LIMIT 10''');
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> getIncidentesByRoute() async {
|
||||
final db = await database;
|
||||
return db.rawQuery('''
|
||||
SELECT route_id, COUNT(*) as total
|
||||
FROM alertas WHERE tipo LIKE 'INCIDENTE_%'
|
||||
GROUP BY route_id ORDER BY total DESC LIMIT 10''');
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> getRatingByWeek() async {
|
||||
final db = await database;
|
||||
return db.rawQuery('''
|
||||
SELECT strftime('%W', fecha) as semana,
|
||||
AVG(estrellas) as promedio,
|
||||
COUNT(*) as total
|
||||
FROM reviews
|
||||
GROUP BY colonia
|
||||
ORDER BY promedio ASC''');
|
||||
GROUP BY semana ORDER BY semana DESC LIMIT 8''');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'core/app_colors.dart';
|
||||
import 'services/auth_service.dart';
|
||||
import 'services/route_simulator_service.dart';
|
||||
import 'services/theme_service.dart';
|
||||
import 'screens/splash_screen.dart';
|
||||
import 'screens/login_screen.dart';
|
||||
import 'screens/register_screen.dart';
|
||||
import 'screens/citizen/citizen_home_screen.dart';
|
||||
import 'screens/driver/driver_home_screen.dart';
|
||||
import 'screens/admin/admin_dashboard_screen.dart';
|
||||
import 'screens/onboarding/onboarding_screen.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
runApp(const CelayaLimpiaApp());
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final onboardingDone = prefs.getBool('onboarding_done') ?? false;
|
||||
runApp(CelayaLimpiaApp(onboardingDone: onboardingDone));
|
||||
}
|
||||
|
||||
class CelayaLimpiaApp extends StatelessWidget {
|
||||
const CelayaLimpiaApp({super.key});
|
||||
final bool onboardingDone;
|
||||
const CelayaLimpiaApp({super.key, required this.onboardingDone});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -24,34 +30,53 @@ class CelayaLimpiaApp extends StatelessWidget {
|
||||
providers: [
|
||||
ChangeNotifierProvider(create: (_) => AuthService()),
|
||||
ChangeNotifierProvider(create: (_) => RouteSimulatorService()),
|
||||
ChangeNotifierProvider(create: (_) => ThemeService()),
|
||||
],
|
||||
child: MaterialApp(
|
||||
title: 'Celaya Limpia',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: AppColors.guindaPrimary,
|
||||
primary: AppColors.guindaPrimary,
|
||||
secondary: AppColors.dorado,
|
||||
),
|
||||
inputDecorationTheme: const InputDecorationTheme(
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: AppColors.guindaPrimary, width: 2),
|
||||
),
|
||||
labelStyle: TextStyle(color: AppColors.guindaPrimary),
|
||||
),
|
||||
child: Consumer<ThemeService>(
|
||||
builder: (_, themeService, __) => MaterialApp(
|
||||
title: 'Celaya Limpia',
|
||||
debugShowCheckedModeBanner: false,
|
||||
themeMode: themeService.themeMode,
|
||||
theme: _lightTheme(),
|
||||
darkTheme: _darkTheme(),
|
||||
initialRoute: onboardingDone ? '/splash' : '/onboarding',
|
||||
routes: {
|
||||
'/onboarding': (_) => const OnboardingScreen(),
|
||||
'/splash': (_) => const SplashScreen(),
|
||||
'/login': (_) => const LoginScreen(),
|
||||
'/register': (_) => const RegisterScreen(),
|
||||
'/home': (_) => const CitizenHomeScreen(),
|
||||
'/driver': (_) => const DriverHomeScreen(),
|
||||
'/admin': (_) => const AdminDashboardScreen(),
|
||||
},
|
||||
),
|
||||
initialRoute: '/splash',
|
||||
routes: {
|
||||
'/splash': (_) => const SplashScreen(),
|
||||
'/login': (_) => const LoginScreen(),
|
||||
'/register': (_) => const RegisterScreen(),
|
||||
'/home': (_) => const CitizenHomeScreen(),
|
||||
'/driver': (_) => const DriverHomeScreen(),
|
||||
'/admin': (_) => const AdminDashboardScreen(),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ThemeData _lightTheme() => ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: AppColors.guindaPrimary,
|
||||
primary: AppColors.guindaPrimary, secondary: AppColors.dorado),
|
||||
scaffoldBackgroundColor: AppColors.grisFondo,
|
||||
inputDecorationTheme: const InputDecorationTheme(
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: AppColors.guindaPrimary, width: 2)),
|
||||
labelStyle: TextStyle(color: AppColors.guindaPrimary)),
|
||||
);
|
||||
|
||||
ThemeData _darkTheme() => ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: AppColors.guindaLight,
|
||||
brightness: Brightness.dark, primary: AppColors.guindaLight,
|
||||
secondary: AppColors.dorado),
|
||||
scaffoldBackgroundColor: const Color(0xFF121212),
|
||||
cardColor: const Color(0xFF1E1E1E),
|
||||
inputDecorationTheme: const InputDecorationTheme(
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: AppColors.guindaLight, width: 2)),
|
||||
labelStyle: TextStyle(color: AppColors.guindaLight)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ class RouteModel {
|
||||
final int truckId;
|
||||
String status;
|
||||
final List<RoutePosition> positions;
|
||||
final String turno; // MATUTINO | VESPERTINO | NOCTURNO
|
||||
final String turno;
|
||||
|
||||
RouteModel({required this.routeId, required this.name,
|
||||
required this.truckId, required this.status,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
@@ -8,6 +9,10 @@ import '../../models/models.dart';
|
||||
import '../../data/routes_data.dart';
|
||||
import '../../models/route_model.dart' show ColonyModel;
|
||||
import 'create_route_screen.dart';
|
||||
import 'admin_stats_screen.dart';
|
||||
import 'manage_conductors_screen.dart';
|
||||
import 'export_pdf_screen.dart';
|
||||
import '../../screens/settings_screen.dart';
|
||||
import '../../widgets/route_map_widget.dart';
|
||||
|
||||
class AdminDashboardScreen extends StatefulWidget {
|
||||
@@ -27,6 +32,7 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
||||
_AdminHomeTab(sim:sim, auth:auth),
|
||||
_AdminMapTab(sim:sim),
|
||||
_AdminReportesTab(),
|
||||
_AdminConductoresTab(),
|
||||
_AdminAssignmentsTab(),
|
||||
_AdminAlertasTab(sim:sim),
|
||||
_AdminRoutesTab(),
|
||||
@@ -136,6 +142,15 @@ class _AdminHomeTabState extends State<_AdminHomeTab> {
|
||||
child: Container(height: 4, color: AppColors.dorado)),
|
||||
title: const Text('Panel Administrador', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
actions: [
|
||||
IconButton(icon: const Icon(Icons.picture_as_pdf), tooltip: 'Exportar PDF',
|
||||
onPressed: () => Navigator.push(context,
|
||||
MaterialPageRoute(builder: (_) => const ExportPdfScreen()))),
|
||||
IconButton(icon: const Icon(Icons.bar_chart), tooltip: 'Estadisticas',
|
||||
onPressed: () => Navigator.push(context,
|
||||
MaterialPageRoute(builder: (_) => const AdminStatsScreen()))),
|
||||
IconButton(icon: const Icon(Icons.settings_outlined), tooltip: 'Configuracion',
|
||||
onPressed: () => Navigator.push(context,
|
||||
MaterialPageRoute(builder: (_) => const SettingsScreen()))),
|
||||
IconButton(icon: const Icon(Icons.refresh), onPressed: _load),
|
||||
IconButton(icon: const Icon(Icons.logout),
|
||||
onPressed: () async { await widget.auth.logout();
|
||||
@@ -431,6 +446,7 @@ class _AdminReportesTabState extends State<_AdminReportesTab> {
|
||||
final routeId = r['route_id']??'';
|
||||
final estado = r['estado']??'PENDIENTE';
|
||||
final id = r['id'] as int?;
|
||||
final fotoPath = r['foto_path'] as String?;
|
||||
return Card(margin:const EdgeInsets.only(bottom:8),
|
||||
child:Padding(padding:const EdgeInsets.all(12),child:Column(
|
||||
crossAxisAlignment:CrossAxisAlignment.start, children:[
|
||||
@@ -455,6 +471,12 @@ class _AdminReportesTabState extends State<_AdminReportesTab> {
|
||||
const SizedBox(height:6),
|
||||
Text(_tipos[tipo]??tipo,style:const TextStyle(fontWeight:FontWeight.w600,fontSize:13)),
|
||||
Text(r['descripcion']??'',style:const TextStyle(fontSize:12,color:AppColors.grisTexto)),
|
||||
if (fotoPath != null && fotoPath.isNotEmpty) ...[
|
||||
const SizedBox(height:6),
|
||||
ClipRRect(borderRadius:BorderRadius.circular(6),
|
||||
child:Image.file(File(fotoPath), height:100, width:double.infinity,
|
||||
fit:BoxFit.cover)),
|
||||
],
|
||||
const SizedBox(height:6),
|
||||
Row(children:[
|
||||
Text('⭐'*calif,style:const TextStyle(fontSize:11)),
|
||||
@@ -793,6 +815,25 @@ class _AdminBanner extends StatelessWidget {
|
||||
]))));
|
||||
}
|
||||
|
||||
|
||||
// ── TAB Conductores (delega a ManageConductorsScreen) ────────────────────
|
||||
class _AdminConductoresTab extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(automaticallyImplyLeading: false,
|
||||
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
|
||||
title: const Text('Gestión de Conductores'),
|
||||
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||
child: Container(height: 4, color: AppColors.dorado)),
|
||||
actions: [
|
||||
IconButton(icon: const Icon(Icons.open_in_full),
|
||||
tooltip: 'Ver en pantalla completa',
|
||||
onPressed: () => Navigator.push(context,
|
||||
MaterialPageRoute(builder: (_) => const ManageConductorsScreen()))),
|
||||
]),
|
||||
body: const ManageConductorsScreen());
|
||||
}
|
||||
|
||||
// ── TAB 6: Gestión de Rutas ───────────────────────────────────────────────
|
||||
class _AdminRoutesTab extends StatefulWidget {
|
||||
@override State<_AdminRoutesTab> createState() => _AdminRoutesTabState();
|
||||
|
||||
262
lib/screens/admin/admin_stats_screen.dart
Normal file
262
lib/screens/admin/admin_stats_screen.dart
Normal file
@@ -0,0 +1,262 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../database/db_helper.dart';
|
||||
|
||||
class AdminStatsScreen extends StatefulWidget {
|
||||
const AdminStatsScreen({super.key});
|
||||
@override State<AdminStatsScreen> createState() => _AdminStatsScreenState();
|
||||
}
|
||||
|
||||
class _AdminStatsScreenState extends State<AdminStatsScreen> {
|
||||
Map<String, dynamic> _stats = {};
|
||||
List<Map<String, dynamic>> _byColonia = [];
|
||||
List<Map<String, dynamic>> _byRoute = [];
|
||||
List<Map<String, dynamic>> _byWeek = [];
|
||||
bool _loading = true;
|
||||
|
||||
@override void initState() { super.initState(); _load(); }
|
||||
|
||||
Future<void> _load() async {
|
||||
final s = await DbHelper.getAdminStats();
|
||||
final bc = await DbHelper.getReportesByColonia();
|
||||
final br = await DbHelper.getIncidentesByRoute();
|
||||
final bw = await DbHelper.getRatingByWeek();
|
||||
if (mounted) setState(() {
|
||||
_stats = s; _byColonia = bc; _byRoute = br; _byWeek = bw; _loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
backgroundColor: AppColors.grisFondo,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
|
||||
title: const Text('Dashboard de Estadisticas'),
|
||||
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||
child: Container(height: 4, color: AppColors.dorado)),
|
||||
actions: [IconButton(icon: const Icon(Icons.refresh), onPressed: _load)],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(padding: const EdgeInsets.all(14), child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
|
||||
// KPIs
|
||||
Row(children: [
|
||||
_KpiCard('Reportes', '${_stats['total_reportes']}',
|
||||
Icons.report, AppColors.naranjaAlerta),
|
||||
const SizedBox(width: 8),
|
||||
_KpiCard('Calificacion Prom.',
|
||||
(_stats['avg_rating'] as double? ?? 0).toStringAsFixed(1),
|
||||
Icons.star, Colors.amber),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
Row(children: [
|
||||
_KpiCard('Alertas Activas', '${_stats['alertas_activas']}',
|
||||
Icons.warning, AppColors.rojoError),
|
||||
const SizedBox(width: 8),
|
||||
_KpiCard('Conductores', '${_stats['total_conductores']}',
|
||||
Icons.person, AppColors.moradoConductor),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Calificacion por semana (línea)
|
||||
if (_byWeek.isNotEmpty) ...[
|
||||
_SectionTitle('Calificacion promedio semanal'),
|
||||
const SizedBox(height: 8),
|
||||
Card(child: Padding(padding: const EdgeInsets.all(16),
|
||||
child: SizedBox(height: 180,
|
||||
child: LineChart(LineChartData(
|
||||
minY: 1, maxY: 5,
|
||||
titlesData: FlTitlesData(
|
||||
leftTitles: AxisTitles(sideTitles: SideTitles(
|
||||
showTitles: true, interval: 1,
|
||||
getTitlesWidget: (v,_) => Text(v.toInt().toString(),
|
||||
style: const TextStyle(fontSize: 10)))),
|
||||
bottomTitles: AxisTitles(sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
getTitlesWidget: (v, _) {
|
||||
final idx = v.toInt();
|
||||
if (idx < 0 || idx >= _byWeek.length) return const SizedBox();
|
||||
return Text('S${_byWeek.length - idx}',
|
||||
style: const TextStyle(fontSize: 9));
|
||||
})),
|
||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
),
|
||||
gridData: FlGridData(drawHorizontalLine: true, horizontalInterval: 1),
|
||||
borderData: FlBorderData(show: true,
|
||||
border: Border.all(color: Colors.grey.shade300)),
|
||||
lineBarsData: [LineChartBarData(
|
||||
spots: _byWeek.reversed.toList().asMap().entries.map((e) =>
|
||||
FlSpot(e.key.toDouble(),
|
||||
(e.value['promedio'] as num? ?? 0).toDouble().clamp(1.0, 5.0))).toList(),
|
||||
isCurved: true,
|
||||
color: AppColors.verdeAdmin,
|
||||
barWidth: 3,
|
||||
belowBarData: BarAreaData(show: true,
|
||||
color: AppColors.verdeAdmin.withOpacity(0.1)),
|
||||
dotData: const FlDotData(show: true),
|
||||
)],
|
||||
))))),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// Reportes por colonia (barras horizontales)
|
||||
if (_byColonia.isNotEmpty) ...[
|
||||
_SectionTitle('Reportes por colonia (Top 10)'),
|
||||
const SizedBox(height: 8),
|
||||
Card(child: Padding(padding: const EdgeInsets.all(16),
|
||||
child: SizedBox(height: 240,
|
||||
child: BarChart(BarChartData(
|
||||
alignment: BarChartAlignment.spaceAround,
|
||||
maxY: (_byColonia.map((c) => (c['total'] as int? ?? 0).toDouble())
|
||||
.reduce((a,b)=>a>b?a:b) * 1.2),
|
||||
titlesData: FlTitlesData(
|
||||
bottomTitles: AxisTitles(sideTitles: SideTitles(
|
||||
showTitles: true, reservedSize: 32,
|
||||
getTitlesWidget: (v, _) {
|
||||
final i = v.toInt();
|
||||
if (i < 0 || i >= _byColonia.length) return const SizedBox();
|
||||
final name = (_byColonia[i]['colonia'] as String? ?? '');
|
||||
return Transform.rotate(angle: -0.5,
|
||||
child: Text(name.length > 8 ? '${name.substring(0,8)}.' : name,
|
||||
style: const TextStyle(fontSize: 8)));
|
||||
})),
|
||||
leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: true,
|
||||
getTitlesWidget: (v,_) => Text(v.toInt().toString(),
|
||||
style: const TextStyle(fontSize: 9)))),
|
||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
),
|
||||
barGroups: _byColonia.asMap().entries.map((e) => BarChartGroupData(
|
||||
x: e.key,
|
||||
barRods: [
|
||||
BarChartRodData(
|
||||
toY: (e.value['total'] as int? ?? 0).toDouble(),
|
||||
color: AppColors.guindaPrimary,
|
||||
width: 16, borderRadius: BorderRadius.circular(4)),
|
||||
BarChartRodData(
|
||||
toY: (e.value['resueltos'] as int? ?? 0).toDouble(),
|
||||
color: AppColors.verdeExito,
|
||||
width: 16, borderRadius: BorderRadius.circular(4)),
|
||||
],
|
||||
)).toList(),
|
||||
gridData: const FlGridData(drawHorizontalLine: true),
|
||||
borderData: FlBorderData(show: true,
|
||||
border: Border.all(color: Colors.grey.shade300)),
|
||||
))))),
|
||||
Padding(padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(children: [
|
||||
_Legend(AppColors.guindaPrimary, 'Total reportes'),
|
||||
const SizedBox(width: 16),
|
||||
_Legend(AppColors.verdeExito, 'Resueltos'),
|
||||
])),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// Rutas con más incidentes
|
||||
if (_byRoute.isNotEmpty) ...[
|
||||
_SectionTitle('Rutas con mas incidentes'),
|
||||
const SizedBox(height: 8),
|
||||
Card(child: Padding(padding: const EdgeInsets.all(16),
|
||||
child: SizedBox(height: 200,
|
||||
child: BarChart(BarChartData(
|
||||
alignment: BarChartAlignment.spaceAround,
|
||||
maxY: (_byRoute.map((r) => (r['total'] as int? ?? 0).toDouble())
|
||||
.reduce((a,b)=>a>b?a:b) * 1.3),
|
||||
titlesData: FlTitlesData(
|
||||
bottomTitles: AxisTitles(sideTitles: SideTitles(
|
||||
showTitles: true, reservedSize: 28,
|
||||
getTitlesWidget: (v, _) {
|
||||
final i = v.toInt();
|
||||
if (i < 0 || i >= _byRoute.length) return const SizedBox();
|
||||
return Text((_byRoute[i]['route_id'] as String? ?? '').replaceAll('RUTA-','R'),
|
||||
style: const TextStyle(fontSize: 9));
|
||||
})),
|
||||
leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: true,
|
||||
getTitlesWidget: (v,_) => Text(v.toInt().toString(),
|
||||
style: const TextStyle(fontSize: 9)))),
|
||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
),
|
||||
barGroups: _byRoute.asMap().entries.map((e) => BarChartGroupData(
|
||||
x: e.key,
|
||||
barRods: [BarChartRodData(
|
||||
toY: (e.value['total'] as int? ?? 0).toDouble(),
|
||||
gradient: const LinearGradient(
|
||||
colors: [AppColors.naranjaAlerta, AppColors.rojoError],
|
||||
begin: Alignment.bottomCenter, end: Alignment.topCenter),
|
||||
width: 20, borderRadius: BorderRadius.circular(4))],
|
||||
)).toList(),
|
||||
gridData: const FlGridData(drawHorizontalLine: true),
|
||||
borderData: FlBorderData(show: true,
|
||||
border: Border.all(color: Colors.grey.shade300)),
|
||||
))))),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// Colonias más problemáticas (lista)
|
||||
_SectionTitle('Colonias mas problematicas'),
|
||||
const SizedBox(height: 8),
|
||||
Card(child: Column(children: [
|
||||
..._byColonia.take(5).map((c) {
|
||||
final total = (c['total'] as int? ?? 0);
|
||||
final resueltos = (c['resueltos'] as int? ?? 0);
|
||||
final pct = total > 0 ? resueltos / total : 0.0;
|
||||
return ListTile(dense: true,
|
||||
leading: CircleAvatar(radius: 16,
|
||||
backgroundColor: total > 3 ? AppColors.rojoError.withOpacity(0.15)
|
||||
: AppColors.naranjaAlerta.withOpacity(0.15),
|
||||
child: Text('$total', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold,
|
||||
color: total > 3 ? AppColors.rojoError : AppColors.naranjaAlerta))),
|
||||
title: Text(c['colonia'] as String? ?? '',
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)),
|
||||
subtitle: LinearProgressIndicator(value: pct,
|
||||
backgroundColor: Colors.grey.shade200,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.verdeExito)),
|
||||
trailing: Text('${(pct*100).toInt()}% resuelto',
|
||||
style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
|
||||
);
|
||||
}),
|
||||
])),
|
||||
const SizedBox(height: 30),
|
||||
])),
|
||||
);
|
||||
}
|
||||
|
||||
class _KpiCard extends StatelessWidget {
|
||||
final String label, value; final IconData icon; final Color color;
|
||||
const _KpiCard(this.label, this.value, this.icon, this.color);
|
||||
@override
|
||||
Widget build(BuildContext context) => Expanded(child: Card(child: Padding(
|
||||
padding: const EdgeInsets.all(14), child: Row(children: [
|
||||
CircleAvatar(radius: 22, backgroundColor: color.withOpacity(0.12),
|
||||
child: Icon(icon, color: color, size: 22)),
|
||||
const SizedBox(width: 10),
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(value, style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: color)),
|
||||
Text(label, style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
|
||||
]),
|
||||
]))));
|
||||
}
|
||||
|
||||
class _SectionTitle extends StatelessWidget {
|
||||
final String title;
|
||||
const _SectionTitle(this.title);
|
||||
@override
|
||||
Widget build(BuildContext context) => Text(title,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15, color: AppColors.verdeAdmin));
|
||||
}
|
||||
|
||||
class _Legend extends StatelessWidget {
|
||||
final Color color; final String label;
|
||||
const _Legend(this.color, this.label);
|
||||
@override
|
||||
Widget build(BuildContext context) => Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Container(width: 12, height: 12, decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(2))),
|
||||
const SizedBox(width: 4),
|
||||
Text(label, style: const TextStyle(fontSize: 11, color: AppColors.grisTexto)),
|
||||
]);
|
||||
}
|
||||
244
lib/screens/admin/export_pdf_screen.dart
Normal file
244
lib/screens/admin/export_pdf_screen.dart
Normal file
@@ -0,0 +1,244 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../database/db_helper.dart';
|
||||
|
||||
class ExportPdfScreen extends StatefulWidget {
|
||||
const ExportPdfScreen({super.key});
|
||||
@override State<ExportPdfScreen> createState() => _ExportPdfScreenState();
|
||||
}
|
||||
|
||||
class _ExportPdfScreenState extends State<ExportPdfScreen> {
|
||||
bool _generating = false;
|
||||
String? _lastPath;
|
||||
|
||||
pw.TableRow _pdfRow(String label, String value, {bool isHeader = false}) =>
|
||||
pw.TableRow(
|
||||
decoration: isHeader ? pw.BoxDecoration(color: PdfColors.grey100) : null,
|
||||
children: [
|
||||
pw.Padding(padding: const pw.EdgeInsets.all(6),
|
||||
child: pw.Text(label, style: pw.TextStyle(
|
||||
fontSize: 10, fontWeight: isHeader ? pw.FontWeight.bold : null))),
|
||||
pw.Padding(padding: const pw.EdgeInsets.all(6),
|
||||
child: pw.Text(value, style: pw.TextStyle(
|
||||
fontSize: 10, fontWeight: pw.FontWeight.bold))),
|
||||
]);
|
||||
|
||||
Future<void> _generatePdf() async {
|
||||
setState(() => _generating = true);
|
||||
try {
|
||||
final stats = await DbHelper.getAdminStats();
|
||||
final colonias = await DbHelper.getReportesByColonia();
|
||||
final incidentes = await DbHelper.getIncidentesByRoute();
|
||||
final reviews = await DbHelper.getReviewSummaryByColonia();
|
||||
final now = DateTime.now();
|
||||
const meses = ['','Enero','Febrero','Marzo','Abril','Mayo','Junio',
|
||||
'Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'];
|
||||
|
||||
final pdf = pw.Document();
|
||||
pdf.addPage(pw.MultiPage(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
margin: const pw.EdgeInsets.all(32),
|
||||
header: (ctx) => pw.Column(children: [
|
||||
pw.Row(mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [
|
||||
pw.Column(crossAxisAlignment: pw.CrossAxisAlignment.start, children: [
|
||||
pw.Text('H. AYUNTAMIENTO DE CELAYA', style: pw.TextStyle(
|
||||
fontSize: 14, fontWeight: pw.FontWeight.bold,
|
||||
color: PdfColor.fromHex('6D1E3A'))),
|
||||
pw.Text('Direccion de Servicios Publicos',
|
||||
style: const pw.TextStyle(fontSize: 11, color: PdfColors.grey700)),
|
||||
pw.Text('Sistema de Recoleccion de Residuos',
|
||||
style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey600)),
|
||||
]),
|
||||
pw.Column(crossAxisAlignment: pw.CrossAxisAlignment.end, children: [
|
||||
pw.Text('REPORTE MENSUAL', style: pw.TextStyle(
|
||||
fontSize: 12, fontWeight: pw.FontWeight.bold,
|
||||
color: PdfColor.fromHex('6D1E3A'))),
|
||||
pw.Text('${meses[now.month]} ${now.year}',
|
||||
style: const pw.TextStyle(fontSize: 11)),
|
||||
pw.Text('Generado: ${now.day}/${now.month}/${now.year}',
|
||||
style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey600)),
|
||||
]),
|
||||
]),
|
||||
pw.Divider(color: PdfColor.fromHex('C9A84C'), thickness: 2),
|
||||
pw.SizedBox(height: 8),
|
||||
]),
|
||||
build: (ctx) => [
|
||||
pw.Text('RESUMEN EJECUTIVO', style: pw.TextStyle(
|
||||
fontSize: 13, fontWeight: pw.FontWeight.bold,
|
||||
color: PdfColor.fromHex('6D1E3A'))),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Table(
|
||||
border: pw.TableBorder.all(color: PdfColors.grey300),
|
||||
columnWidths: {0: const pw.FlexColumnWidth(2), 1: const pw.FlexColumnWidth(1)},
|
||||
children: [
|
||||
_pdfRow('Total de reportes ciudadanos', '${stats["total_reportes"]}', isHeader: true),
|
||||
_pdfRow('Total de resenas recibidas', '${stats["total_reviews"]}'),
|
||||
_pdfRow('Calificacion promedio',
|
||||
'${(stats["avg_rating"] as double? ?? 0).toStringAsFixed(2)} / 5.0'),
|
||||
_pdfRow('Alertas activas', '${stats["alertas_activas"]}'),
|
||||
_pdfRow('Conductores', '${stats["total_conductores"]}'),
|
||||
]),
|
||||
pw.SizedBox(height: 20),
|
||||
|
||||
if (colonias.isNotEmpty) ...[
|
||||
pw.Text('REPORTES POR COLONIA', style: pw.TextStyle(
|
||||
fontSize: 13, fontWeight: pw.FontWeight.bold,
|
||||
color: PdfColor.fromHex('6D1E3A'))),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Table(
|
||||
border: pw.TableBorder.all(color: PdfColors.grey300),
|
||||
columnWidths: {
|
||||
0: const pw.FlexColumnWidth(3), 1: const pw.FlexColumnWidth(1),
|
||||
2: const pw.FlexColumnWidth(1), 3: const pw.FlexColumnWidth(1),
|
||||
},
|
||||
children: [
|
||||
pw.TableRow(
|
||||
decoration: pw.BoxDecoration(color: PdfColor.fromHex('6D1E3A')),
|
||||
children: ['Colonia','Total','Resueltos','Pendientes'].map((h) =>
|
||||
pw.Padding(padding: const pw.EdgeInsets.all(6),
|
||||
child: pw.Text(h, style: pw.TextStyle(color: PdfColors.white,
|
||||
fontWeight: pw.FontWeight.bold, fontSize: 10)))).toList()),
|
||||
...colonias.map((c) {
|
||||
final total = c['total'] as int? ?? 0;
|
||||
final res = c['resueltos'] as int? ?? 0;
|
||||
return pw.TableRow(children: [
|
||||
c['colonia'] as String? ?? '', '$total', '$res', '${total - res}',
|
||||
].map((v) => pw.Padding(padding: const pw.EdgeInsets.all(5),
|
||||
child: pw.Text(v, style: const pw.TextStyle(fontSize: 9)))).toList());
|
||||
}),
|
||||
]),
|
||||
pw.SizedBox(height: 20),
|
||||
],
|
||||
|
||||
if (incidentes.isNotEmpty) ...[
|
||||
pw.Text('INCIDENTES POR RUTA', style: pw.TextStyle(
|
||||
fontSize: 13, fontWeight: pw.FontWeight.bold,
|
||||
color: PdfColor.fromHex('6D1E3A'))),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Table(
|
||||
border: pw.TableBorder.all(color: PdfColors.grey300),
|
||||
columnWidths: {0: const pw.FlexColumnWidth(2), 1: const pw.FlexColumnWidth(1)},
|
||||
children: [
|
||||
pw.TableRow(decoration: pw.BoxDecoration(color: PdfColor.fromHex('6D1E3A')),
|
||||
children: ['Ruta','Incidentes'].map((h) => pw.Padding(
|
||||
padding: const pw.EdgeInsets.all(6),
|
||||
child: pw.Text(h, style: pw.TextStyle(color: PdfColors.white,
|
||||
fontWeight: pw.FontWeight.bold, fontSize: 10)))).toList()),
|
||||
...incidentes.map((r) => pw.TableRow(children: [
|
||||
r['route_id'] as String? ?? '', '${r["total"]}',
|
||||
].map((v) => pw.Padding(padding: const pw.EdgeInsets.all(5),
|
||||
child: pw.Text(v, style: const pw.TextStyle(fontSize: 9)))).toList())),
|
||||
]),
|
||||
pw.SizedBox(height: 20),
|
||||
],
|
||||
|
||||
if (reviews.isNotEmpty) ...[
|
||||
pw.Text('CALIFICACIONES POR COLONIA', style: pw.TextStyle(
|
||||
fontSize: 13, fontWeight: pw.FontWeight.bold,
|
||||
color: PdfColor.fromHex('6D1E3A'))),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Table(
|
||||
border: pw.TableBorder.all(color: PdfColors.grey300),
|
||||
columnWidths: {0: const pw.FlexColumnWidth(3),
|
||||
1: const pw.FlexColumnWidth(1), 2: const pw.FlexColumnWidth(1)},
|
||||
children: [
|
||||
pw.TableRow(decoration: pw.BoxDecoration(color: PdfColor.fromHex('6D1E3A')),
|
||||
children: ['Colonia','Promedio','Total'].map((h) => pw.Padding(
|
||||
padding: const pw.EdgeInsets.all(6),
|
||||
child: pw.Text(h, style: pw.TextStyle(color: PdfColors.white,
|
||||
fontWeight: pw.FontWeight.bold, fontSize: 10)))).toList()),
|
||||
...reviews.map((r) => pw.TableRow(children: [
|
||||
r['colonia'] as String? ?? '',
|
||||
'${(r["promedio"] as num? ?? 0).toStringAsFixed(1)}/5',
|
||||
'${r["total"]}',
|
||||
].map((v) => pw.Padding(padding: const pw.EdgeInsets.all(5),
|
||||
child: pw.Text(v, style: const pw.TextStyle(fontSize: 9)))).toList())),
|
||||
]),
|
||||
pw.SizedBox(height: 20),
|
||||
],
|
||||
|
||||
pw.Divider(color: PdfColor.fromHex('C9A84C')),
|
||||
pw.Text('Celaya Limpia - H. Ayuntamiento de Celaya, Gto. - ${now.year}',
|
||||
style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey500),
|
||||
textAlign: pw.TextAlign.center),
|
||||
],
|
||||
));
|
||||
|
||||
// Guardar en directorio temporal y compartir con share_plus
|
||||
final bytes = await pdf.save();
|
||||
final dir = await getTemporaryDirectory();
|
||||
final file = File('${dir.path}/reporte_celaya_${now.month}_${now.year}.pdf');
|
||||
await file.writeAsBytes(bytes);
|
||||
|
||||
setState(() => _lastPath = file.path);
|
||||
|
||||
await Share.shareXFiles(
|
||||
[XFile(file.path, mimeType: 'application/pdf')],
|
||||
subject: 'Reporte Mensual Celaya Limpia - ${meses[now.month]} ${now.year}',
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error al generar PDF: $e'),
|
||||
backgroundColor: AppColors.rojoError));
|
||||
}
|
||||
if (mounted) setState(() => _generating = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
backgroundColor: AppColors.grisFondo,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
|
||||
title: const Text('Exportar Reporte PDF'),
|
||||
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||
child: Container(height: 4, color: AppColors.dorado))),
|
||||
body: Center(child: Padding(padding: const EdgeInsets.all(32), child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Container(width: 100, height: 100,
|
||||
decoration: BoxDecoration(color: AppColors.verdeAdmin.withOpacity(0.1),
|
||||
shape: BoxShape.circle),
|
||||
child: const Icon(Icons.picture_as_pdf, size: 52, color: AppColors.verdeAdmin)),
|
||||
const SizedBox(height: 24),
|
||||
const Text('Reporte Mensual', style: TextStyle(fontSize: 22,
|
||||
fontWeight: FontWeight.bold, color: AppColors.verdeAdmin)),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Genera un PDF con el resumen completo:\nreportes, incidentes y calificaciones.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: AppColors.grisTexto)),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(width: double.infinity, height: 52,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _generating ? null : _generatePdf,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))),
|
||||
icon: _generating
|
||||
? const SizedBox(width: 20, height: 20,
|
||||
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||
: const Icon(Icons.download),
|
||||
label: Text(_generating ? 'Generando...' : 'Generar y Compartir PDF',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold)))),
|
||||
if (_lastPath != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Container(padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(color: AppColors.verdeExito.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppColors.verdeExito.withOpacity(0.3))),
|
||||
child: Row(children: [
|
||||
const Icon(Icons.check_circle, color: AppColors.verdeExito, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(child: Text('PDF generado correctamente',
|
||||
style: TextStyle(color: AppColors.verdeExito, fontWeight: FontWeight.w600,
|
||||
fontSize: 13))),
|
||||
TextButton(onPressed: _generatePdf,
|
||||
child: const Text('Compartir de nuevo',
|
||||
style: TextStyle(fontSize: 11, color: AppColors.verdeAdmin))),
|
||||
])),
|
||||
],
|
||||
]))));
|
||||
}
|
||||
179
lib/screens/admin/manage_conductors_screen.dart
Normal file
179
lib/screens/admin/manage_conductors_screen.dart
Normal file
@@ -0,0 +1,179 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../database/db_helper.dart';
|
||||
|
||||
class ManageConductorsScreen extends StatefulWidget {
|
||||
const ManageConductorsScreen({super.key});
|
||||
@override State<ManageConductorsScreen> createState() => _ManageConductorsScreenState();
|
||||
}
|
||||
|
||||
class _ManageConductorsScreenState extends State<ManageConductorsScreen> {
|
||||
List<Map<String, dynamic>> _conductores = [];
|
||||
bool _loading = true;
|
||||
|
||||
@override void initState() { super.initState(); _load(); }
|
||||
|
||||
Future<void> _load() async {
|
||||
final c = await DbHelper.getConductoresConMeta();
|
||||
if (mounted) setState(() { _conductores = c; _loading = false; });
|
||||
}
|
||||
|
||||
Future<void> _showFormDialog({Map<String, dynamic>? existing}) async {
|
||||
final nombreCtrl = TextEditingController(text: existing?['nombre'] ?? '');
|
||||
final emailCtrl = TextEditingController(text: existing?['email'] ?? '');
|
||||
final passCtrl = TextEditingController();
|
||||
final notasCtrl = TextEditingController(text: existing?['notas'] ?? '');
|
||||
bool activo = (existing?['activo'] as int? ?? 1) == 1;
|
||||
bool obscure = true;
|
||||
|
||||
await showDialog(context: context, builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setSt) => AlertDialog(
|
||||
title: Text(existing == null ? 'Nuevo Conductor' : 'Editar Conductor'),
|
||||
content: SingleChildScrollView(child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
TextField(controller: nombreCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Nombre completo',
|
||||
prefixIcon: Icon(Icons.person_outline), border: OutlineInputBorder())),
|
||||
const SizedBox(height: 10),
|
||||
TextField(controller: emailCtrl, keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(labelText: 'Correo electronico',
|
||||
prefixIcon: Icon(Icons.email_outlined), border: OutlineInputBorder())),
|
||||
const SizedBox(height: 10),
|
||||
if (existing == null)
|
||||
TextField(controller: passCtrl, obscureText: obscure,
|
||||
decoration: InputDecoration(labelText: 'Contrasena',
|
||||
prefixIcon: const Icon(Icons.lock_outline), border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(icon: Icon(obscure ? Icons.visibility_off : Icons.visibility),
|
||||
onPressed: () => setSt(() => obscure = !obscure)))),
|
||||
if (existing == null) const SizedBox(height: 10),
|
||||
TextField(controller: notasCtrl, maxLines: 2,
|
||||
decoration: const InputDecoration(labelText: 'Notas internas (opcional)',
|
||||
border: OutlineInputBorder())),
|
||||
const SizedBox(height: 10),
|
||||
if (existing != null)
|
||||
SwitchListTile(value: activo, dense: true,
|
||||
title: Text(activo ? 'Conductor Activo' : 'Conductor Inactivo',
|
||||
style: TextStyle(color: activo ? AppColors.verdeAdmin : AppColors.rojoError,
|
||||
fontWeight: FontWeight.bold)),
|
||||
activeColor: AppColors.verdeAdmin,
|
||||
onChanged: (v) => setSt(() => activo = v)),
|
||||
])),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancelar')),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppColors.verdeAdmin,
|
||||
foregroundColor: Colors.white),
|
||||
onPressed: () async {
|
||||
if (nombreCtrl.text.trim().isEmpty || emailCtrl.text.trim().isEmpty) return;
|
||||
if (existing == null) {
|
||||
if (passCtrl.text.length < 6) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('La contrasena debe tener al menos 6 caracteres'),
|
||||
backgroundColor: AppColors.rojoError));
|
||||
return;
|
||||
}
|
||||
await DbHelper.insertConductor(nombreCtrl.text.trim(),
|
||||
emailCtrl.text.trim().toLowerCase(), passCtrl.text);
|
||||
} else {
|
||||
await DbHelper.updateConductor(existing['id'], nombreCtrl.text.trim(),
|
||||
emailCtrl.text.trim().toLowerCase());
|
||||
await DbHelper.updateConductorMeta(existing['id'], activo, notasCtrl.text.trim());
|
||||
}
|
||||
if (ctx.mounted) Navigator.pop(ctx);
|
||||
await _load();
|
||||
},
|
||||
child: Text(existing == null ? 'Crear' : 'Guardar')),
|
||||
])));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
backgroundColor: AppColors.grisFondo,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
|
||||
title: Text('Conductores (${_conductores.length})'),
|
||||
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||
child: Container(height: 4, color: AppColors.dorado)),
|
||||
actions: [
|
||||
IconButton(icon: const Icon(Icons.refresh), onPressed: _load),
|
||||
IconButton(icon: const Icon(Icons.add_circle_outline),
|
||||
tooltip: 'Nuevo conductor',
|
||||
onPressed: () => _showFormDialog()),
|
||||
],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _conductores.isEmpty
|
||||
? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
const Icon(Icons.person_off, color: AppColors.grisTexto, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Sin conductores registrados',
|
||||
style: TextStyle(color: AppColors.grisTexto)),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppColors.verdeAdmin,
|
||||
foregroundColor: Colors.white),
|
||||
onPressed: () => _showFormDialog(),
|
||||
icon: const Icon(Icons.add), label: const Text('Agregar primer conductor')),
|
||||
]))
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: _conductores.length,
|
||||
itemBuilder: (_, i) {
|
||||
final c = _conductores[i];
|
||||
final activo = (c['activo'] as int? ?? 1) == 1;
|
||||
final incidentes = c['total_incidentes'] as int? ?? 0;
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10),
|
||||
side: BorderSide(color: activo
|
||||
? AppColors.verdeAdmin.withOpacity(0.3)
|
||||
: AppColors.rojoError.withOpacity(0.3))),
|
||||
child: Padding(padding: const EdgeInsets.all(14), child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
CircleAvatar(radius: 22,
|
||||
backgroundColor: activo
|
||||
? AppColors.verdeAdmin.withOpacity(0.15)
|
||||
: Colors.grey.shade200,
|
||||
child: Icon(Icons.person,
|
||||
color: activo ? AppColors.verdeAdmin : AppColors.grisTexto, size: 24)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(c['nombre'] ?? '', style: const TextStyle(
|
||||
fontWeight: FontWeight.bold, fontSize: 14)),
|
||||
Text(c['email'] ?? '', style: const TextStyle(
|
||||
color: AppColors.grisTexto, fontSize: 12)),
|
||||
])),
|
||||
Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: activo ? AppColors.verdeAdmin.withOpacity(0.1)
|
||||
: AppColors.rojoError.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(10)),
|
||||
child: Text(activo ? 'Activo' : 'Inactivo',
|
||||
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold,
|
||||
color: activo ? AppColors.verdeAdmin : AppColors.rojoError))),
|
||||
IconButton(icon: const Icon(Icons.edit_outlined, size: 18),
|
||||
onPressed: () => _showFormDialog(existing: c)),
|
||||
]),
|
||||
if (incidentes > 0 || (c['notas'] as String?)?.isNotEmpty == true) ...[
|
||||
const Divider(height: 16),
|
||||
if (incidentes > 0)
|
||||
Row(children: [
|
||||
Icon(Icons.warning_amber, size: 14,
|
||||
color: incidentes > 3 ? AppColors.rojoError : AppColors.naranjaAlerta),
|
||||
const SizedBox(width: 4),
|
||||
Text('$incidentes incidente${incidentes != 1 ? 's' : ''} historico${incidentes != 1 ? 's' : ''}',
|
||||
style: TextStyle(fontSize: 12,
|
||||
color: incidentes > 3 ? AppColors.rojoError : AppColors.naranjaAlerta)),
|
||||
]),
|
||||
if ((c['notas'] as String?)?.isNotEmpty == true) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text('Notas: ${c['notas']}',
|
||||
style: const TextStyle(fontSize: 11, color: AppColors.grisTexto,
|
||||
fontStyle: FontStyle.italic)),
|
||||
],
|
||||
],
|
||||
])));
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -50,12 +50,32 @@ class _AddDomicilioScreenState extends State<AddDomicilioScreen> {
|
||||
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)';
|
||||
|
||||
// 1. Buscar primero en colonies_data (rutas predefinidas)
|
||||
final staticData = getColonyByName(_coloniaSeleccionada!);
|
||||
String routeId = staticData?.routeId ?? '';
|
||||
String horario = staticData?.horarioEstimado ?? '';
|
||||
|
||||
// 2. Si no hay match estático, buscar en route_definitions del admin
|
||||
if (routeId.isEmpty) {
|
||||
final routeDefs = await DbHelper.getAllRouteDefinitions();
|
||||
for (final rd in routeDefs) {
|
||||
if (rd.colonias.any((c) =>
|
||||
c.toLowerCase() == _coloniaSeleccionada!.toLowerCase())) {
|
||||
routeId = rd.routeId;
|
||||
horario = '${_turnoLabel(rd.turno)} (${rd.horaInicio}–${rd.horaFin})';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback si no se encontró
|
||||
if (routeId.isEmpty) {
|
||||
routeId = 'RUTA-01';
|
||||
horario = 'Matutino (06:00–08:00)';
|
||||
}
|
||||
|
||||
if (widget.editing != null) {
|
||||
// Editar existente — eliminar y volver a insertar
|
||||
await DbHelper.deleteDomicilio(widget.editing!.id!);
|
||||
}
|
||||
|
||||
@@ -75,6 +95,9 @@ class _AddDomicilioScreenState extends State<AddDomicilioScreen> {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
|
||||
String _turnoLabel(String t) =>
|
||||
t == 'MATUTINO' ? 'Matutino' : t == 'VESPERTINO' ? 'Vespertino' : 'Nocturno';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
|
||||
@@ -16,12 +16,12 @@ class _AiCameraScreenState extends State<AiCameraScreen> {
|
||||
CameraController? _cam;
|
||||
Interpreter? _interpreter;
|
||||
bool _processing = false;
|
||||
String _result = 'Apunta a un residuo y escanea';
|
||||
String _result = 'Apunta a un residuo y toca el botón';
|
||||
String _confidence = '';
|
||||
bool _modelLoaded = false;
|
||||
|
||||
// 0=Orgánico, 1=Inorgánico (según waste_classification_model)
|
||||
final _labels = ['Residuo Orgánico ♻️', 'Residuo Inorgánico 🗑️'];
|
||||
final _labels = ['Residuo Organico', 'Residuo Inorganico'];
|
||||
final _labelColors = [AppColors.verdeExito, AppColors.naranjaAlerta];
|
||||
|
||||
@override
|
||||
@@ -52,7 +52,7 @@ class _AiCameraScreenState extends State<AiCameraScreen> {
|
||||
_interpreter = await Interpreter.fromAsset('assets/models/waste_model.tflite');
|
||||
setState(() => _modelLoaded = true);
|
||||
} catch (e) {
|
||||
setState(() => _result = '⚠️ Modelo no encontrado.\nAgrega waste_model.tflite a assets/models/');
|
||||
setState(() => _result = 'Modelo no encontrado.\nAgrega waste_model.tflite a assets/models/');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
270
lib/screens/citizen/chatbot_screen.dart
Normal file
270
lib/screens/citizen/chatbot_screen.dart
Normal file
@@ -0,0 +1,270 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
|
||||
// ── Árbol de respuestas predefinidas ──────────────────────────────────────
|
||||
class _ChatNode {
|
||||
final String text;
|
||||
final List<_ChatOption> options;
|
||||
final bool isAnswer;
|
||||
const _ChatNode(this.text, this.options, {this.isAnswer = false});
|
||||
}
|
||||
|
||||
class _ChatOption {
|
||||
final String label;
|
||||
final _ChatNode next;
|
||||
const _ChatOption(this.label, this.next);
|
||||
}
|
||||
|
||||
final _chatTree = _ChatNode('Hola, soy el asistente de Celaya Limpia. ¿En que te puedo ayudar?', [
|
||||
_ChatOption('Separacion de residuos', _ChatNode('¿Que quieres saber sobre separacion?', [
|
||||
_ChatOption('Como separo mi basura', _ChatNode(
|
||||
'Separa tus residuos en 3 grupos:\n\n'
|
||||
'ORGANICOS (bolsa verde):\nRestos de comida, cascara de huevo, pasto, hojas.\n\n'
|
||||
'INORGANICOS reciclables (bolsa azul):\nPET, latas, carton limpio, vidrio.\n\n'
|
||||
'NO reciclables (bolsa negra):\nPanales, papel sanitario, colillas, chicles.',
|
||||
[], isAnswer: true)),
|
||||
_ChatOption('Que NO debo mezclar', _ChatNode(
|
||||
'NUNCA mezcles:\n\n'
|
||||
'- Pilas o baterias con basura comun\n'
|
||||
'- Aceite de cocina (contamina el agua)\n'
|
||||
'- Medicamentos vencidos\n'
|
||||
'- Jeringas o material medico\n'
|
||||
'- Electronicos con basura doméstica\n\n'
|
||||
'Estos requieren manejo especial.',
|
||||
[], isAnswer: true)),
|
||||
_ChatOption('Que hago con el aceite', _ChatNode(
|
||||
'El aceite de cocina usado NO va a la basura ni al drenaje.\n\n'
|
||||
'1. Dejalo enfriar completamente\n'
|
||||
'2. Guardalo en botella de PET cerrada\n'
|
||||
'3. Llevalo a los puntos de acopio del Ayuntamiento de Celaya\n\n'
|
||||
'El aceite reciclado se convierte en biodiesel.',
|
||||
[], isAnswer: true)),
|
||||
])),
|
||||
_ChatOption('Residuos especiales', _ChatNode('¿Que tipo de residuo especial tienes?', [
|
||||
_ChatOption('Donde dejo electronicos', _ChatNode(
|
||||
'Los aparatos electronicos (celulares, computadoras, focos ahorradores) '
|
||||
'son residuos RAEE.\n\n'
|
||||
'Puntos de acopio en Celaya:\n'
|
||||
'- Tiendas de electronica\n'
|
||||
'- Centros comerciales con contenedores especiales\n'
|
||||
'- Eventos de recoleccion del municipio\n\n'
|
||||
'NUNCA los tires a la basura comun.',
|
||||
[], isAnswer: true)),
|
||||
_ChatOption('Que hago con medicamentos', _ChatNode(
|
||||
'Los medicamentos vencidos son residuos peligrosos.\n\n'
|
||||
'- Llevalos a farmacias que tengan programa de devolucion\n'
|
||||
'- Algunos hospitales los reciben\n'
|
||||
'- Nunca los tires al drenaje ni a la basura comun\n\n'
|
||||
'Contaminar el agua con medicamentos afecta a toda la comunidad.',
|
||||
[], isAnswer: true)),
|
||||
_ChatOption('Que hago con pilas y baterias', _ChatNode(
|
||||
'Las pilas y baterias contienen metales pesados toxicos.\n\n'
|
||||
'Depositalas en:\n'
|
||||
'- Supermercados (contenedores naranjas)\n'
|
||||
'- Tiendas de electronica\n'
|
||||
'- Oficinas del Ayuntamiento de Celaya\n\n'
|
||||
'1 pila puede contaminar 600,000 litros de agua.',
|
||||
[], isAnswer: true)),
|
||||
])),
|
||||
_ChatOption('Sobre el servicio de recoleccion', _ChatNode('¿Que necesitas saber?', [
|
||||
_ChatOption('Cuando debo sacar la basura', _ChatNode(
|
||||
'Celaya Limpia te notificara:\n\n'
|
||||
'1. Cuando el camion salga del relleno sanitario\n'
|
||||
'2. Cuando este a 30 minutos\n'
|
||||
'3. A 15 minutos: este es el momento de sacar tus bolsas\n\n'
|
||||
'NO saques la basura antes del aviso de 15 minutos. '
|
||||
'Atrae fauna nociva y obstruye la acera.',
|
||||
[], isAnswer: true)),
|
||||
_ChatOption('El camion no paso', _ChatNode(
|
||||
'Si el camion no paso en tu horario habitual:\n\n'
|
||||
'1. Revisa las alertas en la app (puede haber un retraso o incidente)\n'
|
||||
'2. Guarda tu basura bien cerrada\n'
|
||||
'3. Reporta la incidencia desde la seccion "Reportar"\n\n'
|
||||
'El administrador revisara tu reporte y te mantendra informado.',
|
||||
[], isAnswer: true)),
|
||||
_ChatOption('Como califico el servicio', _ChatNode(
|
||||
'Despues de que el camion pase por tu zona, '
|
||||
'la app te mostrara una notificacion para calificar.\n\n'
|
||||
'Puedes dar de 1 a 5 estrellas y dejar un comentario.\n\n'
|
||||
'Tus calificaciones ayudan al Ayuntamiento a identificar '
|
||||
'colonias con problemas y mejorar el servicio.',
|
||||
[], isAnswer: true)),
|
||||
])),
|
||||
_ChatOption('Denuncia o emergencia', _ChatNode(
|
||||
'Para situaciones urgentes:\n\n'
|
||||
'- Reporte de incidencias: usa la seccion "Reportar" en la app\n'
|
||||
'- Emergencias: llama al 911\n'
|
||||
'- Ayuntamiento de Celaya: (461) 614-8000\n'
|
||||
'- SEMARNAT Guanajuato: (477) 717-2600\n\n'
|
||||
'Para basura clandestina o tiraderos ilegales, reportalo al municipio.',
|
||||
[], isAnswer: true)),
|
||||
]);
|
||||
|
||||
// ── Pantalla del chatbot ──────────────────────────────────────────────────
|
||||
class ChatbotScreen extends StatefulWidget {
|
||||
const ChatbotScreen({super.key});
|
||||
@override State<ChatbotScreen> createState() => _ChatbotScreenState();
|
||||
}
|
||||
|
||||
class _ChatbotScreenState extends State<ChatbotScreen> {
|
||||
final List<_Message> _messages = [];
|
||||
_ChatNode _current = _chatTree;
|
||||
final _scroll = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Mensaje inicial
|
||||
_messages.add(_Message(text: _chatTree.text, isBot: true));
|
||||
}
|
||||
|
||||
void _handleOption(_ChatOption option) {
|
||||
setState(() {
|
||||
// Mensaje del usuario
|
||||
_messages.add(_Message(text: option.label, isBot: false));
|
||||
// Ir al siguiente nodo
|
||||
_current = option.next;
|
||||
_messages.add(_Message(text: _current.text, isBot: true,
|
||||
isAnswer: _current.isAnswer));
|
||||
});
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
_scroll.animateTo(_scroll.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300), curve: Curves.easeOut);
|
||||
});
|
||||
}
|
||||
|
||||
void _reset() {
|
||||
setState(() {
|
||||
_messages.clear();
|
||||
_current = _chatTree;
|
||||
_messages.add(_Message(text: _chatTree.text, isBot: true));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
backgroundColor: AppColors.grisFondo,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
|
||||
title: const Row(children: [
|
||||
CircleAvatar(radius: 14,
|
||||
backgroundColor: Colors.white24,
|
||||
child: Icon(Icons.smart_toy, color: AppColors.dorado, size: 18)),
|
||||
SizedBox(width: 8),
|
||||
Text('Asistente Celaya Limpia'),
|
||||
]),
|
||||
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||
child: Container(height: 4, color: AppColors.dorado)),
|
||||
actions: [
|
||||
IconButton(icon: const Icon(Icons.refresh), tooltip: 'Reiniciar',
|
||||
onPressed: _reset),
|
||||
],
|
||||
),
|
||||
body: Column(children: [
|
||||
// Mensajes
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: _scroll,
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: _messages.length,
|
||||
itemBuilder: (_, i) => _MessageBubble(msg: _messages[i]),
|
||||
),
|
||||
),
|
||||
// Opciones del nodo actual
|
||||
if (_current.options.isNotEmpty)
|
||||
Container(
|
||||
color: Colors.white,
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Selecciona una opcion:',
|
||||
style: TextStyle(fontSize: 11, color: AppColors.grisTexto,
|
||||
fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(spacing: 8, runSpacing: 8,
|
||||
children: _current.options.map((opt) =>
|
||||
ActionChip(
|
||||
label: Text(opt.label, style: const TextStyle(fontSize: 12)),
|
||||
backgroundColor: AppColors.guindaPrimary.withOpacity(0.1),
|
||||
side: const BorderSide(color: AppColors.guindaPrimary),
|
||||
labelStyle: const TextStyle(color: AppColors.guindaPrimary),
|
||||
onPressed: () => _handleOption(opt),
|
||||
)).toList()),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
// Botón de reiniciar al llegar a una respuesta final
|
||||
Container(
|
||||
color: Colors.white,
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: SizedBox(width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _reset,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.guindaPrimary,
|
||||
side: const BorderSide(color: AppColors.guindaPrimary)),
|
||||
icon: const Icon(Icons.arrow_back, size: 16),
|
||||
label: const Text('Hacer otra pregunta'))),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
@override void dispose() { _scroll.dispose(); super.dispose(); }
|
||||
}
|
||||
|
||||
class _Message {
|
||||
final String text;
|
||||
final bool isBot;
|
||||
final bool isAnswer;
|
||||
const _Message({required this.text, required this.isBot, this.isAnswer = false});
|
||||
}
|
||||
|
||||
class _MessageBubble extends StatelessWidget {
|
||||
final _Message msg;
|
||||
const _MessageBubble({super.key, required this.msg});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: msg.isBot ? MainAxisAlignment.start : MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (msg.isBot) ...[
|
||||
CircleAvatar(radius: 16,
|
||||
backgroundColor: AppColors.guindaPrimary,
|
||||
child: const Icon(Icons.smart_toy, color: Colors.white, size: 16)),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Flexible(child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: msg.isBot
|
||||
? (msg.isAnswer ? Colors.green.shade50 : Colors.white)
|
||||
: AppColors.guindaPrimary,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(16),
|
||||
topRight: const Radius.circular(16),
|
||||
bottomLeft: Radius.circular(msg.isBot ? 4 : 16),
|
||||
bottomRight: Radius.circular(msg.isBot ? 16 : 4),
|
||||
),
|
||||
border: msg.isBot ? Border.all(
|
||||
color: msg.isAnswer
|
||||
? Colors.green.shade200
|
||||
: Colors.grey.shade200) : null,
|
||||
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.06),
|
||||
blurRadius: 4, offset: const Offset(0, 2))],
|
||||
),
|
||||
child: Text(msg.text,
|
||||
style: TextStyle(fontSize: 13, height: 1.5,
|
||||
color: msg.isBot ? AppColors.negroTexto : Colors.white)),
|
||||
)),
|
||||
if (!msg.isBot) const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,10 @@ import 'citizen_guia_screen.dart';
|
||||
import 'citizen_reporte_screen.dart';
|
||||
import 'add_domicilio_screen.dart';
|
||||
import 'review_screen.dart';
|
||||
import 'collection_calendar_screen.dart';
|
||||
import 'notification_history_screen.dart';
|
||||
import 'chatbot_screen.dart';
|
||||
import '../settings_screen.dart';
|
||||
|
||||
class CitizenHomeScreen extends StatefulWidget {
|
||||
const CitizenHomeScreen({super.key});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../database/db_helper.dart';
|
||||
import '../../models/models.dart';
|
||||
@@ -16,12 +18,14 @@ class _CitizenReporteScreenState extends State<CitizenReporteScreen> {
|
||||
int _calif = 5;
|
||||
bool _loading = false, _sent = false;
|
||||
List<ReporteModel> _reportes = [];
|
||||
File? _foto;
|
||||
final _picker = ImagePicker();
|
||||
|
||||
static const _tipos = {
|
||||
'CAMION_NO_PASO':'🚛 El camión no pasó',
|
||||
'RETRASO':'⏱️ Retraso significativo',
|
||||
'RESIDUOS_NO_RECOGIDOS':'🗑️ Residuos no recogidos',
|
||||
'OTRO':'📝 Otro motivo',
|
||||
'CAMION_NO_PASO': 'El camion no paso',
|
||||
'RETRASO': 'Retraso significativo',
|
||||
'RESIDUOS_NO_RECOGIDOS': 'Residuos no recogidos',
|
||||
'OTRO': 'Otro motivo',
|
||||
};
|
||||
|
||||
@override void initState() { super.initState(); _load(); }
|
||||
@@ -33,22 +37,59 @@ class _CitizenReporteScreenState extends State<CitizenReporteScreen> {
|
||||
if (mounted) setState(() => _reportes = r);
|
||||
}
|
||||
|
||||
Future<void> _pickImage(ImageSource source) async {
|
||||
try {
|
||||
final picked = await _picker.pickImage(source: source, imageQuality: 70, maxWidth: 1024);
|
||||
if (picked != null && mounted) setState(() => _foto = File(picked.path));
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('No se pudo acceder a la camara: $e'),
|
||||
backgroundColor: AppColors.rojoError));
|
||||
}
|
||||
}
|
||||
|
||||
void _showPhotoOptions() {
|
||||
showModalBottomSheet(context: context, builder: (_) => SafeArea(
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
ListTile(leading: const Icon(Icons.camera_alt, color: AppColors.guindaPrimary),
|
||||
title: const Text('Tomar foto'),
|
||||
onTap: () { Navigator.pop(context); _pickImage(ImageSource.camera); }),
|
||||
ListTile(leading: const Icon(Icons.photo_library, color: AppColors.guindaPrimary),
|
||||
title: const Text('Elegir de galeria'),
|
||||
onTap: () { Navigator.pop(context); _pickImage(ImageSource.gallery); }),
|
||||
if (_foto != null)
|
||||
ListTile(leading: const Icon(Icons.delete_outline, color: AppColors.rojoError),
|
||||
title: const Text('Quitar foto', style: TextStyle(color: AppColors.rojoError)),
|
||||
onTap: () { Navigator.pop(context); setState(() => _foto = null); }),
|
||||
])));
|
||||
}
|
||||
|
||||
Future<void> _send() async {
|
||||
final auth = context.read<AuthService>();
|
||||
if (auth.currentUser == null || _desc.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('Describe el problema'), backgroundColor: AppColors.rojoError)); return;
|
||||
content: Text('Describe el problema'),
|
||||
backgroundColor: AppColors.rojoError));
|
||||
return;
|
||||
}
|
||||
setState(() => _loading = true);
|
||||
await DbHelper.insertReporte(ReporteModel(
|
||||
userId: auth.currentUser!.id!, tipo: _tipo, descripcion: _desc.text.trim(),
|
||||
colonia: auth.primaryDomicilio?.colonia ?? '',
|
||||
routeId: auth.primaryDomicilio?.routeId ?? '',
|
||||
fecha: DateTime.now().toIso8601String(), calificacion: _calif,
|
||||
));
|
||||
|
||||
final db = await DbHelper.database;
|
||||
await db.insert('reportes', {
|
||||
'user_id': auth.currentUser!.id,
|
||||
'tipo': _tipo,
|
||||
'descripcion': _desc.text.trim(),
|
||||
'colonia': auth.primaryDomicilio?.colonia ?? '',
|
||||
'route_id': auth.primaryDomicilio?.routeId ?? '',
|
||||
'fecha': DateTime.now().toIso8601String(),
|
||||
'estado': 'PENDIENTE',
|
||||
'calificacion': _calif,
|
||||
'foto_path': _foto?.path,
|
||||
});
|
||||
|
||||
await _load();
|
||||
if (!mounted) return;
|
||||
setState(() { _loading = false; _sent = true; _desc.clear(); });
|
||||
setState(() { _loading = false; _sent = true; _desc.clear(); _foto = null; });
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (mounted) setState(() => _sent = false);
|
||||
}
|
||||
@@ -61,57 +102,120 @@ class _CitizenReporteScreenState extends State<CitizenReporteScreen> {
|
||||
title: const Text('Reportar Incidencia'),
|
||||
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||
child: Container(height: 4, color: AppColors.dorado))),
|
||||
body: _sent ? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
const Icon(Icons.check_circle, color: AppColors.verdeExito, size: 64),
|
||||
const SizedBox(height: 12),
|
||||
const Text('¡Reporte enviado!', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: AppColors.verdeExito)),
|
||||
const Text('El Ayuntamiento lo revisará pronto.', style: TextStyle(color: AppColors.grisTexto)),
|
||||
])) : SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(children: [
|
||||
Card(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(padding: const EdgeInsets.all(16), child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('Nueva Incidencia', style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.guindaPrimary, fontSize: 16)),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Tipo:', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
|
||||
..._tipos.entries.map((e) => RadioListTile<String>(dense: true, value: e.key,
|
||||
groupValue: _tipo, title: Text(e.value, style: const TextStyle(fontSize: 13)),
|
||||
activeColor: AppColors.guindaPrimary,
|
||||
onChanged: (v) => setState(() => _tipo = v!))),
|
||||
const SizedBox(height: 8),
|
||||
DropdownButtonFormField<int>(value: _calif,
|
||||
decoration: const InputDecoration(labelText: 'Calificación', border: OutlineInputBorder()),
|
||||
items: [5,4,3,2,1].map((n) => DropdownMenuItem(value: n,
|
||||
child: Text(['⭐⭐⭐⭐⭐ Excelente','⭐⭐⭐⭐ Bueno','⭐⭐⭐ Regular','⭐⭐ Malo','⭐ Muy malo'][5-n]))).toList(),
|
||||
onChanged: (v) => setState(() => _calif = v!)),
|
||||
const SizedBox(height: 10),
|
||||
TextField(controller: _desc, maxLines: 3,
|
||||
decoration: const InputDecoration(hintText: 'Describe el problema...',
|
||||
border: OutlineInputBorder(), filled: true, fillColor: Colors.white)),
|
||||
const SizedBox(height: 14),
|
||||
SizedBox(width: double.infinity, height: 48,
|
||||
child: ElevatedButton.icon(onPressed: _loading ? null : _send,
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppColors.guindaPrimary,
|
||||
foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
icon: _loading ? const SizedBox(width: 18, height: 18,
|
||||
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) : const Icon(Icons.send),
|
||||
label: const Text('ENVIAR REPORTE', style: TextStyle(fontWeight: FontWeight.bold)))),
|
||||
]))),
|
||||
if (_reportes.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
const Align(alignment: Alignment.centerLeft,
|
||||
child: Text('Mis Reportes', style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.guindaPrimary, fontSize: 15))),
|
||||
const SizedBox(height: 8),
|
||||
..._reportes.map((r) => Card(margin: const EdgeInsets.only(bottom: 6),
|
||||
child: ListTile(dense: true,
|
||||
leading: const CircleAvatar(backgroundColor: AppColors.guindaPrimary, radius: 16,
|
||||
child: Icon(Icons.report, color: Colors.white, size: 16)),
|
||||
title: Text(_tipos[r.tipo] ?? r.tipo, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)),
|
||||
subtitle: Text(r.descripcion, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11)),
|
||||
trailing: Container(padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
||||
decoration: BoxDecoration(color: AppColors.naranjaAlerta.withOpacity(0.15), borderRadius: BorderRadius.circular(10)),
|
||||
child: Text(r.estado, style: const TextStyle(fontSize: 9, color: AppColors.naranjaAlerta, fontWeight: FontWeight.bold)))))),
|
||||
],
|
||||
])),
|
||||
body: _sent
|
||||
? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
const Icon(Icons.check_circle, color: AppColors.verdeExito, size: 64),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Reporte enviado', style: TextStyle(fontSize: 20,
|
||||
fontWeight: FontWeight.bold, color: AppColors.verdeExito)),
|
||||
const Text('El Ayuntamiento lo revisara pronto.',
|
||||
style: TextStyle(color: AppColors.grisTexto)),
|
||||
]))
|
||||
: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(children: [
|
||||
Card(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(padding: const EdgeInsets.all(16), child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('Tipo de incidencia', style: TextStyle(
|
||||
fontWeight: FontWeight.bold, color: AppColors.guindaPrimary, fontSize: 15)),
|
||||
const SizedBox(height: 8),
|
||||
..._tipos.entries.map((e) => RadioListTile<String>(dense: true,
|
||||
value: e.key, groupValue: _tipo,
|
||||
title: Text(e.value, style: const TextStyle(fontSize: 13)),
|
||||
activeColor: AppColors.guindaPrimary,
|
||||
onChanged: (v) => setState(() => _tipo = v!))),
|
||||
const SizedBox(height: 8),
|
||||
DropdownButtonFormField<int>(value: _calif,
|
||||
decoration: const InputDecoration(labelText: 'Calificacion del servicio',
|
||||
border: OutlineInputBorder()),
|
||||
items: [5,4,3,2,1].map((n) => DropdownMenuItem(value: n,
|
||||
child: Text(['Excelente','Bueno','Regular','Malo','Muy malo'][5-n]))).toList(),
|
||||
onChanged: (v) => setState(() => _calif = v!)),
|
||||
const SizedBox(height: 10),
|
||||
TextField(controller: _desc, maxLines: 3,
|
||||
decoration: const InputDecoration(hintText: 'Describe el problema...',
|
||||
border: OutlineInputBorder(), filled: true, fillColor: Colors.white)),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Foto adjunta
|
||||
const Text('Foto del incidente (opcional)',
|
||||
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
|
||||
const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
onTap: _showPhotoOptions,
|
||||
child: Container(
|
||||
width: double.infinity, height: _foto != null ? 180 : 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: _foto != null
|
||||
? AppColors.guindaPrimary : Colors.grey.shade300,
|
||||
style: BorderStyle.solid)),
|
||||
child: _foto != null
|
||||
? Stack(children: [
|
||||
ClipRRect(borderRadius: BorderRadius.circular(8),
|
||||
child: Image.file(_foto!, fit: BoxFit.cover,
|
||||
width: double.infinity, height: 180)),
|
||||
Positioned(top: 8, right: 8,
|
||||
child: GestureDetector(onTap: () => setState(() => _foto = null),
|
||||
child: Container(padding: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.rojoError, shape: BoxShape.circle),
|
||||
child: const Icon(Icons.close, color: Colors.white, size: 16)))),
|
||||
])
|
||||
: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
const Icon(Icons.add_a_photo_outlined,
|
||||
color: AppColors.grisTexto, size: 28),
|
||||
const SizedBox(height: 4),
|
||||
const Text('Agregar foto', style: TextStyle(
|
||||
color: AppColors.grisTexto, fontSize: 12)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
SizedBox(width: double.infinity, height: 48,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _loading ? null : _send,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
icon: _loading ? const SizedBox(width: 18, height: 18,
|
||||
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||
: const Icon(Icons.send),
|
||||
label: const Text('ENVIAR REPORTE',
|
||||
style: TextStyle(fontWeight: FontWeight.bold)))),
|
||||
]))),
|
||||
if (_reportes.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
const Align(alignment: Alignment.centerLeft,
|
||||
child: Text('Mis Reportes', style: TextStyle(fontWeight: FontWeight.bold,
|
||||
color: AppColors.guindaPrimary, fontSize: 15))),
|
||||
const SizedBox(height: 8),
|
||||
..._reportes.map((r) => Card(margin: const EdgeInsets.only(bottom: 6),
|
||||
child: ListTile(dense: true,
|
||||
leading: CircleAvatar(backgroundColor: AppColors.guindaPrimary, radius: 16,
|
||||
child: const Icon(Icons.report, color: Colors.white, size: 16)),
|
||||
title: Text(_tipos[r.tipo] ?? r.tipo,
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)),
|
||||
subtitle: Text(r.descripcion, maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 11)),
|
||||
trailing: Container(padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: _estadoColor(r.estado).withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(10)),
|
||||
child: Text(r.estado, style: TextStyle(fontSize: 9,
|
||||
color: _estadoColor(r.estado), fontWeight: FontWeight.bold)))))),
|
||||
],
|
||||
])),
|
||||
);
|
||||
|
||||
Color _estadoColor(String e) {
|
||||
switch (e) {
|
||||
case 'RESUELTO': return AppColors.verdeExito;
|
||||
case 'EN_REVISION': return AppColors.azulInfo;
|
||||
default: return AppColors.naranjaAlerta;
|
||||
}
|
||||
}
|
||||
|
||||
@override void dispose() { _desc.dispose(); super.dispose(); }
|
||||
}
|
||||
|
||||
253
lib/screens/citizen/collection_calendar_screen.dart
Normal file
253
lib/screens/citizen/collection_calendar_screen.dart
Normal file
@@ -0,0 +1,253 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../database/db_helper.dart';
|
||||
import '../../models/models.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
|
||||
class CollectionCalendarScreen extends StatefulWidget {
|
||||
const CollectionCalendarScreen({super.key});
|
||||
@override State<CollectionCalendarScreen> createState() => _CollectionCalendarScreenState();
|
||||
}
|
||||
|
||||
class _CollectionCalendarScreenState extends State<CollectionCalendarScreen> {
|
||||
RouteDefinitionModel? _routeDef;
|
||||
List<ReviewModel> _myReviews = [];
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() { super.initState(); _load(); }
|
||||
|
||||
Future<void> _load() async {
|
||||
final auth = context.read<AuthService>();
|
||||
final dom = auth.primaryDomicilio;
|
||||
if (dom != null) {
|
||||
final rd = await DbHelper.getRouteDefinitionById(dom.routeId);
|
||||
final rv = await DbHelper.getAllReviews();
|
||||
final mine = rv.where((r) => r.userId == auth.currentUser?.id).toList();
|
||||
if (mounted) setState(() { _routeDef = rd; _myReviews = mine; _loading = false; });
|
||||
} else {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _shareSchedule() {
|
||||
final auth = context.read<AuthService>();
|
||||
final dom = auth.primaryDomicilio;
|
||||
if (dom == null) return;
|
||||
final rd = _routeDef;
|
||||
final diasStr = rd?.dias.map(_diaLabel).join(', ') ?? 'Lunes, Miércoles y Viernes';
|
||||
final horario = rd != null ? '${rd.horaInicio}–${rd.horaFin}' : dom.horarioEstimado;
|
||||
|
||||
Share.share(
|
||||
'🗑️ Horario de recolección de basura\n'
|
||||
'📍 Colonia: ${dom.colonia}\n'
|
||||
'📅 Días: $diasStr\n'
|
||||
'⏰ Horario: $horario\n'
|
||||
'🚛 Ruta: ${dom.routeId}\n\n'
|
||||
'Descarga Celaya Limpia para recibir avisos en tiempo real.',
|
||||
);
|
||||
}
|
||||
|
||||
String _diaLabel(String d) {
|
||||
const m = {'LUNES':'Lu','MARTES':'Ma','MIERCOLES':'Mi',
|
||||
'JUEVES':'Ju','VIERNES':'Vi','SABADO':'Sa','DOMINGO':'Do'};
|
||||
return m[d] ?? d;
|
||||
}
|
||||
|
||||
// Días del mes actual con marcas de recolección
|
||||
List<Widget> _buildCalendar() {
|
||||
final now = DateTime.now();
|
||||
final first = DateTime(now.year, now.month, 1);
|
||||
final days = DateTime(now.year, now.month + 1, 0).day;
|
||||
final dias = _routeDef?.dias ?? [];
|
||||
|
||||
const weekDays = ['LUNES','MARTES','MIERCOLES','JUEVES','VIERNES','SABADO','DOMINGO'];
|
||||
final monthName = ['','Enero','Febrero','Marzo','Abril','Mayo','Junio',
|
||||
'Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'][now.month];
|
||||
|
||||
return [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text('$monthName ${now.year}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16,
|
||||
color: AppColors.guindaPrimary)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Cabeceras días
|
||||
Row(children: ['Lu','Ma','Mi','Ju','Vi','Sa','Do'].map((d) =>
|
||||
Expanded(child: Center(child: Text(d, style: const TextStyle(
|
||||
fontWeight: FontWeight.bold, fontSize: 11, color: AppColors.grisTexto))))).toList()),
|
||||
const SizedBox(height: 4),
|
||||
// Grilla de días
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 7, childAspectRatio: 1),
|
||||
itemCount: (first.weekday - 1) + days,
|
||||
itemBuilder: (_, i) {
|
||||
if (i < first.weekday - 1) return const SizedBox();
|
||||
final day = i - (first.weekday - 1) + 1;
|
||||
final date = DateTime(now.year, now.month, day);
|
||||
final diaSem = weekDays[date.weekday - 1];
|
||||
final isCollection = dias.contains(diaSem);
|
||||
final isToday = day == now.day;
|
||||
final isPast = date.isBefore(DateTime(now.year, now.month, now.day));
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
color: isCollection
|
||||
? (isPast ? AppColors.guindaPrimary.withOpacity(0.4) : AppColors.guindaPrimary)
|
||||
: (isToday ? Colors.grey.shade200 : null),
|
||||
shape: BoxShape.circle,
|
||||
border: isToday ? Border.all(color: AppColors.dorado, width: 2) : null,
|
||||
),
|
||||
child: Stack(alignment: Alignment.center, children: [
|
||||
Text('$day', style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isToday ? FontWeight.bold : FontWeight.normal,
|
||||
color: isCollection ? Colors.white : AppColors.negroTexto,
|
||||
)),
|
||||
if (isCollection)
|
||||
Positioned(bottom: 2, child: Container(
|
||||
width: 4, height: 4,
|
||||
decoration: const BoxDecoration(color: AppColors.dorado, shape: BoxShape.circle),
|
||||
)),
|
||||
]),
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = context.read<AuthService>();
|
||||
final dom = auth.primaryDomicilio;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.grisFondo,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
|
||||
title: const Text('Calendario de Recoleccion'),
|
||||
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||
child: Container(height: 4, color: AppColors.dorado)),
|
||||
actions: [
|
||||
IconButton(icon: const Icon(Icons.share), tooltip: 'Compartir horario',
|
||||
onPressed: _shareSchedule),
|
||||
],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
// Info de la ruta
|
||||
if (dom != null)
|
||||
Card(child: Padding(padding: const EdgeInsets.all(14), child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Row(children: [
|
||||
Icon(Icons.local_shipping, color: AppColors.guindaPrimary, size: 18),
|
||||
SizedBox(width: 6),
|
||||
Text('Tu servicio de recoleccion', style: TextStyle(
|
||||
fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)),
|
||||
]),
|
||||
const Divider(),
|
||||
Text('Colonia: ${dom.colonia}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
|
||||
Text('Ruta: ${dom.routeId}',
|
||||
style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)),
|
||||
if (_routeDef != null) ...[
|
||||
Text('Dias: ${_routeDef!.dias.map(_diaLabel).join(" · ")}',
|
||||
style: const TextStyle(fontSize: 12)),
|
||||
Text('Horario: ${_routeDef!.horaInicio} - ${_routeDef!.horaFin}',
|
||||
style: const TextStyle(fontSize: 12)),
|
||||
] else
|
||||
Text(dom.horarioEstimado,
|
||||
style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)),
|
||||
]))),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Leyenda
|
||||
Row(children: [
|
||||
_Legend(color: AppColors.guindaPrimary, label: 'Dia de recoleccion'),
|
||||
const SizedBox(width: 12),
|
||||
_Legend(color: AppColors.dorado, label: 'Punto en dia activo'),
|
||||
const SizedBox(width: 12),
|
||||
_Legend(color: Colors.grey.shade200, label: 'Hoy'),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Calendario
|
||||
Card(child: Padding(padding: const EdgeInsets.all(14), child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: _buildCalendar()))),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Consejos semanales
|
||||
Card(color: Colors.blue.shade50,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10),
|
||||
side: BorderSide(color: Colors.blue.shade200)),
|
||||
child: Padding(padding: const EdgeInsets.all(14), child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Row(children: [
|
||||
Icon(Icons.tips_and_updates, color: AppColors.azulInfo),
|
||||
SizedBox(width: 8),
|
||||
Text('Consejo de la semana', style: TextStyle(
|
||||
fontWeight: FontWeight.bold, color: AppColors.azulInfo, fontSize: 14)),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
Text(_weeklyTip(), style: const TextStyle(fontSize: 13, color: AppColors.negroTexto)),
|
||||
])),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Mis calificaciones
|
||||
if (_myReviews.isNotEmpty) ...[
|
||||
const Text('Mis calificaciones', style: TextStyle(
|
||||
fontWeight: FontWeight.bold, fontSize: 15, color: AppColors.guindaPrimary)),
|
||||
const SizedBox(height: 8),
|
||||
..._myReviews.take(3).map((r) => Card(margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(dense: true,
|
||||
leading: CircleAvatar(backgroundColor: Colors.amber.shade100,
|
||||
child: Text('${r.estrellas}', style: const TextStyle(
|
||||
fontWeight: FontWeight.bold, color: Colors.amber))),
|
||||
title: Text(r.colonia, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)),
|
||||
subtitle: Text(r.comentario, maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 11)),
|
||||
trailing: Text(
|
||||
'${DateTime.tryParse(r.fecha)?.day}/${DateTime.tryParse(r.fecha)?.month}',
|
||||
style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
|
||||
))),
|
||||
],
|
||||
const SizedBox(height: 30),
|
||||
])),
|
||||
);
|
||||
}
|
||||
|
||||
String _weeklyTip() {
|
||||
final tips = [
|
||||
'Separa tus residuos en organicos (restos de comida) e inorganicos (plasticos, metales). Facilita el reciclaje y reduce la contaminacion.',
|
||||
'Coloca tus bolsas en la acera SOLO cuando recibas el aviso de 15 minutos. Sacarlas antes atrae fauna nociva.',
|
||||
'El reciclaje de 1 tonelada de papel salva 17 arboles. Dobla tus cajas y periodicos antes de depositarlos.',
|
||||
'Los aceites usados de cocina NO van a la basura. Llevalos a los puntos de acopio del municipio.',
|
||||
'Composta tus restos organicos si tienes jardin. Reduce hasta un 40% tu basura y mejora tu suelo.',
|
||||
'Las pilas y baterias son residuos peligrosos. Depositalas en los contenedores especiales de tiendas.',
|
||||
'Un celular viejo contiene oro, plata y cobre. Llevalo a un punto RAEE para su reciclaje correcto.',
|
||||
];
|
||||
return tips[DateTime.now().weekday % tips.length];
|
||||
}
|
||||
}
|
||||
|
||||
class _Legend extends StatelessWidget {
|
||||
final Color color; final String label;
|
||||
const _Legend({required this.color, required this.label});
|
||||
@override
|
||||
Widget build(BuildContext context) => Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Container(width: 12, height: 12, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 4),
|
||||
Text(label, style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
|
||||
]);
|
||||
}
|
||||
127
lib/screens/citizen/notification_history_screen.dart
Normal file
127
lib/screens/citizen/notification_history_screen.dart
Normal file
@@ -0,0 +1,127 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../database/db_helper.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
|
||||
class NotificationHistoryScreen extends StatefulWidget {
|
||||
const NotificationHistoryScreen({super.key});
|
||||
@override State<NotificationHistoryScreen> createState() => _NotificationHistoryScreenState();
|
||||
}
|
||||
|
||||
class _NotificationHistoryScreenState extends State<NotificationHistoryScreen> {
|
||||
List<Map<String, dynamic>> _notifs = [];
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() { super.initState(); _load(); }
|
||||
|
||||
Future<void> _load() async {
|
||||
final auth = context.read<AuthService>();
|
||||
if (auth.currentUser == null) return;
|
||||
final n = await DbHelper.getNotifHistory(auth.currentUser!.id!);
|
||||
await DbHelper.markAllNotifsRead(auth.currentUser!.id!);
|
||||
if (mounted) setState(() { _notifs = n; _loading = false; });
|
||||
}
|
||||
|
||||
Color _color(String type) {
|
||||
switch (type) {
|
||||
case 'truckProximity':
|
||||
case 'truckApproaching15min': return AppColors.naranjaAlerta;
|
||||
case 'routeCompleted':
|
||||
case 'reviewPrompt': return AppColors.verdeExito;
|
||||
case 'routeCancelled': return AppColors.rojoError;
|
||||
case 'gpsLost': return Colors.red.shade800;
|
||||
case 'truckStopped': return AppColors.naranjaAlerta;
|
||||
default: return AppColors.azulInfo;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _icon(String type) {
|
||||
switch (type) {
|
||||
case 'truckProximity':
|
||||
case 'truckApproaching15min': return Icons.warning_amber_rounded;
|
||||
case 'routeCompleted': return Icons.check_circle;
|
||||
case 'reviewPrompt': return Icons.star;
|
||||
case 'routeCancelled': return Icons.cancel;
|
||||
case 'gpsLost': return Icons.gps_off;
|
||||
default: return Icons.notifications;
|
||||
}
|
||||
}
|
||||
|
||||
String _timeAgo(String fechaStr) {
|
||||
final f = DateTime.tryParse(fechaStr);
|
||||
if (f == null) return '';
|
||||
final diff = DateTime.now().difference(f);
|
||||
if (diff.inMinutes < 1) return 'Ahora';
|
||||
if (diff.inMinutes < 60) return 'Hace ${diff.inMinutes} min';
|
||||
if (diff.inHours < 24) return 'Hace ${diff.inHours}h';
|
||||
return '${f.day}/${f.month}/${f.year}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
backgroundColor: AppColors.grisFondo,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
|
||||
title: const Text('Historial de Alertas'),
|
||||
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||
child: Container(height: 4, color: AppColors.dorado)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await DbHelper.markAllNotifsRead(
|
||||
context.read<AuthService>().currentUser!.id!);
|
||||
setState(() {});
|
||||
},
|
||||
child: const Text('Marcar leídas', style: TextStyle(color: AppColors.dorado, fontSize: 12)),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _notifs.isEmpty
|
||||
? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Icon(Icons.notifications_none, color: Colors.grey.shade400, size: 64),
|
||||
const SizedBox(height: 12),
|
||||
Text('Sin alertas registradas', style: TextStyle(color: Colors.grey.shade500)),
|
||||
]))
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: _notifs.length,
|
||||
itemBuilder: (_, i) {
|
||||
final n = _notifs[i];
|
||||
final isUnread = (n['leida'] as int?) == 0;
|
||||
final color = _color(n['event_type'] ?? '');
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isUnread ? color.withOpacity(0.05) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: isUnread ? color.withOpacity(0.3) : Colors.grey.shade200),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: color.withOpacity(0.15),
|
||||
child: Icon(_icon(n['event_type'] ?? ''), color: color, size: 20),
|
||||
),
|
||||
title: Text(n['title'] ?? '', style: TextStyle(
|
||||
fontWeight: isUnread ? FontWeight.bold : FontWeight.normal,
|
||||
fontSize: 13)),
|
||||
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(n['body'] ?? '', style: const TextStyle(fontSize: 11), maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 2),
|
||||
Text('${n['route_id']} · ${_timeAgo(n['fecha'] ?? '')}',
|
||||
style: TextStyle(fontSize: 10, color: color.withOpacity(0.7))),
|
||||
]),
|
||||
trailing: isUnread
|
||||
? Container(width: 8, height: 8,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle))
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import '../../database/db_helper.dart';
|
||||
import '../../models/models.dart';
|
||||
import '../../data/routes_data.dart';
|
||||
import '../../widgets/route_map_widget.dart';
|
||||
import '../settings_screen.dart';
|
||||
|
||||
class DriverHomeScreen extends StatefulWidget {
|
||||
const DriverHomeScreen({super.key});
|
||||
@@ -265,8 +266,7 @@ class _DriverMapTab extends StatelessWidget {
|
||||
title:Text(route.name,style:const TextStyle(fontSize:13)),
|
||||
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
|
||||
child:Container(height:4,color:AppColors.dorado))),
|
||||
body:RouteMapWidget(route:route,simulator:sim,
|
||||
height:MediaQuery.of(context).size.height,showFullRoute:true));
|
||||
body:DriverRouteMap(route:route,simulator:sim));
|
||||
}
|
||||
|
||||
// ── Tab reporte incidente — usa routeId actual ────────────────────────────
|
||||
|
||||
121
lib/screens/onboarding/onboarding_screen.dart
Normal file
121
lib/screens/onboarding/onboarding_screen.dart
Normal file
@@ -0,0 +1,121 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
|
||||
class OnboardingScreen extends StatefulWidget {
|
||||
const OnboardingScreen({super.key});
|
||||
@override State<OnboardingScreen> createState() => _OnboardingScreenState();
|
||||
}
|
||||
|
||||
class _OnboardingScreenState extends State<OnboardingScreen> {
|
||||
final _ctrl = PageController();
|
||||
int _page = 0;
|
||||
|
||||
static const _pages = [
|
||||
_OnboardPage(icon:Icons.delete_sweep_rounded, color:AppColors.guindaPrimary,
|
||||
title:'Bienvenido a Celaya Limpia',
|
||||
subtitle:'El sistema de recoleccion inteligente del H. Ayuntamiento de Celaya.',
|
||||
desc:'Recibe alertas en tiempo real, conoce tu horario y ayuda a mantener tu ciudad limpia.'),
|
||||
_OnboardPage(icon:Icons.notifications_active, color:AppColors.azulInfo,
|
||||
title:'Alertas inteligentes',
|
||||
subtitle:'Te avisamos exactamente cuando debes sacar tu basura.',
|
||||
desc:'Recibiras 3 alertas:\n\n30 min antes: el camion esta en camino.\n15 min antes: saca tus bolsas a la acera.\nAl pasar: confirma que fue recogida.\n\nNunca mas pierdas al camion recolector.'),
|
||||
_OnboardPage(icon:Icons.recycling, color:AppColors.verdeExito,
|
||||
title:'Guia de separacion',
|
||||
subtitle:'Aprende a separar correctamente tus residuos.',
|
||||
desc:'Bolsa verde: organicos (comida, jardin)\nBolsa azul: reciclables (PET, latas)\nBolsa negra: no reciclables\n\nUsa la camara IA para identificar si un residuo es organico o inorganico al instante.'),
|
||||
_OnboardPage(icon:Icons.star, color:Colors.amber,
|
||||
title:'Tu opinion importa',
|
||||
subtitle:'Califica el servicio y ayuda a mejorarlo.',
|
||||
desc:'Despues de cada recoleccion podras:\n\nCalificar de 1 a 5 estrellas\nDejar comentarios al Ayuntamiento\nReportar incidencias con foto\n\nTus reportes son atendidos por el equipo municipal.'),
|
||||
];
|
||||
|
||||
Future<void> _finish() async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
await p.setBool('onboarding_done', true);
|
||||
if (!mounted) return;
|
||||
Navigator.pushReplacementNamed(context, '/login');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Stack(children: [
|
||||
PageView.builder(controller:_ctrl, onPageChanged:(p)=>setState(()=>_page=p),
|
||||
itemCount:_pages.length,
|
||||
itemBuilder:(_,i)=>_PageContent(page:_pages[i])),
|
||||
Positioned(bottom:120, left:0, right:0,
|
||||
child:Row(mainAxisAlignment:MainAxisAlignment.center,
|
||||
children:List.generate(_pages.length,(i)=>AnimatedContainer(
|
||||
duration:const Duration(milliseconds:250),
|
||||
margin:const EdgeInsets.symmetric(horizontal:4),
|
||||
width:_page==i?24:8, height:8,
|
||||
decoration:BoxDecoration(
|
||||
color:_page==i?_pages[i].color:Colors.grey.shade300,
|
||||
borderRadius:BorderRadius.circular(4)))))),
|
||||
Positioned(bottom:40, left:24, right:24,
|
||||
child:Row(children:[
|
||||
if (_page>0)
|
||||
TextButton(onPressed:()=>_ctrl.previousPage(
|
||||
duration:const Duration(milliseconds:300),curve:Curves.easeOut),
|
||||
child:const Text('Atras',style:TextStyle(color:AppColors.grisTexto)))
|
||||
else
|
||||
TextButton(onPressed:_finish,
|
||||
child:const Text('Omitir',style:TextStyle(color:AppColors.grisTexto))),
|
||||
const Spacer(),
|
||||
ElevatedButton(
|
||||
onPressed:_page<_pages.length-1
|
||||
?()=>_ctrl.nextPage(duration:const Duration(milliseconds:300),curve:Curves.easeOut)
|
||||
:_finish,
|
||||
style:ElevatedButton.styleFrom(
|
||||
backgroundColor:_pages[_page].color, foregroundColor:Colors.white,
|
||||
padding:const EdgeInsets.symmetric(horizontal:28,vertical:12),
|
||||
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(25))),
|
||||
child:Text(_page<_pages.length-1?'Siguiente':'Comenzar',
|
||||
style:const TextStyle(fontWeight:FontWeight.bold))),
|
||||
])),
|
||||
]));
|
||||
}
|
||||
@override void dispose(){ _ctrl.dispose(); super.dispose(); }
|
||||
}
|
||||
|
||||
class _OnboardPage {
|
||||
final IconData icon; final Color color;
|
||||
final String title, subtitle, desc;
|
||||
const _OnboardPage({required this.icon,required this.color,
|
||||
required this.title,required this.subtitle,required this.desc});
|
||||
}
|
||||
|
||||
class _PageContent extends StatelessWidget {
|
||||
final _OnboardPage page;
|
||||
const _PageContent({super.key, required this.page});
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
decoration:BoxDecoration(gradient:LinearGradient(
|
||||
begin:Alignment.topCenter, end:Alignment.bottomCenter,
|
||||
colors:[page.color, page.color.withOpacity(0.85), Colors.white],
|
||||
stops:const[0,0.4,0.7])),
|
||||
child:SafeArea(child:Column(children:[
|
||||
const SizedBox(height:48),
|
||||
Container(width:120,height:120,
|
||||
decoration:BoxDecoration(color:Colors.white.withOpacity(0.2),shape:BoxShape.circle,
|
||||
border:Border.all(color:Colors.white.withOpacity(0.5),width:2)),
|
||||
child:Icon(page.icon,size:60,color:Colors.white)),
|
||||
const SizedBox(height:28),
|
||||
Padding(padding:const EdgeInsets.symmetric(horizontal:32),child:Column(children:[
|
||||
Text(page.title,textAlign:TextAlign.center,
|
||||
style:const TextStyle(fontSize:24,fontWeight:FontWeight.bold,color:Colors.white)),
|
||||
const SizedBox(height:10),
|
||||
Text(page.subtitle,textAlign:TextAlign.center,
|
||||
style:TextStyle(fontSize:14,color:Colors.white.withOpacity(0.9))),
|
||||
])),
|
||||
const SizedBox(height:32),
|
||||
Expanded(child:Container(margin:const EdgeInsets.symmetric(horizontal:20),
|
||||
padding:const EdgeInsets.all(22),
|
||||
decoration:BoxDecoration(color:Colors.white,borderRadius:BorderRadius.circular(20),
|
||||
boxShadow:[BoxShadow(color:page.color.withOpacity(0.2),blurRadius:20,offset:const Offset(0,8))]),
|
||||
child:Text(page.desc,textAlign:TextAlign.left,
|
||||
style:const TextStyle(fontSize:13,height:1.7,color:AppColors.negroTexto)))),
|
||||
const SizedBox(height:110),
|
||||
])));
|
||||
}
|
||||
53
lib/screens/settings_screen.dart
Normal file
53
lib/screens/settings_screen.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../core/app_colors.dart';
|
||||
import '../services/theme_service.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = context.watch<ThemeService>();
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Configuracion'),
|
||||
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
|
||||
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||
child: Container(height: 4, color: AppColors.dorado))),
|
||||
body: ListView(padding: const EdgeInsets.all(16), children: [
|
||||
Card(child: Column(children: [
|
||||
const Padding(padding: EdgeInsets.all(14),
|
||||
child: Row(children: [
|
||||
Icon(Icons.palette_outlined, color: AppColors.guindaPrimary),
|
||||
SizedBox(width: 8),
|
||||
Text('Apariencia', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15)),
|
||||
])),
|
||||
const Divider(height: 1),
|
||||
SwitchListTile(
|
||||
value: theme.isDark,
|
||||
onChanged: (_) => theme.toggle(),
|
||||
secondary: Icon(theme.isDark ? Icons.dark_mode : Icons.light_mode,
|
||||
color: theme.isDark ? Colors.amber : AppColors.guindaPrimary),
|
||||
title: Text(theme.isDark ? 'Modo oscuro activo' : 'Modo claro activo'),
|
||||
subtitle: const Text('Util para rutas nocturnas'),
|
||||
activeColor: AppColors.guindaPrimary,
|
||||
),
|
||||
])),
|
||||
const SizedBox(height: 12),
|
||||
Card(child: Column(children: [
|
||||
const Padding(padding: EdgeInsets.all(14),
|
||||
child: Row(children: [
|
||||
Icon(Icons.info_outline, color: AppColors.guindaPrimary),
|
||||
SizedBox(width: 8),
|
||||
Text('Acerca de', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15)),
|
||||
])),
|
||||
const Divider(height: 1),
|
||||
const ListTile(leading: Icon(Icons.location_city), title: Text('H. Ayuntamiento de Celaya'),
|
||||
subtitle: Text('Guanajuato, Mexico')),
|
||||
const ListTile(leading: Icon(Icons.code), title: Text('Version 2.0.0'),
|
||||
subtitle: Text('Sistema Integral de Recoleccion de Residuos')),
|
||||
])),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,11 @@ class RouteSimulatorService extends ChangeNotifier {
|
||||
final n = AppNotification(event:event, title:title, body:body, routeId:routeId);
|
||||
_lastNotification = n;
|
||||
_history.insert(0, n);
|
||||
// Persistir en DB para historial
|
||||
DbHelper.insertNotifHistory(
|
||||
routeId: routeId, eventType: event.name,
|
||||
title: title, body: body,
|
||||
).catchError((_) {});
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
24
lib/services/theme_service.dart
Normal file
24
lib/services/theme_service.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class ThemeService extends ChangeNotifier {
|
||||
ThemeMode _themeMode = ThemeMode.light;
|
||||
ThemeMode get themeMode => _themeMode;
|
||||
bool get isDark => _themeMode == ThemeMode.dark;
|
||||
|
||||
ThemeService() { _load(); }
|
||||
|
||||
Future<void> _load() async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
final isDark = p.getBool('dark_mode') ?? false;
|
||||
_themeMode = isDark ? ThemeMode.dark : ThemeMode.light;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> toggle() async {
|
||||
_themeMode = _themeMode == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark;
|
||||
final p = await SharedPreferences.getInstance();
|
||||
await p.setBool('dark_mode', _themeMode == ThemeMode.dark);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
@@ -16,17 +17,28 @@ List<LatLng> _smooth(List<LatLng> pts) {
|
||||
res.add(p1);
|
||||
for (int j = 1; j <= 8; j++) {
|
||||
final t = j / 9.0;
|
||||
res.add(LatLng(_cr(p0.latitude, p1.latitude, p2.latitude, p3.latitude, t),
|
||||
_cr(p0.longitude, p1.longitude, p2.longitude, p3.longitude, t)));
|
||||
res.add(LatLng(_cr(p0.latitude,p1.latitude,p2.latitude,p3.latitude,t),
|
||||
_cr(p0.longitude,p1.longitude,p2.longitude,p3.longitude,t)));
|
||||
}
|
||||
}
|
||||
res.add(pts.last);
|
||||
return res;
|
||||
}
|
||||
|
||||
double _cr(double a, double b, double c, double d, double t) => 0.5 * (
|
||||
(2*b) + (-a+c)*t + (2*a-5*b+4*c-d)*t*t + (-a+3*b-3*c+d)*t*t*t);
|
||||
double _cr(double a,double b,double c,double d,double t) => 0.5*(
|
||||
(2*b)+(-a+c)*t+(2*a-5*b+4*c-d)*t*t+(-a+3*b-3*c+d)*t*t*t);
|
||||
|
||||
// Calcular bearing entre dos puntos
|
||||
double _bearing(LatLng from, LatLng to) {
|
||||
final lat1 = from.latitude * math.pi / 180;
|
||||
final lat2 = to.latitude * math.pi / 180;
|
||||
final dLng = (to.longitude - from.longitude) * math.pi / 180;
|
||||
final y = math.sin(dLng) * math.cos(lat2);
|
||||
final x = math.cos(lat1)*math.sin(lat2) - math.sin(lat1)*math.cos(lat2)*math.cos(dLng);
|
||||
return (math.atan2(y, x) * 180 / math.pi + 360) % 360;
|
||||
}
|
||||
|
||||
// ── Mapa ciudadano ────────────────────────────────────────────────────────
|
||||
class RouteMapWidget extends StatelessWidget {
|
||||
final RouteModel route;
|
||||
final RouteSimulatorService simulator;
|
||||
@@ -43,44 +55,65 @@ class RouteMapWidget extends StatelessWidget {
|
||||
final gps = simulator.isGpsActive(route.routeId);
|
||||
final all = _smooth(route.polylinePoints);
|
||||
final done = posIdx > 0
|
||||
? _smooth(route.positions.take(posIdx+1).map((p) => p.latLng).toList())
|
||||
? _smooth(route.positions.take(posIdx+1).map((p)=>p.latLng).toList())
|
||||
: <LatLng>[];
|
||||
|
||||
return ClipRRect(borderRadius: BorderRadius.circular(12),
|
||||
// Calcular bearing hacia siguiente punto
|
||||
double bear = 0;
|
||||
if (posIdx < route.positions.length - 1) {
|
||||
bear = _bearing(cur, route.positions[posIdx+1].latLng);
|
||||
}
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SizedBox(height: height,
|
||||
child: FlutterMap(
|
||||
options: MapOptions(initialCenter: cur, initialZoom: 14),
|
||||
options: MapOptions(
|
||||
initialCenter: cur, initialZoom: 14.5,
|
||||
initialRotation: -bear, // Rotar mapa según dirección del camión
|
||||
),
|
||||
children: [
|
||||
TileLayer(urlTemplate:'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName:'com.example.celaya_limpia'),
|
||||
// Ruta completa (gris punteada)
|
||||
PolylineLayer(polylines:[
|
||||
Polyline(points:all, color:Colors.grey.withOpacity(0.4), strokeWidth:4,
|
||||
Polyline(points:all, color:Colors.grey.shade400, strokeWidth:4,
|
||||
borderColor:Colors.white54, borderStrokeWidth:1),
|
||||
]),
|
||||
// Tramo recorrido (guinda)
|
||||
if (done.isNotEmpty) PolylineLayer(polylines:[
|
||||
Polyline(points:done, color:AppColors.guindaPrimary, strokeWidth:5,
|
||||
borderColor:AppColors.guindaDark, borderStrokeWidth:1),
|
||||
]),
|
||||
MarkerLayer(markers:[
|
||||
Marker(point:cur, width:52, height:52,
|
||||
child:Container(decoration:BoxDecoration(
|
||||
color:gps?AppColors.guindaPrimary:Colors.grey, shape:BoxShape.circle,
|
||||
border:Border.all(color:Colors.white,width:2.5),
|
||||
boxShadow:[BoxShadow(color:Colors.black38,blurRadius:6)]),
|
||||
child:Icon(gps?Icons.local_shipping:Icons.gps_off,color:Colors.white,size:24))),
|
||||
Marker(point:route.positions.first.latLng, width:28, height:28,
|
||||
child:Container(decoration:BoxDecoration(color:AppColors.verdeExito,shape:BoxShape.circle,
|
||||
border:Border.all(color:Colors.white,width:2)),
|
||||
child:const Icon(Icons.circle,color:Colors.white,size:10))),
|
||||
Marker(point:route.positions.last.latLng, width:32, height:32,
|
||||
child:Container(decoration:BoxDecoration(color:AppColors.rojoError,shape:BoxShape.circle,
|
||||
border:Border.all(color:Colors.white,width:2)),
|
||||
child:const Icon(Icons.flag,color:Colors.white,size:16))),
|
||||
// Camión con orientación
|
||||
Marker(point:cur, width:56, height:56,
|
||||
child:Transform.rotate(
|
||||
angle: bear * math.pi / 180,
|
||||
child:Container(
|
||||
decoration:BoxDecoration(
|
||||
color:gps?AppColors.guindaPrimary:Colors.grey,
|
||||
shape:BoxShape.circle,
|
||||
border:Border.all(color:Colors.white,width:2.5),
|
||||
boxShadow:[BoxShadow(color:Colors.black38,blurRadius:6)]),
|
||||
child:Icon(gps?Icons.navigation:Icons.gps_off,
|
||||
color:Colors.white,size:26)))),
|
||||
// Origen
|
||||
Marker(point:route.positions.first.latLng, width:24, height:24,
|
||||
child:Container(decoration:BoxDecoration(color:AppColors.verdeExito,
|
||||
shape:BoxShape.circle,border:Border.all(color:Colors.white,width:2)),
|
||||
child:const Icon(Icons.circle,color:Colors.white,size:8))),
|
||||
// Destino
|
||||
Marker(point:route.positions.last.latLng, width:28, height:28,
|
||||
child:Container(decoration:BoxDecoration(color:AppColors.rojoError,
|
||||
shape:BoxShape.circle,border:Border.all(color:Colors.white,width:2)),
|
||||
child:const Icon(Icons.flag,color:Colors.white,size:14))),
|
||||
]),
|
||||
])));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mapa Admin (todas las rutas) ──────────────────────────────────────────
|
||||
class AdminMapWidget extends StatefulWidget {
|
||||
final List<RouteModel> routes;
|
||||
final RouteSimulatorService simulator;
|
||||
@@ -91,9 +124,9 @@ class AdminMapWidget extends StatefulWidget {
|
||||
class _AdminMapWidgetState extends State<AdminMapWidget> {
|
||||
String? _sel;
|
||||
static const _colors = [
|
||||
Colors.blue, Colors.green, Colors.orange, Colors.purple, Colors.teal,
|
||||
Colors.red, Colors.indigo, Colors.brown, Colors.cyan, Colors.pink,
|
||||
Colors.amber, Colors.lime, Colors.deepOrange, Colors.lightBlue, Colors.deepPurple,
|
||||
Colors.blue,Colors.green,Colors.orange,Colors.purple,Colors.teal,
|
||||
Colors.red,Colors.indigo,Colors.brown,Colors.cyan,Colors.pink,
|
||||
Colors.amber,Colors.lime,Colors.deepOrange,Colors.lightBlue,Colors.deepPurple,
|
||||
];
|
||||
|
||||
@override
|
||||
@@ -105,7 +138,7 @@ class _AdminMapWidgetState extends State<AdminMapWidget> {
|
||||
TileLayer(urlTemplate:'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName:'com.example.celaya_limpia'),
|
||||
PolylineLayer(polylines: widget.routes.asMap().entries.map((e){
|
||||
final c = _colors[e.key % _colors.length];
|
||||
final c = _colors[e.key%_colors.length];
|
||||
final isS = _sel==null||_sel==e.value.routeId;
|
||||
return Polyline(points:_smooth(e.value.polylinePoints),
|
||||
color:c.withOpacity(isS?0.85:0.2), strokeWidth:isS?4:1.5,
|
||||
@@ -115,32 +148,86 @@ class _AdminMapWidgetState extends State<AdminMapWidget> {
|
||||
final r = e.value;
|
||||
final idx = widget.simulator.getPositionIndex(r.routeId);
|
||||
final pos = idx < r.positions.length ? r.positions[idx].latLng : r.positions.last.latLng;
|
||||
final c = _colors[e.key % _colors.length];
|
||||
final c = _colors[e.key%_colors.length];
|
||||
final gps = widget.simulator.isGpsActive(r.routeId);
|
||||
return Marker(point:pos, width:46, height:46,
|
||||
double bear = 0;
|
||||
if (idx < r.positions.length - 1) bear = _bearing(pos, r.positions[idx+1].latLng);
|
||||
return Marker(point:pos, width:44, height:44,
|
||||
child:GestureDetector(
|
||||
onTap:()=>setState(()=>_sel=_sel==r.routeId?null:r.routeId),
|
||||
child:Container(decoration:BoxDecoration(color:gps?c:Colors.grey,
|
||||
shape:BoxShape.circle, border:Border.all(color:Colors.white,width:2),
|
||||
boxShadow:[BoxShadow(color:Colors.black26,blurRadius:4)]),
|
||||
child:Center(child:Text(r.truckId.toString().substring(1),
|
||||
style:const TextStyle(color:Colors.white,fontSize:11,fontWeight:FontWeight.bold))))));
|
||||
child:Transform.rotate(angle: bear*math.pi/180,
|
||||
child:Container(decoration:BoxDecoration(
|
||||
color:gps?c:Colors.grey, shape:BoxShape.circle,
|
||||
border:Border.all(color:Colors.white,width:2),
|
||||
boxShadow:[BoxShadow(color:Colors.black26,blurRadius:4)]),
|
||||
child:Center(child:Text(r.truckId.toString().substring(1),
|
||||
style:const TextStyle(color:Colors.white,fontSize:11,fontWeight:FontWeight.bold)))))));
|
||||
}).toList()),
|
||||
],
|
||||
)),
|
||||
if (_sel!=null) ...[
|
||||
const Divider(height:1),
|
||||
Container(padding:const EdgeInsets.symmetric(horizontal:16,vertical:10),
|
||||
color:AppColors.guindaPrimary,
|
||||
child:Row(children:[
|
||||
const Icon(Icons.local_shipping,color:Colors.white,size:16),
|
||||
const SizedBox(width:8),
|
||||
Expanded(child:Text(widget.routes.firstWhere((r)=>r.routeId==_sel).name,
|
||||
style:const TextStyle(color:Colors.white,fontWeight:FontWeight.bold,fontSize:13))),
|
||||
Text('Pos ${widget.simulator.getPositionIndex(_sel!)}/7',
|
||||
style:const TextStyle(color:AppColors.dorado,fontSize:12)),
|
||||
])),
|
||||
],
|
||||
])),
|
||||
if (_sel!=null) Container(
|
||||
padding:const EdgeInsets.symmetric(horizontal:16,vertical:10),
|
||||
color:AppColors.guindaPrimary,
|
||||
child:Row(children:[
|
||||
const Icon(Icons.local_shipping,color:Colors.white,size:16),
|
||||
const SizedBox(width:8),
|
||||
Expanded(child:Text(widget.routes.firstWhere((r)=>r.routeId==_sel).name,
|
||||
style:const TextStyle(color:Colors.white,fontWeight:FontWeight.bold,fontSize:13))),
|
||||
Text('Pos ${widget.simulator.getPositionIndex(_sel!)}/7',
|
||||
style:const TextStyle(color:AppColors.dorado,fontSize:12)),
|
||||
])),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mapa conductor (con bearing) ──────────────────────────────────────────
|
||||
class DriverRouteMap extends StatelessWidget {
|
||||
final RouteModel route;
|
||||
final RouteSimulatorService simulator;
|
||||
const DriverRouteMap({super.key, required this.route, required this.simulator});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final idx = simulator.getPositionIndex(route.routeId);
|
||||
final gps = simulator.isGpsActive(route.routeId);
|
||||
final positions = route.positions;
|
||||
final cur = idx < positions.length ? positions[idx].latLng : positions.last.latLng;
|
||||
double bear = 0;
|
||||
if (idx < positions.length - 1) bear = _bearing(cur, positions[idx+1].latLng);
|
||||
|
||||
final done = idx > 0
|
||||
? _smooth(positions.take(idx+1).map((p)=>p.latLng).toList())
|
||||
: <LatLng>[];
|
||||
final pending = _smooth(positions.skip(idx).map((p)=>p.latLng).toList());
|
||||
|
||||
return FlutterMap(
|
||||
options: MapOptions(initialCenter:cur, initialZoom:15, initialRotation:-bear),
|
||||
children:[
|
||||
TileLayer(urlTemplate:'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName:'com.example.celaya_limpia'),
|
||||
PolylineLayer(polylines:[
|
||||
Polyline(points:pending, color:Colors.grey.shade400, strokeWidth:5,
|
||||
borderColor:Colors.white54, borderStrokeWidth:1),
|
||||
if (done.isNotEmpty)
|
||||
Polyline(points:done, color:AppColors.moradoConductor, strokeWidth:6,
|
||||
borderColor:AppColors.moradoConductor.withOpacity(0.4), borderStrokeWidth:2),
|
||||
]),
|
||||
MarkerLayer(markers:[
|
||||
// Waypoints pendientes
|
||||
...positions.skip(idx+1).take(4).toList().asMap().entries.map((e)=>
|
||||
Marker(point:e.value.latLng, width:22, height:22,
|
||||
child:Container(decoration:BoxDecoration(color:Colors.amber,shape:BoxShape.circle,
|
||||
border:Border.all(color:Colors.white,width:1.5)),
|
||||
child:Center(child:Text('${idx+2+e.key}',
|
||||
style:const TextStyle(color:Colors.white,fontSize:9,fontWeight:FontWeight.bold)))))),
|
||||
// Camión orientado
|
||||
Marker(point:cur, width:56, height:56,
|
||||
child:Transform.rotate(angle:bear*math.pi/180,
|
||||
child:Container(decoration:BoxDecoration(
|
||||
color:gps?AppColors.moradoConductor:Colors.grey, shape:BoxShape.circle,
|
||||
border:Border.all(color:Colors.white,width:2.5),
|
||||
boxShadow:[BoxShadow(color:Colors.black38,blurRadius:8)]),
|
||||
child:Icon(gps?Icons.navigation:Icons.gps_off,color:Colors.white,size:28)))),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user