simulacion de estados y flujo de notificacion, modificacion de estilos en todas las vistas

This commit is contained in:
shinra32
2026-05-23 07:08:49 -06:00
parent ca076607c7
commit 92f570294a
43 changed files with 4335 additions and 2035 deletions

View File

@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/app_widgets.dart';
class AboutScreen extends StatelessWidget {
const AboutScreen({super.key});
@@ -11,93 +10,52 @@ class AboutScreen extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.background,
appBar: AppBar(title: const Text('Acerca de la app')),
body: FutureBuilder<PackageInfo>(
future: PackageInfo.fromPlatform(),
builder: (context, snap) {
final version = snap.data?.version ?? '1.0.0';
final build = snap.data?.buildNumber ?? '1';
return ListView(
padding: const EdgeInsets.all(16),
children: [
AppCard(
child: Column(
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: AppTheme.primaryLight,
shape: BoxShape.circle,
),
child: const Icon(
Icons.recycling_rounded,
size: 40,
color: AppTheme.primaryDark,
),
),
const SizedBox(height: 12),
const Text(
'Recolecta',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 4),
Text(
'Versión $version (build $build)',
style: const TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
),
),
],
),
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: _buildPageHeader(context, version, build),
),
const SizedBox(height: 16),
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
AppSectionTitle(title: 'Acerca de'),
Text(
'Recolecta es una aplicación del Servicio de Limpia de Celaya '
'para informar al ciudadano sobre rutas, horarios y separación '
'correcta de residuos.',
style: TextStyle(
fontSize: 14,
height: 1.5,
color: AppTheme.textPrimary,
SliverPadding(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 32),
sliver: SliverList(
delegate: SliverChildListDelegate([
_SectionLabel('Descripción'),
const SizedBox(height: 10),
_InfoCard(
icon: Icons.info_outline_rounded,
content: 'RecolectApp es una aplicación del Servicio de Limpia de Celaya '
'para informar al ciudadano sobre rutas, horarios y separación '
'correcta de residuos.',
),
const SizedBox(height: 20),
_SectionLabel('Créditos'),
const SizedBox(height: 10),
_InfoCard(
icon: Icons.people_outline_rounded,
content: 'Desarrollado por el equipo ONLINCESHACK.\nServicio de Limpia · Celaya, Gto.',
),
const SizedBox(height: 20),
_SectionLabel('Tecnología'),
const SizedBox(height: 10),
_TechRow(icon: Icons.phone_android_rounded, label: 'Flutter · Dart'),
const SizedBox(height: 8),
_TechRow(icon: Icons.cloud_outlined, label: 'FastAPI · Supabase'),
const SizedBox(height: 8),
_TechRow(icon: Icons.notifications_outlined, label: 'Firebase Cloud Messaging'),
const SizedBox(height: 32),
Center(
child: Text(
'© 2025 RecolectApp · Todos los derechos reservados',
style: const TextStyle(fontSize: 11, color: AppTheme.textHint),
textAlign: TextAlign.center,
),
),
],
),
),
const SizedBox(height: 16),
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
AppSectionTitle(title: 'Créditos'),
Text(
'Desarrollado por el equipo ONLINCESHACK.\n'
'Servicio de Limpia · Celaya, Gto.',
style: TextStyle(
fontSize: 14,
height: 1.5,
color: AppTheme.textPrimary,
),
),
],
),
),
const SizedBox(height: 24),
const Center(
child: Text(
'© 2025 Recolecta',
style: TextStyle(fontSize: 12, color: AppTheme.textHint),
]),
),
),
],
@@ -106,4 +64,208 @@ class AboutScreen extends StatelessWidget {
),
);
}
Widget _buildPageHeader(BuildContext context, String version, String build) {
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
stops: [0.0, 0.6, 1.0],
colors: [Color(0xFF4A0E26), Color(0xFF6D1234), Color(0xFF9B1B4A)],
),
),
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded,
color: Colors.white, size: 20),
onPressed: () => Navigator.of(context).pop(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const SizedBox(height: 16),
Center(
child: Column(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
shape: BoxShape.circle,
border: Border.all(
color: Colors.white.withValues(alpha: 0.3),
width: 2,
),
),
child: const Icon(
Icons.recycling_rounded,
size: 42,
color: Colors.white,
),
),
const SizedBox(height: 14),
const Text(
'RecolectApp',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: -0.5,
),
),
const SizedBox(height: 4),
Text(
'Versión $version (build $build)',
style: TextStyle(
fontSize: 13,
color: Colors.white.withValues(alpha: 0.75),
),
),
const SizedBox(height: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(100),
border: Border.all(
color: Colors.white.withValues(alpha: 0.25)),
),
child: Text(
'Servicio de Limpia · Celaya, Gto.',
style: TextStyle(
fontSize: 11,
color: Colors.white.withValues(alpha: 0.9),
fontWeight: FontWeight.w500,
),
),
),
],
),
),
],
),
),
),
);
}
}
class _SectionLabel extends StatelessWidget {
final String text;
const _SectionLabel(this.text);
@override
Widget build(BuildContext context) {
return Text(
text.toUpperCase(),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 0.8,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
}
}
class _InfoCard extends StatelessWidget {
final IconData icon;
final String content;
const _InfoCard({required this.icon, required this.content});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.5,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(9.5),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(width: 3, color: AppTheme.primary),
Expanded(
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, size: 18, color: AppTheme.primary),
),
const SizedBox(width: 12),
Expanded(
child: Text(
content,
style: const TextStyle(
fontSize: 13,
height: 1.5,
color: AppTheme.textPrimary,
),
),
),
],
),
),
),
],
),
),
),
);
}
}
class _TechRow extends StatelessWidget {
final IconData icon;
final String label;
const _TechRow({required this.icon, required this.label});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant, width: 0.5),
),
child: Row(
children: [
Icon(icon, size: 18, color: AppTheme.primary),
const SizedBox(width: 12),
Text(
label,
style: const TextStyle(fontSize: 13, color: AppTheme.textPrimary),
),
],
),
);
}
}

View File

@@ -213,202 +213,279 @@ class _AddAddressPageState extends ConsumerState<AddAddressPage> {
return Scaffold(
backgroundColor: AppTheme.background,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
iconTheme: const IconThemeData(color: AppTheme.textPrimary),
title: const Text(
'Agregar dirección',
style: TextStyle(
color: AppTheme.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppFormCard(
icon: Icons.home_outlined,
title: 'Dirección de tu casa',
child: Column(
children: [
AppFormField(
label: 'Etiqueta',
hint: 'Ej. Mi Casa, Trabajo',
controller: _labelCtrl,
),
const SizedBox(height: 14),
AppFormField(
label: 'Código Postal',
hint: 'Ej. 38000',
controller: _cpCtrl,
keyboardType: TextInputType.number,
onChanged: (v) => _validarCP(v, coloniasList),
),
if (_selectedColonia != null) ...[
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.primaryLight.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
border: Border.all(color: AppTheme.primaryMid),
body: CustomScrollView(
slivers: [
SliverToBoxAdapter(child: _buildPageHeader(context)),
SliverPadding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
sliver: SliverList(
delegate: SliverChildListDelegate([
AppFormCard(
icon: Icons.home_outlined,
title: 'Dirección de tu casa',
child: Column(
children: [
AppFormField(
label: 'Etiqueta',
hint: 'Ej. Mi Casa, Trabajo',
controller: _labelCtrl,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
const SizedBox(height: 14),
AppFormField(
label: 'Código Postal',
hint: 'Ej. 38000',
controller: _cpCtrl,
keyboardType: TextInputType.number,
onChanged: (v) => _validarCP(v, coloniasList),
),
if (_selectedColonia != null) ...[
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.primaryLight.withValues(alpha: 0.5),
borderRadius:
BorderRadius.circular(AppTheme.radiusSm),
border: Border.all(color: AppTheme.primaryMid),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.check_circle_outline,
color: AppTheme.primary,
size: 18,
Row(
children: [
const Icon(
Icons.check_circle_outline,
color: AppTheme.primary,
size: 18,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Colonia: ${_selectedColonia!.nombre}',
style: const TextStyle(
fontWeight: FontWeight.w600,
color: AppTheme.primaryDark,
),
),
),
],
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Colonia: ${_selectedColonia!.nombre}',
if (_selectedColonia!.horarioEstimado !=
null) ...[
const SizedBox(height: 8),
Text(
'Horario ${_selectedColonia!.turno?.toLowerCase() ?? ''}',
style: const TextStyle(
fontWeight: FontWeight.w600,
color: AppTheme.primaryDark,
fontSize: 13,
color: AppTheme.textPrimary,
),
),
),
],
),
if (_selectedColonia!.horarioEstimado != null) ...[
const SizedBox(height: 8),
Text(
'Horario ${_selectedColonia!.turno?.toLowerCase() ?? ''}',
style: const TextStyle(
fontSize: 13,
color: AppTheme.textPrimary,
),
),
Text(
_selectedColonia!.horarioEstimado!,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
],
],
),
),
const SizedBox(height: 14),
AppFormField(
label: 'Calle y número',
hint: 'Av. Insurgentes 245',
controller: _calleCtrl,
),
const SizedBox(height: 16),
const Text(
'Toca el mapa para ubicar tu casa exacta:',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
Container(
height: 200,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
border: Border.all(color: AppTheme.border),
),
clipBehavior: Clip.hardEdge,
child: FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter: mapCenter,
initialZoom: 15.0,
cameraConstraint: bounds != null
? CameraConstraint.containCenter(bounds: bounds)
: const CameraConstraint.unconstrained(),
onTap: (_, latlng) => _fetchStreetName(latlng),
),
children: [
TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.onlineshack.recolecta',
),
if (_selectedLocation != null)
MarkerLayer(
markers: [
Marker(
point: _selectedLocation!,
width: 40,
height: 40,
child: const Icon(
Icons.location_on,
color: AppTheme.danger,
size: 40,
Text(
_selectedColonia!.horarioEstimado!,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
],
),
],
),
),
] else ...[
const SizedBox(height: 24),
const Center(
child: Text(
'Ingresa un código postal con servicio\npara asignar tu colonia.',
textAlign: TextAlign.center,
style: TextStyle(
color: AppTheme.textSecondary,
fontSize: 13,
),
),
),
],
],
),
),
const SizedBox(height: 28),
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: _loading ? null : _guardar,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: _loading
? const SizedBox(
key: ValueKey('loading'),
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const FittedBox(
key: ValueKey('text'),
fit: BoxFit.scaleDown,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.check, size: 18),
SizedBox(width: 8),
Text('Guardar dirección'),
],
),
),
const SizedBox(height: 14),
AppFormField(
label: 'Calle y número',
hint: 'Av. Insurgentes 245',
controller: _calleCtrl,
),
const SizedBox(height: 16),
const Text(
'Toca el mapa para ubicar tu casa exacta:',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
Container(
height: 220,
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(AppTheme.radiusMd),
border: Border.all(color: AppTheme.border),
boxShadow: AppTheme.softShadow,
),
clipBehavior: Clip.hardEdge,
child: FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter: mapCenter,
initialZoom: 15.0,
cameraConstraint: bounds != null
? CameraConstraint.containCenter(
bounds: bounds)
: const CameraConstraint.unconstrained(),
onTap: (_, latlng) => _fetchStreetName(latlng),
),
children: [
TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName:
'com.onlineshack.recolecta',
),
if (_selectedLocation != null)
MarkerLayer(
markers: [
Marker(
point: _selectedLocation!,
width: 40,
height: 40,
child: const Icon(
Icons.location_on,
color: AppTheme.danger,
size: 40,
),
),
],
),
],
),
),
] else ...[
const SizedBox(height: 24),
const Center(
child: Text(
'Ingresa un código postal con servicio\npara asignar tu colonia.',
textAlign: TextAlign.center,
style: TextStyle(
color: AppTheme.textSecondary,
fontSize: 13,
),
),
),
],
],
),
),
const SizedBox(height: 32),
]),
),
),
],
),
bottomNavigationBar: _buildSaveButton(),
);
}
Widget _buildPageHeader(BuildContext context) {
return Container(
padding: EdgeInsets.fromLTRB(
20,
MediaQuery.of(context).padding.top + 12,
20,
24,
),
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF4A0E26), Color(0xFF9B1B4A)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(28),
bottomRight: Radius.circular(28),
),
),
child: Row(
children: [
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.arrow_back,
color: Colors.white,
size: 20,
),
),
const SizedBox(height: 24),
],
),
const SizedBox(width: 14),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Agregar dirección',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
SizedBox(height: 2),
Text(
'Registra tu domicilio de recolección',
style: TextStyle(fontSize: 13, color: Colors.white70),
),
],
),
),
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.add_home_outlined,
color: Colors.white,
size: 22,
),
),
],
),
);
}
Widget _buildSaveButton() {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _loading ? null : _guardar,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: _loading
? const SizedBox(
key: ValueKey('loading'),
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Row(
key: ValueKey('text'),
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check, size: 18),
SizedBox(width: 8),
Text('Guardar dirección'),
],
),
),
),
),
),
);

View File

