562 lines
17 KiB
Dart
562 lines
17 KiB
Dart
import 'dart:convert';
|
|
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 = _asString(audioValue);
|
|
if (audioUrl.isEmpty) return {};
|
|
return {
|
|
'01': audioUrl,
|
|
'02': audioUrl,
|
|
'03': audioUrl,
|
|
'04': audioUrl,
|
|
'05': audioUrl,
|
|
'06': audioUrl,
|
|
};
|
|
}
|
|
|
|
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) {
|
|
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,
|
|
},
|
|
'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<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;
|
|
}
|
|
}
|