Polish navigation, Quran flows, and sharing UX

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

View File

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