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

View File

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

View File

@@ -0,0 +1,119 @@
import 'package:flutter/foundation.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'models/app_settings.dart';
import 'models/checklist_item.dart';
import 'models/daily_worship_log.dart';
import 'models/dzikir_counter.dart';
import 'models/quran_bookmark.dart';
import 'models/cached_prayer_times.dart';
import 'models/shalat_log.dart';
import 'models/tilawah_log.dart';
import 'models/dzikir_log.dart';
import 'models/puasa_log.dart';
/// Box name constants for Hive.
class HiveBoxes {
HiveBoxes._();
static const String settings = 'settings';
static const String checklistItems = 'checklist_items';
static const String worshipLogs = 'worship_logs';
static const String dzikirCounters = 'dzikir_counters';
static const String bookmarks = 'bookmarks';
static const String cachedPrayerTimes = 'cached_prayer_times';
}
/// Initialize Hive and open all boxes.
Future<void> initHive() async {
await Hive.initFlutter();
// Register adapters
Hive.registerAdapter(AppSettingsAdapter());
Hive.registerAdapter(ChecklistItemAdapter());
Hive.registerAdapter(DailyWorshipLogAdapter());
Hive.registerAdapter(DzikirCounterAdapter());
Hive.registerAdapter(QuranBookmarkAdapter());
Hive.registerAdapter(CachedPrayerTimesAdapter());
Hive.registerAdapter(ShalatLogAdapter());
Hive.registerAdapter(TilawahLogAdapter());
Hive.registerAdapter(DzikirLogAdapter());
Hive.registerAdapter(PuasaLogAdapter());
// Open boxes
try {
await Hive.openBox<AppSettings>(HiveBoxes.settings);
} catch (e) {
debugPrint('Settings box corrupted, resetting: $e');
if (Hive.isBoxOpen(HiveBoxes.settings)) {
await Hive.box<AppSettings>(HiveBoxes.settings).close();
}
await Hive.deleteBoxFromDisk(HiveBoxes.settings);
await Hive.openBox<AppSettings>(HiveBoxes.settings);
}
await Hive.openBox<ChecklistItem>(HiveBoxes.checklistItems);
final worshipBox = await Hive.openBox<DailyWorshipLog>(HiveBoxes.worshipLogs);
await Hive.openBox<DzikirCounter>(HiveBoxes.dzikirCounters);
await Hive.openBox<QuranBookmark>(HiveBoxes.bookmarks);
await Hive.openBox<CachedPrayerTimes>(HiveBoxes.cachedPrayerTimes);
// MIGRATION: Delete legacy logs that crash due to type casts (Map<String, bool> vs Map<String, ShalatLog>)
final keysToDelete = [];
for (final key in worshipBox.keys) {
try {
final log = worshipBox.get(key);
if (log != null) {
log.shalatLogs.values.toList(); // Force evaluation
}
} catch (_) {
keysToDelete.add(key);
}
}
if (keysToDelete.isNotEmpty) {
await worshipBox.deleteAll(keysToDelete);
debugPrint('Deleted ${keysToDelete.length} legacy worship logs.');
}
}
/// Seeds default settings and checklist items on first launch.
Future<void> seedDefaults() async {
// Seed AppSettings
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
if (settingsBox.isEmpty) {
await settingsBox.put('default', AppSettings());
}
// Seed default checklist items
final checklistBox = Hive.box<ChecklistItem>(HiveBoxes.checklistItems);
if (checklistBox.isEmpty) {
final defaults = [
ChecklistItem(
id: 'fajr', title: 'Sholat Fajr', category: 'sholat_fardhu', sortOrder: 0),
ChecklistItem(
id: 'dhuhr', title: 'Sholat Dhuhr', category: 'sholat_fardhu', sortOrder: 1),
ChecklistItem(
id: 'asr', title: 'Sholat Asr', category: 'sholat_fardhu', sortOrder: 2),
ChecklistItem(
id: 'maghrib', title: 'Sholat Maghrib', category: 'sholat_fardhu', sortOrder: 3),
ChecklistItem(
id: 'isha', title: 'Sholat Isha', category: 'sholat_fardhu', sortOrder: 4),
ChecklistItem(
id: 'tilawah', title: 'Tilawah Quran', category: 'tilawah',
subtitle: '1 Juz', sortOrder: 5),
ChecklistItem(
id: 'dzikir_pagi', title: 'Dzikir Pagi', category: 'dzikir',
subtitle: '1 session', sortOrder: 6),
ChecklistItem(
id: 'dzikir_petang', title: 'Dzikir Petang', category: 'dzikir',
subtitle: '1 session', sortOrder: 7),
ChecklistItem(
id: 'rawatib', title: 'Sholat Sunnah Rawatib', category: 'sunnah', sortOrder: 8),
ChecklistItem(
id: 'shodaqoh', title: 'Shodaqoh', category: 'charity', sortOrder: 9),
];
for (final item in defaults) {
await checklistBox.put(item.id, item);
}
}
}

View File

