- Murattal: Spotify-style 5-button controls [Shuffle, Prev, Play, Next, Playlist] - Murattal: Animated 7-bar equalizer visualization in player circle - Murattal: Unsplash API background with frosted glass player overlay - Murattal: Transparent AppBar with backdrop blur - Murattal: Surah playlist bottom sheet with full 114 Surah list - Murattal: Auto-play disabled on screen open, enabled on navigation - Murattal: Shuffle mode for random Surah playback - Murattal: Photographer attribution per Unsplash guidelines - Dashboard: Auto-scroll prayer schedule to next active prayer - Fix: setState lifecycle errors on Reading & Murattal screens - Setup: flutter_dotenv, cached_network_image, url_launcher deps
567 lines
19 KiB
Dart
567 lines
19 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:hive_flutter/hive_flutter.dart';
|
|
import 'package:intl/intl.dart';
|
|
import '../../../app/theme/app_colors.dart';
|
|
import '../../../core/widgets/progress_bar.dart';
|
|
import '../../../data/local/hive_boxes.dart';
|
|
import '../../../data/local/models/daily_worship_log.dart';
|
|
import '../../../data/local/models/checklist_item.dart';
|
|
|
|
class LaporanScreen extends ConsumerStatefulWidget {
|
|
const LaporanScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<LaporanScreen> createState() => _LaporanScreenState();
|
|
}
|
|
|
|
class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
|
with SingleTickerProviderStateMixin {
|
|
late TabController _tabController;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_tabController = TabController(length: 3, vsync: this);
|
|
_tabController.addListener(() => setState(() {}));
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_tabController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
/// Get the last 7 days' point data.
|
|
List<_DayData> _getWeeklyData() {
|
|
final logBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
|
|
final now = DateTime.now();
|
|
final data = <_DayData>[];
|
|
|
|
for (int i = 6; i >= 0; i--) {
|
|
final date = now.subtract(Duration(days: i));
|
|
final key = DateFormat('yyyy-MM-dd').format(date);
|
|
final log = logBox.get(key);
|
|
data.add(_DayData(
|
|
label: DateFormat('E').format(date).substring(0, 3),
|
|
value: (log?.totalPoints ?? 0).toDouble(), // Use points instead of %
|
|
isToday: i == 0,
|
|
));
|
|
}
|
|
return data;
|
|
}
|
|
|
|
/// Get average points for the week.
|
|
double _weekAverage(List<_DayData> data) {
|
|
if (data.isEmpty) return 0;
|
|
final sum = data.fold<double>(0, (s, d) => s + d.value);
|
|
return sum / data.length;
|
|
}
|
|
|
|
/// Find best and worst performing items.
|
|
_InsightPair _getInsights() {
|
|
final logBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
|
|
final now = DateTime.now();
|
|
|
|
final completionCounts = <String, int>{};
|
|
final totalCounts = <String, int>{};
|
|
int daysChecked = 0;
|
|
|
|
for (int i = 0; i < 7; i++) {
|
|
final date = now.subtract(Duration(days: i));
|
|
final key = DateFormat('yyyy-MM-dd').format(date);
|
|
final log = logBox.get(key);
|
|
|
|
if (log != null && log.totalItems > 0) {
|
|
daysChecked++;
|
|
|
|
// Fardhu
|
|
totalCounts['fardhu'] = (totalCounts['fardhu'] ?? 0) + 5;
|
|
int completedFardhu = log.shalatLogs.values.where((l) => l.completed).length;
|
|
completionCounts['fardhu'] = (completionCounts['fardhu'] ?? 0) + completedFardhu;
|
|
|
|
// Rawatib
|
|
int rawatibTotal = 0;
|
|
int rawatibCompleted = 0;
|
|
for (var sLog in log.shalatLogs.values) {
|
|
if (sLog.qabliyah != null) { rawatibTotal++; if (sLog.qabliyah!) rawatibCompleted++; }
|
|
if (sLog.badiyah != null) { rawatibTotal++; if (sLog.badiyah!) rawatibCompleted++; }
|
|
}
|
|
if (rawatibTotal > 0) {
|
|
totalCounts['rawatib'] = (totalCounts['rawatib'] ?? 0) + rawatibTotal;
|
|
completionCounts['rawatib'] = (completionCounts['rawatib'] ?? 0) + rawatibCompleted;
|
|
}
|
|
|
|
// Tilawah
|
|
if (log.tilawahLog != null) {
|
|
totalCounts['tilawah'] = (totalCounts['tilawah'] ?? 0) + 1;
|
|
if (log.tilawahLog!.isCompleted) {
|
|
completionCounts['tilawah'] = (completionCounts['tilawah'] ?? 0) + 1;
|
|
}
|
|
}
|
|
|
|
// Dzikir
|
|
if (log.dzikirLog != null) {
|
|
totalCounts['dzikir'] = (totalCounts['dzikir'] ?? 0) + 2;
|
|
int dCompleted = (log.dzikirLog!.pagi ? 1 : 0) + (log.dzikirLog!.petang ? 1 : 0);
|
|
completionCounts['dzikir'] = (completionCounts['dzikir'] ?? 0) + dCompleted;
|
|
}
|
|
|
|
// Puasa
|
|
if (log.puasaLog != null) {
|
|
totalCounts['puasa'] = (totalCounts['puasa'] ?? 0) + 1;
|
|
if (log.puasaLog!.completed) {
|
|
completionCounts['puasa'] = (completionCounts['puasa'] ?? 0) + 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (daysChecked == 0 || totalCounts.isEmpty) {
|
|
return _InsightPair(
|
|
best: _InsightItem(title: 'Sholat Fardhu', percent: 0),
|
|
worst: _InsightItem(title: 'Belum Ada Data', percent: 0),
|
|
);
|
|
}
|
|
|
|
String bestId = totalCounts.keys.first;
|
|
String worstId = totalCounts.keys.first;
|
|
double bestRate = -1.0;
|
|
double worstRate = 2.0;
|
|
|
|
for (final id in totalCounts.keys) {
|
|
final total = totalCounts[id]!;
|
|
final completed = completionCounts[id] ?? 0;
|
|
final rate = completed / total;
|
|
if (rate > bestRate) {
|
|
bestRate = rate;
|
|
bestId = id;
|
|
}
|
|
if (rate < worstRate) {
|
|
worstRate = rate;
|
|
worstId = id;
|
|
}
|
|
}
|
|
|
|
final idToTitle = {
|
|
'fardhu': 'Sholat Fardhu',
|
|
'rawatib': 'Sholat Rawatib',
|
|
'tilawah': 'Tilawah Quran',
|
|
'dzikir': 'Dzikir Harian',
|
|
'puasa': 'Puasa Sunnah',
|
|
};
|
|
|
|
return _InsightPair(
|
|
best: _InsightItem(
|
|
title: idToTitle[bestId] ?? bestId,
|
|
percent: (bestRate * 100).round(),
|
|
),
|
|
worst: _InsightItem(
|
|
title: idToTitle[worstId] ?? worstId,
|
|
percent: (worstRate * 100).round(),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final isDark = theme.brightness == Brightness.dark;
|
|
final weekData = _getWeeklyData();
|
|
final avgPercent = _weekAverage(weekData);
|
|
final insights = _getInsights();
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Laporan Kualitas Ibadah'),
|
|
centerTitle: false,
|
|
actions: [
|
|
IconButton(
|
|
onPressed: () {},
|
|
icon: const Icon(Icons.notifications_outlined),
|
|
),
|
|
IconButton(
|
|
onPressed: () => context.push('/settings'),
|
|
icon: const Icon(Icons.settings_outlined),
|
|
),
|
|
const SizedBox(width: 8),
|
|
],
|
|
),
|
|
body: Column(
|
|
children: [
|
|
// ── Tab Bar ──
|
|
Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: 16),
|
|
decoration: BoxDecoration(
|
|
border: Border(
|
|
bottom: BorderSide(
|
|
color: isDark
|
|
? AppColors.primary.withValues(alpha: 0.1)
|
|
: AppColors.cream,
|
|
),
|
|
),
|
|
),
|
|
child: TabBar(
|
|
controller: _tabController,
|
|
labelColor: AppColors.primary,
|
|
unselectedLabelColor: isDark
|
|
? AppColors.textSecondaryDark
|
|
: AppColors.textSecondaryLight,
|
|
indicatorColor: AppColors.primary,
|
|
indicatorWeight: 3,
|
|
labelStyle: const TextStyle(
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 14,
|
|
),
|
|
tabs: const [
|
|
Tab(text: 'Mingguan'),
|
|
Tab(text: 'Bulanan'),
|
|
Tab(text: 'Tahunan'),
|
|
],
|
|
),
|
|
),
|
|
// ── Tab Content ──
|
|
Expanded(
|
|
child: TabBarView(
|
|
controller: _tabController,
|
|
children: [
|
|
_buildWeeklyView(context, isDark, weekData, avgPercent, insights),
|
|
_buildComingSoon(context, 'Bulanan'),
|
|
_buildComingSoon(context, 'Tahunan'),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildWeeklyView(
|
|
BuildContext context,
|
|
bool isDark,
|
|
List<_DayData> weekData,
|
|
double avgPercent,
|
|
_InsightPair insights,
|
|
) {
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// ── Completion Card ──
|
|
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.1)
|
|
: AppColors.cream,
|
|
),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
'Poin Rata-Rata Harian',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: isDark
|
|
? AppColors.textSecondaryDark
|
|
: AppColors.textSecondaryLight,
|
|
),
|
|
),
|
|
Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.primary.withValues(alpha: 0.15),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: const Icon(Icons.stars,
|
|
color: AppColors.primary, size: 18),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Text(
|
|
'${avgPercent.round()} pt',
|
|
style: const TextStyle(
|
|
fontSize: 36,
|
|
fontWeight: FontWeight.w800,
|
|
height: 1.1,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 20),
|
|
// ── Bar Chart ──
|
|
SizedBox(
|
|
height: 140,
|
|
child: Builder(
|
|
builder: (context) {
|
|
final maxPts = weekData.map((d) => d.value).fold<double>(0.0, (a, b) => a > b ? a : b).clamp(50.0, 300.0);
|
|
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: weekData.map((d) {
|
|
final ratio = (d.value / maxPts).clamp(0.05, 1.0);
|
|
return Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
Flexible(
|
|
child: Container(
|
|
width: double.infinity,
|
|
height: 120 * ratio,
|
|
decoration: BoxDecoration(
|
|
color: d.isToday
|
|
? AppColors.primary
|
|
: AppColors.primary
|
|
.withValues(alpha: 0.3 + ratio * 0.4),
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
d.label,
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
fontWeight: d.isToday
|
|
? FontWeight.w700
|
|
: FontWeight.w400,
|
|
color: d.isToday
|
|
? AppColors.primary
|
|
: (isDark
|
|
? AppColors.textSecondaryDark
|
|
: AppColors.textSecondaryLight),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
);
|
|
}
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// ── Insights ──
|
|
Text('Wawasan',
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.titleMedium
|
|
?.copyWith(fontWeight: FontWeight.w700)),
|
|
const SizedBox(height: 12),
|
|
// Best performing
|
|
_insightCard(
|
|
context,
|
|
isDark,
|
|
icon: Icons.star,
|
|
iconBg: AppColors.primary.withValues(alpha: 0.15),
|
|
iconColor: AppColors.primary,
|
|
label: 'PALING RAJIN',
|
|
title: insights.best.title,
|
|
percent: insights.best.percent,
|
|
percentColor: AppColors.primary,
|
|
),
|
|
const SizedBox(height: 10),
|
|
// Needs improvement
|
|
_insightCard(
|
|
context,
|
|
isDark,
|
|
icon: Icons.trending_up,
|
|
iconBg: const Color(0xFFFFF3E0),
|
|
iconColor: Colors.orange,
|
|
label: 'PERLU DITINGKATKAN',
|
|
title: insights.worst.title,
|
|
percent: insights.worst.percent,
|
|
percentColor: Colors.orange,
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// ── Motivational Quote ──
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(20),
|
|
decoration: BoxDecoration(
|
|
color: isDark
|
|
? AppColors.primary.withValues(alpha: 0.08)
|
|
: const Color(0xFFF5F9F0),
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'❝',
|
|
style: TextStyle(
|
|
fontSize: 32,
|
|
color: AppColors.primary,
|
|
height: 0.8,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'"Amal yang paling dicintai Allah adalah yang paling konsisten, meskipun sedikit."',
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
fontStyle: FontStyle.italic,
|
|
height: 1.5,
|
|
color: isDark ? Colors.white : Colors.black87,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'— Shahih Bukhari',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: isDark
|
|
? AppColors.textSecondaryDark
|
|
: AppColors.textSecondaryLight,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _insightCard(
|
|
BuildContext context,
|
|
bool isDark, {
|
|
required IconData icon,
|
|
required Color iconBg,
|
|
required Color iconColor,
|
|
required String label,
|
|
required String title,
|
|
required int percent,
|
|
required Color percentColor,
|
|
}) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(
|
|
color: isDark
|
|
? AppColors.primary.withValues(alpha: 0.1)
|
|
: AppColors.cream,
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 44,
|
|
height: 44,
|
|
decoration: BoxDecoration(
|
|
color: iconBg,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Icon(icon, color: iconColor, size: 22),
|
|
),
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 9,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 1.5,
|
|
color: isDark
|
|
? AppColors.textSecondaryDark
|
|
: AppColors.textSecondaryLight,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
title,
|
|
style: const TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Text(
|
|
'$percent%',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w800,
|
|
color: percentColor,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildComingSoon(BuildContext context, String period) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.bar_chart,
|
|
size: 48, color: AppColors.primary.withValues(alpha: 0.3)),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'Laporan $period',
|
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Segera hadir',
|
|
style: TextStyle(
|
|
color: Theme.of(context).brightness == Brightness.dark
|
|
? AppColors.textSecondaryDark
|
|
: AppColors.textSecondaryLight),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DayData {
|
|
final String label;
|
|
final double value;
|
|
final bool isToday;
|
|
_DayData({required this.label, required this.value, this.isToday = false});
|
|
}
|
|
|
|
class _InsightItem {
|
|
final String title;
|
|
final int percent;
|
|
_InsightItem({required this.title, required this.percent});
|
|
}
|
|
|
|
class _InsightPair {
|
|
final _InsightItem best;
|
|
final _InsightItem worst;
|
|
_InsightPair({required this.best, required this.worst});
|
|
}
|