Fix notification delivery and settings flows
This commit is contained in:
@@ -78,6 +78,8 @@ class NotificationInboxItem {
|
||||
class NotificationInboxService {
|
||||
NotificationInboxService._();
|
||||
static final NotificationInboxService instance = NotificationInboxService._();
|
||||
static const Duration _nonCriticalReadRetention = Duration(days: 7);
|
||||
static const int _maxVisibleItems = 120;
|
||||
|
||||
Box get _box => Hive.box(HiveBoxes.notificationInbox);
|
||||
|
||||
@@ -86,16 +88,26 @@ class NotificationInboxService {
|
||||
List<NotificationInboxItem> allItems({
|
||||
String filter = 'all',
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
final items = _box.values
|
||||
.whereType<Map>()
|
||||
.map((raw) => NotificationInboxItem.fromMap(raw))
|
||||
.where((item) => !item.isExpired)
|
||||
// Curated mode: hide stale read noise while keeping pinned/important.
|
||||
.where((item) {
|
||||
if (item.isPinned) return true;
|
||||
if (_isCriticalType(item.type)) return true;
|
||||
if (!item.isRead) return true;
|
||||
final age = now.difference(item.createdAt);
|
||||
return age <= _nonCriticalReadRetention;
|
||||
}).where((item) {
|
||||
switch (filter) {
|
||||
case 'unread':
|
||||
return !item.isRead;
|
||||
case 'system':
|
||||
return item.type == 'system';
|
||||
case 'critical':
|
||||
return _isCriticalType(item.type);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
@@ -106,7 +118,8 @@ class NotificationInboxService {
|
||||
}
|
||||
return b.createdAt.compareTo(a.createdAt);
|
||||
});
|
||||
return items;
|
||||
// Keep list bounded in UI to avoid overwhelming long histories.
|
||||
return items.take(_maxVisibleItems).toList();
|
||||
}
|
||||
|
||||
int unreadCount() => allItems().where((e) => !e.isRead).length;
|
||||
@@ -122,6 +135,20 @@ class NotificationInboxService {
|
||||
bool isPinned = false,
|
||||
Map<String, dynamic> meta = const <String, dynamic>{},
|
||||
}) async {
|
||||
await _upsertGroupedIfNeeded(
|
||||
title: title,
|
||||
body: body,
|
||||
type: type,
|
||||
source: source,
|
||||
isPinned: isPinned,
|
||||
expiresAt: expiresAt,
|
||||
meta: meta,
|
||||
);
|
||||
if (_shouldAggregate(type: type, source: source, isPinned: isPinned)) {
|
||||
await pruneNoise();
|
||||
return;
|
||||
}
|
||||
|
||||
final key = dedupeKey ?? _defaultKey(type, title, body);
|
||||
if (_box.containsKey(key)) {
|
||||
final existingRaw = _box.get(key);
|
||||
@@ -160,6 +187,7 @@ class NotificationInboxService {
|
||||
meta: meta,
|
||||
);
|
||||
await _box.put(key, item.toMap());
|
||||
await pruneNoise();
|
||||
await NotificationAnalyticsService.instance.track(
|
||||
'notif_inbox_created',
|
||||
dimensions: <String, dynamic>{
|
||||
@@ -255,6 +283,119 @@ class NotificationInboxService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearNonCritical() async {
|
||||
final keys = <dynamic>[];
|
||||
for (final key in _box.keys) {
|
||||
final raw = _box.get(key);
|
||||
if (raw is! Map) continue;
|
||||
final item = NotificationInboxItem.fromMap(raw);
|
||||
if (item.isPinned) continue;
|
||||
if (_isCriticalType(item.type)) continue;
|
||||
keys.add(key);
|
||||
}
|
||||
if (keys.isNotEmpty) {
|
||||
await _box.deleteAll(keys);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> pruneNoise() async {
|
||||
final now = DateTime.now();
|
||||
final keys = <dynamic>[];
|
||||
for (final key in _box.keys) {
|
||||
final raw = _box.get(key);
|
||||
if (raw is! Map) continue;
|
||||
final item = NotificationInboxItem.fromMap(raw);
|
||||
if (item.isPinned || _isCriticalType(item.type)) continue;
|
||||
if (item.isRead &&
|
||||
now.difference(item.createdAt) > _nonCriticalReadRetention) {
|
||||
keys.add(key);
|
||||
}
|
||||
}
|
||||
if (keys.isNotEmpty) {
|
||||
await _box.deleteAll(keys);
|
||||
}
|
||||
}
|
||||
|
||||
bool _shouldAggregate({
|
||||
required String type,
|
||||
required String source,
|
||||
required bool isPinned,
|
||||
}) {
|
||||
if (isPinned) return false;
|
||||
if (_isCriticalType(type)) return false;
|
||||
return source == 'remote' || type == 'content' || type == 'system';
|
||||
}
|
||||
|
||||
bool _isCriticalType(String type) {
|
||||
return type == 'streak_risk' || type == 'summary' || type == 'prayer';
|
||||
}
|
||||
|
||||
Future<void> _upsertGroupedIfNeeded({
|
||||
required String title,
|
||||
required String body,
|
||||
required String type,
|
||||
required String source,
|
||||
required bool isPinned,
|
||||
required DateTime? expiresAt,
|
||||
required Map<String, dynamic> meta,
|
||||
}) async {
|
||||
if (!_shouldAggregate(type: type, source: source, isPinned: isPinned)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final dayKey = _dateKey(DateTime.now());
|
||||
final groupKey = 'group.$source.$type.$dayKey';
|
||||
final currentRaw = _box.get(groupKey);
|
||||
final currentCount = (currentRaw is Map
|
||||
? (NotificationInboxItem.fromMap(currentRaw).meta['groupCount']
|
||||
as num?)
|
||||
: null)
|
||||
?.toInt();
|
||||
final nextCount = (currentCount ?? 0) + 1;
|
||||
final groupedTitle = _groupTitle(type);
|
||||
final groupedBody = nextCount <= 1
|
||||
? body
|
||||
: '$nextCount notifikasi serupa hari ini. Terbaru: $body';
|
||||
final nextMeta = <String, dynamic>{
|
||||
...meta,
|
||||
'groupCount': nextCount,
|
||||
'isGrouped': true,
|
||||
'lastTitle': title,
|
||||
};
|
||||
|
||||
final item = NotificationInboxItem(
|
||||
id: groupKey,
|
||||
title: groupedTitle,
|
||||
body: groupedBody,
|
||||
type: type,
|
||||
createdAt: DateTime.now(),
|
||||
expiresAt: expiresAt,
|
||||
readAt: null,
|
||||
isPinned: false,
|
||||
source: source,
|
||||
deeplink: null,
|
||||
meta: nextMeta,
|
||||
);
|
||||
await _box.put(groupKey, item.toMap());
|
||||
}
|
||||
|
||||
String _groupTitle(String type) {
|
||||
switch (type) {
|
||||
case 'content':
|
||||
return 'Update Konten';
|
||||
case 'system':
|
||||
return 'Update Sistem';
|
||||
default:
|
||||
return 'Notifikasi';
|
||||
}
|
||||
}
|
||||
|
||||
String _dateKey(DateTime value) {
|
||||
final mm = value.month.toString().padLeft(2, '0');
|
||||
final dd = value.day.toString().padLeft(2, '0');
|
||||
return '${value.year}$mm$dd';
|
||||
}
|
||||
|
||||
String _defaultKey(String type, String title, String body) {
|
||||
final seed = '$type|$title|$body';
|
||||
var hash = 17;
|
||||
|
||||
Reference in New Issue
Block a user