@@ -0,0 +1,101 @@
import 'package:hive_flutter/hive_flutter.dart';
part 'app_settings.g.dart';
/// User settings stored in Hive.
@HiveType(typeId: 0)
class AppSettings extends HiveObject {
@HiveField(0)
String userName;
@HiveField(1)
String userEmail;
@HiveField(2)
int themeModeIndex; // 0=system, 1=light, 2=dark
@HiveField(3)
double arabicFontSize;
@HiveField(4)
String uiLanguage; // 'id' or 'en'
@HiveField(5)
Map<String, bool> adhanEnabled;
@HiveField(6)
Map<String, int> iqamahOffset; // minutes
@HiveField(7)
String? checklistReminderTime; // HH:mm format
@HiveField(8)
double? lastLat;
@HiveField(9)
double? lastLng;
@HiveField(10)
String? lastCityName;
@HiveField(11)
int rawatibLevel; // 0 = Off, 1 = Muakkad Only, 2 = Full
@HiveField(12)
int tilawahTargetValue;
@HiveField(13)
String tilawahTargetUnit; // 'Juz', 'Halaman', 'Ayat'
@HiveField(14)
bool tilawahAutoSync;
@HiveField(15)
bool trackDzikir;
@HiveField(16)
bool trackPuasa;
@HiveField(17)
bool showLatin;
@HiveField(18)
bool showTerjemahan;
AppSettings({
this.userName = 'User',
this.userEmail = '',
this.themeModeIndex = 0,
this.arabicFontSize = 24.0,
this.uiLanguage = 'id',
Map<String, bool>? adhanEnabled,
Map<String, int>? iqamahOffset,
this.checklistReminderTime = '09:00',
this.lastLat,
this.lastLng,
this.lastCityName,
this.rawatibLevel = 1, // Default to Muakkad
this.tilawahTargetValue = 1,
this.tilawahTargetUnit = 'Juz',
this.tilawahAutoSync = false,
this.trackDzikir = true,
this.trackPuasa = false,
this.showLatin = true,
this.showTerjemahan = true,
}) : adhanEnabled = adhanEnabled ??
{
'fajr': true,
'dhuhr': true,
'asr': true,
'maghrib': true,
'isha': true,
},
iqamahOffset = iqamahOffset ??
{
'fajr': 15,
'dhuhr': 10,
'asr': 10,
'maghrib': 5,
'isha': 10,
};
}

View File

