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 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>? _surahListCache; final Map> _surahCache = {}; List>? _allAyahCache; List>? _tafsirCache; List>? _asbabCache; List>? _juzCache; List>? _themeCache; List>? _asmaCache; List>? _doaCache; List>? _haditsCache; final Map>> _dzikirByTypeCache = {}; final Map>> _wordByWordCache = {}; final Map>> _pageAyahCache = {}; Future _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) { return decoded['data']; } return null; } catch (_) { return null; } } Future _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) { 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 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 _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 _normalizeAyahAudioMap(dynamic audioValue) { if (audioValue is Map) { final normalized = {}; 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 _mapSurahSummary(Map 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 _mapAyah(Map 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>> getAllSurahs() async { if (_surahListCache != null) return _surahListCache!; final raw = await _getData('/v1/quran/surah'); if (raw is! List) return []; _surahListCache = raw.whereType>().map(_mapSurahSummary).toList(); return _surahListCache!; } Future?> getSurah(int number) async { if (_surahCache.containsKey(number)) { return _surahCache[number]; } final surahs = await getAllSurahs(); Map? 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(_mapAyah).toList(); final mapped = { ...summary, 'ayat': mappedAyah, }; _surahCache[number] = mapped; return mapped; } Future?> 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>.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?> 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 = { 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>> 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((item) { return { 'word': _asString(item['word']), 'arab': _asString(item['arab']), 'indo': _asString(item['indo']), }; }).toList(); _wordByWordCache[key] = mapped; return mapped; } Future>> getAllAyah() async { if (_allAyahCache != null) return _allAyahCache!; final raw = await _getData('/v1/quran/ayah'); if (raw is! List) return []; _allAyahCache = raw.whereType>().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>> getTafsirBySurah(int surahId) async { if (_tafsirCache == null) { final raw = await _getData('/v1/quran/tafsir'); if (raw is! List) return []; _tafsirCache = raw.whereType>().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 = >{}; final ayahBySurahAyah = >{}; 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 = >[]; for (final tafsir in _tafsirCache!) { final tafsirId = _asInt(tafsir['id']); final tafsirAyah = _asInt(tafsir['ayah']); Map? 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>> getAsbabBySurah(int surahId) async { if (_asbabCache == null) { final raw = await _getData('/v1/quran/asbab'); if (raw is! List) return []; _asbabCache = raw.whereType>().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 = >{}; final ayahBySurahAyah = >{}; 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 = >[]; for (final asbab in _asbabCache!) { final asbabId = _asInt(asbab['id']); final asbabAyah = _asInt(asbab['ayah']); Map? 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>> getJuzList() async { if (_juzCache != null) return _juzCache!; final raw = await _getData('/v1/quran/juz'); if (raw is! List) return []; _juzCache = raw.whereType>().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>> 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((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>> getThemes() async { if (_themeCache != null) return _themeCache!; final raw = await _getData('/v1/quran/theme'); if (raw is! List) return []; _themeCache = raw.whereType>().map((item) { return { 'id': _asInt(item['id']), 'name': _asString(item['name']), }; }).toList(); return _themeCache!; } Future>> 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>> getAsmaulHusna() async { if (_asmaCache != null) return _asmaCache!; final raw = await _getData('/v1/quran/asma'); if (raw is! List) return []; _asmaCache = raw.whereType>().map((item) { return { 'id': _asInt(item['id']), 'arab': _asString(item['arab']), 'latin': _asString(item['latin']), 'indo': _asString(item['indo']), }; }).toList(); return _asmaCache!; } Future>> 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((item) { return { 'judul': _asString(item['judul']), 'arab': _asString(item['arab']), 'indo': _asString(item['indo']), 'source': _asString(item['source']), }; }).toList(); return _doaCache!; } Future>> 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((item) { return { 'no': _asInt(item['no']), 'judul': _asString(item['judul']), 'arab': _asString(item['arab']), 'indo': _asString(item['indo']), }; }).toList(); return _haditsCache!; } Future>> 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 = >[]; for (var i = 0; i < raw.length; i++) { final item = raw[i]; if (item is! Map) 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; } }