Primera app funcional

This commit is contained in:
2026-05-22 18:27:43 -06:00
parent 43661dc2b0
commit 37e83a8226
30 changed files with 4053 additions and 291 deletions

View File

@@ -0,0 +1,175 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:camera/camera.dart';
import 'package:tflite_flutter/tflite_flutter.dart';
import 'package:image/image.dart' as img;
import '../../core/app_colors.dart';
List<CameraDescription> _cameras = [];
class AiCameraScreen extends StatefulWidget {
const AiCameraScreen({super.key});
@override State<AiCameraScreen> createState() => _AiCameraScreenState();
}
class _AiCameraScreenState extends State<AiCameraScreen> {
CameraController? _cam;
Interpreter? _interpreter;
bool _processing = false;
String _result = 'Apunta a un residuo y escanea';
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 _labelColors = [AppColors.verdeExito, AppColors.naranjaAlerta];
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
try {
_cameras = await availableCameras();
} catch (_) {}
await _initCamera();
await _loadModel();
}
Future<void> _initCamera() async {
if (_cameras.isEmpty) return;
_cam = CameraController(_cameras[0], ResolutionPreset.medium, enableAudio: false);
try {
await _cam!.initialize();
if (mounted) setState(() {});
} catch (_) {}
}
Future<void> _loadModel() async {
try {
_interpreter = await Interpreter.fromAsset('assets/models/waste_model.tflite');
setState(() => _modelLoaded = true);
} catch (e) {
setState(() => _result = '⚠️ Modelo no encontrado.\nAgrega waste_model.tflite a assets/models/');
}
}
Future<void> _classify() async {
if (_cam == null || !_cam!.value.isInitialized || _processing || !_modelLoaded) return;
setState(() { _processing = true; _result = 'Analizando...'; _confidence = ''; });
try {
final pic = await _cam!.takePicture();
final raw = await File(pic.path).readAsBytes();
img.Image? decoded = img.decodeImage(raw);
if (decoded == null) throw Exception('No se pudo decodificar');
final resized = img.copyResize(decoded, width: 150, height: 150);
var input = List.generate(1, (_) =>
List.generate(150, (_) => List.generate(150, (_) => List.generate(3, (_) => 0.0))));
for (int y = 0; y < 150; y++) {
for (int x = 0; x < 150; x++) {
final px = resized.getPixel(x, y);
input[0][y][x][0] = px.r / 255.0;
input[0][y][x][1] = px.g / 255.0;
input[0][y][x][2] = px.b / 255.0;
}
}
var output = List.filled(2, 0.0).reshape([1, 2]);
_interpreter!.run(input, output);
final pred = List<double>.from(output[0]);
final maxIdx = pred[0] > pred[1] ? 0 : 1;
final conf = pred[maxIdx] * 100;
await File(pic.path).delete();
setState(() {
_result = _labels[maxIdx];
_confidence = 'Confianza: ${conf.toStringAsFixed(1)}%';
});
} catch (e) {
setState(() => _result = 'Error en análisis');
} finally {
setState(() => _processing = false);
}
}
@override
void dispose() {
_cam?.dispose();
_interpreter?.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final resultColor = _result.contains('Orgánico') ? AppColors.verdeExito
: _result.contains('Inorgánico') ? AppColors.naranjaAlerta
: AppColors.guindaPrimary;
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: const Text('Clasificador IA de Residuos'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
),
body: Column(children: [
// Visor cámara
Expanded(flex: 4,
child: Container(margin: const EdgeInsets.all(14),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.guindaPrimary, width: 3)),
child: _cam != null && _cam!.value.isInitialized
? CameraPreview(_cam!)
: const Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.camera_alt, color: Colors.white54, size: 48),
SizedBox(height: 8),
Text('Iniciando cámara...', style: TextStyle(color: Colors.white54)),
])),
),
),
// Panel resultado
Expanded(flex: 2,
child: Container(width: double.infinity,
decoration: BoxDecoration(color: AppColors.guindaPrimary.withOpacity(0.06),
borderRadius: const BorderRadius.vertical(top: Radius.circular(28))),
padding: const EdgeInsets.all(20),
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Text(_result, textAlign: TextAlign.center,
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: resultColor)),
if (_confidence.isNotEmpty) ...[
const SizedBox(height: 6),
Text(_confidence, style: const TextStyle(fontSize: 16, color: Colors.black54, fontWeight: FontWeight.w500)),
],
const SizedBox(height: 16),
if (!_modelLoaded)
Container(padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: Colors.orange.shade50, borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.shade300)),
child: const Text(' Para usar la IA, coloca waste_model.tflite en assets/models/',
textAlign: TextAlign.center, style: TextStyle(fontSize: 11))),
if (_modelLoaded)
SizedBox(width: double.infinity, height: 50,
child: ElevatedButton.icon(
onPressed: _processing ? null : _classify,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))),
icon: _processing
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Icon(Icons.center_focus_strong),
label: Text(_processing ? 'Procesando...' : 'Escanear Residuo',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
)),
]),
),
),
]),
);
}
}

View File

