feat: checkpoint API migration and dzikir UX updates

This commit is contained in:
Dwindi Ramadhana
2026-03-16 00:30:32 +07:00
parent c4696f2d9f
commit a049129a35
85 changed files with 4285 additions and 211 deletions

View File

@@ -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) ──

View File

@@ -64,7 +64,7 @@ class AppTextStyles {
static const TextStyle arabicLarge = TextStyle(
fontFamily: 'Amiri',
fontSize: 28,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w400,
height: 2.2,
);
}

View File

@@ -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,

View File

@@ -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

View 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;
}
}

View File

@@ -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,

View 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,
),
),
],
],
),
);
},
),
),
],
),
);
}
}

View File

@@ -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,
),
],
),
),
);
}
}

View 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,
),
),
],
),
);
},
),
),
],
),
);
}
}

View File

@@ -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(

View 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,
),
),
);
}
}

View File

@@ -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,

View File

@@ -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,
),
),

View File

@@ -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,
),
),
);

View File

@@ -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,

View File

@@ -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,