Co-authored-by: eddgranados12 <eddgranados12@users.noreply.github.com>
Co-authored-by: MENDOZA BALLARDO GAEL RICARDO <gael-meb123@users.noreply.github.com> Co-authored-by: Azareth-Tr <Azareth-Tr@users.noreply.github.com> modificacion de vistas panel admin, login, animaciones y implementacion de mascota
This commit is contained in:
@@ -1,20 +1,378 @@
|
||||
// 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 '../../core/theme/app_theme.dart';
|
||||
|
||||
class NotificationsScreen extends StatelessWidget {
|
||||
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 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 Scaffold(
|
||||
backgroundColor: AppTheme.background,
|
||||
appBar: AppBar(title: const Text('Avisos y Alertas')),
|
||||
body: const Center(
|
||||
child: Text(
|
||||
'Bandeja de entrada de FCM',
|
||||
style: TextStyle(color: AppTheme.textSecondary),
|
||||
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: '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(0xFFFAEEDA), // amber-50
|
||||
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(0xFFBA7517)),
|
||||
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)),
|
||||
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(0xFF1D9E75);
|
||||
case FcmEventType.truckProximity:
|
||||
return const Color(0xFFBA7517);
|
||||
case FcmEventType.routeCompleted:
|
||||
return Colors.grey;
|
||||
case FcmEventType.reassignment:
|
||||
return const Color(0xFF378ADD);
|
||||
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),
|
||||
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),
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user