@@ -0,0 +1,95 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app_settings.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class AppSettingsAdapter extends TypeAdapter<AppSettings> {
@override
final int typeId = 0;
@override
AppSettings read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return AppSettings(
userName: fields.containsKey(0) ? fields[0] as String? ?? '' : '',
userEmail: fields.containsKey(1) ? fields[1] as String? ?? '' : '',
themeModeIndex: fields.containsKey(2) ? fields[2] as int? ?? 0 : 0,
arabicFontSize: fields.containsKey(3) ? fields[3] as double? ?? 26.0 : 26.0,
uiLanguage: fields.containsKey(4) ? fields[4] as String? ?? 'id' : 'id',
adhanEnabled: fields.containsKey(5) ? (fields[5] as Map?)?.cast<String, bool>() : null,
iqamahOffset: fields.containsKey(6) ? (fields[6] as Map?)?.cast<String, int>() : null,
checklistReminderTime: fields.containsKey(7) ? fields[7] as String? : null,
lastLat: fields.containsKey(8) ? fields[8] as double? : null,
lastLng: fields.containsKey(9) ? fields[9] as double? : null,
lastCityName: fields.containsKey(10) ? fields[10] as String? : null,
rawatibLevel: fields.containsKey(11) ? fields[11] as int? ?? 1 : 1,
tilawahTargetValue: fields.containsKey(12) ? fields[12] as int? ?? 1 : 1,
tilawahTargetUnit: fields.containsKey(13) ? fields[13] as String? ?? 'Juz' : 'Juz',
tilawahAutoSync: fields.containsKey(14) ? fields[14] as bool? ?? false : false,
trackDzikir: fields.containsKey(15) ? fields[15] as bool? ?? true : true,
trackPuasa: fields.containsKey(16) ? fields[16] as bool? ?? false : false,
showLatin: fields.containsKey(17) ? fields[17] as bool? ?? true : true,
showTerjemahan: fields.containsKey(18) ? fields[18] as bool? ?? true : true,
);
}
@override
void write(BinaryWriter writer, AppSettings obj) {
writer
..writeByte(19)
..writeByte(0)
..write(obj.userName)
..writeByte(1)
..write(obj.userEmail)
..writeByte(2)
..write(obj.themeModeIndex)
..writeByte(3)
..write(obj.arabicFontSize)
..writeByte(4)
..write(obj.uiLanguage)
..writeByte(5)
..write(obj.adhanEnabled)
..writeByte(6)
..write(obj.iqamahOffset)
..writeByte(7)
..write(obj.checklistReminderTime)
..writeByte(8)
..write(obj.lastLat)
..writeByte(9)
..write(obj.lastLng)
..writeByte(10)
..write(obj.lastCityName)
..writeByte(11)
..write(obj.rawatibLevel)
..writeByte(12)
..write(obj.tilawahTargetValue)
..writeByte(13)
..write(obj.tilawahTargetUnit)
..writeByte(14)
..write(obj.tilawahAutoSync)
..writeByte(15)
..write(obj.trackDzikir)
..writeByte(16)
..write(obj.trackPuasa)
..writeByte(17)
..write(obj.showLatin)
..writeByte(18)
..write(obj.showTerjemahan);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AppSettingsAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,50 @@
import 'package:hive_flutter/hive_flutter.dart';
part 'cached_prayer_times.g.dart';
/// Cached prayer times for a specific location + date.
@HiveType(typeId: 5)
class CachedPrayerTimes extends HiveObject {
@HiveField(0)
String key; // 'lat_lng_yyyy-MM-dd'
@HiveField(1)
double lat;
@HiveField(2)
double lng;
@HiveField(3)
String date;
@HiveField(4)
DateTime fajr;
@HiveField(5)
DateTime sunrise;
@HiveField(6)
DateTime dhuhr;
@HiveField(7)
DateTime asr;
@HiveField(8)
DateTime maghrib;
@HiveField(9)
DateTime isha;
CachedPrayerTimes({
required this.key,
required this.lat,
required this.lng,
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,68 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'cached_prayer_times.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class CachedPrayerTimesAdapter extends TypeAdapter<CachedPrayerTimes> {
@override
final int typeId = 5;
@override
CachedPrayerTimes read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return CachedPrayerTimes(
key: fields[0] as String,
lat: fields[1] as double,
lng: fields[2] as double,
date: fields[3] as String,
fajr: fields[4] as DateTime,
sunrise: fields[5] as DateTime,
dhuhr: fields[6] as DateTime,
asr: fields[7] as DateTime,
maghrib: fields[8] as DateTime,
isha: fields[9] as DateTime,
);
}
@override
void write(BinaryWriter writer, CachedPrayerTimes obj) {
writer
..writeByte(10)
..writeByte(0)
..write(obj.key)
..writeByte(1)
..write(obj.lat)
..writeByte(2)
..write(obj.lng)
..writeByte(3)
..write(obj.date)
..writeByte(4)
..write(obj.fajr)
..writeByte(5)
..write(obj.sunrise)
..writeByte(6)
..write(obj.dhuhr)
..writeByte(7)
..write(obj.asr)
..writeByte(8)
..write(obj.maghrib)
..writeByte(9)
..write(obj.isha);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is CachedPrayerTimesAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,34 @@
import 'package:hive_flutter/hive_flutter.dart';
part 'checklist_item.g.dart';
/// A single checklist item definition (template, not daily state).
@HiveType(typeId: 1)
class ChecklistItem extends HiveObject {
@HiveField(0)
String id;
@HiveField(1)
String title;
@HiveField(2)
String category;
@HiveField(3)
String? subtitle;
@HiveField(4)
int sortOrder;
@HiveField(5)
bool isCustom;
ChecklistItem({
required this.id,
required this.title,
required this.category,
this.subtitle,
required this.sortOrder,
this.isCustom = false,
});
}

View File

@@ -0,0 +1,56 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'checklist_item.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class ChecklistItemAdapter extends TypeAdapter<ChecklistItem> {
@override
final int typeId = 1;
@override
ChecklistItem read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return ChecklistItem(
id: fields[0] as String,
title: fields[1] as String,
category: fields[2] as String,
subtitle: fields[3] as String?,
sortOrder: fields[4] as int,
isCustom: fields[5] as bool,
);
}
@override
void write(BinaryWriter writer, ChecklistItem obj) {
writer
..writeByte(6)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.title)
..writeByte(2)
..write(obj.category)
..writeByte(3)
..write(obj.subtitle)
..writeByte(4)
..write(obj.sortOrder)
..writeByte(5)
..write(obj.isCustom);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ChecklistItemAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,86 @@
import 'package:hive_flutter/hive_flutter.dart';
import 'shalat_log.dart';
import 'tilawah_log.dart';
import 'dzikir_log.dart';
import 'puasa_log.dart';
part 'daily_worship_log.g.dart';
/// Daily worship completion log, keyed by date string 'yyyy-MM-dd'.
@HiveType(typeId: 2)
class DailyWorshipLog extends HiveObject {
@HiveField(0)
String date;
@HiveField(1)
Map<String, ShalatLog> shalatLogs; // e.g., 'subuh' -> ShalatLog
@HiveField(5)
TilawahLog? tilawahLog;
@HiveField(6)
DzikirLog? dzikirLog;
@HiveField(7)
PuasaLog? puasaLog;
@HiveField(2)
int totalItems;
@HiveField(3)
int completedCount;
@HiveField(4)
double completionPercent;
DailyWorshipLog({
required this.date,
Map<String, ShalatLog>? shalatLogs,
this.tilawahLog,
this.dzikirLog,
this.puasaLog,
this.totalItems = 0,
this.completedCount = 0,
this.completionPercent = 0.0,
}) : shalatLogs = shalatLogs ?? {};
/// Dynamically calculates the "Poin Ibadah" for this day.
int get totalPoints {
int points = 0;
// 1. Shalat Fardhu
for (final sLog in shalatLogs.values) {
if (sLog.completed) {
if (sLog.location == 'Masjid') {
points += 25;
} else {
points += 10;
}
}
if (sLog.qabliyah == true) points += 5;
if (sLog.badiyah == true) points += 5;
}
// 2. Tilawah
if (tilawahLog != null) {
// 1 point per Ayat read
points += tilawahLog!.rawAyatRead;
// Bonus 20 points for completing daily target
if (tilawahLog!.isCompleted) {
points += 20;
}
}
// 3. Dzikir & Puasa
if (dzikirLog != null) {
if (dzikirLog!.pagi) points += 10;
if (dzikirLog!.petang) points += 10;
}
if (puasaLog != null && puasaLog!.completed) {
points += 30;
}
return points;
}
}

View File

@@ -0,0 +1,70 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'daily_worship_log.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class DailyWorshipLogAdapter extends TypeAdapter<DailyWorshipLog> {
@override
final int typeId = 2;
@override
DailyWorshipLog read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
Map<String, ShalatLog>? parsedShalatLogs;
try {
parsedShalatLogs = (fields[1] as Map?)?.cast<String, ShalatLog>();
} catch (_) {
// If casting fails (e.g. it was the old Map<String, bool>), ignore it.
parsedShalatLogs = {};
}
return DailyWorshipLog(
date: fields[0] as String,
shalatLogs: parsedShalatLogs,
tilawahLog: fields[5] as TilawahLog?,
dzikirLog: fields[6] as DzikirLog?,
puasaLog: fields[7] as PuasaLog?,
totalItems: fields[2] as int,
completedCount: fields[3] as int,
completionPercent: fields[4] as double,
);
}
@override
void write(BinaryWriter writer, DailyWorshipLog obj) {
writer
..writeByte(8)
..writeByte(0)
..write(obj.date)
..writeByte(1)
..write(obj.shalatLogs)
..writeByte(5)
..write(obj.tilawahLog)
..writeByte(6)
..write(obj.dzikirLog)
..writeByte(7)
..write(obj.puasaLog)
..writeByte(2)
..write(obj.totalItems)
..writeByte(3)
..write(obj.completedCount)
..writeByte(4)
..write(obj.completionPercent);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is DailyWorshipLogAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,26 @@
import 'package:hive_flutter/hive_flutter.dart';
part 'dzikir_counter.g.dart';
/// Counter for a single dzikir item on a specific date.
@HiveType(typeId: 3)
class DzikirCounter extends HiveObject {
@HiveField(0)
String dzikirId;
@HiveField(1)
String date;
@HiveField(2)
int count;
@HiveField(3)
int target;
DzikirCounter({
required this.dzikirId,
required this.date,
this.count = 0,
required this.target,
});
}

View File

@@ -0,0 +1,50 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'dzikir_counter.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class DzikirCounterAdapter extends TypeAdapter<DzikirCounter> {
@override
final int typeId = 3;
@override
DzikirCounter read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return DzikirCounter(
dzikirId: fields[0] as String,
date: fields[1] as String,
count: fields[2] as int,
target: fields[3] as int,
);
}
@override
void write(BinaryWriter writer, DzikirCounter obj) {
writer
..writeByte(4)
..writeByte(0)
..write(obj.dzikirId)
..writeByte(1)
..write(obj.date)
..writeByte(2)
..write(obj.count)
..writeByte(3)
..write(obj.target);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is DzikirCounterAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,17 @@
import 'package:hive/hive.dart';
part 'dzikir_log.g.dart';
@HiveType(typeId: 9)
class DzikirLog {
@HiveField(0)
bool pagi;
@HiveField(1)
bool petang;
DzikirLog({
this.pagi = false,
this.petang = false,
});
}

View File

@@ -0,0 +1,44 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'dzikir_log.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class DzikirLogAdapter extends TypeAdapter<DzikirLog> {
@override
final int typeId = 9;
@override
DzikirLog read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return DzikirLog(
pagi: fields[0] as bool,
petang: fields[1] as bool,
);
}
@override
void write(BinaryWriter writer, DzikirLog obj) {
writer
..writeByte(2)
..writeByte(0)
..write(obj.pagi)
..writeByte(1)
..write(obj.petang);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is DzikirLogAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,17 @@
import 'package:hive/hive.dart';
part 'puasa_log.g.dart';
@HiveType(typeId: 10)
class PuasaLog {
@HiveField(0)
String? jenisPuasa; // 'Senin', 'Kamis', 'Ayyamul Bidh', 'Daud', etc.
@HiveField(1)
bool completed;
PuasaLog({
this.jenisPuasa,
this.completed = false,
});
}

View File

@@ -0,0 +1,44 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'puasa_log.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class PuasaLogAdapter extends TypeAdapter<PuasaLog> {
@override
final int typeId = 10;
@override
PuasaLog read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return PuasaLog(
jenisPuasa: fields[0] as String?,
completed: fields[1] as bool,
);
}
@override
void write(BinaryWriter writer, PuasaLog obj) {
writer
..writeByte(2)
..writeByte(0)
..write(obj.jenisPuasa)
..writeByte(1)
..write(obj.completed);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is PuasaLogAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,42 @@
import 'package:hive_flutter/hive_flutter.dart';
part 'quran_bookmark.g.dart';
/// A bookmarked Quran verse.
@HiveType(typeId: 4)
class QuranBookmark extends HiveObject {
@HiveField(0)
int surahId;
@HiveField(1)
int verseId;
@HiveField(2)
String surahName;
@HiveField(3)
String verseText; // Arabic snippet
@HiveField(4)
DateTime savedAt;
@HiveField(5)
bool isLastRead;
@HiveField(6)
String? verseLatin;
@HiveField(7)
String? verseTranslation;
QuranBookmark({
required this.surahId,
required this.verseId,
required this.surahName,
required this.verseText,
required this.savedAt,
this.isLastRead = false,
this.verseLatin,
this.verseTranslation,
});
}

View File

@@ -0,0 +1,62 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'quran_bookmark.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class QuranBookmarkAdapter extends TypeAdapter<QuranBookmark> {
@override
final int typeId = 4;
@override
QuranBookmark read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return QuranBookmark(
surahId: fields[0] as int,
verseId: fields[1] as int,
surahName: fields[2] as String,
verseText: fields[3] as String,
savedAt: fields[4] as DateTime,
isLastRead: fields[5] as bool? ?? false,
verseLatin: fields[6] as String?,
verseTranslation: fields[7] as String?,
);
}
@override
void write(BinaryWriter writer, QuranBookmark obj) {
writer
..writeByte(8)
..writeByte(0)
..write(obj.surahId)
..writeByte(1)
..write(obj.verseId)
..writeByte(2)
..write(obj.surahName)
..writeByte(3)
..write(obj.verseText)
..writeByte(4)
..write(obj.savedAt)
..writeByte(5)
..write(obj.isLastRead)
..writeByte(6)
..write(obj.verseLatin)
..writeByte(7)
..write(obj.verseTranslation);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is QuranBookmarkAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,25 @@
import 'package:hive/hive.dart';
part 'shalat_log.g.dart';
@HiveType(typeId: 7)
class ShalatLog {
@HiveField(0)
bool completed;
@HiveField(1)
String? location; // 'Rumah' or 'Masjid'
@HiveField(2)
bool? qabliyah;
@HiveField(3)
bool? badiyah;
ShalatLog({
this.completed = false,
this.location,
this.qabliyah,
this.badiyah,
});
}

View File

@@ -0,0 +1,50 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'shalat_log.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class ShalatLogAdapter extends TypeAdapter<ShalatLog> {
@override
final int typeId = 7;
@override
ShalatLog read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return ShalatLog(
completed: fields[0] as bool,
location: fields[1] as String?,
qabliyah: fields[2] as bool?,
badiyah: fields[3] as bool?,
);
}
@override
void write(BinaryWriter writer, ShalatLog obj) {
writer
..writeByte(4)
..writeByte(0)
..write(obj.completed)
..writeByte(1)
..write(obj.location)
..writeByte(2)
..write(obj.qabliyah)
..writeByte(3)
..write(obj.badiyah);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ShalatLogAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,35 @@
import 'package:hive/hive.dart';
part 'tilawah_log.g.dart';
@HiveType(typeId: 8)
class TilawahLog {
@HiveField(0)
int targetValue;
@HiveField(1)
String targetUnit; // 'Juz', 'Halaman', 'Ayat'
@HiveField(2)
int currentProgress;
@HiveField(3)
bool autoSync;
@HiveField(4)
int rawAyatRead;
@HiveField(5)
bool targetCompleted;
TilawahLog({
this.targetValue = 1,
this.targetUnit = 'Juz',
this.currentProgress = 0,
this.autoSync = false,
this.rawAyatRead = 0,
this.targetCompleted = false,
});
bool get isCompleted => targetCompleted;
}

View File

@@ -0,0 +1,56 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'tilawah_log.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class TilawahLogAdapter extends TypeAdapter<TilawahLog> {
@override
final int typeId = 8;
@override
TilawahLog read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return TilawahLog(
targetValue: fields[0] as int,
targetUnit: fields[1] as String,
currentProgress: fields[2] as int,
autoSync: fields[3] as bool,
rawAyatRead: fields[4] as int? ?? 0,
targetCompleted: fields[5] as bool? ?? false,
);
}
@override
void write(BinaryWriter writer, TilawahLog obj) {
writer
..writeByte(6)
..writeByte(0)
..write(obj.targetValue)
..writeByte(1)
..write(obj.targetUnit)
..writeByte(2)
..write(obj.currentProgress)
..writeByte(3)
..write(obj.autoSync)
..writeByte(4)
..write(obj.rawAyatRead)
..writeByte(5)
..write(obj.targetCompleted);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TilawahLogAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

View File

@@ -0,0 +1,107 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../local/hive_boxes.dart';
import '../local/models/dzikir_counter.dart';
import 'package:intl/intl.dart';
/// Represents a single dzikir item from the bundled JSON.
class DzikirItem {
final String id;
final String arabic;
final String transliteration;
final String translation;
final int targetCount;
final String? source;
DzikirItem({
required this.id,
required this.arabic,
required this.transliteration,
required this.translation,
required this.targetCount,
this.source,
});
factory DzikirItem.fromJson(Map<String, dynamic> json) {
return DzikirItem(
id: json['id'] as String,
arabic: json['arabic'] as String? ?? '',
transliteration: json['transliteration'] as String? ?? '',
translation: json['translation'] as String? ?? '',
targetCount: json['target_count'] as int? ?? 1,
source: json['source'] as String?,
);
}
}
/// Types of dzikir sessions.
enum DzikirType { pagi, petang }
/// Service to load dzikir data and manage counters.
class DzikirService {
DzikirService._();
static final DzikirService instance = DzikirService._();
final Map<DzikirType, List<DzikirItem>> _cache = {};
/// Load dzikir items from bundled JSON.
Future<List<DzikirItem>> getDzikir(DzikirType type) async {
if (_cache.containsKey(type)) return _cache[type]!;
final path = type == DzikirType.pagi
? 'assets/dzikir/dzikir_pagi.json'
: 'assets/dzikir/dzikir_petang.json';
try {
final jsonString = await rootBundle.loadString(path);
final List<dynamic> data = json.decode(jsonString);
_cache[type] =
data.map((d) => DzikirItem.fromJson(d as Map<String, dynamic>)).toList();
} catch (_) {
_cache[type] = [];
}
return _cache[type]!;
}
/// Get counters for a specific date from Hive.
Map<String, int> getCountersForDate(String date) {
final box = Hive.box<DzikirCounter>(HiveBoxes.dzikirCounters);
final result = <String, int>{};
for (final key in box.keys) {
final counter = box.get(key);
if (counter != null && counter.date == date) {
result[counter.dzikirId] = counter.count;
}
}
return result;
}
/// Increment a dzikir counter for a specific ID on a specific date.
Future<void> increment(String dzikirId, String date, int target) async {
final box = Hive.box<DzikirCounter>(HiveBoxes.dzikirCounters);
final key = '${dzikirId}_$date';
final existing = box.get(key);
if (existing != null) {
existing.count = (existing.count + 1).clamp(0, target);
await existing.save();
} else {
await box.put(
key,
DzikirCounter(
dzikirId: dzikirId,
date: date,
count: 1,
target: target,
),
);
}
}
/// Get today's date string.
String get todayKey => DateFormat('yyyy-MM-dd').format(DateTime.now());
}

View File

@@ -0,0 +1,108 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
/// Service for EQuran.id v2 API.
/// Provides complete Quran data: Arabic, Indonesian translation,
/// tafsir, and audio from 6 qari.
class EQuranService {
static const String _baseUrl = 'https://equran.id/api/v2';
static final EQuranService instance = EQuranService._();
EQuranService._();
// In-memory cache
List<Map<String, dynamic>>? _surahListCache;
/// Get list of all 114 surahs.
Future<List<Map<String, dynamic>>> getAllSurahs() async {
if (_surahListCache != null) return _surahListCache!;
try {
final response = await http.get(Uri.parse('$_baseUrl/surat'));
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['code'] == 200) {
_surahListCache =
List<Map<String, dynamic>>.from(data['data']);
return _surahListCache!;
}
}
} catch (e) {
// silent fallback
}
return [];
}
/// Get full surah with all ayat, audio, etc.
/// Returns the full surah data object.
Future<Map<String, dynamic>?> getSurah(int number) async {
try {
final response =
await http.get(Uri.parse('$_baseUrl/surat/$number'));
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['code'] == 200) {
return Map<String, dynamic>.from(data['data']);
}
}
} catch (e) {
// silent fallback
}
return null;
}
/// Get tafsir for a surah.
Future<Map<String, dynamic>?> getTafsir(int number) async {
try {
final response =
await http.get(Uri.parse('$_baseUrl/tafsir/$number'));
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['code'] == 200) {
return Map<String, dynamic>.from(data['data']);
}
}
} catch (e) {
// silent fallback
}
return null;
}
/// Get deterministic daily ayat from API
Future<Map<String, dynamic>?> getDailyAyat() async {
try {
final now = DateTime.now();
final dayOfYear = int.parse(now.difference(DateTime(now.year, 1, 1)).inDays.toString());
// Pick surah 1-114
int surahId = (dayOfYear % 114) + 1;
final surahData = await getSurah(surahId);
if (surahData != null && surahData['ayat'] != null) {
int totalAyat = surahData['jumlahAyat'] ?? 1;
int ayatIndex = dayOfYear % totalAyat;
final targetAyat = surahData['ayat'][ayatIndex];
return {
'surahName': surahData['namaLatin'],
'nomorSurah': surahId,
'nomorAyat': targetAyat['nomorAyat'],
'teksArab': targetAyat['teksArab'],
'teksIndonesia': targetAyat['teksIndonesia'],
};
}
} catch (e) {
// silent fallback
}
return null;
}
/// Available qari names mapped to audio key index.
static const Map<String, String> qariNames = {
'01': 'Abdullah Al-Juhany',
'02': 'Abdul Muhsin Al-Qasim',
'03': 'Abdurrahman As-Sudais',
'04': 'Ibrahim Al-Dossari',
'05': 'Misyari Rasyid Al-Afasi',
'06': 'Yasser Al-Dosari',
};
}

