Polish navigation, Quran flows, and sharing UX
This commit is contained in:
@@ -21,6 +21,8 @@ class HiveBoxes {
|
||||
static const String dzikirCounters = 'dzikir_counters';
|
||||
static const String bookmarks = 'bookmarks';
|
||||
static const String cachedPrayerTimes = 'cached_prayer_times';
|
||||
static const String notificationInbox = 'notification_inbox';
|
||||
static const String notificationRuntime = 'notification_runtime';
|
||||
}
|
||||
|
||||
/// Initialize Hive and open all boxes.
|
||||
@@ -56,6 +58,8 @@ Future<void> initHive() async {
|
||||
await Hive.openBox<DzikirCounter>(HiveBoxes.dzikirCounters);
|
||||
await Hive.openBox<QuranBookmark>(HiveBoxes.bookmarks);
|
||||
await Hive.openBox<CachedPrayerTimes>(HiveBoxes.cachedPrayerTimes);
|
||||
await Hive.openBox(HiveBoxes.notificationInbox);
|
||||
await Hive.openBox(HiveBoxes.notificationRuntime);
|
||||
|
||||
// MIGRATION: Delete legacy logs that crash due to type casts (Map<String, bool> vs Map<String, ShalatLog>)
|
||||
final keysToDelete = [];
|
||||
@@ -69,7 +73,7 @@ Future<void> initHive() async {
|
||||
keysToDelete.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (keysToDelete.isNotEmpty) {
|
||||
await worshipBox.deleteAll(keysToDelete);
|
||||
debugPrint('Deleted ${keysToDelete.length} legacy worship logs.');
|
||||
@@ -89,26 +93,53 @@ Future<void> seedDefaults() async {
|
||||
if (checklistBox.isEmpty) {
|
||||
final defaults = [
|
||||
ChecklistItem(
|
||||
id: 'fajr', title: 'Sholat Fajr', category: 'sholat_fardhu', sortOrder: 0),
|
||||
id: 'fajr',
|
||||
title: 'Sholat Fajr',
|
||||
category: 'sholat_fardhu',
|
||||
sortOrder: 0),
|
||||
ChecklistItem(
|
||||
id: 'dhuhr', title: 'Sholat Dhuhr', category: 'sholat_fardhu', sortOrder: 1),
|
||||
id: 'dhuhr',
|
||||
title: 'Sholat Dhuhr',
|
||||
category: 'sholat_fardhu',
|
||||
sortOrder: 1),
|
||||
ChecklistItem(
|
||||
id: 'asr', title: 'Sholat Asr', category: 'sholat_fardhu', sortOrder: 2),
|
||||
id: 'asr',
|
||||
title: 'Sholat Asr',
|
||||
category: 'sholat_fardhu',
|
||||
sortOrder: 2),
|
||||
ChecklistItem(
|
||||
id: 'maghrib', title: 'Sholat Maghrib', category: 'sholat_fardhu', sortOrder: 3),
|
||||
id: 'maghrib',
|
||||
title: 'Sholat Maghrib',
|
||||
category: 'sholat_fardhu',
|
||||
sortOrder: 3),
|
||||
ChecklistItem(
|
||||
id: 'isha', title: 'Sholat Isha', category: 'sholat_fardhu', sortOrder: 4),
|
||||
id: 'isha',
|
||||
title: 'Sholat Isha',
|
||||
category: 'sholat_fardhu',
|
||||
sortOrder: 4),
|
||||
ChecklistItem(
|
||||
id: 'tilawah', title: 'Tilawah Quran', category: 'tilawah',
|
||||
subtitle: '1 Juz', sortOrder: 5),
|
||||
id: 'tilawah',
|
||||
title: 'Tilawah Quran',
|
||||
category: 'tilawah',
|
||||
subtitle: '1 Juz',
|
||||
sortOrder: 5),
|
||||
ChecklistItem(
|
||||
id: 'dzikir_pagi', title: 'Dzikir Pagi', category: 'dzikir',
|
||||
subtitle: '1 session', sortOrder: 6),
|
||||
id: 'dzikir_pagi',
|
||||
title: 'Dzikir Pagi',
|
||||
category: 'dzikir',
|
||||
subtitle: '1 session',
|
||||
sortOrder: 6),
|
||||
ChecklistItem(
|
||||
id: 'dzikir_petang', title: 'Dzikir Petang', category: 'dzikir',
|
||||
subtitle: '1 session', sortOrder: 7),
|
||||
id: 'dzikir_petang',
|
||||
title: 'Dzikir Petang',
|
||||
category: 'dzikir',
|
||||
subtitle: '1 session',
|
||||
sortOrder: 7),
|
||||
ChecklistItem(
|
||||
id: 'rawatib', title: 'Sholat Sunnah Rawatib', category: 'sunnah', sortOrder: 8),
|
||||
id: 'rawatib',
|
||||
title: 'Sholat Sunnah Rawatib',
|
||||
category: 'sunnah',
|
||||
sortOrder: 8),
|
||||
ChecklistItem(
|
||||
id: 'shodaqoh', title: 'Shodaqoh', category: 'charity', sortOrder: 9),
|
||||
];
|
||||
|
||||
@@ -77,6 +77,33 @@ class AppSettings extends HiveObject {
|
||||
@HiveField(23)
|
||||
bool dzikirHapticOnCount;
|
||||
|
||||
@HiveField(24)
|
||||
bool alertsEnabled;
|
||||
|
||||
@HiveField(25)
|
||||
bool inboxEnabled;
|
||||
|
||||
@HiveField(26)
|
||||
bool streakRiskEnabled;
|
||||
|
||||
@HiveField(27)
|
||||
bool dailyChecklistReminderEnabled;
|
||||
|
||||
@HiveField(28)
|
||||
bool weeklySummaryEnabled;
|
||||
|
||||
@HiveField(29)
|
||||
String quietHoursStart; // HH:mm
|
||||
|
||||
@HiveField(30)
|
||||
String quietHoursEnd; // HH:mm
|
||||
|
||||
@HiveField(31)
|
||||
int maxNonPrayerPushPerDay;
|
||||
|
||||
@HiveField(32)
|
||||
bool mirrorAdzanToInbox;
|
||||
|
||||
AppSettings({
|
||||
this.userName = 'User',
|
||||
this.userEmail = '',
|
||||
@@ -102,6 +129,15 @@ class AppSettings extends HiveObject {
|
||||
this.dzikirCounterButtonPosition = 'bottomPill',
|
||||
this.dzikirAutoAdvance = true,
|
||||
this.dzikirHapticOnCount = true,
|
||||
this.alertsEnabled = true,
|
||||
this.inboxEnabled = true,
|
||||
this.streakRiskEnabled = true,
|
||||
this.dailyChecklistReminderEnabled = false,
|
||||
this.weeklySummaryEnabled = true,
|
||||
this.quietHoursStart = '22:00',
|
||||
this.quietHoursEnd = '05:00',
|
||||
this.maxNonPrayerPushPerDay = 2,
|
||||
this.mirrorAdzanToInbox = false,
|
||||
}) : adhanEnabled = adhanEnabled ??
|
||||
{
|
||||
'fajr': true,
|
||||
|
||||
@@ -20,34 +20,65 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
|
||||
userName: fields.containsKey(0) ? fields[0] as String? ?? '' : '',
|
||||
userEmail: fields.containsKey(1) ? fields[1] as String? ?? '' : '',
|
||||
themeModeIndex: fields.containsKey(2) ? fields[2] as int? ?? 0 : 0,
|
||||
arabicFontSize: fields.containsKey(3) ? fields[3] as double? ?? 26.0 : 26.0,
|
||||
arabicFontSize:
|
||||
fields.containsKey(3) ? fields[3] as double? ?? 26.0 : 26.0,
|
||||
uiLanguage: fields.containsKey(4) ? fields[4] as String? ?? 'id' : 'id',
|
||||
adhanEnabled: fields.containsKey(5) ? (fields[5] as Map?)?.cast<String, bool>() : null,
|
||||
iqamahOffset: fields.containsKey(6) ? (fields[6] as Map?)?.cast<String, int>() : null,
|
||||
checklistReminderTime: fields.containsKey(7) ? fields[7] as String? : null,
|
||||
adhanEnabled: fields.containsKey(5)
|
||||
? (fields[5] as Map?)?.cast<String, bool>()
|
||||
: null,
|
||||
iqamahOffset: fields.containsKey(6)
|
||||
? (fields[6] as Map?)?.cast<String, int>()
|
||||
: null,
|
||||
checklistReminderTime:
|
||||
fields.containsKey(7) ? fields[7] as String? : null,
|
||||
lastLat: fields.containsKey(8) ? fields[8] as double? : null,
|
||||
lastLng: fields.containsKey(9) ? fields[9] as double? : null,
|
||||
lastCityName: fields.containsKey(10) ? fields[10] as String? : null,
|
||||
rawatibLevel: fields.containsKey(11) ? fields[11] as int? ?? 1 : 1,
|
||||
tilawahTargetValue: fields.containsKey(12) ? fields[12] as int? ?? 1 : 1,
|
||||
tilawahTargetUnit: fields.containsKey(13) ? fields[13] as String? ?? 'Juz' : 'Juz',
|
||||
tilawahAutoSync: fields.containsKey(14) ? fields[14] as bool? ?? false : false,
|
||||
tilawahTargetUnit:
|
||||
fields.containsKey(13) ? fields[13] as String? ?? 'Juz' : 'Juz',
|
||||
tilawahAutoSync:
|
||||
fields.containsKey(14) ? fields[14] as bool? ?? false : false,
|
||||
trackDzikir: fields.containsKey(15) ? fields[15] as bool? ?? true : true,
|
||||
trackPuasa: fields.containsKey(16) ? fields[16] as bool? ?? false : false,
|
||||
showLatin: fields.containsKey(17) ? fields[17] as bool? ?? true : true,
|
||||
showTerjemahan: fields.containsKey(18) ? fields[18] as bool? ?? true : true,
|
||||
showTerjemahan:
|
||||
fields.containsKey(18) ? fields[18] as bool? ?? true : true,
|
||||
simpleMode: fields.containsKey(19) ? fields[19] as bool? ?? false : false,
|
||||
dzikirDisplayMode: fields.containsKey(20) ? fields[20] as String? ?? 'list' : 'list',
|
||||
dzikirCounterButtonPosition: fields.containsKey(21) ? fields[21] as String? ?? 'bottomPill' : 'bottomPill',
|
||||
dzikirAutoAdvance: fields.containsKey(22) ? fields[22] as bool? ?? true : true,
|
||||
dzikirHapticOnCount: fields.containsKey(23) ? fields[23] as bool? ?? true : true,
|
||||
dzikirDisplayMode:
|
||||
fields.containsKey(20) ? fields[20] as String? ?? 'list' : 'list',
|
||||
dzikirCounterButtonPosition: fields.containsKey(21)
|
||||
? fields[21] as String? ?? 'bottomPill'
|
||||
: 'bottomPill',
|
||||
dzikirAutoAdvance:
|
||||
fields.containsKey(22) ? fields[22] as bool? ?? true : true,
|
||||
dzikirHapticOnCount:
|
||||
fields.containsKey(23) ? fields[23] as bool? ?? true : true,
|
||||
alertsEnabled:
|
||||
fields.containsKey(24) ? fields[24] as bool? ?? true : true,
|
||||
inboxEnabled: fields.containsKey(25) ? fields[25] as bool? ?? true : true,
|
||||
streakRiskEnabled:
|
||||
fields.containsKey(26) ? fields[26] as bool? ?? true : true,
|
||||
dailyChecklistReminderEnabled:
|
||||
fields.containsKey(27) ? fields[27] as bool? ?? false : false,
|
||||
weeklySummaryEnabled:
|
||||
fields.containsKey(28) ? fields[28] as bool? ?? true : true,
|
||||
quietHoursStart:
|
||||
fields.containsKey(29) ? fields[29] as String? ?? '22:00' : '22:00',
|
||||
quietHoursEnd:
|
||||
fields.containsKey(30) ? fields[30] as String? ?? '05:00' : '05:00',
|
||||
maxNonPrayerPushPerDay:
|
||||
fields.containsKey(31) ? fields[31] as int? ?? 2 : 2,
|
||||
mirrorAdzanToInbox:
|
||||
fields.containsKey(32) ? fields[32] as bool? ?? false : false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, AppSettings obj) {
|
||||
writer
|
||||
..writeByte(24)
|
||||
..writeByte(33)
|
||||
..writeByte(0)
|
||||
..write(obj.userName)
|
||||
..writeByte(1)
|
||||
@@ -95,7 +126,25 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
|
||||
..writeByte(22)
|
||||
..write(obj.dzikirAutoAdvance)
|
||||
..writeByte(23)
|
||||
..write(obj.dzikirHapticOnCount);
|
||||
..write(obj.dzikirHapticOnCount)
|
||||
..writeByte(24)
|
||||
..write(obj.alertsEnabled)
|
||||
..writeByte(25)
|
||||
..write(obj.inboxEnabled)
|
||||
..writeByte(26)
|
||||
..write(obj.streakRiskEnabled)
|
||||
..writeByte(27)
|
||||
..write(obj.dailyChecklistReminderEnabled)
|
||||
..writeByte(28)
|
||||
..write(obj.weeklySummaryEnabled)
|
||||
..writeByte(29)
|
||||
..write(obj.quietHoursStart)
|
||||
..writeByte(30)
|
||||
..write(obj.quietHoursEnd)
|
||||
..writeByte(31)
|
||||
..write(obj.maxNonPrayerPushPerDay)
|
||||
..writeByte(32)
|
||||
..write(obj.mirrorAdzanToInbox);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -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')
|
||||
|
||||
39
lib/data/services/notification_analytics_service.dart
Normal file
39
lib/data/services/notification_analytics_service.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
299
lib/data/services/notification_event_producer_service.dart
Normal file
299
lib/data/services/notification_event_producer_service.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
299
lib/data/services/notification_inbox_service.dart
Normal file
299
lib/data/services/notification_inbox_service.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
24
lib/data/services/notification_orchestrator_service.dart
Normal file
24
lib/data/services/notification_orchestrator_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
86
lib/data/services/notification_runtime_service.dart
Normal file
86
lib/data/services/notification_runtime_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
104
lib/data/services/remote_notification_content_service.dart
Normal file
104
lib/data/services/remote_notification_content_service.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
47
lib/data/services/remote_push_service.dart
Normal file
47
lib/data/services/remote_push_service.dart
Normal 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},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user