@@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
import '../../core/app_colors.dart';
import 'ai_camera_screen.dart';
class CitizenGuiaScreen extends StatelessWidget {
const CitizenGuiaScreen({super.key});
static const _cats = [
_Cat(Icons.grass,Color(0xFF2E7D32),'Orgánicos','Restos de comida, jardín','🟢 Bolsa Verde',[
'Frutas y verduras','Cáscaras de huevo','Posos de café y té',
'Restos de comida preparada','Pasto y hojas','Cáscaras de semillas'],
['Aceites en exceso','Carnes en grandes cantidades']),
_Cat(Icons.recycling,Color(0xFF1565C0),'Reciclables','Papel, plástico, vidrio, metal','🔵 Bolsa Azul',[
'Botellas PET','Latas de aluminio','Cartón y papel limpio',
'Vidrio (botellas, frascos)','Periódico y revistas'],
['Vidrio roto sin envolver','Papel sucio o mojado','Unicel']),
_Cat(Icons.delete,Color(0xFF757575),'No Reciclables','Residuos que no se reusan','⚫ Bolsa Negra',[
'Pañales desechables','Toallas sanitarias','Papel higiénico usado',
'Colillas de cigarro','Cerámica rota'],['Baterías','Medicamentos','Aceite usado']),
_Cat(Icons.warning_amber,Color(0xFFC62828),'Peligrosos','Requieren manejo especial','🔴 Separado',[
'Agujas y jeringas','Medicamentos vencidos','Pilas y baterías',
'Aceite de cocina usado','Pintura y solventes'],[],isWarn:true),
_Cat(Icons.devices_other,Color(0xFFE65100),'Electrónicos (RAEE)','Aparatos electrónicos','🟠 Punto de acopio',[
'Celulares viejos','Computadoras','Televisiones',
'Focos ahorradores','Cables y cargadores'],[],isSpecial:true),
];
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(automaticallyImplyLeading:false,
backgroundColor:AppColors.guindaPrimary, foregroundColor:Colors.white,
title:const Text('Guía de Separación'),
actions:[IconButton(icon:const Icon(Icons.camera_alt),
tooltip:'Clasificar con IA',
onPressed:()=>Navigator.push(context,MaterialPageRoute(builder:(_)=>const AiCameraScreen())))],
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
child:Container(height:4,color:AppColors.dorado))),
body:Column(children:[
Container(width:double.infinity,
color:AppColors.verdeExito.withOpacity(0.1),
padding:const EdgeInsets.symmetric(horizontal:16,vertical:8),
child:Row(children:[
const Icon(Icons.offline_bolt,color:AppColors.verdeExito,size:16),
const SizedBox(width:6),
const Text('Disponible sin conexión a internet',
style:TextStyle(color:AppColors.verdeExito,fontSize:12,fontWeight:FontWeight.w500)),
const Spacer(),
TextButton.icon(icon:const Icon(Icons.camera_alt,size:14),
label:const Text('Clasificar IA',style:TextStyle(fontSize:12)),
style:TextButton.styleFrom(foregroundColor:AppColors.guindaPrimary),
onPressed:()=>Navigator.push(context,MaterialPageRoute(builder:(_)=>const AiCameraScreen()))),
])),
// Importancia de separar
Container(margin:const EdgeInsets.fromLTRB(12,8,12,0),
padding:const EdgeInsets.all(12),
decoration:BoxDecoration(color:Colors.green.shade50,borderRadius:BorderRadius.circular(8),
border:Border.all(color:Colors.green.shade200)),
child:const Column(crossAxisAlignment:CrossAxisAlignment.start, children:[
Text('¿Por qué separar tu basura?',style:TextStyle(fontWeight:FontWeight.bold,color:Color(0xFF2E7D32))),
SizedBox(height:6),
Text('♻️ El 60% de los residuos en México pueden reciclarse o compostarse, pero solo el 5% lo hace.\n'
'🌱 Separar correctamente reduce la contaminación del suelo y agua, genera empleos verdes '
'y disminuye los gases de efecto invernadero producidos en rellenos sanitarios.',
style:TextStyle(fontSize:12,color:Colors.black87)),
])),
Expanded(child:ListView.builder(
padding:const EdgeInsets.all(12),
itemCount:_cats.length,
itemBuilder:(ctx,i)=>_CatCard(cat:_cats[i]))),
]),
);
}
class _Cat {
final IconData icon; final Color color; final String title, subtitle, bolsa;
final List<String> items, noItems;
final bool isWarn, isSpecial;
const _Cat(this.icon,this.color,this.title,this.subtitle,this.bolsa,
this.items,this.noItems,{this.isWarn=false,this.isSpecial=false});
}
class _CatCard extends StatefulWidget {
final _Cat cat;
const _CatCard({super.key, required this.cat});
@override State<_CatCard> createState() => _CatCardState();
}
class _CatCardState extends State<_CatCard> {
bool _open = false;
@override
Widget build(BuildContext context) {
final c = widget.cat;
return Card(margin:const EdgeInsets.only(bottom:10),elevation:2,
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(10),
side:BorderSide(color:c.color.withOpacity(0.3))),
child:InkWell(borderRadius:BorderRadius.circular(10),
onTap:()=>setState(()=>_open=!_open),
child:Column(children:[
Container(decoration:BoxDecoration(color:c.color.withOpacity(0.1),
borderRadius:BorderRadius.vertical(top:const Radius.circular(10),
bottom:_open?Radius.zero:const Radius.circular(10))),
padding:const EdgeInsets.all(14),
child:Row(children:[
Container(width:40,height:40,decoration:BoxDecoration(color:c.color,borderRadius:BorderRadius.circular(8)),
child:Icon(c.icon,color:Colors.white,size:22)),
const SizedBox(width:10),
Expanded(child:Column(crossAxisAlignment:CrossAxisAlignment.start, children:[
Text(c.title,style:TextStyle(fontWeight:FontWeight.bold,fontSize:15,color:c.color)),
Text(c.subtitle,style:const TextStyle(color:AppColors.grisTexto,fontSize:11)),
Text(c.bolsa,style:TextStyle(fontSize:11,fontWeight:FontWeight.w600,color:c.color)),
])),
Icon(_open?Icons.expand_less:Icons.expand_more,color:c.color),
])),
if (_open) Padding(padding:const EdgeInsets.fromLTRB(14,0,14,14),
child:Column(crossAxisAlignment:CrossAxisAlignment.start, children:[
const SizedBox(height:8),
Text('✅ Qué va aquí:',style:TextStyle(fontWeight:FontWeight.bold,color:c.color,fontSize:12)),
const SizedBox(height:4),
...c.items.map((e)=>Padding(padding:const EdgeInsets.symmetric(vertical:2),
child:Row(children:[Icon(Icons.check_circle_outline,size:13,color:c.color),
const SizedBox(width:6),Text(e,style:const TextStyle(fontSize:12))]))),
if (c.noItems.isNotEmpty) ...[
const SizedBox(height:8),
const Text('❌ NO incluir:',style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.rojoError,fontSize:12)),
...c.noItems.map((e)=>Padding(padding:const EdgeInsets.symmetric(vertical:2),
child:Row(children:[const Icon(Icons.cancel_outlined,size:13,color:AppColors.rojoError),
const SizedBox(width:6),Text(e,style:const TextStyle(fontSize:12,color:AppColors.rojoError))]))),
],
if (c.isSpecial) ...[
const SizedBox(height:8),
Container(padding:const EdgeInsets.all(8),
decoration:BoxDecoration(color:Colors.orange.shade50,borderRadius:BorderRadius.circular(6),
border:Border.all(color:Colors.orange.shade200)),
child:const Text('📍 Lleva a puntos de acopio autorizados por el municipio.',
style:TextStyle(fontSize:11))),
],
if (c.isWarn) ...[
const SizedBox(height:8),
Container(padding:const EdgeInsets.all(8),
decoration:BoxDecoration(color:Colors.red.shade50,borderRadius:BorderRadius.circular(6),
border:Border.all(color:Colors.red.shade200)),
child:const Text('⚠️ NUNCA mezcles residuos peligrosos con basura común.',
style:TextStyle(fontSize:11))),
],
])),
])));
}
}

