Co-authored-by: MENDOZA BALLARDO GAEL RICARDO <gael-meb123@users.noreply.github.com>

Co-authored-by: Azareth-Tr <Azareth-Tr@users.noreply.github.com>
Co-authored-by: eddgranados12 <eddgranados12@users.noreply.github.com>

implementacion de login, vistas, correcion de errores en vista registro, domicilios
This commit is contained in:
shinra32
2026-05-22 23:07:24 -06:00
parent b4ee3e7b49
commit c91b6e2091
52 changed files with 3940 additions and 4368 deletions

73
views_v2/eta_model.dart Normal file
View File

@@ -0,0 +1,73 @@
// lib/features/eta/eta_model.dart
// Modelo de respuesta del endpoint GET /eta?address_id=X
// El backend NUNCA devuelve coordenadas; solo texto y status.
enum RouteStatus {
pendiente,
enRuta,
completada,
diferida,
reasignada,
}
RouteStatus routeStatusFromString(String s) {
switch (s) {
case 'en_ruta':
return RouteStatus.enRuta;
case 'completada':
return RouteStatus.completada;
case 'diferida':
return RouteStatus.diferida;
case 'reasignada':
return RouteStatus.reasignada;
default:
return RouteStatus.pendiente;
}
}
class EtaResponse {
/// Texto accionable que muestra el ciudadano.
/// Ejemplos: "Llega en aproximadamente 15 minutos"
/// "Servicio del día finalizado"
final String mensaje;
/// Estado de la ruta para mostrar el badge correcto.
final RouteStatus status;
/// Ventana horaria opcional, ej. "7:207:35 p.m."
/// Solo presente cuando positionId == 4 (TRUCK_PROXIMITY).
final String? ventanaHoraria;
const EtaResponse({
required this.mensaje,
required this.status,
this.ventanaHoraria,
});
factory EtaResponse.fromJson(Map<String, dynamic> json) {
return EtaResponse(
mensaje: json['mensaje'] as String,
status: routeStatusFromString(json['status'] as String),
ventanaHoraria: json['ventana_horaria'] as String?,
);
}
/// Estado de progreso local (0-3) mapeado al positionId del backend.
/// Útil para la barra de 4 pasos en la UI.
int get stepIndex {
switch (status) {
case RouteStatus.pendiente:
return 0;
case RouteStatus.enRuta:
return 1;
case RouteStatus.completada:
return 3;
default:
return 2;
}
}
bool get isCompleted => status == RouteStatus.completada;
bool get isNearby =>
ventanaHoraria != null && status == RouteStatus.enRuta;
}

View File

@@ -0,0 +1,41 @@
// lib/features/eta/eta_provider.dart
// Riverpod AsyncNotifier: carga ETA al abrir la app y al recibir push FCM.
// No hace polling continuo.
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'eta_model.dart';
import 'eta_service.dart';
// ──────────────────────────────────────────
// Provider del addressId activo del ciudadano
// (se puebla en el provider de auth/session)
// ──────────────────────────────────────────
final activeAddressIdProvider = StateProvider<String?>((ref) => null);
// ──────────────────────────────────────────
// AsyncNotifier principal de ETA
// ──────────────────────────────────────────
class EtaNotifier extends AsyncNotifier<EtaResponse> {
@override
Future<EtaResponse> build() async {
final addressId = ref.watch(activeAddressIdProvider);
if (addressId == null) {
throw Exception('No hay domicilio verificado');
}
return ref.read(etaServiceProvider).fetchEta(addressId);
}
/// Llamar desde la UI (botón refrescar) o desde el handler de FCM.
Future<void> refresh() async {
state = const AsyncLoading();
final addressId = ref.read(activeAddressIdProvider);
if (addressId == null) return;
state = await AsyncValue.guard(
() => ref.read(etaServiceProvider).fetchEta(addressId),
);
}
}
final etaProvider = AsyncNotifierProvider<EtaNotifier, EtaResponse>(
EtaNotifier.new,
);

385
views_v2/eta_screen.dart Normal file
View File