@@ -26,7 +26,7 @@ class AddressMapCard extends StatelessWidget {
// Si existen coordenadas exactas las usa, de lo contrario cae al centro de la colonia
final center = (lat != null && lng != null)
? LatLng(lat!, lng!)
: kColoniaCenter(colonia);
: kColoniasCoordinates[colonia] ?? const LatLng(20.5222, -100.8123);
return Container(
margin: const EdgeInsets.only(bottom: 16),

View File

@@ -7,6 +7,7 @@ import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_widgets.dart';
import 'data/admin_service.dart';
import 'models/admin_driver.dart';
import 'models/admin_incident.dart';
import 'models/admin_route.dart';
import 'models/admin_unit.dart';
import 'models/admin_user.dart';
@@ -67,6 +68,23 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
}
}
Future<void> _handleSimulationTick() async {
try {
final events = await _service.simulationTick();
if (events.isEmpty) {
_snack('Tick enviado · sin eventos nuevos en esta posición');
} else {
final tipos = events
.map((e) => e['event']?.toString() ?? '?')
.toSet()
.join(', ');
_snack('Tick enviado · ${events.length} push FCM ($tipos)');
}
} catch (e) {
_snack('No se pudo avanzar la simulación: $e', error: true);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -74,6 +92,11 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
appBar: AppBar(
title: const Text('Panel de administración'),
actions: [
IconButton(
tooltip: 'Avanzar simulación (envía push FCM)',
icon: const Icon(Icons.play_circle_fill_rounded),
onPressed: _handleSimulationTick,
),
IconButton(
tooltip: 'Refrescar',
icon: const Icon(Icons.refresh),
@@ -101,7 +124,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
tabs: const [
Tab(text: 'Usuarios'),
Tab(text: 'Rutas'),
Tab(text: 'Camiones'),
Tab(text: 'Unidades'),
],
),
),
@@ -118,7 +141,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
? 'Nuevo usuario'
: _activeTab == 1
? 'Nueva ruta'
: 'Nuevo camión',
: 'Nueva unidad',
),
),
);
@@ -372,7 +395,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
DropdownButtonFormField<int?>(
initialValue: truckId,
decoration: InputDecoration(
labelText: 'Camión asignado',
labelText: 'Unidad asignada',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(
AppTheme.radiusMd,
@@ -445,7 +468,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
}
}
// ── Formulario camión (unit) ────────────────────────────────────────────────
// ── Formulario unidad ───────────────────────────────────────────────────────
Future<void> _showUnitForm({AdminUnitModel? unit}) async {
final isEdit = unit != null;
final idCtrl = TextEditingController(text: unit?.id.toString() ?? '');
@@ -463,7 +486,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
),
title: Text(isEdit ? 'Editar camión' : 'Nuevo camión'),
title: Text(isEdit ? 'Editar unidad' : 'Nueva unidad'),
content: Form(
key: formKey,
child: SingleChildScrollView(
@@ -561,7 +584,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
if (saved == true) {
ref.invalidate(adminUnitsProvider);
ref.invalidate(adminRoutesProvider);
_snack(isEdit ? 'Camión actualizado' : 'Camión creado');
_snack(isEdit ? 'Unidad actualizada' : 'Unidad creada');
}
}
@@ -774,7 +797,7 @@ class _RoutesTab extends ConsumerWidget {
style: const TextStyle(fontSize: 13),
),
Text(
'Camión: ${unit?.displayPlate ?? (r.truckId == null ? 'Sin asignar' : '#${r.truckId}')}',
'Unidad: ${unit?.displayPlate ?? (r.truckId == null ? 'Sin asignar' : '#${r.truckId}')}',
style: const TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
@@ -845,6 +868,7 @@ class _RoutesTab extends ConsumerWidget {
}
}
// ── Tab Unidades ──────────────────────────────────────────────────────────────
class _TrucksTab extends ConsumerWidget {
const _TrucksTab();
@@ -866,7 +890,7 @@ class _TrucksTab extends ConsumerWidget {
),
data: (units) {
if (units.isEmpty) {
return const _EmptyView('No hay camiones registrados.');
return const _EmptyView('No hay unidades registradas.');
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
@@ -892,6 +916,7 @@ class _TrucksTab extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Encabezado: placa + badge estado ─────────────────
Row(
children: [
Expanded(
@@ -907,6 +932,7 @@ class _TrucksTab extends ConsumerWidget {
],
),
const SizedBox(height: 6),
// ── Detalles ──────────────────────────────────────────
Text(
'ID: #${t.id}',
style: const TextStyle(
@@ -926,9 +952,21 @@ class _TrucksTab extends ConsumerWidget {
),
),
const SizedBox(height: 12),
// ── Botones de acción ─────────────────────────────────
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Ver incidencias
TextButton.icon(
onPressed: () => _showIncidentsSheet(context, ref, t),
icon: const Icon(Icons.warning_amber_rounded, size: 18),
label: const Text('Incidencias'),
style: TextButton.styleFrom(
foregroundColor: Colors.orange.shade700,
),
),
const SizedBox(width: 4),
// Editar
TextButton.icon(
onPressed: () {
final state = context
@@ -938,11 +976,12 @@ class _TrucksTab extends ConsumerWidget {
icon: const Icon(Icons.edit_outlined, size: 18),
label: const Text('Editar'),
),
const SizedBox(width: 8),
const SizedBox(width: 4),
// Eliminar
TextButton.icon(
onPressed: () => _confirmAndDelete(
context,
tipo: 'camión',
tipo: 'unidad',
onConfirm: () async {
await ref
.read(adminServiceProvider)
@@ -968,6 +1007,23 @@ class _TrucksTab extends ConsumerWidget {
);
}
// ── Abre el bottom sheet de incidencias ───────────────────────────────────
void _showIncidentsSheet(
BuildContext context,
WidgetRef ref,
AdminUnitModel unit,
) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: AppTheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => _IncidentsSheet(unit: unit),
);
}
Widget _unitStatusBadge(String status) {
switch (status) {
case 'inactive':
@@ -980,6 +1036,374 @@ class _TrucksTab extends ConsumerWidget {
}
}
// ── Bottom sheet de incidencias ───────────────────────────────────────────────
class _IncidentsSheet extends ConsumerStatefulWidget {
const _IncidentsSheet({required this.unit});
final AdminUnitModel unit;
@override
ConsumerState<_IncidentsSheet> createState() => _IncidentsSheetState();
}
class _IncidentsSheetState extends ConsumerState<_IncidentsSheet> {
@override
Widget build(BuildContext context) {
final async = ref.watch(adminIncidentsByUnitProvider(widget.unit.id));
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.6,
maxChildSize: 0.92,
builder: (_, controller) => Column(
children: [
// ── Handle ─────────────────────────────────────────────────
const SizedBox(height: 12),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 12),
// ── Header ─────────────────────────────────────────────────
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
const Icon(
Icons.warning_amber_rounded,
color: Colors.orange,
size: 22,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Incidencias — ${widget.unit.displayPlate}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
),
IconButton(
icon: const Icon(
Icons.add_circle_outline,
color: AppTheme.primary,
),
tooltip: 'Nueva incidencia',
onPressed: () => _showCreateIncidentDialog(context),
),
],
),
),
const Divider(height: 1),
// ── Lista ───────────────────────────────────────────────────
Expanded(
child: async.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.error_outline,
color: AppTheme.danger,
size: 40,
),
const SizedBox(height: 8),
Text(
e.toString(),
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () => ref.invalidate(
adminIncidentsByUnitProvider(widget.unit.id),
),
child: const Text('Reintentar'),
),
],
),
),
data: (incidents) {
if (incidents.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'Sin incidencias registradas.',
style: TextStyle(color: AppTheme.textSecondary),
),
),
);
}
return ListView.separated(
controller: controller,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 32),
itemCount: incidents.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (_, i) => _IncidentCard(incident: incidents[i]),
);
},
),
),
],
),
);
}
Future<void> _showCreateIncidentDialog(BuildContext context) async {
String type = 'otro';
final desc = TextEditingController();
final formKey = GlobalKey<FormState>();
final saved = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setStateDialog) => AlertDialog(
backgroundColor: AppTheme.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
),
title: const Text('Nueva incidencia'),
content: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<String>(
initialValue: type,
decoration: InputDecoration(
labelText: 'Categoría',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
),
),
items: const [
DropdownMenuItem(
value: 'derrame',
child: Text('💧 Derrame'),
),
DropdownMenuItem(
value: 'dano_propiedad',
child: Text('💥 Daño a propiedad'),
),
DropdownMenuItem(
value: 'conducta',
child: Text('😠 Conducta'),
),
DropdownMenuItem(
value: 'no_recoleccion',
child: Text('🗑 No recolección'),
),
DropdownMenuItem(value: 'otro', child: Text('📋 Otro')),
],
onChanged: (v) {
if (v != null) setStateDialog(() => type = v);
},
),
const SizedBox(height: 10),
TextFormField(
controller: desc,
maxLines: 3,
decoration: InputDecoration(
labelText: 'Descripción',
helperText: 'Mínimo 3 caracteres',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
),
),
validator: (v) {
final t = (v ?? '').trim();
if (t.length < 3) return 'Describe brevemente lo ocurrido';
return null;
},
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancelar'),
),
ElevatedButton(
onPressed: () async {
if (!(formKey.currentState?.validate() ?? false)) return;
try {
await ref
.read(adminServiceProvider)
.createIncident(
unitId: widget.unit.id,
type: type,
description: desc.text.trim(),
);
if (ctx.mounted) Navigator.pop(ctx, true);
} catch (e) {
if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Text('Error: $e'),
backgroundColor: AppTheme.danger,
),
);
}
}
},
child: const Text('Guardar'),
),
],
),
),
);
if (saved == true) {
ref.invalidate(adminIncidentsByUnitProvider(widget.unit.id));
}
}
}
// ── Tarjeta individual de incidencia ──────────────────────────────────────────
class _IncidentCard extends StatelessWidget {
const _IncidentCard({required this.incident});
final AdminIncidentModel incident;
@override
Widget build(BuildContext context) {
return AppCard(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_typeIcon(incident.type),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Tipo + fecha ──────────────────────────────────────
Row(
children: [
_typeBadge(incident.type),
const Spacer(),
Text(
_formatDate(incident.createdAt),
style: const TextStyle(
fontSize: 11,
color: AppTheme.textSecondary,
),
),
],
),
// ── Conductor ─────────────────────────────────────────
if (incident.driverName != null) ...[
const SizedBox(height: 6),
Row(
children: [
const Icon(
Icons.person_outline,
size: 14,
color: AppTheme.textSecondary,
),
const SizedBox(width: 4),
Expanded(
child: Text(
incident.driverName!,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
],
// ── Ruta ─────────────────────────────────────────────
if (incident.routeId != null) ...[
const SizedBox(height: 2),
Text(
'Ruta: ${incident.routeId}',
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
// ── Descripción ───────────────────────────────────────
if (incident.description != null &&
incident.description!.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
incident.description!,
style: const TextStyle(fontSize: 13),
),
],
],
),
),
],
),
);
}
Widget _typeIcon(String type) {
IconData icon;
Color color;
switch (type) {
case 'derrame':
icon = Icons.water_drop_outlined;
color = Colors.blue;
break;
case 'dano_propiedad':
icon = Icons.report_gmailerrorred_outlined;
color = AppTheme.danger;
break;
case 'conducta':
icon = Icons.sentiment_very_dissatisfied_outlined;
color = Colors.orange;
break;
case 'no_recoleccion':
icon = Icons.delete_forever_outlined;
color = Colors.deepOrange;
break;
default:
icon = Icons.info_outline;
color = AppTheme.textSecondary;
}
return Icon(icon, color: color, size: 22);
}
Widget _typeBadge(String type) {
switch (type) {
case 'derrame':
return AppStatusBadge.amber('Derrame');
case 'dano_propiedad':
return AppStatusBadge.danger('Daño');
case 'conducta':
return AppStatusBadge.amber('Conducta');
case 'no_recoleccion':
return AppStatusBadge.danger('No recolección');
default:
return AppStatusBadge.gray('Otro');
}
}
String _formatDate(DateTime dt) {
final d = dt.toLocal();
final day = d.day.toString().padLeft(2, '0');
final month = d.month.toString().padLeft(2, '0');
final hour = d.hour.toString().padLeft(2, '0');
final minute = d.minute.toString().padLeft(2, '0');
return '$day/$month/${d.year} $hour:$minute';
}
}
// ── Shared widgets ────────────────────────────────────────────────────────────
class _EmptyView extends StatelessWidget {
const _EmptyView(this.message);
@@ -1079,4 +1503,3 @@ void _confirmAndDelete(
}
// EOF

View File

@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/api_client.dart';
import '../models/admin_driver.dart';
import '../models/admin_incident.dart';
import '../models/admin_route.dart';
import '../models/admin_unit.dart';
import '../models/admin_user.dart';
@@ -188,4 +189,42 @@ class AdminService {
Future<void> deleteDriver(String id) async {
await _dio.delete<void>('/admin/drivers/$id');
}
// ── Incidents ────────────────────────────────────────────────────────────────
Future<List<AdminIncidentModel>> listIncidentsByUnit(int unitId) async {
final res = await _dio.get<List<dynamic>>('/admin/units/$unitId/incidents');
return (res.data ?? [])
.whereType<Map>()
.map((e) => AdminIncidentModel.fromJson(Map<String, dynamic>.from(e)))
.toList();
}
Future<AdminIncidentModel> createIncident({
required int unitId,
required String type,
String? description,
}) async {
final res = await _dio.post<Map<String, dynamic>>(
'/admin/units/$unitId/incidents',
data: {
'unit_id': unitId,
'type': type,
if (description != null && description.isNotEmpty)
'description': description,
},
);
return AdminIncidentModel.fromJson(res.data!);
}
// ── Simulación ──────────────────────────────────────────────────────────────
/// Avanza una vez la simulación (`positionId += 1` en todas las rutas) y
/// dispara los push FCM correspondientes. Devuelve los eventos disparados.
Future<List<Map<String, dynamic>>> simulationTick() async {
final res = await _dio.post<Map<String, dynamic>>('/simulation/tick');
final events = (res.data?['events'] as List?) ?? const [];
return events
.whereType<Map>()
.map((e) => Map<String, dynamic>.from(e))
.toList();
}
}

View File

@@ -0,0 +1,67 @@
// lib/features/admin/models/admin_incident.dart
class AdminIncidentModel {
final String id;
final int unitId;
final String? routeId;
// Mapea a `incidents.category` en la base de datos.
final String type;
final String? description;
final String? driverName;
final String status; // open | in_review | resolved
final String? photoUrl;
final DateTime createdAt;
const AdminIncidentModel({
required this.id,
required this.unitId,
this.routeId,
required this.type,
this.description,
this.driverName,
this.status = 'open',
this.photoUrl,
required this.createdAt,
});
factory AdminIncidentModel.fromJson(Map<String, dynamic> json) =>
AdminIncidentModel(
// El id en DB es BIGSERIAL; el backend lo serializa como string,
// pero por defensa aceptamos number también.
id: json['id'].toString(),
unitId: (json['unit_id'] as num).toInt(),
routeId: json['route_id'] as String?,
type: (json['type'] as String?) ?? 'otro',
description: json['description'] as String?,
driverName: json['driver_name'] as String?,
status: (json['status'] as String?) ?? 'open',
photoUrl: json['photo_url'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
);
String get typeLabel {
switch (type) {
case 'derrame':
return 'Derrame';
case 'dano_propiedad':
return 'Daño a propiedad';
case 'conducta':
return 'Conducta';
case 'no_recoleccion':
return 'No recolección';
default:
return 'Otro';
}
}
String get statusLabel {
switch (status) {
case 'in_review':
return 'En revisión';
case 'resolved':
return 'Resuelta';
default:
return 'Abierta';
}
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/admin_service.dart';
import '../models/admin_driver.dart';
import '../models/admin_incident.dart';
import '../models/admin_route.dart';
import '../models/admin_unit.dart';
import '../models/admin_user.dart';
@@ -21,3 +22,8 @@ final adminUnitsProvider = FutureProvider<List<AdminUnitModel>>((ref) {
final adminDriversProvider = FutureProvider<List<AdminDriverModel>>((ref) {
return ref.read(adminServiceProvider).listDrivers();
});
final adminIncidentsByUnitProvider =
FutureProvider.family<List<AdminIncidentModel>, int>((ref, unitId) {
return ref.read(adminServiceProvider).listIncidentsByUnit(unitId);
});

View File

@@ -0,0 +1,38 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
class AppAlert {
final String id;
final String title;
final String body;
final DateTime timestamp;
AppAlert({
required this.id,
required this.title,
required this.body,
required this.timestamp,
});
}
class AlertsNotifier extends Notifier<List<AppAlert>> {
@override
List<AppAlert> build() => [];
void addAlert(String title, String body) {
state = [
AppAlert(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: title,
body: body,
timestamp: DateTime.now(),
),
...state,
];
}
void clearAll() => state = [];
}
final alertsProvider = NotifierProvider<AlertsNotifier, List<AppAlert>>(
AlertsNotifier.new,
);

View File

@@ -1,218 +1,65 @@
import 'package:flutter/material.dart';
import '../../core/theme/app_theme.dart';
import '../../core/models/ui_models.dart';
import '../../core/widgets/app_widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class AlertsScreen extends StatefulWidget {
import '../../core/theme/app_theme.dart';
import 'alerts_provider.dart';
class AlertsScreen extends ConsumerWidget {
const AlertsScreen({super.key});
@override
State<AlertsScreen> createState() => _AlertsScreenState();
}
Widget build(BuildContext context, WidgetRef ref) {
final alerts = ref.watch(alertsProvider);
class _AlertsScreenState extends State<AlertsScreen> {
final UIAlertaModel _alertaActiva = UIAlertaModel(
id: 'alerta-001',
tipo: TipoAlerta.cercana,
distanciaMetros: 180,
fecha: DateTime.now(),
direccionCasa: 'Av. Insurgentes 245',
leida: false,
);
final List<UIAlertaModel> _historial = [
UIAlertaModel(
id: 'h-001',
tipo: TipoAlerta.cercana,
distanciaMetros: 200,
fecha: DateTime.now().subtract(const Duration(hours: 1)),
direccionCasa: 'Av. Insurgentes 245',
leida: true,
),
UIAlertaModel(
id: 'h-002',
tipo: TipoAlerta.cercana,
distanciaMetros: 200,
fecha: DateTime.now().subtract(const Duration(days: 2, hours: 2)),
direccionCasa: 'Av. Insurgentes 245',
leida: true,
),
UIAlertaModel(
id: 'h-003',
tipo: TipoAlerta.cercana,
distanciaMetros: 200,
fecha: DateTime.now().subtract(
const Duration(days: 4, hours: 1, minutes: 30)),
direccionCasa: 'Av. Insurgentes 245',
leida: true,
),
UIAlertaModel(
id: 'h-004',
tipo: TipoAlerta.cercana,
distanciaMetros: 200,
fecha: DateTime.now().subtract(const Duration(days: 7, hours: 3)),
direccionCasa: 'Av. Insurgentes 245',
leida: true,
),
];
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.background,
appBar: AppBar(
title: const Text('Alertas'),
title: const Text('Mis Alertas'),
actions: [
TextButton(
onPressed: () {},
child: const Text('Limpiar',
style: TextStyle(color: Colors.white, fontSize: 13)),
),
if (alerts.isNotEmpty)
IconButton(
icon: const Icon(Icons.delete_sweep_outlined),
tooltip: 'Limpiar historial',
onPressed: () => ref.read(alertsProvider.notifier).clearAll(),
),
],
),
body: RefreshIndicator(
color: AppTheme.primary,
onRefresh: () async =>
Future.delayed(const Duration(milliseconds: 800)),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_AlertaActivaCard(alerta: _alertaActiva),
const SizedBox(height: 20),
if (_historial.isEmpty)
const _EmptyState()
else ...[
const AppSectionTitle(title: 'Historial de alertas'),
..._historial.map((a) => _AlertaHistorialItem(alerta: a)),
],
],
),
),
body: alerts.isEmpty
? _buildEmptyState()
: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: alerts.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
return _AlertCard(alert: alerts[index]);
},
),
);
}
}
// ── Alerta activa ─────────────────────────────────────────────────────────────
class _AlertaActivaCard extends StatelessWidget {
final UIAlertaModel alerta;
const _AlertaActivaCard({required this.alerta});
@override
Widget build(BuildContext context) {
final progreso = (1 - (alerta.distanciaMetros / 400)).clamp(0.0, 1.0);
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
border: Border.all(color: AppTheme.primaryMid),
boxShadow: AppTheme.softShadow,
),
Widget _buildEmptyState() {
return const Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppTheme.primary,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.notifications_active,
color: Colors.white, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('¡El camión está cerca!',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppTheme.primaryDark)),
Text(alerta.fechaFormateada,
style: const TextStyle(
fontSize: 12, color: AppTheme.primary)),
],
),
),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.primary,
borderRadius: BorderRadius.circular(20),
),
child: const Text('Ahora',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.white)),
),
],
Icon(
Icons.notifications_off_outlined,
size: 64,
color: AppTheme.textSecondary,
),
const SizedBox(height: 16),
const Text('El camión se encuentra a',
style: TextStyle(fontSize: 13, color: AppTheme.primaryDark)),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
alerta.distanciaTexto,
style: const TextStyle(
fontSize: 36,
fontWeight: FontWeight.w700,
color: AppTheme.primary,
height: 1.1),
),
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Text('de tu casa en ${alerta.direccionCasa}',
style: const TextStyle(
fontSize: 13, color: AppTheme.primaryDark)),
),
],
),
const SizedBox(height: 14),
Row(
children: [
const Text('Llegada estimada:',
style:
TextStyle(fontSize: 12, color: AppTheme.primaryDark)),
const SizedBox(width: 6),
Text(alerta.tiempoEstimadoTexto,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppTheme.primary)),
],
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: progreso,
backgroundColor:
AppTheme.primaryMid.withValues(alpha: 0.4),
valueColor:
const AlwaysStoppedAnimation<Color>(AppTheme.primary),
minHeight: 7,
SizedBox(height: 16),
Text(
'No tienes notificaciones nuevas',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 4),
const Row(
children: [
Text('Lejos',
style: TextStyle(fontSize: 10, color: AppTheme.primary)),
Spacer(),
Text('Tu casa',
style: TextStyle(fontSize: 10, color: AppTheme.primary)),
],
SizedBox(height: 8),
Text(
'Aquí aparecerán los avisos del camión.',
style: TextStyle(fontSize: 14, color: AppTheme.textSecondary),
),
],
),
@@ -220,108 +67,77 @@ class _AlertaActivaCard extends StatelessWidget {
}
}
// ── Ítem de historial ─────────────────────────────────────────────────────────
class _AlertaHistorialItem extends StatelessWidget {
final UIAlertaModel alerta;
const _AlertaHistorialItem({required this.alerta});
class _AlertCard extends StatelessWidget {
final AppAlert alert;
const _AlertCard({required this.alert});
String _timeAgo(DateTime date) {
final diff = DateTime.now().difference(date);
if (diff.inMinutes < 1) return 'Justo ahora';
if (diff.inHours < 1) return 'Hace ${diff.inMinutes} min';
if (diff.inDays < 1) return 'Hace ${diff.inHours} h';
return 'Hace ${diff.inDays} d';
}
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
border: Border.all(color: AppTheme.border, width: 0.5),
boxShadow: AppTheme.softShadow,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppTheme.background,
borderRadius: BorderRadius.circular(10),
padding: const EdgeInsets.all(10),
decoration: const BoxDecoration(
color: AppTheme.primaryLight,
shape: BoxShape.circle,
),
child: const Icon(
Icons.notifications_active_outlined,
color: AppTheme.primary,
size: 24,
),
child: const Icon(Icons.notifications_outlined,
color: AppTheme.textSecondary, size: 18),
),
const SizedBox(width: 12),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Camión a ${alerta.distanciaTexto}',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary)),
const SizedBox(height: 2),
Text(alerta.fechaFormateada,
style: const TextStyle(
fontSize: 12, color: AppTheme.textSecondary)),
Text(
alert.title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppTheme.primaryDark,
),
),
const SizedBox(height: 4),
Text(
alert.body,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textPrimary,
height: 1.4,
),
),
const SizedBox(height: 8),
Text(
_timeAgo(alert.timestamp),
style: const TextStyle(
fontSize: 12,
color: AppTheme.textHint,
),
),
],
),
),
_EtiquetaDia(texto: alerta.etiquetaFecha),
],
),
);
}
}
class _EtiquetaDia extends StatelessWidget {
final String texto;
const _EtiquetaDia({required this.texto});
@override
Widget build(BuildContext context) {
final esHoy = texto == 'Hoy';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: esHoy ? AppTheme.primaryLight : AppTheme.background,
borderRadius: BorderRadius.circular(20),
),
child: Text(
texto,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: esHoy ? AppTheme.primaryDark : AppTheme.textSecondary,
),
),
);
}
}
// ── Sin alertas ───────────────────────────────────────────────────────────────
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 60),
child: Column(
children: [
Icon(Icons.notifications_outlined,
color: AppTheme.primary, size: 48),
SizedBox(height: 16),
Text('Sin alertas por ahora',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary)),
SizedBox(height: 6),
Text(
'Te notificaremos cuando el camión\nesté cerca de tu casa.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13, color: AppTheme.textSecondary, height: 1.5),
),
],
),
);

