Avance de la aplicacion

This commit is contained in:
2026-05-22 20:43:49 -06:00
parent 37e83a8226
commit 458af32fcf
13 changed files with 1918 additions and 463 deletions

View File

@@ -0,0 +1,189 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/app_colors.dart';
import '../../data/celaya_colonias.dart';
import '../../data/colonies_data.dart';
import '../../database/db_helper.dart';
import '../../models/models.dart';
import '../../models/route_model.dart';
import '../../services/auth_service.dart';
class AddDomicilioScreen extends StatefulWidget {
final DomicilioModel? editing;
const AddDomicilioScreen({super.key, this.editing});
@override State<AddDomicilioScreen> createState() => _AddDomicilioScreenState();
}
class _AddDomicilioScreenState extends State<AddDomicilioScreen> {
final _calleCtrl = TextEditingController();
final _aliasCtrl = TextEditingController(text: 'Casa');
String? _coloniaSeleccionada;
ColonyModel? _coloniaData;
bool _loading = false;
String _searchQuery = '';
@override
void initState() {
super.initState();
if (widget.editing != null) {
_calleCtrl.text = widget.editing!.calle;
_aliasCtrl.text = widget.editing!.alias;
_coloniaSeleccionada = widget.editing!.colonia;
_coloniaData = getColonyByName(widget.editing!.colonia);
}
}
List<String> get _filteredColonias {
if (_searchQuery.isEmpty) return celayaColonias;
return celayaColonias
.where((c) => c.toLowerCase().contains(_searchQuery.toLowerCase()))
.toList();
}
Future<void> _guardar() async {
if (_calleCtrl.text.trim().isEmpty || _coloniaSeleccionada == null) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Por favor completa todos los campos'),
backgroundColor: AppColors.rojoError));
return;
}
setState(() => _loading = true);
final auth = context.read<AuthService>();
final routeData = getColonyByName(_coloniaSeleccionada!);
final routeId = routeData?.routeId ?? 'RUTA-01';
final horario = routeData?.horarioEstimado ?? 'Matutino (06:00-08:00)';
if (widget.editing != null) {
// Editar existente — eliminar y volver a insertar
await DbHelper.deleteDomicilio(widget.editing!.id!);
}
final dom = DomicilioModel(
userId: auth.currentUser!.id!,
alias: _aliasCtrl.text.trim(),
calle: _calleCtrl.text.trim(),
colonia: _coloniaSeleccionada!,
routeId: routeId,
horarioEstimado: horario,
);
await DbHelper.insertDomicilio(dom);
await auth.reloadDomicilios();
if (!mounted) return;
setState(() => _loading = false);
Navigator.pop(context, true);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: Text(widget.editing != null ? 'Editar Domicilio' : 'Agregar Domicilio'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// Alias
TextField(
controller: _aliasCtrl,
decoration: const InputDecoration(
labelText: 'Alias (ej. Casa, Trabajo, Familia)',
prefixIcon: Icon(Icons.label_outline, color: AppColors.guindaPrimary),
border: OutlineInputBorder(), filled: true, fillColor: Colors.white),
),
const SizedBox(height: 12),
// Calle
TextField(
controller: _calleCtrl,
decoration: const InputDecoration(
labelText: 'Calle y número',
prefixIcon: Icon(Icons.signpost_outlined, color: AppColors.guindaPrimary),
border: OutlineInputBorder(), filled: true, fillColor: Colors.white),
),
const SizedBox(height: 16),
const Text('Colonia', style: TextStyle(fontWeight: FontWeight.bold,
color: AppColors.guindaPrimary, fontSize: 15)),
const SizedBox(height: 8),
// Buscador de colonias
TextField(
onChanged: (v) => setState(() => _searchQuery = v),
decoration: const InputDecoration(
hintText: 'Buscar colonia...',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(), filled: true, fillColor: Colors.white,
isDense: true,
),
),
const SizedBox(height: 8),
// Lista de colonias
Container(
height: 240,
decoration: BoxDecoration(
color: Colors.white, borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300)),
child: ListView.builder(
itemCount: _filteredColonias.length,
itemBuilder: (_, i) {
final c = _filteredColonias[i];
final isSelected = c == _coloniaSeleccionada;
return ListTile(
dense: true,
title: Text(c, style: TextStyle(fontSize: 13,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? AppColors.guindaPrimary : AppColors.negroTexto)),
trailing: isSelected
? const Icon(Icons.check_circle, color: AppColors.guindaPrimary, size: 18)
: null,
tileColor: isSelected ? AppColors.guindaPrimary.withOpacity(0.08) : null,
onTap: () {
setState(() {
_coloniaSeleccionada = c;
_coloniaData = getColonyByName(c);
});
},
);
},
),
),
if (_coloniaData != null) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.guindaPrimary.withOpacity(0.08),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.guindaPrimary.withOpacity(0.3))),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Ruta asignada: ${_coloniaData!.routeId}',
style: const TextStyle(fontWeight: FontWeight.bold,
color: AppColors.guindaPrimary, fontSize: 13)),
Text('Horario: ${_coloniaData!.horarioEstimado}',
style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)),
]),
),
],
const SizedBox(height: 24),
SizedBox(width: double.infinity, height: 50,
child: ElevatedButton.icon(
onPressed: _loading ? null : _guardar,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
icon: _loading
? const SizedBox(width: 18, height: 18,
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Icon(Icons.save),
label: Text(widget.editing != null ? 'ACTUALIZAR' : 'GUARDAR DOMICILIO',
style: const TextStyle(fontWeight: FontWeight.bold)))),
]),
),
);
}
@override void dispose() { _calleCtrl.dispose(); _aliasCtrl.dispose(); super.dispose(); }
}

