Initial project import and stabilization baseline

This commit is contained in:
dwindown
2026-03-30 21:28:44 +07:00
commit ad33b01231
186 changed files with 20445 additions and 0 deletions

420
lib/data/local/models.dart Normal file
View File

@@ -0,0 +1,420 @@
import 'package:hive_flutter/hive_flutter.dart';
/// Hive type adapter IDs and box names.
class HiveBoxes {
HiveBoxes._();
static const String settings = 'app_settings';
static const String prayerSchedule = 'prayer_schedule';
}
/// AppSettings stored in Hive.
@HiveType(typeId: 0)
class AppSettings extends HiveObject {
@HiveField(0)
String masjidName;
@HiveField(1)
String masjidAddress;
@HiveField(2)
String cityIdApi; // myQuran city hash ID
@HiveField(3)
String cityDisplayName;
@HiveField(4)
bool showImsak;
@HiveField(5)
bool showTerbit;
// Iqomah durations in minutes
@HiveField(6)
int iqomahSubuh;
@HiveField(7)
int iqomahDzuhur;
@HiveField(8)
int iqomahAshar;
@HiveField(9)
int iqomahMaghrib;
@HiveField(10)
int iqomahIsya;
// Pre-Adzan lead time (minutes before adzan to lock main screen)
@HiveField(11)
int preAdzanLead;
// Blank screen durations
@HiveField(12)
int blankScreenNormal; // minutes
@HiveField(13)
int blankScreenJumat; // minutes
// Running text items
@HiveField(14)
List<String> runningTexts;
// Friday officers
@HiveField(15)
String khatibName;
@HiveField(16)
String imamName;
// Rotation settings
@HiveField(17)
int mainScreenDurationSec;
@HiveField(18)
int slideDurationSec;
// Last sync timestamp
@HiveField(19)
String? lastSyncDate;
// Slideshow image paths (local)
@HiveField(20)
List<String> slideshowImages;
// Text scaling (0=Small, 1=Medium, 2=Large)
@HiveField(21)
int textScaleIndex;
// Unsplash Background configs
@HiveField(22)
bool useUnsplashBackground;
@HiveField(23)
String unsplashKeyword;
@HiveField(24)
int unsplashRotationHours;
// Branded background image (local file path set by admin)
@HiveField(25)
String? brandedBgImage;
// Per-item duration for running texts (seconds each)
@HiveField(26)
List<int> runningTextDurations;
// Running text animation type: 'marquee' or 'fade'
@HiveField(27)
String marqueeAnimType;
// Granular text group scales (independent of textScaleIndex)
// Group: Prayer card label (e.g. "SUBUH", "DZUHUR")
@HiveField(28)
double scaleCardLabel;
// Group: Prayer card body (time + iqomah text)
@HiveField(29)
double scaleCardBody;
// Group: Running text ticker at bottom
@HiveField(30)
double scaleRunningText;
AppSettings({
this.masjidName = 'Masjid Al-Ikhlas',
this.masjidAddress = 'Jl. Kebaikan No. 1',
this.cityIdApi = '1218', // Default: Yogyakarta
this.cityDisplayName = 'Kota Yogyakarta',
this.showImsak = true,
this.showTerbit = true,
this.iqomahSubuh = 15,
this.iqomahDzuhur = 10,
this.iqomahAshar = 10,
this.iqomahMaghrib = 7,
this.iqomahIsya = 10,
this.preAdzanLead = 10,
this.blankScreenNormal = 15,
this.blankScreenJumat = 45,
this.runningTexts = const [
'Mohon luruskan dan rapatkan shaf',
'Kajian rutin setiap Ahad pagi',
],
this.khatibName = 'Ust. Fulan, S.Ag',
this.imamName = 'Ust. Alan, Lc',
this.mainScreenDurationSec = 15,
this.slideDurationSec = 10,
this.lastSyncDate,
this.slideshowImages = const [],
this.textScaleIndex = 1,
this.useUnsplashBackground = false,
this.unsplashKeyword = 'mosque',
this.unsplashRotationHours = 6,
this.brandedBgImage,
this.runningTextDurations = const [],
this.marqueeAnimType = 'marquee',
this.scaleCardLabel = 1.0,
this.scaleCardBody = 1.0,
this.scaleRunningText = 1.0,
});
AppSettings copyWith({
String? masjidName,
String? masjidAddress,
String? cityIdApi,
String? cityDisplayName,
bool? showImsak,
bool? showTerbit,
int? iqomahSubuh,
int? iqomahDzuhur,
int? iqomahAshar,
int? iqomahMaghrib,
int? iqomahIsya,
int? preAdzanLead,
int? blankScreenNormal,
int? blankScreenJumat,
List<String>? runningTexts,
String? khatibName,
String? imamName,
int? mainScreenDurationSec,
int? slideDurationSec,
String? lastSyncDate,
List<String>? slideshowImages,
int? textScaleIndex,
bool? useUnsplashBackground,
String? unsplashKeyword,
int? unsplashRotationHours,
String? brandedBgImage,
List<int>? runningTextDurations,
String? marqueeAnimType,
double? scaleCardLabel,
double? scaleCardBody,
double? scaleRunningText,
}) {
return AppSettings(
masjidName: masjidName ?? this.masjidName,
masjidAddress: masjidAddress ?? this.masjidAddress,
cityIdApi: cityIdApi ?? this.cityIdApi,
cityDisplayName: cityDisplayName ?? this.cityDisplayName,
showImsak: showImsak ?? this.showImsak,
showTerbit: showTerbit ?? this.showTerbit,
iqomahSubuh: iqomahSubuh ?? this.iqomahSubuh,
iqomahDzuhur: iqomahDzuhur ?? this.iqomahDzuhur,
iqomahAshar: iqomahAshar ?? this.iqomahAshar,
iqomahMaghrib: iqomahMaghrib ?? this.iqomahMaghrib,
iqomahIsya: iqomahIsya ?? this.iqomahIsya,
preAdzanLead: preAdzanLead ?? this.preAdzanLead,
blankScreenNormal: blankScreenNormal ?? this.blankScreenNormal,
blankScreenJumat: blankScreenJumat ?? this.blankScreenJumat,
runningTexts: runningTexts ?? this.runningTexts,
khatibName: khatibName ?? this.khatibName,
imamName: imamName ?? this.imamName,
mainScreenDurationSec: mainScreenDurationSec ?? this.mainScreenDurationSec,
slideDurationSec: slideDurationSec ?? this.slideDurationSec,
lastSyncDate: lastSyncDate ?? this.lastSyncDate,
slideshowImages: slideshowImages ?? this.slideshowImages,
textScaleIndex: textScaleIndex ?? this.textScaleIndex,
useUnsplashBackground: useUnsplashBackground ?? this.useUnsplashBackground,
unsplashKeyword: unsplashKeyword ?? this.unsplashKeyword,
unsplashRotationHours: unsplashRotationHours ?? this.unsplashRotationHours,
brandedBgImage: brandedBgImage ?? this.brandedBgImage,
runningTextDurations: runningTextDurations ?? this.runningTextDurations,
marqueeAnimType: marqueeAnimType ?? this.marqueeAnimType,
scaleCardLabel: scaleCardLabel ?? this.scaleCardLabel,
scaleCardBody: scaleCardBody ?? this.scaleCardBody,
scaleRunningText: scaleRunningText ?? this.scaleRunningText,
);
}
}
/// Adapter for AppSettings — hand-written to avoid code generation.
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++) {
fields[reader.readByte()] = reader.read();
}
return AppSettings(
masjidName: fields[0] as String? ?? 'Masjid Al-Ikhlas',
masjidAddress: fields[1] as String? ?? 'Jl. Kebaikan No. 1',
cityIdApi: fields[2] as String? ?? '1218',
cityDisplayName: fields[3] as String? ?? 'Kota Yogyakarta',
showImsak: fields[4] as bool? ?? true,
showTerbit: fields[5] as bool? ?? true,
iqomahSubuh: fields[6] as int? ?? 15,
iqomahDzuhur: fields[7] as int? ?? 10,
iqomahAshar: fields[8] as int? ?? 10,
iqomahMaghrib: fields[9] as int? ?? 7,
iqomahIsya: fields[10] as int? ?? 10,
preAdzanLead: fields[11] as int? ?? 10,
blankScreenNormal: fields[12] as int? ?? 15,
blankScreenJumat: fields[13] as int? ?? 45,
runningTexts: (fields[14] as List?)?.cast<String>() ?? const [],
khatibName: fields[15] as String? ?? '',
imamName: fields[16] as String? ?? '',
mainScreenDurationSec: fields[17] as int? ?? 15,
slideDurationSec: fields[18] as int? ?? 10,
lastSyncDate: fields[19] as String?,
slideshowImages: (fields[20] as List?)?.cast<String>() ?? const [],
textScaleIndex: fields[21] as int? ?? 1,
useUnsplashBackground: fields[22] as bool? ?? false,
unsplashKeyword: fields[23] as String? ?? 'mosque',
unsplashRotationHours: fields[24] as int? ?? 6,
brandedBgImage: fields[25] as String?,
runningTextDurations: (fields[26] as List?)?.cast<int>() ?? const [],
marqueeAnimType: fields[27] as String? ?? 'marquee',
scaleCardLabel: (fields[28] as num?)?.toDouble() ?? 1.0,
scaleCardBody: (fields[29] as num?)?.toDouble() ?? 1.0,
scaleRunningText: (fields[30] as num?)?.toDouble() ?? 1.0,
);
}
@override
void write(BinaryWriter writer, AppSettings obj) {
writer
..writeByte(31)
..writeByte(0)..write(obj.masjidName)
..writeByte(1)..write(obj.masjidAddress)
..writeByte(2)..write(obj.cityIdApi)
..writeByte(3)..write(obj.cityDisplayName)
..writeByte(4)..write(obj.showImsak)
..writeByte(5)..write(obj.showTerbit)
..writeByte(6)..write(obj.iqomahSubuh)
..writeByte(7)..write(obj.iqomahDzuhur)
..writeByte(8)..write(obj.iqomahAshar)
..writeByte(9)..write(obj.iqomahMaghrib)
..writeByte(10)..write(obj.iqomahIsya)
..writeByte(11)..write(obj.preAdzanLead)
..writeByte(12)..write(obj.blankScreenNormal)
..writeByte(13)..write(obj.blankScreenJumat)
..writeByte(14)..write(obj.runningTexts)
..writeByte(15)..write(obj.khatibName)
..writeByte(16)..write(obj.imamName)
..writeByte(17)..write(obj.mainScreenDurationSec)
..writeByte(18)..write(obj.slideDurationSec)
..writeByte(19)..write(obj.lastSyncDate)
..writeByte(20)..write(obj.slideshowImages)
..writeByte(21)..write(obj.textScaleIndex)
..writeByte(22)..write(obj.useUnsplashBackground)
..writeByte(23)..write(obj.unsplashKeyword)
..writeByte(24)..write(obj.unsplashRotationHours)
..writeByte(25)..write(obj.brandedBgImage)
..writeByte(26)..write(obj.runningTextDurations)
..writeByte(27)..write(obj.marqueeAnimType)
..writeByte(28)..write(obj.scaleCardLabel)
..writeByte(29)..write(obj.scaleCardBody)
..writeByte(30)..write(obj.scaleRunningText);
}
}
/// Daily prayer schedule row cached from the MyQuran API.
@HiveType(typeId: 1)
class DailyPrayerSchedule extends HiveObject {
@HiveField(0)
String date; // yyyy-MM-dd
@HiveField(1)
String imsak;
@HiveField(2)
String subuh;
@HiveField(3)
String terbit;
@HiveField(4)
String dhuha;
@HiveField(5)
String dzuhur;
@HiveField(6)
String ashar;
@HiveField(7)
String maghrib;
@HiveField(8)
String isya;
DailyPrayerSchedule({
required this.date,
required this.imsak,
required this.subuh,
required this.terbit,
required this.dhuha,
required this.dzuhur,
required this.ashar,
required this.maghrib,
required this.isya,
});
/// Parse time string "HH:mm" to a DateTime on the given date.
DateTime timeToDateTime(String time, DateTime refDate) {
final parts = time.split(':');
return DateTime(
refDate.year,
refDate.month,
refDate.day,
int.parse(parts[0]),
int.parse(parts[1]),
);
}
/// Get all prayer times as DateTime map for a given reference date.
Map<String, DateTime> toDateTimeMap(DateTime refDate) => {
'imsak': timeToDateTime(imsak, refDate),
'subuh': timeToDateTime(subuh, refDate),
'terbit': timeToDateTime(terbit, refDate),
'dhuha': timeToDateTime(dhuha, refDate),
'dzuhur': timeToDateTime(dzuhur, refDate),
'ashar': timeToDateTime(ashar, refDate),
'maghrib': timeToDateTime(maghrib, refDate),
'isya': timeToDateTime(isya, refDate),
};
}
/// Adapter for DailyPrayerSchedule.
class DailyPrayerScheduleAdapter extends TypeAdapter<DailyPrayerSchedule> {
@override
final int typeId = 1;
@override
DailyPrayerSchedule read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{};
for (int i = 0; i < numOfFields; i++) {
fields[reader.readByte()] = reader.read();
}
return DailyPrayerSchedule(
date: fields[0] as String? ?? '',
imsak: fields[1] as String? ?? '00:00',
subuh: fields[2] as String? ?? '00:00',
terbit: fields[3] as String? ?? '00:00',
dhuha: fields[4] as String? ?? '00:00',
dzuhur: fields[5] as String? ?? '00:00',
ashar: fields[6] as String? ?? '00:00',
maghrib: fields[7] as String? ?? '00:00',
isya: fields[8] as String? ?? '00:00',
);
}
@override
void write(BinaryWriter writer, DailyPrayerSchedule obj) {
writer
..writeByte(9)
..writeByte(0)..write(obj.date)
..writeByte(1)..write(obj.imsak)
..writeByte(2)..write(obj.subuh)
..writeByte(3)..write(obj.terbit)
..writeByte(4)..write(obj.dhuha)
..writeByte(5)..write(obj.dzuhur)
..writeByte(6)..write(obj.ashar)
..writeByte(7)..write(obj.maghrib)
..writeByte(8)..write(obj.isya);
}
}