View File

@@ -145,6 +145,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
key: ValueKey('loading'),
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
VideoMascot(size: 34, zoom: 1.5),
@@ -227,7 +228,7 @@ class _GreenHeader extends StatelessWidget {
begin: Alignment.topLeft,
end: Alignment.bottomRight,
stops: [0.0, 0.6, 1.0],
colors: [Color(0xFF0A4A38), Color(0xFF0F6E56), Color(0xFF1D9E75)],
colors: [Color(0xFF4A0E26), Color(0xFF6D1234), Color(0xFF9B1B4A)],
),
),
child: SafeArea(

File diff suppressed because it is too large Load Diff

View File

@@ -23,9 +23,9 @@ class VideoMascot extends StatelessWidget {
'assets/animations/blink_saludo.gif',
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
// Plan B: si el archivo no existe o hay error, mostramos la huellita
// Plan B: si el archivo no existe o hay error, mostramos el bote
return const Center(
child: Icon(Icons.pets, color: Colors.white, size: 48),
child: Icon(Icons.delete_outline, color: Colors.white, size: 48),
);
},
),

View File

@@ -123,6 +123,12 @@ class _EtaNotifier extends AsyncNotifier<_EtaResult> {
if (items.isEmpty) return const _EtaResult.noAddress();
final addressId = items.first['id'] as String;
final routeId = items.first['route_id'] as String?;
// Publica el routeId activo para que `_FcmStatusBadge` deje de mostrar
// "suscribiendo..." y refleje el topic real al que está suscrito el cliente.
Future.microtask(() {
ref.read(activeRouteIdProvider.notifier).set(routeId);
});
final etaResp = await dio.get<dynamic>(
'/eta',
queryParameters: {'address_id': addressId},
@@ -149,6 +155,10 @@ final etaProvider = AsyncNotifierProvider<_EtaNotifier, _EtaResult>(
class ActiveRouteIdNotifier extends Notifier<String?> {
@override
String? build() => null;
void set(String? value) {
if (state != value) state = value;
}
}
final activeRouteIdProvider = NotifierProvider<ActiveRouteIdNotifier, String?>(
@@ -312,7 +322,11 @@ class _EcoChatBanner extends StatelessWidget {
color: Colors.white24,
shape: BoxShape.circle,
),
child: const Icon(Icons.pets, color: Colors.white, size: 28),
child: const Icon(
Icons.delete_outline,
color: Colors.white,
size: 28,
),
),
const SizedBox(width: 16),
const Expanded(
@@ -408,14 +422,14 @@ class _EtaHeroCard extends StatelessWidget {
Color _bgColor(BuildContext context) {
final cs = Theme.of(context).colorScheme;
if (result.isCompleted) return cs.surfaceContainerHighest;
if (result.isNearby) return const Color(0xFFFFF8E1); // amber-50
return const Color(0xFFE1F5EE); // teal-50
if (result.isNearby) return const Color(0xFFF5EDD8); // beige dorado claro
return const Color(0xFFE8D5DB); // rosa claro institucional
}
Color _accentColor(BuildContext context) {
if (result.isCompleted) return Theme.of(context).colorScheme.outline;
if (result.isNearby) return const Color(0xFFBA7517); // amber-400
return const Color(0xFF1D9E75); // teal-400
if (result.isNearby) return const Color(0xFFC8A36A); // beige dorado
return const Color(0xFF9B1B4A); // vino principal
}
@override
@@ -693,7 +707,7 @@ class _FcmStatusBadge extends ConsumerWidget {
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Color(0xFF1D9E75),
color: Color(0xFF1E7A46),
shape: BoxShape.circle,
),
),

View File

@@ -0,0 +1,112 @@
// lib/features/feedback/feedback_provider.dart
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/network/api_client.dart';
import 'feedback_model.dart';
// ──────────────────────────────────────────
// Service
// ──────────────────────────────────────────
class FeedbackService {
final Dio _dio;
FeedbackService(this._dio);
Future<void> submit(FeedbackRequest req) async {
await _dio.post<void>('/feedback', data: req.toJson());
}
}
final feedbackServiceProvider = Provider<FeedbackService>(
(ref) => FeedbackService(ref.read(apiClientProvider)),
);
// ──────────────────────────────────────────
// Estado del formulario
// ──────────────────────────────────────────
enum FeedbackFormStatus { idle, loading, success, error }
class FeedbackFormState {
final FeedbackType selectedType;
final int rating;
final String message;
final FeedbackFormStatus status;
final String? errorMessage;
const FeedbackFormState({
this.selectedType = FeedbackType.noPaso,
this.rating = 3,
this.message = '',
this.status = FeedbackFormStatus.idle,
this.errorMessage,
});
FeedbackFormState copyWith({
FeedbackType? selectedType,
int? rating,
String? message,
FeedbackFormStatus? status,
String? errorMessage,
}) {
return FeedbackFormState(
selectedType: selectedType ?? this.selectedType,
rating: rating ?? this.rating,
message: message ?? this.message,
status: status ?? this.status,
errorMessage: errorMessage ?? this.errorMessage,
);
}
}
// ──────────────────────────────────────────
// Notifier
// ──────────────────────────────────────────
class FeedbackNotifier extends Notifier<FeedbackFormState> {
@override
FeedbackFormState build() => const FeedbackFormState();
void setType(FeedbackType type) => state = state.copyWith(selectedType: type);
void setRating(int r) => state = state.copyWith(rating: r);
void setMessage(String m) => state = state.copyWith(message: m);
void reset() => state = const FeedbackFormState();
Future<void> submit({
required String addressId,
required String unitId,
}) async {
state = state.copyWith(status: FeedbackFormStatus.loading);
try {
final req = FeedbackRequest(
addressId: addressId,
type: state.selectedType,
rating: state.rating,
message: state.message,
targetUnitId: unitId,
);
await ref.read(feedbackServiceProvider).submit(req);
state = state.copyWith(status: FeedbackFormStatus.success);
} on DioException catch (e) {
final data = e.response?.data;
final detail = (data is Map && data['detail'] != null)
? data['detail'].toString()
: null;
state = state.copyWith(
status: FeedbackFormStatus.error,
errorMessage: detail ?? e.message ?? 'Error al enviar',
);
} catch (e) {
state = state.copyWith(
status: FeedbackFormStatus.error,
errorMessage: 'Error al enviar: $e',
);
}
}
}
final feedbackProvider = NotifierProvider<FeedbackNotifier, FeedbackFormState>(
FeedbackNotifier.new,
);

View File

@@ -1,17 +1,564 @@
import 'package:flutter/material.dart';
// lib/features/feedback/feedback_screen.dart
// Buzón de retroalimentación. Expone "Unidad 101", nunca el nombre del chofer.
class FeedbackScreen extends StatelessWidget {
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/app_theme.dart';
import '../eta/eta_provider.dart'; // activeAddressIdProvider
import '../incidents/providers/incident_providers.dart'; // assignedUnitProvider
import 'feedback_model.dart';
import 'feedback_provider.dart';
class FeedbackScreen extends ConsumerStatefulWidget {
const FeedbackScreen({super.key});
@override
ConsumerState<FeedbackScreen> createState() => _FeedbackScreenState();
}
class _FeedbackScreenState extends ConsumerState<FeedbackScreen> {
final _messageController = TextEditingController();
@override
void dispose() {
_messageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final formState = ref.watch(feedbackProvider);
if (formState.status == FeedbackFormStatus.success) {
return _SuccessView(
onReset: () {
ref.read(feedbackProvider.notifier).reset();
_messageController.clear();
},
);
}
return Scaffold(
appBar: AppBar(title: const Text('Retroalimentación')),
body: const Center(
child: Text(
'TODO: Feedback Screen - Formulario de queja hacia la unidad',
backgroundColor: AppTheme.background,
body: CustomScrollView(
slivers: [
SliverToBoxAdapter(child: _buildPageHeader(context)),
SliverPadding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
sliver: SliverList(
delegate: SliverChildListDelegate([
// Tipo de reporte
_SectionLabel('Tipo de reporte'),
const SizedBox(height: 10),
_TypeChips(
selected: formState.selectedType,
onSelect: (t) =>
ref.read(feedbackProvider.notifier).setType(t),
),
const SizedBox(height: 24),
// Rating
_SectionLabel('Calificación del servicio'),
const SizedBox(height: 12),
Center(
child: _StarRating(
rating: formState.rating,
onRate: (r) =>
ref.read(feedbackProvider.notifier).setRating(r),
),
),
const SizedBox(height: 24),
// Unidad (sin exponer chofer)
_SectionLabel('Unidad involucrada'),
const SizedBox(height: 10),
const _UnitBadge(),
const SizedBox(height: 24),
// Mensaje libre
_SectionLabel('Descripción (opcional)'),
const SizedBox(height: 10),
Container(
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
border: Border.all(color: AppTheme.border),
boxShadow: AppTheme.softShadow,
),
child: TextField(
controller: _messageController,
maxLines: 4,
maxLength: 300,
onChanged: (v) =>
ref.read(feedbackProvider.notifier).setMessage(v),
style: const TextStyle(
fontSize: 14,
color: AppTheme.textPrimary,
),
decoration: const InputDecoration(
hintText: 'Cuéntanos qué pasó...',
border: InputBorder.none,
contentPadding: EdgeInsets.all(14),
),
),
),
const SizedBox(height: 12),
// Error
if (formState.status == FeedbackFormStatus.error)
_ErrorBanner(
message: formState.errorMessage ?? 'Error',
),
const SizedBox(height: 32),
]),
),
),
],
),
bottomNavigationBar: _buildSubmitButton(context, formState, ref),
);
}
Widget _buildPageHeader(BuildContext context) {
return Container(
padding: EdgeInsets.fromLTRB(
20,
MediaQuery.of(context).padding.top + 12,
20,
24,
),
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF4A0E26), Color(0xFF9B1B4A)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(28),
bottomRight: Radius.circular(28),
),
),
child: Row(
children: [
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.arrow_back,
color: Colors.white,
size: 20,
),
),
),
const SizedBox(width: 14),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Buzón de retroalimentación',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
SizedBox(height: 2),
Text(
'Tu opinión mejora el servicio',
style: TextStyle(fontSize: 13, color: Colors.white70),
),
],
),
),
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.rate_review_outlined,
color: Colors.white,
size: 22,
),
),
],
),
);
}
Widget _buildSubmitButton(
BuildContext context,
FeedbackFormState formState,
WidgetRef ref,
) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
child: SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: formState.status == FeedbackFormStatus.loading
? null
: () {
final addressId = ref.read(activeAddressIdProvider);
final unit = ref.read(assignedUnitProvider).value;
if (addressId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Selecciona una dirección activa antes de enviar.',
),
),
);
return;
}
if (unit == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Tu zona aún no tiene una unidad asignada.',
),
),
);
return;
}
ref.read(feedbackProvider.notifier).submit(
addressId: addressId,
unitId: unit.id.toString(),
);
},
child: formState.status == FeedbackFormStatus.loading
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Enviar reporte'),
),
),
),
);
}
}
// ── Chips de tipo ─────────────────────────────────────────────────────────────
class _TypeChips extends StatelessWidget {
final FeedbackType selected;
final ValueChanged<FeedbackType> onSelect;
const _TypeChips({required this.selected, required this.onSelect});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: FeedbackType.values.map((t) {
final isSelected = t == selected;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(t.label),
selected: isSelected,
onSelected: (_) => onSelect(t),
selectedColor: AppTheme.primaryLight,
backgroundColor: AppTheme.surface,
side: BorderSide(
color: isSelected ? AppTheme.primary : AppTheme.border,
width: isSelected ? 1.5 : 0.5,
),
labelStyle: TextStyle(
color: isSelected ? AppTheme.primaryDark : AppTheme.textSecondary,
fontSize: 13,
fontWeight:
isSelected ? FontWeight.w600 : FontWeight.normal,
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
);
}).toList(),
),
);
}
}
// ── Stars ─────────────────────────────────────────────────────────────────────
class _StarRating extends StatelessWidget {
final int rating;
final ValueChanged<int> onRate;
const _StarRating({required this.rating, required this.onRate});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(5, (i) {
final filled = i < rating;
return GestureDetector(
onTap: () => onRate(i + 1),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Icon(
filled ? Icons.star_rounded : Icons.star_outline_rounded,
size: 42,
color: filled ? const Color(0xFFEF9F27) : AppTheme.border,
),
),
);
}),
);
}
}
// ── Badge de unidad (sin exponer chofer) ──────────────────────────────────────
class _UnitBadge extends ConsumerWidget {
const _UnitBadge();
@override
Widget build(BuildContext context, WidgetRef ref) {
final unitAsync = ref.watch(assignedUnitProvider);
final unitLabel = unitAsync.when(
loading: () => 'Detectando unidad…',
error: (_, _) => 'Unidad no disponible',
data: (u) => u == null ? 'Sin unidad asignada' : 'Unidad ${u.id}',
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
border: Border.all(color: AppTheme.primaryMid),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.local_shipping_outlined,
size: 18,
color: AppTheme.primaryDark,
),
const SizedBox(width: 8),
Text(
unitLabel,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppTheme.primaryDark,
),
),
],
),
),
const SizedBox(height: 8),
Row(
children: [
const Icon(
Icons.shield_outlined,
size: 13,
color: AppTheme.textSecondary,
),
const SizedBox(width: 4),
const Expanded(
child: Text(
'Solo se registra el número de unidad. El operador no es identificado.',
style: TextStyle(
fontSize: 11,
color: AppTheme.textSecondary,
),
),
),
],
),
],
);
}
}
// ── Error banner ──────────────────────────────────────────────────────────────
class _ErrorBanner extends StatelessWidget {
final String message;
const _ErrorBanner({required this.message});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.dangerLight,
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
border: Border.all(color: AppTheme.danger.withValues(alpha: 0.3)),
),
child: Row(
children: [
const Icon(Icons.error_outline, size: 16, color: AppTheme.danger),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: const TextStyle(
fontSize: 12,
color: AppTheme.danger,
),
),
),
],
),
);
}
}
// ── Success view ──────────────────────────────────────────────────────────────
class _SuccessView extends StatelessWidget {
final VoidCallback onReset;
const _SuccessView({required this.onReset});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.background,
body: Column(
children: [
Container(
padding: EdgeInsets.fromLTRB(
20,
MediaQuery.of(context).padding.top + 12,
20,
24,
),
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF4A0E26), Color(0xFF9B1B4A)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(28),
bottomRight: Radius.circular(28),
),
),
child: Row(
children: [
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.arrow_back,
color: Colors.white,
size: 20,
),
),
),
const SizedBox(width: 14),
const Text(
'Buzón de retroalimentación',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
],
),
),
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 72,
height: 72,
decoration: const BoxDecoration(
color: AppTheme.primaryLight,
shape: BoxShape.circle,
),
child: const Icon(
Icons.check_circle_outline_rounded,
size: 40,
color: AppTheme.primary,
),
),
const SizedBox(height: 20),
const Text(
'Reporte enviado',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 10),
const Text(
'Gracias. Tu retroalimentación ayuda a mejorar el servicio. El reporte fue registrado de forma anónima.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
height: 1.5,
),
),
const SizedBox(height: 28),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: onReset,
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.primary,
side: const BorderSide(color: AppTheme.primary),
),
child: const Text('Enviar otro reporte'),
),
),
],
),
),
),
),
],
),
);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
class _SectionLabel extends StatelessWidget {
final String text;
const _SectionLabel(this.text);
@override
Widget build(BuildContext context) {
return Text(
text.toUpperCase(),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppTheme.textSecondary,
letterSpacing: 0.8,
),
);
}
}

