Polish navigation, Quran flows, and sharing UX

This commit is contained in:
Dwindi Ramadhana
2026-03-18 00:07:10 +07:00
parent a049129a35
commit 2d09b5b356
59 changed files with 11835 additions and 3184 deletions

View File

@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:math';
import 'package:http/http.dart' as http;
class MuslimApiException implements Exception {
@@ -138,7 +139,7 @@ class MuslimApiService {
}
Map<String, String> _normalizeAudioMap(dynamic audioValue) {
final audioUrl = _asString(audioValue);
final audioUrl = _extractAudioUrl(audioValue);
if (audioUrl.isEmpty) return {};
return {
'01': audioUrl,
@@ -150,6 +151,59 @@ class MuslimApiService {
};
}
String _extractAudioUrl(dynamic value) {
if (value == null) return '';
if (value is String) return value.trim();
if (value is Map) {
final direct = _asString(value['url']).trim();
if (direct.isNotEmpty) return direct;
final src = _asString(value['src']).trim();
if (src.isNotEmpty) return src;
final audio = _asString(value['audio']).trim();
if (audio.isNotEmpty) return audio;
}
return '';
}
String _normalizeQariKey(dynamic rawKey) {
if (rawKey == null) return '';
if (rawKey is int) return rawKey.toString().padLeft(2, '0');
if (rawKey is num) return rawKey.toInt().toString().padLeft(2, '0');
final text = rawKey.toString().trim();
if (text.isEmpty) return '';
final digits = text.replaceAll(RegExp(r'[^0-9]'), '');
if (digits.isNotEmpty) {
final parsed = int.tryParse(digits);
if (parsed != null) return parsed.toString().padLeft(2, '0');
}
return text;
}
Map<String, String> _normalizeAyahAudioMap(dynamic audioValue) {
if (audioValue is Map) {
final normalized = <String, String>{};
audioValue.forEach((rawKey, rawValue) {
final key = _normalizeQariKey(rawKey);
final url = _extractAudioUrl(rawValue);
if (key.isNotEmpty && url.isNotEmpty) {
normalized[key] = url;
}
});
if (normalized.isNotEmpty) {
final fallbackUrl = normalized.values.first;
for (final qariId in qariNames.keys) {
normalized.putIfAbsent(qariId, () => fallbackUrl);
}
return normalized;
}
}
return _normalizeAudioMap(audioValue);
}
Map<String, dynamic> _mapSurahSummary(Map<String, dynamic> item) {
final number = _asInt(item['number']);
return {
@@ -165,20 +219,12 @@ class MuslimApiService {
}
Map<String, dynamic> _mapAyah(Map<String, dynamic> item) {
final audio = _asString(item['audio']);
return {
'nomorAyat': _asInt(item['ayah']),
'teksArab': _asString(item['arab']),
'teksLatin': _asString(item['latin']),
'teksIndonesia': _asString(item['text']),
'audio': {
'01': audio,
'02': audio,
'03': audio,
'04': audio,
'05': audio,
'06': audio,
},
'audio': _normalizeAyahAudioMap(item['audio'] ?? item['audio_url']),
'juz': _asInt(item['juz']),
'page': _asInt(item['page']),
'hizb': _asInt(item['hizb']),
@@ -194,10 +240,8 @@ class MuslimApiService {
if (_surahListCache != null) return _surahListCache!;
final raw = await _getData('/v1/quran/surah');
if (raw is! List) return [];
_surahListCache = raw
.whereType<Map<String, dynamic>>()
.map(_mapSurahSummary)
.toList();
_surahListCache =
raw.whereType<Map<String, dynamic>>().map(_mapSurahSummary).toList();
return _surahListCache!;
}
@@ -219,10 +263,8 @@ class MuslimApiService {
return null;
}
final mappedAyah = rawAyah
.whereType<Map<String, dynamic>>()
.map(_mapAyah)
.toList();
final mappedAyah =
rawAyah.whereType<Map<String, dynamic>>().map(_mapAyah).toList();
final mapped = {
...summary,
@@ -257,11 +299,58 @@ class MuslimApiService {
}
}
Future<List<Map<String, dynamic>>> getWordByWord(int surahId, int ayahId) async {
Future<Map<String, dynamic>?> getRandomAyat({
int? excludeSurahNumber,
int? excludeAyahNumber,
}) async {
try {
final allAyah = await getAllAyah();
if (allAyah.isEmpty) return null;
final surahs = await getAllSurahs();
if (surahs.isEmpty) return null;
final surahNames = <int, String>{
for (final surah in surahs)
_asInt(surah['nomor']): _asString(surah['namaLatin']),
};
final filtered = allAyah.where((ayah) {
final surahNumber = _asInt(ayah['surah']);
final ayahNumber = _asInt(ayah['ayah']);
final isExcluded = excludeSurahNumber != null &&
excludeAyahNumber != null &&
surahNumber == excludeSurahNumber &&
ayahNumber == excludeAyahNumber;
if (isExcluded) return false;
return _asString(ayah['arab']).trim().isNotEmpty &&
_asString(ayah['text']).trim().isNotEmpty;
}).toList();
final candidates = filtered.isNotEmpty ? filtered : allAyah;
final picked = candidates[Random().nextInt(candidates.length)];
final surahNumber = _asInt(picked['surah']);
return {
'surahName': surahNames[surahNumber] ?? '',
'nomorSurah': surahNumber,
'nomorAyat': _asInt(picked['ayah'], fallback: 1),
'teksArab': _asString(picked['arab']),
'teksIndonesia': _asString(picked['text']),
};
} catch (_) {
return null;
}
}
Future<List<Map<String, dynamic>>> getWordByWord(
int surahId, int ayahId) async {
final key = '$surahId:$ayahId';
if (_wordByWordCache.containsKey(key)) return _wordByWordCache[key]!;
final raw = await _getData('/v1/quran/word/ayah?surahId=$surahId&ayahId=$ayahId');
final raw =
await _getData('/v1/quran/word/ayah?surahId=$surahId&ayahId=$ayahId');
if (raw is! List) return [];
final mapped = raw.whereType<Map<String, dynamic>>().map((item) {
@@ -342,7 +431,8 @@ class MuslimApiService {
});
}
result.sort((a, b) => (a['nomorAyat'] as int).compareTo(b['nomorAyat'] as int));
result.sort(
(a, b) => (a['nomorAyat'] as int).compareTo(b['nomorAyat'] as int));
return result;
}
@@ -386,7 +476,8 @@ class MuslimApiService {
});
}
result.sort((a, b) => (a['nomorAyat'] as int).compareTo(b['nomorAyat'] as int));
result.sort(
(a, b) => (a['nomorAyat'] as int).compareTo(b['nomorAyat'] as int));
return result;
}
@@ -449,12 +540,17 @@ class MuslimApiService {
if (q.isEmpty) return [];
final allAyah = await getAllAyah();
final results = allAyah.where((item) {
final text = _asString(item['text']).toLowerCase();
final latin = _asString(item['latin']).toLowerCase();
final arab = _asString(item['arab']);
return text.contains(q) || latin.contains(q) || arab.contains(query.trim());
}).take(50).toList();
final results = allAyah
.where((item) {
final text = _asString(item['text']).toLowerCase();
final latin = _asString(item['latin']).toLowerCase();
final arab = _asString(item['arab']);
return text.contains(q) ||
latin.contains(q) ||
arab.contains(query.trim());
})
.take(50)
.toList();
return results;
}
@@ -478,9 +574,8 @@ class MuslimApiService {
Future<List<Map<String, dynamic>>> getDoaList({bool strict = false}) async {
if (_doaCache != null) return _doaCache!;
final raw = strict
? await _getDataOrThrow('/v1/doa')
: await _getData('/v1/doa');
final raw =
strict ? await _getDataOrThrow('/v1/doa') : await _getData('/v1/doa');
if (raw is! List) {
if (strict) {
throw const MuslimApiException('Invalid doa payload');
@@ -500,7 +595,8 @@ class MuslimApiService {
return _doaCache!;
}
Future<List<Map<String, dynamic>>> getHaditsList({bool strict = false}) async {
Future<List<Map<String, dynamic>>> getHaditsList(
{bool strict = false}) async {
if (_haditsCache != null) return _haditsCache!;
final raw = strict
? await _getDataOrThrow('/v1/hadits')

View File

@@ -0,0 +1,39 @@
import 'dart:convert';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:intl/intl.dart';
import '../local/hive_boxes.dart';
/// Lightweight local analytics sink for notification events.
class NotificationAnalyticsService {
NotificationAnalyticsService._();
static final NotificationAnalyticsService instance =
NotificationAnalyticsService._();
Box get _box => Hive.box(HiveBoxes.notificationRuntime);
Future<void> track(
String event, {
Map<String, dynamic> dimensions = const <String, dynamic>{},
}) async {
final date = DateFormat('yyyy-MM-dd').format(DateTime.now());
final counterKey = 'analytics.$date.$event';
final current = (_box.get(counterKey) as int?) ?? 0;
await _box.put(counterKey, current + 1);
// Keep a small rolling audit buffer for debug support.
final raw = (_box.get('analytics.recent') ?? '[]').toString();
final decoded = json.decode(raw);
final list = decoded is List ? decoded : <dynamic>[];
list.add({
'event': event,
'at': DateTime.now().toIso8601String(),
'dimensions': dimensions,
});
while (list.length > 100) {
list.removeAt(0);
}
await _box.put('analytics.recent', json.encode(list));
}
}

View File

@@ -0,0 +1,299 @@
import 'package:hive_flutter/hive_flutter.dart';
import 'package:intl/intl.dart';
import '../local/hive_boxes.dart';
import '../local/models/app_settings.dart';
import '../local/models/daily_worship_log.dart';
import 'notification_inbox_service.dart';
import 'notification_runtime_service.dart';
import 'notification_service.dart';
/// Creates in-app inbox events from runtime/system conditions.
class NotificationEventProducerService {
NotificationEventProducerService._();
static final NotificationEventProducerService instance =
NotificationEventProducerService._();
final NotificationInboxService _inbox = NotificationInboxService.instance;
final NotificationRuntimeService _runtime =
NotificationRuntimeService.instance;
Future<void> emitPermissionWarningsIfNeeded({
required AppSettings settings,
required NotificationPermissionStatus permissionStatus,
}) async {
if (!settings.adhanEnabled.values.any((v) => v)) return;
final dateKey = _todayKey();
if (!permissionStatus.notificationsAllowed) {
final title = 'Izin notifikasi dinonaktifkan';
final body =
'Aktifkan izin notifikasi agar pengingat adzan dan iqamah dapat muncul.';
if (settings.inboxEnabled) {
await _inbox.addItem(
title: title,
body: body,
type: 'system',
source: 'local',
deeplink: '/settings',
dedupeKey: 'system.permission.notifications.$dateKey',
expiresAt: DateTime.now().add(const Duration(days: 2)),
);
}
await _pushSystemIfAllowed(
settings: settings,
dedupeSeed: 'push.system.permission.notifications.$dateKey',
title: title,
body: body,
);
}
if (!permissionStatus.exactAlarmAllowed) {
final title = 'Izin alarm presisi belum aktif';
final body =
'Aktifkan alarm presisi agar pengingat adzan tepat waktu di perangkat Android.';
if (settings.inboxEnabled) {
await _inbox.addItem(
title: title,
body: body,
type: 'system',
source: 'local',
deeplink: '/settings',
dedupeKey: 'system.permission.exact_alarm.$dateKey',
expiresAt: DateTime.now().add(const Duration(days: 2)),
);
}
await _pushSystemIfAllowed(
settings: settings,
dedupeSeed: 'push.system.permission.exact_alarm.$dateKey',
title: title,
body: body,
);
}
}
Future<void> emitScheduleFallback({
required AppSettings settings,
required String cityId,
required bool locationUnavailable,
}) async {
final dateKey = _todayKey();
final title = locationUnavailable
? 'Lokasi belum tersedia'
: 'Jadwal online terganggu';
final body = locationUnavailable
? 'Lokasi perangkat belum aktif. Aplikasi menggunakan lokasi default sementara.'
: 'Aplikasi memakai perhitungan lokal sementara. Pastikan internet aktif untuk jadwal paling akurat.';
final scope = locationUnavailable ? 'loc' : 'net';
final dedupe = 'system.schedule.fallback.$cityId.$dateKey.$scope';
if (settings.inboxEnabled) {
await _inbox.addItem(
title: title,
body: body,
type: 'system',
source: 'local',
deeplink: '/imsakiyah',
dedupeKey: dedupe,
expiresAt: DateTime.now().add(const Duration(days: 1)),
meta: <String, dynamic>{
'cityId': cityId,
'date': dateKey,
'scope': scope,
},
);
}
await _pushSystemIfAllowed(
settings: settings,
dedupeSeed: 'push.$dedupe',
title: title,
body: body,
);
}
Future<void> emitNotificationSyncFailed({
required AppSettings settings,
required String cityId,
}) async {
final dateKey = _todayKey();
final title = 'Sinkronisasi alarm adzan gagal';
final body =
'Pengingat adzan belum tersinkron. Coba buka aplikasi lagi atau periksa pengaturan notifikasi.';
final dedupe = 'system.notification.sync_failed.$cityId.$dateKey';
if (settings.inboxEnabled) {
await _inbox.addItem(
title: title,
body: body,
type: 'system',
source: 'local',
deeplink: '/settings',
dedupeKey: dedupe,
expiresAt: DateTime.now().add(const Duration(days: 1)),
meta: <String, dynamic>{
'cityId': cityId,
'date': dateKey,
},
);
}
await _pushSystemIfAllowed(
settings: settings,
dedupeSeed: 'push.$dedupe',
title: title,
body: body,
);
}
Future<void> emitStreakRiskIfNeeded({
required AppSettings settings,
}) async {
if (!settings.inboxEnabled || !settings.streakRiskEnabled) return;
final now = DateTime.now();
if (now.hour < 18) return;
final dateKey = _todayKey();
final worshipBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
final log = worshipBox.get(dateKey);
if (log == null) return;
final tilawahRisk = log.tilawahLog != null && !log.tilawahLog!.isCompleted;
final dzikirRisk =
settings.trackDzikir && log.dzikirLog != null && !log.dzikirLog!.petang;
if (tilawahRisk) {
final title = 'Streak Tilawah berisiko terputus';
const body =
'Selesaikan target tilawah hari ini untuk menjaga konsistensi.';
final dedupe = 'streak.tilawah.$dateKey';
await _inbox.addItem(
title: title,
body: body,
type: 'streak_risk',
source: 'local',
deeplink: '/quran',
dedupeKey: dedupe,
expiresAt: DateTime(now.year, now.month, now.day, 23, 59),
);
await _pushHabitIfAllowed(
settings: settings,
dedupeSeed: 'push.$dedupe',
title: title,
body: body,
);
}
if (dzikirRisk) {
final title = 'Dzikir petang belum tercatat';
const body = 'Lengkapi dzikir petang untuk menjaga streak amalan harian.';
final dedupe = 'streak.dzikir.$dateKey';
await _inbox.addItem(
title: title,
body: body,
type: 'streak_risk',
source: 'local',
deeplink: '/tools/dzikir',
dedupeKey: dedupe,
expiresAt: DateTime(now.year, now.month, now.day, 23, 59),
);
await _pushHabitIfAllowed(
settings: settings,
dedupeSeed: 'push.$dedupe',
title: title,
body: body,
);
}
}
Future<void> emitWeeklySummaryIfNeeded({
required AppSettings settings,
}) async {
if (!settings.inboxEnabled || !settings.weeklySummaryEnabled) return;
final now = DateTime.now();
if (now.weekday != DateTime.monday || now.hour < 6) return;
final monday = now.subtract(Duration(days: now.weekday - 1));
final weekKey = DateFormat('yyyy-MM-dd').format(monday);
if (_runtime.lastWeeklySummaryWeekKey() == weekKey) return;
final worshipBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
var completionDays = 0;
var totalPoints = 0;
for (int i = 1; i <= 7; i++) {
final date = now.subtract(Duration(days: i));
final key = DateFormat('yyyy-MM-dd').format(date);
final log = worshipBox.get(key);
if (log == null) continue;
if (log.completionPercent >= 70) completionDays++;
totalPoints += log.totalPoints;
}
await _inbox.addItem(
title: 'Ringkasan Ibadah Mingguan',
body:
'7 hari terakhir: $completionDays hari konsisten, total $totalPoints poin. Lihat detail laporan.',
type: 'summary',
source: 'local',
deeplink: '/laporan',
dedupeKey: 'summary.weekly.$weekKey',
expiresAt: now.add(const Duration(days: 7)),
);
await _runtime.setLastWeeklySummaryWeekKey(weekKey);
}
String _todayKey() => DateFormat('yyyy-MM-dd').format(DateTime.now());
Future<void> _pushSystemIfAllowed({
required AppSettings settings,
required String dedupeSeed,
required String title,
required String body,
}) async {
await _pushNonPrayer(
settings: settings,
dedupeSeed: dedupeSeed,
title: title,
body: body,
payloadType: 'system',
silent: true,
);
}
Future<void> _pushHabitIfAllowed({
required AppSettings settings,
required String dedupeSeed,
required String title,
required String body,
}) async {
await _pushNonPrayer(
settings: settings,
dedupeSeed: dedupeSeed,
title: title,
body: body,
payloadType: 'streak_risk',
silent: false,
);
}
Future<void> _pushNonPrayer({
required AppSettings settings,
required String dedupeSeed,
required String title,
required String body,
required String payloadType,
required bool silent,
}) async {
if (!settings.alertsEnabled) return;
final notif = NotificationService.instance;
await notif.showNonPrayerAlert(
settings: settings,
id: notif.nonPrayerNotificationId(dedupeSeed),
title: title,
body: body,
payloadType: payloadType,
silent: silent,
);
}
}

View File

@@ -0,0 +1,299 @@
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<String, dynamic> meta;
bool get isRead => readAt != null;
bool get isExpired => expiresAt != null && DateTime.now().isAfter(expiresAt!);
Map<String, dynamic> 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<dynamic, dynamic> 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 <String, dynamic>{},
);
}
}
class NotificationInboxService {
NotificationInboxService._();
static final NotificationInboxService instance = NotificationInboxService._();
Box get _box => Hive.box(HiveBoxes.notificationInbox);
ValueListenable<Box> listenable() => _box.listenable();
List<NotificationInboxItem> allItems({
String filter = 'all',
}) {
final items = _box.values
.whereType<Map>()
.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<void> addItem({
required String title,
required String body,
required String type,
String source = 'local',
String? deeplink,
String? dedupeKey,
DateTime? expiresAt,
bool isPinned = false,
Map<String, dynamic> meta = const <String, dynamic>{},
}) 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: <String, dynamic>{
'event_type': type,
'source': source,
},
);
}
Future<void> 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: <String, dynamic>{'event_type': item.type},
);
}
Future<void> 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: <String, dynamic>{'event_type': item.type},
);
}
Future<void> markAllRead() async {
final updates = <dynamic, Map<String, dynamic>>{};
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<void> remove(String id) async {
await _box.delete(id);
}
Future<void> removeByType(String type) 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.type == type) {
keys.add(key);
}
}
if (keys.isNotEmpty) {
await _box.deleteAll(keys);
}
}
Future<void> 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<void> removeExpired() async {
final expiredKeys = <dynamic>[];
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<String, dynamic>? 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,
);
}
}

