import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.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 '../../../core/widgets/arabic_text.dart'; import '../../../core/widgets/bottom_sheet_content_padding.dart'; import '../../../data/local/hive_boxes.dart'; import '../../../data/local/models/app_settings.dart'; import '../../../data/local/models/daily_worship_log.dart'; import '../../../data/local/models/dzikir_counter.dart'; import '../../../data/local/models/dzikir_log.dart'; import '../../../data/local/models/shalat_log.dart'; import '../../../data/services/location_service.dart'; import '../../../data/services/myquran_sholat_service.dart'; import '../../../data/services/muslim_api_service.dart'; import '../../../data/services/prayer_service.dart'; import '../../dashboard/data/prayer_times_provider.dart'; class DzikirScreen extends ConsumerStatefulWidget { final bool isSimpleModeTab; const DzikirScreen({super.key, this.isSimpleModeTab = false}); @override ConsumerState createState() => _DzikirScreenState(); } class _DzikirScreenState extends ConsumerState with SingleTickerProviderStateMixin, WidgetsBindingObserver { static const String _quranBismillah = 'بِسْمِ اللّٰهِ الرَّحْمٰنِ الرَّحِيْمِ'; late TabController _tabController; final Map _pageControllers = { 'pagi': PageController(), 'petang': PageController(), 'harian': PageController(), 'solat': PageController(), }; final Map _focusPageIndex = { 'pagi': 0, 'petang': 0, 'harian': 0, 'solat': 0, }; List> _pagiItems = []; List> _petangItems = []; List> _harianItems = []; List> _sesudahSholatItems = []; Map? _pagiIntroItem; Map? _petangIntroItem; bool _loading = true; String? _error; late Box _counterBox; late String _todayKey; Timer? _dayResetTimer; Timer? _solatResetTimer; bool _refreshingSolatScope = false; String _solatScopeKey = 'solat_bootstrap'; String _solatScopeDateKey = ''; DateTime? _nextSolatResetAt; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _tabController = TabController(length: 4, vsync: this); _tabController.addListener(() { if (!mounted) return; if (_tabController.index == 0) { unawaited(_refreshSolatScope()); } setState(() {}); }); _counterBox = Hive.box(HiveBoxes.dzikirCounters); _todayKey = _currentTodayKey(); _solatScopeDateKey = _todayKey; _scheduleDayResetTimer(); unawaited(_refreshSolatScope(forceSetState: false)); _loadData(); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _dayResetTimer?.cancel(); _solatResetTimer?.cancel(); _tabController.dispose(); for (final controller in _pageControllers.values) { controller.dispose(); } super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { _refreshTodayScope(); _scheduleDayResetTimer(); unawaited(_refreshSolatScope()); } } String _currentTodayKey() => DateFormat('yyyy-MM-dd').format(DateTime.now()); void _scheduleDayResetTimer() { _dayResetTimer?.cancel(); final now = DateTime.now(); final nextMidnight = DateTime(now.year, now.month, now.day + 1); final delay = nextMidnight.difference(now) + const Duration(seconds: 1); _dayResetTimer = Timer(delay, () { _refreshTodayScope(); _scheduleDayResetTimer(); }); } void _refreshTodayScope() { final nextTodayKey = _currentTodayKey(); if (nextTodayKey == _todayKey) return; _todayKey = nextTodayKey; if (_harianItems.isNotEmpty) { _seedHarianProgressFromLinkedDzikir(); } unawaited(_refreshSolatScope()); if (!mounted) return; setState(() {}); } void _scheduleSolatResetTimer() { _solatResetTimer?.cancel(); final nextResetAt = _nextSolatResetAt; if (nextResetAt == null) return; var delay = nextResetAt.difference(DateTime.now()) + const Duration(seconds: 1); if (delay.isNegative) { delay = const Duration(seconds: 1); } _solatResetTimer = Timer(delay, () { unawaited(_refreshSolatScope()); }); } Future _refreshSolatScope({bool forceSetState = true}) async { if (_refreshingSolatScope) return; _refreshingSolatScope = true; try { final scope = await _loadCurrentSolatScope(); if (scope == null) return; final scopeChanged = _solatScopeKey != scope.scopeKey; _solatScopeKey = scope.scopeKey; _solatScopeDateKey = scope.dateKey; _nextSolatResetAt = scope.nextResetAt; _scheduleSolatResetTimer(); if (scopeChanged) { _focusPageIndex['solat'] = 0; final controller = _pageControllers['solat']; if (controller != null && controller.hasClients) { controller.jumpToPage(0); } } if (!mounted) return; if (scopeChanged || forceSetState) { setState(() {}); } } finally { _refreshingSolatScope = false; } } Future<({String scopeKey, String dateKey, DateTime nextResetAt})?> _loadCurrentSolatScope() async { final now = DateTime.now(); final cityId = ref.read(selectedCityIdProvider); final dates = [ DateTime(now.year, now.month, now.day - 1), DateTime(now.year, now.month, now.day), DateTime(now.year, now.month, now.day + 1), ]; final prayerEntries = <({String prayerKey, String dateKey, DateTime time})>[]; for (final date in dates) { final schedule = await _loadPrayerScheduleForDate(cityId, date); final dateKey = DateFormat('yyyy-MM-dd').format(date); for (final prayerKey in const [ 'subuh', 'dzuhur', 'ashar', 'maghrib', 'isya', ]) { final parsed = _parsePrayerDateTime(date, schedule[prayerKey]); if (parsed == null) continue; prayerEntries.add(( prayerKey: prayerKey, dateKey: dateKey, time: parsed, )); } } if (prayerEntries.isEmpty) return null; prayerEntries.sort((a, b) => a.time.compareTo(b.time)); ({String prayerKey, String dateKey, DateTime time})? active; ({String prayerKey, String dateKey, DateTime time})? next; for (final entry in prayerEntries) { if (!entry.time.isAfter(now)) { active = entry; continue; } next = entry; break; } active ??= prayerEntries.first; next ??= prayerEntries.last; return ( scopeKey: '${active.dateKey}_${active.prayerKey}', dateKey: active.dateKey, nextResetAt: next.time, ); } Future> _loadPrayerScheduleForDate( String cityId, DateTime date, ) async { final dateKey = DateFormat('yyyy-MM-dd').format(date); final jadwal = await MyQuranSholatService.instance.getDailySchedule(cityId, dateKey); if (jadwal != null) return jadwal; return _buildFallbackPrayerSchedule(date); } Map _buildFallbackPrayerSchedule(DateTime date) { final lastKnown = LocationService.instance.getLastKnownLocation(); final lat = lastKnown?.lat ?? -6.2088; final lng = lastKnown?.lng ?? 106.8456; final result = PrayerService.instance.getPrayerTimes(lat, lng, date); final timeFormat = DateFormat('HH:mm'); return { 'subuh': timeFormat.format(result.fajr), 'dzuhur': timeFormat.format(result.dhuhr), 'ashar': timeFormat.format(result.asr), 'maghrib': timeFormat.format(result.maghrib), 'isya': timeFormat.format(result.isha), }; } DateTime? _parsePrayerDateTime(DateTime date, String? rawTime) { if (rawTime == null || rawTime.trim().isEmpty || rawTime == '-') { return null; } final parts = rawTime.trim().split(':'); if (parts.length != 2) return null; final hour = int.tryParse(parts[0]); final minute = int.tryParse(parts[1]); if (hour == null || minute == null) return null; return DateTime(date.year, date.month, date.day, hour, minute); } String _counterScopeKeyForPrefix(String prefix) { if (prefix == 'solat') return _solatScopeKey; return _todayKey; } String _counterDateKeyForPrefix(String prefix) { if (prefix == 'solat') return _solatScopeDateKey; return _todayKey; } Future _loadData() async { _refreshTodayScope(); setState(() { _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; final pagiNormalized = _normalizeRumayshoDzikir('pagi', pagi); final petangNormalized = _normalizeRumayshoDzikir('petang', petang); final quranOrthography = await _loadQuranOrthographyOverrides(); final pagiSplit = _splitDailyDzikirItems( 'pagi', pagiNormalized.$2, ); final pagiItems = _applyQuranOrthographyOverrides( pagiSplit.$1, quranOrthography, ); final petangItems = _applyQuranOrthographyOverrides( petangNormalized.$2, quranOrthography, ); final harianItems = _applyQuranOrthographyOverrides( pagiSplit.$2, quranOrthography, ); setState(() { _pagiIntroItem = pagiNormalized.$1; _petangIntroItem = petangNormalized.$1; _pagiItems = pagiItems; _petangItems = petangItems; _harianItems = harianItems; _sesudahSholatItems = solat; _loading = false; }); _ensureValidFocusPages(); _seedHarianProgressFromLinkedDzikir(); _syncDzikirTrackerIfCompleted(prefix: 'pagi', items: _pagiItems); _syncDzikirTrackerIfCompleted(prefix: 'petang', items: _petangItems); } catch (_) { if (!mounted) return; setState(() { _loading = false; _error = 'Gagal memuat dzikir dari server'; }); } } (Map?, List>) _normalizeRumayshoDzikir( String prefix, List> rawItems, ) { if (prefix != 'pagi' && prefix != 'petang') { return (null, List>.from(rawItems)); } final items = rawItems.map((item) => Map.from(item)).toList(); Map? intro; if (items.isNotEmpty && _isTaawwudzItem(items.first)) { intro = items.removeAt(0); } final normalized = >[]; for (var i = 0; i < items.length; i++) { if (i + 2 < items.length && _isIkhlasItem(items[i]) && _isFalaqItem(items[i + 1]) && _isNasItem(items[i + 2])) { normalized.add({ 'id': '${prefix}_quls', 'arab': [ items[i]['arab']?.toString().trim() ?? '', items[i + 1]['arab']?.toString().trim() ?? '', items[i + 2]['arab']?.toString().trim() ?? '', ].join('\n\n'), 'indo': [ items[i]['indo']?.toString().trim() ?? '', items[i + 1]['indo']?.toString().trim() ?? '', items[i + 2]['indo']?.toString().trim() ?? '', ].join('\n\n'), 'ulang': 3, }); i += 2; continue; } normalized.add(items[i]); } return (intro, normalized); } Future> _loadQuranOrthographyOverrides() async { final overrides = {}; try { final baqarah = await MuslimApiService.instance.getSurah(2); final ayat = List>.from(baqarah?['ayat'] ?? []); if (ayat.length >= 255) { final ayatKursi = ayat[254]['teksArab']?.toString().trim() ?? ''; if (ayatKursi.isNotEmpty) { overrides['ayat_kursi'] = ayatKursi; } } } catch (_) {} try { final quls = []; for (final surahId in const [112, 113, 114]) { final surah = await MuslimApiService.instance.getSurah(surahId); final verses = List>.from(surah?['ayat'] ?? []); if (verses.isEmpty) continue; final text = verses .map((verse) => verse['teksArab']?.toString().trim() ?? '') .where((text) => text.isNotEmpty) .join(' '); if (text.isEmpty) continue; quls.add('$_quranBismillah\n$text'); } if (quls.length == 3) { overrides['quls'] = quls.join('\n\n'); } } catch (_) {} return overrides; } (List>, List>) _splitDailyDzikirItems( String prefix, List> items, ) { if (prefix != 'pagi') { return (List>.from(items), >[]); } final mainItems = >[]; final dailyItems = >[]; for (var i = 0; i < items.length; i++) { final sourceNumber = i + 1; final item = items[i]; final copied = Map.from(item); // Rumaysho dzikir pagi keeps [15] and [18] as "dalam sehari". // [16] and [17] stay in the morning flow. if (sourceNumber == 15 || sourceNumber == 18) { dailyItems.add(copied); } else { mainItems.add(copied); } } return (mainItems, dailyItems); } List> _applyQuranOrthographyOverrides( List> items, Map overrides, ) { return items.map((item) { final next = Map.from(item); final arab = next['arab']?.toString() ?? ''; final id = next['id']?.toString() ?? ''; if (_isAyatKursiItem(next) && overrides['ayat_kursi']?.isNotEmpty == true) { next['arab'] = overrides['ayat_kursi']; } else if (id.endsWith('_quls') && overrides['quls']?.isNotEmpty == true) { next['arab'] = overrides['quls']; } else { next['arab'] = arab; } return next; }).toList(); } bool _isTaawwudzItem(Map item) { final arab = item['arab']?.toString() ?? ''; return arab.contains('أَعُوذُ بِاللَّهِ مِنَ الشَّيْطَانِ الرَّجِيمِ'); } bool _isAyatKursiItem(Map item) { final arab = item['arab']?.toString() ?? ''; return arab.contains('اللَّهُ لاَ إِلَهَ إِلاَّ هُوَ الْحَيُّ الْقَيُّومُ'); } bool _isIkhlasItem(Map item) { final arab = item['arab']?.toString() ?? ''; return arab.contains('قُلْ هُوَ اللَّهُ أَحَدٌ'); } bool _isFalaqItem(Map item) { final arab = item['arab']?.toString() ?? ''; return arab.contains('قُلْ أَعُوذُ بِرَبِّ الْفَلَقِ'); } bool _isNasItem(Map item) { final arab = item['arab']?.toString() ?? ''; return arab.contains('قُلْ أَعُوذُ بِرَبِّ النَّاسِ'); } bool _isDailyHundredTauhidItem(Map item) { final arab = item['arab']?.toString() ?? ''; final ulang = (item['ulang'] as num?)?.toInt() ?? 1; return ulang == 100 && arab.contains('لاَ إِلَهَ إِلاَّ اللهُ وَحْدَهُ لاَ شَرِيْكَ لَهُ'); } bool _isDailyIstighfarItem(Map item) { final arab = item['arab']?.toString() ?? ''; final ulang = (item['ulang'] as num?)?.toInt() ?? 1; return ulang == 100 && arab.contains('أَسْتَغْفِرُ اللَّهَ') && arab.contains('وَأَتُوبُ إِلَيْهِ'); } bool _isLinkedTenTauhidItem(Map item) { final arab = item['arab']?.toString() ?? ''; final ulang = (item['ulang'] as num?)?.toInt() ?? 1; return ulang == 10 && arab.contains('لاَ إِلَهَ إِلاَّ اللهُ وَحْدَهُ لاَ شَرِيْكَ لَهُ'); } String _dzikirTargetBadgeLabel(Map item, int target) { if (_isDailyHundredTauhidItem(item) || _isDailyIstighfarItem(item)) { return '$target X / HARI'; } return '$target KALI'; } String? _dzikirScopeNote(Map item) { if (_isDailyHundredTauhidItem(item) || _isDailyIstighfarItem(item)) { return 'Dibaca 100 kali dalam sehari, tidak harus selesai dalam satu duduk.'; } return null; } void _seedHarianProgressFromLinkedDzikir() { final harianIndex = _harianItems.indexWhere(_isDailyHundredTauhidItem); if (harianIndex == -1) return; final harianItem = _harianItems[harianIndex]; final harianTarget = (harianItem['ulang'] as num?)?.toInt() ?? 1; final harianKey = '${_resolveDzikirId(harianItem, 'harian', harianIndex)}_$_todayKey'; final existing = _counterBox.get(harianKey); var desired = 0; final pagiIndex = _pagiItems.indexWhere(_isLinkedTenTauhidItem); if (pagiIndex != -1) { final pagiItem = _pagiItems[pagiIndex]; desired += _getCounter( _resolveDzikirId(pagiItem, 'pagi', pagiIndex), (pagiItem['ulang'] as num?)?.toInt() ?? 1, prefix: 'pagi', ).count; } final petangIndex = _petangItems.indexWhere(_isLinkedTenTauhidItem); if (petangIndex != -1) { final petangItem = _petangItems[petangIndex]; desired += _getCounter( _resolveDzikirId(petangItem, 'petang', petangIndex), (petangItem['ulang'] as num?)?.toInt() ?? 1, prefix: 'petang', ).count; } desired = desired.clamp(0, harianTarget); if (existing == null) { if (desired == 0) return; _counterBox.put( harianKey, DzikirCounter( dzikirId: _resolveDzikirId(harianItem, 'harian', harianIndex), date: _todayKey, count: desired, target: harianTarget, ), ); return; } if (existing.count >= desired) return; existing.count = desired; existing.target = harianTarget; existing.save(); } void _syncDailyProgressFromLinkedIncrement(Map item) { if (!_isLinkedTenTauhidItem(item)) return; final harianIndex = _harianItems.indexWhere(_isDailyHundredTauhidItem); if (harianIndex == -1) return; final harianItem = _harianItems[harianIndex]; final dzikirId = _resolveDzikirId(harianItem, 'harian', harianIndex); final target = (harianItem['ulang'] as num?)?.toInt() ?? 1; final key = '${dzikirId}_$_todayKey'; final counter = _counterBox.get(key); if (counter == null) { _counterBox.put( key, DzikirCounter( dzikirId: dzikirId, date: _todayKey, count: 1, target: target, ), ); setState(() {}); return; } if (counter.count >= target) return; counter.count++; counter.target = target; counter.save(); setState(() {}); } void _showArabicFontSettings() { final settingsBox = Hive.box(HiveBoxes.settings); final settings = settingsBox.get('default') ?? AppSettings(); if (!settings.isInBox) { settingsBox.put('default', settings); } double arabicFontSize = settings.arabicFontSize; showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (ctx) => StatefulBuilder( builder: (context, setModalState) { return Padding( padding: bottomSheetContentPadding(context), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Pengaturan Tampilan', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), const SizedBox(height: 16), const Text('Ukuran Font Arab'), Slider( value: arabicFontSize, min: 16, max: 40, divisions: 12, label: '${arabicFontSize.round()}pt', activeColor: AppColors.primary, onChanged: (value) { setModalState(() => arabicFontSize = value); settings.arabicFontSize = value; if (settings.isInBox) { settings.save(); } else { settingsBox.put('default', settings); } }, ), const SizedBox(height: 8), ], ), ); }, ), ); } void _ensureValidFocusPages() { _clampFocusPageForPrefix( 'pagi', _pagiItems.length + (_pagiIntroItem != null ? 1 : 0), ); _clampFocusPageForPrefix( 'petang', _petangItems.length + (_petangIntroItem != null ? 1 : 0), ); _clampFocusPageForPrefix('harian', _harianItems.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); }); } DzikirCounter _getCounter( String dzikirId, int target, { required String prefix, }) { final scopeKey = _counterScopeKeyForPrefix(prefix); final dateKey = _counterDateKeyForPrefix(prefix); final key = '${dzikirId}_$scopeKey'; return _counterBox.get(key) ?? DzikirCounter( dzikirId: dzikirId, date: dateKey, count: 0, target: target, ); } bool _increment( String dzikirId, int target, { required String prefix, required bool hapticEnabled, }) { _refreshTodayScope(); final scopeKey = _counterScopeKeyForPrefix(prefix); final dateKey = _counterDateKeyForPrefix(prefix); final key = '${dzikirId}_$scopeKey'; var counter = _counterBox.get(key); final wasComplete = counter != null && counter.count >= counter.target; if (counter == null) { counter = DzikirCounter( dzikirId: dzikirId, date: dateKey, count: 1, target: target, ); _counterBox.put(key, counter); } else if (counter.count < counter.target) { counter.count++; counter.save(); } final isCompleteNow = counter.count >= counter.target; if (hapticEnabled) { HapticFeedback.lightImpact(); } setState(() {}); return !wasComplete && isCompleteNow; } bool _isDzikirGroupComplete( String prefix, List> items, ) { if (items.isEmpty) return false; for (int i = 0; i < items.length; i++) { final item = items[i]; final dzikirId = _resolveDzikirId(item, prefix, i); final target = (item['ulang'] as num?)?.toInt() ?? 1; final counter = _getCounter(dzikirId, target, prefix: prefix); if (counter.count < target) { return false; } } return true; } void _syncDzikirTrackerIfCompleted({ required String prefix, required List> items, }) { _refreshTodayScope(); if (widget.isSimpleModeTab) return; if (prefix != 'pagi' && prefix != 'petang') return; if (!_isDzikirGroupComplete(prefix, items)) return; final logBox = Hive.box(HiveBoxes.worshipLogs); var log = logBox.get(_todayKey); if (log == null) { log = DailyWorshipLog( date: _todayKey, shalatLogs: { 'subuh': ShalatLog(), 'dzuhur': ShalatLog(), 'ashar': ShalatLog(), 'maghrib': ShalatLog(), 'isya': ShalatLog(), }, ); logBox.put(_todayKey, log); } log.dzikirLog ??= DzikirLog(); final dzikirLog = log.dzikirLog!; bool changed = false; if (prefix == 'pagi' && !dzikirLog.pagi) { dzikirLog.pagi = true; changed = true; } if (prefix == 'petang' && !dzikirLog.petang) { dzikirLog.petang = true; changed = true; } if (changed) { log.save(); } } @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; return ValueListenableBuilder>( valueListenable: Hive.box(HiveBoxes.settings) .listenable(keys: ['default']), builder: (_, settingsBox, __) { final settings = settingsBox.get('default') ?? AppSettings(); final isFocusMode = settings.dzikirDisplayMode == 'focus'; return Scaffold( appBar: AppBar( automaticallyImplyLeading: false, leading: IconButton( icon: const Icon(LucideIcons.chevronLeft), tooltip: 'Kembali', onPressed: () { context.go(widget.isSimpleModeTab ? '/' : '/tools'); }, ), title: const Text('Dzikir Harian'), actionsPadding: const EdgeInsets.only(right: 8), actions: [ IconButton( onPressed: _loadData, icon: const Icon(LucideIcons.refreshCw), tooltip: 'Muat ulang', ), IconButton( onPressed: _showArabicFontSettings, icon: const Icon(LucideIcons.settings2), tooltip: 'Pengaturan tampilan', ), ], bottom: TabBar( controller: _tabController, labelColor: AppColors.primary, unselectedLabelColor: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight, indicatorColor: AppColors.primary, indicatorWeight: 3, labelStyle: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13), tabs: const [ Tab(text: 'Sesudah Sholat'), Tab(text: 'Pagi'), Tab(text: 'Petang'), Tab(text: 'Harian'), ], ), ), body: SafeArea( top: false, bottom: true, child: _loading ? const Center(child: CircularProgressIndicator()) : _error != null ? _buildErrorState(isDark) : TabBarView( controller: _tabController, children: [ isFocusMode ? _buildFocusModeTab( context, isDark, settings, items: _sesudahSholatItems, introItem: null, prefix: 'solat', title: 'Dzikir Sesudah Sholat', subtitle: 'Dibaca setelah shalat fardhu. Hitungan akan dimulai ulang otomatis saat waktu shalat berikutnya masuk.', ) : _buildDzikirList( context, isDark, settings, _sesudahSholatItems, null, 'solat', 'Dzikir Sesudah Sholat', 'Dibaca setelah shalat fardhu. Hitungan akan dimulai ulang otomatis saat waktu shalat berikutnya masuk.', ), isFocusMode ? _buildFocusModeTab( context, isDark, settings, items: _pagiItems, introItem: _pagiIntroItem, prefix: 'pagi', title: 'Dzikir Pagi', subtitle: 'Dibaca setelah shalat Subuh hingga terbit matahari.', ) : _buildDzikirList( context, isDark, settings, _pagiItems, _pagiIntroItem, 'pagi', 'Dzikir Pagi', 'Dibaca setelah shalat Subuh hingga terbit matahari.', ), isFocusMode ? _buildFocusModeTab( context, isDark, settings, items: _petangItems, introItem: _petangIntroItem, prefix: 'petang', title: 'Dzikir Petang', subtitle: 'Dibaca setelah Ashar hingga terbenam matahari.', ) : _buildDzikirList( context, isDark, settings, _petangItems, _petangIntroItem, 'petang', 'Dzikir Petang', 'Dibaca setelah Ashar hingga terbenam matahari.', ), isFocusMode ? _buildFocusModeTab( context, isDark, settings, items: _harianItems, introItem: null, prefix: 'harian', title: 'Dzikir Harian', subtitle: 'Target dzikir yang dapat dicicil sepanjang hari. Progress dari Dzikir Pagi dan Petang ikut terhitung di sini.', ) : _buildDzikirList( context, isDark, settings, _harianItems, null, 'harian', 'Dzikir Harian', 'Target dzikir yang dapat dicicil sepanjang hari. Progress dari Dzikir Pagi dan Petang ikut terhitung di sini.', ), ], ), ), ); }, ); } 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, AppSettings settings, List> items, Map? introItem, String prefix, String title, String subtitle, ) { if (items.isEmpty) { 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, 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, ), ), const SizedBox(height: 4), Text( subtitle, textAlign: TextAlign.center, style: TextStyle( color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight, fontSize: 13, ), ), if (introItem != null) ...[ const SizedBox(height: 16), _buildIntroRemembrance(isDark, introItem), ], ], ), ); } final item = items[index - 1]; final dzikirId = _resolveDzikirId(item, prefix, index - 1); final target = (item['ulang'] as num?)?.toInt() ?? 1; final counter = _getCounter(dzikirId, target, prefix: prefix); final isComplete = counter.count >= counter.target; return Padding( padding: const EdgeInsets.only(bottom: 16), child: Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight, borderRadius: BorderRadius.circular(16), 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( _dzikirTargetBadgeLabel(item, target), style: const TextStyle( fontSize: 10, fontWeight: FontWeight.w700, color: AppColors.primary, ), ), ), Text( (index).toString().padLeft(2, '0'), style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight, ), ), ], ), const SizedBox(height: 16), SizedBox( width: double.infinity, child: ArabicText( item['arab']?.toString() ?? '', textAlign: TextAlign.right, baseFontSize: 24, fontWeight: FontWeight.w400, height: 2.0, ), ), const SizedBox(height: 10), Text( '"${item['indo']?.toString() ?? ''}"', style: TextStyle( fontSize: 13, color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight, height: 1.5, ), ), if (_dzikirScopeNote(item) case final note?) ...[ const SizedBox(height: 8), Text( note, style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.primary.withValues(alpha: 0.8), height: 1.45, ), ), ], const SizedBox(height: 16), GestureDetector( onTap: () async { if (prefix == 'solat') { await _refreshSolatScope(); } final becameComplete = _increment( dzikirId, target, prefix: prefix, hapticEnabled: settings.dzikirHapticOnCount, ); _syncDailyProgressFromLinkedIncrement(item); if (becameComplete) { _syncDzikirTrackerIfCompleted( prefix: prefix, items: items, ); } }, child: Container( width: double.infinity, 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( isComplete ? 'Selesai' : '${counter.count} / $target', style: TextStyle( fontSize: 15, fontWeight: FontWeight.w700, color: isComplete ? AppColors.primary : AppColors.onPrimary, ), ), ], ), ), ), ], ), ), ); }, ); } Widget _buildFocusModeTab( BuildContext context, bool isDark, AppSettings settings, { required List> items, required Map? introItem, 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 introOffset = introItem != null ? 1 : 0; final pageCount = items.length + introOffset; final rawCurrent = _focusPageIndex[prefix] ?? 0; final currentPage = rawCurrent.clamp(0, pageCount - 1); final isIntroPage = introItem != null && currentPage == 0; final currentIndex = introItem != null ? currentPage - 1 : currentPage; DzikirCounter? currentCounter; int currentTarget = 0; bool isComplete = false; if (!isIntroPage) { final currentItem = items[currentIndex]; final currentId = _resolveDzikirId(currentItem, prefix, currentIndex); currentTarget = (currentItem['ulang'] as num?)?.toInt() ?? 1; currentCounter = _getCounter(currentId, currentTarget, prefix: prefix); isComplete = currentCounter.count >= currentCounter.target; } final actionLabel = isIntroPage ? 'Lanjut' : (isComplete ? 'Selesai' : '${currentCounter!.count} / $currentTarget'); final actionIcon = isIntroPage ? LucideIcons.chevronRight : (isComplete ? LucideIcons.check : LucideIcons.fingerprint); 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( isIntroPage ? 'Pembuka' : 'Item ${currentIndex + 1} dari ${items.length}', style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.primary, ), ), ), const SizedBox(height: 12), Expanded( child: Stack( children: [ LayoutBuilder( builder: (context, constraints) { final maxCardHeight = constraints.maxHeight > 92 ? constraints.maxHeight - 92 : constraints.maxHeight; return PageView.builder( controller: controller, itemCount: pageCount, onPageChanged: (index) { setState(() { _focusPageIndex[prefix] = index; }); }, itemBuilder: (context, index) { if (introItem != null && index == 0) { return Padding( padding: const EdgeInsets.only(bottom: 92), child: Align( alignment: Alignment.topCenter, child: ConstrainedBox( constraints: BoxConstraints( maxHeight: maxCardHeight, ), child: _buildFocusIntroCard( isDark, item: introItem, ), ), ), ); } final itemIndex = introItem != null ? index - 1 : index; final item = items[itemIndex]; final dzikirId = _resolveDzikirId(item, prefix, itemIndex); final target = (item['ulang'] as num?)?.toInt() ?? 1; final counter = _getCounter(dzikirId, target, prefix: prefix); final complete = counter.count >= counter.target; return Padding( padding: const EdgeInsets.only(bottom: 92), child: Align( alignment: Alignment.topCenter, child: ConstrainedBox( constraints: BoxConstraints( maxHeight: maxCardHeight, ), child: _buildFocusCard( isDark, item: item, index: itemIndex, target: target, counter: counter, isComplete: complete, ), ), ), ); }, ); }, ), if (settings.dzikirCounterButtonPosition == 'fabCircle') Positioned( right: 8, bottom: 12, child: _buildFocusCounterFab( isDark, isComplete: isComplete, icon: actionIcon, label: isIntroPage ? actionLabel : (isComplete ? 'Selesai' : '${currentCounter!.count}/$currentTarget'), onTap: () => _onFocusCounterTap( context, settings, prefix, items, introItem: introItem, ), ), ) else Positioned( left: 0, right: 0, bottom: 12, child: _buildFocusCounterPill( isComplete: isComplete, icon: actionIcon, label: actionLabel, onTap: () => _onFocusCounterTap( context, settings, prefix, items, introItem: introItem, ), ), ), ], ), ), ], ), ); } Widget _buildIntroRemembrance( bool isDark, Map item, ) { return Container( width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.primary.withValues(alpha: 0.06), borderRadius: BorderRadius.circular(16), border: Border.all( color: AppColors.primary.withValues(alpha: 0.12), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Pembuka', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 1.0, color: AppColors.primary, ), ), const SizedBox(height: 10), ArabicText( item['arab']?.toString() ?? '', textAlign: TextAlign.right, baseFontSize: 20, fontWeight: FontWeight.w400, height: 1.9, ), const SizedBox(height: 8), Text( '"${item['indo']?.toString() ?? ''}"', style: TextStyle( fontSize: 13, height: 1.5, color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight, ), ), ], ), ); } Widget _buildFocusIntroCard( bool isDark, { required Map item, }) { 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: isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.cream, ), ), child: SingleChildScrollView( physics: const ClampingScrollPhysics(), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: AppColors.primary.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(50), ), child: const Text( 'PEMBUKA', style: TextStyle( fontSize: 10, fontWeight: FontWeight.w700, color: AppColors.primary, ), ), ), const SizedBox(height: 20), SizedBox( width: double.infinity, child: ArabicText( item['arab']?.toString() ?? '', textAlign: TextAlign.right, baseFontSize: 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, ), ), ], ), ), ); } Widget _buildFocusCard( bool isDark, { required Map 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, mainAxisSize: MainAxisSize.min, 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( _dzikirTargetBadgeLabel(item, target), style: const 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), Flexible( fit: FlexFit.loose, child: SingleChildScrollView( physics: const ClampingScrollPhysics(), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: double.infinity, child: ArabicText( item['arab']?.toString() ?? '', textAlign: TextAlign.right, baseFontSize: 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, ), ), if (_dzikirScopeNote(item) case final note?) ...[ const SizedBox(height: 10), Text( note, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.primary.withValues(alpha: 0.82), height: 1.45, ), ), ], 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: const TextStyle( color: AppColors.primary, fontWeight: FontWeight.w700, fontSize: 12, ), ), ), ], ), ), ), ], ), ); } Widget _buildFocusCounterPill({ required bool isComplete, required IconData icon, 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( icon, 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 IconData icon, 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( icon, 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, ), ], ), ), ); } Future _onFocusCounterTap( BuildContext context, AppSettings settings, String prefix, List> items, { required Map? introItem, }) async { _refreshTodayScope(); if (items.isEmpty) return; if (prefix == 'solat') { await _refreshSolatScope(); if (!context.mounted) return; } final introOffset = introItem != null ? 1 : 0; final currentPage = (_focusPageIndex[prefix] ?? 0).clamp(0, items.length + introOffset - 1); if (introItem != null && currentPage == 0) { final controller = _pageControllers[prefix]; if (controller != null && controller.hasClients) { controller.nextPage( duration: const Duration(milliseconds: 240), curve: Curves.easeOut, ); } return; } final currentIndex = introItem != null ? currentPage - 1 : currentPage; final item = items[currentIndex]; final dzikirId = _resolveDzikirId(item, prefix, currentIndex); final target = (item['ulang'] as num?)?.toInt() ?? 1; final becameComplete = _increment( dzikirId, target, prefix: prefix, hapticEnabled: settings.dzikirHapticOnCount, ); _syncDailyProgressFromLinkedIncrement(item); if (!becameComplete) return; _syncDzikirTrackerIfCompleted( prefix: prefix, items: items, ); 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 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, ), ], ), ), ); } }