feat: Murattal player enhancements & prayer schedule auto-scroll
- 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
This commit is contained in:
566
lib/features/laporan/presentation/laporan_screen.dart
Normal file
566
lib/features/laporan/presentation/laporan_screen.dart
Normal file
@@ -0,0 +1,566 @@
|
||||
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});
|
||||
}
|
||||
Reference in New Issue
Block a user