View File

@@ -48,20 +48,14 @@ class _HelpFaqScreenState extends ConsumerState<HelpFaqScreen> {
return Scaffold(
backgroundColor: AppTheme.background,
appBar: AppBar(
title: const Text('Ayuda y preguntas frecuentes'),
actions: [
IconButton(
tooltip: 'Reiniciar conversación',
icon: const Icon(Icons.refresh),
onPressed: state.messages.isEmpty
body: Column(
children: [
_GradientHeader(
hasMessages: state.messages.isNotEmpty,
onReset: state.messages.isEmpty
? null
: () => ref.read(helpChatControllerProvider.notifier).reset(),
),
],
),
body: Column(
children: [
if (state.messages.isEmpty) _QuickQuestions(onSelect: _send),
Expanded(
child: state.messages.isEmpty
@@ -80,7 +74,7 @@ class _HelpFaqScreenState extends ConsumerState<HelpFaqScreen> {
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
color: AppTheme.danger.withOpacity(0.1),
color: AppTheme.danger.withValues(alpha: 0.1),
child: Text(
state.error!,
style: const TextStyle(color: AppTheme.danger, fontSize: 13),
@@ -97,20 +91,120 @@ class _HelpFaqScreenState extends ConsumerState<HelpFaqScreen> {
}
}
class _GradientHeader extends StatelessWidget {
final bool hasMessages;
final VoidCallback? onReset;
const _GradientHeader({required this.hasMessages, this.onReset});
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
stops: [0.0, 0.6, 1.0],
colors: [Color(0xFF4A0E26), Color(0xFF6D1234), Color(0xFF9B1B4A)],
),
),
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded,
color: Colors.white, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
const Spacer(),
if (hasMessages)
IconButton(
tooltip: 'Reiniciar conversación',
icon: const Icon(Icons.refresh_rounded,
color: Colors.white, size: 20),
onPressed: onReset,
),
],
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: const Icon(Icons.support_agent_rounded,
color: Colors.white, size: 22),
),
const SizedBox(width: 14),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Ayuda y soporte',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
Text(
'Pregunta lo que necesites',
style: TextStyle(
fontSize: 12,
color: Colors.white.withValues(alpha: 0.75),
),
),
],
),
],
),
),
],
),
),
),
);
}
}
class _QuickQuestions extends StatelessWidget {
final ValueChanged<String> onSelect;
const _QuickQuestions({required this.onSelect});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant, width: 0.5),
),
),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final q in _quickQuestions)
ActionChip(label: Text(q), onPressed: () => onSelect(q)),
ActionChip(
label: Text(q, style: const TextStyle(fontSize: 12)),
onPressed: () => onSelect(q),
backgroundColor: AppTheme.primaryLight,
side: const BorderSide(color: AppTheme.primary, width: 0.5),
labelStyle: const TextStyle(color: AppTheme.primaryDark),
),
],
),
);

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -11,6 +12,7 @@ import '../../core/network/api_client.dart';
import '../notifications/notification_service.dart';
import '../../shared/widgets/prevention_banner.dart';
import '../../shared/widgets/progress_steps.dart';
import '../separation_guide/ai_pet_chat_screen.dart';
// ─────────────────────────────────────────────────────────────────────────────
// Modelo de resultado ETA
@@ -75,11 +77,19 @@ class _EtaResult {
// Provider de ETA
// ─────────────────────────────────────────────────────────────────────────────
class _EtaNotifier extends AsyncNotifier<_EtaResult> {
Timer? _timer;
@override
Future<_EtaResult> build() => _fetch();
Future<_EtaResult> build() {
// Consulta silenciosa cada 10 segundos para ver el avance en tiempo real
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 10), (_) => refresh());
ref.onDispose(() => _timer?.cancel());
return _fetch();
}
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(_fetch);
}
@@ -127,6 +137,20 @@ final etaProvider = AsyncNotifierProvider<_EtaNotifier, _EtaResult>(
class ActiveRouteIdNotifier extends Notifier<String?> {
@override
String? build() => null;
void set(String? value) {
debugPrint('📡 [FCM] Evaluando suscripción a la ruta: $value');
if (state != value) {
final oldRoute = state;
state = value;
if (oldRoute != null) NotificationService.unsubscribeFromRoute(oldRoute);
if (value != null) {
NotificationService.subscribeToRoute(
value,
).catchError((e) => debugPrint('❌ Error FCM: $e'));
}
}
}
}
final activeRouteIdProvider = NotifierProvider<ActiveRouteIdNotifier, String?>(
@@ -149,7 +173,7 @@ class _CitizenHomeScreenState extends ConsumerState<CitizenHomeScreen>
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
// Refresca al recibir push FCM (RUTA_PROXIMITY, ROUTE_START, etc.)
// Refresca al recibir push FCM (RUTA_PROXIMITY, ROUTE_START, etc.)F
NotificationService.onFcmMessage.addListener(_onPush);
}
@@ -242,11 +266,15 @@ class _EtaContent extends StatelessWidget {
const PreventionBanner(),
const SizedBox(height: 12),
// ── 5. Badge de suscripción FCM ─────────────────────────────────
// ── 5. Banner del Chat IA (Eco) ─────────────────────────────────
const _EcoChatBanner(),
const SizedBox(height: 12),
// ── 6. Badge de suscripción FCM ─────────────────────────────────
const _FcmStatusBadge(),
const SizedBox(height: 16),
// ── 6. Horario semanal ──────────────────────────────────────────
// ── 7. Horario semanal ──────────────────────────────────────────
AppSectionTitle(title: 'Horario del camión'),
_HorarioCard(),
const SizedBox(height: 24),
@@ -256,6 +284,71 @@ class _EtaContent extends StatelessWidget {
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Banner de Eco (Chat IA)
// ─────────────────────────────────────────────────────────────────────────────
class _EcoChatBanner extends StatelessWidget {
const _EcoChatBanner();
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AiPetChatScreen()),
);
},
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.primaryDark,
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
boxShadow: AppTheme.softShadow,
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: const BoxDecoration(
color: Colors.white24,
shape: BoxShape.circle,
),
child: const Icon(
Icons.delete_outline,
color: Colors.white,
size: 28,
),
),
const SizedBox(width: 16),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'¿Dudas sobre reciclaje?',
style: TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 4),
Text(
'Pregúntale a Eco, tu asistente inteligente',
style: TextStyle(color: Colors.white70, fontSize: 13),
),
],
),
),
const Icon(Icons.chevron_right, color: Colors.white),
],
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Mapa de ubicación del domicilio (no interactivo)
// ─────────────────────────────────────────────────────────────────────────────
@@ -322,13 +415,13 @@ class _EtaHeroCard extends StatelessWidget {
final cs = Theme.of(context).colorScheme;
if (result.isCompleted) return cs.surfaceContainerHighest;
if (result.isNearby) return const Color(0xFFFFF8E1); // amber-50
return const Color(0xFFE1F5EE); // teal-50
return const Color(0xFFE8D5DB); // rosa claro institucional
}
Color _accentColor(BuildContext context) {
if (result.isCompleted) return Theme.of(context).colorScheme.outline;
if (result.isNearby) return const Color(0xFFBA7517); // amber-400
return const Color(0xFF1D9E75); // teal-400
if (result.isNearby) return const Color(0xFFC8A36A); // beige dorado
return const Color(0xFF9B1B4A); // vino principal
}
@override
@@ -606,7 +699,7 @@ class _FcmStatusBadge extends ConsumerWidget {
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Color(0xFF1D9E75),
color: Color(0xFF1E7A46),
shape: BoxShape.circle,
),
),

