feat: Murattal player enhancements & prayer schedule auto-scroll
- Murattal: Spotify-style 5-button controls [Shuffle, Prev, Play, Next, Playlist] - Murattal: Animated 7-bar equalizer visualization in player circle - Murattal: Unsplash API background with frosted glass player overlay - Murattal: Transparent AppBar with backdrop blur - Murattal: Surah playlist bottom sheet with full 114 Surah list - Murattal: Auto-play disabled on screen open, enabled on navigation - Murattal: Shuffle mode for random Surah playback - Murattal: Photographer attribution per Unsplash guidelines - Dashboard: Auto-scroll prayer schedule to next active prayer - Fix: setState lifecycle errors on Reading & Murattal screens - Setup: flutter_dotenv, cached_network_image, url_launcher deps
This commit is contained in:
0
lib/data/services/.gitkeep
Normal file
0
lib/data/services/.gitkeep
Normal file
107
lib/data/services/dzikir_service.dart
Normal file
107
lib/data/services/dzikir_service.dart
Normal 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());
|
||||
}
|
||||
108
lib/data/services/equran_service.dart
Normal file
108
lib/data/services/equran_service.dart
Normal 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',
|
||||
};
|
||||
}
|
||||
86
lib/data/services/location_service.dart
Normal file
86
lib/data/services/location_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
108
lib/data/services/myquran_sholat_service.dart
Normal file
108
lib/data/services/myquran_sholat_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
98
lib/data/services/notification_service.dart
Normal file
98
lib/data/services/notification_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
1
lib/data/services/placeholder.dart
Normal file
1
lib/data/services/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
126
lib/data/services/prayer_service.dart
Normal file
126
lib/data/services/prayer_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
98
lib/data/services/quran_service.dart
Normal file
98
lib/data/services/quran_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
83
lib/data/services/unsplash_service.dart
Normal file
83
lib/data/services/unsplash_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user