View File

@@ -0,0 +1,24 @@
import '../local/models/app_settings.dart';
import 'notification_event_producer_service.dart';
import 'notification_inbox_service.dart';
import 'remote_notification_content_service.dart';
/// High-level coordinator for non-prayer notification flows.
class NotificationOrchestratorService {
NotificationOrchestratorService._();
static final NotificationOrchestratorService instance =
NotificationOrchestratorService._();
Future<void> runPassivePass({
required AppSettings settings,
}) async {
await NotificationInboxService.instance.removeExpired();
await NotificationEventProducerService.instance.emitStreakRiskIfNeeded(
settings: settings,
);
await NotificationEventProducerService.instance.emitWeeklySummaryIfNeeded(
settings: settings,
);
await RemoteNotificationContentService.instance.sync(settings: settings);
}
}

View File

@@ -0,0 +1,86 @@
import 'package:hive_flutter/hive_flutter.dart';
import 'package:intl/intl.dart';
import '../local/hive_boxes.dart';
import '../local/models/app_settings.dart';
/// Runtime persistence for notification counters and cursors.
class NotificationRuntimeService {
NotificationRuntimeService._();
static final NotificationRuntimeService instance =
NotificationRuntimeService._();
static const _nonPrayerCountPrefix = 'non_prayer_push_count.';
static const _lastRemoteSyncKey = 'remote.last_sync_at';
static const _lastWeeklySummaryKey = 'summary.last_week_key';
Box get _box => Hive.box(HiveBoxes.notificationRuntime);
String _todayKey() => DateFormat('yyyy-MM-dd').format(DateTime.now());
int nonPrayerPushCountToday() {
return (_box.get('$_nonPrayerCountPrefix${_todayKey()}') as int?) ?? 0;
}
Future<void> incrementNonPrayerPushCount() async {
final key = '$_nonPrayerCountPrefix${_todayKey()}';
final next = ((_box.get(key) as int?) ?? 0) + 1;
await _box.put(key, next);
}
bool isWithinQuietHours(AppSettings settings, {DateTime? now}) {
final current = now ?? DateTime.now();
final startParts = _parseHourMinute(settings.quietHoursStart);
final endParts = _parseHourMinute(settings.quietHoursEnd);
if (startParts == null || endParts == null) return false;
final currentMinutes = current.hour * 60 + current.minute;
final startMinutes = startParts.$1 * 60 + startParts.$2;
final endMinutes = endParts.$1 * 60 + endParts.$2;
if (startMinutes == endMinutes) {
// Same value means quiet-hours disabled.
return false;
}
if (startMinutes < endMinutes) {
return currentMinutes >= startMinutes && currentMinutes < endMinutes;
}
// Overnight interval (e.g. 22:00 -> 05:00).
return currentMinutes >= startMinutes || currentMinutes < endMinutes;
}
bool canSendNonPrayerPush(AppSettings settings, {DateTime? now}) {
if (!settings.alertsEnabled) return false;
if (isWithinQuietHours(settings, now: now)) return false;
return nonPrayerPushCountToday() < settings.maxNonPrayerPushPerDay;
}
DateTime? lastRemoteSyncAt() {
final raw = (_box.get(_lastRemoteSyncKey) ?? '').toString();
if (raw.isEmpty) return null;
return DateTime.tryParse(raw);
}
Future<void> setLastRemoteSyncAt(DateTime value) async {
await _box.put(_lastRemoteSyncKey, value.toIso8601String());
}
String? lastWeeklySummaryWeekKey() {
final raw = (_box.get(_lastWeeklySummaryKey) ?? '').toString();
return raw.isEmpty ? null : raw;
}
Future<void> setLastWeeklySummaryWeekKey(String key) async {
await _box.put(_lastWeeklySummaryKey, key);
}
(int, int)? _parseHourMinute(String value) {
final match = RegExp(r'^(\d{1,2}):(\d{2})$').firstMatch(value.trim());
if (match == null) return null;
final hour = int.tryParse(match.group(1) ?? '');
final minute = int.tryParse(match.group(2) ?? '');
if (hour == null || minute == null) return null;
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
return (hour, minute);
}
}