@@ -0,0 +1,385 @@
// lib/features/eta/eta_screen.dart
// Vista principal del ciudadano: ETA sin mapa ni coordenadas.
// Se refresca en initState y al recibir push FCM (vía NotificationService).
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'eta_model.dart';
import 'eta_provider.dart';
import '../notifications/notification_service.dart';
import '../../shared/widgets/prevention_banner.dart';
import '../../shared/widgets/progress_steps.dart';
class EtaScreen extends ConsumerStatefulWidget {
const EtaScreen({super.key});
@override
ConsumerState<EtaScreen> createState() => _EtaScreenState();
}
class _EtaScreenState extends ConsumerState<EtaScreen>
with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
// Escuchar pushes en foreground: refrescar ETA al recibir cualquier
// evento FCM de RUTA_PROXIMITY o ROUTE_START.
NotificationService.onFcmMessage.addListener(_onPush);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
NotificationService.onFcmMessage.removeListener(_onPush);
super.dispose();
}
/// Refresca cuando la app vuelve al foreground.
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
ref.read(etaProvider.notifier).refresh();
}
}
void _onPush() {
ref.read(etaProvider.notifier).refresh();
}
@override
Widget build(BuildContext context) {
final etaAsync = ref.watch(etaProvider);
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
appBar: AppBar(
title: const Text('Mi recolección'),
actions: [
IconButton(
icon: const Icon(Icons.refresh_rounded),
tooltip: 'Actualizar',
onPressed: () => ref.read(etaProvider.notifier).refresh(),
),
],
),
body: etaAsync.when(
loading: () => const _EtaLoading(),
error: (e, _) => _EtaError(message: e.toString()),
data: (eta) => _EtaContent(eta: eta),
),
);
}
}
// ──────────────────────────────────────────
// Contenido principal cuando hay datos
// ──────────────────────────────────────────
class _EtaContent extends StatelessWidget {
final EtaResponse eta;
const _EtaContent({required this.eta});
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: () =>
ProviderScope.containerOf(context).read(etaProvider.notifier).refresh(),
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
children: [
_EtaHeroCard(eta: eta),
const SizedBox(height: 12),
const PreventionBanner(),
const SizedBox(height: 12),
ProgressSteps(stepIndex: eta.stepIndex),
const SizedBox(height: 12),
const _FcmStatusBadge(),
const SizedBox(height: 24),
],
),
);
}
}
// ──────────────────────────────────────────
// Hero card con ventana horaria y mensaje
// ──────────────────────────────────────────
class _EtaHeroCard extends StatelessWidget {
final EtaResponse eta;
const _EtaHeroCard({required this.eta});
Color _bgColor(BuildContext context) {
final cs = Theme.of(context).colorScheme;
if (eta.isCompleted) return cs.surfaceContainerHighest;
if (eta.isNearby) return const Color(0xFFFFF8E1); // amber-50 equivalente
return const Color(0xFFE1F5EE); // teal-50
}
Color _accentColor(BuildContext context) {
if (eta.isCompleted) return Theme.of(context).colorScheme.outline;
if (eta.isNearby) return const Color(0xFFBA7517); // amber-400
return const Color(0xFF1D9E75); // teal-400
}
@override
Widget build(BuildContext context) {
final accent = _accentColor(context);
final textTheme = Theme.of(context).textTheme;
return AnimatedContainer(
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: _bgColor(context),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: accent.withOpacity(0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Status pill
_StatusPill(eta: eta, accent: accent),
const SizedBox(height: 10),
// Ventana horaria o estado
Text(
eta.ventanaHoraria ?? _windowLabel(eta.status),
style: textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
color: accent,
height: 1.1,
),
),
const SizedBox(height: 6),
Text(
eta.mensaje,
style: textTheme.bodyMedium?.copyWith(
color: accent.withOpacity(0.85),
height: 1.45,
),
),
],
),
);
}
String _windowLabel(RouteStatus s) {
switch (s) {
case RouteStatus.completada:
return 'Servicio finalizado';
case RouteStatus.diferida:
return 'Servicio diferido';
case RouteStatus.reasignada:
return 'Ruta reasignada';
default:
return 'En camino';
}
}
}
class _StatusPill extends StatelessWidget {
final EtaResponse eta;
final Color accent;
const _StatusPill({required this.eta, required this.accent});
@override
Widget build(BuildContext context) {
final label = eta.isNearby
? 'Cerca de tu domicilio'
: eta.isCompleted
? 'Servicio completado'
: 'En camino a tu sector';
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!eta.isCompleted)
_PulsingDot(color: accent),
if (!eta.isCompleted) const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: accent.withOpacity(0.15),
borderRadius: BorderRadius.circular(100),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: accent,
),
),
),
],
);
}
}
class _PulsingDot extends StatefulWidget {
final Color color;
const _PulsingDot({required this.color});
@override
State<_PulsingDot> createState() => _PulsingDotState();
}
class _PulsingDotState extends State<_PulsingDot>
with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
late Animation<double> _anim;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat(reverse: true);
_anim = Tween<double>(begin: 1.0, end: 0.3).animate(_ctrl);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _anim,
builder: (_, __) => Opacity(
opacity: _anim.value,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: widget.color,
shape: BoxShape.circle,
),
),
),
);
}
}
// ──────────────────────────────────────────
// Badge de suscripción FCM
// ──────────────────────────────────────────
class _FcmStatusBadge extends ConsumerWidget {
const _FcmStatusBadge();
@override
Widget build(BuildContext context, WidgetRef ref) {
final routeId = ref.watch(activeRouteIdProvider);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Color(0xFF1D9E75),
shape: BoxShape.circle,
),
),
const SizedBox(width: 10),
Expanded(
child: Text.rich(
TextSpan(
children: [
const TextSpan(
text: 'Notificaciones activas ',
style: TextStyle(fontWeight: FontWeight.w500),
),
TextSpan(
text: routeId != null
? 'para topic_$routeId'
: '— suscribiendo...',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
style: const TextStyle(fontSize: 12),
),
),
],
),
);
}
}
// Expone el routeId activo (se puebla desde el provider de sesión/domicilio)
final activeRouteIdProvider = StateProvider<String?>((ref) => null);
// ──────────────────────────────────────────
// Estados de carga y error
// ──────────────────────────────────────────
class _EtaLoading extends StatelessWidget {
const _EtaLoading();
@override
Widget build(BuildContext context) {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator.adaptive(),
SizedBox(height: 12),
Text('Consultando estado del servicio...'),
],
),
);
}
}
class _EtaError extends StatelessWidget {
final String message;
const _EtaError({required this.message});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.wifi_off_rounded, size: 48, color: Colors.grey),
const SizedBox(height: 12),
const Text(
'No se pudo obtener el estado',
style: TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 6),
Text(
message,
style: const TextStyle(fontSize: 12, color: Colors.grey),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () =>
ProviderScope.containerOf(context)
.read(etaProvider.notifier)
.refresh(),
icon: const Icon(Icons.refresh_rounded),
label: const Text('Reintentar'),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,25 @@
// lib/features/eta/eta_service.dart
// Llama a GET /eta?address_id=X via dio.
// La respuesta NUNCA contiene coordenadas (validado en backend + RLS).
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/dio_client.dart';
import 'eta_model.dart';
class EtaService {
final Dio _dio;
EtaService(this._dio);
Future<EtaResponse> fetchEta(String addressId) async {
final response = await _dio.get<Map<String, dynamic>>(
'/eta',
queryParameters: {'address_id': addressId},
);
return EtaResponse.fromJson(response.data!);
}
}
final etaServiceProvider = Provider<EtaService>(
(ref) => EtaService(ref.read(dioProvider)),
);

View File

@@ -0,0 +1,130 @@
// lib/features/notifications/notification_service.dart
// Gestiona FCM: suscripción a topic, handlers foreground/background.
//
// 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_local_notifications/flutter_local_notifications.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;
void notify(RemoteMessage msg) {
lastMessage = msg;
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,
badge: true,
sound: true,
);
debugPrint('[FCM] Permission: ${settings.authorizationStatus}');
// Canal Android
const androidChannel = AndroidNotificationChannel(
_kChannelId,
_kChannelName,
description: _kChannelDesc,
importance: Importance.high,
);
await _localNotifications
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(androidChannel);
// Inicializar flutter_local_notifications
const initSettings = InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
iOS: DarwinInitializationSettings(),
);
await _localNotifications.initialize(initSettings);
// 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);
});
// Verificar si la app abrió desde una notificación (terminated)
final initial = await _messaging.getInitialMessage();
if (initial != null) {
onFcmMessage.notify(initial);
}
}
/// 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 {
final topic = 'topic_$routeId';
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,
notification.title,
notification.body,
NotificationDetails(
android: AndroidNotificationDetails(
_kChannelId,
_kChannelName,
channelDescription: _kChannelDesc,
importance: Importance.high,
priority: Priority.high,
// Sin ningún campo de mapa o ubicación
),
iOS: const DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
),
);
}
}