View File

@@ -1,14 +1,16 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/app_colors.dart';
import '../../services/auth_service.dart';
import '../../services/route_simulator_service.dart';
import '../../database/db_helper.dart';
import '../../models/models.dart';
import '../../services/auth_service.dart';
import '../../services/route_simulator_service.dart';
import '../../data/routes_data.dart';
import '../../widgets/route_map_widget.dart';
import 'citizen_guia_screen.dart';
import 'citizen_reporte_screen.dart';
import 'add_domicilio_screen.dart';
import 'review_screen.dart';
class CitizenHomeScreen extends StatefulWidget {
const CitizenHomeScreen({super.key});
@@ -22,7 +24,7 @@ class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
Widget build(BuildContext context) {
final auth = context.watch<AuthService>();
final sim = context.watch<RouteSimulatorService>();
final dom = auth.primaryDomicilio; // domicilio del ciudadano
final dom = auth.primaryDomicilio;
final last = dom != null ? sim.getNotificationForRoute(dom.routeId) : null;
final tabs = [
@@ -38,7 +40,8 @@ class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
if (last != null)
Positioned(
top: MediaQuery.of(context).padding.top + 8, left: 0, right: 0,
child: _NotifBanner(notif: last, onDismiss: () => sim.dismissRouteNotification(dom?.routeId ?? '')),
child: _NotifBanner(notif: last,
onDismiss: () => sim.dismissRouteNotification(dom?.routeId ?? '')),
),
]),
bottomNavigationBar: NavigationBar(
@@ -47,19 +50,18 @@ class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
backgroundColor: Colors.white,
indicatorColor: AppColors.guindaPrimary.withOpacity(0.15),
destinations: const [
NavigationDestination(icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home, color: AppColors.guindaPrimary), label: 'Inicio'),
NavigationDestination(icon: Icon(Icons.eco_outlined),
selectedIcon: Icon(Icons.eco, color: AppColors.guindaPrimary), label: 'Guía'),
NavigationDestination(icon: Icon(Icons.report_outlined),
selectedIcon: Icon(Icons.report, color: AppColors.guindaPrimary), label: 'Reportar'),
NavigationDestination(icon:Icon(Icons.home_outlined),
selectedIcon:Icon(Icons.home,color:AppColors.guindaPrimary),label:'Inicio'),
NavigationDestination(icon:Icon(Icons.eco_outlined),
selectedIcon:Icon(Icons.eco,color:AppColors.guindaPrimary),label:'Guía'),
NavigationDestination(icon:Icon(Icons.report_outlined),
selectedIcon:Icon(Icons.report,color:AppColors.guindaPrimary),label:'Reportar'),
],
),
);
}
}
// ── Tab principal (StatefulWidget para cargar status de ruta) ─────────────
class _HomeTab extends StatefulWidget {
final AuthService auth;
final RouteSimulatorService sim;
@@ -69,98 +71,90 @@ class _HomeTab extends StatefulWidget {
class _HomeTabState extends State<_HomeTab> {
RouteStatusModel? _routeStatus;
RouteDefinitionModel? _routeDef;
@override
void initState() {
super.initState();
_loadStatus();
}
@override void initState() { super.initState(); _loadStatus(); }
Future<void> _loadStatus() async {
final dom = widget.auth.primaryDomicilio;
if (dom == null) return;
final s = await DbHelper.getRouteStatus(dom.routeId);
if (mounted) setState(() => _routeStatus = s);
final rd = await DbHelper.getRouteDefinitionById(dom.routeId);
if (mounted) setState(() { _routeStatus = s; _routeDef = rd; });
}
bool get _isRouteProblematic {
final s = _routeStatus?.status ?? RouteStatus.enRuta;
return s == RouteStatus.cancelada ||
s == RouteStatus.fallaMecanica ||
s == RouteStatus.retrasada;
return s == RouteStatus.cancelada || s == RouteStatus.fallaMecanica || s == RouteStatus.retrasada;
}
@override
Widget build(BuildContext context) {
final dom = widget.auth.primaryDomicilio;
final allDoms = widget.auth.allDomicilios;
final routeId = dom?.routeId ?? '';
final route = dom != null ? getRouteById(dom.routeId) : null;
final isTruckClose = widget.sim.isTruckClose(routeId);
final status = _routeStatus?.status ?? RouteStatus.enRuta;
final isTruckClose = widget.sim.isTruckClose(routeId);
final isCompleted = widget.sim.isRouteCompleted(routeId);
final needsReview = widget.sim.needsReviewPrompt(routeId);
return RefreshIndicator(
onRefresh: _loadStatus,
child: CustomScrollView(slivers: [
SliverAppBar(
expandedHeight: 120, pinned: true,
SliverAppBar(expandedHeight: 120, pinned: true,
backgroundColor: AppColors.guindaPrimary,
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
flexibleSpace: FlexibleSpaceBar(
background: Container(
color: AppColors.guindaPrimary,
padding: const EdgeInsets.fromLTRB(20, 50, 20, 16),
child: Row(children: [
const Icon(Icons.delete_sweep_rounded, color: AppColors.dorado, size: 30),
const SizedBox(width: 12),
Expanded(child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Hola, ${widget.auth.currentUser?.nombre.split(' ').first ?? ''}',
style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
const Text('Celaya Limpia', style: TextStyle(color: AppColors.dorado, fontSize: 12)),
],
)),
IconButton(
icon: const Icon(Icons.logout, color: Colors.white70),
onPressed: () async {
await widget.auth.logout();
if (context.mounted) Navigator.pushReplacementNamed(context, '/login');
},
),
]),
),
),
flexibleSpace: FlexibleSpaceBar(background: Container(
color: AppColors.guindaPrimary,
padding: const EdgeInsets.fromLTRB(20, 50, 20, 16),
child: Row(children: [
const Icon(Icons.delete_sweep_rounded, color: AppColors.dorado, size: 30),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center, children: [
Text('Hola, ${widget.auth.currentUser?.nombre.split(' ').first ?? ''}',
style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
const Text('Celaya Limpia', style: TextStyle(color: AppColors.dorado, fontSize: 12)),
])),
IconButton(icon: const Icon(Icons.logout, color: Colors.white70),
onPressed: () async {
await widget.auth.logout();
if (context.mounted) Navigator.pushReplacementNamed(context, '/login');
}),
]),
)),
),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(delegate: SliverChildListDelegate([
// ── Si la ruta tiene problema → mostrar alerta en vez de ETA/mapa
if (_isRouteProblematic) ...[
_RouteStatusBanner(status: _routeStatus!),
const SizedBox(height: 12),
] else ...[
// ETA Card normal
// ── Selector de domicilio ────────────────────────────────────
if (allDoms.length > 1) _DomicilioSelector(
auth: widget.auth, onChanged: _loadStatus),
// ── Prompt de calificación ───────────────────────────────────
if (needsReview && dom != null)
_ReviewPromptCard(routeId: routeId, colonia: dom.colonia,
sim: widget.sim),
// ── Estado de ruta (cancelada/falla/retrasada) ───────────────
if (_isRouteProblematic)
_RouteStatusBanner(status: _routeStatus!)
else ...[
// ETA Card
_EtaCard(sim: widget.sim, routeId: routeId, dom: dom, route: route),
const SizedBox(height: 12),
// Mapa solo cuando camión está cerca
if (isTruckClose && route != null) ...[
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.shade300),
),
child: const Row(children: [
Icon(Icons.location_on, color: Colors.orange, size: 18),
SizedBox(width: 6),
Expanded(child: Text('📍 El camión está cerca — mapa activado',
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.orange, fontSize: 12))),
]),
),
// Información detallada de la ruta (días y horario)
if (_routeDef != null) _RouteInfoCard(routeDef: _routeDef!),
if (_routeDef == null && dom != null) _BasicRouteInfo(dom: dom),
const SizedBox(height: 12),
// Mapa solo cuando camión está cerca (<15 min)
if (isTruckClose && route != null && !isCompleted) ...[
_WarningNoPursue(),
const SizedBox(height: 8),
RouteMapWidget(route: route, simulator: widget.sim, height: 220),
const SizedBox(height: 12),
@@ -168,81 +162,17 @@ class _HomeTabState extends State<_HomeTab> {
],
// Aviso privacidad
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.amber.shade50, borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.amber.shade300),
),
child: const Row(children: [
Icon(Icons.shield_outlined, color: Colors.amber, size: 18),
SizedBox(width: 6),
Expanded(child: Text('🔒 Solo ves la información de tu ruta asignada.',
style: TextStyle(fontSize: 11, color: Colors.black87))),
]),
),
_PrivacyBanner(),
const SizedBox(height: 12),
// Info domicilio
if (dom != null)
Card(child: Padding(
padding: const EdgeInsets.all(14),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Row(children: [
Icon(Icons.location_on, color: AppColors.guindaPrimary, size: 16),
SizedBox(width: 6),
Text('Mi Domicilio', style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)),
]),
const Divider(),
Text(dom.calle, style: const TextStyle(fontSize: 13)),
Text('${dom.colonia}${dom.routeId}',
style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)),
Text(dom.horarioEstimado,
style: const TextStyle(color: AppColors.grisTexto, fontSize: 11)),
]),
)),
// Mis domicilios
_DomiciliosCard(auth: widget.auth),
const SizedBox(height: 12),
// Historial notificaciones
if (widget.sim.history.isNotEmpty) ...[
const SizedBox(height: 12),
Card(child: Padding(
padding: const EdgeInsets.all(14),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('Alertas recientes',
style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)),
const Divider(),
...widget.sim.history.take(4).map((n) {
final color = n.event == NotifEvent.truckProximity
? AppColors.naranjaAlerta
: n.event == NotifEvent.routeCompleted
? AppColors.verdeExito
: n.event == NotifEvent.routeCancelled
? AppColors.rojoError
: AppColors.azulInfo;
final icon = n.event == NotifEvent.truckProximity
? Icons.warning_amber
: n.event == NotifEvent.routeCompleted
? Icons.check_circle
: n.event == NotifEvent.routeCancelled
? Icons.cancel
: Icons.local_shipping;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 6),
Expanded(child: Text(n.title,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500))),
Text(
'${n.timestamp.hour.toString().padLeft(2, '0')}:${n.timestamp.minute.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 10, color: AppColors.grisTexto),
),
]),
);
}),
]),
)),
],
if (widget.sim.historyForRoute(routeId).isNotEmpty)
_HistorialCard(sim: widget.sim, routeId: routeId),
const SizedBox(height: 80),
])),
),
@@ -251,6 +181,223 @@ class _HomeTabState extends State<_HomeTab> {
}
}
// ── Selector de domicilio activo ──────────────────────────────────────────
class _DomicilioSelector extends StatelessWidget {
final AuthService auth; final VoidCallback onChanged;
const _DomicilioSelector({required this.auth, required this.onChanged});
@override
Widget build(BuildContext context) {
return Container(margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.guindaPrimary.withOpacity(0.3)),
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 4)]),
child: DropdownButtonHideUnderline(
child: DropdownButton<int>(
isExpanded: true,
value: auth.primaryDomicilio?.id,
icon: const Icon(Icons.swap_horiz, color: AppColors.guindaPrimary),
items: auth.allDomicilios.map((d) => DropdownMenuItem(
value: d.id,
child: Row(children: [
Icon(d.isPrimary ? Icons.home : Icons.location_on_outlined,
color: AppColors.guindaPrimary, size: 16),
const SizedBox(width: 6),
Expanded(child: Text('${d.alias}${d.colonia}',
style: const TextStyle(fontSize: 13), overflow: TextOverflow.ellipsis)),
]))).toList(),
onChanged: (id) async {
if (id != null) {
await DbHelper.setPrimaryDomicilio(id, auth.currentUser!.id!);
await auth.reloadDomicilios();
onChanged();
}
},
),
));
}
}
// ── Prompt de reseña ──────────────────────────────────────────────────────
class _ReviewPromptCard extends StatelessWidget {
final String routeId, colonia; final RouteSimulatorService sim;
const _ReviewPromptCard({required this.routeId, required this.colonia, required this.sim});
@override
Widget build(BuildContext context) => Card(
color: Colors.amber.shade50,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.amber.shade300, width: 1.5)),
child: Padding(padding: const EdgeInsets.all(14), child: Column(children: [
const Row(children: [
Text('', style: TextStyle(fontSize: 24)),
SizedBox(width: 8),
Expanded(child: Text('¿Cómo estuvo el servicio de hoy?',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14))),
]),
const SizedBox(height: 4),
const Text('El camión pasó por tu colonia. Toma un momento para calificar el servicio.',
style: TextStyle(fontSize: 12, color: AppColors.grisTexto)),
const SizedBox(height: 10),
Row(children: [
Expanded(child: ElevatedButton.icon(
onPressed: () => Navigator.push(context, MaterialPageRoute(
builder: (_) => ReviewScreen(routeId: routeId, colonia: colonia))),
style: ElevatedButton.styleFrom(backgroundColor: Colors.amber,
foregroundColor: Colors.black87),
icon: const Icon(Icons.star, size: 16),
label: const Text('Calificar', style: TextStyle(fontWeight: FontWeight.bold)))),
const SizedBox(width: 8),
TextButton(onPressed: () => sim.clearReviewPrompt(routeId),
child: const Text('Después', style: TextStyle(color: AppColors.grisTexto))),
]),
])));
}
// ── Info detallada de la ruta ─────────────────────────────────────────────
class _RouteInfoCard extends StatelessWidget {
final RouteDefinitionModel routeDef;
const _RouteInfoCard({required this.routeDef});
@override
Widget build(BuildContext context) => Card(
child: Padding(padding: const EdgeInsets.all(14), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
const Row(children: [
Icon(Icons.schedule, color: AppColors.guindaPrimary, size: 16),
SizedBox(width: 6),
Text('Información de tu ruta', style: TextStyle(
fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)),
]),
const Divider(),
Text(routeDef.nombre, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
const SizedBox(height: 4),
Row(children: [
const Icon(Icons.access_time, size: 13, color: AppColors.grisTexto),
const SizedBox(width: 4),
Text('${routeDef.horaInicio}${routeDef.horaFin} (${_turnoLabel(routeDef.turno)})',
style: const TextStyle(fontSize: 12, color: AppColors.negroTexto)),
]),
const SizedBox(height: 4),
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Icon(Icons.calendar_today, size: 13, color: AppColors.grisTexto),
const SizedBox(width: 4),
Expanded(child: Text(
routeDef.dias.map(AppDias.label).join(', '),
style: const TextStyle(fontSize: 12, color: AppColors.negroTexto))),
]),
])));
String _turnoLabel(String t) => t=='MATUTINO'?'🌄 Matutino':t=='VESPERTINO'?'🌅 Vespertino':'🌙 Nocturno';
}
class _BasicRouteInfo extends StatelessWidget {
final DomicilioModel dom;
const _BasicRouteInfo({required this.dom});
@override
Widget build(BuildContext context) => Card(
child: Padding(padding: const EdgeInsets.all(14), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
const Row(children: [
Icon(Icons.schedule, color: AppColors.guindaPrimary, size: 16),
SizedBox(width: 6),
Text('Tu servicio de recolección', style: TextStyle(
fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)),
]),
const Divider(),
Text('Ruta: ${dom.routeId}', style: const TextStyle(fontWeight: FontWeight.w600)),
Text('Horario: ${dom.horarioEstimado}',
style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)),
])));
}
// ── Aviso anti-persecución ────────────────────────────────────────────────
class _WarningNoPursue extends StatelessWidget {
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: Colors.red.shade50, borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade300)),
child: const Row(children: [
Icon(Icons.warning_amber_rounded, color: AppColors.rojoError, size: 20),
SizedBox(width: 8),
Expanded(child: Text(
'⚠️ Ya es momento de sacar tu basura.\n'
'🚫 NO persigas ni interceptes el camión en movimiento.\n'
'✅ Coloca tus bolsas en la acera y espera.',
style: TextStyle(fontSize: 11, color: AppColors.rojoError, fontWeight: FontWeight.w500))),
]));
}
// ── Mis domicilios ────────────────────────────────────────────────────────
class _DomiciliosCard extends StatelessWidget {
final AuthService auth;
const _DomiciliosCard({required this.auth});
@override
Widget build(BuildContext context) {
final doms = auth.allDomicilios;
return Card(child: Padding(padding: const EdgeInsets.all(14), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
const Icon(Icons.home_outlined, color: AppColors.guindaPrimary, size: 16),
const SizedBox(width: 6),
const Expanded(child: Text('Mis Domicilios',
style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.guindaPrimary))),
TextButton.icon(
onPressed: () async {
final result = await Navigator.push(context,
MaterialPageRoute(builder: (_) => const AddDomicilioScreen()));
if (result == true) await auth.reloadDomicilios();
},
icon: const Icon(Icons.add, size: 14),
label: const Text('Agregar', style: TextStyle(fontSize: 12)),
style: TextButton.styleFrom(foregroundColor: AppColors.guindaPrimary)),
]),
const Divider(),
if (doms.isEmpty)
const Text('Sin domicilios registrados',
style: TextStyle(color: AppColors.grisTexto, fontSize: 12))
else
...doms.map((d) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(children: [
Icon(d.isPrimary ? Icons.home : Icons.location_on_outlined,
color: d.isPrimary ? AppColors.guindaPrimary : AppColors.grisTexto, size: 16),
const SizedBox(width: 8),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('${d.alias}${d.colonia}',
style: TextStyle(fontWeight: d.isPrimary ? FontWeight.bold : FontWeight.normal,
fontSize: 12)),
Text(d.calle, style: const TextStyle(color: AppColors.grisTexto, fontSize: 11)),
Text('${d.routeId}${d.horarioEstimado}',
style: const TextStyle(color: AppColors.grisTexto, fontSize: 10)),
])),
if (!d.isPrimary)
IconButton(icon: const Icon(Icons.star_border, size: 16, color: AppColors.dorado),
tooltip: 'Hacer principal',
onPressed: () async {
await DbHelper.setPrimaryDomicilio(d.id!, auth.currentUser!.id!);
await auth.reloadDomicilios();
}),
IconButton(icon: const Icon(Icons.edit_outlined, size: 14, color: AppColors.grisTexto),
onPressed: () async {
final result = await Navigator.push(context, MaterialPageRoute(
builder: (_) => AddDomicilioScreen(editing: d)));
if (result == true) await auth.reloadDomicilios();
}),
if (!d.isPrimary)
IconButton(icon: const Icon(Icons.delete_outline, size: 14, color: AppColors.rojoError),
onPressed: () async {
await DbHelper.deleteDomicilio(d.id!);
await auth.reloadDomicilios();
}),
]))),
])));
}
}
// ── Banner de ruta con problema ───────────────────────────────────────────
class _RouteStatusBanner extends StatelessWidget {
final RouteStatusModel status;
@@ -261,188 +408,171 @@ class _RouteStatusBanner extends StatelessWidget {
final isCancelled = status.status == RouteStatus.cancelada;
final isFalla = status.status == RouteStatus.fallaMecanica;
final isRetrasada = status.status == RouteStatus.retrasada;
final color = isCancelled ? AppColors.rojoError
: isFalla ? Colors.red.shade800
: AppColors.naranjaAlerta;
final icon = isCancelled ? Icons.cancel
: isFalla ? Icons.build
: Icons.access_time;
final color = isCancelled ? AppColors.rojoError : isFalla ? Colors.red.shade800 : AppColors.naranjaAlerta;
final icon = isCancelled ? Icons.cancel : isFalla ? Icons.build : Icons.access_time;
final titulo = isCancelled ? '❌ Ruta Cancelada Hoy'
: isFalla ? '🔧 Falla Mecánica en Servicio'
: '⏱️ Servicio con Retraso';
final descripcion = isCancelled
? 'El servicio de recolección de tu colonia no se realizará hoy. Favor de guardar tus residuos para la próxima jornada.'
: isFalla
? 'El camión asignado a tu sector presentó una falla mecánica. El Ayuntamiento está atendiendo la situación.'
: 'El camión de tu sector presenta un retraso en su recorrido. El servicio se realizará, pero con demora.';
: isFalla ? '🔧 Falla Mecánica en Servicio' : '⏱️ Servicio con Retraso';
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// Alerta principal
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(12),
boxShadow: [BoxShadow(color: color.withOpacity(0.4), blurRadius: 8, offset: const Offset(0, 4))],
),
Container(width: double.infinity, padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(12)),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(icon, color: Colors.white, size: 26),
const SizedBox(width: 10),
Expanded(child: Text(titulo,
style: const TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold))),
Expanded(child: Text(titulo, style: const TextStyle(color: Colors.white,
fontSize: 17, fontWeight: FontWeight.bold))),
]),
const SizedBox(height: 10),
Text(descripcion, style: const TextStyle(color: Colors.white, fontSize: 13, height: 1.4)),
]),
),
// Mensaje del administrador (posible solución)
const SizedBox(height: 8),
Text(isCancelled
? 'El servicio no se realizará hoy. Guarda tus residuos para mañana.'
: isFalla
? 'El camión presentó una falla. El Ayuntamiento atiende la situación.'
: 'El camión presenta un retraso. El servicio se realizará con demora.',
style: const TextStyle(color: Colors.white, fontSize: 13)),
])),
if (status.mensaje != null && status.mensaje!.isNotEmpty) ...[
const SizedBox(height: 10),
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withOpacity(0.4)),
),
Container(width: double.infinity, padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withOpacity(0.4))),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(Icons.admin_panel_settings, color: color, size: 16),
const SizedBox(width: 6),
Text('Mensaje del Ayuntamiento',
style: TextStyle(fontWeight: FontWeight.bold, color: color, fontSize: 13)),
Text('Mensaje del Ayuntamiento', style: TextStyle(
fontWeight: FontWeight.bold, color: color, fontSize: 13)),
]),
const SizedBox(height: 6),
Text(status.mensaje!,
style: const TextStyle(fontSize: 13, color: AppColors.negroTexto, height: 1.4)),
]),
),
Text(status.mensaje!, style: const TextStyle(fontSize: 13)),
])),
],
// Consejo ciudadano
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
Container(padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300)),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('💡 Recomendaciones:',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: AppColors.grisTexto)),
const Text('💡 Recomendaciones:', style: TextStyle(fontWeight: FontWeight.bold,
fontSize: 12, color: AppColors.grisTexto)),
const SizedBox(height: 4),
if (isCancelled)
const Text('• Guarda tus bolsas en un lugar cerrado\n'
'• No dejes residuos en la acera\n'
'Revisa la app mañana para el horario actualizado',
style: TextStyle(fontSize: 12, color: AppColors.grisTexto)),
if (isFalla)
const Text('• Espera confirmación del Ayuntamiento\n'
'• Puede enviarse una unidad de reemplazo\n'
'• Revisa las alertas en esta pantalla',
style: TextStyle(fontSize: 12, color: AppColors.grisTexto)),
if (isRetrasada)
const Text('• Tu basura será recogida hoy, con demora\n'
'• Puedes sacar tus bolsas cuando recibas la alerta\n'
'• Recibirás notificación cuando el camión se acerque',
style: TextStyle(fontSize: 12, color: AppColors.grisTexto)),
]),
),
Text(isCancelled
? '• Guarda tus bolsas en lugar cerrado\n• No dejes residuos en la acera\n• Revisa la app mañana'
: isRetrasada
? 'Espera el aviso de 15 minutos antes de sacar tu basura\n• El camión llegará eventualmente\n• Recibe la notificación en esta app'
: '• Espera confirmación del Ayuntamiento\n• Puede enviarse unidad de reemplazo',
style: const TextStyle(fontSize: 12, color: AppColors.grisTexto)),
])),
const SizedBox(height: 12),
]);
}
}
// ── ETA Card ──────────────────────────────────────────────────────────────
class _EtaCard extends StatelessWidget {
final RouteSimulatorService sim;
final String routeId;
final dom; final route;
final RouteSimulatorService sim; final String routeId; final dom; final route;
const _EtaCard({required this.sim, required this.routeId, required this.dom, required this.route});
@override
Widget build(BuildContext context) => Container(
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [AppColors.guindaPrimary, AppColors.guindaDark],
begin: Alignment.topLeft, end: Alignment.bottomRight),
gradient: const LinearGradient(colors:[AppColors.guindaPrimary,AppColors.guindaDark],
begin:Alignment.topLeft,end:Alignment.bottomRight),
borderRadius: BorderRadius.circular(14),
boxShadow: [BoxShadow(color: AppColors.guindaDark.withOpacity(0.4),
blurRadius: 8, offset: const Offset(0, 4))],
),
boxShadow: [BoxShadow(color:AppColors.guindaDark.withOpacity(0.4),blurRadius:8,offset:const Offset(0,4))]),
padding: const EdgeInsets.all(18),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
const Icon(Icons.local_shipping, color: AppColors.dorado, size: 22),
const SizedBox(width: 8),
Expanded(child: Text(route?.name ?? 'Ruta asignada',
style: const TextStyle(color: AppColors.dorado, fontSize: 13, fontWeight: FontWeight.w600))),
Row(children:[
const Icon(Icons.local_shipping,color:AppColors.dorado,size:22),
const SizedBox(width:8),
Expanded(child:Text(route?.name??dom?.routeId??'Tu ruta',
style:const TextStyle(color:AppColors.dorado,fontSize:13,fontWeight:FontWeight.w600))),
]),
const SizedBox(height: 8),
const SizedBox(height:8),
Text(sim.getEtaText(routeId),
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
if (dom != null)
Text('${dom.horarioEstimado}',
style: const TextStyle(color: Colors.white60, fontSize: 11)),
const SizedBox(height: 10),
style:const TextStyle(color:Colors.white,fontSize:16,fontWeight:FontWeight.bold)),
const SizedBox(height:6),
if (dom!=null) Text('${dom.horarioEstimado}',
style:const TextStyle(color:Colors.white60,fontSize:11)),
const SizedBox(height:10),
LinearProgressIndicator(
value: route != null
? (sim.getPositionIndex(routeId) + 1) / route.positions.length : 0,
backgroundColor: Colors.white24,
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.dorado),
),
]),
);
value:route!=null?(sim.getPositionIndex(routeId)+1)/route.positions.length:0,
backgroundColor:Colors.white24,
valueColor:const AlwaysStoppedAnimation<Color>(AppColors.dorado)),
]));
}
// ── Banner notificación ───────────────────────────────────────────────────
// ── Privacidad ────────────────────────────────────────────────────────────
class _PrivacyBanner extends StatelessWidget {
@override
Widget build(BuildContext context) => Container(
padding:const EdgeInsets.all(10),
decoration:BoxDecoration(color:Colors.amber.shade50,borderRadius:BorderRadius.circular(8),
border:Border.all(color:Colors.amber.shade300)),
child:const Row(children:[
Icon(Icons.shield_outlined,color:Colors.amber,size:18),
SizedBox(width:6),
Expanded(child:Text('🔒 Solo ves la información de tu ruta asignada.',
style:TextStyle(fontSize:11,color:Colors.black87))),
]));
}
// ── Historial notificaciones ──────────────────────────────────────────────
class _HistorialCard extends StatelessWidget {
final RouteSimulatorService sim; final String routeId;
const _HistorialCard({required this.sim, required this.routeId});
@override
Widget build(BuildContext context) {
final notifs = sim.historyForRoute(routeId).take(5).toList();
return Card(child:Padding(padding:const EdgeInsets.all(14),child:Column(
crossAxisAlignment:CrossAxisAlignment.start, children:[
const Text('Alertas recientes',style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.guindaPrimary)),
const Divider(),
...notifs.map((n){
final color = n.event==NotifEvent.truckProximity||n.event==NotifEvent.truckApproaching15min
?AppColors.naranjaAlerta:n.event==NotifEvent.routeCompleted||n.event==NotifEvent.reviewPrompt
?AppColors.verdeExito:n.event==NotifEvent.routeCancelled?AppColors.rojoError:AppColors.azulInfo;
return Padding(padding:const EdgeInsets.symmetric(vertical:3),
child:Row(children:[
Icon(Icons.circle,size:8,color:color),
const SizedBox(width:8),
Expanded(child:Text(n.title,style:const TextStyle(fontSize:12,fontWeight:FontWeight.w500))),
Text('${n.timestamp.hour.toString().padLeft(2,'0')}:${n.timestamp.minute.toString().padLeft(2,'0')}',
style:const TextStyle(fontSize:10,color:AppColors.grisTexto)),
]));
}),
])));
}
}
// ── Notif Banner ──────────────────────────────────────────────────────────
class _NotifBanner extends StatelessWidget {
final AppNotification notif; final VoidCallback onDismiss;
const _NotifBanner({required this.notif, required this.onDismiss});
@override
Widget build(BuildContext context) {
final color = notif.event == NotifEvent.truckProximity
? AppColors.naranjaAlerta
: notif.event == NotifEvent.routeCompleted
? AppColors.verdeExito
: notif.event == NotifEvent.routeCancelled
? AppColors.rojoError
: notif.event == NotifEvent.gpsLost
? Colors.red.shade800
: AppColors.azulInfo;
return Material(
color: Colors.transparent,
child: Container(
margin: const EdgeInsets.all(12),
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(12),
boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 8, offset: Offset(0, 4))]),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(children: [
const Icon(Icons.notifications_active, color: Colors.white, size: 24),
const SizedBox(width: 10),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, children: [
Text(notif.title, style: const TextStyle(color: Colors.white,
fontWeight: FontWeight.bold, fontSize: 13)),
Text(notif.body, style: const TextStyle(color: Colors.white70, fontSize: 11),
maxLines: 2, overflow: TextOverflow.ellipsis),
])),
IconButton(icon: const Icon(Icons.close, color: Colors.white, size: 18),
onPressed: onDismiss),
]),
),
),
);
final isUrgent = notif.event==NotifEvent.truckProximity||notif.event==NotifEvent.truckApproaching15min;
final isReview = notif.event==NotifEvent.reviewPrompt;
final color = isUrgent?AppColors.naranjaAlerta
:isReview?Colors.amber.shade700
:notif.event==NotifEvent.routeCancelled?AppColors.rojoError
:notif.event==NotifEvent.gpsLost?Colors.red.shade800
:AppColors.azulInfo;
return Material(color:Colors.transparent,
child:Container(margin:const EdgeInsets.all(12),
decoration:BoxDecoration(color:color,borderRadius:BorderRadius.circular(12),
boxShadow:const[BoxShadow(color:Colors.black26,blurRadius:8,offset:Offset(0,4))]),
child:Padding(padding:const EdgeInsets.all(12),child:Row(children:[
Icon(isReview?Icons.star:Icons.notifications_active,color:Colors.white,size:24),
const SizedBox(width:10),
Expanded(child:Column(crossAxisAlignment:CrossAxisAlignment.start,
mainAxisSize:MainAxisSize.min,children:[
Text(notif.title,style:const TextStyle(color:Colors.white,fontWeight:FontWeight.bold,fontSize:13)),
Text(notif.body,style:const TextStyle(color:Colors.white70,fontSize:11),
maxLines:2,overflow:TextOverflow.ellipsis),
])),
IconButton(icon:const Icon(Icons.close,color:Colors.white,size:18),onPressed:onDismiss),
]))));
}
}

