Files
jamshalat-diary/lib/features/dzikir/presentation/dzikir_screen.dart
2026-03-18 00:07:10 +07:00

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