Files
hackathon-innovaflow5.0-cdf…/recolecta_app/lib/features/notifications/notifications_screen.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,
),
],
),
);
}
}