Files
jamshalat-diary/lib/data/services/muslim_api_service.dart
2026-03-18 00:07:10 +07:00

658 lines
20 KiB
Dart

import 'dart:convert';
import 'dart:math';
import 'package:http/http.dart' as http;
class MuslimApiException implements Exception {
final String message;
const MuslimApiException(this.message);
@override
String toString() => 'MuslimApiException: $message';
}
/// Service for muslim.backoffice.biz.id API.
///
/// Exposes Quran, dzikir, doa, hadits, and enrichment data while preserving
/// the data contract currently expected by Quran and dashboard UI widgets.
class MuslimApiService {
static const String _baseUrl = 'https://muslim.backoffice.biz.id';
static final MuslimApiService instance = MuslimApiService._();
MuslimApiService._();
static const Map<String, String> qariNames = {
'01': 'Abdullah Al-Juhany',
'02': 'Abdul Muhsin Al-Qasim',
'03': 'Abdurrahman As-Sudais',
'04': 'Ibrahim Al-Dossari',
'05': 'Misyari Rasyid Al-Afasi',
'06': 'Yasser Al-Dosari',
};
List<Map<String, dynamic>>? _surahListCache;
final Map<int, Map<String, dynamic>> _surahCache = {};
List<Map<String, dynamic>>? _allAyahCache;
List<Map<String, dynamic>>? _tafsirCache;
List<Map<String, dynamic>>? _asbabCache;
List<Map<String, dynamic>>? _juzCache;
List<Map<String, dynamic>>? _themeCache;
List<Map<String, dynamic>>? _asmaCache;
List<Map<String, dynamic>>? _doaCache;
List<Map<String, dynamic>>? _haditsCache;
final Map<String, List<Map<String, dynamic>>> _dzikirByTypeCache = {};
final Map<String, List<Map<String, dynamic>>> _wordByWordCache = {};
final Map<int, List<Map<String, dynamic>>> _pageAyahCache = {};
Future<dynamic> _getData(String path) async {
try {
final response = await http.get(Uri.parse('$_baseUrl$path'));
if (response.statusCode != 200) {
return null;
}
final decoded = json.decode(response.body);
if (decoded is Map<String, dynamic>) {
return decoded['data'];
}
return null;
} catch (_) {
return null;
}
}
Future<dynamic> _getDataOrThrow(String path) async {
final response = await http.get(Uri.parse('$_baseUrl$path'));
if (response.statusCode != 200) {
throw MuslimApiException(
'Request failed ($path): HTTP ${response.statusCode}',
);
}
final decoded = json.decode(response.body);
if (decoded is! Map<String, dynamic>) {
throw const MuslimApiException('Invalid API payload shape');
}
final status = _asInt(decoded['status']);
if (status != 200) {
throw MuslimApiException('API returned non-200 status: $status');
}
if (!decoded.containsKey('data')) {
throw const MuslimApiException('API payload missing data key');
}
return decoded['data'];
}
int _asInt(dynamic value, {int fallback = 0}) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value) ?? fallback;
return fallback;
}
String _asString(dynamic value, {String fallback = ''}) {
if (value == null) return fallback;
return value.toString();
}
int _asCount(dynamic value, {int fallback = 1}) {
if (value == null) return fallback;
if (value is int) return value;
if (value is num) return value.toInt();
final text = value.toString();
final match = RegExp(r'\d+').firstMatch(text);
if (match == null) return fallback;
return int.tryParse(match.group(0)!) ?? fallback;
}
String _stableDzikirId(String type, Map<String, dynamic> item) {
final apiId = _asString(item['id']);
if (apiId.isNotEmpty) {
return '${type}_$apiId';
}
final seed = [
type,
_asString(item['type']),
_asString(item['arab']),
_asString(item['indo']),
_asString(item['ulang']),
].join('|');
var hash = 0;
for (final unit in seed.codeUnits) {
hash = ((hash * 31) + unit) & 0x7fffffff;
}
return '${type}_$hash';
}
String _dzikirApiType(String type) {
switch (type) {
case 'petang':
return 'sore';
default:
return type;
}
}
Map<String, String> _normalizeAudioMap(dynamic audioValue) {
final audioUrl = _extractAudioUrl(audioValue);
if (audioUrl.isEmpty) return {};
return {
'01': audioUrl,
'02': audioUrl,
'03': audioUrl,
'04': audioUrl,
'05': audioUrl,
'06': audioUrl,
};
}
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 {
'nomor': number,
'nama': _asString(item['name_short']),
'namaLatin': _asString(item['name_id']),
'jumlahAyat': _asInt(item['number_of_verses']),
'tempatTurun': _asString(item['revelation_id']),
'arti': _asString(item['translation_id']),
'deskripsi': _asString(item['tafsir']),
'audioFull': _normalizeAudioMap(item['audio_url']),
};
}
Map<String, dynamic> _mapAyah(Map<String, dynamic> item) {
return {
'nomorAyat': _asInt(item['ayah']),
'teksArab': _asString(item['arab']),
'teksLatin': _asString(item['latin']),
'teksIndonesia': _asString(item['text']),
'audio': _normalizeAyahAudioMap(item['audio'] ?? item['audio_url']),
'juz': _asInt(item['juz']),
'page': _asInt(item['page']),
'hizb': _asInt(item['hizb']),
'theme': _asString(item['theme']),
'asbab': _asString(item['asbab']),
'notes': _asString(item['notes']),
'surah': _asInt(item['surah']),
'ayahId': _asInt(item['id']),
};
}
Future<List<Map<String, dynamic>>> getAllSurahs() async {
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();
return _surahListCache!;
}
Future<Map<String, dynamic>?> getSurah(int number) async {
if (_surahCache.containsKey(number)) {
return _surahCache[number];
}
final surahs = await getAllSurahs();
Map<String, dynamic>? summary;
for (final surah in surahs) {
if (surah['nomor'] == number) {
summary = surah;
break;
}
}
final rawAyah = await _getData('/v1/quran/ayah/surah?id=$number');
if (summary == null || rawAyah is! List) {
return null;
}
final mappedAyah =
rawAyah.whereType<Map<String, dynamic>>().map(_mapAyah).toList();
final mapped = {
...summary,
'ayat': mappedAyah,
};
_surahCache[number] = mapped;
return mapped;
}
Future<Map<String, dynamic>?> getDailyAyat() async {
try {
final now = DateTime.now();
final dayOfYear = now.difference(DateTime(now.year, 1, 1)).inDays;
final surahId = (dayOfYear % 114) + 1;
final surah = await getSurah(surahId);
if (surah == null) return null;
final ayat = List<Map<String, dynamic>>.from(surah['ayat'] ?? []);
if (ayat.isEmpty) return null;
final ayatIndex = dayOfYear % ayat.length;
final picked = ayat[ayatIndex];
return {
'surahName': surah['namaLatin'] ?? '',
'nomorSurah': surahId,
'nomorAyat': picked['nomorAyat'] ?? 1,
'teksArab': picked['teksArab'] ?? '',
'teksIndonesia': picked['teksIndonesia'] ?? '',
};
} catch (_) {
return null;
}
}
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');
if (raw is! List) return [];
final mapped = raw.whereType<Map<String, dynamic>>().map((item) {
return {
'word': _asString(item['word']),
'arab': _asString(item['arab']),
'indo': _asString(item['indo']),
};
}).toList();
_wordByWordCache[key] = mapped;
return mapped;
}
Future<List<Map<String, dynamic>>> getAllAyah() async {
if (_allAyahCache != null) return _allAyahCache!;
final raw = await _getData('/v1/quran/ayah');
if (raw is! List) return [];
_allAyahCache = raw.whereType<Map<String, dynamic>>().map((item) {
return {
'id': _asInt(item['id']),
'surah': _asInt(item['surah']),
'ayah': _asInt(item['ayah']),
'arab': _asString(item['arab']),
'latin': _asString(item['latin']),
'text': _asString(item['text']),
'juz': _asInt(item['juz']),
'page': _asInt(item['page']),
'hizb': _asInt(item['hizb']),
'theme': _asString(item['theme']),
'asbab': _asString(item['asbab']),
};
}).toList();
return _allAyahCache!;
}
Future<List<Map<String, dynamic>>> getTafsirBySurah(int surahId) async {
if (_tafsirCache == null) {
final raw = await _getData('/v1/quran/tafsir');
if (raw is! List) return [];
_tafsirCache = raw.whereType<Map<String, dynamic>>().map((item) {
return {
'id': _asInt(item['id']),
'ayah': _asInt(item['ayah']),
'wajiz': _asString(item['wajiz']),
'tahlili': _asString(item['tahlili']),
};
}).toList();
}
final allAyah = await getAllAyah();
if (allAyah.isEmpty || _tafsirCache == null) return [];
final ayahById = <int, Map<String, dynamic>>{};
final ayahBySurahAyah = <String, Map<String, dynamic>>{};
for (final ayah in allAyah) {
final id = _asInt(ayah['id']);
final surah = _asInt(ayah['surah']);
final ayahNumber = _asInt(ayah['ayah']);
ayahById[id] = ayah;
ayahBySurahAyah['$surah:$ayahNumber'] = ayah;
}
final result = <Map<String, dynamic>>[];
for (final tafsir in _tafsirCache!) {
final tafsirId = _asInt(tafsir['id']);
final tafsirAyah = _asInt(tafsir['ayah']);
Map<String, dynamic>? ayahMeta = ayahById[tafsirId];
ayahMeta ??= ayahBySurahAyah['$surahId:$tafsirAyah'];
if (ayahMeta == null) continue;
if (ayahMeta['surah'] != surahId) continue;
result.add({
'nomorAyat': _asInt(ayahMeta['ayah'], fallback: tafsirAyah),
'wajiz': tafsir['wajiz'],
'tahlili': tafsir['tahlili'],
});
}
result.sort(
(a, b) => (a['nomorAyat'] as int).compareTo(b['nomorAyat'] as int));
return result;
}
Future<List<Map<String, dynamic>>> getAsbabBySurah(int surahId) async {
if (_asbabCache == null) {
final raw = await _getData('/v1/quran/asbab');
if (raw is! List) return [];
_asbabCache = raw.whereType<Map<String, dynamic>>().map((item) {
return {
'id': _asInt(item['id']),
'ayah': _asInt(item['ayah']),
'text': _asString(item['text']),
};
}).toList();
}
final allAyah = await getAllAyah();
if (allAyah.isEmpty || _asbabCache == null) return [];
final ayahById = <int, Map<String, dynamic>>{};
final ayahBySurahAyah = <String, Map<String, dynamic>>{};
for (final ayah in allAyah) {
final id = _asInt(ayah['id']);
final surah = _asInt(ayah['surah']);
final ayahNumber = _asInt(ayah['ayah']);
ayahById[id] = ayah;
ayahBySurahAyah['$surah:$ayahNumber'] = ayah;
}
final result = <Map<String, dynamic>>[];
for (final asbab in _asbabCache!) {
final asbabId = _asInt(asbab['id']);
final asbabAyah = _asInt(asbab['ayah']);
Map<String, dynamic>? ayahMeta = ayahById[asbabId];
ayahMeta ??= ayahBySurahAyah['$surahId:$asbabAyah'];
if (ayahMeta == null) continue;
if (ayahMeta['surah'] != surahId) continue;
result.add({
'nomorAyat': _asInt(ayahMeta['ayah'], fallback: asbabAyah),
'text': asbab['text'],
});
}
result.sort(
(a, b) => (a['nomorAyat'] as int).compareTo(b['nomorAyat'] as int));
return result;
}
Future<List<Map<String, dynamic>>> getJuzList() async {
if (_juzCache != null) return _juzCache!;
final raw = await _getData('/v1/quran/juz');
if (raw is! List) return [];
_juzCache = raw.whereType<Map<String, dynamic>>().map((item) {
return {
'number': _asInt(item['number']),
'name': _asString(item['name']),
'surah_id_start': _asInt(item['surah_id_start']),
'verse_start': _asInt(item['verse_start']),
'surah_id_end': _asInt(item['surah_id_end']),
'verse_end': _asInt(item['verse_end']),
'name_start_id': _asString(item['name_start_id']),
'name_end_id': _asString(item['name_end_id']),
};
}).toList();
return _juzCache!;
}
Future<List<Map<String, dynamic>>> getAyahByPage(int page) async {
if (_pageAyahCache.containsKey(page)) return _pageAyahCache[page]!;
final raw = await _getData('/v1/quran/ayah/page?id=$page');
if (raw is! List) return [];
final mapped = raw.whereType<Map<String, dynamic>>().map((item) {
return {
'surah': _asInt(item['surah']),
'ayah': _asInt(item['ayah']),
'arab': _asString(item['arab']),
'text': _asString(item['text']),
'theme': _asString(item['theme']),
};
}).toList();
_pageAyahCache[page] = mapped;
return mapped;
}
Future<List<Map<String, dynamic>>> getThemes() async {
if (_themeCache != null) return _themeCache!;
final raw = await _getData('/v1/quran/theme');
if (raw is! List) return [];
_themeCache = raw.whereType<Map<String, dynamic>>().map((item) {
return {
'id': _asInt(item['id']),
'name': _asString(item['name']),
};
}).toList();
return _themeCache!;
}
Future<List<Map<String, dynamic>>> searchAyah(String query) async {
final q = query.trim().toLowerCase();
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();
return results;
}
Future<List<Map<String, dynamic>>> getAsmaulHusna() async {
if (_asmaCache != null) return _asmaCache!;
final raw = await _getData('/v1/quran/asma');
if (raw is! List) return [];
_asmaCache = raw.whereType<Map<String, dynamic>>().map((item) {
return {
'id': _asInt(item['id']),
'arab': _asString(item['arab']),
'latin': _asString(item['latin']),
'indo': _asString(item['indo']),
};
}).toList();
return _asmaCache!;
}
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');
if (raw is! List) {
if (strict) {
throw const MuslimApiException('Invalid doa payload');
}
return [];
}
_doaCache = raw.whereType<Map<String, dynamic>>().map((item) {
return {
'judul': _asString(item['judul']),
'arab': _asString(item['arab']),
'indo': _asString(item['indo']),
'source': _asString(item['source']),
};
}).toList();
return _doaCache!;
}
Future<List<Map<String, dynamic>>> getHaditsList(
{bool strict = false}) async {
if (_haditsCache != null) return _haditsCache!;
final raw = strict
? await _getDataOrThrow('/v1/hadits')
: await _getData('/v1/hadits');
if (raw is! List) {
if (strict) {
throw const MuslimApiException('Invalid hadits payload');
}
return [];
}
_haditsCache = raw.whereType<Map<String, dynamic>>().map((item) {
return {
'no': _asInt(item['no']),
'judul': _asString(item['judul']),
'arab': _asString(item['arab']),
'indo': _asString(item['indo']),
};
}).toList();
return _haditsCache!;
}
Future<List<Map<String, dynamic>>> getDzikirByType(
String type, {
bool strict = false,
}) async {
if (_dzikirByTypeCache.containsKey(type)) {
return _dzikirByTypeCache[type]!;
}
final apiType = _dzikirApiType(type);
final raw = strict
? await _getDataOrThrow('/v1/dzikir?type=$apiType')
: await _getData('/v1/dzikir?type=$apiType');
if (raw is! List) {
if (strict) {
throw MuslimApiException('Invalid dzikir payload for type: $type');
}
return [];
}
final mapped = <Map<String, dynamic>>[];
for (var i = 0; i < raw.length; i++) {
final item = raw[i];
if (item is! Map<String, dynamic>) continue;
mapped.add({
'id': _stableDzikirId(type, item),
'arab': _asString(item['arab']),
'indo': _asString(item['indo']),
'type': _asString(item['type']),
'ulang': _asCount(item['ulang'], fallback: 1),
});
}
_dzikirByTypeCache[type] = mapped;
return mapped;
}
}