View File

@@ -0,0 +1,86 @@
import 'package:geolocator/geolocator.dart';
import 'package:geocoding/geocoding.dart' as geocoding;
import 'package:hive_flutter/hive_flutter.dart';
import '../local/hive_boxes.dart';
import '../local/models/app_settings.dart';
/// Location service with GPS + fallback to last known location.
class LocationService {
LocationService._();
static final LocationService instance = LocationService._();
/// Request permission and get current GPS location.
Future<Position?> getCurrentLocation() async {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) return null;
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) return null;
}
if (permission == LocationPermission.deniedForever) return null;
try {
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.medium,
timeLimit: Duration(seconds: 10),
),
);
// Save to settings for fallback
await _saveLastKnown(position.latitude, position.longitude);
return position;
} catch (_) {
return null;
}
}
/// Get last known location from Hive settings.
({double lat, double lng, String? cityName})? getLastKnownLocation() {
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = settingsBox.get('default');
if (settings?.lastLat != null && settings?.lastLng != null) {
return (
lat: settings!.lastLat!,
lng: settings.lastLng!,
cityName: settings.lastCityName,
);
}
return null;
}
/// Reverse geocode to get city name from coordinates.
Future<String> getCityName(double lat, double lng) async {
try {
final placemarks = await geocoding.placemarkFromCoordinates(lat, lng);
if (placemarks.isNotEmpty) {
final place = placemarks.first;
final city = place.locality ?? place.subAdministrativeArea ?? 'Unknown';
final country = place.country ?? '';
return '$city, $country';
}
} catch (_) {
// Geocoding may fail offline — return coords
}
return '${lat.toStringAsFixed(2)}, ${lng.toStringAsFixed(2)}';
}
/// Save last known position to Hive.
Future<void> _saveLastKnown(double lat, double lng) async {
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = settingsBox.get('default');
if (settings != null) {
settings.lastLat = lat;
settings.lastLng = lng;
try {
settings.lastCityName = await getCityName(lat, lng);
} catch (_) {
// Ignore geocoding errors
}
await settings.save();
}
}
}

