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:
73
views_v2/eta_model.dart
Normal file
73
views_v2/eta_model.dart
Normal 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:20–7: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;
|
||||
}
|
||||
41
views_v2/eta_provider.dart
Normal file
41
views_v2/eta_provider.dart
Normal 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
385
views_v2/eta_screen.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
25
views_v2/eta_serviser.dart
Normal file
25
views_v2/eta_serviser.dart
Normal 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)),
|
||||
);
|
||||
130
views_v2/notification_service.dart
Normal file
130
views_v2/notification_service.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user