import 'package:flutter/foundation.dart'; import 'package:hive_flutter/hive_flutter.dart'; import '../local/hive_boxes.dart'; import 'notification_analytics_service.dart'; class NotificationInboxItem { const NotificationInboxItem({ required this.id, required this.title, required this.body, required this.type, required this.createdAt, required this.expiresAt, required this.readAt, required this.isPinned, required this.source, required this.deeplink, required this.meta, }); final String id; final String title; final String body; final String type; final DateTime createdAt; final DateTime? expiresAt; final DateTime? readAt; final bool isPinned; final String source; final String? deeplink; final Map meta; bool get isRead => readAt != null; bool get isExpired => expiresAt != null && DateTime.now().isAfter(expiresAt!); Map toMap() => { 'id': id, 'title': title, 'body': body, 'type': type, 'createdAt': createdAt.toIso8601String(), 'expiresAt': expiresAt?.toIso8601String(), 'readAt': readAt?.toIso8601String(), 'isPinned': isPinned, 'source': source, 'deeplink': deeplink, 'meta': meta, }; static NotificationInboxItem fromMap(Map map) { final createdRaw = (map['createdAt'] ?? '').toString(); final expiresRaw = (map['expiresAt'] ?? '').toString(); final readRaw = (map['readAt'] ?? '').toString(); final rawMeta = map['meta']; return NotificationInboxItem( id: (map['id'] ?? '').toString(), title: (map['title'] ?? '').toString(), body: (map['body'] ?? '').toString(), type: (map['type'] ?? 'system').toString(), createdAt: DateTime.tryParse(createdRaw) ?? DateTime.fromMillisecondsSinceEpoch(0), expiresAt: expiresRaw.isEmpty ? null : DateTime.tryParse(expiresRaw), readAt: readRaw.isEmpty ? null : DateTime.tryParse(readRaw), isPinned: map['isPinned'] == true, source: (map['source'] ?? 'local').toString(), deeplink: ((map['deeplink'] ?? '').toString().trim().isEmpty) ? null : (map['deeplink'] ?? '').toString(), meta: rawMeta is Map ? rawMeta.map((k, v) => MapEntry(k.toString(), v)) : const {}, ); } } class NotificationInboxService { NotificationInboxService._(); static final NotificationInboxService instance = NotificationInboxService._(); Box get _box => Hive.box(HiveBoxes.notificationInbox); ValueListenable listenable() => _box.listenable(); List allItems({ String filter = 'all', }) { final items = _box.values .whereType() .map((raw) => NotificationInboxItem.fromMap(raw)) .where((item) => !item.isExpired) .where((item) { switch (filter) { case 'unread': return !item.isRead; case 'system': return item.type == 'system'; default: return true; } }).toList() ..sort((a, b) { if (a.isPinned != b.isPinned) { return a.isPinned ? -1 : 1; } return b.createdAt.compareTo(a.createdAt); }); return items; } int unreadCount() => allItems().where((e) => !e.isRead).length; Future addItem({ required String title, required String body, required String type, String source = 'local', String? deeplink, String? dedupeKey, DateTime? expiresAt, bool isPinned = false, Map meta = const {}, }) async { final key = dedupeKey ?? _defaultKey(type, title, body); if (_box.containsKey(key)) { final existingRaw = _box.get(key); if (existingRaw is Map) { final existing = NotificationInboxItem.fromMap(existingRaw); await _box.put( key, existing .copyWith( title: title, body: body, type: type, source: source, deeplink: deeplink, expiresAt: expiresAt ?? existing.expiresAt, isPinned: isPinned || existing.isPinned, meta: meta.isEmpty ? existing.meta : meta, ) .toMap(), ); } return; } final item = NotificationInboxItem( id: key, title: title, body: body, type: type, createdAt: DateTime.now(), expiresAt: expiresAt, readAt: null, isPinned: isPinned, source: source, deeplink: deeplink, meta: meta, ); await _box.put(key, item.toMap()); await NotificationAnalyticsService.instance.track( 'notif_inbox_created', dimensions: { 'event_type': type, 'source': source, }, ); } Future markRead(String id) async { final raw = _box.get(id); if (raw is! Map) return; final item = NotificationInboxItem.fromMap(raw); if (item.isRead) return; await _box.put( id, item.copyWith(readAt: DateTime.now()).toMap(), ); await NotificationAnalyticsService.instance.track( 'notif_mark_read', dimensions: {'event_type': item.type}, ); } Future markUnread(String id) async { final raw = _box.get(id); if (raw is! Map) return; final item = NotificationInboxItem.fromMap(raw); if (!item.isRead) return; await _box.put( id, item.copyWith(readAt: null).toMap(), ); await NotificationAnalyticsService.instance.track( 'notif_mark_unread', dimensions: {'event_type': item.type}, ); } Future markAllRead() async { final updates = >{}; for (final key in _box.keys) { final raw = _box.get(key); if (raw is! Map) continue; final item = NotificationInboxItem.fromMap(raw); if (item.isRead) continue; updates[key] = item.copyWith(readAt: DateTime.now()).toMap(); } if (updates.isNotEmpty) { await _box.putAll(updates); } } Future remove(String id) async { await _box.delete(id); } Future removeByType(String type) async { final keys = []; for (final key in _box.keys) { final raw = _box.get(key); if (raw is! Map) continue; final item = NotificationInboxItem.fromMap(raw); if (item.type == type) { keys.add(key); } } if (keys.isNotEmpty) { await _box.deleteAll(keys); } } Future togglePinned(String id) async { final raw = _box.get(id); if (raw is! Map) return; final item = NotificationInboxItem.fromMap(raw); await _box.put( id, item.copyWith(isPinned: !item.isPinned).toMap(), ); } Future removeExpired() async { final expiredKeys = []; for (final key in _box.keys) { final raw = _box.get(key); if (raw is! Map) continue; final item = NotificationInboxItem.fromMap(raw); if (item.isExpired) expiredKeys.add(key); } if (expiredKeys.isNotEmpty) { await _box.deleteAll(expiredKeys); } } String _defaultKey(String type, String title, String body) { final seed = '$type|$title|$body'; var hash = 17; for (final rune in seed.runes) { hash = 31 * hash + rune; } return 'inbox_${hash.abs()}'; } } extension on NotificationInboxItem { static const _readAtUnchanged = Object(); NotificationInboxItem copyWith({ String? title, String? body, String? type, DateTime? createdAt, DateTime? expiresAt, Object? readAt = _readAtUnchanged, bool? isPinned, String? source, String? deeplink, Map? meta, }) { return NotificationInboxItem( id: id, title: title ?? this.title, body: body ?? this.body, type: type ?? this.type, createdAt: createdAt ?? this.createdAt, expiresAt: expiresAt ?? this.expiresAt, readAt: identical(readAt, _readAtUnchanged) ? this.readAt : readAt as DateTime?, isPinned: isPinned ?? this.isPinned, source: source ?? this.source, deeplink: deeplink ?? this.deeplink, meta: meta ?? this.meta, ); } }