Files
jamshalat-diary/lib/features/dzikir/presentation/dzikir_screen.dart
2026-03-16 00:30:32 +07:00

923 lines
30 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../app/theme/app_colors.dart';
import '../../../data/local/hive_boxes.dart';
import '../../../data/local/models/app_settings.dart';
import '../../../data/local/models/dzikir_counter.dart';
import '../../../data/services/muslim_api_service.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 {
late TabController _tabController;
final Map<String, PageController> _pageControllers = {
'pagi': PageController(),
'petang': PageController(),
'solat': PageController(),
};
final Map<String, int> _focusPageIndex = {
'pagi': 0,
'petang': 0,
'solat': 0,
};
List<Map<String, dynamic>> _pagiItems = [];
List<Map<String, dynamic>> _petangItems = [];
List<Map<String, dynamic>> _sesudahSholatItems = [];
bool _loading = true;
String? _error;
late Box<DzikirCounter> _counterBox;
late String _todayKey;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
_tabController.addListener(() {
if (!mounted) return;
setState(() {});
});
_counterBox = Hive.box<DzikirCounter>(HiveBoxes.dzikirCounters);
_todayKey = DateFormat('yyyy-MM-dd').format(DateTime.now());
_loadData();
}
@override
void dispose() {
_tabController.dispose();
for (final controller in _pageControllers.values) {
controller.dispose();
}
super.dispose();
}
Future<void> _loadData() async {
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;
setState(() {
_pagiItems = pagi;
_petangItems = petang;
_sesudahSholatItems = solat;
_loading = false;
});
_ensureValidFocusPages();
} catch (_) {
if (!mounted) return;
setState(() {
_loading = false;
_error = 'Gagal memuat dzikir dari server';
});
}
}
void _ensureValidFocusPages() {
_clampFocusPageForPrefix('pagi', _pagiItems.length);
_clampFocusPageForPrefix('petang', _petangItems.length);
_clampFocusPageForPrefix('solat', _sesudahSholatItems.length);
}
void _clampFocusPageForPrefix(String prefix, int itemLength) {
final maxIndex = itemLength > 0 ? itemLength - 1 : 0;
final current = _focusPageIndex[prefix] ?? 0;
final next = current > maxIndex ? maxIndex : current;
_focusPageIndex[prefix] = next;
final controller = _pageControllers[prefix];
if (controller == null || !controller.hasClients) return;
if (controller.page?.round() == next) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !controller.hasClients) return;
controller.jumpToPage(next);
});
}
DzikirCounter _getCounter(String dzikirId, int target) {
final key = '${dzikirId}_$_todayKey';
return _counterBox.get(key) ??
DzikirCounter(
dzikirId: dzikirId,
date: _todayKey,
count: 0,
target: target,
);
}
bool _increment(
String dzikirId,
int target, {
required bool hapticEnabled,
}) {
final key = '${dzikirId}_$_todayKey';
var counter = _counterBox.get(key);
final wasComplete = counter != null && counter.count >= counter.target;
if (counter == null) {
counter = DzikirCounter(
dzikirId: dzikirId,
date: _todayKey,
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;
}
@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: !widget.isSimpleModeTab,
title: const Text('Dzikir Harian'),
actions: [
IconButton(
onPressed: _loadData,
icon: const Icon(LucideIcons.refreshCw),
tooltip: 'Muat ulang',
),
],
bottom: TabBar(
controller: _tabController,
labelColor: AppColors.primary,
unselectedLabelColor: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
indicatorColor: AppColors.primary,
indicatorWeight: 3,
labelStyle:
const TextStyle(fontWeight: FontWeight.w700, fontSize: 13),
tabs: const [
Tab(text: 'Pagi'),
Tab(text: 'Petang'),
Tab(text: 'Sesudah Sholat'),
],
),
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? _buildErrorState(isDark)
: TabBarView(
controller: _tabController,
children: [
isFocusMode
? _buildFocusModeTab(
context,
isDark,
settings,
items: _pagiItems,
prefix: 'pagi',
title: 'Dzikir Pagi',
subtitle:
'Dibaca setelah shalat Subuh hingga terbit matahari.',
)
: _buildDzikirList(
context,
isDark,
settings,
_pagiItems,
'pagi',
'Dzikir Pagi',
'Dibaca setelah shalat Subuh hingga terbit matahari.',
),
isFocusMode
? _buildFocusModeTab(
context,
isDark,
settings,
items: _petangItems,
prefix: 'petang',
title: 'Dzikir Petang',
subtitle:
'Dibaca setelah Ashar hingga terbenam matahari.',
)
: _buildDzikirList(
context,
isDark,
settings,
_petangItems,
'petang',
'Dzikir Petang',
'Dibaca setelah Ashar hingga terbenam matahari.',
),
isFocusMode
? _buildFocusModeTab(
context,
isDark,
settings,
items: _sesudahSholatItems,
prefix: 'solat',
title: 'Dzikir Sesudah Sholat',
subtitle:
'Dibaca setelah shalat fardhu sesuai kebutuhan.',
)
: _buildDzikirList(
context,
isDark,
settings,
_sesudahSholatItems,
'solat',
'Dzikir Sesudah Sholat',
'Dibaca setelah shalat fardhu sesuai kebutuhan.',
),
],
),
);
},
);
}
Widget _buildErrorState(bool isDark) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.wifiOff,
size: 42,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
const SizedBox(height: 12),
Text(
_error!,
textAlign: TextAlign.center,
style: TextStyle(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
),
),
);
}
Widget _buildDzikirList(
BuildContext context,
bool isDark,
AppSettings settings,
List<Map<String, dynamic>> items,
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,
),
),
],
),
);
}
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);
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(
'$target KALI',
style: 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: Text(
item['arab']?.toString() ?? '',
textAlign: TextAlign.right,
style: const TextStyle(
fontFamily: 'Amiri',
fontSize: 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,
),
),
const SizedBox(height: 16),
GestureDetector(
onTap: () => _increment(
dzikirId,
target,
hapticEnabled: settings.dzikirHapticOnCount,
),
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 String prefix,
required String title,
required String subtitle,
}) {
if (items.isEmpty) {
return _buildEmptyState(
isDark,
title: 'Belum ada data dzikir',
subtitle: 'Data untuk tab ini belum tersedia.',
);
}
final controller = _pageControllers[prefix]!;
final rawCurrent = _focusPageIndex[prefix] ?? 0;
final currentIndex = rawCurrent.clamp(0, items.length - 1);
final currentItem = items[currentIndex];
final currentId = _resolveDzikirId(currentItem, prefix, currentIndex);
final currentTarget = (currentItem['ulang'] as num?)?.toInt() ?? 1;
final currentCounter = _getCounter(currentId, currentTarget);
final isComplete = currentCounter.count >= currentCounter.target;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
child: Column(
children: [
Text(
title,
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w800),
),
const SizedBox(height: 4),
Text(
subtitle,
textAlign: TextAlign.center,
style: TextStyle(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
fontSize: 13,
),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(50),
),
child: Text(
'Item ${currentIndex + 1} dari ${items.length}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
),
const SizedBox(height: 12),
Expanded(
child: Stack(
children: [
PageView.builder(
controller: controller,
itemCount: items.length,
onPageChanged: (index) {
setState(() {
_focusPageIndex[prefix] = index;
});
},
itemBuilder: (context, index) {
final item = items[index];
final dzikirId = _resolveDzikirId(item, prefix, index);
final target = (item['ulang'] as num?)?.toInt() ?? 1;
final counter = _getCounter(dzikirId, target);
final complete = counter.count >= counter.target;
return Padding(
padding: const EdgeInsets.only(bottom: 92),
child: _buildFocusCard(
isDark,
item: item,
index: index,
target: target,
counter: counter,
isComplete: complete,
),
);
},
),
if (settings.dzikirCounterButtonPosition == 'fabCircle')
Positioned(
right: 8,
bottom: 12,
child: _buildFocusCounterFab(
isDark,
isComplete: isComplete,
label: isComplete
? 'Selesai'
: '${currentCounter.count}/$currentTarget',
onTap: () => _onFocusCounterTap(
context,
settings,
prefix,
items,
),
),
)
else
Positioned(
left: 0,
right: 0,
bottom: 12,
child: _buildFocusCounterPill(
isComplete: isComplete,
label: isComplete
? 'Selesai'
: '${currentCounter.count} / $currentTarget',
onTap: () => _onFocusCounterTap(
context,
settings,
prefix,
items,
),
),
),
],
),
),
],
),
);
}
Widget _buildFocusCard(
bool isDark, {
required Map<String, dynamic> item,
required int index,
required int target,
required DzikirCounter counter,
required bool isComplete,
}) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isComplete
? AppColors.primary.withValues(alpha: 0.3)
: (isDark
? AppColors.primary.withValues(alpha: 0.08)
: AppColors.cream),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(50),
),
child: Text(
'$target KALI',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
),
Text(
'${(index + 1).toString().padLeft(2, '0')}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
),
const SizedBox(height: 20),
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: double.infinity,
child: Text(
item['arab']?.toString() ?? '',
textAlign: TextAlign.right,
style: const TextStyle(
fontFamily: 'Amiri',
fontSize: 28,
fontWeight: FontWeight.w400,
height: 2.0,
),
),
),
const SizedBox(height: 14),
Text(
'"${item['indo']?.toString() ?? ''}"',
style: TextStyle(
fontSize: 14,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
height: 1.6,
),
),
const SizedBox(height: 12),
if (isComplete)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(50),
),
child: Text(
'Selesai (${counter.count}/$target)',
style: TextStyle(
color: AppColors.primary,
fontWeight: FontWeight.w700,
fontSize: 12,
),
),
),
],
),
),
),
],
),
);
}
Widget _buildFocusCounterPill({
required bool isComplete,
required String label,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8),
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
color: isComplete
? AppColors.primary.withValues(alpha: 0.15)
: AppColors.primary,
borderRadius: BorderRadius.circular(50),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isComplete ? LucideIcons.check : LucideIcons.fingerprint,
size: 18,
color: isComplete ? AppColors.primary : AppColors.onPrimary,
),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: isComplete ? AppColors.primary : AppColors.onPrimary,
),
),
],
),
),
);
}
Widget _buildFocusCounterFab(
bool isDark, {
required bool isComplete,
required String label,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 72,
height: 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isComplete
? AppColors.primary.withValues(alpha: 0.15)
: AppColors.primary,
boxShadow: [
BoxShadow(
color: (isDark ? Colors.black : Colors.black26)
.withValues(alpha: 0.14),
blurRadius: 18,
offset: const Offset(0, 6),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isComplete ? LucideIcons.check : LucideIcons.fingerprint,
size: 18,
color: isComplete ? AppColors.primary : AppColors.onPrimary,
),
const SizedBox(height: 2),
Text(
label,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: isComplete ? AppColors.primary : AppColors.onPrimary,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
void _onFocusCounterTap(
BuildContext context,
AppSettings settings,
String prefix,
List<Map<String, dynamic>> items,
) {
if (items.isEmpty) return;
final currentIndex = (_focusPageIndex[prefix] ?? 0).clamp(0, items.length - 1);
final item = items[currentIndex];
final dzikirId = _resolveDzikirId(item, prefix, currentIndex);
final target = (item['ulang'] as num?)?.toInt() ?? 1;
final becameComplete = _increment(
dzikirId,
target,
hapticEnabled: settings.dzikirHapticOnCount,
);
if (!becameComplete) return;
final isLast = currentIndex == items.length - 1;
if (settings.dzikirAutoAdvance && !isLast) {
final controller = _pageControllers[prefix];
if (controller != null && controller.hasClients) {
controller.nextPage(
duration: const Duration(milliseconds: 240),
curve: Curves.easeOut,
);
}
return;
}
if (isLast) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Semua dzikir pada tab ini selesai'),
duration: Duration(seconds: 2),
),
);
}
}
String _resolveDzikirId(Map<String, dynamic> item, String prefix, int index) {
final rawId = item['id']?.toString();
if (rawId != null && rawId.isNotEmpty) {
return rawId;
}
return '${prefix}_${index + 1}';
}
Widget _buildEmptyState(
bool isDark, {
required String title,
required String subtitle,
}) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.inbox,
size: 42,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
const SizedBox(height: 12),
Text(
title,
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15),
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
Text(
subtitle,
style: TextStyle(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
}