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:
0
lib/features/checklist/presentation/.gitkeep
Normal file
0
lib/features/checklist/presentation/.gitkeep
Normal file
648
lib/features/checklist/presentation/checklist_screen.dart
Normal file
648
lib/features/checklist/presentation/checklist_screen.dart
Normal file
@@ -0,0 +1,648 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:go_router/go_router.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/app_settings.dart';
|
||||
import '../../../data/local/models/daily_worship_log.dart';
|
||||
import '../../../data/local/models/shalat_log.dart';
|
||||
import '../../../data/local/models/tilawah_log.dart';
|
||||
import '../../../data/local/models/dzikir_log.dart';
|
||||
import '../../../data/local/models/puasa_log.dart';
|
||||
|
||||
class ChecklistScreen extends ConsumerStatefulWidget {
|
||||
const ChecklistScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ChecklistScreen> createState() => _ChecklistScreenState();
|
||||
}
|
||||
|
||||
class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
late String _todayKey;
|
||||
late Box<DailyWorshipLog> _logBox;
|
||||
late Box<AppSettings> _settingsBox;
|
||||
late AppSettings _settings;
|
||||
|
||||
final List<String> _fardhuPrayers = ['Subuh', 'Dzuhur', 'Ashar', 'Maghrib', 'Isya'];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_todayKey = DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
_logBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
|
||||
_settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
_settings = _settingsBox.get('default') ?? AppSettings();
|
||||
_ensureLogExists();
|
||||
}
|
||||
|
||||
void _ensureLogExists() {
|
||||
if (!_logBox.containsKey(_todayKey)) {
|
||||
final shalatLogs = <String, ShalatLog>{};
|
||||
for (final p in _fardhuPrayers) {
|
||||
shalatLogs[p.toLowerCase()] = ShalatLog();
|
||||
}
|
||||
|
||||
_logBox.put(
|
||||
_todayKey,
|
||||
DailyWorshipLog(
|
||||
date: _todayKey,
|
||||
shalatLogs: shalatLogs,
|
||||
tilawahLog: TilawahLog(
|
||||
targetValue: _settings.tilawahTargetValue,
|
||||
targetUnit: _settings.tilawahTargetUnit,
|
||||
autoSync: _settings.tilawahAutoSync,
|
||||
),
|
||||
dzikirLog: _settings.trackDzikir ? DzikirLog() : null,
|
||||
puasaLog: _settings.trackPuasa ? PuasaLog() : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DailyWorshipLog get _todayLog => _logBox.get(_todayKey)!;
|
||||
|
||||
void _recalculateProgress() {
|
||||
final log = _todayLog;
|
||||
|
||||
// Lazily attach Dzikir and Puasa if user toggles them mid-day
|
||||
if (_settings.trackDzikir && log.dzikirLog == null) log.dzikirLog = DzikirLog();
|
||||
if (_settings.trackPuasa && log.puasaLog == null) log.puasaLog = PuasaLog();
|
||||
|
||||
int total = 0;
|
||||
int completed = 0;
|
||||
|
||||
// Shalat
|
||||
for (final p in _fardhuPrayers) {
|
||||
final pKey = p.toLowerCase();
|
||||
final sLog = log.shalatLogs[pKey];
|
||||
if (sLog != null) {
|
||||
total++;
|
||||
if (sLog.completed) completed++;
|
||||
|
||||
if (hasQabliyah(pKey, _settings.rawatibLevel)) {
|
||||
total++;
|
||||
if (sLog.qabliyah == true) completed++;
|
||||
}
|
||||
if (hasBadiyah(pKey, _settings.rawatibLevel)) {
|
||||
total++;
|
||||
if (sLog.badiyah == true) completed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tilawah
|
||||
if (log.tilawahLog != null) {
|
||||
total++;
|
||||
if (log.tilawahLog!.isCompleted) completed++;
|
||||
}
|
||||
|
||||
// Dzikir
|
||||
if (_settings.trackDzikir && log.dzikirLog != null) {
|
||||
total += 2;
|
||||
if (log.dzikirLog!.pagi) completed++;
|
||||
if (log.dzikirLog!.petang) completed++;
|
||||
}
|
||||
|
||||
// Puasa
|
||||
if (_settings.trackPuasa && log.puasaLog != null) {
|
||||
total++;
|
||||
if (log.puasaLog!.completed) completed++;
|
||||
}
|
||||
|
||||
log.totalItems = total;
|
||||
log.completedCount = completed;
|
||||
log.completionPercent = total > 0 ? completed / total : 0.0;
|
||||
log.save();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
bool hasQabliyah(String prayer, int level) {
|
||||
if (level == 0) return false;
|
||||
if (prayer == 'subuh') return true;
|
||||
if (prayer == 'dzuhur') return true;
|
||||
if (prayer == 'ashar') return level == 2; // Ghairu Muakkad
|
||||
if (prayer == 'maghrib') return level == 2; // Ghairu Muakkad
|
||||
if (prayer == 'isya') return level == 2; // Ghairu Muakkad
|
||||
return false;
|
||||
}
|
||||
|
||||
bool hasBadiyah(String prayer, int level) {
|
||||
if (level == 0) return false;
|
||||
if (prayer == 'subuh') return false;
|
||||
if (prayer == 'dzuhur') return true;
|
||||
if (prayer == 'ashar') return false;
|
||||
if (prayer == 'maghrib') return true;
|
||||
if (prayer == 'isya') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final log = _todayLog;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Ibadah Harian'),
|
||||
Text(
|
||||
DateFormat('EEEE, d MMM yyyy').format(DateTime.now()),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
_buildProgressCard(log, isDark),
|
||||
const SizedBox(height: 24),
|
||||
_sectionLabel('SHOLAT FARDHU & RAWATIB'),
|
||||
const SizedBox(height: 12),
|
||||
..._fardhuPrayers.map((p) => _buildShalatCard(p, isDark)).toList(),
|
||||
const SizedBox(height: 24),
|
||||
_sectionLabel('TILAWAH AL-QURAN'),
|
||||
const SizedBox(height: 12),
|
||||
_buildTilawahCard(isDark),
|
||||
if (_settings.trackDzikir || _settings.trackPuasa) ...[
|
||||
const SizedBox(height: 24),
|
||||
_sectionLabel('AMALAN TAMBAHAN'),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (_settings.trackDzikir) _buildDzikirCard(isDark),
|
||||
if (_settings.trackPuasa) _buildPuasaCard(isDark),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionLabel(String text) {
|
||||
return Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
color: AppColors.sage,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressCard(DailyWorshipLog log, bool isDark) {
|
||||
final percent = log.completionPercent;
|
||||
final remaining = log.totalItems - log.completedCount;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : const Color(0xFF2B3441),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"POIN HARI INI",
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
color: AppColors.primary.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.stars, color: AppColors.primary, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${log.totalPoints} pts',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${(percent * 100).round()}%',
|
||||
style: const TextStyle(
|
||||
fontSize: 42,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
'Selesai',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppProgressBar(
|
||||
value: percent,
|
||||
height: 8,
|
||||
backgroundColor: Colors.white.withValues(alpha: 0.15),
|
||||
fillColor: AppColors.primary,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
remaining == 0 && log.totalItems > 0
|
||||
? 'MasyaAllah! Poin maksimal tercapai hari ini! 🎉'
|
||||
: 'Kumpulkan poin lebih banyak dengan Sholat di Masjid dan amalan sunnah lainnya!',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildShalatCard(String prayerName, bool isDark) {
|
||||
final pKey = prayerName.toLowerCase();
|
||||
final log = _todayLog.shalatLogs[pKey];
|
||||
if (log == null) return const SizedBox.shrink();
|
||||
|
||||
final hasQab = hasQabliyah(pKey, _settings.rawatibLevel);
|
||||
final hasBad = hasBadiyah(pKey, _settings.rawatibLevel);
|
||||
final isCompleted = log.completed;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isCompleted
|
||||
? AppColors.primary.withValues(alpha: 0.3)
|
||||
: (isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.cream),
|
||||
),
|
||||
),
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
leading: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: isCompleted
|
||||
? AppColors.primary.withValues(alpha: 0.15)
|
||||
: (isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.cream.withValues(alpha: 0.5)),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(Icons.mosque, size: 22, color: isCompleted ? AppColors.primary : AppColors.sage),
|
||||
),
|
||||
title: Text(
|
||||
'Sholat $prayerName',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
color: isCompleted && isDark ? AppColors.textSecondaryDark : null,
|
||||
decoration: isCompleted ? TextDecoration.lineThrough : null,
|
||||
),
|
||||
),
|
||||
subtitle: log.location != null
|
||||
? Text('Di ${log.location}', style: const TextStyle(fontSize: 12, color: AppColors.primary))
|
||||
: null,
|
||||
trailing: _CustomCheckbox(
|
||||
value: isCompleted,
|
||||
onChanged: (v) {
|
||||
log.completed = v ?? false;
|
||||
_recalculateProgress();
|
||||
},
|
||||
),
|
||||
childrenPadding: const EdgeInsets.only(left: 16, right: 16, bottom: 16),
|
||||
children: [
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
// Location Radio
|
||||
Row(
|
||||
children: [
|
||||
const Text('Pelaksanaan:', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(width: 16),
|
||||
_radioOption('Masjid', log, () {
|
||||
log.location = 'Masjid';
|
||||
log.completed = true; // Auto-check parent
|
||||
_recalculateProgress();
|
||||
}),
|
||||
const SizedBox(width: 16),
|
||||
_radioOption('Rumah', log, () {
|
||||
log.location = 'Rumah';
|
||||
log.completed = true; // Auto-check parent
|
||||
_recalculateProgress();
|
||||
}),
|
||||
],
|
||||
),
|
||||
if (hasQab || hasBad) const SizedBox(height: 12),
|
||||
if (hasQab)
|
||||
_sunnahRow('Qabliyah $prayerName', log.qabliyah ?? false, (v) {
|
||||
log.qabliyah = v;
|
||||
_recalculateProgress();
|
||||
}),
|
||||
if (hasBad)
|
||||
_sunnahRow('Ba\'diyah $prayerName', log.badiyah ?? false, (v) {
|
||||
log.badiyah = v;
|
||||
_recalculateProgress();
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _radioOption(String title, ShalatLog log, VoidCallback onTap) {
|
||||
final selected = log.location == title;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
selected ? Icons.radio_button_checked : Icons.radio_button_off,
|
||||
size: 18,
|
||||
color: selected ? AppColors.primary : Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(title, style: TextStyle(fontSize: 13, color: selected ? AppColors.primary : null)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sunnahRow(String title, bool value, ValueChanged<bool?> onChanged) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(title, style: const TextStyle(fontSize: 14)),
|
||||
_CustomCheckbox(value: value, onChanged: onChanged),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTilawahCard(bool isDark) {
|
||||
final log = _todayLog.tilawahLog;
|
||||
if (log == null) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: log.isCompleted
|
||||
? AppColors.primary.withValues(alpha: 0.3)
|
||||
: (isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.cream),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// ── Row 1: Target + Checkbox ──
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: log.isCompleted
|
||||
? AppColors.primary.withValues(alpha: 0.15)
|
||||
: (isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.cream.withValues(alpha: 0.5)),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(Icons.menu_book, size: 22, color: log.isCompleted ? AppColors.primary : AppColors.sage),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Tilawah',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: log.isCompleted && isDark ? AppColors.textSecondaryDark : null,
|
||||
decoration: log.isCompleted ? TextDecoration.lineThrough : null,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Target: ${log.targetValue} ${log.targetUnit}',
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_CustomCheckbox(
|
||||
value: log.targetCompleted,
|
||||
onChanged: (v) {
|
||||
log.targetCompleted = v ?? false;
|
||||
_recalculateProgress();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 12),
|
||||
// ── Row 2: Ayat Tracker ──
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.auto_stories, size: 18, color: AppColors.sage),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Sudah Baca: ${log.rawAyatRead} Ayat',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (log.autoSync)
|
||||
Tooltip(
|
||||
message: 'Sinkron dari Al-Quran',
|
||||
child: Icon(Icons.sync, size: 16, color: AppColors.primary),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove_circle_outline, size: 20),
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: log.rawAyatRead > 0
|
||||
? () {
|
||||
log.rawAyatRead--;
|
||||
_recalculateProgress();
|
||||
}
|
||||
: null,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline, size: 20, color: AppColors.primary),
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () {
|
||||
log.rawAyatRead++;
|
||||
_recalculateProgress();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDzikirCard(bool isDark) {
|
||||
final log = _todayLog.dzikirLog;
|
||||
if (log == null) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.auto_awesome, size: 20, color: AppColors.sage),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Dzikir Harian', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_sunnahRow('Dzikir Pagi', log.pagi, (v) {
|
||||
log.pagi = v ?? false;
|
||||
_recalculateProgress();
|
||||
}),
|
||||
_sunnahRow('Dzikir Petang', log.petang, (v) {
|
||||
log.petang = v ?? false;
|
||||
_recalculateProgress();
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPuasaCard(bool isDark) {
|
||||
final log = _todayLog.puasaLog;
|
||||
if (log == null) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.nightlight_round, size: 20, color: AppColors.sage),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(child: Text('Puasa Sunnah', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15))),
|
||||
DropdownButton<String>(
|
||||
value: log.jenisPuasa,
|
||||
hint: const Text('Jenis', style: TextStyle(fontSize: 12)),
|
||||
underline: const SizedBox(),
|
||||
items: ['Senin', 'Kamis', 'Ayyamul Bidh', 'Daud', 'Lainnya']
|
||||
.map((e) => DropdownMenuItem(value: e, child: Text(e, style: const TextStyle(fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
log.jenisPuasa = v;
|
||||
_recalculateProgress();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CustomCheckbox(
|
||||
value: log.completed,
|
||||
onChanged: (v) {
|
||||
log.completed = v ?? false;
|
||||
_recalculateProgress();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CustomCheckbox extends StatelessWidget {
|
||||
final bool value;
|
||||
final ValueChanged<bool?> onChanged;
|
||||
|
||||
const _CustomCheckbox({required this.value, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () => onChanged(!value),
|
||||
child: Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: value ? AppColors.primary : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: value ? null : Border.all(color: Colors.grey, width: 2),
|
||||
),
|
||||
child: value ? const Icon(Icons.check, size: 16, color: Colors.white) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1
lib/features/checklist/presentation/placeholder.dart
Normal file
1
lib/features/checklist/presentation/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
0
lib/features/dashboard/data/.gitkeep
Normal file
0
lib/features/dashboard/data/.gitkeep
Normal file
1
lib/features/dashboard/data/placeholder.dart
Normal file
1
lib/features/dashboard/data/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
210
lib/features/dashboard/data/prayer_times_provider.dart
Normal file
210
lib/features/dashboard/data/prayer_times_provider.dart
Normal file
@@ -0,0 +1,210 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../data/services/myquran_sholat_service.dart';
|
||||
import '../../../data/services/prayer_service.dart';
|
||||
import '../../../data/services/location_service.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
|
||||
/// Represents a single prayer time entry.
|
||||
class PrayerTimeEntry {
|
||||
final String name;
|
||||
final String time; // "HH:mm"
|
||||
final bool isActive;
|
||||
PrayerTimeEntry({
|
||||
required this.name,
|
||||
required this.time,
|
||||
this.isActive = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// Full day prayer schedule from myQuran API.
|
||||
class DaySchedule {
|
||||
final String cityName;
|
||||
final String province;
|
||||
final String date; // yyyy-MM-dd
|
||||
final String tanggal; // formatted date from API
|
||||
final Map<String, String> times; // {imsak, subuh, terbit, dhuha, dzuhur, ashar, maghrib, isya}
|
||||
|
||||
DaySchedule({
|
||||
required this.cityName,
|
||||
required this.province,
|
||||
required this.date,
|
||||
required this.tanggal,
|
||||
required this.times,
|
||||
});
|
||||
|
||||
/// Is this schedule for tomorrow?
|
||||
bool get isTomorrow {
|
||||
final todayStr = DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
return date.compareTo(todayStr) > 0;
|
||||
}
|
||||
|
||||
/// Get prayer time entries as a list.
|
||||
List<PrayerTimeEntry> get prayerList {
|
||||
final now = DateTime.now();
|
||||
final formatter = DateFormat('HH:mm');
|
||||
final currentTime = formatter.format(now);
|
||||
|
||||
final prayers = [
|
||||
PrayerTimeEntry(name: 'Imsak', time: times['imsak'] ?? '-'),
|
||||
PrayerTimeEntry(name: 'Subuh', time: times['subuh'] ?? '-'),
|
||||
PrayerTimeEntry(name: 'Terbit', time: times['terbit'] ?? '-'),
|
||||
PrayerTimeEntry(name: 'Dhuha', time: times['dhuha'] ?? '-'),
|
||||
PrayerTimeEntry(name: 'Dzuhur', time: times['dzuhur'] ?? '-'),
|
||||
PrayerTimeEntry(name: 'Ashar', time: times['ashar'] ?? '-'),
|
||||
PrayerTimeEntry(name: 'Maghrib', time: times['maghrib'] ?? '-'),
|
||||
PrayerTimeEntry(name: 'Isya', time: times['isya'] ?? '-'),
|
||||
];
|
||||
|
||||
// Find the next prayer
|
||||
int activeIndex = -1;
|
||||
if (isTomorrow) {
|
||||
// User specifically requested to show tomorrow's Subuh as upcoming
|
||||
activeIndex = 1; // 0=Imsak, 1=Subuh
|
||||
} else {
|
||||
for (int i = 0; i < prayers.length; i++) {
|
||||
if (prayers[i].time != '-' && prayers[i].time.compareTo(currentTime) > 0) {
|
||||
activeIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activeIndex >= 0) {
|
||||
prayers[activeIndex] = PrayerTimeEntry(
|
||||
name: prayers[activeIndex].name,
|
||||
time: prayers[activeIndex].time,
|
||||
isActive: true,
|
||||
);
|
||||
}
|
||||
|
||||
return prayers;
|
||||
}
|
||||
|
||||
/// Get the next prayer name and time.
|
||||
PrayerTimeEntry? get nextPrayer {
|
||||
final list = prayerList;
|
||||
for (final p in list) {
|
||||
if (p.isActive) return p;
|
||||
}
|
||||
// If none active and it's today, all prayers have passed
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Default Jakarta city ID from myQuran API.
|
||||
const _defaultCityId = '58a2fc6ed39fd083f55d4182bf88826d';
|
||||
|
||||
/// Provider for the user's selected city ID (stored in Hive settings).
|
||||
final selectedCityIdProvider = StateProvider<String>((ref) {
|
||||
final box = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = box.get('default');
|
||||
final stored = settings?.lastCityName ?? '';
|
||||
if (stored.contains('|')) {
|
||||
return stored.split('|').last;
|
||||
}
|
||||
return _defaultCityId;
|
||||
});
|
||||
|
||||
/// Provider for today's prayer times using myQuran API.
|
||||
final prayerTimesProvider = FutureProvider<DaySchedule?>((ref) async {
|
||||
final cityId = ref.watch(selectedCityIdProvider);
|
||||
final today = DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
|
||||
DaySchedule? schedule;
|
||||
|
||||
// Try API first
|
||||
final jadwal =
|
||||
await MyQuranSholatService.instance.getDailySchedule(cityId, today);
|
||||
|
||||
if (jadwal != null) {
|
||||
final cityInfo = await MyQuranSholatService.instance.getCityInfo(cityId);
|
||||
schedule = DaySchedule(
|
||||
cityName: cityInfo?['kabko'] ?? 'Jakarta',
|
||||
province: cityInfo?['prov'] ?? 'DKI Jakarta',
|
||||
date: today,
|
||||
tanggal: jadwal['tanggal'] ?? today,
|
||||
times: jadwal,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if all prayers today have passed
|
||||
if (schedule != null && !schedule.isTomorrow && schedule.nextPrayer == null) {
|
||||
// All prayers passed, fetch tomorrow's schedule
|
||||
final tomorrow = DateTime.now().add(const Duration(days: 1));
|
||||
final tomorrowStr = DateFormat('yyyy-MM-dd').format(tomorrow);
|
||||
|
||||
final tmrwJadwal =
|
||||
await MyQuranSholatService.instance.getDailySchedule(cityId, tomorrowStr);
|
||||
|
||||
if (tmrwJadwal != null) {
|
||||
final cityInfo = await MyQuranSholatService.instance.getCityInfo(cityId);
|
||||
schedule = DaySchedule(
|
||||
cityName: cityInfo?['kabko'] ?? 'Jakarta',
|
||||
province: cityInfo?['prov'] ?? 'DKI Jakarta',
|
||||
date: tomorrowStr,
|
||||
tanggal: tmrwJadwal['tanggal'] ?? tomorrowStr,
|
||||
times: tmrwJadwal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (schedule != null) {
|
||||
return schedule;
|
||||
}
|
||||
|
||||
// Fallback to adhan package
|
||||
final position = await LocationService.instance.getCurrentLocation();
|
||||
double lat = position?.latitude ?? -6.2088;
|
||||
double lng = position?.longitude ?? 106.8456;
|
||||
|
||||
final result = PrayerService.instance.getPrayerTimes(lat, lng, DateTime.now());
|
||||
if (result != null) {
|
||||
final timeFormat = DateFormat('HH:mm');
|
||||
return DaySchedule(
|
||||
cityName: 'Jakarta',
|
||||
province: 'DKI Jakarta',
|
||||
date: today,
|
||||
tanggal: DateFormat('EEEE, dd/MM/yyyy').format(DateTime.now()),
|
||||
times: {
|
||||
'imsak': timeFormat.format(result.fajr.subtract(const Duration(minutes: 10))),
|
||||
'subuh': timeFormat.format(result.fajr),
|
||||
'terbit': timeFormat.format(result.sunrise),
|
||||
'dhuha': timeFormat.format(result.sunrise.add(const Duration(minutes: 15))),
|
||||
'dzuhur': timeFormat.format(result.dhuhr),
|
||||
'ashar': timeFormat.format(result.asr),
|
||||
'maghrib': timeFormat.format(result.maghrib),
|
||||
'isya': timeFormat.format(result.isha),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
/// Provider for monthly prayer schedule (for Imsakiyah screen).
|
||||
final monthlyScheduleProvider =
|
||||
FutureProvider.family<Map<String, Map<String, String>>, String>(
|
||||
(ref, month) async {
|
||||
final cityId = ref.watch(selectedCityIdProvider);
|
||||
return MyQuranSholatService.instance.getMonthlySchedule(cityId, month);
|
||||
});
|
||||
|
||||
/// Provider for current city name.
|
||||
final cityNameProvider = FutureProvider<String>((ref) async {
|
||||
final box = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = box.get('default');
|
||||
final stored = settings?.lastCityName ?? '';
|
||||
if (stored.contains('|')) {
|
||||
return stored.split('|').first;
|
||||
}
|
||||
|
||||
final cityId = ref.watch(selectedCityIdProvider);
|
||||
final info = await MyQuranSholatService.instance.getCityInfo(cityId);
|
||||
if (info != null) {
|
||||
return '${info['kabko']}, ${info['prov']}';
|
||||
}
|
||||
return 'Kota Jakarta, DKI Jakarta';
|
||||
});
|
||||
0
lib/features/dashboard/domain/.gitkeep
Normal file
0
lib/features/dashboard/domain/.gitkeep
Normal file
1
lib/features/dashboard/domain/placeholder.dart
Normal file
1
lib/features/dashboard/domain/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
673
lib/features/dashboard/presentation/dashboard_screen.dart
Normal file
673
lib/features/dashboard/presentation/dashboard_screen.dart
Normal file
@@ -0,0 +1,673 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../core/widgets/prayer_time_card.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/daily_worship_log.dart';
|
||||
import '../data/prayer_times_provider.dart';
|
||||
|
||||
class DashboardScreen extends ConsumerStatefulWidget {
|
||||
const DashboardScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DashboardScreen> createState() => _DashboardScreenState();
|
||||
}
|
||||
|
||||
class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
Timer? _countdownTimer;
|
||||
Duration _countdown = Duration.zero;
|
||||
String _nextPrayerName = '';
|
||||
final ScrollController _prayerScrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_countdownTimer?.cancel();
|
||||
_prayerScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startCountdown(DaySchedule schedule) {
|
||||
_countdownTimer?.cancel();
|
||||
_updateCountdown(schedule);
|
||||
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
_updateCountdown(schedule);
|
||||
});
|
||||
}
|
||||
|
||||
void _updateCountdown(DaySchedule schedule) {
|
||||
final next = schedule.nextPrayer;
|
||||
if (next != null && next.time != '-') {
|
||||
final parts = next.time.split(':');
|
||||
if (parts.length == 2) {
|
||||
final now = DateTime.now();
|
||||
var target = DateTime(now.year, now.month, now.day,
|
||||
int.parse(parts[0]), int.parse(parts[1]));
|
||||
if (target.isBefore(now)) {
|
||||
target = target.add(const Duration(days: 1));
|
||||
}
|
||||
setState(() {
|
||||
_nextPrayerName = next.name;
|
||||
_countdown = target.difference(now);
|
||||
if (_countdown.isNegative) _countdown = Duration.zero;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _formatCountdown(Duration d) {
|
||||
final h = d.inHours.toString().padLeft(2, '0');
|
||||
final m = (d.inMinutes % 60).toString().padLeft(2, '0');
|
||||
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
|
||||
return '$h:$m:$s';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final prayerTimesAsync = ref.watch(prayerTimesProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
_buildHeader(context, isDark),
|
||||
const SizedBox(height: 20),
|
||||
prayerTimesAsync.when(
|
||||
data: (schedule) {
|
||||
if (schedule != null) {
|
||||
_startCountdown(schedule);
|
||||
return _buildHeroCard(context, schedule);
|
||||
}
|
||||
return _buildHeroCardPlaceholder(context);
|
||||
},
|
||||
loading: () => _buildHeroCardPlaceholder(context),
|
||||
error: (_, __) => _buildHeroCardPlaceholder(context),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildPrayerTimesSection(context, prayerTimesAsync),
|
||||
const SizedBox(height: 24),
|
||||
_buildChecklistSummary(context, isDark),
|
||||
const SizedBox(height: 24),
|
||||
_buildWeeklyProgress(context, isDark),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context, bool isDark) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: AppColors.primary, width: 2),
|
||||
color: AppColors.primary.withValues(alpha: 0.2),
|
||||
),
|
||||
child: const Icon(Icons.person, size: 20, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Selamat datang,',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"Assalamu'alaikum",
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: Icon(
|
||||
Icons.notifications_outlined,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => context.push('/settings'),
|
||||
icon: Icon(
|
||||
Icons.settings_outlined,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeroCard(BuildContext context, DaySchedule schedule) {
|
||||
final next = schedule.nextPrayer;
|
||||
final name = _nextPrayerName.isNotEmpty
|
||||
? _nextPrayerName
|
||||
: (next?.name ?? 'Isya');
|
||||
final time = next?.time ?? '--:--';
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primary.withValues(alpha: 0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: -20,
|
||||
right: -20,
|
||||
child: Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.schedule,
|
||||
size: 16,
|
||||
color: AppColors.onPrimary.withValues(alpha: 0.8)),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'SHOLAT BERIKUTNYA',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
color: AppColors.onPrimary.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'$name — $time',
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.onPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Hitung mundur: ${_formatCountdown(_countdown)}',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.onPrimary.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// City name
|
||||
Text(
|
||||
'📍 ${schedule.cityName}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.onPrimary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => context.push('/tools/qibla'),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.onPrimary,
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.explore, size: 18, color: Colors.white),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Arah Kiblat',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.volume_up,
|
||||
color: AppColors.onPrimary,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeroCardPlaceholder(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: 180,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.onPrimary),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPrayerTimesSection(
|
||||
BuildContext context, AsyncValue<DaySchedule?> prayerTimesAsync) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
prayerTimesAsync.value?.isTomorrow == true
|
||||
? 'Jadwal Sholat Besok'
|
||||
: 'Jadwal Sholat Hari Ini',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700)),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: Text(
|
||||
prayerTimesAsync.value?.isTomorrow == true ? 'BESOK' : 'HARI INI',
|
||||
style: TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 110,
|
||||
child: prayerTimesAsync.when(
|
||||
data: (schedule) {
|
||||
if (schedule == null) return const SizedBox();
|
||||
final prayers = schedule.prayerList.where(
|
||||
(p) => ['Subuh', 'Dzuhur', 'Ashar', 'Maghrib', 'Isya']
|
||||
.contains(p.name),
|
||||
).toList();
|
||||
return ListView.separated(
|
||||
controller: _prayerScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: prayers.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, i) {
|
||||
final p = prayers[i];
|
||||
final icon = _prayerIcon(p.name);
|
||||
// Auto-scroll to active prayer on first build
|
||||
if (p.isActive && i > 0) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_prayerScrollController.hasClients) {
|
||||
final targetOffset = i * 124.0; // 112 width + 12 gap
|
||||
_prayerScrollController.animateTo(
|
||||
targetOffset.clamp(0, _prayerScrollController.position.maxScrollExtent),
|
||||
duration: const Duration(milliseconds: 400),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
return PrayerTimeCard(
|
||||
prayerName: p.name,
|
||||
time: p.time,
|
||||
icon: icon,
|
||||
isActive: p.isActive,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) =>
|
||||
const Center(child: Text('Gagal memuat jadwal')),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
IconData _prayerIcon(String name) {
|
||||
switch (name) {
|
||||
case 'Subuh':
|
||||
return Icons.wb_twilight;
|
||||
case 'Dzuhur':
|
||||
return Icons.wb_sunny;
|
||||
case 'Ashar':
|
||||
return Icons.filter_drama;
|
||||
case 'Maghrib':
|
||||
return Icons.wb_twilight;
|
||||
case 'Isya':
|
||||
return Icons.dark_mode;
|
||||
default:
|
||||
return Icons.schedule;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildChecklistSummary(BuildContext context, bool isDark) {
|
||||
final todayKey = DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
final box = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
|
||||
final log = box.get(todayKey);
|
||||
|
||||
final points = log?.totalPoints ?? 0;
|
||||
// We can assume a max "excellent" day is around 150 points for the progress ring scale
|
||||
final percent = (points / 150).clamp(0.0, 1.0);
|
||||
|
||||
// Prepare dynamic preview lines
|
||||
int fardhuCompleted = 0;
|
||||
if (log != null) {
|
||||
fardhuCompleted = log.shalatLogs.values.where((l) => l.completed).length;
|
||||
}
|
||||
|
||||
String amalanText = 'Belum ada data';
|
||||
if (log != null) {
|
||||
List<String> aList = [];
|
||||
if (log.tilawahLog?.isCompleted == true) aList.add('Tilawah');
|
||||
if (log.puasaLog?.completed == true) aList.add('Puasa');
|
||||
if (log.dzikirLog?.pagi == true) aList.add('Dzikir');
|
||||
if (aList.isNotEmpty) {
|
||||
amalanText = aList.join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
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: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Poin Ibadah Hari Ini',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Kumpulkan poin dengan konsisten!',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
value: percent,
|
||||
strokeWidth: 4,
|
||||
backgroundColor:
|
||||
AppColors.primary.withValues(alpha: 0.15),
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||
AppColors.primary),
|
||||
),
|
||||
Text(
|
||||
'$points',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_checklistPreviewItem(
|
||||
context, isDark, 'Sholat Fardhu', '$fardhuCompleted dari 5 selesai', fardhuCompleted == 5),
|
||||
const SizedBox(height: 8),
|
||||
_checklistPreviewItem(
|
||||
context, isDark, 'Amalan Selesai', amalanText, points > 50),
|
||||
const SizedBox(height: 16),
|
||||
GestureDetector(
|
||||
onTap: () => context.go('/checklist'),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Lihat Semua Checklist',
|
||||
style: TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _checklistPreviewItem(BuildContext context, bool isDark, String title,
|
||||
String subtitle, bool completed) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.05)
|
||||
: AppColors.backgroundLight,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
completed ? Icons.check_circle : Icons.radio_button_unchecked,
|
||||
color: AppColors.primary,
|
||||
size: 22,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600)),
|
||||
Text(subtitle,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWeeklyProgress(BuildContext context, bool isDark) {
|
||||
final box = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
|
||||
final now = DateTime.now();
|
||||
|
||||
// Reverse so today is on the far right (index 6)
|
||||
final last7Days = List.generate(7, (i) => now.subtract(Duration(days: 6 - i)));
|
||||
final daysLabels = ['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min'];
|
||||
|
||||
final weekPoints = <int>[];
|
||||
for (final d in last7Days) {
|
||||
final k = DateFormat('yyyy-MM-dd').format(d);
|
||||
final l = box.get(k);
|
||||
weekPoints.add(l?.totalPoints ?? 0);
|
||||
}
|
||||
|
||||
// Find the max points acquired this week to scale the bars, with a minimum floor of 50
|
||||
final maxPts = weekPoints.reduce((a, b) => a > b ? a : b).clamp(50, 300);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Progres Poin Mingguan',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
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(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: List.generate(7, (i) {
|
||||
final val = weekPoints[i];
|
||||
final ratio = (val / maxPts).clamp(0.1, 1.0);
|
||||
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 80,
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
width: 24,
|
||||
height: 80 * ratio,
|
||||
decoration: BoxDecoration(
|
||||
color: val > 0
|
||||
? AppColors.primary.withValues(
|
||||
alpha: 0.2 + ratio * 0.8)
|
||||
: AppColors.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
daysLabels[last7Days[i].weekday - 1], // Correct localized day
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: i == 6 ? FontWeight.w800 : FontWeight.w600,
|
||||
color: i == 6
|
||||
? AppColors.primary
|
||||
: (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
0
lib/features/dzikir/presentation/.gitkeep
Normal file
0
lib/features/dzikir/presentation/.gitkeep
Normal file
306
lib/features/dzikir/presentation/dzikir_screen.dart
Normal file
306
lib/features/dzikir/presentation/dzikir_screen.dart
Normal file
@@ -0,0 +1,306 @@
|
||||
import 'dart:convert';
|
||||
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 '../../../app/theme/app_colors.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/dzikir_counter.dart';
|
||||
|
||||
class DzikirScreen extends ConsumerStatefulWidget {
|
||||
const DzikirScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DzikirScreen> createState() => _DzikirScreenState();
|
||||
}
|
||||
|
||||
class _DzikirScreenState extends ConsumerState<DzikirScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
List<Map<String, dynamic>> _pagiItems = [];
|
||||
List<Map<String, dynamic>> _petangItems = [];
|
||||
late Box<DzikirCounter> _counterBox;
|
||||
late String _todayKey;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
_counterBox = Hive.box<DzikirCounter>(HiveBoxes.dzikirCounters);
|
||||
_todayKey = DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
_loadData();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
final pagiJson =
|
||||
await rootBundle.loadString('assets/dzikir/dzikir_pagi.json');
|
||||
final petangJson =
|
||||
await rootBundle.loadString('assets/dzikir/dzikir_petang.json');
|
||||
setState(() {
|
||||
_pagiItems = List<Map<String, dynamic>>.from(json.decode(pagiJson));
|
||||
_petangItems = List<Map<String, dynamic>>.from(json.decode(petangJson));
|
||||
});
|
||||
}
|
||||
|
||||
DzikirCounter _getCounter(String dzikirId, int target) {
|
||||
final key = '${dzikirId}_$_todayKey';
|
||||
return _counterBox.get(key) ??
|
||||
DzikirCounter(
|
||||
dzikirId: dzikirId,
|
||||
date: _todayKey,
|
||||
count: 0,
|
||||
target: target,
|
||||
);
|
||||
}
|
||||
|
||||
void _increment(String dzikirId, int target) {
|
||||
final key = '${dzikirId}_$_todayKey';
|
||||
var counter = _counterBox.get(key);
|
||||
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();
|
||||
}
|
||||
}
|
||||
setState(() {});
|
||||
// Haptic feedback
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Dzikir Pagi & Petang'),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.info_outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Tabs
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
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: 'Pagi'),
|
||||
Tab(text: 'Petang'),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildDzikirList(context, isDark, _pagiItems, 'pagi',
|
||||
'Dzikir Pagi', 'Dibaca setelah shalat Shubuh hingga terbit matahari'),
|
||||
_buildDzikirList(context, isDark, _petangItems, 'petang',
|
||||
'Dzikir Petang', 'Dibaca setelah shalat Ashar hingga terbenam matahari'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDzikirList(BuildContext context, bool isDark,
|
||||
List<Map<String, dynamic>> items, String prefix, String title, String subtitle) {
|
||||
if (items.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: items.length + 1, // +1 for header
|
||||
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 = '${prefix}_${item['id']}';
|
||||
final target = (item['count'] 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: [
|
||||
// Header row: count badge + number
|
||||
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),
|
||||
// Arabic text
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Text(
|
||||
item['arabic'] ?? '',
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 24,
|
||||
height: 2.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Transliteration
|
||||
Text(
|
||||
item['transliteration'] ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Translation
|
||||
Text(
|
||||
'"${item['translation'] ?? ''}"',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Counter button
|
||||
GestureDetector(
|
||||
onTap: () => _increment(dzikirId, target),
|
||||
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 ? Icons.check : Icons.touch_app,
|
||||
size: 18,
|
||||
color: isComplete
|
||||
? AppColors.primary
|
||||
: AppColors.onPrimary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${counter.count} / $target',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isComplete
|
||||
? AppColors.primary
|
||||
: AppColors.onPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
1
lib/features/dzikir/presentation/placeholder.dart
Normal file
1
lib/features/dzikir/presentation/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
0
lib/features/imsakiyah/presentation/.gitkeep
Normal file
0
lib/features/imsakiyah/presentation/.gitkeep
Normal file
557
lib/features/imsakiyah/presentation/imsakiyah_screen.dart
Normal file
557
lib/features/imsakiyah/presentation/imsakiyah_screen.dart
Normal file
@@ -0,0 +1,557 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
import '../../../data/services/prayer_service.dart';
|
||||
import '../../../data/services/myquran_sholat_service.dart';
|
||||
import '../../dashboard/data/prayer_times_provider.dart';
|
||||
|
||||
class ImsakiyahScreen extends ConsumerStatefulWidget {
|
||||
const ImsakiyahScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ImsakiyahScreen> createState() => _ImsakiyahScreenState();
|
||||
}
|
||||
|
||||
class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
int _selectedMonthIndex = 0;
|
||||
late List<_MonthOption> _months;
|
||||
late AppSettings _settings;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final box = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
_settings = box.get('default') ?? AppSettings();
|
||||
_months = _generateMonths();
|
||||
// Find current month
|
||||
final now = DateTime.now();
|
||||
for (int i = 0; i < _months.length; i++) {
|
||||
if (_months[i].month == now.month && _months[i].year == now.year) {
|
||||
_selectedMonthIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<_MonthOption> _generateMonths() {
|
||||
final now = DateTime.now();
|
||||
final list = <_MonthOption>[];
|
||||
for (int offset = -2; offset <= 3; offset++) {
|
||||
final date = DateTime(now.year, now.month + offset, 1);
|
||||
list.add(_MonthOption(
|
||||
label: DateFormat('MMMM yyyy').format(date),
|
||||
month: date.month,
|
||||
year: date.year,
|
||||
));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
List<_DayRow> _createRows(Map<String, Map<String, String>>? apiData) {
|
||||
final selected = _months[_selectedMonthIndex];
|
||||
final daysInMonth =
|
||||
DateTime(selected.year, selected.month + 1, 0).day;
|
||||
final rows = <_DayRow>[];
|
||||
|
||||
for (int d = 1; d <= daysInMonth; d++) {
|
||||
final date = DateTime(selected.year, selected.month, d);
|
||||
final dateStr = DateFormat('yyyy-MM-dd').format(date);
|
||||
|
||||
if (apiData != null && apiData.containsKey(dateStr)) {
|
||||
final times = apiData[dateStr]!;
|
||||
rows.add(_DayRow(
|
||||
date: date,
|
||||
fajr: times['subuh'] ?? '-',
|
||||
sunrise: times['terbit'] ?? '-',
|
||||
dhuhr: times['dzuhur'] ?? '-',
|
||||
asr: times['ashar'] ?? '-',
|
||||
maghrib: times['maghrib'] ?? '-',
|
||||
isha: times['isya'] ?? '-',
|
||||
));
|
||||
} else {
|
||||
final times =
|
||||
PrayerService.instance.getPrayerTimes(-6.2088, 106.8456, date);
|
||||
rows.add(_DayRow(
|
||||
date: date,
|
||||
fajr: DateFormat('HH:mm').format(times.fajr),
|
||||
sunrise: DateFormat('HH:mm').format(times.sunrise),
|
||||
dhuhr: DateFormat('HH:mm').format(times.dhuhr),
|
||||
asr: DateFormat('HH:mm').format(times.asr),
|
||||
maghrib: DateFormat('HH:mm').format(times.maghrib),
|
||||
isha: DateFormat('HH:mm').format(times.isha),
|
||||
));
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
void _showLocationDialog(BuildContext context) {
|
||||
final searchCtrl = TextEditingController();
|
||||
bool isSearching = false;
|
||||
List<Map<String, dynamic>> results = [];
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setDialogState) => AlertDialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
title: const Text('Cari Kota/Kabupaten'),
|
||||
content: SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.85,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: searchCtrl,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Cth: Jakarta',
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () async {
|
||||
if (searchCtrl.text.trim().isEmpty) return;
|
||||
setDialogState(() => isSearching = true);
|
||||
final res = await MyQuranSholatService.instance
|
||||
.searchCity(searchCtrl.text.trim());
|
||||
if (mounted) {
|
||||
setDialogState(() {
|
||||
results = res;
|
||||
isSearching = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
onSubmitted: (val) async {
|
||||
if (val.trim().isEmpty) return;
|
||||
setDialogState(() => isSearching = true);
|
||||
final res = await MyQuranSholatService.instance
|
||||
.searchCity(val.trim());
|
||||
if (mounted) {
|
||||
setDialogState(() {
|
||||
results = res;
|
||||
isSearching = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (isSearching)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else if (results.isEmpty)
|
||||
const Text('Tidak ada hasil', style: TextStyle(color: Colors.grey))
|
||||
else
|
||||
SizedBox(
|
||||
height: 200,
|
||||
width: double.maxFinite,
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: results.length,
|
||||
itemBuilder: (context, i) {
|
||||
final city = results[i];
|
||||
return ListTile(
|
||||
title: Text(city['lokasi'] ?? ''),
|
||||
onTap: () {
|
||||
final id = city['id'];
|
||||
final name = city['lokasi'];
|
||||
if (id != null && name != null) {
|
||||
_settings.lastCityName = '$name|$id';
|
||||
_settings.save();
|
||||
|
||||
// Update providers to refresh data
|
||||
ref.invalidate(selectedCityIdProvider);
|
||||
ref.invalidate(cityNameProvider);
|
||||
|
||||
Navigator.pop(ctx);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Batal'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final today = DateTime.now();
|
||||
|
||||
final selectedMonth = _months[_selectedMonthIndex];
|
||||
final monthArg = '${selectedMonth.year}-${selectedMonth.month.toString().padLeft(2, '0')}';
|
||||
final cityNameAsync = ref.watch(cityNameProvider);
|
||||
final monthlyDataAsync = ref.watch(monthlyScheduleProvider(monthArg));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Kalender Sholat'),
|
||||
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: [
|
||||
// ── Month Selector ──
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
itemCount: _months.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, i) {
|
||||
final isSelected = i == _selectedMonthIndex;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _selectedMonthIndex = i),
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? AppColors.primary
|
||||
: (isDark ? AppColors.surfaceDark : AppColors.surfaceLight),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
border: isSelected
|
||||
? null
|
||||
: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.2)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
_months[i].label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight:
|
||||
isSelected ? FontWeight.w600 : FontWeight.w400,
|
||||
color: isSelected
|
||||
? AppColors.onPrimary
|
||||
: (isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ── Location Card ──
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: GestureDetector(
|
||||
onTap: () => _showLocationDialog(context),
|
||||
child: 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: [
|
||||
const Icon(Icons.location_on,
|
||||
color: AppColors.primary, size: 24),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Lokasi Anda',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
cityNameAsync.value ?? 'Jakarta, Indonesia',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600, fontSize: 15),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.expand_more,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Table Header ──
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.1),
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_headerCell('TGL', flex: 4),
|
||||
_headerCell('SUBUH', flex: 3),
|
||||
_headerCell('SYURUQ', flex: 3),
|
||||
_headerCell('DZUHUR', flex: 3),
|
||||
_headerCell('ASHAR', flex: 3),
|
||||
_headerCell('MAGH', flex: 3),
|
||||
_headerCell('ISYA', flex: 3),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── Table Body ──
|
||||
Expanded(
|
||||
child: monthlyDataAsync.when(
|
||||
data: (apiData) {
|
||||
final rows = _createRows(apiData);
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, i) {
|
||||
final row = rows[i];
|
||||
final isToday = row.date.day == today.day &&
|
||||
row.date.month == today.month &&
|
||||
row.date.year == today.year;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: isToday
|
||||
? AppColors.primary
|
||||
: (isDark
|
||||
? AppColors.surfaceDark
|
||||
: AppColors.surfaceLight),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: isToday
|
||||
? null
|
||||
: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.05)
|
||||
: AppColors.cream.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Day column
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
DateFormat('MMM')
|
||||
.format(row.date)
|
||||
.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1,
|
||||
color: isToday
|
||||
? AppColors.onPrimary
|
||||
.withValues(alpha: 0.7)
|
||||
: AppColors.sage,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${row.date.day}',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: isToday ? AppColors.onPrimary : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_dataCell(row.fajr, isToday, flex: 3),
|
||||
_dataCell(row.sunrise, isToday, flex: 3),
|
||||
_dataCell(row.dhuhr, isToday, bold: true, flex: 3),
|
||||
_dataCell(row.asr, isToday, flex: 3),
|
||||
_dataCell(row.maghrib, isToday, bold: true, flex: 3),
|
||||
_dataCell(row.isha, isToday, flex: 3),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) {
|
||||
final rows = _createRows(null); // fallback
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, i) {
|
||||
final row = rows[i];
|
||||
final isToday = row.date.day == today.day &&
|
||||
row.date.month == today.month &&
|
||||
row.date.year == today.year;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: isToday
|
||||
? AppColors.primary
|
||||
: (isDark
|
||||
? AppColors.surfaceDark
|
||||
: AppColors.surfaceLight),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: isToday
|
||||
? null
|
||||
: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.05)
|
||||
: AppColors.cream.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
DateFormat('MMM')
|
||||
.format(row.date)
|
||||
.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1,
|
||||
color: isToday
|
||||
? AppColors.onPrimary
|
||||
.withValues(alpha: 0.7)
|
||||
: AppColors.sage,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${row.date.day}',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: isToday ? AppColors.onPrimary : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_dataCell(row.fajr, isToday, flex: 3),
|
||||
_dataCell(row.sunrise, isToday, flex: 3),
|
||||
_dataCell(row.dhuhr, isToday, bold: true, flex: 3),
|
||||
_dataCell(row.asr, isToday, flex: 3),
|
||||
_dataCell(row.maghrib, isToday, bold: true, flex: 3),
|
||||
_dataCell(row.isha, isToday, flex: 3),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _headerCell(String text, {int flex = 1}) {
|
||||
return Expanded(
|
||||
flex: flex,
|
||||
child: Center(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1,
|
||||
color: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dataCell(String value, bool isToday,
|
||||
{bool bold = false, int flex = 1}) {
|
||||
return Expanded(
|
||||
flex: flex,
|
||||
child: Center(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: bold ? FontWeight.w700 : FontWeight.w400,
|
||||
color: isToday ? AppColors.onPrimary : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MonthOption {
|
||||
final String label;
|
||||
final int month;
|
||||
final int year;
|
||||
_MonthOption({required this.label, required this.month, required this.year});
|
||||
}
|
||||
|
||||
class _DayRow {
|
||||
final DateTime date;
|
||||
final String fajr, sunrise, dhuhr, asr, maghrib, isha;
|
||||
_DayRow({
|
||||
required this.date,
|
||||
required this.fajr,
|
||||
required this.sunrise,
|
||||
required this.dhuhr,
|
||||
required this.asr,
|
||||
required this.maghrib,
|
||||
required this.isha,
|
||||
});
|
||||
}
|
||||
1
lib/features/imsakiyah/presentation/placeholder.dart
Normal file
1
lib/features/imsakiyah/presentation/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
0
lib/features/laporan/presentation/.gitkeep
Normal file
0
lib/features/laporan/presentation/.gitkeep
Normal file
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});
|
||||
}
|
||||
1
lib/features/laporan/presentation/placeholder.dart
Normal file
1
lib/features/laporan/presentation/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
0
lib/features/qibla/presentation/.gitkeep
Normal file
0
lib/features/qibla/presentation/.gitkeep
Normal file
1
lib/features/qibla/presentation/placeholder.dart
Normal file
1
lib/features/qibla/presentation/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
391
lib/features/qibla/presentation/qibla_screen.dart
Normal file
391
lib/features/qibla/presentation/qibla_screen.dart
Normal file
@@ -0,0 +1,391 @@
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_qiblah/flutter_qiblah.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
|
||||
class QiblaScreen extends ConsumerStatefulWidget {
|
||||
const QiblaScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<QiblaScreen> createState() => _QiblaScreenState();
|
||||
}
|
||||
|
||||
class _QiblaScreenState extends ConsumerState<QiblaScreen> {
|
||||
// Fallback simulated data for environments without compass hardware (like macOS emulator)
|
||||
double _qiblaAngle = 295.0; // Default Jakarta to Mecca
|
||||
String _direction = 'NW';
|
||||
bool _hasHardwareSupport = false;
|
||||
|
||||
late final Future<bool?> _deviceSupport = _checkDeviceSupport();
|
||||
|
||||
Future<bool?> _checkDeviceSupport() async {
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
try {
|
||||
return await FlutterQiblah.androidDeviceSensorSupport();
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Pre-calculate static fallback
|
||||
_calculateStaticQibla();
|
||||
}
|
||||
|
||||
void _calculateStaticQibla() {
|
||||
// Default to Jakarta coordinates
|
||||
const lat = -6.2088;
|
||||
const lng = 106.8456;
|
||||
// Mecca coordinates
|
||||
const meccaLat = 21.4225;
|
||||
const meccaLng = 39.8262;
|
||||
|
||||
// Calculate qibla direction
|
||||
final dLng = (meccaLng - lng) * math.pi / 180;
|
||||
final lat1 = lat * math.pi / 180;
|
||||
final lat2 = meccaLat * math.pi / 180;
|
||||
|
||||
final y = math.sin(dLng) * math.cos(lat2);
|
||||
final x = math.cos(lat1) * math.sin(lat2) -
|
||||
math.sin(lat1) * math.cos(lat2) * math.cos(dLng);
|
||||
|
||||
var bearing = math.atan2(y, x) * 180 / math.pi;
|
||||
bearing = (bearing + 360) % 360;
|
||||
|
||||
setState(() {
|
||||
_qiblaAngle = bearing;
|
||||
_updateDirectionText(bearing);
|
||||
});
|
||||
}
|
||||
|
||||
void _updateDirectionText(double angle) {
|
||||
if (angle >= 337.5 || angle < 22.5) {
|
||||
_direction = 'N';
|
||||
} else if (angle < 67.5) {
|
||||
_direction = 'NE';
|
||||
} else if (angle < 112.5) {
|
||||
_direction = 'E';
|
||||
} else if (angle < 157.5) {
|
||||
_direction = 'SE';
|
||||
} else if (angle < 202.5) {
|
||||
_direction = 'S';
|
||||
} else if (angle < 247.5) {
|
||||
_direction = 'SW';
|
||||
} else if (angle < 292.5) {
|
||||
_direction = 'W';
|
||||
} else {
|
||||
_direction = 'NW';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return FutureBuilder(
|
||||
future: _deviceSupport,
|
||||
builder: (_, AsyncSnapshot<bool?> snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
// If device has a compass sensor (true on physical phones)
|
||||
if (snapshot.data == true) {
|
||||
return _buildLiveQibla(context, isDark);
|
||||
}
|
||||
|
||||
// If device lacks compass (macOS/emulators)
|
||||
return _buildSimulatedQibla(context, isDark);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLiveQibla(BuildContext context, bool isDark) {
|
||||
return StreamBuilder(
|
||||
stream: FlutterQiblah.qiblahStream,
|
||||
builder: (_, AsyncSnapshot<QiblahDirection> snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
final qiblahDirection = snapshot.data;
|
||||
if (qiblahDirection == null) {
|
||||
return Scaffold(body: Center(child: Text('Menunggu sensor arah...', style: TextStyle(color: isDark ? Colors.white : Colors.black))));
|
||||
}
|
||||
|
||||
_updateDirectionText(qiblahDirection.qiblah);
|
||||
|
||||
return _buildQiblaLayout(
|
||||
context: context,
|
||||
isDark: isDark,
|
||||
angleRad: qiblahDirection.qiblah * (math.pi / 180),
|
||||
displayAngle: qiblahDirection.qiblah,
|
||||
isLive: true,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSimulatedQibla(BuildContext context, bool isDark) {
|
||||
return _buildQiblaLayout(
|
||||
context: context,
|
||||
isDark: isDark,
|
||||
angleRad: _qiblaAngle * (math.pi / 180),
|
||||
displayAngle: _qiblaAngle,
|
||||
isLive: false,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQiblaLayout({
|
||||
required BuildContext context,
|
||||
required bool isDark,
|
||||
required double angleRad,
|
||||
required double displayAngle,
|
||||
required bool isLive,
|
||||
}) {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Qibla Finder'),
|
||||
leading: IconButton(
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isDark
|
||||
? AppColors.surfaceDark
|
||||
: AppColors.surfaceLight,
|
||||
border: Border.all(color: AppColors.cream),
|
||||
),
|
||||
child: const Icon(Icons.arrow_back, size: 18),
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
border: Border.all(color: AppColors.cream),
|
||||
),
|
||||
child: Icon(isLive ? Icons.my_location : Icons.location_disabled, size: 18),
|
||||
),
|
||||
onPressed: () {
|
||||
if (isLive) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Menggunakan sensor perangkat aktual')),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Mode Simulasi: Hardware kompas tidak terdeteksi')),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: isDark
|
||||
? [AppColors.backgroundDark, AppColors.surfaceDark]
|
||||
: [
|
||||
AppColors.backgroundLight,
|
||||
AppColors.primary.withValues(alpha: 0.05),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 32),
|
||||
// Location label
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.location_on,
|
||||
size: 16, color: AppColors.primary),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Mecca, Saudi Arabia',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Degree + direction
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'${displayAngle.round()}°',
|
||||
style: const TextStyle(
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
_direction,
|
||||
style: TextStyle(
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// Compass
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 300,
|
||||
height: 300,
|
||||
child: CustomPaint(
|
||||
painter: _CompassPainter(
|
||||
qiblaAngle: angleRad,
|
||||
isDark: isDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Calibration status
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isLive
|
||||
? AppColors.primary.withValues(alpha: 0.1)
|
||||
: Colors.orange.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: Text(
|
||||
isLive ? 'SENSOR AKTIF' : 'MODE SIMULASI (TIDAK ADA SENSOR)',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
color: isLive ? AppColors.primary : Colors.orange,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CompassPainter extends CustomPainter {
|
||||
final double qiblaAngle;
|
||||
final bool isDark;
|
||||
|
||||
_CompassPainter({required this.qiblaAngle, required this.isDark});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final radius = size.width / 2 - 8;
|
||||
|
||||
// Outer circle
|
||||
final outerPaint = Paint()
|
||||
..color = AppColors.primary.withValues(alpha: 0.15)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2;
|
||||
canvas.drawCircle(center, radius, outerPaint);
|
||||
|
||||
// Inner dashed circle
|
||||
final innerPaint = Paint()
|
||||
..color = AppColors.primary.withValues(alpha: 0.08)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1;
|
||||
canvas.drawCircle(center, radius * 0.7, innerPaint);
|
||||
|
||||
// Cross lines
|
||||
final crossPaint = Paint()
|
||||
..color = AppColors.primary.withValues(alpha: 0.1)
|
||||
..strokeWidth = 1;
|
||||
canvas.drawLine(
|
||||
Offset(center.dx, center.dy - radius),
|
||||
Offset(center.dx, center.dy + radius),
|
||||
crossPaint);
|
||||
canvas.drawLine(
|
||||
Offset(center.dx - radius, center.dy),
|
||||
Offset(center.dx + radius, center.dy),
|
||||
crossPaint);
|
||||
// Diagonals
|
||||
final diagOffset = radius * 0.707;
|
||||
canvas.drawLine(
|
||||
Offset(center.dx - diagOffset, center.dy - diagOffset),
|
||||
Offset(center.dx + diagOffset, center.dy + diagOffset),
|
||||
crossPaint);
|
||||
canvas.drawLine(
|
||||
Offset(center.dx + diagOffset, center.dy - diagOffset),
|
||||
Offset(center.dx - diagOffset, center.dy + diagOffset),
|
||||
crossPaint);
|
||||
|
||||
// Center dot
|
||||
final centerDotPaint = Paint()
|
||||
..color = AppColors.primary.withValues(alpha: 0.3)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2;
|
||||
canvas.drawCircle(center, 6, centerDotPaint);
|
||||
|
||||
// Qibla direction line
|
||||
final qiblaEndX = center.dx + radius * 0.85 * math.cos(qiblaAngle - math.pi / 2);
|
||||
final qiblaEndY = center.dy + radius * 0.85 * math.sin(qiblaAngle - math.pi / 2);
|
||||
|
||||
// Glow effect
|
||||
final glowPaint = Paint()
|
||||
..color = AppColors.primary.withValues(alpha: 0.3)
|
||||
..strokeWidth = 6
|
||||
..strokeCap = StrokeCap.round;
|
||||
canvas.drawLine(center, Offset(qiblaEndX, qiblaEndY), glowPaint);
|
||||
|
||||
// Main line
|
||||
final linePaint = Paint()
|
||||
..color = AppColors.primary
|
||||
..strokeWidth = 3
|
||||
..strokeCap = StrokeCap.round;
|
||||
canvas.drawLine(center, Offset(qiblaEndX, qiblaEndY), linePaint);
|
||||
|
||||
// Qibla icon circle at end
|
||||
final iconPaint = Paint()..color = AppColors.primary;
|
||||
canvas.drawCircle(Offset(qiblaEndX, qiblaEndY), 16, iconPaint);
|
||||
|
||||
// Kaaba icon (simplified)
|
||||
final kaabaPaint = Paint()
|
||||
..color = Colors.white
|
||||
..style = PaintingStyle.fill;
|
||||
canvas.drawRect(
|
||||
Rect.fromCenter(
|
||||
center: Offset(qiblaEndX, qiblaEndY),
|
||||
width: 12,
|
||||
height: 12,
|
||||
),
|
||||
kaabaPaint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _CompassPainter oldDelegate) =>
|
||||
qiblaAngle != oldDelegate.qiblaAngle;
|
||||
}
|
||||
0
lib/features/quran/presentation/.gitkeep
Normal file
0
lib/features/quran/presentation/.gitkeep
Normal file
1
lib/features/quran/presentation/placeholder.dart
Normal file
1
lib/features/quran/presentation/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
323
lib/features/quran/presentation/quran_bookmarks_screen.dart
Normal file
323
lib/features/quran/presentation/quran_bookmarks_screen.dart
Normal file
@@ -0,0 +1,323 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/quran_bookmark.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
|
||||
class QuranBookmarksScreen extends StatefulWidget {
|
||||
const QuranBookmarksScreen({super.key});
|
||||
|
||||
@override
|
||||
State<QuranBookmarksScreen> createState() => _QuranBookmarksScreenState();
|
||||
}
|
||||
|
||||
class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
|
||||
bool _showLatin = true;
|
||||
bool _showTerjemahan = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final box = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = box.get('default') ?? AppSettings();
|
||||
_showLatin = settings.showLatin;
|
||||
_showTerjemahan = settings.showTerjemahan;
|
||||
}
|
||||
|
||||
void _showDisplaySettings() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (context, setModalState) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Pengaturan Tampilan',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SwitchListTile(
|
||||
title: const Text('Tampilkan Latin'),
|
||||
value: _showLatin,
|
||||
activeColor: AppColors.primary,
|
||||
onChanged: (val) {
|
||||
setModalState(() => _showLatin = val);
|
||||
setState(() => _showLatin = val);
|
||||
final box = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = box.get('default') ?? AppSettings();
|
||||
settings.showLatin = val;
|
||||
settings.save();
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text('Tampilkan Terjemahan'),
|
||||
value: _showTerjemahan,
|
||||
activeColor: AppColors.primary,
|
||||
onChanged: (val) {
|
||||
setModalState(() => _showTerjemahan = val);
|
||||
setState(() => _showTerjemahan = val);
|
||||
final box = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = box.get('default') ?? AppSettings();
|
||||
settings.showTerjemahan = val;
|
||||
settings.save();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Markah Al-Quran'),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_display),
|
||||
onPressed: _showDisplaySettings,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ValueListenableBuilder(
|
||||
valueListenable: Hive.box<QuranBookmark>(HiveBoxes.bookmarks).listenable(),
|
||||
builder: (context, Box<QuranBookmark> box, _) {
|
||||
if (box.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.bookmark_border,
|
||||
size: 64,
|
||||
color: AppColors.primary.withValues(alpha: 0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Belum ada markah',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Tandai ayat saat membaca Al-Quran',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Filter bookmarks
|
||||
final allBookmarks = box.values.toList();
|
||||
final lastRead = allBookmarks.where((b) => b.isLastRead).toList();
|
||||
final favorites = allBookmarks.where((b) => !b.isLastRead).toList()
|
||||
..sort((a, b) => b.savedAt.compareTo(a.savedAt));
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (lastRead.isNotEmpty) ...[
|
||||
const Text(
|
||||
'TERAKHIR DIBACA',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
color: AppColors.sage,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildBookmarkCard(context, lastRead.first, isDark, box, isLastRead: true),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
|
||||
if (favorites.isNotEmpty) ...[
|
||||
const Text(
|
||||
'AYAT FAVORIT',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
color: AppColors.sage,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...favorites.map((fav) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _buildBookmarkCard(context, fav, isDark, box, isLastRead: false),
|
||||
)),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBookmarkCard(BuildContext context, QuranBookmark bookmark, bool isDark, Box<QuranBookmark> box, {required bool isLastRead}) {
|
||||
final dateStr = DateFormat('dd MMM yyyy, HH:mm').format(bookmark.savedAt);
|
||||
|
||||
return InkWell(
|
||||
onTap: () => context.push('/tools/quran/${bookmark.surahId}?startVerse=${bookmark.verseId}'),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isLastRead
|
||||
? AppColors.primary.withValues(alpha: 0.3)
|
||||
: (isDark ? AppColors.primary.withValues(alpha: 0.1) : AppColors.cream),
|
||||
width: isLastRead ? 1.5 : 1.0,
|
||||
),
|
||||
boxShadow: isLastRead ? [
|
||||
BoxShadow(
|
||||
color: AppColors.primary.withValues(alpha: 0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
)
|
||||
] : null,
|
||||
),
|
||||
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.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isLastRead) ...[
|
||||
const Icon(Icons.push_pin, size: 12, color: AppColors.primary),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Text(
|
||||
'QS. ${bookmark.surahName}: ${bookmark.verseId}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.red, size: 20),
|
||||
onPressed: () => box.delete(bookmark.key),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
bookmark.verseText,
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 22,
|
||||
height: 1.8,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (_showLatin && bookmark.verseLatin != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
bookmark.verseLatin!,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
if (_showTerjemahan && bookmark.verseTranslation != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
bookmark.verseTranslation!,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.6,
|
||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
if (isLastRead) ...[
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => context.push('/tools/quran/${bookmark.surahId}?startVerse=${bookmark.verseId}'),
|
||||
icon: const Icon(Icons.menu_book, size: 18),
|
||||
label: const Text('Lanjutkan Membaca'),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 12,
|
||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${isLastRead ? 'Ditandai' : 'Disimpan'}: $dateStr',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
869
lib/features/quran/presentation/quran_murattal_screen.dart
Normal file
869
lib/features/quran/presentation/quran_murattal_screen.dart
Normal file
@@ -0,0 +1,869 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../data/services/equran_service.dart';
|
||||
import '../../../data/services/unsplash_service.dart';
|
||||
|
||||
/// Quran Murattal (audio player) screen.
|
||||
/// Implements full Surah playback using just_audio and EQuran v2 API.
|
||||
class QuranMurattalScreen extends ConsumerStatefulWidget {
|
||||
final String surahId;
|
||||
final String? initialQariId;
|
||||
final bool autoPlay;
|
||||
const QuranMurattalScreen({
|
||||
super.key,
|
||||
required this.surahId,
|
||||
this.initialQariId,
|
||||
this.autoPlay = false,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<QuranMurattalScreen> createState() =>
|
||||
_QuranMurattalScreenState();
|
||||
}
|
||||
|
||||
class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
|
||||
final AudioPlayer _audioPlayer = AudioPlayer();
|
||||
|
||||
Map<String, dynamic>? _surahData;
|
||||
bool _isLoading = true;
|
||||
|
||||
// Audio State Variables
|
||||
bool _isPlaying = false;
|
||||
bool _isBuffering = false;
|
||||
Duration _position = Duration.zero;
|
||||
Duration _duration = Duration.zero;
|
||||
|
||||
StreamSubscription? _positionSub;
|
||||
StreamSubscription? _durationSub;
|
||||
StreamSubscription? _playerStateSub;
|
||||
|
||||
// Qari State
|
||||
late String _selectedQariId;
|
||||
|
||||
// Shuffle State
|
||||
bool _isShuffleEnabled = false;
|
||||
|
||||
// Unsplash Background
|
||||
Map<String, String>? _unsplashPhoto;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedQariId = widget.initialQariId ?? '05'; // Default to Misyari Rasyid Al-Afasi
|
||||
_initDataAndPlayer();
|
||||
_loadUnsplashPhoto();
|
||||
}
|
||||
|
||||
Future<void> _loadUnsplashPhoto() async {
|
||||
final photo = await UnsplashService.instance.getIslamicPhoto();
|
||||
if (mounted && photo != null) {
|
||||
setState(() => _unsplashPhoto = photo);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initDataAndPlayer() async {
|
||||
final surahNum = int.tryParse(widget.surahId) ?? 1;
|
||||
final data = await EQuranService.instance.getSurah(surahNum);
|
||||
|
||||
if (data != null && mounted) {
|
||||
setState(() {
|
||||
_surahData = data;
|
||||
_isLoading = false;
|
||||
});
|
||||
_setupAudioStreamListeners();
|
||||
_loadAudioSource();
|
||||
} else if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _setupAudioStreamListeners() {
|
||||
_positionSub = _audioPlayer.positionStream.listen((pos) {
|
||||
if (mounted) setState(() => _position = pos);
|
||||
});
|
||||
|
||||
_durationSub = _audioPlayer.durationStream.listen((dur) {
|
||||
if (mounted && dur != null) setState(() => _duration = dur);
|
||||
});
|
||||
|
||||
_playerStateSub = _audioPlayer.playerStateStream.listen((state) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isPlaying = state.playing;
|
||||
_isBuffering = state.processingState == ProcessingState.buffering ||
|
||||
state.processingState == ProcessingState.loading;
|
||||
|
||||
// Auto pause and reset to 0 when finished
|
||||
if (state.processingState == ProcessingState.completed) {
|
||||
_audioPlayer.pause();
|
||||
_audioPlayer.seek(Duration.zero);
|
||||
|
||||
// Auto-play next surah
|
||||
final currentSurah = int.tryParse(widget.surahId) ?? 1;
|
||||
if (_isShuffleEnabled) {
|
||||
final random = Random();
|
||||
int nextSurah = random.nextInt(114) + 1;
|
||||
while (nextSurah == currentSurah) {
|
||||
nextSurah = random.nextInt(114) + 1;
|
||||
}
|
||||
_navigateToSurahNumber(nextSurah, autoplay: true);
|
||||
} else if (currentSurah < 114) {
|
||||
_navigateToSurah(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadAudioSource() async {
|
||||
if (_surahData == null) return;
|
||||
|
||||
final audioUrls = _surahData!['audioFull'];
|
||||
if (audioUrls != null && audioUrls[_selectedQariId] != null) {
|
||||
try {
|
||||
await _audioPlayer.setUrl(audioUrls[_selectedQariId]);
|
||||
if (widget.autoPlay) {
|
||||
_audioPlayer.play();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Gagal memuat audio murattal')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_positionSub?.cancel();
|
||||
_durationSub?.cancel();
|
||||
_playerStateSub?.cancel();
|
||||
_audioPlayer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _formatDuration(Duration d) {
|
||||
final minutes = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
if (d.inHours > 0) {
|
||||
return '${d.inHours}:$minutes:$seconds';
|
||||
}
|
||||
return '$minutes:$seconds';
|
||||
}
|
||||
|
||||
void _seekRelative(int seconds) {
|
||||
final newPosition = _position + Duration(seconds: seconds);
|
||||
if (newPosition < Duration.zero) {
|
||||
_audioPlayer.seek(Duration.zero);
|
||||
} else if (newPosition > _duration) {
|
||||
_audioPlayer.seek(_duration);
|
||||
} else {
|
||||
_audioPlayer.seek(newPosition);
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToSurah(int direction) {
|
||||
final currentSurah = int.tryParse(widget.surahId) ?? 1;
|
||||
final nextSurah = currentSurah + direction;
|
||||
_navigateToSurahNumber(nextSurah, autoplay: true);
|
||||
}
|
||||
|
||||
void _navigateToSurahNumber(int surahNum, {bool autoplay = false}) {
|
||||
if (surahNum >= 1 && surahNum <= 114) {
|
||||
context.pushReplacement('/tools/quran/$surahNum/murattal?qariId=$_selectedQariId&autoplay=$autoplay');
|
||||
}
|
||||
}
|
||||
|
||||
void _showQariSelector() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Pilih Qari',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
...EQuranService.qariNames.entries.map((entry) {
|
||||
final isSelected = entry.key == _selectedQariId;
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
isSelected ? Icons.radio_button_checked : Icons.radio_button_unchecked,
|
||||
color: isSelected ? AppColors.primary : Colors.grey,
|
||||
),
|
||||
title: Text(
|
||||
entry.value,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
color: isSelected ? AppColors.primary : null,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
if (!isSelected) {
|
||||
setState(() => _selectedQariId = entry.key);
|
||||
_loadAudioSource();
|
||||
}
|
||||
},
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showSurahPlaylist() {
|
||||
final currentSurah = int.tryParse(widget.surahId) ?? 1;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (context) {
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.7,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.9,
|
||||
expand: false,
|
||||
builder: (context, scrollController) {
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Playlist Surah',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: FutureBuilder<List<Map<String, dynamic>>>(
|
||||
future: EQuranService.instance.getAllSurahs(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final surahs = snapshot.data!;
|
||||
return ListView.builder(
|
||||
controller: scrollController,
|
||||
itemCount: surahs.length,
|
||||
itemBuilder: (context, i) {
|
||||
final surah = surahs[i];
|
||||
final surahNum = surah['nomor'] ?? (i + 1);
|
||||
final isCurrentSurah = surahNum == currentSurah;
|
||||
return ListTile(
|
||||
leading: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: isCurrentSurah
|
||||
? AppColors.primary
|
||||
: AppColors.primary.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$surahNum',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isCurrentSurah ? Colors.white : AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
surah['namaLatin'] ?? 'Surah $surahNum',
|
||||
style: TextStyle(
|
||||
fontWeight: isCurrentSurah ? FontWeight.bold : FontWeight.normal,
|
||||
color: isCurrentSurah ? AppColors.primary : null,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${surah['arti'] ?? ''} • ${surah['jumlahAyat'] ?? 0} Ayat',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
trailing: isCurrentSurah
|
||||
? Icon(Icons.graphic_eq, color: AppColors.primary, size: 20)
|
||||
: null,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
if (!isCurrentSurah) {
|
||||
context.pushReplacement(
|
||||
'/tools/quran/$surahNum/murattal?qariId=$_selectedQariId',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final surahName = _surahData?['namaLatin'] ?? 'Surah ${widget.surahId}';
|
||||
|
||||
final hasPhoto = _unsplashPhoto != null;
|
||||
|
||||
return Scaffold(
|
||||
extendBodyBehindAppBar: hasPhoto,
|
||||
appBar: AppBar(
|
||||
backgroundColor: hasPhoto ? Colors.transparent : null,
|
||||
elevation: hasPhoto ? 0 : null,
|
||||
iconTheme: hasPhoto ? const IconThemeData(color: Colors.white) : null,
|
||||
flexibleSpace: hasPhoto
|
||||
? ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||
child: Container(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
title: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Surah $surahName',
|
||||
style: TextStyle(
|
||||
color: hasPhoto ? Colors.white : null,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'MURATTAL',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
color: hasPhoto ? Colors.white70 : AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// === FULL-BLEED BACKGROUND ===
|
||||
if (_unsplashPhoto != null)
|
||||
CachedNetworkImage(
|
||||
imageUrl: _unsplashPhoto!['imageUrl'] ?? '',
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
AppColors.primary.withValues(alpha: 0.1),
|
||||
AppColors.primary.withValues(alpha: 0.05),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: isDark ? Colors.black : Colors.grey.shade100,
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
AppColors.primary.withValues(alpha: 0.1),
|
||||
AppColors.primary.withValues(alpha: 0.03),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Dark overlay
|
||||
if (_unsplashPhoto != null)
|
||||
Container(
|
||||
color: Colors.black.withValues(alpha: 0.35),
|
||||
),
|
||||
|
||||
// === CENTER CONTENT (Equalizer + Text) ===
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 280, // leave room for the player
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Equalizer circle
|
||||
Container(
|
||||
width: 220,
|
||||
height: 220,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: _unsplashPhoto != null
|
||||
? [
|
||||
Colors.white.withValues(alpha: 0.15),
|
||||
Colors.white.withValues(alpha: 0.05),
|
||||
]
|
||||
: [
|
||||
AppColors.primary.withValues(alpha: 0.2),
|
||||
AppColors.primary.withValues(alpha: 0.05),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 140,
|
||||
height: 140,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: _unsplashPhoto != null
|
||||
? Colors.white.withValues(alpha: 0.1)
|
||||
: AppColors.primary.withValues(alpha: 0.12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: List.generate(7, (i) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: _EqualizerBar(
|
||||
isPlaying: _isPlaying,
|
||||
index: i,
|
||||
color: _unsplashPhoto != null
|
||||
? Colors.white
|
||||
: AppColors.primary,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// Qari name
|
||||
Text(
|
||||
EQuranService.qariNames[_selectedQariId] ?? 'Memuat...',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: _unsplashPhoto != null ? Colors.white : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Memutar Surat $surahName',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: _unsplashPhoto != null
|
||||
? Colors.white70
|
||||
: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// === FROSTED GLASS PLAYER CONTROLS ===
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
child: BackdropFilter(
|
||||
filter: _unsplashPhoto != null
|
||||
? ImageFilter.blur(sigmaX: 20, sigmaY: 20)
|
||||
: ImageFilter.blur(sigmaX: 0, sigmaY: 0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 48),
|
||||
decoration: BoxDecoration(
|
||||
color: _unsplashPhoto != null
|
||||
? Colors.white.withValues(alpha: 0.15)
|
||||
: (isDark ? AppColors.surfaceDark : AppColors.surfaceLight),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
border: _unsplashPhoto != null
|
||||
? Border(
|
||||
top: BorderSide(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
width: 0.5,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Progress slider
|
||||
SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 3,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||
),
|
||||
child: Slider(
|
||||
value: _position.inMilliseconds.toDouble(),
|
||||
max: _duration.inMilliseconds > 0 ? _duration.inMilliseconds.toDouble() : 1.0,
|
||||
onChanged: (v) {
|
||||
_audioPlayer.seek(Duration(milliseconds: v.round()));
|
||||
},
|
||||
activeColor: _unsplashPhoto != null ? Colors.white : AppColors.primary,
|
||||
inactiveColor: _unsplashPhoto != null
|
||||
? Colors.white.withValues(alpha: 0.2)
|
||||
: AppColors.primary.withValues(alpha: 0.15),
|
||||
),
|
||||
),
|
||||
// Time labels
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_formatDuration(_position),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _unsplashPhoto != null
|
||||
? Colors.white70
|
||||
: (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatDuration(_duration),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _unsplashPhoto != null
|
||||
? Colors.white70
|
||||
: (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Playback controls — Spotify-style 5-button row
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
// Shuffle
|
||||
IconButton(
|
||||
onPressed: () => setState(() => _isShuffleEnabled = !_isShuffleEnabled),
|
||||
icon: Icon(
|
||||
Icons.shuffle_rounded,
|
||||
size: 24,
|
||||
color: _isShuffleEnabled
|
||||
? (_unsplashPhoto != null ? Colors.white : AppColors.primary)
|
||||
: (_unsplashPhoto != null
|
||||
? Colors.white54
|
||||
: (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight)),
|
||||
),
|
||||
),
|
||||
// Previous Surah
|
||||
IconButton(
|
||||
onPressed: (int.tryParse(widget.surahId) ?? 1) > 1
|
||||
? () => _navigateToSurah(-1)
|
||||
: null,
|
||||
icon: Icon(
|
||||
Icons.skip_previous_rounded,
|
||||
size: 36,
|
||||
color: (int.tryParse(widget.surahId) ?? 1) > 1
|
||||
? (_unsplashPhoto != null ? Colors.white : (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight))
|
||||
: Colors.grey.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
// Play/Pause
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (_isPlaying) {
|
||||
_audioPlayer.pause();
|
||||
} else {
|
||||
_audioPlayer.play();
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: _unsplashPhoto != null
|
||||
? Colors.white
|
||||
: AppColors.primary,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: (_unsplashPhoto != null
|
||||
? Colors.white
|
||||
: AppColors.primary)
|
||||
.withValues(alpha: 0.3),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: _isBuffering
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(18.0),
|
||||
child: CircularProgressIndicator(
|
||||
color: _unsplashPhoto != null
|
||||
? Colors.black87
|
||||
: Colors.white,
|
||||
strokeWidth: 3,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
_isPlaying
|
||||
? Icons.pause_rounded
|
||||
: Icons.play_arrow_rounded,
|
||||
size: 36,
|
||||
color: _unsplashPhoto != null
|
||||
? Colors.black87
|
||||
: AppColors.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Next Surah
|
||||
IconButton(
|
||||
onPressed: (int.tryParse(widget.surahId) ?? 1) < 114
|
||||
? () => _navigateToSurah(1)
|
||||
: null,
|
||||
icon: Icon(
|
||||
Icons.skip_next_rounded,
|
||||
size: 36,
|
||||
color: (int.tryParse(widget.surahId) ?? 1) < 114
|
||||
? (_unsplashPhoto != null ? Colors.white : (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight))
|
||||
: Colors.grey.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
// Playlist
|
||||
IconButton(
|
||||
onPressed: _showSurahPlaylist,
|
||||
icon: Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
size: 28,
|
||||
color: _unsplashPhoto != null
|
||||
? Colors.white70
|
||||
: (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Qari selector trigger
|
||||
GestureDetector(
|
||||
onTap: _showQariSelector,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: _unsplashPhoto != null
|
||||
? Colors.white.withValues(alpha: 0.15)
|
||||
: AppColors.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.person, size: 16,
|
||||
color: _unsplashPhoto != null ? Colors.white : AppColors.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
EQuranService.qariNames[_selectedQariId] ?? 'Ganti Qari',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _unsplashPhoto != null ? Colors.white : AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.expand_more,
|
||||
size: 16,
|
||||
color: _unsplashPhoto != null ? Colors.white : AppColors.primary),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// === ATTRIBUTION ===
|
||||
if (_unsplashPhoto != null)
|
||||
Positioned(
|
||||
bottom: 280,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
final url = _unsplashPhoto!['photographerUrl'];
|
||||
if (url != null && url.isNotEmpty) {
|
||||
launchUrl(Uri.parse('$url?utm_source=jamshalat_diary&utm_medium=referral'));
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
'📷 ${_unsplashPhoto!['photographerName']} / Unsplash',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Animated equalizer bar widget for the Murattal player.
|
||||
class _EqualizerBar extends StatefulWidget {
|
||||
final bool isPlaying;
|
||||
final int index;
|
||||
final Color color;
|
||||
|
||||
const _EqualizerBar({
|
||||
required this.isPlaying,
|
||||
required this.index,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_EqualizerBar> createState() => _EqualizerBarState();
|
||||
}
|
||||
|
||||
class _EqualizerBarState extends State<_EqualizerBar>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _animation;
|
||||
|
||||
// Each bar has a unique height range and speed for variety
|
||||
static const _barConfigs = [
|
||||
[0.3, 0.9, 600],
|
||||
[0.2, 1.0, 500],
|
||||
[0.4, 0.8, 700],
|
||||
[0.1, 1.0, 450],
|
||||
[0.3, 0.9, 550],
|
||||
[0.2, 0.85, 650],
|
||||
[0.35, 0.95, 480],
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final config = _barConfigs[widget.index % _barConfigs.length];
|
||||
final minHeight = config[0] as double;
|
||||
final maxHeight = config[1] as double;
|
||||
final durationMs = (config[2] as num).toInt();
|
||||
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: Duration(milliseconds: durationMs),
|
||||
);
|
||||
|
||||
_animation = Tween<double>(
|
||||
begin: minHeight,
|
||||
end: maxHeight,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
|
||||
if (widget.isPlaying) {
|
||||
_controller.repeat(reverse: true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _EqualizerBar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.isPlaying && !oldWidget.isPlaying) {
|
||||
_controller.repeat(reverse: true);
|
||||
} else if (!widget.isPlaying && oldWidget.isPlaying) {
|
||||
_controller.animateTo(0.0, duration: const Duration(milliseconds: 300));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
width: 6,
|
||||
height: 50 * _animation.value,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.color.withValues(alpha: 0.6 + (_animation.value * 0.4)),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
1027
lib/features/quran/presentation/quran_reading_screen.dart
Normal file
1027
lib/features/quran/presentation/quran_reading_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
260
lib/features/quran/presentation/quran_screen.dart
Normal file
260
lib/features/quran/presentation/quran_screen.dart
Normal file
@@ -0,0 +1,260 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
import '../../../data/local/models/quran_bookmark.dart';
|
||||
import '../../../data/services/equran_service.dart';
|
||||
|
||||
class QuranScreen extends ConsumerStatefulWidget {
|
||||
const QuranScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<QuranScreen> createState() => _QuranScreenState();
|
||||
}
|
||||
|
||||
class _QuranScreenState extends ConsumerState<QuranScreen> {
|
||||
List<Map<String, dynamic>> _surahs = [];
|
||||
String _searchQuery = '';
|
||||
bool _loading = true;
|
||||
|
||||
bool _showLatin = true;
|
||||
bool _showTerjemahan = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSurahs();
|
||||
final box = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = box.get('default') ?? AppSettings();
|
||||
_showLatin = settings.showLatin;
|
||||
_showTerjemahan = settings.showTerjemahan;
|
||||
}
|
||||
|
||||
Future<void> _loadSurahs() async {
|
||||
final data = await EQuranService.instance.getAllSurahs();
|
||||
setState(() {
|
||||
_surahs = data;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _showDisplaySettings() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (context, setModalState) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Pengaturan Tampilan',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SwitchListTile(
|
||||
title: const Text('Tampilkan Latin'),
|
||||
value: _showLatin,
|
||||
activeColor: AppColors.primary,
|
||||
onChanged: (val) {
|
||||
setModalState(() => _showLatin = val);
|
||||
setState(() => _showLatin = val);
|
||||
final box = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = box.get('default') ?? AppSettings();
|
||||
settings.showLatin = val;
|
||||
settings.save();
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text('Tampilkan Terjemahan'),
|
||||
value: _showTerjemahan,
|
||||
activeColor: AppColors.primary,
|
||||
onChanged: (val) {
|
||||
setModalState(() => _showTerjemahan = val);
|
||||
setState(() => _showTerjemahan = val);
|
||||
final box = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = box.get('default') ?? AppSettings();
|
||||
settings.showTerjemahan = val;
|
||||
settings.save();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final filtered = _searchQuery.isEmpty
|
||||
? _surahs
|
||||
: _surahs
|
||||
.where((s) =>
|
||||
(s['namaLatin'] as String? ?? '')
|
||||
.toLowerCase()
|
||||
.contains(_searchQuery.toLowerCase()) ||
|
||||
(s['nama'] as String? ?? '').contains(_searchQuery))
|
||||
.toList();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Al-Quran'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bookmark_outline),
|
||||
onPressed: () => context.push('/tools/quran/bookmarks'),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_display),
|
||||
onPressed: _showDisplaySettings,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Search bar
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.1)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
onChanged: (v) => setState(() => _searchQuery = v),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Cari surah...',
|
||||
prefixIcon: Icon(Icons.search,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight),
|
||||
border: InputBorder.none,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Surah list
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: filtered.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
_searchQuery.isEmpty
|
||||
? 'Tidak dapat memuat data'
|
||||
: 'Surah tidak ditemukan',
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ValueListenableBuilder(
|
||||
valueListenable: Hive.box<QuranBookmark>(HiveBoxes.bookmarks).listenable(),
|
||||
builder: (context, box, _) {
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: filtered.length,
|
||||
separatorBuilder: (_, __) => Divider(
|
||||
height: 1,
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.08)
|
||||
: AppColors.cream,
|
||||
),
|
||||
itemBuilder: (context, i) {
|
||||
final surah = filtered[i];
|
||||
final number = surah['nomor'] ?? (i + 1);
|
||||
final nameLatin = surah['namaLatin'] ?? '';
|
||||
final nameArabic = surah['nama'] ?? '';
|
||||
final totalVerses = surah['jumlahAyat'] ?? 0;
|
||||
final tempatTurun = surah['tempatTurun'] ?? '';
|
||||
final arti = surah['arti'] ?? '';
|
||||
|
||||
final hasLastRead = box.values.any((b) => b.isLastRead && b.surahId == number);
|
||||
|
||||
return ListTile(
|
||||
onTap: () =>
|
||||
context.push('/tools/quran/$number'),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 0, vertical: 6),
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary
|
||||
.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$number',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Text(
|
||||
nameLatin,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
if (hasLastRead) ...[
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.push_pin, size: 14, color: AppColors.primary),
|
||||
],
|
||||
],
|
||||
),
|
||||
subtitle: Text(
|
||||
'$arti • $totalVerses Ayat • $tempatTurun',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
trailing: Text(
|
||||
nameArabic,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
0
lib/features/settings/presentation/.gitkeep
Normal file
0
lib/features/settings/presentation/.gitkeep
Normal file
1
lib/features/settings/presentation/placeholder.dart
Normal file
1
lib/features/settings/presentation/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
891
lib/features/settings/presentation/settings_screen.dart
Normal file
891
lib/features/settings/presentation/settings_screen.dart
Normal file
@@ -0,0 +1,891 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../core/providers/theme_provider.dart';
|
||||
import '../../../core/widgets/ios_toggle.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
import '../../../data/services/myquran_sholat_service.dart';
|
||||
import '../../../data/services/myquran_sholat_service.dart';
|
||||
import '../../dashboard/data/prayer_times_provider.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../data/local/models/daily_worship_log.dart';
|
||||
|
||||
class SettingsScreen extends ConsumerStatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
late AppSettings _settings;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final box = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
_settings = box.get('default') ?? AppSettings();
|
||||
}
|
||||
|
||||
void _saveSettings() {
|
||||
_settings.save();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
bool get _isDarkMode => _settings.themeModeIndex != 1;
|
||||
bool get _notificationsEnabled =>
|
||||
_settings.adhanEnabled.values.any((v) => v);
|
||||
|
||||
String get _displayCityName {
|
||||
final stored = _settings.lastCityName ?? 'Jakarta';
|
||||
if (stored.contains('|')) {
|
||||
return stored.split('|').first;
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
|
||||
void _toggleDarkMode(bool value) {
|
||||
_settings.themeModeIndex = value ? 2 : 1;
|
||||
_saveSettings();
|
||||
ref.read(themeProvider.notifier).state =
|
||||
value ? ThemeMode.dark : ThemeMode.light;
|
||||
}
|
||||
|
||||
void _toggleNotifications(bool value) {
|
||||
_settings.adhanEnabled.updateAll((key, _) => value);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Pengaturan'),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// ── Profile Card ──
|
||||
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: [
|
||||
// Avatar
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppColors.primary,
|
||||
AppColors.primary.withValues(alpha: 0.6),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
_settings.userName.isNotEmpty
|
||||
? _settings.userName[0].toUpperCase()
|
||||
: 'U',
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_settings.userName,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (_settings.userEmail.isNotEmpty)
|
||||
Text(
|
||||
_settings.userEmail,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => _showEditProfileDialog(context),
|
||||
icon: Icon(Icons.edit,
|
||||
size: 20, color: AppColors.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── PREFERENCES ──
|
||||
_sectionLabel('PREFERENSI'),
|
||||
const SizedBox(height: 12),
|
||||
_settingRow(
|
||||
isDark,
|
||||
icon: Icons.dark_mode,
|
||||
iconColor: const Color(0xFF6C5CE7),
|
||||
title: 'Mode Gelap',
|
||||
trailing: IosToggle(
|
||||
value: _isDarkMode,
|
||||
onChanged: _toggleDarkMode,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_settingRow(
|
||||
isDark,
|
||||
icon: Icons.notifications,
|
||||
iconColor: const Color(0xFFE17055),
|
||||
title: 'Notifikasi',
|
||||
trailing: IosToggle(
|
||||
value: _notificationsEnabled,
|
||||
onChanged: _toggleNotifications,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── CHECKLIST IBADAH ──
|
||||
_sectionLabel('CHECKLIST IBADAH'),
|
||||
const SizedBox(height: 12),
|
||||
_settingRow(
|
||||
isDark,
|
||||
icon: Icons.mosque_outlined,
|
||||
iconColor: Colors.teal,
|
||||
title: 'Tingkat Sholat Rawatib',
|
||||
subtitle: _settings.rawatibLevel == 0 ? 'Mati' : (_settings.rawatibLevel == 1 ? 'Muakkad Saja' : 'Lengkap (Semua)'),
|
||||
trailing: const Icon(Icons.chevron_right, size: 20),
|
||||
onTap: () => _showRawatibDialog(context),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_settingRow(
|
||||
isDark,
|
||||
icon: Icons.menu_book,
|
||||
iconColor: Colors.amber,
|
||||
title: 'Target Tilawah',
|
||||
subtitle: '${_settings.tilawahTargetValue} ${_settings.tilawahTargetUnit}',
|
||||
trailing: const Icon(Icons.chevron_right, size: 20),
|
||||
onTap: () => _showTilawahDialog(context),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_settingRow(
|
||||
isDark,
|
||||
icon: Icons.sync,
|
||||
iconColor: Colors.blue,
|
||||
title: 'Auto-Sync Tilawah',
|
||||
subtitle: 'Catat otomatis dari menu Al-Quran',
|
||||
trailing: IosToggle(
|
||||
value: _settings.tilawahAutoSync,
|
||||
onChanged: (v) {
|
||||
_settings.tilawahAutoSync = v;
|
||||
_saveSettings();
|
||||
|
||||
final todayKey = DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
final logBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
|
||||
final log = logBox.get(todayKey);
|
||||
if (log != null && log.tilawahLog != null) {
|
||||
log.tilawahLog!.autoSync = v;
|
||||
log.save();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_settingRow(
|
||||
isDark,
|
||||
icon: Icons.library_add_check,
|
||||
iconColor: Colors.indigo,
|
||||
title: 'Amalan Tambahan',
|
||||
subtitle: 'Dzikir & Puasa Sunnah',
|
||||
trailing: const Icon(Icons.chevron_right, size: 20),
|
||||
onTap: () => _showAmalanDialog(context),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── PRAYER SETTINGS ──
|
||||
_sectionLabel('WAKTU SHOLAT'),
|
||||
const SizedBox(height: 12),
|
||||
_settingRow(
|
||||
isDark,
|
||||
icon: Icons.mosque,
|
||||
iconColor: AppColors.primary,
|
||||
title: 'Metode Perhitungan',
|
||||
subtitle: 'Kemenag RI',
|
||||
trailing: const Icon(Icons.chevron_right, size: 20),
|
||||
onTap: () => _showMethodDialog(context),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_settingRow(
|
||||
isDark,
|
||||
icon: Icons.location_on,
|
||||
iconColor: const Color(0xFF00B894),
|
||||
title: 'Lokasi',
|
||||
subtitle: _displayCityName,
|
||||
trailing: const Icon(Icons.chevron_right, size: 20),
|
||||
onTap: () => _showLocationDialog(context),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_settingRow(
|
||||
isDark,
|
||||
icon: Icons.timer,
|
||||
iconColor: const Color(0xFFFDAA5E),
|
||||
title: 'Waktu Iqamah',
|
||||
subtitle: 'Atur per waktu sholat',
|
||||
trailing: const Icon(Icons.chevron_right, size: 20),
|
||||
onTap: () => _showIqamahDialog(context),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── DISPLAY ──
|
||||
_sectionLabel('TAMPILAN'),
|
||||
const SizedBox(height: 12),
|
||||
_settingRow(
|
||||
isDark,
|
||||
icon: Icons.text_fields,
|
||||
iconColor: const Color(0xFF636E72),
|
||||
title: 'Ukuran Font Arab',
|
||||
subtitle: '${_settings.arabicFontSize.round()}pt',
|
||||
trailing: SizedBox(
|
||||
width: 120,
|
||||
child: Slider(
|
||||
value: _settings.arabicFontSize,
|
||||
min: 16,
|
||||
max: 40,
|
||||
divisions: 12,
|
||||
activeColor: AppColors.primary,
|
||||
onChanged: (v) {
|
||||
_settings.arabicFontSize = v;
|
||||
_saveSettings();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── ABOUT ──
|
||||
_sectionLabel('TENTANG'),
|
||||
const SizedBox(height: 12),
|
||||
_settingRow(
|
||||
isDark,
|
||||
icon: Icons.info_outline,
|
||||
iconColor: AppColors.sage,
|
||||
title: 'Versi Aplikasi',
|
||||
subtitle: '1.0.0',
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_settingRow(
|
||||
isDark,
|
||||
icon: Icons.favorite_outline,
|
||||
iconColor: Colors.red,
|
||||
title: 'Beri Nilai Kami',
|
||||
trailing: const Icon(Icons.chevron_right, size: 20),
|
||||
onTap: () {},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── Reset Button ──
|
||||
GestureDetector(
|
||||
onTap: () => _showResetDialog(context),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: Colors.red.withValues(alpha: 0.3),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.logout, color: Colors.red, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Hapus Semua Data',
|
||||
style: TextStyle(
|
||||
color: Colors.red,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionLabel(String text) {
|
||||
return Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
color: AppColors.sage,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _settingRow(
|
||||
bool isDark, {
|
||||
required IconData icon,
|
||||
required Color iconColor,
|
||||
required String title,
|
||||
String? subtitle,
|
||||
Widget? trailing,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: 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.08)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: iconColor.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(icon, color: iconColor, size: 20),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (subtitle != null)
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) trailing,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showMethodDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
title: const Text('Metode Perhitungan'),
|
||||
content: SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.85,
|
||||
child: const Text(
|
||||
'Aplikasi ini menggunakan data resmi dari Kementerian Agama RI (Kemenag) melalui API myQuran.\n\nData Kemenag sudah standar dan akurat untuk seluruh wilayah Indonesia, sehingga tidak perlu diubah.',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Tutup'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showLocationDialog(BuildContext context) {
|
||||
final searchCtrl = TextEditingController();
|
||||
bool isSearching = false;
|
||||
List<Map<String, dynamic>> results = [];
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setDialogState) => AlertDialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
title: const Text('Cari Kota/Kabupaten'),
|
||||
content: SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.85,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: searchCtrl,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Cth: Jakarta',
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () async {
|
||||
if (searchCtrl.text.trim().isEmpty) return;
|
||||
setDialogState(() => isSearching = true);
|
||||
final res = await MyQuranSholatService.instance
|
||||
.searchCity(searchCtrl.text.trim());
|
||||
setDialogState(() {
|
||||
results = res;
|
||||
isSearching = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
onSubmitted: (val) async {
|
||||
if (val.trim().isEmpty) return;
|
||||
setDialogState(() => isSearching = true);
|
||||
final res = await MyQuranSholatService.instance
|
||||
.searchCity(val.trim());
|
||||
setDialogState(() {
|
||||
results = res;
|
||||
isSearching = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (isSearching)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else if (results.isEmpty)
|
||||
const Text('Tidak ada hasil', style: TextStyle(color: Colors.grey))
|
||||
else
|
||||
SizedBox(
|
||||
height: 200,
|
||||
width: double.maxFinite,
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: results.length,
|
||||
itemBuilder: (context, i) {
|
||||
final city = results[i];
|
||||
return ListTile(
|
||||
title: Text(city['lokasi'] ?? ''),
|
||||
onTap: () {
|
||||
final id = city['id'];
|
||||
final name = city['lokasi'];
|
||||
if (id != null && name != null) {
|
||||
_settings.lastCityName = '$name|$id';
|
||||
_saveSettings();
|
||||
|
||||
// Update providers to refresh data
|
||||
ref.invalidate(selectedCityIdProvider);
|
||||
ref.invalidate(cityNameProvider);
|
||||
|
||||
Navigator.pop(ctx);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Batal'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showEditProfileDialog(BuildContext context) {
|
||||
final nameCtrl = TextEditingController(text: _settings.userName);
|
||||
final emailCtrl = TextEditingController(text: _settings.userEmail);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
title: const Text('Edit Profil'),
|
||||
content: SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.85,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nama',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: emailCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Batal'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
_settings.userName = nameCtrl.text.trim();
|
||||
_settings.userEmail = emailCtrl.text.trim();
|
||||
_saveSettings();
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('Simpan'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showIqamahDialog(BuildContext context) {
|
||||
final offsets = Map<String, int>.from(_settings.iqamahOffset);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setDialogState) => AlertDialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
title: const Text('Waktu Iqamah (menit)'),
|
||||
content: SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.85,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: offsets.entries.map((e) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
e.key[0].toUpperCase() + e.key.substring(1),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: e.value.toDouble(),
|
||||
min: 0,
|
||||
max: 30,
|
||||
divisions: 30,
|
||||
label: '${e.value} min',
|
||||
activeColor: AppColors.primary,
|
||||
onChanged: (v) {
|
||||
setDialogState(() {
|
||||
offsets[e.key] = v.round();
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 40,
|
||||
child: Text(
|
||||
'${e.value}m',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Batal'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
_settings.iqamahOffset = offsets;
|
||||
_saveSettings();
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('Simpan'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showRawatibDialog(BuildContext context) {
|
||||
int tempLevel = _settings.rawatibLevel;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setDialogState) => AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
const Text('Sholat Rawatib', style: TextStyle(fontSize: 18)),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.info_outline, color: AppColors.primary),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (bCtx) => Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Informasi Sholat Rawatib', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Muakkad (Sangat Ditekankan)', style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.primary)),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Total 10 atau 12 Rakaat:'),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 12, top: 4),
|
||||
child: Text('• 2 Rakaat sebelum Subuh\n• 2 atau 4 Rakaat sebelum Dzuhur\n• 2 Rakaat sesudah Dzuhur\n• 2 Rakaat sesudah Maghrib\n• 2 Rakaat sesudah Isya', style: TextStyle(height: 1.5)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Ghairu Muakkad (Tambahan)', style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.primary)),
|
||||
const SizedBox(height: 8),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 12),
|
||||
child: Text('• Tambahan 2 Rakaat sesudah Dzuhur\n• 4 Rakaat sebelum Ashar\n• 2 Rakaat sebelum Maghrib\n• 2 Rakaat sebelum Isya', style: TextStyle(height: 1.5)),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RadioListTile<int>(
|
||||
title: const Text('Mati (Tanpa Rawatib)'),
|
||||
value: 0,
|
||||
groupValue: tempLevel,
|
||||
onChanged: (v) => setDialogState(() => tempLevel = v!),
|
||||
),
|
||||
RadioListTile<int>(
|
||||
title: const Text('Muakkad Saja'),
|
||||
value: 1,
|
||||
groupValue: tempLevel,
|
||||
onChanged: (v) => setDialogState(() => tempLevel = v!),
|
||||
),
|
||||
RadioListTile<int>(
|
||||
title: const Text('Lengkap (Semua)'),
|
||||
value: 2,
|
||||
groupValue: tempLevel,
|
||||
onChanged: (v) => setDialogState(() => tempLevel = v!),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Batal'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
_settings.rawatibLevel = tempLevel;
|
||||
_saveSettings();
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('Simpan'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showTilawahDialog(BuildContext context) {
|
||||
final qtyCtrl = TextEditingController(text: _settings.tilawahTargetValue.toString());
|
||||
String tempUnit = _settings.tilawahTargetUnit;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setDialogState) => AlertDialog(
|
||||
title: const Text('Target Tilawah Harian'),
|
||||
content: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: TextField(
|
||||
controller: qtyCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(border: OutlineInputBorder()),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: tempUnit,
|
||||
decoration: const InputDecoration(border: OutlineInputBorder()),
|
||||
items: ['Juz', 'Halaman', 'Ayat'].map((u) => DropdownMenuItem(value: u, child: Text(u))).toList(),
|
||||
onChanged: (v) => setDialogState(() => tempUnit = v!),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Batal'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final qty = int.tryParse(qtyCtrl.text.trim()) ?? 1;
|
||||
_settings.tilawahTargetValue = qty > 0 ? qty : 1;
|
||||
_settings.tilawahTargetUnit = tempUnit;
|
||||
_saveSettings();
|
||||
|
||||
// Update today's active checklist immediately
|
||||
final todayKey = DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
final logBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
|
||||
final log = logBox.get(todayKey);
|
||||
if (log != null && log.tilawahLog != null) {
|
||||
log.tilawahLog!.targetValue = _settings.tilawahTargetValue;
|
||||
log.tilawahLog!.targetUnit = _settings.tilawahTargetUnit;
|
||||
log.save();
|
||||
}
|
||||
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('Simpan'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAmalanDialog(BuildContext context) {
|
||||
bool tDzikir = _settings.trackDzikir;
|
||||
bool tPuasa = _settings.trackPuasa;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setDialogState) => AlertDialog(
|
||||
title: const Text('Amalan Tambahan'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SwitchListTile(
|
||||
title: const Text('Dzikir Pagi & Petang'),
|
||||
value: tDzikir,
|
||||
onChanged: (v) => setDialogState(() => tDzikir = v),
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text('Puasa Sunnah'),
|
||||
value: tPuasa,
|
||||
onChanged: (v) => setDialogState(() => tPuasa = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Batal'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
_settings.trackDzikir = tDzikir;
|
||||
_settings.trackPuasa = tPuasa;
|
||||
_saveSettings();
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('Simpan'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showResetDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Hapus Semua Data?'),
|
||||
content: const Text(
|
||||
'Ini akan menghapus semua riwayat ibadah, marka quran, penghitung dzikir, dan mereset pengaturan. Tindakan ini tidak dapat dibatalkan.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Batal'),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: Colors.red),
|
||||
onPressed: () async {
|
||||
await Hive.box(HiveBoxes.worshipLogs).clear();
|
||||
await Hive.box(HiveBoxes.bookmarks).clear();
|
||||
await Hive.box(HiveBoxes.dzikirCounters).clear();
|
||||
final box = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
await box.clear();
|
||||
await box.put('default', AppSettings());
|
||||
setState(() {
|
||||
_settings = box.get('default')!;
|
||||
});
|
||||
ref.read(themeProvider.notifier).state = ThemeMode.system;
|
||||
if (ctx.mounted) Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('Hapus'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
251
lib/features/tools/presentation/tools_screen.dart
Normal file
251
lib/features/tools/presentation/tools_screen.dart
Normal file
@@ -0,0 +1,251 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../data/services/equran_service.dart';
|
||||
|
||||
class ToolsScreen extends ConsumerWidget {
|
||||
const ToolsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Alat Islami'),
|
||||
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: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'AKSES CEPAT',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
color: AppColors.sage,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _ToolCard(
|
||||
icon: Icons.explore,
|
||||
title: 'Arah\nKiblat',
|
||||
color: AppColors.primary,
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/qibla'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _ToolCard(
|
||||
icon: Icons.menu_book,
|
||||
title: 'Baca\nQuran',
|
||||
color: const Color(0xFF4A90D9),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/quran'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _ToolCard(
|
||||
icon: Icons.auto_awesome,
|
||||
title: 'Penghitung\nDzikir',
|
||||
color: const Color(0xFFE8A838),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/dzikir'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _ToolCard(
|
||||
icon: Icons.headphones,
|
||||
title: 'Quran\nMurattal',
|
||||
color: const Color(0xFF7B61FF),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/quran/1/murattal'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// Ayat Hari Ini
|
||||
FutureBuilder<Map<String, dynamic>?>(
|
||||
future: EQuranService.instance.getDailyAyat(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return 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: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot.hasData || snapshot.data == null) {
|
||||
return const SizedBox.shrink(); // Hide if error/no internet
|
||||
}
|
||||
|
||||
final data = snapshot.data!;
|
||||
return 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: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Ayat Hari Ini',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.share,
|
||||
size: 18,
|
||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight),
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
data['teksArab'] ?? '',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 24,
|
||||
height: 1.8,
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'"${data['teksIndonesia'] ?? ''}"',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontStyle: FontStyle.italic,
|
||||
height: 1.5,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'QS. ${data['surahName']}: ${data['nomorAyat']}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ToolCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final Color color;
|
||||
final bool isDark;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ToolCard({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.color,
|
||||
required this.isDark,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 140,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? color.withValues(alpha: 0.15)
|
||||
: AppColors.cream,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: color.withValues(alpha: 0.08),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 24),
|
||||
),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user