View File

@@ -0,0 +1,183 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/app_colors.dart';
import '../../database/db_helper.dart';
import '../../models/models.dart';
import '../../services/auth_service.dart';
import '../../services/route_simulator_service.dart';
class ReviewScreen extends StatefulWidget {
final String routeId;
final String colonia;
const ReviewScreen({super.key, required this.routeId, required this.colonia});
@override State<ReviewScreen> createState() => _ReviewScreenState();
}
class _ReviewScreenState extends State<ReviewScreen> {
int _estrellas = 5;
final _comentCtrl = TextEditingController();
bool _loading = false;
bool _sent = false;
static const _labels = ['', 'Muy malo', 'Malo', 'Regular', 'Bueno', 'Excelente'];
static const _colors = [
Colors.transparent, AppColors.rojoError, AppColors.naranjaAlerta,
Colors.amber, AppColors.verdeExito, AppColors.verdeExito,
];
Future<void> _enviar() async {
final auth = context.read<AuthService>();
if (auth.currentUser == null) return;
// Verificar si ya calificó hoy
final yaCalificado = await DbHelper.hasReviewedRoute(
auth.currentUser!.id!, widget.routeId);
if (yaCalificado && mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Ya calificaste este servicio hoy'),
backgroundColor: AppColors.azulInfo));
return;
}
setState(() => _loading = true);
await DbHelper.insertReview(ReviewModel(
userId: auth.currentUser!.id!,
colonia: widget.colonia,
routeId: widget.routeId,
estrellas: _estrellas,
comentario: _comentCtrl.text.trim().isEmpty
? 'Sin comentario' : _comentCtrl.text.trim(),
fecha: DateTime.now().toIso8601String(),
nombreUsuario: auth.currentUser!.nombre,
));
context.read<RouteSimulatorService>().clearReviewPrompt(widget.routeId);
if (!mounted) return;
setState(() { _loading = false; _sent = true; });
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: const Text('Calificar el Servicio'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
),
body: _sent
? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
const Text('', style: TextStyle(fontSize: 64)),
const SizedBox(height: 16),
const Text('¡Gracias por tu calificación!',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold,
color: AppColors.guindaPrimary)),
const SizedBox(height: 8),
const Text('Tu opinión ayuda a mejorar el servicio\nde recolección en Celaya.',
textAlign: TextAlign.center,
style: TextStyle(color: AppColors.grisTexto)),
const SizedBox(height: 24),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white),
onPressed: () => Navigator.pop(context),
child: const Text('Volver al inicio')),
]))
: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(children: [
// Header
Container(
width: double.infinity, padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.guindaPrimary.withOpacity(0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.guindaPrimary.withOpacity(0.2))),
child: Column(children: [
const Icon(Icons.local_shipping, color: AppColors.guindaPrimary, size: 36),
const SizedBox(height: 8),
Text(widget.routeId, style: const TextStyle(
fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)),
Text(widget.colonia, style: const TextStyle(
color: AppColors.grisTexto, fontSize: 12)),
]),
),
const SizedBox(height: 24),
// Estrellas
const Text('¿Cómo calificarías el servicio de hoy?',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const SizedBox(height: 16),
Row(mainAxisAlignment: MainAxisAlignment.center, children: List.generate(5, (i) {
final star = i + 1;
return GestureDetector(
onTap: () => setState(() => _estrellas = star),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Icon(
_estrellas >= star ? Icons.star : Icons.star_border,
color: _estrellas >= star ? Colors.amber : Colors.grey,
size: 44,
),
),
);
})),
const SizedBox(height: 8),
Text(_labels[_estrellas],
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold,
color: _colors[_estrellas])),
const SizedBox(height: 24),
// Comentario
const Align(alignment: Alignment.centerLeft,
child: Text('Comentario (opcional)',
style: TextStyle(fontWeight: FontWeight.w600))),
const SizedBox(height: 8),
TextField(
controller: _comentCtrl,
maxLines: 4,
maxLength: 200,
decoration: const InputDecoration(
hintText: 'Cuéntanos cómo estuvo el servicio...',
border: OutlineInputBorder(),
filled: true, fillColor: Colors.white),
),
const SizedBox(height: 20),
// Aviso
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.shade200)),
child: const Row(children: [
Icon(Icons.info_outline, color: AppColors.azulInfo, size: 16),
SizedBox(width: 6),
Expanded(child: Text(
'Tu calificación es anónima para otros ciudadanos, '
'pero el Ayuntamiento la usará para mejorar el servicio.',
style: TextStyle(fontSize: 11, color: AppColors.azulInfo))),
]),
),
const SizedBox(height: 24),
SizedBox(width: double.infinity, height: 50,
child: ElevatedButton.icon(
onPressed: _loading ? null : _enviar,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
icon: _loading
? const SizedBox(width: 18, height: 18,
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Icon(Icons.star),
label: const Text('ENVIAR CALIFICACIÓN',
style: TextStyle(fontWeight: FontWeight.bold)))),
]),
),
);
}
@override void dispose() { _comentCtrl.dispose(); super.dispose(); }
}