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