View File

@@ -0,0 +1,108 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
/// Service for myQuran.com v3 Sholat API.
/// Provides Kemenag-accurate prayer times for Indonesian cities.
class MyQuranSholatService {
static const String _baseUrl = 'https://api.myquran.com/v3/sholat';
static final MyQuranSholatService instance = MyQuranSholatService._();
MyQuranSholatService._();
/// Search for a city/kabupaten by name.
/// Returns list of {id, lokasi}.
Future<List<Map<String, dynamic>>> searchCity(String query) async {
try {
final response = await http.get(
Uri.parse('$_baseUrl/kota/cari/$query'),
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['status'] == true) {
return List<Map<String, dynamic>>.from(data['data']);
}
}
} catch (e) {
// silent fallback
}
return [];
}
/// Get prayer times for a specific city and date.
/// [cityId] = myQuran city ID (hash string)
/// [date] = 'yyyy-MM-dd' format
/// Returns map: {tanggal, imsak, subuh, terbit, dhuha, dzuhur, ashar, maghrib, isya}
Future<Map<String, String>?> getDailySchedule(
String cityId, String date) async {
try {
final response = await http.get(
Uri.parse('$_baseUrl/jadwal/$cityId/$date'),
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['status'] == true) {
final jadwal = data['data']['jadwal'][date];
if (jadwal != null) {
return Map<String, String>.from(
jadwal.map((k, v) => MapEntry(k.toString(), v.toString())),
);
}
}
}
} catch (e) {
// silent fallback
}
return null;
}
/// Get monthly prayer schedule.
/// [month] = 'yyyy-MM' format
/// Returns map of date → jadwal.
Future<Map<String, Map<String, String>>> getMonthlySchedule(
String cityId, String month) async {
try {
final response = await http.get(
Uri.parse('$_baseUrl/jadwal/$cityId/$month'),
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['status'] == true) {
final jadwalMap = data['data']['jadwal'] as Map<String, dynamic>;
final result = <String, Map<String, String>>{};
for (final entry in jadwalMap.entries) {
result[entry.key] = Map<String, String>.from(
(entry.value as Map).map(
(k, v) => MapEntry(k.toString(), v.toString())),
);
}
return result;
}
}
} catch (e) {
// silent fallback
}
return {};
}
/// Get city info (kabko, prov) from a jadwal response.
Future<Map<String, String>?> getCityInfo(String cityId) async {
final today =
DateTime.now().toIso8601String().substring(0, 10);
try {
final response = await http.get(
Uri.parse('$_baseUrl/jadwal/$cityId/$today'),
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['status'] == true) {
return {
'kabko': data['data']['kabko']?.toString() ?? '',
'prov': data['data']['prov']?.toString() ?? '',
};
}
}
} catch (e) {
// silent fallback
}
return null;
}
}