View File

@@ -0,0 +1,111 @@
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.
///
/// Ported directly from the jamshalat-diary project.
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 — device is offline
}
return [];
}
/// Get prayer times for today.
/// [cityId] = myQuran city ID (hash string)
/// Returns map: {tanggal, imsak, subuh, terbit, dhuha, dzuhur, ashar, maghrib, isya}
Future<Map<String, String>?> getDailySchedule(String cityId) async {
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) {
final jadwalMap = data['data']['jadwal'] as Map<String, dynamic>;
if (jadwalMap.isNotEmpty) {
final firstKey = jadwalMap.keys.first;
final jadwal = jadwalMap[firstKey];
if (jadwal != null) {
final result = Map<String, String>.from(jadwal.map((k, v) => MapEntry(k.toString(), v.toString())));
result['date'] = firstKey;
return result;
}
}
}
}
} catch (e) {
// silent fallback
}
return null;
}
/// Get monthly prayer schedule (bulk fetch for offline caching).
/// [month] = 'yyyy-MM' format (e.g., '2024-03')
/// 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,46 @@
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/foundation.dart';
class SoundService {
SoundService._();
static final instance = SoundService._();
late AudioPlayer _player;
bool _initialized = false;
void init() {
if (_initialized) return;
_player = AudioPlayer();
// Pre-cache sounds by setting sources but not playing immediately if desired,
// though AudioCache is handled implicitly in newer audioplayers.
_player.setReleaseMode(ReleaseMode.stop);
_initialized = true;
}
Future<void> playAdzanBeep() async {
try {
if (!_initialized) init();
// Plays a single beep exactly when Adzan time hits
await _player.play(AssetSource('sounds/beep.mp3'));
} catch (e) {
debugPrint('[SoundService] Error playing adzan beep: $e');
}
}
Future<void> playIqomahCountdown() async {
try {
if (!_initialized) init();
// Plays the 3-beep countdown for the last 3 seconds of Iqamah
await _player.play(AssetSource('sounds/3-detik-countdown.mp3'));
} catch (e) {
debugPrint('[SoundService] Error playing iqomah countdown: $e');
}
}
void dispose() {
if (_initialized) {
_player.dispose();
_initialized = false;
}
}
}