View File

@@ -1,13 +1,95 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_widgets.dart';
import '../../shared/widgets/eco_floating_button.dart';
import '../notifications/notification_service.dart';
import '../alerts/alerts_provider.dart';
class CitizenShell extends StatelessWidget {
class CitizenShell extends ConsumerStatefulWidget {
const CitizenShell({super.key, required this.child});
final Widget child;
@override
ConsumerState<CitizenShell> createState() => _CitizenShellState();
}
class _CitizenShellState extends ConsumerState<CitizenShell> {
@override
void initState() {
super.initState();
NotificationService.onFcmMessage.addListener(_onGlobalPush);
}
@override
void dispose() {
NotificationService.onFcmMessage.removeListener(_onGlobalPush);
super.dispose();
}
void _onGlobalPush() {
final msg = NotificationService.onFcmMessage.lastMessage;
if (msg?.notification != null) {
final title = msg!.notification!.title ?? 'Nueva Alerta';
final body = msg.notification!.body ?? '';
// Guardamos la alerta en el historial
ref.read(alertsProvider.notifier).addAlert(title, body);
// Mostramos un globo de notificación amigable dentro de la app
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: AppTheme.primaryDark,
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.all(16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
duration: const Duration(seconds: 5),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
Icons.notifications_active,
color: Colors.white,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
],
),
const SizedBox(height: 4),
Text(
body,
style: const TextStyle(color: Colors.white70, fontSize: 13),
),
],
),
action: SnackBarAction(
label: 'VER',
textColor: AppTheme.primaryLight,
onPressed: () => context.go('/alerts'),
),
),
);
}
}
}
int _currentIndex(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation;
if (location.startsWith('/alerts')) return 1;
@@ -32,7 +114,7 @@ class CitizenShell extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: child,
body: widget.child,
floatingActionButton: const EcoFloatingButton(),
bottomNavigationBar: AppBottomNav(
currentIndex: _currentIndex(context),

View File

@@ -75,95 +75,170 @@ class _MyHouseScreenState extends State<MyHouseScreen> {
if (_casa == null) {
return Scaffold(
backgroundColor: AppTheme.background,
appBar: AppBar(title: const Text('Mi casa')),
body: const Center(child: Text('No tienes un domicilio registrado.')),
body: Column(
children: [
_buildPageHeader(context, showEdit: false),
const Expanded(
child: Center(
child: Text(
'No tienes un domicilio registrado.',
style: TextStyle(fontSize: 15, color: AppTheme.textSecondary),
),
),
),
_buildAddressButton(context),
],
),
);
}
return Scaffold(
backgroundColor: AppTheme.background,
appBar: AppBar(
title: const Text('Mi casa'),
actions: [
IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () => _mostrarEditarDireccion(context),
tooltip: 'Editar dirección',
body: CustomScrollView(
slivers: [
SliverToBoxAdapter(child: _buildPageHeader(context, showEdit: true)),
SliverPadding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
sliver: SliverList(
delegate: SliverChildListDelegate([
const AppSectionTitle(title: 'Domicilio registrado'),
_CasaCard(casa: _casa!),
const SizedBox(height: 20),
const AppSectionTitle(title: 'Mapa del Sector (Restringido)'),
_MapaColoniaRestringido(
colonia: _casa!.colonia,
lat: _casa!.lat,
lng: _casa!.lng,
),
const SizedBox(height: 20),
const AppSectionTitle(title: 'Radio de alerta'),
_RadioAlertaCard(
radioActual: _casa!.radioAlertaMetros,
onChanged: (v) => setState(
() => _casa = _casa!.copyWith(radioAlertaMetros: v),
),
),
const SizedBox(height: 20),
const AppSectionTitle(title: 'Notificaciones'),
_NotificacionesCard(
casa: _casa!,
onAlertaCercanaChanged: (v) =>
setState(() => _casa = _casa!.copyWith(alertaCercana: v)),
onAlertaMediaChanged: (v) =>
setState(() => _casa = _casa!.copyWith(alertaMedia: v)),
onRecordatorioChanged: (v) => setState(
() => _casa = _casa!.copyWith(recordatorioDiario: v),
),
),
const SizedBox(height: 20),
const AppSectionTitle(title: 'Horario del camión'),
_HorarioCard(),
const SizedBox(height: 24),
]),
),
),
SliverToBoxAdapter(child: _buildAddressButton(context)),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
);
}
Widget _buildPageHeader(BuildContext context, {required bool showEdit}) {
return Container(
padding: EdgeInsets.fromLTRB(
20,
MediaQuery.of(context).padding.top + 12,
20,
24,
),
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF4A0E26), Color(0xFF9B1B4A)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(28),
bottomRight: Radius.circular(28),
),
),
child: Row(
children: [
_CasaCard(casa: _casa!),
const SizedBox(height: 16),
const AppSectionTitle(title: 'Mapa del Sector (Restringido)'),
_MapaColoniaRestringido(
colonia: _casa!.colonia,
lat: _casa!.lat,
lng: _casa!.lng,
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.home_outlined,
color: Colors.white,
size: 24,
),
),
const SizedBox(height: 16),
const AppSectionTitle(title: 'Radio de alerta'),
_RadioAlertaCard(
radioActual: _casa!.radioAlertaMetros,
onChanged: (v) =>
setState(() => _casa = _casa!.copyWith(radioAlertaMetros: v)),
const SizedBox(width: 14),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Mi Casa',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
SizedBox(height: 2),
Text(
'Domicilio registrado',
style: TextStyle(fontSize: 13, color: Colors.white70),
),
],
),
),
const SizedBox(height: 16),
const AppSectionTitle(title: 'Notificaciones'),
_NotificacionesCard(
casa: _casa!,
onAlertaCercanaChanged: (v) =>
setState(() => _casa = _casa!.copyWith(alertaCercana: v)),
onAlertaMediaChanged: (v) =>
setState(() => _casa = _casa!.copyWith(alertaMedia: v)),
onRecordatorioChanged: (v) =>
setState(() => _casa = _casa!.copyWith(recordatorioDiario: v)),
),
const SizedBox(height: 16),
const AppSectionTitle(title: 'Horario del camión'),
_HorarioCard(),
const SizedBox(height: 16),
GestureDetector(
onTap: () async {
if (showEdit)
GestureDetector(
onTap: () => _mostrarEditarDireccion(context),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.edit_outlined,
color: Colors.white,
size: 18,
),
),
),
],
),
);
}
Widget _buildAddressButton(BuildContext context) {
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 96),
child: SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () async {
final added = await context.push<bool>('/add-address');
if (added == true && mounted) {
setState(() => _isLoading = true);
_cargarDomicilio();
}
},
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
border: Border.all(color: AppTheme.primaryMid),
boxShadow: AppTheme.softShadow,
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.add_home_outlined,
color: AppTheme.primary,
size: 20,
),
SizedBox(width: 8),
Text(
'Agregar otra dirección',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.primary,
),
),
],
),
),
icon: const Icon(Icons.add_home_outlined, size: 20),
label: const Text('Agregar otra dirección'),
),
const SizedBox(height: 24),
],
),
),
);
}
@@ -191,64 +266,82 @@ class _CasaCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
border: Border.all(color: AppTheme.primaryMid, width: 0.8),
border: Border.all(color: AppTheme.border, width: 0.5),
boxShadow: AppTheme.softShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
child: ClipRRect(
borderRadius: BorderRadius.circular(AppTheme.radiusLg - 0.5),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.home_outlined,
color: AppTheme.primary,
size: 24,
),
),
const SizedBox(width: 12),
Container(width: 3, color: AppTheme.primary),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
casa.alias,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.home_outlined,
color: AppTheme.primary,
size: 24,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
casa.alias,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 4),
AppStatusBadge.green(
casa.activa ? 'Activa' : 'Inactiva',
),
],
),
),
],
),
),
const SizedBox(height: 4),
AppStatusBadge.green(casa.activa ? 'Activa' : 'Inactiva'),
],
const SizedBox(height: 14),
const Divider(color: AppTheme.borderLight),
const SizedBox(height: 10),
_DetailRow(
icon: Icons.location_on_outlined,
text: casa.direccionCompleta,
),
const SizedBox(height: 8),
_DetailRow(
icon: Icons.radar_outlined,
text:
'Alerta a ${casa.radioAlertaMetros} m de distancia',
),
],
),
),
),
],
),
const SizedBox(height: 14),
const Divider(color: AppTheme.borderLight),
const SizedBox(height: 10),
_DetailRow(
icon: Icons.location_on_outlined,
text: casa.direccionCompleta,
),
const SizedBox(height: 8),
_DetailRow(
icon: Icons.radar_outlined,
text: 'Alerta a ${casa.radioAlertaMetros} m de distancia',
),
],
),
),
);
}
@@ -263,14 +356,16 @@ class _MapaColoniaRestringido extends StatelessWidget {
@override
Widget build(BuildContext context) {
final center = kColoniaCenter(colonia);
final center =
kColoniasCoordinates[colonia] ?? const LatLng(20.5222, -100.8123);
final pin = (lat != null && lng != null) ? LatLng(lat!, lng!) : center;
return Container(
height: 200,
height: 220,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
border: Border.all(color: AppTheme.border, width: 1),
boxShadow: AppTheme.softShadow,
),
clipBehavior: Clip.hardEdge,
child: FlutterMap(

View File

@@ -1,18 +1,23 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_widgets.dart';
import '../notifications/notification_service.dart';
import '../alerts/alerts_provider.dart';
import 'citizen_home_screen.dart';
import '../alerts/alerts_screen.dart';
import 'house_screen.dart';
import '../profile/profile_screen.dart';
class MainShell extends StatefulWidget {
class MainShell extends ConsumerStatefulWidget {
const MainShell({super.key});
@override
State<MainShell> createState() => _MainShellState();
ConsumerState<MainShell> createState() => _MainShellState();
}
class _MainShellState extends State<MainShell> {
class _MainShellState extends ConsumerState<MainShell> {
int _currentIndex = 0;
static const List<Widget> _screens = [
@@ -22,6 +27,77 @@ class _MainShellState extends State<MainShell> {
ProfileScreen(),
];
@override
void initState() {
super.initState();
NotificationService.onFcmMessage.addListener(_onGlobalPush);
}
@override
void dispose() {
NotificationService.onFcmMessage.removeListener(_onGlobalPush);
super.dispose();
}
void _onGlobalPush() {
final msg = NotificationService.onFcmMessage.lastMessage;
if (msg?.notification != null) {
final title = msg!.notification!.title ?? 'Nueva Alerta';
final body = msg.notification!.body ?? '';
ref.read(alertsProvider.notifier).addAlert(title, body);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: AppTheme.primaryDark,
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.all(16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
duration: const Duration(seconds: 5),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
Icons.notifications_active,
color: Colors.white,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
],
),
const SizedBox(height: 4),
Text(
body,
style: const TextStyle(color: Colors.white70, fontSize: 13),
),
],
),
action: SnackBarAction(
label: 'VER',
textColor: AppTheme.primaryLight,
onPressed: () => setState(() => _currentIndex = 1),
),
),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(

View File

@@ -16,13 +16,38 @@ class IncidentService {
final Dio _dio;
Future<List<UnitOption>> listUnits() async {
final res = await _dio.get<List<dynamic>>('/incidents/units');
final res = await _dio.get<List<dynamic>>(
'/incidents/units',
options: Options(
receiveTimeout: const Duration(seconds: 6),
sendTimeout: const Duration(seconds: 6),
),
);
return (res.data ?? [])
.whereType<Map>()
.map((e) => UnitOption.fromJson(Map<String, dynamic>.from(e)))
.toList();
}
/// Devuelve la unidad asignada al domicilio (vía su ruta).
/// `null` si el backend responde 404 (sin ruta o sin unidad).
Future<UnitOption?> getAddressUnit(String addressId) async {
try {
final res = await _dio.get<Map<String, dynamic>>(
'/addresses/$addressId/unit',
options: Options(
receiveTimeout: const Duration(seconds: 6),
sendTimeout: const Duration(seconds: 6),
),
);
if (res.data == null) return null;
return UnitOption.fromJson(res.data!);
} on DioException catch (e) {
if (e.response?.statusCode == 404) return null;
rethrow;
}
}
Future<IncidentReport> createIncident({
required String category,
required String description,

View File

@@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../eta/eta_provider.dart';
import '../data/incident_service.dart';
import '../models/incident.dart';
@@ -10,3 +11,13 @@ final unitsProvider = FutureProvider<List<UnitOption>>((ref) async {
final myIncidentsProvider = FutureProvider<List<IncidentReport>>((ref) async {
return ref.read(incidentServiceProvider).myIncidents();
});
/// Unidad asignada al domicilio activo del ciudadano.
/// Se deriva en backend a partir de `addresses.route_id → routes.truck_id`.
/// Devuelve `null` si el ciudadano aún no tiene una dirección activa
/// o si su ruta no tiene unidad asignada.
final assignedUnitProvider = FutureProvider<UnitOption?>((ref) async {
final addressId = ref.watch(activeAddressIdProvider);
if (addressId == null) return null;
return ref.read(incidentServiceProvider).getAddressUnit(addressId);
});

View File

@@ -22,7 +22,6 @@ class _ReportIssueScreenState extends ConsumerState<ReportIssueScreen> {
final _formKey = GlobalKey<FormState>();
final _descCtrl = TextEditingController();
int? _unitId;
String _category = 'no_recoleccion';
File? _photo;
bool _submitting = false;
@@ -49,12 +48,13 @@ class _ReportIssueScreenState extends ConsumerState<ReportIssueScreen> {
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() => _submitting = true);
try {
final assignedUnit = ref.read(assignedUnitProvider).value;
await ref
.read(incidentServiceProvider)
.createIncident(
category: _category,
description: _descCtrl.text.trim(),
unitId: _unitId,
unitId: assignedUnit?.id,
photo: _photo,
);
ref.invalidate(myIncidentsProvider);
@@ -76,169 +76,357 @@ class _ReportIssueScreenState extends ConsumerState<ReportIssueScreen> {
String _friendly(Object e) {
if (e is DioException) {
final data = e.response?.data;
if (data is Map && data['detail'] != null)
if (data is Map && data['detail'] != null) {
return data['detail'].toString();
}
}
return 'No se pudo enviar el reporte';
}
@override
Widget build(BuildContext context) {
final unitsAsync = ref.watch(unitsProvider);
final assignedUnitAsync = ref.watch(assignedUnitProvider);
return Scaffold(
backgroundColor: AppTheme.background,
appBar: AppBar(title: const Text('Reportar un problema')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: AppCard(
body: Form(
key: _formKey,
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(child: _buildPageHeader(context)),
SliverPadding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
sliver: SliverList(
delegate: SliverChildListDelegate([
const AppSectionTitle(title: 'Detalles del reporte'),
AppFormCard(
icon: Icons.local_shipping_outlined,
title: 'Unidad asignada a tu zona',
child: _AssignedUnitBadge(
assignedUnitAsync: assignedUnitAsync,
),
),
const SizedBox(height: 16),
AppFormCard(
icon: Icons.category_outlined,
title: 'Categoría del problema',
child: DropdownButtonFormField<String>(
initialValue: _category,
isExpanded: true,
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
items: [
for (final entry in incidentCategories.entries)
DropdownMenuItem<String>(
value: entry.key,
child: Text(entry.value),
),
],
onChanged: (v) {
if (v != null) setState(() => _category = v);
},
validator: (v) => (v == null || v.isEmpty)
? 'Selecciona una categoría'
: null,
),
),
const SizedBox(height: 16),
AppFormCard(
icon: Icons.description_outlined,
title: 'Descripción',
child: AppFormField(
label: 'Cuéntanos qué pasó',
controller: _descCtrl,
hint: 'Cuéntanos qué pasó…',
maxLines: 5,
keyboardType: TextInputType.multiline,
validator: (v) {
final t = (v ?? '').trim();
if (t.length < 3) {
return 'Describe el problema (mínimo 3 caracteres)';
}
return null;
},
),
),
const SizedBox(height: 16),
AppFormCard(
icon: Icons.photo_camera_outlined,
title: 'Evidencia fotográfica',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _submitting ? null : _pickPhoto,
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.primary,
side: const BorderSide(
color: AppTheme.primary,
),
),
icon: const Icon(Icons.photo_camera_outlined),
label: Text(
_photo == null
? 'Adjuntar foto'
: 'Cambiar foto',
),
),
),
if (_photo != null) ...[
const SizedBox(width: 8),
IconButton(
tooltip: 'Quitar foto',
icon: const Icon(
Icons.close,
color: AppTheme.danger,
),
onPressed: _submitting
? null
: () => setState(() => _photo = null),
),
],
],
),
if (_photo != null) ...[
const SizedBox(height: 12),
ClipRRect(
borderRadius:
BorderRadius.circular(AppTheme.radiusMd),
child: Image.file(
_photo!,
height: 180,
width: double.infinity,
fit: BoxFit.cover,
),
),
],
],
),
),
const SizedBox(height: 32),
]),
),
),
],
),
),
bottomNavigationBar: _buildSubmitButton(),
);
}
Widget _buildPageHeader(BuildContext context) {
return Container(
padding: EdgeInsets.fromLTRB(
20,
MediaQuery.of(context).padding.top + 12,
20,
24,
),
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF4A0E26), Color(0xFF9B1B4A)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(28),
bottomRight: Radius.circular(28),
),
),
child: Row(
children: [
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.arrow_back,
color: Colors.white,
size: 20,
),
),
),
const SizedBox(width: 14),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const AppSectionTitle(title: 'Detalles del reporte'),
// Unidad
Text(
'Unidad relacionada (opcional)',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
'Reportar un problema',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
const SizedBox(height: 6),
unitsAsync.when(
loading: () => const LinearProgressIndicator(),
error: (e, _) => Text(
'No se pudieron cargar las unidades',
style: const TextStyle(
color: AppTheme.danger,
fontSize: 12,
),
),
data: (units) => DropdownButtonFormField<int?>(
initialValue: _unitId,
isExpanded: true,
items: [
const DropdownMenuItem<int?>(
value: null,
child: Text('Sin unidad'),
),
for (final u in units)
DropdownMenuItem<int?>(
value: u.id,
child: Text(u.label),
),
],
onChanged: (v) => setState(() => _unitId = v),
),
),
const SizedBox(height: 16),
// Categoría
SizedBox(height: 2),
Text(
'Categoría',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 6),
DropdownButtonFormField<String>(
initialValue: _category,
isExpanded: true,
items: [
for (final entry in incidentCategories.entries)
DropdownMenuItem<String>(
value: entry.key,
child: Text(entry.value),
),
],
onChanged: (v) {
if (v != null) setState(() => _category = v);
},
validator: (v) => (v == null || v.isEmpty)
? 'Selecciona una categoría'
: null,
),
const SizedBox(height: 16),
AppFormField(
label: 'Descripción',
controller: _descCtrl,
hint: 'Cuéntanos qué pasó…',
maxLines: 5,
keyboardType: TextInputType.multiline,
validator: (v) {
final t = (v ?? '').trim();
if (t.length < 3)
return 'Describe el problema (mínimo 3 caracteres)';
return null;
},
),
const SizedBox(height: 16),
// Foto
Row(
children: [
OutlinedButton.icon(
onPressed: _submitting ? null : _pickPhoto,
icon: const Icon(Icons.photo_camera_outlined),
label: Text(
_photo == null ? 'Adjuntar foto' : 'Cambiar foto',
),
),
if (_photo != null) ...[
const SizedBox(width: 8),
IconButton(
tooltip: 'Quitar foto',
icon: const Icon(Icons.close, color: AppTheme.danger),
onPressed: _submitting
? null
: () => setState(() => _photo = null),
),
],
],
),
if (_photo != null) ...[
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
child: Image.file(
_photo!,
height: 180,
width: double.infinity,
fit: BoxFit.cover,
),
),
],
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Enviar reporte'),
),
'Ayúdanos a mejorar el servicio',
style: TextStyle(fontSize: 13, color: Colors.white70),
),
],
),
),
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.bug_report_outlined,
color: Colors.white,
size: 22,
),
),
],
),
);
}
Widget _buildSubmitButton() {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Enviar reporte'),
),
),
),
);
}
}
class _AssignedUnitBadge extends StatelessWidget {
const _AssignedUnitBadge({required this.assignedUnitAsync});
final AsyncValue<UnitOption?> assignedUnitAsync;
@override
Widget build(BuildContext context) {
return assignedUnitAsync.when(
loading: () => Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: AppTheme.background,
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
border: Border.all(color: AppTheme.border),
),
child: const Row(
children: [
SizedBox(
height: 14,
width: 14,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 10),
Text(
'Detectando unidad asignada…',
style: TextStyle(fontSize: 13),
),
],
),
),
error: (_, _) => Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: AppTheme.dangerLight,
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
border: Border.all(color: AppTheme.danger.withValues(alpha: 0.3)),
),
child: const Row(
children: [
Icon(Icons.error_outline, size: 16, color: AppTheme.danger),
SizedBox(width: 8),
Expanded(
child: Text(
'No se pudo obtener la unidad asignada. El reporte se enviará sin unidad.',
style: TextStyle(fontSize: 12, color: AppTheme.danger),
),
),
],
),
),
data: (unit) {
if (unit == null) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: AppTheme.amberLight,
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
border: Border.all(
color: AppTheme.amber.withValues(alpha: 0.4),
),
),
child: const Row(
children: [
Icon(Icons.info_outline, size: 16, color: AppTheme.amber),
SizedBox(width: 8),
Expanded(
child: Text(
'Tu zona aún no tiene una unidad asignada.',
style: TextStyle(fontSize: 13, color: AppTheme.amber),
),
),
],
),
);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
border: Border.all(color: AppTheme.primaryMid),
),
child: Row(
children: [
const Icon(
Icons.local_shipping_outlined,
size: 18,
color: AppTheme.primaryDark,
),
const SizedBox(width: 8),
Expanded(
child: Text(
unit.label,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppTheme.primaryDark,
),
),
),
],
),
);
},
);
}
}