View File

@@ -0,0 +1,98 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/timezone.dart' as tz;
/// Notification service for Adhan and Iqamah notifications.
class NotificationService {
NotificationService._();
static final NotificationService instance = NotificationService._();
final FlutterLocalNotificationsPlugin _plugin =
FlutterLocalNotificationsPlugin();
bool _initialized = false;
/// Initialize notification channels.
Future<void> init() async {
if (_initialized) return;
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const darwinSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
);
const settings = InitializationSettings(
android: androidSettings,
iOS: darwinSettings,
macOS: darwinSettings,
);
await _plugin.initialize(settings);
_initialized = true;
}
/// Schedule an Adhan notification at a specific time.
Future<void> scheduleAdhan({
required int id,
required String prayerName,
required DateTime time,
}) async {
await _plugin.zonedSchedule(
id,
'Adhan - $prayerName',
'It\'s time for $prayerName prayer',
tz.TZDateTime.from(time, tz.local),
const NotificationDetails(
android: AndroidNotificationDetails(
'adhan_channel',
'Adhan Notifications',
channelDescription: 'Prayer time adhan notifications',
importance: Importance.high,
priority: Priority.high,
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
);
}
/// Schedule an Iqamah reminder notification.
Future<void> scheduleIqamah({
required int id,
required String prayerName,
required DateTime adhanTime,
required int offsetMinutes,
}) async {
final iqamahTime = adhanTime.add(Duration(minutes: offsetMinutes));
await _plugin.zonedSchedule(
id + 100, // Offset IDs for iqamah
'Iqamah - $prayerName',
'Iqamah for $prayerName in $offsetMinutes minutes',
tz.TZDateTime.from(iqamahTime, tz.local),
const NotificationDetails(
android: AndroidNotificationDetails(
'iqamah_channel',
'Iqamah Reminders',
channelDescription: 'Iqamah reminder notifications',
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentSound: true,
),
),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
);
}
/// Cancel all pending notifications.
Future<void> cancelAll() async {
await _plugin.cancelAll();
}
}

View File

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

View File

@@ -0,0 +1,126 @@
import 'package:adhan/adhan.dart' as adhan;
import 'package:hive_flutter/hive_flutter.dart';
import '../local/hive_boxes.dart';
import '../local/models/cached_prayer_times.dart';
import 'package:intl/intl.dart';
/// Result object for prayer times.
class PrayerTimesResult {
final DateTime fajr;
final DateTime sunrise;
final DateTime dhuhr;
final DateTime asr;
final DateTime maghrib;
final DateTime isha;
PrayerTimesResult({
required this.fajr,
required this.sunrise,
required this.dhuhr,
required this.asr,
required this.maghrib,
required this.isha,
});
}
/// Prayer time calculation service using the adhan package.
class PrayerService {
PrayerService._();
static final PrayerService instance = PrayerService._();
/// Calculate prayer times for a given location and date.
/// Uses cache if available; writes to cache after calculation.
PrayerTimesResult getPrayerTimes(double lat, double lng, DateTime date) {
final dateKey = DateFormat('yyyy-MM-dd').format(date);
final cacheKey = '${lat.toStringAsFixed(4)}_${lng.toStringAsFixed(4)}_$dateKey';
// Check cache
final cacheBox = Hive.box<CachedPrayerTimes>(HiveBoxes.cachedPrayerTimes);
final cached = cacheBox.get(cacheKey);
if (cached != null) {
return PrayerTimesResult(
fajr: cached.fajr,
sunrise: cached.sunrise,
dhuhr: cached.dhuhr,
asr: cached.asr,
maghrib: cached.maghrib,
isha: cached.isha,
);
}
// Calculate using adhan package
final coordinates = adhan.Coordinates(lat, lng);
final dateComponents = adhan.DateComponents(date.year, date.month, date.day);
final params = adhan.CalculationMethod.muslim_world_league.getParameters();
params.madhab = adhan.Madhab.shafi;
final prayerTimes = adhan.PrayerTimes(coordinates, dateComponents, params);
final result = PrayerTimesResult(
fajr: prayerTimes.fajr!,
sunrise: prayerTimes.sunrise!,
dhuhr: prayerTimes.dhuhr!,
asr: prayerTimes.asr!,
maghrib: prayerTimes.maghrib!,
isha: prayerTimes.isha!,
);
// Cache result
cacheBox.put(
cacheKey,
CachedPrayerTimes(
key: cacheKey,
lat: lat,
lng: lng,
date: dateKey,
fajr: result.fajr,
sunrise: result.sunrise,
dhuhr: result.dhuhr,
asr: result.asr,
maghrib: result.maghrib,
isha: result.isha,
),
);
return result;
}
/// Get the next prayer name and time from now.
MapEntry<String, DateTime>? getNextPrayer(PrayerTimesResult times) {
final now = DateTime.now();
final entries = {
'Fajr': times.fajr,
'Dhuhr': times.dhuhr,
'Asr': times.asr,
'Maghrib': times.maghrib,
'Isha': times.isha,
};
for (final entry in entries.entries) {
if (entry.value.isAfter(now)) {
return entry;
}
}
return null; // All prayers passed for today
}
/// Get the current active prayer (the last prayer whose time has passed).
String? getCurrentPrayer(PrayerTimesResult times) {
final now = DateTime.now();
String? current;
if (now.isAfter(times.isha)) {
current = 'Isha';
} else if (now.isAfter(times.maghrib)) {
current = 'Maghrib';
} else if (now.isAfter(times.asr)) {
current = 'Asr';
} else if (now.isAfter(times.dhuhr)) {
current = 'Dhuhr';
} else if (now.isAfter(times.fajr)) {
current = 'Fajr';
}
return current;
}
}

View File

@@ -0,0 +1,98 @@
import 'dart:convert';
import 'package:flutter/services.dart';
/// Represents a single Surah with its verses.
class Surah {
final int id;
final String nameArabic;
final String nameLatin;
final int verseCount;
final int juzStart;
final String revelationType;
final List<Verse> verses;
Surah({
required this.id,
required this.nameArabic,
required this.nameLatin,
required this.verseCount,
this.juzStart = 1,
this.revelationType = 'Meccan',
this.verses = const [],
});
factory Surah.fromJson(Map<String, dynamic> json) {
return Surah(
id: json['id'] as int,
nameArabic: json['name_arabic'] as String? ?? '',
nameLatin: json['name_latin'] as String? ?? '',
verseCount: json['verse_count'] as int? ?? 0,
juzStart: json['juz_start'] as int? ?? 1,
revelationType: json['revelation_type'] as String? ?? 'Meccan',
verses: (json['verses'] as List<dynamic>?)
?.map((v) => Verse.fromJson(v as Map<String, dynamic>))
.toList() ??
[],
);
}
}
/// A single Quran verse.
class Verse {
final int id;
final String arabic;
final String? transliteration;
final String translationId;
Verse({
required this.id,
required this.arabic,
this.transliteration,
required this.translationId,
});
factory Verse.fromJson(Map<String, dynamic> json) {
return Verse(
id: json['id'] as int,
arabic: json['arabic'] as String? ?? '',
transliteration: json['transliteration'] as String?,
translationId: json['translation_id'] as String? ?? '',
);
}
}
/// Service to load Quran data from bundled JSON asset.
class QuranService {
QuranService._();
static final QuranService instance = QuranService._();
List<Surah>? _cachedSurahs;
/// Load all 114 Surahs from local JSON. Cached in memory after first load.
Future<List<Surah>> getAllSurahs() async {
if (_cachedSurahs != null) return _cachedSurahs!;
try {
final jsonString =
await rootBundle.loadString('assets/quran/quran_id.json');
final List<dynamic> data = json.decode(jsonString);
_cachedSurahs = data
.map((s) => Surah.fromJson(s as Map<String, dynamic>))
.toList();
} catch (_) {
_cachedSurahs = [];
}
return _cachedSurahs!;
}
/// Get a single Surah by ID.
Future<Surah?> getSurah(int id) async {
final surahs = await getAllSurahs();
try {
return surahs.firstWhere((s) => s.id == id);
} catch (_) {
return null;
}
}
}

View File

@@ -0,0 +1,83 @@
import 'dart:convert';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http;
import 'package:hive_flutter/hive_flutter.dart';
/// Service for fetching Islamic-themed photos from Unsplash.
/// Implements aggressive caching to minimize API usage (1 request/day).
class UnsplashService {
static const String _baseUrl = 'https://api.unsplash.com';
static const String _cacheBoxName = 'unsplash_cache';
static const String _cacheKey = 'cached_photo';
static const String _cacheTimestampKey = 'cached_timestamp';
static const Duration _cacheTTL = Duration(hours: 24);
static final UnsplashService instance = UnsplashService._();
UnsplashService._();
// In-memory cache for the current session
Map<String, String>? _memoryCache;
/// Get a cached or fresh Islamic photo.
/// Returns a map with keys: 'imageUrl', 'photographerName', 'photographerUrl', 'unsplashUrl'
Future<Map<String, String>?> getIslamicPhoto() async {
// 1. Check memory cache
if (_memoryCache != null) return _memoryCache;
// 2. Check Hive cache
final box = await Hive.openBox(_cacheBoxName);
final cachedData = box.get(_cacheKey);
final cachedTimestamp = box.get(_cacheTimestampKey);
if (cachedData != null && cachedTimestamp != null) {
final cachedTime = DateTime.fromMillisecondsSinceEpoch(cachedTimestamp);
if (DateTime.now().difference(cachedTime) < _cacheTTL) {
_memoryCache = Map<String, String>.from(json.decode(cachedData));
return _memoryCache;
}
}
// 3. Fetch from API
final photo = await _fetchFromApi();
if (photo != null) {
// Cache in Hive
await box.put(_cacheKey, json.encode(photo));
await box.put(_cacheTimestampKey, DateTime.now().millisecondsSinceEpoch);
_memoryCache = photo;
}
return photo;
}
Future<Map<String, String>?> _fetchFromApi() async {
final accessKey = dotenv.env['UNSPLASH_ACCESS_KEY'];
if (accessKey == null || accessKey.isEmpty || accessKey == 'YOUR_ACCESS_KEY_HERE') {
return null;
}
try {
final queries = ['masjid', 'kaabah', 'mosque', 'islamic architecture'];
// Rotate query based on the day of year for variety
final dayOfYear = DateTime.now().difference(DateTime(DateTime.now().year, 1, 1)).inDays;
final query = queries[dayOfYear % queries.length];
final response = await http.get(
Uri.parse('$_baseUrl/photos/random?query=$query&orientation=portrait&content_filter=high'),
headers: {'Authorization': 'Client-ID $accessKey'},
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
return {
'imageUrl': data['urls']?['regular'] ?? '',
'photographerName': data['user']?['name'] ?? 'Unknown',
'photographerUrl': data['user']?['links']?['html'] ?? '',
'unsplashUrl': data['links']?['html'] ?? '',
};
}
} catch (e) {
// Silent fallback — show the equalizer without background
}
return null;
}
}