1873 lines
61 KiB
Dart
1873 lines
61 KiB
Dart
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 '../../../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<DzikirScreen> createState() => _DzikirScreenState();
|
|
}
|
|
|
|
class _DzikirScreenState extends ConsumerState<DzikirScreen>
|
|
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
|
static const String _quranBismillah =
|
|
'بِسْمِ اللّٰهِ الرَّحْمٰنِ الرَّحِيْمِ';
|
|
late TabController _tabController;
|
|
|
|
final Map<String, PageController> _pageControllers = {
|
|
'pagi': PageController(),
|
|
'petang': PageController(),
|
|
'harian': PageController(),
|
|
'solat': PageController(),
|
|
};
|
|
|
|
final Map<String, int> _focusPageIndex = {
|
|
'pagi': 0,
|
|
'petang': 0,
|
|
'harian': 0,
|
|
'solat': 0,
|
|
};
|
|
|
|
List<Map<String, dynamic>> _pagiItems = [];
|
|
List<Map<String, dynamic>> _petangItems = [];
|
|
List<Map<String, dynamic>> _harianItems = [];
|
|
List<Map<String, dynamic>> _sesudahSholatItems = [];
|
|
Map<String, dynamic>? _pagiIntroItem;
|
|
Map<String, dynamic>? _petangIntroItem;
|
|
bool _loading = true;
|
|
String? _error;
|
|
|
|
late Box<DzikirCounter> _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<DzikirCounter>(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<void> _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<Map<String, String>> _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<String, String> _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<void> _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<String, dynamic>?, List<Map<String, dynamic>>) _normalizeRumayshoDzikir(
|
|
String prefix,
|
|
List<Map<String, dynamic>> rawItems,
|
|
) {
|
|
if (prefix != 'pagi' && prefix != 'petang') {
|
|
return (null, List<Map<String, dynamic>>.from(rawItems));
|
|
}
|
|
|
|
final items =
|
|
rawItems.map((item) => Map<String, dynamic>.from(item)).toList();
|
|
Map<String, dynamic>? intro;
|
|
|
|
if (items.isNotEmpty && _isTaawwudzItem(items.first)) {
|
|
intro = items.removeAt(0);
|
|
}
|
|
|
|
final normalized = <Map<String, dynamic>>[];
|
|
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<Map<String, String>> _loadQuranOrthographyOverrides() async {
|
|
final overrides = <String, String>{};
|
|
|
|
try {
|
|
final baqarah = await MuslimApiService.instance.getSurah(2);
|
|
final ayat = List<Map<String, dynamic>>.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 = <String>[];
|
|
for (final surahId in const [112, 113, 114]) {
|
|
final surah = await MuslimApiService.instance.getSurah(surahId);
|
|
final verses = List<Map<String, dynamic>>.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<Map<String, dynamic>>, List<Map<String, dynamic>>)
|
|
_splitDailyDzikirItems(
|
|
String prefix,
|
|
List<Map<String, dynamic>> items,
|
|
) {
|
|
if (prefix != 'pagi') {
|
|
return (List<Map<String, dynamic>>.from(items), <Map<String, dynamic>>[]);
|
|
}
|
|
|
|
final mainItems = <Map<String, dynamic>>[];
|
|
final dailyItems = <Map<String, dynamic>>[];
|
|
for (var i = 0; i < items.length; i++) {
|
|
final sourceNumber = i + 1;
|
|
final item = items[i];
|
|
final copied = Map<String, dynamic>.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<Map<String, dynamic>> _applyQuranOrthographyOverrides(
|
|
List<Map<String, dynamic>> items,
|
|
Map<String, String> overrides,
|
|
) {
|
|
return items.map((item) {
|
|
final next = Map<String, dynamic>.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<String, dynamic> item) {
|
|
final arab = item['arab']?.toString() ?? '';
|
|
return arab.contains('أَعُوذُ بِاللَّهِ مِنَ الشَّيْطَانِ الرَّجِيمِ');
|
|
}
|
|
|
|
bool _isAyatKursiItem(Map<String, dynamic> item) {
|
|
final arab = item['arab']?.toString() ?? '';
|
|
return arab.contains('اللَّهُ لاَ إِلَهَ إِلاَّ هُوَ الْحَيُّ الْقَيُّومُ');
|
|
}
|
|
|
|
bool _isIkhlasItem(Map<String, dynamic> item) {
|
|
final arab = item['arab']?.toString() ?? '';
|
|
return arab.contains('قُلْ هُوَ اللَّهُ أَحَدٌ');
|
|
}
|
|
|
|
bool _isFalaqItem(Map<String, dynamic> item) {
|
|
final arab = item['arab']?.toString() ?? '';
|
|
return arab.contains('قُلْ أَعُوذُ بِرَبِّ الْفَلَقِ');
|
|
}
|
|
|
|
bool _isNasItem(Map<String, dynamic> item) {
|
|
final arab = item['arab']?.toString() ?? '';
|
|
return arab.contains('قُلْ أَعُوذُ بِرَبِّ النَّاسِ');
|
|
}
|
|
|
|
bool _isDailyHundredTauhidItem(Map<String, dynamic> item) {
|
|
final arab = item['arab']?.toString() ?? '';
|
|
final ulang = (item['ulang'] as num?)?.toInt() ?? 1;
|
|
return ulang == 100 &&
|
|
arab.contains('لاَ إِلَهَ إِلاَّ اللهُ وَحْدَهُ لاَ شَرِيْكَ لَهُ');
|
|
}
|
|
|
|
bool _isDailyIstighfarItem(Map<String, dynamic> item) {
|
|
final arab = item['arab']?.toString() ?? '';
|
|
final ulang = (item['ulang'] as num?)?.toInt() ?? 1;
|
|
return ulang == 100 &&
|
|
arab.contains('أَسْتَغْفِرُ اللَّهَ') &&
|
|
arab.contains('وَأَتُوبُ إِلَيْهِ');
|
|
}
|
|
|
|
bool _isLinkedTenTauhidItem(Map<String, dynamic> item) {
|
|
final arab = item['arab']?.toString() ?? '';
|
|
final ulang = (item['ulang'] as num?)?.toInt() ?? 1;
|
|
return ulang == 10 &&
|
|
arab.contains('لاَ إِلَهَ إِلاَّ اللهُ وَحْدَهُ لاَ شَرِيْكَ لَهُ');
|
|
}
|
|
|
|
String _dzikirTargetBadgeLabel(Map<String, dynamic> item, int target) {
|
|
if (_isDailyHundredTauhidItem(item) || _isDailyIstighfarItem(item)) {
|
|
return '$target X / HARI';
|
|
}
|
|
return '$target KALI';
|
|
}
|
|
|
|
String? _dzikirScopeNote(Map<String, dynamic> 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<String, dynamic> 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<AppSettings>(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) {
|
|
final keyboardInset = MediaQuery.of(context).viewInsets.bottom;
|
|
return Padding(
|
|
padding: EdgeInsets.fromLTRB(16, 20, 16, 20 + keyboardInset),
|
|
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<Map<String, dynamic>> 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<Map<String, dynamic>> items,
|
|
}) {
|
|
_refreshTodayScope();
|
|
if (widget.isSimpleModeTab) return;
|
|
if (prefix != 'pagi' && prefix != 'petang') return;
|
|
if (!_isDzikirGroupComplete(prefix, items)) return;
|
|
|
|
final logBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
|
|
var log = logBox.get(_todayKey);
|
|
if (log == null) {
|
|
log = DailyWorshipLog(
|
|
date: _todayKey,
|
|
shalatLogs: <String, ShalatLog>{
|
|
'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<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: 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<Map<String, dynamic>> items,
|
|
Map<String, dynamic>? 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<Map<String, dynamic>> items,
|
|
required Map<String, dynamic>? 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<String, dynamic> 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<String, dynamic> 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<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,
|
|
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<void> _onFocusCounterTap(
|
|
BuildContext context,
|
|
AppSettings settings,
|
|
String prefix,
|
|
List<Map<String, dynamic>> items, {
|
|
required Map<String, dynamic>? 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<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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|