View File

@@ -1,7 +1,43 @@
import 'dart:io' show Platform;
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/data/latest.dart' as tz_data;
import 'package:timezone/timezone.dart' as tz;
/// Notification service for Adhan and Iqamah notifications.
import '../local/models/app_settings.dart';
import 'notification_analytics_service.dart';
import 'notification_runtime_service.dart';
class NotificationPermissionStatus {
const NotificationPermissionStatus({
required this.notificationsAllowed,
required this.exactAlarmAllowed,
});
final bool notificationsAllowed;
final bool exactAlarmAllowed;
}
class NotificationPendingAlert {
const NotificationPendingAlert({
required this.id,
required this.type,
required this.title,
required this.body,
required this.scheduledAt,
});
final int id;
final String type;
final String title;
final String body;
final DateTime? scheduledAt;
}
/// Notification service for Adzan and Iqamah reminders.
///
/// This service owns the local notifications setup, permission requests,
/// timezone setup, and scheduling lifecycle for prayer notifications.
class NotificationService {
NotificationService._();
static final NotificationService instance = NotificationService._();
@@ -10,16 +46,100 @@ class NotificationService {
FlutterLocalNotificationsPlugin();
bool _initialized = false;
String? _lastSyncSignature;
static const int _checklistReminderId = 920001;
/// Initialize notification channels.
static const _adhanDetails = NotificationDetails(
android: AndroidNotificationDetails(
'adhan_channel',
'Adzan Notifications',
channelDescription: 'Pengingat waktu adzan',
importance: Importance.max,
priority: Priority.high,
playSound: true,
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
macOS: DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
);
static const _iqamahDetails = NotificationDetails(
android: AndroidNotificationDetails(
'iqamah_channel',
'Iqamah Reminders',
channelDescription: 'Pengingat waktu iqamah',
importance: Importance.high,
priority: Priority.high,
playSound: true,
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentSound: true,
),
macOS: DarwinNotificationDetails(
presentAlert: true,
presentSound: true,
),
);
static const _habitDetails = NotificationDetails(
android: AndroidNotificationDetails(
'habit_channel',
'Pengingat Ibadah Harian',
channelDescription: 'Pengingat checklist, streak, dan kebiasaan ibadah',
importance: Importance.high,
priority: Priority.high,
playSound: true,
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentSound: true,
),
macOS: DarwinNotificationDetails(
presentAlert: true,
presentSound: true,
),
);
static const _systemDetails = NotificationDetails(
android: AndroidNotificationDetails(
'system_channel',
'Peringatan Sistem',
channelDescription: 'Peringatan status izin dan sinkronisasi jadwal',
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
playSound: false,
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentSound: false,
),
macOS: DarwinNotificationDetails(
presentAlert: true,
presentSound: false,
),
);
/// Initialize plugin, permissions, and timezone once.
Future<void> init() async {
if (_initialized) return;
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
tz_data.initializeTimeZones();
_configureLocalTimeZone();
const androidSettings =
AndroidInitializationSettings('@mipmap/ic_launcher');
const darwinSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
);
const settings = InitializationSettings(
@@ -28,71 +148,509 @@ class NotificationService {
macOS: darwinSettings,
);
await _plugin.initialize(settings);
await _plugin.initialize(settings: settings);
await _requestPermissions();
_initialized = true;
}
/// Schedule an Adhan notification at a specific time.
Future<void> scheduleAdhan({
void _configureLocalTimeZone() {
final tzId = _resolveTimeZoneIdByOffset(DateTime.now().timeZoneOffset);
try {
tz.setLocalLocation(tz.getLocation(tzId));
} catch (_) {
tz.setLocalLocation(tz.UTC);
}
}
// We prioritize Indonesian zones for better prayer scheduling defaults.
String _resolveTimeZoneIdByOffset(Duration offset) {
switch (offset.inMinutes) {
case 420:
return 'Asia/Jakarta';
case 480:
return 'Asia/Makassar';
case 540:
return 'Asia/Jayapura';
default:
if (offset.inMinutes % 60 == 0) {
final etcHours = -(offset.inMinutes ~/ 60);
final sign = etcHours >= 0 ? '+' : '';
return 'Etc/GMT$sign$etcHours';
}
return 'UTC';
}
}
Future<void> _requestPermissions() async {
if (Platform.isAndroid) {
final androidPlugin = _plugin.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
await androidPlugin?.requestNotificationsPermission();
await androidPlugin?.requestExactAlarmsPermission();
return;
}
if (Platform.isIOS) {
await _plugin
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(alert: true, badge: true, sound: true);
return;
}
if (Platform.isMacOS) {
await _plugin
.resolvePlatformSpecificImplementation<
MacOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(alert: true, badge: true, sound: true);
}
}
Future<void> syncPrayerNotifications({
required String cityId,
required Map<String, bool> adhanEnabled,
required Map<String, int> iqamahOffset,
required Map<String, Map<String, String>> schedulesByDate,
}) async {
await init();
final hasAnyEnabled = adhanEnabled.values.any((v) => v);
if (!hasAnyEnabled) {
await cancelAllPending();
_lastSyncSignature = null;
return;
}
final signature = _buildSyncSignature(
cityId, adhanEnabled, iqamahOffset, schedulesByDate);
if (_lastSyncSignature == signature) return;
await cancelAllPending();
final now = DateTime.now();
final dateEntries = schedulesByDate.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
for (final dateEntry in dateEntries) {
final date = DateTime.tryParse(dateEntry.key);
if (date == null) continue;
for (final prayerKey in const [
'subuh',
'dzuhur',
'ashar',
'maghrib',
'isya',
]) {
final canonicalPrayer = _canonicalPrayerKey(prayerKey);
if (canonicalPrayer == null) continue;
if (!(adhanEnabled[canonicalPrayer] ?? false)) continue;
final rawTime = (dateEntry.value[prayerKey] ?? '').trim();
final prayerTime = _parseScheduleDateTime(date, rawTime);
if (prayerTime == null || !prayerTime.isAfter(now)) continue;
await _scheduleAdhan(
id: _notificationId(
cityId: cityId,
dateKey: dateEntry.key,
prayerKey: canonicalPrayer,
isIqamah: false,
),
prayerName: _localizedPrayerName(canonicalPrayer),
time: prayerTime,
);
final offsetMinutes = iqamahOffset[canonicalPrayer] ?? 0;
if (offsetMinutes <= 0) continue;
final iqamahTime = prayerTime.add(Duration(minutes: offsetMinutes));
if (!iqamahTime.isAfter(now)) continue;
await _scheduleIqamah(
id: _notificationId(
cityId: cityId,
dateKey: dateEntry.key,
prayerKey: canonicalPrayer,
isIqamah: true,
),
prayerName: _localizedPrayerName(canonicalPrayer),
iqamahTime: iqamahTime,
offsetMinutes: offsetMinutes,
);
}
}
_lastSyncSignature = signature;
}
Future<void> _scheduleAdhan({
required int id,
required String prayerName,
required DateTime time,
}) async {
await _plugin.zonedSchedule(
id,
'Adhan - $prayerName',
'It\'s time for $prayerName prayer',
tz.TZDateTime.from(time, tz.local),
const NotificationDetails(
android: AndroidNotificationDetails(
'adhan_channel',
'Adhan Notifications',
channelDescription: 'Prayer time adhan notifications',
importance: Importance.high,
priority: Priority.high,
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
),
id: id,
title: 'Adzan $prayerName',
body: 'Waktu sholat $prayerName telah masuk.',
scheduledDate: tz.TZDateTime.from(time, tz.local),
notificationDetails: _adhanDetails,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
payload: 'adhan|$prayerName|${time.toIso8601String()}',
);
await NotificationAnalyticsService.instance.track(
'notif_push_scheduled',
dimensions: <String, dynamic>{
'event_type': 'adhan',
'prayer': prayerName,
},
);
}
/// Schedule an Iqamah reminder notification.
Future<void> scheduleIqamah({
Future<void> _scheduleIqamah({
required int id,
required String prayerName,
required DateTime adhanTime,
required DateTime iqamahTime,
required int offsetMinutes,
}) async {
final iqamahTime = adhanTime.add(Duration(minutes: offsetMinutes));
await _plugin.zonedSchedule(
id + 100, // Offset IDs for iqamah
'Iqamah - $prayerName',
'Iqamah for $prayerName in $offsetMinutes minutes',
tz.TZDateTime.from(iqamahTime, tz.local),
const NotificationDetails(
android: AndroidNotificationDetails(
'iqamah_channel',
'Iqamah Reminders',
channelDescription: 'Iqamah reminder notifications',
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentSound: true,
),
),
id: id,
title: 'Iqamah $prayerName',
body: 'Iqamah $prayerName dalam $offsetMinutes menit.',
scheduledDate: tz.TZDateTime.from(iqamahTime, tz.local),
notificationDetails: _iqamahDetails,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
payload: 'iqamah|$prayerName|${iqamahTime.toIso8601String()}',
);
await NotificationAnalyticsService.instance.track(
'notif_push_scheduled',
dimensions: <String, dynamic>{
'event_type': 'iqamah',
'prayer': prayerName,
},
);
}
/// Cancel all pending notifications.
Future<void> cancelAll() async {
await _plugin.cancelAll();
DateTime? _parseScheduleDateTime(DateTime date, String hhmm) {
final match = RegExp(r'^(\d{1,2}):(\d{2})').firstMatch(hhmm);
if (match == null) return null;
final hour = int.tryParse(match.group(1) ?? '');
final minute = int.tryParse(match.group(2) ?? '');
if (hour == null || minute == null) return null;
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
return DateTime(date.year, date.month, date.day, hour, minute);
}
String? _canonicalPrayerKey(String scheduleKey) {
switch (scheduleKey) {
case 'subuh':
case 'fajr':
return 'fajr';
case 'dzuhur':
case 'dhuhr':
return 'dhuhr';
case 'ashar':
case 'asr':
return 'asr';
case 'maghrib':
return 'maghrib';
case 'isya':
case 'isha':
return 'isha';
default:
return null;
}
}
String _localizedPrayerName(String canonicalPrayerKey) {
switch (canonicalPrayerKey) {
case 'fajr':
return 'Subuh';
case 'dhuhr':
return 'Dzuhur';
case 'asr':
return 'Ashar';
case 'maghrib':
return 'Maghrib';
case 'isha':
return 'Isya';
default:
return canonicalPrayerKey;
}
}
int _notificationId({
required String cityId,
required String dateKey,
required String prayerKey,
required bool isIqamah,
}) {
final seed = '$cityId|$dateKey|$prayerKey|${isIqamah ? 'iqamah' : 'adhan'}';
var hash = 17;
for (final rune in seed.runes) {
hash = 37 * hash + rune;
}
final bounded = hash.abs() % 700000;
return isIqamah ? bounded + 800000 : bounded + 100000;
}
String _buildSyncSignature(
String cityId,
Map<String, bool> adhanEnabled,
Map<String, int> iqamahOffset,
Map<String, Map<String, String>> schedulesByDate,
) {
final sortedAdhan = adhanEnabled.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
final sortedIqamah = iqamahOffset.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
final sortedDates = schedulesByDate.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
final buffer = StringBuffer(cityId);
for (final e in sortedAdhan) {
buffer.write('|${e.key}:${e.value ? 1 : 0}');
}
for (final e in sortedIqamah) {
buffer.write('|${e.key}:${e.value}');
}
for (final dateEntry in sortedDates) {
buffer.write('|${dateEntry.key}');
final times = dateEntry.value.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
for (final t in times) {
buffer.write('|${t.key}:${t.value}');
}
}
return buffer.toString();
}
Future<void> cancelAllPending() async {
try {
await _plugin.cancelAllPendingNotifications();
} catch (_) {
await _plugin.cancelAll();
}
}
Future<int> pendingCount() async {
final pending = await _plugin.pendingNotificationRequests();
return pending.length;
}
Future<void> syncHabitNotifications({
required AppSettings settings,
}) async {
await init();
if (!settings.alertsEnabled || !settings.dailyChecklistReminderEnabled) {
await cancelChecklistReminder();
return;
}
final reminderTime = settings.checklistReminderTime ?? '09:00';
final parts = _parseHourMinute(reminderTime);
if (parts == null) {
await cancelChecklistReminder();
return;
}
final now = DateTime.now();
var target = DateTime(
now.year,
now.month,
now.day,
parts.$1,
parts.$2,
);
if (!target.isAfter(now)) {
target = target.add(const Duration(days: 1));
}
await _plugin.zonedSchedule(
id: _checklistReminderId,
title: 'Checklist Ibadah Harian',
body: 'Jangan lupa perbarui progres ibadah hari ini.',
scheduledDate: tz.TZDateTime.from(target, tz.local),
notificationDetails: _habitDetails,
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
matchDateTimeComponents: DateTimeComponents.time,
payload: 'checklist|daily|${target.toIso8601String()}',
);
}
Future<void> cancelChecklistReminder() async {
await _plugin.cancel(id: _checklistReminderId);
}
int nonPrayerNotificationId(String seed) {
var hash = 17;
for (final rune in seed.runes) {
hash = 41 * hash + rune;
}
return 900000 + (hash.abs() % 80000);
}
Future<bool> showNonPrayerAlert({
required AppSettings settings,
required int id,
required String title,
required String body,
String payloadType = 'system',
bool silent = false,
bool bypassQuietHours = false,
bool bypassDailyCap = false,
}) async {
await init();
final runtime = NotificationRuntimeService.instance;
if (!settings.alertsEnabled) return false;
if (!bypassQuietHours && runtime.isWithinQuietHours(settings)) return false;
if (!bypassDailyCap &&
runtime.nonPrayerPushCountToday() >= settings.maxNonPrayerPushPerDay) {
return false;
}
await _plugin.show(
id: id,
title: title,
body: body,
notificationDetails: silent ? _systemDetails : _habitDetails,
payload: '$payloadType|non_prayer|${DateTime.now().toIso8601String()}',
);
if (!bypassDailyCap) {
await runtime.incrementNonPrayerPushCount();
}
await NotificationAnalyticsService.instance.track(
'notif_push_fired',
dimensions: <String, dynamic>{
'event_type': payloadType,
'channel': 'push',
},
);
return true;
}
Future<NotificationPermissionStatus> getPermissionStatus() async {
await init();
try {
if (Platform.isAndroid) {
final androidPlugin = _plugin.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
final notificationsAllowed =
await androidPlugin?.areNotificationsEnabled() ?? true;
final exactAlarmAllowed =
await androidPlugin?.canScheduleExactNotifications() ?? true;
return NotificationPermissionStatus(
notificationsAllowed: notificationsAllowed,
exactAlarmAllowed: exactAlarmAllowed,
);
}
if (Platform.isIOS) {
final iosPlugin = _plugin.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>();
final options = await iosPlugin?.checkPermissions();
return NotificationPermissionStatus(
notificationsAllowed: options?.isEnabled ?? true,
exactAlarmAllowed: true,
);
}
if (Platform.isMacOS) {
final macPlugin = _plugin.resolvePlatformSpecificImplementation<
MacOSFlutterLocalNotificationsPlugin>();
final options = await macPlugin?.checkPermissions();
return NotificationPermissionStatus(
notificationsAllowed: options?.isEnabled ?? true,
exactAlarmAllowed: true,
);
}
} catch (_) {
// Fallback to non-blocking defaults if platform query fails.
}
return const NotificationPermissionStatus(
notificationsAllowed: true,
exactAlarmAllowed: true,
);
}
Future<List<NotificationPendingAlert>> pendingAlerts() async {
final pending = await _plugin.pendingNotificationRequests();
final alerts = pending.map(_mapPendingRequest).toList()
..sort((a, b) {
final aTime = a.scheduledAt;
final bTime = b.scheduledAt;
if (aTime == null && bTime == null) return a.id.compareTo(b.id);
if (aTime == null) return 1;
if (bTime == null) return -1;
return aTime.compareTo(bTime);
});
return alerts;
}
NotificationPendingAlert _mapPendingRequest(PendingNotificationRequest raw) {
final payload = raw.payload ?? '';
final parts = payload.split('|');
if (parts.length >= 3) {
final type = parts[0].trim().toLowerCase();
final title = raw.title ?? '${_labelForType(type)}${parts[1].trim()}';
final body = raw.body ?? '';
final scheduledAt = DateTime.tryParse(parts[2].trim());
return NotificationPendingAlert(
id: raw.id,
type: type,
title: title,
body: body,
scheduledAt: scheduledAt,
);
}
final fallbackType = _inferTypeFromTitle(raw.title ?? '');
return NotificationPendingAlert(
id: raw.id,
type: fallbackType,
title: raw.title ?? 'Pengingat',
body: raw.body ?? '',
scheduledAt: null,
);
}
String _inferTypeFromTitle(String title) {
final normalized = title.toLowerCase();
if (normalized.contains('iqamah')) return 'iqamah';
if (normalized.contains('adzan')) return 'adhan';
return 'alert';
}
String _labelForType(String type) {
switch (type) {
case 'adhan':
return 'Adzan';
case 'iqamah':
return 'Iqamah';
case 'checklist':
return 'Checklist';
case 'streak_risk':
return 'Streak';
case 'system':
return 'Sistem';
default:
return 'Pengingat';
}
}
(int, int)? _parseHourMinute(String hhmm) {
final match = RegExp(r'^(\d{1,2}):(\d{2})$').firstMatch(hhmm.trim());
if (match == null) return null;
final hour = int.tryParse(match.group(1) ?? '');
final minute = int.tryParse(match.group(2) ?? '');
if (hour == null || minute == null) return null;
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
return (hour, minute);
}
}

View File

@@ -0,0 +1,104 @@
import 'dart:convert';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http;
import '../local/models/app_settings.dart';
import 'notification_inbox_service.dart';
import 'notification_runtime_service.dart';
import 'notification_service.dart';
/// Pulls server-defined notification content and maps it to local inbox items.
class RemoteNotificationContentService {
RemoteNotificationContentService._();
static final RemoteNotificationContentService instance =
RemoteNotificationContentService._();
final NotificationInboxService _inbox = NotificationInboxService.instance;
final NotificationRuntimeService _runtime =
NotificationRuntimeService.instance;
Future<void> sync({
required AppSettings settings,
}) async {
if (!settings.inboxEnabled) return;
final endpoint = (dotenv.env['NOTIFICATION_FEED_URL'] ?? '').trim();
if (endpoint.isEmpty) return;
final now = DateTime.now();
final lastSync = _runtime.lastRemoteSyncAt();
if (lastSync != null &&
now.difference(lastSync) < const Duration(hours: 6)) {
return;
}
try {
final response = await http.get(Uri.parse(endpoint));
if (response.statusCode < 200 || response.statusCode >= 300) return;
final decoded = json.decode(response.body);
final items = _extractItems(decoded);
if (items.isEmpty) return;
for (final raw in items) {
final id = (raw['id'] ?? '').toString().trim();
final title = (raw['title'] ?? '').toString().trim();
final body = (raw['body'] ?? '').toString().trim();
if (id.isEmpty || title.isEmpty || body.isEmpty) continue;
final deeplink = (raw['deeplink'] ?? '').toString().trim();
final type = (raw['type'] ?? 'content').toString().trim();
final expiresAt =
DateTime.tryParse((raw['expiresAt'] ?? '').toString().trim());
final isPinned = raw['isPinned'] == true;
final shouldPush = raw['push'] == true;
await _inbox.addItem(
title: title,
body: body,
type: type.isEmpty ? 'content' : type,
source: 'remote',
deeplink: deeplink.isEmpty ? null : deeplink,
dedupeKey: 'remote.$id',
expiresAt: expiresAt,
isPinned: isPinned,
meta: <String, dynamic>{'remoteId': id},
);
if (shouldPush && settings.alertsEnabled) {
final notif = NotificationService.instance;
await notif.showNonPrayerAlert(
settings: settings,
id: notif.nonPrayerNotificationId('remote.push.$id'),
title: title,
body: body,
payloadType: 'content',
silent: true,
);
}
}
await _runtime.setLastRemoteSyncAt(now);
} catch (_) {
// Non-fatal: remote feed is optional.
}
}
List<Map<String, dynamic>> _extractItems(dynamic decoded) {
if (decoded is List) {
return decoded.whereType<Map>().map(_toStringKeyedMap).toList();
}
if (decoded is Map) {
final list = decoded['items'];
if (list is List) {
return list.whereType<Map>().map(_toStringKeyedMap).toList();
}
}
return const <Map<String, dynamic>>[];
}
Map<String, dynamic> _toStringKeyedMap(Map raw) {
return raw.map((key, value) => MapEntry(key.toString(), value));
}
}

View File

@@ -0,0 +1,47 @@
import '../local/models/app_settings.dart';
import 'notification_inbox_service.dart';
/// Phase-4 bridge for future FCM/APNs wiring.
///
/// This app currently ships without Firebase/APNs SDK setup in source control.
/// Once push SDK is configured, route incoming payloads to [ingestPayload].
class RemotePushService {
RemotePushService._();
static final RemotePushService instance = RemotePushService._();
final NotificationInboxService _inbox = NotificationInboxService.instance;
Future<void> init() async {
// Reserved for SDK wiring (FCM/APNs token registration, topic subscription).
}
Future<void> ingestPayload(
Map<String, dynamic> payload, {
AppSettings? settings,
}) async {
if (settings != null && !settings.inboxEnabled) return;
final id = (payload['id'] ?? payload['messageId'] ?? '').toString().trim();
final title = (payload['title'] ?? '').toString().trim();
final body = (payload['body'] ?? '').toString().trim();
if (id.isEmpty || title.isEmpty || body.isEmpty) return;
final type = (payload['type'] ?? 'content').toString().trim();
final deeplink = (payload['deeplink'] ?? '').toString().trim();
final expiresAt =
DateTime.tryParse((payload['expiresAt'] ?? '').toString().trim());
final isPinned = payload['isPinned'] == true;
await _inbox.addItem(
title: title,
body: body,
type: type.isEmpty ? 'content' : type,
source: 'remote',
deeplink: deeplink.isEmpty ? null : deeplink,
dedupeKey: 'remote.push.$id',
expiresAt: expiresAt,
isPinned: isPinned,
meta: <String, dynamic>{'remoteId': id},
);
}
}