View File

@@ -0,0 +1,93 @@
import 'package:hive_flutter/hive_flutter.dart';
import 'package:intl/intl.dart';
import '../local/models.dart';
import 'myquran_service.dart';
/// Service to sync monthly prayer data from MyQuran API → Hive.
class SyncService {
SyncService._();
static final SyncService instance = SyncService._();
/// Sync current month + next month prayer data for the configured city.
/// Returns true on success.
Future<bool> syncMonthlyData() async {
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = settingsBox.get('default');
if (settings == null) return false;
final cityId = settings.cityIdApi;
final now = DateTime.now();
final currentMonth = DateFormat('yyyy-MM').format(now);
// Also fetch next month for continuity
final nextMonthDate = DateTime(now.year, now.month + 1, 1);
final nextMonth = DateFormat('yyyy-MM').format(nextMonthDate);
final api = MyQuranSholatService.instance;
final scheduleBox = Hive.box<DailyPrayerSchedule>(HiveBoxes.prayerSchedule);
var success = false;
// Fetch current month
final currentData = await api.getMonthlySchedule(cityId, currentMonth);
if (currentData.isNotEmpty) {
for (final entry in currentData.entries) {
final jadwal = entry.value;
scheduleBox.put(
entry.key,
DailyPrayerSchedule(
date: entry.key,
imsak: jadwal['imsak'] ?? '00:00',
subuh: jadwal['subuh'] ?? '00:00',
terbit: jadwal['terbit'] ?? '00:00',
dhuha: jadwal['dhuha'] ?? '00:00',
dzuhur: jadwal['dzuhur'] ?? '00:00',
ashar: jadwal['ashar'] ?? '00:00',
maghrib: jadwal['maghrib'] ?? '00:00',
isya: jadwal['isya'] ?? '00:00',
),
);
}
success = true;
}
// Fetch next month
final nextData = await api.getMonthlySchedule(cityId, nextMonth);
if (nextData.isNotEmpty) {
for (final entry in nextData.entries) {
final jadwal = entry.value;
scheduleBox.put(
entry.key,
DailyPrayerSchedule(
date: entry.key,
imsak: jadwal['imsak'] ?? '00:00',
subuh: jadwal['subuh'] ?? '00:00',
terbit: jadwal['terbit'] ?? '00:00',
dhuha: jadwal['dhuha'] ?? '00:00',
dzuhur: jadwal['dzuhur'] ?? '00:00',
ashar: jadwal['ashar'] ?? '00:00',
maghrib: jadwal['maghrib'] ?? '00:00',
isya: jadwal['isya'] ?? '00:00',
),
);
}
}
if (success) {
settings.lastSyncDate = DateFormat('yyyy-MM-dd HH:mm').format(now);
await settings.save();
}
return success;
}
/// Get today's prayer schedule from local Hive cache.
DailyPrayerSchedule? getTodaySchedule([DateTime? targetDate]) {
final scheduleBox =
Hive.box<DailyPrayerSchedule>(HiveBoxes.prayerSchedule);
final dateToFetch = targetDate ?? DateTime.now();
final dateStr = DateFormat('yyyy-MM-dd').format(dateToFetch);
return scheduleBox.get(dateStr);
}
}

