398 lines
13 KiB
Dart
398 lines
13 KiB
Dart
// lib/features/notifications/notifications_screen.dart
|
|
// Historial de notificaciones FCM recibidas.
|
|
// Los items se almacenan en memoria (no en BD) — solo mensajes del topic propio.
|
|
|
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'notification_service.dart';
|
|
import '../eta/eta_screen.dart'; // activeRouteIdProvider
|
|
|
|
// ──────────────────────────────────────────
|
|
// Modelo local de item de notificación
|
|
// ──────────────────────────────────────────
|
|
enum FcmEventType { routeStart, truckProximity, routeCompleted, reassignment, unknown }
|
|
|
|
FcmEventType _eventTypeFromMessage(RemoteMessage msg) {
|
|
final type = msg.data['event'] as String?;
|
|
switch (type) {
|
|
case 'ROUTE_START':
|
|
return FcmEventType.routeStart;
|
|
case 'TRUCK_PROXIMITY':
|
|
return FcmEventType.truckProximity;
|
|
case 'ROUTE_COMPLETED':
|
|
return FcmEventType.routeCompleted;
|
|
case 'reasignacion':
|
|
case 'retraso':
|
|
return FcmEventType.reassignment;
|
|
default:
|
|
return FcmEventType.unknown;
|
|
}
|
|
}
|
|
|
|
class NotificationItem {
|
|
final String title;
|
|
final String body;
|
|
final FcmEventType type;
|
|
final DateTime receivedAt;
|
|
|
|
const NotificationItem({
|
|
required this.title,
|
|
required this.body,
|
|
required this.type,
|
|
required this.receivedAt,
|
|
});
|
|
}
|
|
|
|
// ──────────────────────────────────────────
|
|
// Provider: lista de notificaciones en memoria
|
|
// ──────────────────────────────────────────
|
|
final notificationsListProvider =
|
|
NotifierProvider<NotificationsNotifier, List<NotificationItem>>(
|
|
NotificationsNotifier.new,
|
|
);
|
|
|
|
class NotificationsNotifier extends Notifier<List<NotificationItem>> {
|
|
@override
|
|
List<NotificationItem> build() {
|
|
// Escuchar mensajes FCM en foreground
|
|
NotificationService.onFcmMessage.addListener(_onMessage);
|
|
ref.onDispose(
|
|
() => NotificationService.onFcmMessage.removeListener(_onMessage),
|
|
);
|
|
return [];
|
|
}
|
|
|
|
void _onMessage() {
|
|
final msg = NotificationService.onFcmMessage.lastMessage;
|
|
if (msg == null) return;
|
|
final item = NotificationItem(
|
|
title: msg.notification?.title ?? 'Recolección',
|
|
body: msg.notification?.body ?? '',
|
|
type: _eventTypeFromMessage(msg),
|
|
receivedAt: DateTime.now(),
|
|
);
|
|
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 = [];
|
|
}
|
|
|
|
// ──────────────────────────────────────────
|
|
// Pantalla de notificaciones
|
|
// ──────────────────────────────────────────
|
|
class NotificationsScreen extends ConsumerWidget {
|
|
const NotificationsScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final items = ref.watch(notificationsListProvider);
|
|
final routeId = ref.watch(activeRouteIdProvider);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Notificaciones'),
|
|
actions: [
|
|
if (items.isNotEmpty)
|
|
TextButton(
|
|
onPressed: () =>
|
|
ref.read(notificationsListProvider.notifier).clearAll(),
|
|
child: const Text('Limpiar'),
|
|
),
|
|
],
|
|
),
|
|
body: ListView(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
children: [
|
|
// Badge de suscripción FCM
|
|
_FcmTopicBadge(routeId: routeId),
|
|
const SizedBox(height: 12),
|
|
|
|
// Aviso de privacidad
|
|
_PrivacyNote(),
|
|
const SizedBox(height: 16),
|
|
|
|
if (items.isEmpty)
|
|
const _EmptyState()
|
|
else ...[
|
|
const _SectionLabel(label: 'Recientes'),
|
|
...items.map((item) => _NotificationCard(item: item)),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────
|
|
// Widgets auxiliares
|
|
// ──────────────────────────────────────────
|
|
class _FcmTopicBadge extends StatelessWidget {
|
|
final String? routeId;
|
|
const _FcmTopicBadge({required this.routeId});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
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(0xFF1E7A46),
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text.rich(
|
|
TextSpan(children: [
|
|
const TextSpan(
|
|
text: 'Suscrito a ',
|
|
style: TextStyle(fontSize: 12),
|
|
),
|
|
TextSpan(
|
|
text: routeId != null
|
|
? 'topic_$routeId'
|
|
: 'topic pendiente',
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
const TextSpan(
|
|
text: ' · Solo recibes eventos de tu ruta',
|
|
style: TextStyle(fontSize: 12),
|
|
),
|
|
]),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PrivacyNote extends StatelessWidget {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFF5EDD8), // beige dorado claro
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(color: const Color(0xFFFAC775)),
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Icon(Icons.info_outline_rounded,
|
|
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(0xFF7A5410)),
|
|
maxLines: 3,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SectionLabel extends StatelessWidget {
|
|
final String label;
|
|
const _SectionLabel({required this.label});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: Text(
|
|
label.toUpperCase(),
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w500,
|
|
letterSpacing: 0.8,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _NotificationCard extends StatelessWidget {
|
|
final NotificationItem item;
|
|
const _NotificationCard({required this.item});
|
|
|
|
IconData get _icon {
|
|
switch (item.type) {
|
|
case FcmEventType.routeStart:
|
|
return Icons.arrow_forward_rounded;
|
|
case FcmEventType.truckProximity:
|
|
return Icons.local_shipping_rounded;
|
|
case FcmEventType.routeCompleted:
|
|
return Icons.check_circle_outline_rounded;
|
|
case FcmEventType.reassignment:
|
|
return Icons.swap_horiz_rounded;
|
|
default:
|
|
return Icons.notifications_outlined;
|
|
}
|
|
}
|
|
|
|
Color _accentColor() {
|
|
switch (item.type) {
|
|
case FcmEventType.routeStart:
|
|
return const Color(0xFF9B1B4A);
|
|
case FcmEventType.truckProximity:
|
|
return const Color(0xFFC8A36A);
|
|
case FcmEventType.routeCompleted:
|
|
return const Color(0xFF1E7A46);
|
|
case FcmEventType.reassignment:
|
|
return const Color(0xFF004A7C);
|
|
default:
|
|
return Colors.grey;
|
|
}
|
|
}
|
|
|
|
String _relativeTime() {
|
|
final diff = DateTime.now().difference(item.receivedAt);
|
|
if (diff.inMinutes < 1) return 'Ahora mismo';
|
|
if (diff.inMinutes < 60) return 'Hace ${diff.inMinutes} min';
|
|
if (diff.inHours < 24) return 'Hace ${diff.inHours} h';
|
|
return 'Ayer';
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final accent = _accentColor();
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 8),
|
|
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: 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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _EmptyState extends StatelessWidget {
|
|
const _EmptyState();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 48),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.notifications_none_rounded,
|
|
size: 48,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'Sin notificaciones aún',
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Recibirás un aviso cuando el camión esté cerca.',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
} |