View File

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

View File

@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/app_colors.dart';
import '../../database/db_helper.dart';
import '../../models/models.dart';
import '../../services/auth_service.dart';
class CitizenReporteScreen extends StatefulWidget {
const CitizenReporteScreen({super.key});
@override State<CitizenReporteScreen> createState() => _CitizenReporteScreenState();
}
class _CitizenReporteScreenState extends State<CitizenReporteScreen> {
String _tipo = 'CAMION_NO_PASO';
final _desc = TextEditingController();
int _calif = 5;
bool _loading = false, _sent = false;
List<ReporteModel> _reportes = [];
static const _tipos = {
'CAMION_NO_PASO':'🚛 El camión no pasó',
'RETRASO':'⏱️ Retraso significativo',
'RESIDUOS_NO_RECOGIDOS':'🗑️ Residuos no recogidos',
'OTRO':'📝 Otro motivo',
};
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final auth = context.read<AuthService>();
if (auth.currentUser == null) return;
final r = await DbHelper.getReportesByUser(auth.currentUser!.id!);
if (mounted) setState(() => _reportes = r);
}
Future<void> _send() async {
final auth = context.read<AuthService>();
if (auth.currentUser == null || _desc.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Describe el problema'), backgroundColor: AppColors.rojoError)); return;
}
setState(() => _loading = true);
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,
));
await _load();
if (!mounted) return;
setState(() { _loading = false; _sent = true; _desc.clear(); });
await Future.delayed(const Duration(seconds: 2));
if (mounted) setState(() => _sent = false);
}
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(automaticallyImplyLeading: false,
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: const Text('Reportar Incidencia'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado))),
body: _sent ? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
const Icon(Icons.check_circle, color: AppColors.verdeExito, size: 64),
const SizedBox(height: 12),
const Text('¡Reporte enviado!', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: AppColors.verdeExito)),
const Text('El Ayuntamiento lo 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)))))),
],
])),
);
@override void dispose() { _desc.dispose(); super.dispose(); }
}