View File

@@ -0,0 +1,48 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
/// Service for fetching background portraits from the Unsplash API.
class UnsplashService {
static const String _clientId = 'BkgEMpfG_ReNpVwJcbgNx30IZXhoFoWwKgwbrPU0hq4';
static const String _baseUrl = 'https://api.unsplash.com';
static final UnsplashService instance = UnsplashService._();
UnsplashService._();
/// Fetches a list of highly compressed landscape URLs based on the given keyword.
Future<List<String>> fetchLandscapeBackgrounds(String keyword) async {
// Trim keyword and default to 'mosque' if empty
final query = keyword.trim().isEmpty ? 'mosque' : keyword.trim();
// Specifically requesting 'regular' size to fit 1080p elegantly while minimizing RAM overhead.
final url = Uri.parse('$_baseUrl/search/photos?query=$query&orientation=landscape&per_page=20');
try {
final response = await http.get(
url,
headers: {
'Authorization': 'Client-ID $_clientId',
'Accept-Version': 'v1',
},
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
final results = data['results'] as List<dynamic>? ?? [];
final urls = <String>[];
for (final item in results) {
final urlsMap = item['urls'] as Map<String, dynamic>?;
if (urlsMap != null && urlsMap.containsKey('regular')) {
urls.add(urlsMap['regular'].toString());
}
}
return urls;
}
} catch (e) {
// Offline or error — fail silently.
}
return [];
}
}