View File

@@ -3,16 +3,20 @@
//
// Regla de privacidad: los payloads de push NUNCA contienen lat/lng.
// El backend solo manda title/body desde notificaciones.json.
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:go_router/go_router.dart';
import '../../core/router/app_router.dart';
// Canal Android de alta prioridad para alertas de proximidad
const _kChannelId = 'recolecta_alerts';
const _kChannelName = 'Alertas de recolección';
const _kChannelDesc = 'Notificaciones de llegada del camión recolector';
/// Notifier simple: la EtaScreen lo escucha para refrescar sin polling.
class _FcmMessageNotifier extends ChangeNotifier {
RemoteMessage? lastMessage;
@@ -21,26 +25,26 @@ class _FcmMessageNotifier extends ChangeNotifier {
notifyListeners();
}
}
// Handler de background/terminated (top-level, fuera de clase)
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
// Solo loguear; la EtaScreen se refrescará cuando la app vuelva a foreground.
debugPrint('[FCM background] ${message.notification?.title}');
}
class NotificationService {
NotificationService._();
static final _messaging = FirebaseMessaging.instance;
static final _localNotifications = FlutterLocalNotificationsPlugin();
static final onFcmMessage = _FcmMessageNotifier();
/// Inicializar una sola vez en main.dart
static Future<void> initialize() async {
// Registrar handler de background
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
// Solicitar permisos (iOS + Android 13+)
final settings = await _messaging.requestPermission(
alert: true,
@@ -48,7 +52,7 @@ class NotificationService {
sound: true,
);
debugPrint('[FCM] Permission: ${settings.authorizationStatus}');
// Canal Android
const androidChannel = AndroidNotificationChannel(
_kChannelId,
@@ -58,34 +62,61 @@ class NotificationService {
);
await _localNotifications
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(androidChannel);
// Inicializar flutter_local_notifications
const initSettings = InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
iOS: DarwinInitializationSettings(),
);
await _localNotifications.initialize(initSettings);
await _localNotifications.initialize(
initSettings,
onDidReceiveNotificationResponse: (response) {
// Tap del banner mostrado en foreground por flutter_local_notifications
_openNotificationsScreen();
},
);
// Foreground: mostrar notificación local + notificar EtaScreen
FirebaseMessaging.onMessage.listen((message) {
_showLocalNotification(message);
onFcmMessage.notify(message);
});
// Tap en notificación cuando la app estaba en background
FirebaseMessaging.onMessageOpenedApp.listen((message) {
onFcmMessage.notify(message);
_openNotificationsScreen();
});
// Verificar si la app abrió desde una notificación (terminated)
final initial = await _messaging.getInitialMessage();
if (initial != null) {
onFcmMessage.notify(initial);
// Esperar a que el router termine de montar el árbol antes de navegar.
WidgetsBinding.instance.addPostFrameCallback((_) {
_openNotificationsScreen();
});
}
}
/// Navega a `/notifications` usando el Navigator raíz. Tolerante a que
/// la app aún no esté montada (no hace nada en ese caso).
static void _openNotificationsScreen() {
final ctx = rootNavigatorKey.currentContext;
if (ctx == null) {
debugPrint('[FCM] tap recibido pero el navigator aún no está listo');
return;
}
try {
GoRouter.of(ctx).push('/notifications');
} catch (e) {
debugPrint('[FCM] no se pudo navegar a /notifications: $e');
}
}
/// Suscribir al topic de la ruta del ciudadano.
/// Llamar justo después de que verified = true en el domicilio.
static Future<void> subscribeToRoute(String routeId) async {
@@ -93,18 +124,18 @@ class NotificationService {
await _messaging.subscribeToTopic(topic);
debugPrint('[FCM] Suscrito a $topic');
}
/// Desuscribir (al cambiar de domicilio / colonia)
static Future<void> unsubscribeFromRoute(String routeId) async {
final topic = 'topic_$routeId';
await _messaging.unsubscribeFromTopic(topic);
debugPrint('[FCM] Desuscrito de $topic');
}
static Future<void> _showLocalNotification(RemoteMessage message) async {
final notification = message.notification;
if (notification == null) return;
// El payload del backend es solo title+body; NUNCA contiene coordenadas.
await _localNotifications.show(
notification.hashCode,
@@ -127,4 +158,4 @@ class NotificationService {
),
);
}
}
}

View File

@@ -74,7 +74,14 @@ class NotificationsNotifier extends Notifier<List<NotificationItem>> {
);
state = [item, ...state];
}
void addSimulationEvent(String title, String body, FcmEventType type) {
state = [
NotificationItem(title: title, body: body, type: type, receivedAt: DateTime.now()),
...state,
];
}
void clearAll() => state = [];
}
@@ -148,7 +155,7 @@ class _FcmTopicBadge extends StatelessWidget {
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Color(0xFF1D9E75),
color: Color(0xFF1E7A46),
shape: BoxShape.circle,
),
),
@@ -188,7 +195,7 @@ class _PrivacyNote extends StatelessWidget {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFFAEEDA), // amber-50
color: const Color(0xFFF5EDD8), // beige dorado claro
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFFAC775)),
),
@@ -196,12 +203,12 @@ class _PrivacyNote extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.info_outline_rounded,
size: 18, color: Color(0xFFBA7517)),
size: 18, color: Color(0xFFC8A36A)),
const SizedBox(width: 8),
Expanded(
child: Text(
'Los mensajes no revelan la ubicación del camión. Solo se muestra el tiempo estimado de llegada.',
style: const TextStyle(fontSize: 12, color: Color(0xFF633806)),
style: const TextStyle(fontSize: 12, color: Color(0xFF7A5410)),
maxLines: 3,
),
),
@@ -254,13 +261,13 @@ class _NotificationCard extends StatelessWidget {
Color _accentColor() {
switch (item.type) {
case FcmEventType.routeStart:
return const Color(0xFF1D9E75);
return const Color(0xFF9B1B4A);
case FcmEventType.truckProximity:
return const Color(0xFFBA7517);
return const Color(0xFFC8A36A);
case FcmEventType.routeCompleted:
return Colors.grey;
return const Color(0xFF1E7A46);
case FcmEventType.reassignment:
return const Color(0xFF378ADD);
return const Color(0xFF004A7C);
default:
return Colors.grey;
}
@@ -279,62 +286,75 @@ class _NotificationCard extends StatelessWidget {
final accent = _accentColor();
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(10),
border: Border(
left: BorderSide(color: accent, width: 3),
top: BorderSide(color: Theme.of(context).colorScheme.outlineVariant, width: 0.5),
right: BorderSide(color: Theme.of(context).colorScheme.outlineVariant, width: 0.5),
bottom: BorderSide(color: Theme.of(context).colorScheme.outlineVariant, width: 0.5),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.5,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: accent.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(_icon, size: 16, color: accent),
child: ClipRRect(
borderRadius: BorderRadius.circular(9.5),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(width: 3, color: accent),
Expanded(
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(_icon, size: 16, color: accent),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
item.body,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
height: 1.4,
),
),
const SizedBox(height: 4),
Text(
_relativeTime(),
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
),
],
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
item.body,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
height: 1.4,
),
),
const SizedBox(height: 4),
Text(
_relativeTime(),
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
);
}

