feat: checkpoint API migration and dzikir UX updates
This commit is contained in:
@@ -11,11 +11,14 @@ import '../features/checklist/presentation/checklist_screen.dart';
|
||||
import '../features/laporan/presentation/laporan_screen.dart';
|
||||
import '../features/tools/presentation/tools_screen.dart';
|
||||
import '../features/dzikir/presentation/dzikir_screen.dart';
|
||||
import '../features/doa/presentation/doa_screen.dart';
|
||||
import '../features/hadits/presentation/hadits_screen.dart';
|
||||
import '../features/qibla/presentation/qibla_screen.dart';
|
||||
import '../features/quran/presentation/quran_screen.dart';
|
||||
import '../features/quran/presentation/quran_reading_screen.dart';
|
||||
import '../features/quran/presentation/quran_murattal_screen.dart';
|
||||
import '../features/quran/presentation/quran_bookmarks_screen.dart';
|
||||
import '../features/quran/presentation/quran_enrichment_screen.dart';
|
||||
import '../features/settings/presentation/settings_screen.dart';
|
||||
|
||||
/// Navigation key for the shell navigator (bottom-nav screens).
|
||||
@@ -79,6 +82,11 @@ final GoRouter appRouter = GoRouter(
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const QuranScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'enrichment',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const QuranEnrichmentScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'bookmarks',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
@@ -116,6 +124,16 @@ final GoRouter appRouter = GoRouter(
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const QiblaScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'doa',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const DoaScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'hadits',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const HaditsScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Simple Mode Tab: Zikir
|
||||
@@ -128,6 +146,10 @@ final GoRouter appRouter = GoRouter(
|
||||
path: '/quran',
|
||||
builder: (context, state) => const QuranScreen(isSimpleModeTab: true),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'enrichment',
|
||||
builder: (context, state) => const QuranEnrichmentScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'bookmarks',
|
||||
builder: (context, state) => const QuranBookmarksScreen(),
|
||||
@@ -159,6 +181,14 @@ final GoRouter appRouter = GoRouter(
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: '/doa',
|
||||
builder: (context, state) => const DoaScreen(isSimpleModeTab: true),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/hadits',
|
||||
builder: (context, state) => const HaditsScreen(isSimpleModeTab: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
// ── Settings (pushed, no bottom nav) ──
|
||||
|
||||
@@ -64,7 +64,7 @@ class AppTextStyles {
|
||||
static const TextStyle arabicLarge = TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 2.2,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,18 @@ class AppSettings extends HiveObject {
|
||||
@HiveField(19)
|
||||
bool simpleMode; // false = Mode Lengkap, true = Mode Simpel
|
||||
|
||||
@HiveField(20)
|
||||
String dzikirDisplayMode; // 'list' | 'focus'
|
||||
|
||||
@HiveField(21)
|
||||
String dzikirCounterButtonPosition; // 'bottomPill' | 'fabCircle'
|
||||
|
||||
@HiveField(22)
|
||||
bool dzikirAutoAdvance;
|
||||
|
||||
@HiveField(23)
|
||||
bool dzikirHapticOnCount;
|
||||
|
||||
AppSettings({
|
||||
this.userName = 'User',
|
||||
this.userEmail = '',
|
||||
@@ -86,6 +98,10 @@ class AppSettings extends HiveObject {
|
||||
this.showLatin = true,
|
||||
this.showTerjemahan = true,
|
||||
this.simpleMode = false,
|
||||
this.dzikirDisplayMode = 'list',
|
||||
this.dzikirCounterButtonPosition = 'bottomPill',
|
||||
this.dzikirAutoAdvance = true,
|
||||
this.dzikirHapticOnCount = true,
|
||||
}) : adhanEnabled = adhanEnabled ??
|
||||
{
|
||||
'fajr': true,
|
||||
|
||||
@@ -37,13 +37,17 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
|
||||
showLatin: fields.containsKey(17) ? fields[17] 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,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, AppSettings obj) {
|
||||
writer
|
||||
..writeByte(20)
|
||||
..writeByte(24)
|
||||
..writeByte(0)
|
||||
..write(obj.userName)
|
||||
..writeByte(1)
|
||||
@@ -83,7 +87,15 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
|
||||
..writeByte(18)
|
||||
..write(obj.showTerjemahan)
|
||||
..writeByte(19)
|
||||
..write(obj.simpleMode);
|
||||
..write(obj.simpleMode)
|
||||
..writeByte(20)
|
||||
..write(obj.dzikirDisplayMode)
|
||||
..writeByte(21)
|
||||
..write(obj.dzikirCounterButtonPosition)
|
||||
..writeByte(22)
|
||||
..write(obj.dzikirAutoAdvance)
|
||||
..writeByte(23)
|
||||
..write(obj.dzikirHapticOnCount);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
561
lib/data/services/muslim_api_service.dart
Normal file
561
lib/data/services/muslim_api_service.dart
Normal file
@@ -0,0 +1,561 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import '../../../core/widgets/tool_card.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
import '../../../data/local/models/daily_worship_log.dart';
|
||||
import '../../../data/services/equran_service.dart';
|
||||
import '../../../data/services/muslim_api_service.dart';
|
||||
import '../data/prayer_times_provider.dart';
|
||||
|
||||
class DashboardScreen extends ConsumerStatefulWidget {
|
||||
@@ -810,13 +810,57 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ToolCard(
|
||||
icon: LucideIcons.heart,
|
||||
title: 'Kumpulan\nDoa',
|
||||
color: const Color(0xFFE17055),
|
||||
isDark: isDark,
|
||||
onTap: () {
|
||||
final isSimple = Hive.box<AppSettings>(HiveBoxes.settings)
|
||||
.get('default')
|
||||
?.simpleMode ??
|
||||
false;
|
||||
if (isSimple) {
|
||||
context.push('/doa');
|
||||
} else {
|
||||
context.push('/tools/doa');
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ToolCard(
|
||||
icon: LucideIcons.library,
|
||||
title: "Hadits\nArba'in",
|
||||
color: const Color(0xFF6C5CE7),
|
||||
isDark: isDark,
|
||||
onTap: () {
|
||||
final isSimple = Hive.box<AppSettings>(HiveBoxes.settings)
|
||||
.get('default')
|
||||
?.simpleMode ??
|
||||
false;
|
||||
if (isSimple) {
|
||||
context.push('/hadits');
|
||||
} else {
|
||||
context.push('/tools/hadits');
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAyatHariIni(BuildContext context, bool isDark) {
|
||||
return FutureBuilder<Map<String, dynamic>?>(
|
||||
future: EQuranService.instance.getDailyAyat(),
|
||||
future: MuslimApiService.instance.getDailyAyat(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return Container(
|
||||
@@ -870,6 +914,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.8,
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
|
||||
206
lib/features/doa/presentation/doa_screen.dart
Normal file
206
lib/features/doa/presentation/doa_screen.dart
Normal file
@@ -0,0 +1,206 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../data/services/muslim_api_service.dart';
|
||||
|
||||
class DoaScreen extends StatefulWidget {
|
||||
final bool isSimpleModeTab;
|
||||
const DoaScreen({super.key, this.isSimpleModeTab = false});
|
||||
|
||||
@override
|
||||
State<DoaScreen> createState() => _DoaScreenState();
|
||||
}
|
||||
|
||||
class _DoaScreenState extends State<DoaScreen> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
List<Map<String, dynamic>> _allDoa = [];
|
||||
List<Map<String, dynamic>> _filteredDoa = [];
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDoa();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadDoa() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final data = await MuslimApiService.instance.getDoaList(strict: true);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_allDoa = data;
|
||||
_filteredDoa = data;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_allDoa = [];
|
||||
_filteredDoa = [];
|
||||
_loading = false;
|
||||
_error = 'Gagal memuat doa dari server';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchChanged(String value) {
|
||||
final q = value.trim().toLowerCase();
|
||||
if (q.isEmpty) {
|
||||
setState(() => _filteredDoa = _allDoa);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_filteredDoa = _allDoa.where((item) {
|
||||
final title = item['judul']?.toString().toLowerCase() ?? '';
|
||||
final indo = item['indo']?.toString().toLowerCase() ?? '';
|
||||
return title.contains(q) || indo.contains(q);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: !widget.isSimpleModeTab,
|
||||
title: const Text('Kumpulan Doa'),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _loadDoa,
|
||||
icon: const Icon(LucideIcons.refreshCw),
|
||||
tooltip: 'Muat ulang',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: _onSearchChanged,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Cari judul atau isi doa...',
|
||||
prefixIcon: const Icon(LucideIcons.search),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Text(
|
||||
_error!,
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
)
|
||||
: _filteredDoa.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'Doa tidak ditemukan',
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
itemCount: _filteredDoa.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _filteredDoa[index];
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? AppColors.surfaceDark
|
||||
: AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.1)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item['judul']?.toString() ?? '-',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
item['arab']?.toString() ?? '',
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.8,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
item['indo']?.toString() ?? '',
|
||||
style: TextStyle(
|
||||
height: 1.5,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
if ((item['source']?.toString().isNotEmpty ??
|
||||
false)) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'Sumber: ${item['source']}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/dzikir_counter.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
import '../../../data/local/models/dzikir_counter.dart';
|
||||
import '../../../data/services/muslim_api_service.dart';
|
||||
|
||||
class DzikirScreen extends ConsumerStatefulWidget {
|
||||
final bool isSimpleModeTab;
|
||||
@@ -21,15 +22,36 @@ class DzikirScreen extends ConsumerStatefulWidget {
|
||||
class _DzikirScreenState extends ConsumerState<DzikirScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
|
||||
final Map<String, PageController> _pageControllers = {
|
||||
'pagi': PageController(),
|
||||
'petang': PageController(),
|
||||
'solat': PageController(),
|
||||
};
|
||||
|
||||
final Map<String, int> _focusPageIndex = {
|
||||
'pagi': 0,
|
||||
'petang': 0,
|
||||
'solat': 0,
|
||||
};
|
||||
|
||||
List<Map<String, dynamic>> _pagiItems = [];
|
||||
List<Map<String, dynamic>> _petangItems = [];
|
||||
List<Map<String, dynamic>> _sesudahSholatItems = [];
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
|
||||
late Box<DzikirCounter> _counterBox;
|
||||
late String _todayKey;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
_tabController = TabController(length: 3, vsync: this);
|
||||
_tabController.addListener(() {
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
});
|
||||
_counterBox = Hive.box<DzikirCounter>(HiveBoxes.dzikirCounters);
|
||||
_todayKey = DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
_loadData();
|
||||
@@ -38,17 +60,68 @@ class _DzikirScreenState extends ConsumerState<DzikirScreen>
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
for (final controller in _pageControllers.values) {
|
||||
controller.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
final pagiJson =
|
||||
await rootBundle.loadString('assets/dzikir/dzikir_pagi.json');
|
||||
final petangJson =
|
||||
await rootBundle.loadString('assets/dzikir/dzikir_petang.json');
|
||||
setState(() {
|
||||
_pagiItems = List<Map<String, dynamic>>.from(json.decode(pagiJson));
|
||||
_petangItems = List<Map<String, dynamic>>.from(json.decode(petangJson));
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final pagi = await MuslimApiService.instance.getDzikirByType(
|
||||
'pagi',
|
||||
strict: true,
|
||||
);
|
||||
final petang = await MuslimApiService.instance.getDzikirByType(
|
||||
'petang',
|
||||
strict: true,
|
||||
);
|
||||
final solat = await MuslimApiService.instance.getDzikirByType(
|
||||
'solat',
|
||||
strict: true,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_pagiItems = pagi;
|
||||
_petangItems = petang;
|
||||
_sesudahSholatItems = solat;
|
||||
_loading = false;
|
||||
});
|
||||
_ensureValidFocusPages();
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = 'Gagal memuat dzikir dari server';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _ensureValidFocusPages() {
|
||||
_clampFocusPageForPrefix('pagi', _pagiItems.length);
|
||||
_clampFocusPageForPrefix('petang', _petangItems.length);
|
||||
_clampFocusPageForPrefix('solat', _sesudahSholatItems.length);
|
||||
}
|
||||
|
||||
void _clampFocusPageForPrefix(String prefix, int itemLength) {
|
||||
final maxIndex = itemLength > 0 ? itemLength - 1 : 0;
|
||||
final current = _focusPageIndex[prefix] ?? 0;
|
||||
final next = current > maxIndex ? maxIndex : current;
|
||||
_focusPageIndex[prefix] = next;
|
||||
|
||||
final controller = _pageControllers[prefix];
|
||||
if (controller == null || !controller.hasClients) return;
|
||||
if (controller.page?.round() == next) return;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !controller.hasClients) return;
|
||||
controller.jumpToPage(next);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,9 +136,15 @@ class _DzikirScreenState extends ConsumerState<DzikirScreen>
|
||||
);
|
||||
}
|
||||
|
||||
void _increment(String dzikirId, int target) {
|
||||
bool _increment(
|
||||
String dzikirId,
|
||||
int target, {
|
||||
required bool hapticEnabled,
|
||||
}) {
|
||||
final key = '${dzikirId}_$_todayKey';
|
||||
var counter = _counterBox.get(key);
|
||||
final wasComplete = counter != null && counter.count >= counter.target;
|
||||
|
||||
if (counter == null) {
|
||||
counter = DzikirCounter(
|
||||
dzikirId: dzikirId,
|
||||
@@ -74,40 +153,42 @@ class _DzikirScreenState extends ConsumerState<DzikirScreen>
|
||||
target: target,
|
||||
);
|
||||
_counterBox.put(key, counter);
|
||||
} else {
|
||||
if (counter.count < counter.target) {
|
||||
counter.count++;
|
||||
counter.save();
|
||||
}
|
||||
} else if (counter.count < counter.target) {
|
||||
counter.count++;
|
||||
counter.save();
|
||||
}
|
||||
|
||||
final isCompleteNow = counter.count >= counter.target;
|
||||
if (hapticEnabled) {
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
setState(() {});
|
||||
// Haptic feedback
|
||||
HapticFeedback.lightImpact();
|
||||
return !wasComplete && isCompleteNow;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final box = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final isSimpleMode = box.get('default')?.simpleMode ?? false;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: !widget.isSimpleModeTab,
|
||||
title: const Text('Dzikir Pagi & Petang'),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(LucideIcons.info),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Tabs
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: TabBar(
|
||||
return ValueListenableBuilder<Box<AppSettings>>(
|
||||
valueListenable:
|
||||
Hive.box<AppSettings>(HiveBoxes.settings).listenable(keys: ['default']),
|
||||
builder: (_, settingsBox, __) {
|
||||
final settings = settingsBox.get('default') ?? AppSettings();
|
||||
final isFocusMode = settings.dzikirDisplayMode == 'focus';
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: !widget.isSimpleModeTab,
|
||||
title: const Text('Dzikir Harian'),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _loadData,
|
||||
icon: const Icon(LucideIcons.refreshCw),
|
||||
tooltip: 'Muat ulang',
|
||||
),
|
||||
],
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: AppColors.primary,
|
||||
unselectedLabelColor: isDark
|
||||
@@ -116,47 +197,151 @@ class _DzikirScreenState extends ConsumerState<DzikirScreen>
|
||||
indicatorColor: AppColors.primary,
|
||||
indicatorWeight: 3,
|
||||
labelStyle:
|
||||
const TextStyle(fontWeight: FontWeight.w700, fontSize: 14),
|
||||
const TextStyle(fontWeight: FontWeight.w700, fontSize: 13),
|
||||
tabs: const [
|
||||
Tab(text: 'Pagi'),
|
||||
Tab(text: 'Petang'),
|
||||
Tab(text: 'Sesudah Sholat'),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildDzikirList(context, isDark, _pagiItems, 'pagi',
|
||||
'Dzikir Pagi', 'Dibaca setelah shalat Shubuh hingga terbit matahari'),
|
||||
_buildDzikirList(context, isDark, _petangItems, 'petang',
|
||||
'Dzikir Petang', 'Dibaca setelah shalat Ashar hingga terbenam matahari'),
|
||||
],
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? _buildErrorState(isDark)
|
||||
: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
isFocusMode
|
||||
? _buildFocusModeTab(
|
||||
context,
|
||||
isDark,
|
||||
settings,
|
||||
items: _pagiItems,
|
||||
prefix: 'pagi',
|
||||
title: 'Dzikir Pagi',
|
||||
subtitle:
|
||||
'Dibaca setelah shalat Subuh hingga terbit matahari.',
|
||||
)
|
||||
: _buildDzikirList(
|
||||
context,
|
||||
isDark,
|
||||
settings,
|
||||
_pagiItems,
|
||||
'pagi',
|
||||
'Dzikir Pagi',
|
||||
'Dibaca setelah shalat Subuh hingga terbit matahari.',
|
||||
),
|
||||
isFocusMode
|
||||
? _buildFocusModeTab(
|
||||
context,
|
||||
isDark,
|
||||
settings,
|
||||
items: _petangItems,
|
||||
prefix: 'petang',
|
||||
title: 'Dzikir Petang',
|
||||
subtitle:
|
||||
'Dibaca setelah Ashar hingga terbenam matahari.',
|
||||
)
|
||||
: _buildDzikirList(
|
||||
context,
|
||||
isDark,
|
||||
settings,
|
||||
_petangItems,
|
||||
'petang',
|
||||
'Dzikir Petang',
|
||||
'Dibaca setelah Ashar hingga terbenam matahari.',
|
||||
),
|
||||
isFocusMode
|
||||
? _buildFocusModeTab(
|
||||
context,
|
||||
isDark,
|
||||
settings,
|
||||
items: _sesudahSholatItems,
|
||||
prefix: 'solat',
|
||||
title: 'Dzikir Sesudah Sholat',
|
||||
subtitle:
|
||||
'Dibaca setelah shalat fardhu sesuai kebutuhan.',
|
||||
)
|
||||
: _buildDzikirList(
|
||||
context,
|
||||
isDark,
|
||||
settings,
|
||||
_sesudahSholatItems,
|
||||
'solat',
|
||||
'Dzikir Sesudah Sholat',
|
||||
'Dibaca setelah shalat fardhu sesuai kebutuhan.',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState(bool isDark) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.wifiOff,
|
||||
size: 42,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_error!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDzikirList(BuildContext context, bool isDark,
|
||||
List<Map<String, dynamic>> items, String prefix, String title, String subtitle) {
|
||||
Widget _buildDzikirList(
|
||||
BuildContext context,
|
||||
bool isDark,
|
||||
AppSettings settings,
|
||||
List<Map<String, dynamic>> items,
|
||||
String prefix,
|
||||
String title,
|
||||
String subtitle,
|
||||
) {
|
||||
if (items.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
return _buildEmptyState(
|
||||
isDark,
|
||||
title: 'Belum ada data dzikir',
|
||||
subtitle: 'Data untuk tab ini belum tersedia.',
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: items.length + 1, // +1 for header
|
||||
itemCount: items.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(title,
|
||||
style: const TextStyle(
|
||||
fontSize: 22, fontWeight: FontWeight.w800)),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
@@ -174,8 +359,8 @@ class _DzikirScreenState extends ConsumerState<DzikirScreen>
|
||||
}
|
||||
|
||||
final item = items[index - 1];
|
||||
final dzikirId = '${prefix}_${item['id']}';
|
||||
final target = (item['count'] as num?)?.toInt() ?? 1;
|
||||
final dzikirId = _resolveDzikirId(item, prefix, index - 1);
|
||||
final target = (item['ulang'] as num?)?.toInt() ?? 1;
|
||||
final counter = _getCounter(dzikirId, target);
|
||||
final isComplete = counter.count >= counter.target;
|
||||
|
||||
@@ -197,13 +382,14 @@ class _DzikirScreenState extends ConsumerState<DzikirScreen>
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header row: count badge + number
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 4),
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
@@ -230,44 +416,37 @@ class _DzikirScreenState extends ConsumerState<DzikirScreen>
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Arabic text
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Text(
|
||||
item['arabic'] ?? '',
|
||||
item['arab']?.toString() ?? '',
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 2.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Transliteration
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
item['transliteration'] ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Translation
|
||||
Text(
|
||||
'"${item['translation'] ?? ''}"',
|
||||
'"${item['indo']?.toString() ?? ''}"',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Counter button
|
||||
GestureDetector(
|
||||
onTap: () => _increment(dzikirId, target),
|
||||
onTap: () => _increment(
|
||||
dzikirId,
|
||||
target,
|
||||
hapticEnabled: settings.dzikirHapticOnCount,
|
||||
),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
@@ -281,7 +460,9 @@ class _DzikirScreenState extends ConsumerState<DzikirScreen>
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
isComplete ? LucideIcons.check : LucideIcons.fingerprint,
|
||||
isComplete
|
||||
? LucideIcons.check
|
||||
: LucideIcons.fingerprint,
|
||||
size: 18,
|
||||
color: isComplete
|
||||
? AppColors.primary
|
||||
@@ -289,7 +470,7 @@ class _DzikirScreenState extends ConsumerState<DzikirScreen>
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${counter.count} / $target',
|
||||
isComplete ? 'Selesai' : '${counter.count} / $target',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
@@ -309,4 +490,433 @@ class _DzikirScreenState extends ConsumerState<DzikirScreen>
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFocusModeTab(
|
||||
BuildContext context,
|
||||
bool isDark,
|
||||
AppSettings settings, {
|
||||
required List<Map<String, dynamic>> items,
|
||||
required String prefix,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
}) {
|
||||
if (items.isEmpty) {
|
||||
return _buildEmptyState(
|
||||
isDark,
|
||||
title: 'Belum ada data dzikir',
|
||||
subtitle: 'Data untuk tab ini belum tersedia.',
|
||||
);
|
||||
}
|
||||
|
||||
final controller = _pageControllers[prefix]!;
|
||||
final rawCurrent = _focusPageIndex[prefix] ?? 0;
|
||||
final currentIndex = rawCurrent.clamp(0, items.length - 1);
|
||||
final currentItem = items[currentIndex];
|
||||
final currentId = _resolveDzikirId(currentItem, prefix, currentIndex);
|
||||
final currentTarget = (currentItem['ulang'] as num?)?.toInt() ?? 1;
|
||||
final currentCounter = _getCounter(currentId, currentTarget);
|
||||
final isComplete = currentCounter.count >= currentCounter.target;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: Text(
|
||||
'Item ${currentIndex + 1} dari ${items.length}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
PageView.builder(
|
||||
controller: controller,
|
||||
itemCount: items.length,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
_focusPageIndex[prefix] = index;
|
||||
});
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
final dzikirId = _resolveDzikirId(item, prefix, index);
|
||||
final target = (item['ulang'] as num?)?.toInt() ?? 1;
|
||||
final counter = _getCounter(dzikirId, target);
|
||||
final complete = counter.count >= counter.target;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 92),
|
||||
child: _buildFocusCard(
|
||||
isDark,
|
||||
item: item,
|
||||
index: index,
|
||||
target: target,
|
||||
counter: counter,
|
||||
isComplete: complete,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (settings.dzikirCounterButtonPosition == 'fabCircle')
|
||||
Positioned(
|
||||
right: 8,
|
||||
bottom: 12,
|
||||
child: _buildFocusCounterFab(
|
||||
isDark,
|
||||
isComplete: isComplete,
|
||||
label: isComplete
|
||||
? 'Selesai'
|
||||
: '${currentCounter.count}/$currentTarget',
|
||||
onTap: () => _onFocusCounterTap(
|
||||
context,
|
||||
settings,
|
||||
prefix,
|
||||
items,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 12,
|
||||
child: _buildFocusCounterPill(
|
||||
isComplete: isComplete,
|
||||
label: isComplete
|
||||
? 'Selesai'
|
||||
: '${currentCounter.count} / $currentTarget',
|
||||
onTap: () => _onFocusCounterTap(
|
||||
context,
|
||||
settings,
|
||||
prefix,
|
||||
items,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFocusCard(
|
||||
bool isDark, {
|
||||
required Map<String, dynamic> item,
|
||||
required int index,
|
||||
required int target,
|
||||
required DzikirCounter counter,
|
||||
required bool isComplete,
|
||||
}) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isComplete
|
||||
? AppColors.primary.withValues(alpha: 0.3)
|
||||
: (isDark
|
||||
? AppColors.primary.withValues(alpha: 0.08)
|
||||
: AppColors.cream),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: Text(
|
||||
'$target KALI',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${(index + 1).toString().padLeft(2, '0')}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Text(
|
||||
item['arab']?.toString() ?? '',
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 2.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'"${item['indo']?.toString() ?? ''}"',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (isComplete)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: Text(
|
||||
'Selesai (${counter.count}/$target)',
|
||||
style: TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFocusCounterPill({
|
||||
required bool isComplete,
|
||||
required String label,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: isComplete
|
||||
? AppColors.primary.withValues(alpha: 0.15)
|
||||
: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
isComplete ? LucideIcons.check : LucideIcons.fingerprint,
|
||||
size: 18,
|
||||
color: isComplete ? AppColors.primary : AppColors.onPrimary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isComplete ? AppColors.primary : AppColors.onPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFocusCounterFab(
|
||||
bool isDark, {
|
||||
required bool isComplete,
|
||||
required String label,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isComplete
|
||||
? AppColors.primary.withValues(alpha: 0.15)
|
||||
: AppColors.primary,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: (isDark ? Colors.black : Colors.black26)
|
||||
.withValues(alpha: 0.14),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
isComplete ? LucideIcons.check : LucideIcons.fingerprint,
|
||||
size: 18,
|
||||
color: isComplete ? AppColors.primary : AppColors.onPrimary,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isComplete ? AppColors.primary : AppColors.onPrimary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onFocusCounterTap(
|
||||
BuildContext context,
|
||||
AppSettings settings,
|
||||
String prefix,
|
||||
List<Map<String, dynamic>> items,
|
||||
) {
|
||||
if (items.isEmpty) return;
|
||||
|
||||
final currentIndex = (_focusPageIndex[prefix] ?? 0).clamp(0, items.length - 1);
|
||||
final item = items[currentIndex];
|
||||
final dzikirId = _resolveDzikirId(item, prefix, currentIndex);
|
||||
final target = (item['ulang'] as num?)?.toInt() ?? 1;
|
||||
|
||||
final becameComplete = _increment(
|
||||
dzikirId,
|
||||
target,
|
||||
hapticEnabled: settings.dzikirHapticOnCount,
|
||||
);
|
||||
|
||||
if (!becameComplete) return;
|
||||
|
||||
final isLast = currentIndex == items.length - 1;
|
||||
if (settings.dzikirAutoAdvance && !isLast) {
|
||||
final controller = _pageControllers[prefix];
|
||||
if (controller != null && controller.hasClients) {
|
||||
controller.nextPage(
|
||||
duration: const Duration(milliseconds: 240),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLast) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Semua dzikir pada tab ini selesai'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _resolveDzikirId(Map<String, dynamic> item, String prefix, int index) {
|
||||
final rawId = item['id']?.toString();
|
||||
if (rawId != null && rawId.isNotEmpty) {
|
||||
return rawId;
|
||||
}
|
||||
return '${prefix}_${index + 1}';
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(
|
||||
bool isDark, {
|
||||
required String title,
|
||||
required String subtitle,
|
||||
}) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.inbox,
|
||||
size: 42,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
223
lib/features/hadits/presentation/hadits_screen.dart
Normal file
223
lib/features/hadits/presentation/hadits_screen.dart
Normal file
@@ -0,0 +1,223 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../data/services/muslim_api_service.dart';
|
||||
|
||||
class HaditsScreen extends StatefulWidget {
|
||||
final bool isSimpleModeTab;
|
||||
const HaditsScreen({super.key, this.isSimpleModeTab = false});
|
||||
|
||||
@override
|
||||
State<HaditsScreen> createState() => _HaditsScreenState();
|
||||
}
|
||||
|
||||
class _HaditsScreenState extends State<HaditsScreen> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
List<Map<String, dynamic>> _allHadits = [];
|
||||
List<Map<String, dynamic>> _filteredHadits = [];
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadHadits();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadHadits() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final data = await MuslimApiService.instance.getHaditsList(strict: true);
|
||||
if (!mounted) return;
|
||||
data.sort((a, b) {
|
||||
final aa = (a['no'] as num?)?.toInt() ?? 0;
|
||||
final bb = (b['no'] as num?)?.toInt() ?? 0;
|
||||
return aa.compareTo(bb);
|
||||
});
|
||||
setState(() {
|
||||
_allHadits = data;
|
||||
_filteredHadits = data;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_allHadits = [];
|
||||
_filteredHadits = [];
|
||||
_loading = false;
|
||||
_error = 'Gagal memuat hadits dari server';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchChanged(String value) {
|
||||
final q = value.trim().toLowerCase();
|
||||
if (q.isEmpty) {
|
||||
setState(() => _filteredHadits = _allHadits);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_filteredHadits = _allHadits.where((item) {
|
||||
final title = item['judul']?.toString().toLowerCase() ?? '';
|
||||
final indo = item['indo']?.toString().toLowerCase() ?? '';
|
||||
return title.contains(q) || indo.contains(q);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: !widget.isSimpleModeTab,
|
||||
title: const Text("Hadits Arba'in"),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _loadHadits,
|
||||
icon: const Icon(LucideIcons.refreshCw),
|
||||
tooltip: 'Muat ulang',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: _onSearchChanged,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Cari judul atau isi hadits...',
|
||||
prefixIcon: const Icon(LucideIcons.search),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Text(
|
||||
_error!,
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
)
|
||||
: _filteredHadits.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'Hadits tidak ditemukan',
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
itemCount: _filteredHadits.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _filteredHadits[index];
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? AppColors.surfaceDark
|
||||
: AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.1)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary
|
||||
.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
'${item['no'] ?? '-'}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item['judul']?.toString() ?? '-',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
item['arab']?.toString() ?? '',
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.8,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
item['indo']?.toString() ?? '',
|
||||
style: TextStyle(
|
||||
height: 1.5,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,14 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
|
||||
bool _showLatin = true;
|
||||
bool _showTerjemahan = true;
|
||||
|
||||
String _readingRoute(int surahId, int verseId) {
|
||||
final isSimple =
|
||||
Hive.box<AppSettings>(HiveBoxes.settings).get('default')?.simpleMode ??
|
||||
false;
|
||||
final base = isSimple ? '/quran' : '/tools/quran';
|
||||
return '$base/$surahId?startVerse=$verseId';
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -184,7 +192,7 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
|
||||
final dateStr = DateFormat('dd MMM yyyy, HH:mm').format(bookmark.savedAt);
|
||||
|
||||
return InkWell(
|
||||
onTap: () => context.push('/tools/quran/${bookmark.surahId}?startVerse=${bookmark.verseId}'),
|
||||
onTap: () => context.push(_readingRoute(bookmark.surahId, bookmark.verseId)),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -252,6 +260,7 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.8,
|
||||
),
|
||||
),
|
||||
@@ -287,7 +296,8 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => context.push('/tools/quran/${bookmark.surahId}?startVerse=${bookmark.verseId}'),
|
||||
onPressed: () =>
|
||||
context.push(_readingRoute(bookmark.surahId, bookmark.verseId)),
|
||||
icon: const Icon(LucideIcons.bookOpen, size: 18),
|
||||
label: const Text('Lanjutkan Membaca'),
|
||||
style: FilledButton.styleFrom(
|
||||
|
||||
773
lib/features/quran/presentation/quran_enrichment_screen.dart
Normal file
773
lib/features/quran/presentation/quran_enrichment_screen.dart
Normal file
@@ -0,0 +1,773 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../data/services/muslim_api_service.dart';
|
||||
|
||||
class QuranEnrichmentScreen extends StatefulWidget {
|
||||
const QuranEnrichmentScreen({super.key});
|
||||
|
||||
@override
|
||||
State<QuranEnrichmentScreen> createState() => _QuranEnrichmentScreenState();
|
||||
}
|
||||
|
||||
class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final TextEditingController _pageController = TextEditingController(text: '1');
|
||||
|
||||
List<Map<String, dynamic>> _surahs = [];
|
||||
List<Map<String, dynamic>> _searchResults = [];
|
||||
List<Map<String, dynamic>> _tafsirItems = [];
|
||||
List<Map<String, dynamic>> _asbabItems = [];
|
||||
List<Map<String, dynamic>> _juzItems = [];
|
||||
List<Map<String, dynamic>> _pageItems = [];
|
||||
List<Map<String, dynamic>> _themeItems = [];
|
||||
List<Map<String, dynamic>> _asmaItems = [];
|
||||
|
||||
int _selectedSurahId = 1;
|
||||
int _selectedPage = 1;
|
||||
bool _loadingInit = true;
|
||||
bool _loadingSearch = false;
|
||||
bool _loadingTafsir = false;
|
||||
bool _loadingAsbab = false;
|
||||
bool _loadingPage = false;
|
||||
String? _error;
|
||||
|
||||
final Set<String> _expandedWordByWord = {};
|
||||
final Map<String, List<Map<String, dynamic>>> _wordByWord = {};
|
||||
final Set<String> _loadingWordByWord = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 7, vsync: this);
|
||||
_bootstrap();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
_searchController.dispose();
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _bootstrap() async {
|
||||
setState(() {
|
||||
_loadingInit = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final surahs = await MuslimApiService.instance.getAllSurahs();
|
||||
final juz = await MuslimApiService.instance.getJuzList();
|
||||
final themes = await MuslimApiService.instance.getThemes();
|
||||
final asma = await MuslimApiService.instance.getAsmaulHusna();
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_surahs = surahs;
|
||||
_selectedSurahId = surahs.isNotEmpty
|
||||
? ((surahs.first['nomor'] as int?) ?? 1)
|
||||
: 1;
|
||||
_juzItems = juz;
|
||||
_themeItems = themes;
|
||||
_asmaItems = asma;
|
||||
_loadingInit = false;
|
||||
});
|
||||
|
||||
await _loadTafsirForSelectedSurah();
|
||||
await _loadAsbabForSelectedSurah();
|
||||
await _loadPageAyah();
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loadingInit = false;
|
||||
_error = 'Gagal memuat data enrichment';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runSearch() async {
|
||||
final query = _searchController.text.trim();
|
||||
if (query.isEmpty) {
|
||||
setState(() => _searchResults = []);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _loadingSearch = true);
|
||||
final result = await MuslimApiService.instance.searchAyah(query);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_searchResults = result;
|
||||
_loadingSearch = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadTafsirForSelectedSurah() async {
|
||||
setState(() => _loadingTafsir = true);
|
||||
final result =
|
||||
await MuslimApiService.instance.getTafsirBySurah(_selectedSurahId);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_tafsirItems = result;
|
||||
_loadingTafsir = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadAsbabForSelectedSurah() async {
|
||||
setState(() => _loadingAsbab = true);
|
||||
final result = await MuslimApiService.instance.getAsbabBySurah(_selectedSurahId);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_asbabItems = result;
|
||||
_loadingAsbab = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadPageAyah() async {
|
||||
setState(() => _loadingPage = true);
|
||||
final page = int.tryParse(_pageController.text.trim()) ?? _selectedPage;
|
||||
final safePage = page.clamp(1, 604);
|
||||
final result = await MuslimApiService.instance.getAyahByPage(safePage);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_selectedPage = safePage;
|
||||
_pageController.text = '$safePage';
|
||||
_pageItems = result;
|
||||
_loadingPage = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _toggleWordByWord(Map<String, dynamic> ayah) async {
|
||||
final surah = (ayah['surah'] as num?)?.toInt();
|
||||
final ayahNum = (ayah['ayah'] as num?)?.toInt();
|
||||
if (surah == null || ayahNum == null) return;
|
||||
|
||||
final key = '$surah:$ayahNum';
|
||||
final expanded = _expandedWordByWord.contains(key);
|
||||
|
||||
if (expanded) {
|
||||
setState(() => _expandedWordByWord.remove(key));
|
||||
return;
|
||||
}
|
||||
|
||||
if (_wordByWord.containsKey(key)) {
|
||||
setState(() => _expandedWordByWord.add(key));
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_loadingWordByWord.add(key);
|
||||
_expandedWordByWord.add(key);
|
||||
});
|
||||
|
||||
final words = await MuslimApiService.instance.getWordByWord(surah, ayahNum);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_wordByWord[key] = words;
|
||||
_loadingWordByWord.remove(key);
|
||||
});
|
||||
}
|
||||
|
||||
String _surahNameById(int surahId) {
|
||||
for (final s in _surahs) {
|
||||
if (s['nomor'] == surahId) {
|
||||
return s['namaLatin']?.toString() ?? 'Surah $surahId';
|
||||
}
|
||||
}
|
||||
return 'Surah $surahId';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Quran Enrichment'),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _bootstrap,
|
||||
icon: const Icon(LucideIcons.refreshCw),
|
||||
tooltip: 'Muat ulang',
|
||||
),
|
||||
],
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
isScrollable: true,
|
||||
labelColor: AppColors.primary,
|
||||
unselectedLabelColor: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
indicatorColor: AppColors.primary,
|
||||
tabs: const [
|
||||
Tab(text: 'Cari'),
|
||||
Tab(text: 'Tafsir'),
|
||||
Tab(text: 'Asbab'),
|
||||
Tab(text: 'Juz'),
|
||||
Tab(text: 'Halaman'),
|
||||
Tab(text: 'Tema'),
|
||||
Tab(text: 'Asmaul Husna'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: _loadingInit
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Text(
|
||||
_error!,
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
)
|
||||
: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildSearchTab(context, isDark),
|
||||
_buildTafsirTab(context, isDark),
|
||||
_buildAsbabTab(context, isDark),
|
||||
_buildJuzTab(context, isDark),
|
||||
_buildPageTab(context, isDark),
|
||||
_buildThemeTab(context, isDark),
|
||||
_buildAsmaTab(context, isDark),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchTab(BuildContext context, bool isDark) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
textInputAction: TextInputAction.search,
|
||||
onSubmitted: (_) => _runSearch(),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Cari ayat, tema, atau kata kunci...',
|
||||
prefixIcon: const Icon(LucideIcons.search),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: _runSearch,
|
||||
child: const Text('Cari'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _loadingSearch
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _searchResults.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'Belum ada hasil pencarian',
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
itemCount: _searchResults.length,
|
||||
itemBuilder: (context, index) {
|
||||
final ayah = _searchResults[index];
|
||||
final surahId = (ayah['surah'] as num?)?.toInt() ?? 0;
|
||||
final ayahNum = (ayah['ayah'] as num?)?.toInt() ?? 0;
|
||||
final key = '$surahId:$ayahNum';
|
||||
final expanded = _expandedWordByWord.contains(key);
|
||||
final words = _wordByWord[key] ?? const [];
|
||||
final loadingWords = _loadingWordByWord.contains(key);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? AppColors.surfaceDark
|
||||
: AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.1)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'${_surahNameById(surahId)} : $ayahNum',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () => _toggleWordByWord(ayah),
|
||||
icon: Icon(
|
||||
expanded
|
||||
? LucideIcons.chevronUp
|
||||
: LucideIcons.languages,
|
||||
size: 16,
|
||||
),
|
||||
label: Text(
|
||||
expanded ? 'Tutup' : 'Per Kata',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
ayah['arab']?.toString() ?? '',
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.8,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
ayah['text']?.toString() ?? '',
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
if (expanded) ...[
|
||||
const SizedBox(height: 12),
|
||||
if (loadingWords)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
else if (words.isEmpty)
|
||||
Text(
|
||||
'Data kata tidak tersedia untuk ayat ini.',
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
)
|
||||
else
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: words.map((word) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary
|
||||
.withValues(alpha: 0.08),
|
||||
borderRadius:
|
||||
BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
word['arab']?.toString() ?? '',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
word['word']?.toString() ?? '',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
word['indo']?.toString() ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: isDark
|
||||
? AppColors
|
||||
.textSecondaryDark
|
||||
: AppColors
|
||||
.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTafsirTab(BuildContext context, bool isDark) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildSurahSelector(
|
||||
onChanged: (value) {
|
||||
setState(() => _selectedSurahId = value);
|
||||
_loadTafsirForSelectedSurah();
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: _loadingTafsir
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _tafsirItems.isEmpty
|
||||
? _emptyText(isDark, 'Belum ada data tafsir untuk surah ini')
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
itemCount: _tafsirItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _tafsirItems[index];
|
||||
final ayah = item['nomorAyat']?.toString() ?? '-';
|
||||
final wajiz = item['wajiz']?.toString() ?? '';
|
||||
final tahlili = item['tahlili']?.toString() ?? '';
|
||||
return _buildCard(
|
||||
isDark,
|
||||
title: 'Ayat $ayah',
|
||||
body: '$wajiz\n\n$tahlili',
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAsbabTab(BuildContext context, bool isDark) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildSurahSelector(
|
||||
onChanged: (value) {
|
||||
setState(() => _selectedSurahId = value);
|
||||
_loadAsbabForSelectedSurah();
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: _loadingAsbab
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _asbabItems.isEmpty
|
||||
? _emptyText(
|
||||
isDark,
|
||||
'Belum ada data asbabun nuzul untuk surah ini',
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
itemCount: _asbabItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _asbabItems[index];
|
||||
final ayah = item['nomorAyat']?.toString() ?? '-';
|
||||
return _buildCard(
|
||||
isDark,
|
||||
title: 'Ayat $ayah',
|
||||
body: item['text']?.toString() ?? '',
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildJuzTab(BuildContext context, bool isDark) {
|
||||
if (_juzItems.isEmpty) {
|
||||
return _emptyText(isDark, 'Data juz tidak tersedia');
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
itemCount: _juzItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _juzItems[index];
|
||||
final number = item['number']?.toString() ?? '-';
|
||||
final startName = item['name_start_id']?.toString() ?? '-';
|
||||
final endName = item['name_end_id']?.toString() ?? '-';
|
||||
final startVerse = item['verse_start']?.toString() ?? '-';
|
||||
final endVerse = item['verse_end']?.toString() ?? '-';
|
||||
|
||||
return _buildCard(
|
||||
isDark,
|
||||
title: 'Juz $number',
|
||||
body:
|
||||
'Mulai: $startName ayat $startVerse\nSelesai: $endName ayat $endVerse',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPageTab(BuildContext context, bool isDark) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _pageController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Nomor Halaman (1-604)',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: _loadPageAyah,
|
||||
child: const Text('Tampilkan'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _loadingPage
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _pageItems.isEmpty
|
||||
? _emptyText(isDark, 'Tidak ada data untuk halaman ini')
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
itemCount: _pageItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _pageItems[index];
|
||||
final surahId = (item['surah'] as num?)?.toInt() ?? 0;
|
||||
final ayah = item['ayah']?.toString() ?? '-';
|
||||
|
||||
return _buildCard(
|
||||
isDark,
|
||||
title: '${_surahNameById(surahId)} : $ayah',
|
||||
body:
|
||||
'${item['arab']?.toString() ?? ''}\n\n${item['text']?.toString() ?? ''}',
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThemeTab(BuildContext context, bool isDark) {
|
||||
if (_themeItems.isEmpty) {
|
||||
return _emptyText(isDark, 'Data tema belum tersedia');
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
itemCount: _themeItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _themeItems[index];
|
||||
return _buildCard(
|
||||
isDark,
|
||||
title: 'Tema #${item['id'] ?? '-'}',
|
||||
body: item['name']?.toString() ?? '',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAsmaTab(BuildContext context, bool isDark) {
|
||||
if (_asmaItems.isEmpty) {
|
||||
return _emptyText(isDark, 'Data Asmaul Husna tidak tersedia');
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
itemCount: _asmaItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _asmaItems[index];
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.1)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
'${item['id'] ?? '-'}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item['arab']?.toString() ?? '',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
item['latin']?.toString() ?? '',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
item['indo']?.toString() ?? '',
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSurahSelector({required ValueChanged<int> onChanged}) {
|
||||
if (_surahs.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: _emptyText(
|
||||
Theme.of(context).brightness == Brightness.dark,
|
||||
'Data surah tidak tersedia',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppColors.primary.withValues(alpha: 0.2)),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<int>(
|
||||
value: _selectedSurahId,
|
||||
isExpanded: true,
|
||||
items: _surahs.map((surah) {
|
||||
final id = (surah['nomor'] as num?)?.toInt() ?? 1;
|
||||
final name = surah['namaLatin']?.toString() ?? 'Surah $id';
|
||||
return DropdownMenuItem<int>(
|
||||
value: id,
|
||||
child: Text('$id. $name'),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
onChanged(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCard(bool isDark, {required String title, required String body}) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.1)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(body, style: const TextStyle(height: 1.5)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _emptyText(bool isDark, String text) {
|
||||
return Center(
|
||||
child: Text(
|
||||
text,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,14 +9,11 @@ import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../data/services/equran_service.dart';
|
||||
import '../../../data/services/muslim_api_service.dart';
|
||||
import '../../../data/services/unsplash_service.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
|
||||
/// Quran Murattal (audio player) screen.
|
||||
/// Implements full Surah playback using just_audio and EQuran v2 API.
|
||||
/// Implements full Surah playback using just_audio.
|
||||
class QuranMurattalScreen extends ConsumerStatefulWidget {
|
||||
final String surahId;
|
||||
final String? initialQariId;
|
||||
@@ -77,7 +74,7 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
|
||||
|
||||
Future<void> _initDataAndPlayer() async {
|
||||
final surahNum = int.tryParse(widget.surahId) ?? 1;
|
||||
final data = await EQuranService.instance.getSurah(surahNum);
|
||||
final data = await MuslimApiService.instance.getSurah(surahNum);
|
||||
|
||||
if (data != null && mounted) {
|
||||
setState(() {
|
||||
@@ -186,7 +183,10 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
|
||||
|
||||
void _navigateToSurahNumber(int surahNum, {bool autoplay = false}) {
|
||||
if (surahNum >= 1 && surahNum <= 114) {
|
||||
context.pushReplacement('/tools/quran/$surahNum/murattal?qariId=$_selectedQariId&autoplay=$autoplay');
|
||||
final base = widget.isSimpleModeTab ? '/quran' : '/tools/quran';
|
||||
context.pushReplacement(
|
||||
'$base/$surahNum/murattal?qariId=$_selectedQariId&autoplay=$autoplay',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
...EQuranService.qariNames.entries.map((entry) {
|
||||
...MuslimApiService.qariNames.entries.map((entry) {
|
||||
final isSelected = entry.key == _selectedQariId;
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
@@ -287,7 +287,7 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: FutureBuilder<List<Map<String, dynamic>>>(
|
||||
future: EQuranService.instance.getAllSurahs(),
|
||||
future: MuslimApiService.instance.getAllSurahs(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
@@ -339,7 +339,7 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
|
||||
Navigator.pop(context);
|
||||
if (!isCurrentSurah) {
|
||||
context.pushReplacement(
|
||||
'/tools/quran/$surahNum/murattal?qariId=$_selectedQariId',
|
||||
'${widget.isSimpleModeTab ? '/quran' : '/tools/quran'}/$surahNum/murattal?qariId=$_selectedQariId',
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -360,8 +360,6 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final box = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final isSimpleMode = box.get('default')?.simpleMode ?? false;
|
||||
final surahName = _surahData?['namaLatin'] ?? 'Surah ${widget.surahId}';
|
||||
|
||||
final hasPhoto = _unsplashPhoto != null;
|
||||
@@ -519,7 +517,7 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
|
||||
const SizedBox(height: 32),
|
||||
// Qari name
|
||||
Text(
|
||||
EQuranService.qariNames[_selectedQariId] ?? 'Memuat...',
|
||||
MuslimApiService.qariNames[_selectedQariId] ?? 'Memuat...',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
@@ -742,7 +740,7 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
|
||||
color: _unsplashPhoto != null ? Colors.white : AppColors.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
EQuranService.qariNames[_selectedQariId] ?? 'Ganti Qari',
|
||||
MuslimApiService.qariNames[_selectedQariId] ?? 'Ganti Qari',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
@@ -12,7 +12,7 @@ import '../../../data/local/models/quran_bookmark.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
import '../../../data/local/models/daily_worship_log.dart';
|
||||
import '../../../data/local/models/tilawah_log.dart';
|
||||
import '../../../data/services/equran_service.dart';
|
||||
import '../../../data/services/muslim_api_service.dart';
|
||||
import '../../../core/providers/tilawah_tracking_provider.dart';
|
||||
|
||||
class QuranReadingScreen extends ConsumerStatefulWidget {
|
||||
@@ -151,7 +151,8 @@ class _QuranReadingScreenState extends ConsumerState<QuranReadingScreen> {
|
||||
|
||||
Future<void> _loadSurah() async {
|
||||
final surahNum = int.tryParse(widget.surahId) ?? 1;
|
||||
final data = await EQuranService.instance.getSurah(surahNum);
|
||||
final data = await MuslimApiService.instance.getSurah(surahNum);
|
||||
if (!mounted) return;
|
||||
if (data != null) {
|
||||
setState(() {
|
||||
_surah = data;
|
||||
@@ -356,7 +357,9 @@ class _QuranReadingScreenState extends ConsumerState<QuranReadingScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
} Future<void> _showEndTrackingDialog(TilawahSession session, int endVerseId) async {
|
||||
}
|
||||
|
||||
Future<void> _showEndTrackingDialog(TilawahSession session, int endVerseId) async {
|
||||
final endSurahId = _surah!['nomor'] ?? 1;
|
||||
final endSurahName = _surah!['namaLatin'] ?? '';
|
||||
|
||||
@@ -367,26 +370,30 @@ class _QuranReadingScreenState extends ConsumerState<QuranReadingScreen> {
|
||||
calculatedAyat = (endVerseId - session.startVerseId).abs() + 1;
|
||||
} else {
|
||||
// Cross surah calculation
|
||||
final allSurahs = await EQuranService.instance.getAllSurahs();
|
||||
final allSurahs = await MuslimApiService.instance.getAllSurahs();
|
||||
if (allSurahs.isNotEmpty) {
|
||||
int startSurahIdx = allSurahs.indexWhere((s) => s['nomor'] == session.startSurahId);
|
||||
int endSurahIdx = allSurahs.indexWhere((s) => s['nomor'] == endSurahId);
|
||||
|
||||
// Ensure chronological calculation
|
||||
if (startSurahIdx > endSurahIdx) {
|
||||
final tempIdx = startSurahIdx; startSurahIdx = endSurahIdx; endSurahIdx = tempIdx;
|
||||
|
||||
if (startSurahIdx < 0 || endSurahIdx < 0) {
|
||||
calculatedAyat = (endVerseId - session.startVerseId).abs() + 1;
|
||||
} else {
|
||||
// Ensure chronological calculation
|
||||
if (startSurahIdx > endSurahIdx) {
|
||||
final tempIdx = startSurahIdx; startSurahIdx = endSurahIdx; endSurahIdx = tempIdx;
|
||||
}
|
||||
|
||||
final startSurahData = allSurahs[startSurahIdx];
|
||||
final int totalAyatInStart = (startSurahData['jumlahAyat'] as num?)?.toInt() ?? 1;
|
||||
|
||||
calculatedAyat += (totalAyatInStart - session.startVerseId) + 1; // Ayats inside StartSurah
|
||||
|
||||
for (int i = startSurahIdx + 1; i < endSurahIdx; i++) {
|
||||
calculatedAyat += (allSurahs[i]['jumlahAyat'] as int? ?? 0); // Intermediate Surahs
|
||||
}
|
||||
|
||||
calculatedAyat += endVerseId; // Ayats inside EndSurah
|
||||
}
|
||||
|
||||
final startSurahData = allSurahs[startSurahIdx];
|
||||
final int totalAyatInStart = (startSurahData['jumlahAyat'] as num?)?.toInt() ?? 1;
|
||||
|
||||
calculatedAyat += (totalAyatInStart - session.startVerseId) + 1; // Ayats inside StartSurah
|
||||
|
||||
for (int i = startSurahIdx + 1; i < endSurahIdx; i++) {
|
||||
calculatedAyat += (allSurahs[i]['jumlahAyat'] as int? ?? 0); // Intermediate Surahs
|
||||
}
|
||||
|
||||
calculatedAyat += endVerseId; // Ayats inside EndSurah
|
||||
} else {
|
||||
calculatedAyat = 1; // Fallback
|
||||
}
|
||||
@@ -572,6 +579,11 @@ class _QuranReadingScreenState extends ConsumerState<QuranReadingScreen> {
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.headphones),
|
||||
tooltip: 'Murattal Surah',
|
||||
onPressed: _navigateToMurattal,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
LucideIcons.brain,
|
||||
@@ -620,6 +632,7 @@ class _QuranReadingScreenState extends ConsumerState<QuranReadingScreen> {
|
||||
style: TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
@@ -802,6 +815,7 @@ class _QuranReadingScreenState extends ConsumerState<QuranReadingScreen> {
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 2.0,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -7,7 +7,7 @@ import '../../../app/theme/app_colors.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
import '../../../data/local/models/quran_bookmark.dart';
|
||||
import '../../../data/services/equran_service.dart';
|
||||
import '../../../data/services/muslim_api_service.dart';
|
||||
|
||||
class QuranScreen extends ConsumerStatefulWidget {
|
||||
final bool isSimpleModeTab;
|
||||
@@ -36,7 +36,8 @@ class _QuranScreenState extends ConsumerState<QuranScreen> {
|
||||
}
|
||||
|
||||
Future<void> _loadSurahs() async {
|
||||
final data = await EQuranService.instance.getAllSurahs();
|
||||
final data = await MuslimApiService.instance.getAllSurahs();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_surahs = data;
|
||||
_loading = false;
|
||||
@@ -100,8 +101,6 @@ class _QuranScreenState extends ConsumerState<QuranScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final box = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final isSimpleMode = box.get('default')?.simpleMode ?? false;
|
||||
final filtered = _searchQuery.isEmpty
|
||||
? _surahs
|
||||
: _surahs
|
||||
@@ -119,7 +118,15 @@ class _QuranScreenState extends ConsumerState<QuranScreen> {
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.bookmark),
|
||||
onPressed: () => context.push('/tools/quran/bookmarks'),
|
||||
onPressed: () => context.push(widget.isSimpleModeTab
|
||||
? '/quran/bookmarks'
|
||||
: '/tools/quran/bookmarks'),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.sparkles),
|
||||
onPressed: () => context.push(widget.isSimpleModeTab
|
||||
? '/quran/enrichment'
|
||||
: '/tools/quran/enrichment'),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.settings2),
|
||||
@@ -198,8 +205,9 @@ class _QuranScreenState extends ConsumerState<QuranScreen> {
|
||||
final hasLastRead = box.values.any((b) => b.isLastRead && b.surahId == number);
|
||||
|
||||
return ListTile(
|
||||
onTap: () =>
|
||||
context.push('/tools/quran/$number'),
|
||||
onTap: () => context.push(widget.isSimpleModeTab
|
||||
? '/quran/$number'
|
||||
: '/tools/quran/$number'),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 0, vertical: 6),
|
||||
leading: Container(
|
||||
@@ -250,6 +258,7 @@ class _QuranScreenState extends ConsumerState<QuranScreen> {
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -244,6 +244,70 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── DZIKIR DISPLAY ──
|
||||
_sectionLabel('TAMPILAN DZIKIR'),
|
||||
const SizedBox(height: 12),
|
||||
_buildSegmentSettingCard(
|
||||
isDark,
|
||||
title: 'Mode Tampilan Dzikir',
|
||||
subtitle: 'Pilih daftar baris atau fokus per slide',
|
||||
value: _settings.dzikirDisplayMode,
|
||||
options: const {
|
||||
'list': 'Daftar (Baris)',
|
||||
'focus': 'Fokus (Slide)',
|
||||
},
|
||||
onChanged: (value) {
|
||||
_settings.dzikirDisplayMode = value;
|
||||
_saveSettings();
|
||||
},
|
||||
),
|
||||
if (_settings.dzikirDisplayMode == 'focus') ...[
|
||||
const SizedBox(height: 10),
|
||||
_buildSegmentSettingCard(
|
||||
isDark,
|
||||
title: 'Posisi Tombol Hitung',
|
||||
subtitle: 'Atur posisi tombol pada mode fokus',
|
||||
value: _settings.dzikirCounterButtonPosition,
|
||||
options: const {
|
||||
'bottomPill': 'Pill Bawah',
|
||||
'fabCircle': 'Bulat Kanan Bawah',
|
||||
},
|
||||
onChanged: (value) {
|
||||
_settings.dzikirCounterButtonPosition = value;
|
||||
_saveSettings();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_settingRow(
|
||||
isDark,
|
||||
icon: LucideIcons.arrowRight,
|
||||
iconColor: const Color(0xFF00B894),
|
||||
title: 'Lanjut Otomatis Saat Target Tercapai',
|
||||
trailing: IosToggle(
|
||||
value: _settings.dzikirAutoAdvance,
|
||||
onChanged: (v) {
|
||||
_settings.dzikirAutoAdvance = v;
|
||||
_saveSettings();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
_settingRow(
|
||||
isDark,
|
||||
icon: LucideIcons.vibrate,
|
||||
iconColor: const Color(0xFF6C5CE7),
|
||||
title: 'Getaran Saat Hitung',
|
||||
trailing: IosToggle(
|
||||
value: _settings.dzikirHapticOnCount,
|
||||
onChanged: (v) {
|
||||
_settings.dzikirHapticOnCount = v;
|
||||
_saveSettings();
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── PRAYER SETTINGS ──
|
||||
_sectionLabel('WAKTU SHOLAT'),
|
||||
const SizedBox(height: 12),
|
||||
@@ -438,6 +502,103 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSegmentSettingCard(
|
||||
bool isDark, {
|
||||
required String title,
|
||||
String? subtitle,
|
||||
required String value,
|
||||
required Map<String, String> options,
|
||||
required ValueChanged<String> onChanged,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.08)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? AppColors.backgroundDark
|
||||
: AppColors.backgroundLight,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.08)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: options.entries.map((entry) {
|
||||
final selected = value == entry.key;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => onChanged(entry.key),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? AppColors.primary
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
entry.value,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: selected
|
||||
? AppColors.onPrimary
|
||||
: (isDark
|
||||
? AppColors.textPrimaryDark
|
||||
: AppColors.textPrimaryLight),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showMethodDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../core/widgets/tool_card.dart';
|
||||
import '../../../data/services/equran_service.dart';
|
||||
import '../../../data/services/muslim_api_service.dart';
|
||||
|
||||
class ToolsScreen extends ConsumerWidget {
|
||||
const ToolsScreen({super.key});
|
||||
@@ -29,7 +29,7 @@ class ToolsScreen extends ConsumerWidget {
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
body: Padding(
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -50,9 +50,9 @@ class ToolsScreen extends ConsumerWidget {
|
||||
child: ToolCard(
|
||||
icon: LucideIcons.bookOpen,
|
||||
title: 'Al-Quran\nTerjemahan',
|
||||
color: const Color(0xFF00b894),
|
||||
color: const Color(0xFF00B894),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/quran'),
|
||||
onTap: () => context.push('/tools/quran'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -62,7 +62,7 @@ class ToolsScreen extends ConsumerWidget {
|
||||
title: 'Quran\nMurattal',
|
||||
color: const Color(0xFF7B61FF),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/quran/1/murattal'),
|
||||
onTap: () => context.push('/tools/quran/1/murattal'),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -83,25 +83,65 @@ class ToolsScreen extends ConsumerWidget {
|
||||
Expanded(
|
||||
child: ToolCard(
|
||||
icon: LucideIcons.sparkles,
|
||||
title: 'Tasbih\nDigital',
|
||||
title: 'Dzikir\nHarian',
|
||||
color: AppColors.primary,
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/dzikir'),
|
||||
onTap: () => context.push('/tools/dzikir'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// Ayat Hari Ini
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ToolCard(
|
||||
icon: LucideIcons.heart,
|
||||
title: 'Kumpulan\nDoa',
|
||||
color: const Color(0xFFE17055),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/doa'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ToolCard(
|
||||
icon: LucideIcons.library,
|
||||
title: "Hadits\nArba'in",
|
||||
color: const Color(0xFF6C5CE7),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/hadits'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ToolCard(
|
||||
icon: LucideIcons.sparkles,
|
||||
title: 'Quran\nEnrichment',
|
||||
color: const Color(0xFF00CEC9),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/quran/enrichment'),
|
||||
),
|
||||
),
|
||||
const Expanded(child: SizedBox()),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
FutureBuilder<Map<String, dynamic>?>(
|
||||
future: EQuranService.instance.getDailyAyat(),
|
||||
future: MuslimApiService.instance.getDailyAyat(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.primary.withValues(alpha: 0.08) : const Color(0xFFF5F9F0),
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.08)
|
||||
: const Color(0xFFF5F9F0),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
@@ -109,7 +149,7 @@ class ToolsScreen extends ConsumerWidget {
|
||||
}
|
||||
|
||||
if (!snapshot.hasData || snapshot.data == null) {
|
||||
return const SizedBox.shrink(); // Hide if error/no internet
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final data = snapshot.data!;
|
||||
@@ -117,7 +157,9 @@ class ToolsScreen extends ConsumerWidget {
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.primary.withValues(alpha: 0.08) : const Color(0xFFF5F9F0),
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.08)
|
||||
: const Color(0xFFF5F9F0),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
@@ -131,13 +173,19 @@ class ToolsScreen extends ConsumerWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(LucideIcons.share2,
|
||||
size: 18,
|
||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight),
|
||||
icon: Icon(
|
||||
LucideIcons.share2,
|
||||
size: 18,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
@@ -150,6 +198,7 @@ class ToolsScreen extends ConsumerWidget {
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.8,
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
|
||||
Reference in New Issue
Block a user