449 lines
19 KiB
Dart
449 lines
19 KiB
Dart
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),
|
|
]),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|