View File

@@ -48,7 +48,6 @@ class _EditProfileScreenState extends ConsumerState<EditProfileScreen> {
_prefilled = true;
}
// Normaliza un teléfono almacenado (con o sin lada/guiones) al formato 000-000-0000
String _formatPhoneInitial(String? raw) {
if (raw == null || raw.isEmpty) return '';
final digits = raw.replaceAll(RegExp(r'\D'), '');
@@ -132,7 +131,6 @@ class _EditProfileScreenState extends ConsumerState<EditProfileScreen> {
return Scaffold(
backgroundColor: AppTheme.background,
appBar: AppBar(title: const Text('Editar perfil')),
body: userAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
@@ -154,114 +152,225 @@ class _EditProfileScreenState extends ConsumerState<EditProfileScreen> {
return Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
const AppSectionTitle(title: 'Datos personales'),
AppCard(
child: Column(
children: [
AppFormField(
label: 'Nombre',
controller: _nameCtrl,
validator: (v) => (v == null || v.trim().isEmpty)
? 'Ingresa tu nombre'
: null,
),
const SizedBox(height: 12),
AppFormField(
label: 'Correo electrónico',
controller: _emailCtrl,
keyboardType: TextInputType.emailAddress,
validator: (v) {
if (v == null || v.trim().isEmpty) return null;
if (!v.contains('@')) return 'Correo inválido';
return null;
},
),
const SizedBox(height: 12),
_PhoneField(controller: _phoneCtrl),
],
),
),
const SizedBox(height: 20),
const AppSectionTitle(title: 'Cambiar contraseña'),
AppCard(
child: Column(
children: [
AppFormField(
label: 'Contraseña actual',
controller: _currentPasswordCtrl,
obscureText: true,
validator: (v) {
if (!_wantsPasswordChange) return null;
if (v == null || v.length < 6) {
return 'Mínimo 6 caracteres';
}
return null;
},
),
const SizedBox(height: 12),
AppFormField(
label: 'Nueva contraseña',
controller: _newPasswordCtrl,
obscureText: true,
validator: (v) {
if (!_wantsPasswordChange) return null;
if (v == null || v.length < 6) {
return 'Mínimo 6 caracteres';
}
return null;
},
),
const SizedBox(height: 12),
AppFormField(
label: 'Confirmar nueva contraseña',
controller: _confirmPasswordCtrl,
obscureText: true,
validator: (v) {
if (!_wantsPasswordChange) return null;
if (v == null || v.isEmpty) {
return 'Confirma la contraseña';
}
if (v != _newPasswordCtrl.text) return 'No coincide';
return null;
},
),
const SizedBox(height: 8),
const Align(
alignment: Alignment.centerLeft,
child: Text(
'Déjalo en blanco si no deseas cambiarla.',
style: TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(child: _buildPageHeader(context)),
SliverPadding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
sliver: SliverList(
delegate: SliverChildListDelegate([
const AppSectionTitle(title: 'Datos personales'),
AppFormCard(
icon: Icons.person_outline,
title: 'Información personal',
child: Column(
children: [
AppFormField(
label: 'Nombre',
controller: _nameCtrl,
validator: (v) =>
(v == null || v.trim().isEmpty)
? 'Ingresa tu nombre'
: null,
),
const SizedBox(height: 12),
AppFormField(
label: 'Correo electrónico',
controller: _emailCtrl,
keyboardType: TextInputType.emailAddress,
validator: (v) {
if (v == null || v.trim().isEmpty) return null;
if (!v.contains('@')) return 'Correo inválido';
return null;
},
),
const SizedBox(height: 12),
_PhoneField(controller: _phoneCtrl),
],
),
),
],
const SizedBox(height: 20),
const AppSectionTitle(title: 'Cambiar contraseña'),
AppFormCard(
icon: Icons.lock_outline,
title: 'Seguridad',
child: Column(
children: [
AppFormField(
label: 'Contraseña actual',
controller: _currentPasswordCtrl,
obscureText: true,
validator: (v) {
if (!_wantsPasswordChange) return null;
if (v == null || v.length < 6) {
return 'Mínimo 6 caracteres';
}
return null;
},
),
const SizedBox(height: 12),
AppFormField(
label: 'Nueva contraseña',
controller: _newPasswordCtrl,
obscureText: true,
validator: (v) {
if (!_wantsPasswordChange) return null;
if (v == null || v.length < 6) {
return 'Mínimo 6 caracteres';
}
return null;
},
),
const SizedBox(height: 12),
AppFormField(
label: 'Confirmar nueva contraseña',
controller: _confirmPasswordCtrl,
obscureText: true,
validator: (v) {
if (!_wantsPasswordChange) return null;
if (v == null || v.isEmpty) {
return 'Confirma la contraseña';
}
if (v != _newPasswordCtrl.text) {
return 'No coincide';
}
return null;
},
),
const SizedBox(height: 8),
const Align(
alignment: Alignment.centerLeft,
child: Text(
'Déjalo en blanco si no deseas cambiarla.',
style: TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
),
],
),
),
const SizedBox(height: 32),
]),
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Guardar cambios'),
),
),
const SizedBox(height: 24),
],
),
);
},
),
bottomNavigationBar: _buildSaveButton(),
);
}
Widget _buildPageHeader(BuildContext context) {
return Container(
padding: EdgeInsets.fromLTRB(
20,
MediaQuery.of(context).padding.top + 12,
20,
24,
),
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF4A0E26), Color(0xFF9B1B4A)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(28),
bottomRight: Radius.circular(28),
),
),
child: Row(
children: [
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.arrow_back,
color: Colors.white,
size: 20,
),
),
),
const SizedBox(width: 16),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Editar perfil',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
SizedBox(height: 2),
Text(
'Actualiza tu información personal',
style: TextStyle(
fontSize: 13,
color: Colors.white70,
),
),
],
),
),
GestureDetector(
onTap: _saving ? null : _save,
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: _saving
? const Padding(
padding: EdgeInsets.all(8),
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.save_outlined, color: Colors.white, size: 20),
),
),
],
),
);
}
Widget _buildSaveButton() {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Guardar cambios'),
),
),
),
);
}
}
@@ -369,7 +478,7 @@ class _PhoneField extends StatelessWidget {
),
),
validator: (v) {
if (v == null || v.isEmpty) return null; // opcional
if (v == null || v.isEmpty) return null;
final digits = v.replaceAll('-', '');
if (digits.length != 10) {
return 'Ingresa exactamente 10 dígitos';

View File

@@ -5,7 +5,6 @@ import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_widgets.dart';
import '../../core/services/auth_controller.dart';
import '../separation_guide/ai_pet_chat_screen.dart';
import 'models/profile_user.dart';
import 'providers/profile_providers.dart';
@@ -20,91 +19,95 @@ class ProfileScreen extends ConsumerWidget {
return Scaffold(
backgroundColor: AppTheme.background,
appBar: AppBar(title: const Text('Mi perfil')),
body: RefreshIndicator(
color: AppTheme.primary,
onRefresh: () async {
ref.invalidate(currentUserProvider);
await ref.read(currentUserProvider.future);
},
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_ProfileHeader(
user: userAsync.asData?.value,
fallbackRole: authRole,
),
const SizedBox(height: 20),
const AppSectionTitle(title: 'Mi cuenta'),
AppMenuTile(
icon: Icons.person_outline,
title: 'Editar perfil',
subtitle: 'Nombre, correo, teléfono y contraseña',
onTap: () => context.push('/edit-profile'),
),
if ((userAsync.asData?.value.isAdmin ?? false) ||
authRole == 'admin')
AppMenuTile(
icon: Icons.admin_panel_settings_outlined,
title: 'Panel de administración',
subtitle: 'Gestiona usuarios, rutas y camiones',
onTap: () => context.go('/admin'),
),
const SizedBox(height: 16),
const AppSectionTitle(title: 'Soporte'),
AppMenuTile(
icon: Icons.pets,
title: 'Hablar con Eco (Asistente IA)',
subtitle: 'Guía de separación de residuos',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AiPetChatScreen()),
);
},
),
AppMenuTile(
icon: Icons.help_outline,
title: 'Ayuda y preguntas frecuentes',
subtitle: 'Chatea con nuestro asistente',
onTap: () => context.push('/help'),
),
AppMenuTile(
icon: Icons.bug_report_outlined,
title: 'Reportar un problema',
subtitle: 'Reporta una unidad o incidente',
onTap: () => context.push('/report-issue'),
),
AppMenuTile(
icon: Icons.info_outline,
title: 'Acerca de la app',
onTap: () => context.push('/about'),
),
const SizedBox(height: 16),
AppMenuTile(
icon: Icons.logout_rounded,
title: 'Cerrar sesión',
iconColor: AppTheme.danger,
titleColor: AppTheme.danger,
trailing: const SizedBox.shrink(),
onTap: () => _confirmarCerrarSesion(context, ref),
),
const SizedBox(height: 32),
const Center(
child: Text(
'Recolecta v1.0.0\nServicio de Limpia · Celaya, Gto.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
color: AppTheme.textHint,
height: 1.6,
),
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: _ProfileHeroHeader(
user: userAsync.asData?.value,
fallbackRole: authRole,
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
sliver: SliverList(
delegate: SliverChildListDelegate([
const AppSectionTitle(title: 'Cuenta'),
AppMenuTile(
icon: Icons.person_outline,
title: 'Editar perfil',
subtitle: 'Nombre, correo, teléfono y contraseña',
onTap: () => context.push('/edit-profile'),
),
if ((userAsync.asData?.value.isAdmin ?? false) ||
authRole == 'admin')
AppMenuTile(
icon: Icons.admin_panel_settings_outlined,
title: 'Panel de administración',
subtitle: 'Gestiona usuarios, rutas y camiones',
onTap: () => context.go('/admin'),
),
const SizedBox(height: 16),
const AppSectionTitle(title: 'Herramientas'),
AppMenuTile(
icon: Icons.feedback_outlined,
title: 'Buzón de retroalimentación',
subtitle: 'Califica el servicio de recolección',
onTap: () => context.push('/feedback'),
),
const SizedBox(height: 16),
const AppSectionTitle(title: 'Soporte'),
AppMenuTile(
icon: Icons.help_outline,
title: 'Ayuda y preguntas frecuentes',
subtitle: 'Chatea con nuestro asistente',
onTap: () => context.push('/help'),
),
AppMenuTile(
icon: Icons.bug_report_outlined,
title: 'Reportar un problema',
subtitle: 'Reporta una unidad o incidente',
onTap: () => context.push('/report-issue'),
),
AppMenuTile(
icon: Icons.info_outline,
title: 'Acerca de la app',
onTap: () => context.push('/about'),
),
const SizedBox(height: 16),
AppMenuTile(
icon: Icons.logout_rounded,
title: 'Cerrar sesión',
iconColor: AppTheme.danger,
titleColor: AppTheme.danger,
trailing: const SizedBox.shrink(),
onTap: () => _confirmarCerrarSesion(context, ref),
),
const SizedBox(height: 32),
const Center(
child: Text(
'Recolecta v1.0.0\nServicio de Limpia · Celaya, Gto.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
color: AppTheme.textHint,
height: 1.6,
),
),
),
const SizedBox(height: 24),
]),
),
),
const SizedBox(height: 24),
],
),
),
@@ -157,11 +160,11 @@ class ProfileScreen extends ConsumerWidget {
}
}
// ── Encabezado ────────────────────────────────────────────────────────────────
class _ProfileHeader extends StatelessWidget {
// ── Hero header con gradiente ─────────────────────────────────────────────────
class _ProfileHeroHeader extends StatelessWidget {
final ProfileUser? user;
final String fallbackRole;
const _ProfileHeader({required this.user, required this.fallbackRole});
const _ProfileHeroHeader({required this.user, required this.fallbackRole});
@override
Widget build(BuildContext context) {
@@ -171,66 +174,89 @@ class _ProfileHeader extends StatelessWidget {
final initials = user?.initials ?? 'U';
final displayName = user?.displayName ?? 'Usuario';
final email = user?.email ?? '';
final roleLabel = isAdmin
? 'Administrador'
: isDriver
? 'Chofer'
: 'Ciudadano';
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
border: Border.all(color: AppTheme.border, width: 0.5),
boxShadow: AppTheme.softShadow,
padding: EdgeInsets.fromLTRB(
24,
MediaQuery.of(context).padding.top + 20,
24,
32,
),
child: Row(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF4A0E26), Color(0xFF9B1B4A)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(28),
bottomRight: Radius.circular(28),
),
),
child: Column(
children: [
// Avatar con iniciales
Container(
width: 56,
height: 56,
width: 80,
height: 80,
decoration: BoxDecoration(
color: AppTheme.primaryLight,
color: Colors.white.withValues(alpha: 0.15),
shape: BoxShape.circle,
border: Border.all(color: AppTheme.primaryMid, width: 1.5),
border: Border.all(
color: Colors.white.withValues(alpha: 0.4),
width: 2,
),
),
child: Center(
child: Text(
initials,
style: const TextStyle(
fontSize: 20,
fontSize: 28,
fontWeight: FontWeight.w700,
color: AppTheme.primaryDark,
color: Colors.white,
),
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 2),
Text(
email,
style: const TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 6),
AppStatusBadge.green(
isAdmin
? 'Administrador'
: isDriver
? 'Chofer'
: 'Ciudadano',
),
],
const SizedBox(height: 14),
Text(
displayName,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
const SizedBox(height: 4),
Text(
email,
style: TextStyle(
fontSize: 13,
color: Colors.white.withValues(alpha: 0.8),
),
),
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 5),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
border: Border.all(
color: Colors.white.withValues(alpha: 0.3),
),
),
child: Text(
roleLabel,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
],
@@ -238,4 +264,3 @@ class _ProfileHeader extends StatelessWidget {
);
}
}

View File

@@ -2,6 +2,8 @@ import 'dart:math';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../auth/widgets/video_mascot.dart';
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@@ -38,9 +40,10 @@ class _SplashScreenState extends State<SplashScreen>
duration: const Duration(seconds: 6),
)..repeat();
_logoScale = Tween<double>(begin: 0.2, end: 1.0).animate(
CurvedAnimation(parent: _logoCtrl, curve: Curves.elasticOut),
);
_logoScale = Tween<double>(
begin: 0.5,
end: 1.0,
).animate(CurvedAnimation(parent: _logoCtrl, curve: Curves.easeOutBack));
_logoOpacity = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _logoCtrl,
@@ -95,11 +98,7 @@ class _SplashScreenState extends State<SplashScreen>
begin: Alignment.topLeft,
end: Alignment.bottomRight,
stops: [0.0, 0.55, 1.0],
colors: [
Color(0xFF0A4A38),
Color(0xFF0F6E56),
Color(0xFF1D9E75),
],
colors: [Color(0xFF4A0E26), Color(0xFF6D1234), Color(0xFF9B1B4A)],
),
),
child: Stack(
@@ -114,89 +113,83 @@ class _SplashScreenState extends State<SplashScreen>
),
SafeArea(
child: Column(
children: [
const Spacer(flex: 3),
child: SizedBox(
width: double.infinity,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Spacer(flex: 3),
// Logo central
ScaleTransition(
scale: _logoScale,
child: FadeTransition(
opacity: _logoOpacity,
child: Container(
width: 118,
height: 118,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(34),
border: Border.all(
color: Colors.white.withValues(alpha: 0.35),
width: 2,
// Logo central
ScaleTransition(
scale: _logoScale,
child: FadeTransition(
opacity: _logoOpacity,
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: const Icon(
Icons.recycling_rounded,
size: 64,
color: Colors.white,
child: const VideoMascot(size: 130, zoom: 6.5),
),
),
),
),
const SizedBox(height: 36),
const SizedBox(height: 36),
// Nombre de la app
SlideTransition(
position: _textSlide,
child: FadeTransition(
opacity: _textOpacity,
child: const Text(
'RecolectApp',
// Nombre de la app
SlideTransition(
position: _textSlide,
child: FadeTransition(
opacity: _textOpacity,
child: const Text(
'RecolectApp',
style: TextStyle(
fontSize: 38,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: -1.0,
height: 1.1,
),
),
),
),
const SizedBox(height: 10),
// Subtítulo
FadeTransition(
opacity: _subtitleOpacity,
child: Text(
'Sistema de Recolección Inteligente',
style: TextStyle(
fontSize: 38,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: -1.0,
height: 1.1,
fontSize: 14,
color: Colors.white.withValues(alpha: 0.8),
letterSpacing: 0.4,
fontWeight: FontWeight.w400,
),
),
),
),
const SizedBox(height: 10),
const Spacer(flex: 3),
// Subtítulo
FadeTransition(
opacity: _subtitleOpacity,
child: Text(
'Sistema de Recolección Inteligente',
style: TextStyle(
fontSize: 14,
color: Colors.white.withValues(alpha: 0.8),
letterSpacing: 0.4,
fontWeight: FontWeight.w400,
// Indicador de carga
FadeTransition(
opacity: _subtitleOpacity,
child: const Padding(
padding: EdgeInsets.only(bottom: 52),
child: _DotsLoader(),
),
),
),
const Spacer(flex: 3),
// Indicador de carga
FadeTransition(
opacity: _subtitleOpacity,
child: const Padding(
padding: EdgeInsets.only(bottom: 52),
child: _DotsLoader(),
),
),
],
],
),
),
),
],