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:
dwindown
2026-03-13 15:42:17 +07:00
commit faadc1865d
189 changed files with 23834 additions and 0 deletions

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

View File

@@ -0,0 +1 @@
// TODO: implement

View File

View File

@@ -0,0 +1 @@
// TODO: implement

View 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';
});

View File

View File

@@ -0,0 +1 @@
// TODO: implement

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

View File

@@ -0,0 +1 @@
// TODO: implement

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

View File

@@ -0,0 +1 @@
// TODO: implement

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

View File

@@ -0,0 +1 @@
// TODO: implement

View 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});
}

View File

@@ -0,0 +1 @@
// TODO: implement

View File

View File

@@ -0,0 +1 @@
// TODO: implement

View 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;
}

View File

View File

@@ -0,0 +1 @@
// TODO: implement

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

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

File diff suppressed because it is too large Load Diff

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

View File

@@ -0,0 +1 @@
// TODO: implement

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

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