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

52
lib/core/enums.dart Normal file
View File

@@ -0,0 +1,52 @@
/// App-wide state enums for the screen state machine.
library;
/// The 6 states the TV display cycles through.
enum ScreenState {
/// Normal rotation between MainScreen and Slideshow.
normal,
/// Pre-Adzan: Lock to MainScreen, show countdown.
menujuAdzan,
/// Adzan alert: Full-screen takeover.
adzan,
/// Iqomah countdown (or Friday Khutbah info).
menujuIqomah,
/// Black screen during prayer.
shalat,
/// Transitioning back to normal after prayer.
kembaliNormal,
}
/// Named prayer slots used across the app.
enum PrayerName {
imsak,
subuh,
terbit,
dhuha,
dzuhur,
ashar,
maghrib,
isya;
/// Display label — handles Friday override.
String displayLabel({bool isFriday = false}) {
if (this == PrayerName.dzuhur && isFriday) return 'JUMAT';
return id[0].toUpperCase() + id.substring(1);
}
/// Safe string identifier to replace .name
String get id => toString().split('.').last;
/// Whether this is a fardhu prayer (has iqomah).
bool get isFardhu =>
this == PrayerName.subuh ||
this == PrayerName.dzuhur ||
this == PrayerName.ashar ||
this == PrayerName.maghrib ||
this == PrayerName.isya;
}

74
lib/core/hijri_date.dart Normal file
View File

@@ -0,0 +1,74 @@
class HijriDate {
final int year;
final int month;
final int day;
const HijriDate({
required this.year,
required this.month,
required this.day,
});
}
class HijriDateFormatter {
HijriDateFormatter._();
static const List<String> _monthNames = [
'Muharram',
'Safar',
'Rabiul Awal',
'Rabiul Akhir',
'Jumadil Awal',
'Jumadil Akhir',
'Rajab',
'Syaban',
'Ramadan',
'Syawal',
'Zulkaidah',
'Zulhijah',
];
static String format(DateTime date) {
final hijri = fromGregorian(date);
final monthName = _monthNames[hijri.month - 1];
return '${hijri.day} $monthName ${hijri.year} H';
}
// Tabular Islamic calendar conversion. This is deterministic and avoids
// adding a new dependency just to replace the hardcoded placeholder dates.
static HijriDate fromGregorian(DateTime date) {
final jd = _gregorianToJulianDay(date.year, date.month, date.day);
var l = jd - 1948440 + 10632;
final n = ((l - 1) ~/ 10631);
l = l - 10631 * n + 354;
final j = (((10985 - l) ~/ 5316) * ((50 * l) ~/ 17719)) +
((l ~/ 5670) * ((43 * l) ~/ 15238));
l = l -
(((30 - j) ~/ 15) * ((17719 * j) ~/ 50)) -
((j ~/ 16) * ((15238 * j) ~/ 43)) +
29;
final month = (24 * l) ~/ 709;
final day = l - ((709 * month) ~/ 24);
final year = 30 * n + j - 30;
return HijriDate(year: year, month: month, day: day);
}
static int _gregorianToJulianDay(int year, int month, int day) {
final a = (14 - month) ~/ 12;
final y = year + 4800 - a;
final m = month + 12 * a - 3;
return day +
((153 * m + 2) ~/ 5) +
365 * y +
(y ~/ 4) -
(y ~/ 100) +
(y ~/ 400) -
32045;
}
}

174
lib/core/sacred_tokens.dart Normal file
View File

@@ -0,0 +1,174 @@
/// Design tokens extracted from "The Sacred Horizon" Stitch design system.
/// These are the canonical color, typography, spacing, and styling constants.
library;
import 'package:flutter/material.dart';
// ──────────────────────────────────────────────
// COLOR TOKENS — "Masjid Twilight" palette
// ──────────────────────────────────────────────
class SacredColors {
SacredColors._();
// Core
static const Color primary = Color(0xFF88D982); // Living Green
static const Color primaryContainer = Color(0xFF004F11);
static const Color onPrimaryContainer = Color(0xFF72C36E);
static const Color secondary = Color(0xFFE9C349); // Sacred Gold
static const Color secondaryContainer = Color(0xFFAF8D11);
static const Color tertiary = Color(0xFFE9C400);
static const Color tertiaryContainer = Color(0xFFC8A900);
// Background & Surface hierarchy
static const Color background = Color(0xFF131313); // The Void
static const Color surface = Color(0xFF131313);
static const Color surfaceDim = Color(0xFF131313);
static const Color surfaceContainerLowest = Color(0xFF0E0E0E);
static const Color surfaceContainerLow = Color(0xFF1C1B1B);
static const Color surfaceContainer = Color(0xFF201F1F);
static const Color surfaceContainerHigh = Color(0xFF2A2A2A);
static const Color surfaceContainerHighest = Color(0xFF353534);
static const Color surfaceBright = Color(0xFF393939);
static const Color surfaceVariant = Color(0xFF353534);
// On tokens
static const Color onSurface = Color(0xFFE5E2E1);
static const Color onSurfaceVariant = Color(0xFFBFC9C4);
static const Color onBackground = Color(0xFFE5E2E1);
static const Color onPrimary = Color(0xFF003909);
static const Color onSecondary = Color(0xFF3C2F00);
// Outline
static const Color outline = Color(0xFF89938F);
static const Color outlineVariant = Color(0xFF3F4945);
// Error
static const Color error = Color(0xFFFFB4AB);
static const Color errorContainer = Color(0xFF93000A);
// Inverse
static const Color inverseSurface = Color(0xFFE5E2E1);
static const Color inversePrimary = Color(0xFF1B6D24);
// Special
static const Color blackScreen = Color(0xFF000000);
// Convenience: transparent glass for overlays
static Color get glass70 => surfaceVariant.withValues(alpha: 0.70);
static Color get glass60 => surfaceVariant.withValues(alpha: 0.60);
static Color get glass35 => surfaceVariant.withValues(alpha: 0.35);
}
// ──────────────────────────────────────────────
// TYPOGRAPHY
// ──────────────────────────────────────────────
class SacredTypography {
SacredTypography._();
static const String headlineFamily = 'Plus Jakarta Sans';
static const String bodyFamily = 'Manrope';
// Display — The Clock (heartbeat of the system)
static TextStyle clockDisplay(double fontSize) => TextStyle(
fontFamily: headlineFamily,
fontSize: fontSize,
fontWeight: FontWeight.w800,
letterSpacing: -0.02 * fontSize,
color: SacredColors.onSurface,
height: 1.0,
);
// Headline — Prayer Names
static TextStyle headline(double fontSize) => TextStyle(
fontFamily: headlineFamily,
fontSize: fontSize,
fontWeight: FontWeight.w700,
letterSpacing: -0.01 * fontSize,
color: SacredColors.onSurface,
);
// Title — Timings
static TextStyle title(double fontSize) => TextStyle(
fontFamily: bodyFamily,
fontSize: fontSize,
fontWeight: FontWeight.w600,
color: SacredColors.onSurface,
);
// Body
static TextStyle body(double fontSize) => TextStyle(
fontFamily: bodyFamily,
fontSize: fontSize,
fontWeight: FontWeight.w400,
color: SacredColors.onSurface,
);
// Label — metadata
static TextStyle label(double fontSize) => TextStyle(
fontFamily: bodyFamily,
fontSize: fontSize,
fontWeight: FontWeight.w500,
letterSpacing: 0.05 * fontSize,
color: SacredColors.onSurfaceVariant,
);
// Label caps (for TRACKING-WIDEST uppercase captions)
static TextStyle labelCaps(double fontSize) => TextStyle(
fontFamily: bodyFamily,
fontSize: fontSize,
fontWeight: FontWeight.w700,
letterSpacing: 0.2 * fontSize,
color: SacredColors.onSurfaceVariant,
);
}
// ──────────────────────────────────────────────
// SPACING — 8px grid
// ──────────────────────────────────────────────
class SacredSpacing {
SacredSpacing._();
static const double xs = 8;
static const double sm = 16;
static const double md = 24;
static const double lg = 32;
static const double xl = 48;
static const double xxl = 64;
static const double screenEdge = 64; // 4rem minimum from edges
}
// ──────────────────────────────────────────────
// RADII
// ──────────────────────────────────────────────
class SacredRadii {
SacredRadii._();
static const double sm = 4;
static const double md = 8;
static const double lg = 12;
static const double xl = 16;
static const double full = 9999;
}
// ──────────────────────────────────────────────
// TV SCALING
// ──────────────────────────────────────────────
class TVScale {
TVScale._();
/// Scale factor relative to 1920px baseline
static double of(BuildContext context) {
return MediaQuery.of(context).size.width / 1920;
}
static double fontSize(BuildContext context, double base) {
return base * of(context);
}
}

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 [];
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,330 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../core/sacred_tokens.dart';
import '../../providers.dart';
/// Full-screen Adzan alert with pulsing icon and glowing text.
class AdzanAlertScreen extends ConsumerWidget {
const AdzanAlertScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final screenData = ref.watch(screenStateProvider);
final clock = ref.watch(clockProvider).valueOrNull ?? DateTime.now();
final schedule = ref.watch(todayScheduleProvider);
final settings = ref.watch(settingsProvider);
final size = MediaQuery.of(context).size;
final s = size.width / 1920;
final prayerLabel = screenData.activePrayer
?.displayLabel(isFriday: screenData.isFriday) ??
'';
final timeStr =
'${clock.hour.toString().padLeft(2, '0')}:${clock.minute.toString().padLeft(2, '0')}';
final secStr = clock.second.toString().padLeft(2, '0');
final fs = s * ref.watch(textScaleProvider);
return Container(
color: SacredColors.background,
child: Stack(
children: [
// Background gradient
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment.center,
radius: 0.8,
colors: [
SacredColors.background,
SacredColors.background,
],
),
),
),
),
// Ghost mosque icon
Positioned(
left: 40 * s,
top: 0,
bottom: 0,
child: Center(
child: Opacity(
opacity: 0.03,
child: Icon(Icons.mosque, size: 500 * s,
color: SacredColors.onSurface),
),
),
),
// ── Header ──
Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 64 * s, vertical: 24 * s),
color: SacredColors.background,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
settings.masjidName,
style: GoogleFonts.plusJakartaSans(
fontSize: 32 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary,
),
),
Text(
settings.masjidAddress,
style: GoogleFonts.manrope(
fontSize: 14 * fs,
fontWeight: FontWeight.w500,
color: SacredColors.secondary,
),
),
],
),
],
),
),
),
// ── Central Alert Content ──
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Pulsing bell icon with glow
_PulsingIcon(scale: s),
SizedBox(height: 40 * s),
// "WAKTU ADZAN [PRAYER]"
Text(
'WAKTU ADZAN $prayerLabel',
style: GoogleFonts.plusJakartaSans(
fontSize: 80 * s,
fontWeight: FontWeight.w800,
color: SacredColors.secondary,
letterSpacing: -2 * s,
shadows: [
Shadow(
blurRadius: 40 * s,
color: SacredColors.secondary.withValues(alpha: 0.4),
),
],
),
),
SizedBox(height: 32 * s),
// Clock in pill
Container(
padding: EdgeInsets.symmetric(
horizontal: 48 * s, vertical: 20 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerHighest
.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(SacredRadii.full),
border: Border.all(
color: SacredColors.outlineVariant.withValues(alpha: 0.15),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
timeStr,
style: GoogleFonts.plusJakartaSans(
fontSize: 120 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary,
letterSpacing: -3 * s,
height: 1.0,
shadows: [
Shadow(
blurRadius: 30 * s,
color:
SacredColors.primary.withValues(alpha: 0.3),
),
],
),
),
SizedBox(width: 12 * s),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
secStr,
style: GoogleFonts.plusJakartaSans(
fontSize: 48 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurface
.withValues(alpha: 0.5),
),
),
Text(
'WIB',
style: GoogleFonts.manrope(
fontSize: 20 * s,
fontWeight: FontWeight.w500,
color: SacredColors.primary,
letterSpacing: 4 * s,
),
),
],
),
],
),
),
SizedBox(height: 40 * s),
// Sub-message
Text(
'Telah masuk waktu shalat $prayerLabel.\nSegera persiapkan diri menuju masjid.',
textAlign: TextAlign.center,
style: GoogleFonts.manrope(
fontSize: 24 * fs,
fontWeight: FontWeight.w500,
color: SacredColors.onSurface.withValues(alpha: 0.8),
height: 1.5,
),
),
],
),
),
// ── Footer: Prayer times strip ──
if (schedule != null)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _buildFooterStrip(s, fs, schedule, screenData),
),
],
),
);
}
Widget _buildFooterStrip(
double s, double fs, dynamic schedule, dynamic screenData) {
final prayers = {
'Subuh': schedule.subuh,
'Dzuhur': schedule.dzuhur,
'Ashar': schedule.ashar,
'Maghrib': schedule.maghrib,
'Isya': schedule.isya,
};
return Container(
padding: EdgeInsets.symmetric(horizontal: 64 * s, vertical: 28 * s),
color: SacredColors.surfaceContainerLow.withValues(alpha: 0.8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: prayers.entries.map((e) {
final isActive = screenData.activePrayer?.id == e.key.toLowerCase();
return Column(
children: [
Text(
e.key.toUpperCase(),
style: GoogleFonts.manrope(
fontSize: 12 * fs,
fontWeight: FontWeight.w700,
color: isActive
? SacredColors.primary
: SacredColors.onSurface.withValues(alpha: 0.4),
letterSpacing: 2 * s,
),
),
SizedBox(height: 4 * s),
Text(
e.value,
style: GoogleFonts.plusJakartaSans(
fontSize: isActive ? 32 * fs : 28 * fs,
fontWeight: isActive ? FontWeight.w700 : FontWeight.w600,
color: isActive
? SacredColors.primary
: SacredColors.onSurface,
),
),
],
);
}).toList(),
),
);
}
}
class _PulsingIcon extends StatefulWidget {
final double scale;
const _PulsingIcon({required this.scale});
@override
State<_PulsingIcon> createState() => _PulsingIconState();
}
class _PulsingIconState extends State<_PulsingIcon>
with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat(reverse: true);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final s = widget.scale;
return AnimatedBuilder(
animation: _ctrl,
builder: (context, child) {
return Stack(
alignment: Alignment.center,
children: [
Container(
width: 200 * s,
height: 200 * s,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: SacredColors.secondary
.withValues(alpha: 0.1 * _ctrl.value),
boxShadow: [
BoxShadow(
blurRadius: 60 * s * _ctrl.value,
color: SacredColors.secondary.withValues(alpha: 0.1),
),
],
),
),
Icon(
Icons.notifications_active,
size: 120 * s,
color: SacredColors.secondary,
),
],
);
},
);
}
}

View File

@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../core/sacred_tokens.dart';
import '../../providers.dart';
/// Minimal black screen during prayer — absolute zero distraction.
class BlackScreen extends ConsumerWidget {
const BlackScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final screenData = ref.watch(screenStateProvider);
final size = MediaQuery.of(context).size;
final s = size.width / 1920;
final prayerLabel = screenData.activePrayer
?.displayLabel(isFriday: screenData.isFriday) ??
'';
return Container(
color: SacredColors.blackScreen,
child: Stack(
children: [
// Extremely subtle radial tonal shift
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment.center,
radius: 1.2,
colors: [
SacredColors.primaryContainer.withValues(alpha: 0.03),
SacredColors.blackScreen,
],
),
),
),
),
// Center: prayer name + status (extremely dim)
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Opacity(
opacity: 0.10,
child: Text(
prayerLabel.toUpperCase(),
style: GoogleFonts.plusJakartaSans(
fontSize: 96 * s,
fontWeight: FontWeight.w300,
color: SacredColors.onSurface,
letterSpacing: 20 * s,
),
),
),
SizedBox(height: 16 * s),
Opacity(
opacity: 0.08,
child: Text(
'SHALAT SEDANG BERLANGSUNG',
style: GoogleFonts.plusJakartaSans(
fontSize: 14 * s,
fontWeight: FontWeight.w500,
color: SacredColors.onSurface,
letterSpacing: 6 * s,
),
),
),
],
),
),
// Bottom advisory — barely visible
Positioned(
bottom: 64 * s,
left: 0,
right: 0,
child: Center(
child: Opacity(
opacity: 0.06,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.phonelink_ring,
size: 14 * s, color: SacredColors.onSurface),
SizedBox(width: 8 * s),
Text(
'MOHON NONAKTIFKAN ALAT KOMUNIKASI',
style: GoogleFonts.manrope(
fontSize: 10 * s,
fontWeight: FontWeight.w500,
color: SacredColors.onSurface,
letterSpacing: 3 * s,
),
),
],
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/services/sync_service.dart';
import '../../data/services/sound_service.dart';
import '../../core/enums.dart';
import '../../core/sacred_tokens.dart';
import '../../providers.dart';
import 'main_screen.dart';
import 'adzan_screen.dart';
import 'iqomah_screen.dart';
import 'black_screen.dart';
import 'slideshow_screen.dart';
import 'jumat_screen.dart';
import 'khutbah_screen.dart';
/// The root view that orchestrates all screen states via AnimatedSwitcher.
class HomeView extends ConsumerStatefulWidget {
const HomeView({super.key});
@override
ConsumerState<HomeView> createState() => _HomeViewState();
}
class _HomeViewState extends ConsumerState<HomeView> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkAutoSync();
});
}
Future<void> _checkAutoSync() async {
final schedule = ref.read(todayScheduleProvider);
if (schedule == null) {
debugPrint('[AutoSync] No schedule found for today! Starting auto-sync...');
final success = await SyncService.instance.syncMonthlyData();
if (success && mounted) {
debugPrint('[AutoSync] Success! Invalidating todayScheduleProvider.');
ref.invalidate(todayScheduleProvider);
} else {
debugPrint('[AutoSync] Failed or data remained empty.');
}
}
}
@override
Widget build(BuildContext context) {
// Audio trigger listener
ref.listen<ScreenStateData>(screenStateProvider, (previous, next) {
if (previous == null) return;
// TRIGGER 1: Adzan Beep (Fires precisely when transitioning to Adzan)
if (previous.state != ScreenState.adzan && next.state == ScreenState.adzan) {
SoundService.instance.playAdzanBeep();
}
// TRIGGER 2: 3-Second Iqomah Countdown
if (next.state == ScreenState.menujuIqomah && next.iqomahRemaining != null) {
// Play precisely on the tick where it is 3 seconds.
if (previous.iqomahRemaining?.inSeconds != 3 && next.iqomahRemaining!.inSeconds == 3) {
SoundService.instance.playIqomahCountdown();
}
}
});
final screenData = ref.watch(screenStateProvider);
final isMainScreen = ref.watch(isMainScreenProvider);
// Determine which screen to display
Widget screen;
switch (screenData.state) {
case ScreenState.normal:
case ScreenState.menujuAdzan:
if (screenData.isFriday && screenData.nextPrayer?.id == 'dzuhur') {
screen = const JumatScreen(key: ValueKey('jumat'));
} else {
screen = isMainScreen
? const MainScreen(key: ValueKey('main'))
: const SlideshowScreen(key: ValueKey('slideshow'));
}
break;
case ScreenState.kembaliNormal:
screen = const MainScreen(key: ValueKey('main'));
break;
case ScreenState.adzan:
screen = const AdzanAlertScreen(key: ValueKey('adzan'));
break;
case ScreenState.menujuIqomah:
if (screenData.isFriday && screenData.activePrayer?.id == 'dzuhur') {
screen = const KhutbahScreen(key: ValueKey('khutbah'));
} else {
screen = const IqomahScreen(key: ValueKey('iqomah'));
}
break;
case ScreenState.shalat:
screen = const BlackScreen(key: ValueKey('black'));
break;
}
final isSimulating = ref.watch(mockTimeOffsetProvider) != Duration.zero;
return Scaffold(
backgroundColor: SacredColors.background,
body: Stack(
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 800),
transitionBuilder: (child, animation) {
return FadeTransition(opacity: animation, child: child);
},
child: screen,
),
if (isSimulating)
Positioned(
right: 64,
bottom: 64,
child: ElevatedButton.icon(
onPressed: () {
ref.read(mockTimeOffsetProvider.notifier).state = Duration.zero;
},
icon: const Icon(Icons.cancel, color: Colors.white),
label: const Text(
'BATALKAN SIMULASI',
style: TextStyle(
fontWeight: FontWeight.bold,
letterSpacing: 2,
color: Colors.white,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade800,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 10,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,367 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../core/sacred_tokens.dart';
import '../../providers.dart';
/// FORMAT HELPER: Duration → "MM:SS"
String _fmtCountdown(Duration d) {
final m = d.inMinutes.toString().padLeft(2, '0');
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
return '$m:$s';
}
/// Iqomah countdown screen — and Friday Khutbah info override.
class IqomahScreen extends ConsumerWidget {
const IqomahScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final screenData = ref.watch(screenStateProvider);
final settings = ref.watch(settingsProvider);
final size = MediaQuery.of(context).size;
final s = size.width / 1920;
final prayerLabel = screenData.activePrayer
?.displayLabel(isFriday: screenData.isFriday) ??
'';
final countdown = screenData.iqomahRemaining ?? Duration.zero;
final isFridayDzuhur =
screenData.isFriday && screenData.activePrayer?.id == 'dzuhur';
return Container(
color: SacredColors.background,
child: Stack(
children: [
// Subtle radial glow
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment.center,
radius: 0.7,
colors: [
SacredColors.primary.withValues(alpha: 0.08),
SacredColors.background,
],
),
),
),
),
// ── Content ──
Padding(
padding: EdgeInsets.all(64 * s),
child: Column(
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
settings.masjidName.toUpperCase(),
style: GoogleFonts.plusJakartaSans(
fontSize: 36 * s,
fontWeight: FontWeight.w800,
color: SacredColors.primary,
letterSpacing: -1 * s,
),
),
Text(
settings.masjidAddress,
style: GoogleFonts.manrope(
fontSize: 12 * s,
color: SacredColors.onSurface.withValues(alpha: 0.6),
letterSpacing: 4 * s,
),
),
],
),
],
),
// ── Center: Countdown ──
Expanded(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Prayer name pill
Text(
'SHALAT SAAT INI',
style: GoogleFonts.manrope(
fontSize: 16 * s,
fontWeight: FontWeight.w500,
color: SacredColors.onSurface.withValues(alpha: 0.5),
letterSpacing: 8 * s,
),
),
SizedBox(height: 12 * s),
Container(
padding: EdgeInsets.symmetric(
horizontal: 48 * s, vertical: 16 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerHighest,
borderRadius:
BorderRadius.circular(SacredRadii.full),
border: Border.all(
color: SacredColors.primary.withValues(alpha: 0.1),
),
),
child: Text(
prayerLabel.toUpperCase(),
style: GoogleFonts.plusJakartaSans(
fontSize: 56 * s,
fontWeight: FontWeight.w800,
color: SacredColors.primary,
letterSpacing: 2 * s,
),
),
),
SizedBox(height: 32 * s),
// Title
Text(
isFridayDzuhur
? 'PERSIAPAN KHUTBAH'
: 'MENUJU IQOMAH',
style: GoogleFonts.plusJakartaSans(
fontSize: 36 * s,
fontWeight: FontWeight.w700,
color: SacredColors.secondary,
letterSpacing: 4 * s,
),
),
SizedBox(height: 16 * s),
// Giant timer
Text(
_fmtCountdown(countdown),
style: GoogleFonts.plusJakartaSans(
fontSize: 280 * s,
fontWeight: FontWeight.w800,
color: SacredColors.onSurface,
letterSpacing: -8 * s,
height: 1.0,
shadows: [
Shadow(
blurRadius: 40 * s,
color:
SacredColors.primary.withValues(alpha: 0.3),
),
],
),
),
// Pulsing status pill
_StatusPill(
label: 'SIAPKAN DIRI ANDA',
scale: s,
),
// Friday officers info
if (isFridayDzuhur) ...[
SizedBox(height: 32 * s),
_FridayOfficers(settings: settings, scale: s),
],
],
),
),
),
// Hadith reminder
Container(
padding: EdgeInsets.all(32 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerLow.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(SacredRadii.xl),
border: Border(
left: BorderSide(
color: SacredColors.secondary, width: 4 * s),
),
),
child: Text(
'"Luruskan dan Rapatkan Shaf, Sesungguhnya lurusnya shaf termasuk kesempurnaan shalat."',
style: GoogleFonts.plusJakartaSans(
fontSize: 28 * s,
fontWeight: FontWeight.w500,
color: SacredColors.onSurface,
fontStyle: FontStyle.italic,
height: 1.5,
),
),
),
],
),
),
],
),
);
}
}
class _StatusPill extends StatefulWidget {
final String label;
final double scale;
const _StatusPill({required this.label, required this.scale});
@override
State<_StatusPill> createState() => _StatusPillState();
}
class _StatusPillState extends State<_StatusPill>
with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this, duration: const Duration(seconds: 2))
..repeat(reverse: true);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final s = widget.scale;
return FadeTransition(
opacity: Tween(begin: 0.5, end: 1.0).animate(_ctrl),
child: Container(
margin: EdgeInsets.only(top: 16 * s),
padding:
EdgeInsets.symmetric(horizontal: 32 * s, vertical: 12 * s),
decoration: BoxDecoration(
color: SacredColors.secondary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(SacredRadii.full),
border: Border.all(
color: SacredColors.secondary.withValues(alpha: 0.2),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.notifications_active,
color: SacredColors.secondary, size: 20 * s),
SizedBox(width: 8 * s),
Text(
widget.label,
style: GoogleFonts.plusJakartaSans(
fontSize: 16 * s,
fontWeight: FontWeight.w700,
color: SacredColors.secondary,
letterSpacing: 3 * s,
),
),
],
),
),
);
}
}
class _FridayOfficers extends StatelessWidget {
final dynamic settings;
final double scale;
const _FridayOfficers({required this.settings, required this.scale});
@override
Widget build(BuildContext context) {
final s = scale;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
_OfficerCard(
icon: Icons.person_pin,
label: 'KHATIB',
name: settings.khatibName,
color: SacredColors.primary,
scale: s,
),
SizedBox(width: 24 * s),
_OfficerCard(
icon: Icons.timer,
label: 'IMAM',
name: settings.imamName,
color: SacredColors.secondary,
scale: s,
),
],
);
}
}
class _OfficerCard extends StatelessWidget {
final IconData icon;
final String label;
final String name;
final Color color;
final double scale;
const _OfficerCard({
required this.icon,
required this.label,
required this.name,
required this.color,
required this.scale,
});
@override
Widget build(BuildContext context) {
final s = scale;
return Container(
padding: EdgeInsets.all(24 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerHigh.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(SacredRadii.xl),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: EdgeInsets.all(12 * s),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(SacredRadii.lg),
),
child: Icon(icon, color: color, size: 24 * s),
),
SizedBox(width: 16 * s),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: GoogleFonts.manrope(
fontSize: 10 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurface.withValues(alpha: 0.4),
letterSpacing: 3 * s,
),
),
Text(
name,
style: GoogleFonts.plusJakartaSans(
fontSize: 20 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurface,
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,530 @@
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hugeicons/hugeicons.dart';
import '../../core/hijri_date.dart';
import '../../core/sacred_tokens.dart';
import '../../providers.dart';
import '../../data/local/models.dart';
import '../admin/admin_screen.dart';
import 'unsplash_background.dart';
/// A highly polished, dedicated screen displayed specifically on Fridays
/// when transitioning towards Dzuhur (Jumat) prayer.
class JumatScreen extends ConsumerWidget {
const JumatScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final clock = ref.watch(clockProvider).valueOrNull ?? DateTime.now();
final schedule = ref.watch(todayScheduleProvider);
final settings = ref.watch(settingsProvider);
final screenData = ref.watch(screenStateProvider);
final size = MediaQuery.of(context).size;
final s = size.width / 1920;
final fs = s * ref.watch(textScaleProvider);
final timeStr = DateFormat('HH:mm').format(clock);
final secStr = DateFormat(':ss').format(clock);
final dateGregorian = DateFormat('EEEE, d MMMM yyyy', 'en').format(clock);
final dateHijri = HijriDateFormatter.format(clock);
final durToKhutbah = screenData.timeUntilNext ?? const Duration(minutes: 0);
final minToKhutbah = durToKhutbah.inMinutes;
return Container(
color: SacredColors.background,
child: Stack(
children: [
// ── Underlay: Branded local image or Unsplash ──
if (settings.brandedBgImage != null && settings.brandedBgImage!.isNotEmpty)
Positioned.fill(
child: Image.file(
File(settings.brandedBgImage!),
fit: BoxFit.cover,
color: Colors.black.withValues(alpha: 0.55),
colorBlendMode: BlendMode.darken,
),
)
else
const UnsplashBackground(),
// ── Background darkness gradient ──
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
SacredColors.background.withValues(alpha: 0.8),
Colors.transparent,
SacredColors.background.withValues(alpha: 0.9),
],
stops: const [0.0, 0.4, 1.0],
),
),
),
),
// ── Header Shell ──
Positioned(
top: 48 * s,
left: 64 * s,
right: 64 * s,
child: _buildHeader(context, settings, s),
),
// ── Main Content Canvas ──
Positioned.fill(
top: 140 * s,
bottom: 240 * s,
left: 64 * s,
right: 64 * s,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// ── Left Column: Clock & Primary Focus ──
Expanded(
flex: 2,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Pill
Container(
padding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 8 * s),
decoration: BoxDecoration(
color: SacredColors.secondary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(SacredRadii.full),
border: Border.all(color: SacredColors.secondary.withValues(alpha: 0.2)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_PulsingDot(color: SacredColors.secondary, size: 12 * s),
SizedBox(width: 12 * s),
Text(
'PERSIAPAN JUMAT',
style: GoogleFonts.plusJakartaSans(
fontSize: 16 * s,
fontWeight: FontWeight.w700,
color: SacredColors.secondary,
letterSpacing: 3 * s,
),
),
],
),
),
SizedBox(height: 16 * s),
// Massive Clock
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
timeStr,
style: GoogleFonts.plusJakartaSans(
fontSize: 192 * s,
fontWeight: FontWeight.w800,
color: SacredColors.primary,
letterSpacing: -5 * s,
height: 1.0,
),
),
Padding(
padding: EdgeInsets.only(top: 24 * s),
child: Text(
secStr,
style: GoogleFonts.plusJakartaSans(
fontSize: 48 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary.withValues(alpha: 0.7),
),
),
),
],
),
SizedBox(height: 16 * s),
// Dates
Text(
dateGregorian,
style: GoogleFonts.plusJakartaSans(
fontSize: 36 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurface,
),
),
SizedBox(height: 4 * s),
Text(
dateHijri,
style: GoogleFonts.manrope(
fontSize: 24 * s,
fontWeight: FontWeight.w500,
color: SacredColors.secondary.withValues(alpha: 0.9),
),
),
],
),
),
// ── Right Column: Khutbah Info Card ──
Expanded(
flex: 1,
child: Container(
padding: EdgeInsets.all(40 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerHigh.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(SacredRadii.xl),
border: Border.all(color: Colors.white.withValues(alpha: 0.05)),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(SacredRadii.xl),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'NEXT EVENT',
style: GoogleFonts.manrope(
fontSize: 14 * s,
fontWeight: FontWeight.w600,
color: SacredColors.onSurfaceVariant,
letterSpacing: 3 * s,
),
),
HugeIcon(icon: HugeIcons.strokeRoundedSparkles, color: SacredColors.secondary, size: 24 * s),
],
),
SizedBox(height: 16 * s),
Text(
'MENUJU KHUTBAH',
style: GoogleFonts.plusJakartaSans(
fontSize: 42 * s,
fontWeight: FontWeight.w800,
color: SacredColors.onSurface,
height: 1.1,
),
),
SizedBox(height: 32 * s),
// Khatib Info
_buildInfoTile(
s,
icon: Icons.person_pin,
color: SacredColors.primary,
label: 'KHATIB HARI INI',
value: settings.khatibName.isEmpty ? 'Belum Diatur' : settings.khatibName
),
SizedBox(height: 24 * s),
// Countdown Info
_buildInfoTile(
s,
icon: Icons.timer,
color: SacredColors.secondary,
label: 'KHUTBAH DIMULAI DALAM',
value: minToKhutbah > 0 ? '~ $minToKhutbah Menit' : 'Sebentar Lagi'
),
],
),
),
),
),
),
],
),
),
// ── Bottom Navigation Shell (Prayer Times) ──
if (schedule != null)
Positioned(
left: 64 * s,
right: 64 * s,
bottom: 64 * s,
child: _buildPrayerTimesRow(s, schedule),
),
// ── Footer Marquee Shell ──
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _buildMarquee(s, fs, settings),
),
],
),
);
}
Widget _buildHeader(BuildContext context, AppSettings settings, double s) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Mosque Name
GestureDetector(
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const AdminScreen())),
child: Text(
settings.masjidName,
style: GoogleFonts.plusJakartaSans(
fontSize: 32 * s,
fontWeight: FontWeight.w800,
color: SacredColors.primary,
),
),
),
// Mosque Address
Row(
children: [
HugeIcon(icon: HugeIcons.strokeRoundedMosque01, color: SacredColors.primary, size: 24 * s),
SizedBox(width: 8 * s),
Text(
settings.masjidAddress,
style: GoogleFonts.manrope(
fontSize: 18 * s,
fontWeight: FontWeight.w500,
color: SacredColors.onSurfaceVariant,
),
),
],
),
],
);
}
Widget _buildInfoTile(double s, {required IconData icon, required Color color, required String label, required String value}) {
return Container(
padding: EdgeInsets.all(20 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerHighest.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(SacredRadii.xl),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: EdgeInsets.all(12 * s),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(SacredRadii.md),
),
child: Icon(icon, color: color, size: 24 * s),
),
SizedBox(width: 16 * s),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: GoogleFonts.manrope(
fontSize: 12 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurfaceVariant,
letterSpacing: 2 * s,
),
),
SizedBox(height: 4 * s),
Text(
value,
style: GoogleFonts.plusJakartaSans(
fontSize: 22 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurface,
),
),
],
),
),
],
),
);
}
Widget _buildPrayerTimesRow(double s, DailyPrayerSchedule schedule) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 16 * s, vertical: 16 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerLow,
borderRadius: BorderRadius.circular(SacredRadii.xl),
boxShadow: [
BoxShadow(
color: SacredColors.primary.withValues(alpha: 0.1),
blurRadius: 40 * s,
spreadRadius: 0,
),
],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildTimeItem(s, 'Fajr', schedule.subuh, Icons.brightness_3, false),
_buildTimeItem(s, 'Terbit', schedule.terbit, Icons.wb_twilight, false),
_buildTimeItem(s, 'JUMAT', schedule.dzuhur, Icons.wb_sunny, true),
_buildTimeItem(s, 'Asr', schedule.ashar, Icons.sunny_snowing, false),
_buildTimeItem(s, 'Maghrib', schedule.maghrib, Icons.wb_cloudy, false),
_buildTimeItem(s, 'Isha', schedule.isya, Icons.bedtime, false),
],
),
);
}
Widget _buildTimeItem(double s, String name, String time, IconData icon, bool isJumat) {
if (isJumat) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 48 * s, vertical: 12 * s),
decoration: BoxDecoration(
color: SacredColors.primary.withValues(alpha: 0.15),
border: Border.all(color: SacredColors.primary.withValues(alpha: 0.3)),
borderRadius: BorderRadius.circular(SacredRadii.xl),
),
child: Column(
children: [
Icon(icon, color: SacredColors.primary, size: 28 * s),
SizedBox(height: 8 * s),
Text(name, style: GoogleFonts.manrope(fontSize: 18 * s, fontWeight: FontWeight.w800, color: SacredColors.primary)),
SizedBox(height: 4 * s),
Text(time, style: GoogleFonts.plusJakartaSans(fontSize: 16 * s, fontWeight: FontWeight.w700, color: SacredColors.primary)),
],
),
);
}
return Padding(
padding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 8 * s),
child: Column(
children: [
Icon(icon, color: SacredColors.onSurface.withValues(alpha: 0.6), size: 24 * s),
SizedBox(height: 8 * s),
Text(name, style: GoogleFonts.manrope(fontSize: 16 * s, fontWeight: FontWeight.w500, color: SacredColors.onSurface.withValues(alpha: 0.6))),
SizedBox(height: 4 * s),
Text(time, style: GoogleFonts.plusJakartaSans(fontSize: 16 * s, fontWeight: FontWeight.w700, color: SacredColors.onSurface)),
],
),
);
}
Widget _buildMarquee(double s, double fs, AppSettings settings) {
// Quick custom simplified marquee or fallback to settings.runningTexts
final texts = settings.runningTexts.isEmpty
? ["JUMAT MUBARAK: Luruskan dan rapatkan shaf. Harap non-aktifkan alat komunikasi."]
: settings.runningTexts;
return Container(
width: double.infinity,
height: 44 * s,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
),
child: ClipRect(
child: _JumatMarquee(
texts: texts,
s: s,
),
),
);
}
}
class _PulsingDot extends StatefulWidget {
final Color color;
final double size;
const _PulsingDot({required this.color, required this.size});
@override
State<_PulsingDot> createState() => _PulsingDotState();
}
class _PulsingDotState extends State<_PulsingDot> with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 1200))..repeat();
}
@override
void dispose() { _ctrl.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
return SizedBox(
width: widget.size, height: widget.size,
child: Stack(
children: [
FadeTransition(
opacity: Tween(begin: 0.75, end: 0.0).animate(_ctrl),
child: ScaleTransition(
scale: Tween(begin: 1.0, end: 2.0).animate(_ctrl),
child: Container(decoration: BoxDecoration(shape: BoxShape.circle, color: widget.color)),
),
),
Center(child: Container(width: widget.size, height: widget.size, decoration: BoxDecoration(shape: BoxShape.circle, color: widget.color))),
],
),
);
}
}
class _JumatMarquee extends StatefulWidget {
final List<String> texts;
final double s;
const _JumatMarquee({required this.texts, required this.s});
@override
State<_JumatMarquee> createState() => _JumatMarqueeState();
}
class _JumatMarqueeState extends State<_JumatMarquee> with TickerProviderStateMixin {
late AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: const Duration(seconds: 30))..repeat();
}
@override
void dispose() { _ctrl.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
final joined = widget.texts.join("");
final style = GoogleFonts.manrope(
fontSize: 16 * widget.s,
fontWeight: FontWeight.w600,
color: SacredColors.secondary,
letterSpacing: 2 * widget.s,
);
return LayoutBuilder(
builder: (context, constraints) {
return Stack(
children: [
AnimatedBuilder(
animation: _ctrl,
builder: (ctx, child) {
// Ensure endless scroll mathematically
return Positioned(
left: -(_ctrl.value * constraints.maxWidth),
top: 0, bottom: 0,
child: Row(
children: [
Container(alignment: Alignment.centerLeft, width: constraints.maxWidth, child: Text(joined, style: style, maxLines: 1)),
Container(alignment: Alignment.centerLeft, width: constraints.maxWidth, child: Text(joined, style: style, maxLines: 1)),
],
),
);
},
),
],
);
}
);
}
}

View File

@@ -0,0 +1,202 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../core/sacred_tokens.dart';
import '../../providers.dart';
/// Screen displayed uniquely during the Friday Khutbah.
/// It acts like a black (shalat) screen to avoid distraction,
/// but features the active Khatib and Muadzin information and a pulsing indicator.
/// NO COUNTDOWNS are displayed.
class KhutbahScreen extends ConsumerWidget {
const KhutbahScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final size = MediaQuery.of(context).size;
final s = size.width / 1920;
return Container(
color: Colors.black,
child: Stack(
children: [
// Absolute center elements
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Animated Pulsing Status Pill
Container(
padding: EdgeInsets.symmetric(horizontal: 40 * s, vertical: 16 * s),
decoration: BoxDecoration(
color: SacredColors.background,
borderRadius: BorderRadius.circular(SacredRadii.full),
border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_PulsingMic(size: 24 * s, color: SacredColors.secondary),
SizedBox(width: 16 * s),
Text(
'SEDANG KHUTBAH JUMAT',
style: GoogleFonts.plusJakartaSans(
fontSize: 24 * s,
fontWeight: FontWeight.w800,
color: SacredColors.secondary,
letterSpacing: 4 * s,
),
),
],
),
),
SizedBox(height: 64 * s),
// Officer Info Cards
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildKhutbahOfficerCard(
s: s,
icon: Icons.person_pin,
label: 'KHATIB',
name: settings.khatibName.isEmpty ? 'Khatib Hari Ini' : settings.khatibName,
color: SacredColors.primary,
),
SizedBox(width: 48 * s),
_buildKhutbahOfficerCard(
s: s,
icon: Icons.record_voice_over,
label: 'MUADZIN / IMAM',
name: settings.imamName.isEmpty ? 'Muadzin Hari Ini' : settings.imamName,
color: SacredColors.secondary,
),
],
),
SizedBox(height: 80 * s),
Text(
'"Harap menjaga ketenangan dan menyimak Khutbah."',
style: GoogleFonts.manrope(
fontSize: 20 * s,
fontWeight: FontWeight.w500,
color: SacredColors.onSurfaceVariant.withValues(alpha: 0.5),
fontStyle: FontStyle.italic,
),
)
],
),
),
// Simple corner logo/name so it doesn't feel entirely dead
Positioned(
top: 48 * s,
left: 48 * s,
child: Row(
children: [
Icon(Icons.mosque, color: SacredColors.onSurfaceVariant.withValues(alpha: 0.3), size: 24 * s),
SizedBox(width: 12 * s),
Text(
settings.masjidName,
style: GoogleFonts.plusJakartaSans(
fontSize: 16 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurfaceVariant.withValues(alpha: 0.3),
letterSpacing: 2 * s,
),
),
],
),
)
],
),
);
}
Widget _buildKhutbahOfficerCard({
required double s,
required IconData icon,
required String label,
required String name,
required Color color,
}) {
return Container(
width: 400 * s,
padding: EdgeInsets.all(32 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerLow.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(SacredRadii.xl),
border: Border.all(color: color.withValues(alpha: 0.1)),
),
child: Column(
children: [
Container(
padding: EdgeInsets.all(20 * s),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(icon, color: color, size: 40 * s),
),
SizedBox(height: 24 * s),
Text(
label,
style: GoogleFonts.manrope(
fontSize: 14 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurfaceVariant,
letterSpacing: 4 * s,
),
),
SizedBox(height: 8 * s),
Text(
name,
textAlign: TextAlign.center,
style: GoogleFonts.plusJakartaSans(
fontSize: 28 * s,
fontWeight: FontWeight.w800,
color: SacredColors.onSurface,
),
),
],
),
);
}
}
class _PulsingMic extends StatefulWidget {
final double size;
final Color color;
const _PulsingMic({required this.size, required this.color});
@override
State<_PulsingMic> createState() => _PulsingMicState();
}
class _PulsingMicState extends State<_PulsingMic> with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: const Duration(seconds: 2))..repeat(reverse: true);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: Tween(begin: 0.3, end: 1.0).animate(_ctrl),
child: Icon(Icons.mic, size: widget.size, color: widget.color),
);
}
}

View File

@@ -0,0 +1,791 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hugeicons/hugeicons.dart';
import '../../core/hijri_date.dart';
import '../../core/sacred_tokens.dart';
import '../../core/enums.dart';
import '../../providers.dart';
import '../../data/local/models.dart';
import '../admin/admin_screen.dart';
import 'unsplash_background.dart';
import 'dart:io';
/// FORMAT HELPER: Duration → "HH:MM:SS"
String _fmtDuration(Duration d) {
final h = d.inHours.toString().padLeft(2, '0');
final m = (d.inMinutes % 60).toString().padLeft(2, '0');
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
if (d.inHours > 0) return '$h:$m:$s';
return '$m:$s';
}
/// The primary display — clock, prayer cards, countdown, marquee.
class MainScreen extends ConsumerWidget {
const MainScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final clock = ref.watch(clockProvider).valueOrNull ?? DateTime.now();
final schedule = ref.watch(todayScheduleProvider);
final settings = ref.watch(settingsProvider);
final screenData = ref.watch(screenStateProvider);
final size = MediaQuery.of(context).size;
final s = size.width / 1920;
final fs = s * ref.watch(textScaleProvider);
final timeStr = DateFormat('HH:mm').format(clock);
final secStr = DateFormat(':ss').format(clock);
final dateGregorian = DateFormat('EEEE, d MMMM yyyy', 'id').format(clock);
return Container(
color: SacredColors.background,
child: Stack(
children: [
// ── Underlay 1: Branded local image (highest priority if set) ──
if (settings.brandedBgImage != null && settings.brandedBgImage!.isNotEmpty)
Positioned.fill(
child: Image.file(
File(settings.brandedBgImage!),
fit: BoxFit.cover,
color: Colors.black.withValues(alpha: 0.55),
colorBlendMode: BlendMode.darken,
),
)
else
// ── Underlay 2: API Unsplash Landscape (fallback) ──
const UnsplashBackground(),
// ── Background radial tint overlay ──
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment.center,
radius: 0.8,
colors: [
SacredColors.primary.withValues(alpha: 0.15),
SacredColors.background,
],
),
),
),
),
// ── Vignette ──
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
SacredColors.background.withValues(alpha: 0.8),
Colors.transparent,
SacredColors.background.withValues(alpha: 0.9),
],
stops: const [0.0, 0.4, 1.0],
),
),
),
),
// ── Main content column ──
Padding(
padding: EdgeInsets.symmetric(horizontal: 64 * s),
child: Column(
children: [
// ── HEADER ──
_buildHeader(context, s, fs, settings, dateGregorian),
// ── CENTER: Clock + Countdown ──
Expanded(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Countdown pill
if (screenData.nextPrayer != null &&
screenData.timeUntilNext != null)
_buildCountdownPill(s, fs, screenData),
SizedBox(height: 16 * s),
// Massive Clock
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
timeStr,
style: GoogleFonts.plusJakartaSans(
fontSize: 180 * s,
fontWeight: FontWeight.w800,
color: SacredColors.onSurface,
letterSpacing: -6 * s,
height: 1.0,
shadows: [
Shadow(
blurRadius: 40 * s,
color:
SacredColors.primary.withValues(alpha: 0.2),
),
],
),
),
Padding(
padding: EdgeInsets.only(top: 24 * s),
child: Text(
secStr,
style: GoogleFonts.plusJakartaSans(
fontSize: 72 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary,
letterSpacing: -1 * s,
),
),
),
],
),
// Decorative line
Container(
width: 240 * s,
height: 2 * s,
margin: EdgeInsets.only(top: 12 * s),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.transparent,
SacredColors.primary.withValues(alpha: 0.4),
Colors.transparent,
],
),
),
),
SizedBox(height: 16 * s),
// Date line
Text(
dateGregorian,
style: GoogleFonts.manrope(
fontSize: 24 * fs,
fontWeight: FontWeight.w500,
color: SacredColors.onSurfaceVariant,
letterSpacing: 1 * s,
),
),
// Secondary times (Imsak, Terbit, Dhuha)
if (schedule != null)
Padding(
padding: EdgeInsets.only(top: 24 * s),
child: _buildSecondaryTimes(s, fs, schedule, settings),
),
// Removed FRIDAY SPECIAL PANEL since its handled by dedicated JumatScreen
],
),
),
),
// ── FOOTER: Prayer Cards ──
if (schedule != null)
_buildPrayerCardsRow(
s, fs, schedule, screenData, settings, clock),
SizedBox(height: 16 * s),
// ── MARQUEE ──
_buildMarquee(s, fs, settings),
SizedBox(height: 12 * s),
],
),
),
],
),
);
}
Widget _buildHeader(BuildContext context, double s, double fs, AppSettings settings, String dateGregorian) {
final dateHijri = HijriDateFormatter.format(DateTime.now());
return Padding(
padding: EdgeInsets.only(top: 24 * s, bottom: 8 * s),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Left: Mosque name + address (TAPPABLE FOR ADMIN PANEL)
InkWell(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const AdminScreen()),
);
},
borderRadius: BorderRadius.circular(8 * s),
child: Padding(
padding: EdgeInsets.all(8.0 * s),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
settings.masjidName,
style: GoogleFonts.plusJakartaSans(
fontSize: 32 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary,
letterSpacing: -0.5 * s,
),
),
SizedBox(height: 4 * s),
Row(
children: [
HugeIcon(
icon: HugeIcons.strokeRoundedLocation01,
color: SacredColors.secondary,
size: 16 * s,
),
SizedBox(width: 4 * s),
Text(
settings.masjidAddress,
style: GoogleFonts.manrope(
fontSize: 14 * fs,
fontWeight: FontWeight.w500,
color: SacredColors.onSurface.withValues(alpha: 0.7),
letterSpacing: 0.5 * s,
),
),
],
),
],
),
),
),
// Right: Hijri date + mosque icon
Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
dateHijri,
style: GoogleFonts.plusJakartaSans(
fontSize: 20 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurface,
),
),
Text(
dateGregorian.toUpperCase(),
style: GoogleFonts.manrope(
fontSize: 12 * fs,
fontWeight: FontWeight.w500,
color: SacredColors.onSurfaceVariant,
letterSpacing: 2 * s,
),
),
],
),
SizedBox(width: 16 * s),
Container(
width: 48 * s,
height: 48 * s,
decoration: BoxDecoration(
color: SacredColors.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: HugeIcon(
icon: HugeIcons.strokeRoundedHome01,
color: SacredColors.secondary,
size: 28 * s,
),
),
],
),
],
),
);
}
Widget _buildCountdownPill(double s, double fs, ScreenStateData screenData) {
return Container(
padding:
EdgeInsets.symmetric(horizontal: 24 * s, vertical: 8 * s),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(SacredRadii.full),
border: Border.all(
color: SacredColors.primary.withValues(alpha: 0.2), width: 1),
color: SacredColors.primary.withValues(alpha: 0.05),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Pulsing dot
_PulsingDot(color: SacredColors.secondary, size: 10 * s),
SizedBox(width: 12 * s),
Text(
'Menuju Adzan ${screenData.nextPrayer?.displayLabel(isFriday: screenData.isFriday) ?? ""}: '
'${_fmtDuration(screenData.timeUntilNext!)}',
style: GoogleFonts.plusJakartaSans(
fontSize: 20 * fs,
fontWeight: FontWeight.w700,
color: SacredColors.secondary,
letterSpacing: 0.5 * s,
),
),
],
),
);
}
Widget _buildSecondaryTimes(
double s, double fs, DailyPrayerSchedule schedule, AppSettings settings) {
final items = <_SecondaryTimeItem>[];
if (settings.showImsak) {
items.add(_SecondaryTimeItem('Imsak', schedule.imsak));
}
if (settings.showTerbit) {
items.add(_SecondaryTimeItem('Terbit', schedule.terbit));
}
items.add(_SecondaryTimeItem('Dhuha', schedule.dhuha));
return Row(
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 0; i < items.length; i++) ...[
Column(
children: [
Text(
items[i].label.toUpperCase(),
style: GoogleFonts.manrope(
fontSize: 10 * fs,
fontWeight: FontWeight.w700,
color: SacredColors.onSurfaceVariant,
letterSpacing: 3 * s,
),
),
SizedBox(height: 4 * s),
Text(
items[i].time,
style: GoogleFonts.plusJakartaSans(
fontSize: 28 * fs,
fontWeight: FontWeight.w600,
color: SacredColors.onSurface,
),
),
],
),
if (i < items.length - 1) ...[
Padding(
padding: EdgeInsets.symmetric(horizontal: 24 * s),
child: Container(
width: 1,
height: 40 * s,
color: SacredColors.outlineVariant.withValues(alpha: 0.3)),
),
],
],
],
);
}
Widget _buildPrayerCardsRow(double s, double fs, DailyPrayerSchedule schedule,
ScreenStateData screenData, AppSettings settings, DateTime clock) {
final prayers = [
_PrayerCardData(PrayerName.subuh, schedule.subuh,
'Iqamah ${_addMinutes(schedule.subuh, settings.iqomahSubuh)}'),
_PrayerCardData(PrayerName.dzuhur, schedule.dzuhur,
'Iqamah ${_addMinutes(schedule.dzuhur, settings.iqomahDzuhur)}'),
_PrayerCardData(PrayerName.ashar, schedule.ashar,
'Iqamah ${_addMinutes(schedule.ashar, settings.iqomahAshar)}'),
_PrayerCardData(PrayerName.maghrib, schedule.maghrib,
'Iqamah ${_addMinutes(schedule.maghrib, settings.iqomahMaghrib)}'),
_PrayerCardData(PrayerName.isya, schedule.isya,
'Iqamah ${_addMinutes(schedule.isya, settings.iqomahIsya)}'),
];
// Optionally insert Terbit
if (settings.showTerbit) {
prayers.insert(
1, _PrayerCardData(PrayerName.terbit, schedule.terbit, '-'));
}
return SizedBox(
height: (140 * s * settings.scaleCardBody).clamp(110 * s, 240 * s),
child: Row(
children: [
for (int i = 0; i < prayers.length; i++) ...[
Expanded(
child: _PrayerCard(
data: prayers[i],
isActive: screenData.nextPrayer == prayers[i].name,
isFriday: screenData.isFriday,
s: s,
fs: fs,
scaleLabel: settings.scaleCardLabel,
scaleBody: settings.scaleCardBody,
),
),
if (i < prayers.length - 1) SizedBox(width: 12 * s),
],
],
),
);
}
Widget _buildMarquee(double s, double fs, AppSettings settings) {
final texts = settings.runningTexts;
if (texts.isEmpty) return const SizedBox.shrink();
// Pad durations list to match texts length
final durations = List<int>.generate(
texts.length,
(i) => (i < settings.runningTextDurations.length)
? settings.runningTextDurations[i]
: 12,
);
return Container(
width: double.infinity,
height: 44 * s,
decoration: BoxDecoration(
color: SacredColors.background.withValues(alpha: 0.9),
border: Border(
top: BorderSide(
color: SacredColors.surfaceContainerHighest.withValues(alpha: 0.1),
),
),
),
child: ClipRect(
child: _RunningTextWidget(
texts: texts,
durations: durations,
animType: settings.marqueeAnimType,
style: GoogleFonts.manrope(
fontSize: 16 * fs * settings.scaleRunningText,
fontWeight: FontWeight.w500,
color: SacredColors.secondary,
letterSpacing: 0.8 * s,
),
),
),
);
}
String _addMinutes(String time, int minutes) {
final parts = time.split(':');
final h = int.parse(parts[0]);
final m = int.parse(parts[1]);
final dt = DateTime(2000, 1, 1, h, m).add(Duration(minutes: minutes));
return DateFormat('HH:mm').format(dt);
}
}
// ─── Supporting widgets ───
class _SecondaryTimeItem {
final String label;
final String time;
_SecondaryTimeItem(this.label, this.time);
}
class _PrayerCardData {
final PrayerName name;
final String time;
final String iqomahLabel;
_PrayerCardData(this.name, this.time, this.iqomahLabel);
}
class _PrayerCard extends StatelessWidget {
final _PrayerCardData data;
final bool isActive;
final bool isFriday;
final double s;
final double fs;
final double scaleLabel; // controls prayer name label size
final double scaleBody; // controls time + iqomah text size
const _PrayerCard({
required this.data,
required this.isActive,
required this.isFriday,
required this.s,
required this.fs,
this.scaleLabel = 1.0,
this.scaleBody = 1.0,
});
@override
Widget build(BuildContext context) {
final label = data.name.displayLabel(isFriday: isFriday);
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: EdgeInsets.all(16 * s),
decoration: BoxDecoration(
color: isActive
? SacredColors.primaryContainer
: SacredColors.surfaceContainerLow,
borderRadius: BorderRadius.circular(SacredRadii.xl),
border: isActive
? Border.all(color: SacredColors.primary, width: 2 * s)
: null,
boxShadow: isActive
? [
BoxShadow(
color: SacredColors.primary.withValues(alpha: 0.1),
blurRadius: 40 * s,
),
]
: null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label.toUpperCase(),
style: GoogleFonts.manrope(
fontSize: 12 * fs * scaleLabel,
fontWeight: FontWeight.w700,
color: isActive
? SacredColors.onPrimaryContainer
: SacredColors.onSurfaceVariant,
letterSpacing: 2 * s,
),
),
if (isActive)
Icon(Icons.notifications_active,
color: SacredColors.onPrimaryContainer, size: 16 * s),
],
),
Text(
data.time,
style: GoogleFonts.plusJakartaSans(
fontSize: 32 * fs * scaleBody,
fontWeight: FontWeight.w700,
color: isActive
? SacredColors.onPrimaryContainer
: SacredColors.onSurface,
),
),
Text(
data.iqomahLabel,
style: GoogleFonts.manrope(
fontSize: 10 * fs * scaleBody,
color: isActive
? SacredColors.onPrimaryContainer.withValues(alpha: 0.8)
: SacredColors.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}
class _PulsingDot extends StatefulWidget {
final Color color;
final double size;
const _PulsingDot({required this.color, required this.size});
@override
State<_PulsingDot> createState() => _PulsingDotState();
}
class _PulsingDotState extends State<_PulsingDot>
with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat();
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: widget.size,
height: widget.size,
child: Stack(
children: [
FadeTransition(
opacity: Tween(begin: 0.75, end: 0.0).animate(_ctrl),
child: ScaleTransition(
scale: Tween(begin: 1.0, end: 2.0).animate(_ctrl),
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: widget.color,
),
),
),
),
Center(
child: Container(
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: widget.color,
),
),
),
],
),
);
}
}
// ─── Running Text Widget (marquee + fade modes) ───
class _RunningTextWidget extends StatefulWidget {
final List<String> texts;
final List<int> durations; // per-item seconds
final String animType; // 'marquee' or 'fade'
final TextStyle style;
const _RunningTextWidget({
required this.texts,
required this.durations,
required this.animType,
required this.style,
});
@override
State<_RunningTextWidget> createState() => _RunningTextWidgetState();
}
class _RunningTextWidgetState extends State<_RunningTextWidget>
with TickerProviderStateMixin {
int _index = 0;
bool _disposed = false;
late AnimationController _fadeCtrl;
late AnimationController _scrollCtrl;
@override
void initState() {
super.initState();
_fadeCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
);
_scrollCtrl = AnimationController(vsync: this);
_startCycle();
}
void _startCycle() async {
try {
while (!_disposed) {
if (widget.texts.isEmpty) {
await Future.delayed(const Duration(seconds: 1));
continue;
}
final dur = widget.durations[_index];
if (widget.animType == 'fade') {
if (_disposed) break;
await _fadeCtrl.forward().orCancel;
if (_disposed) break;
await Future.delayed(Duration(seconds: dur));
if (_disposed) break;
await _fadeCtrl.reverse().orCancel;
} else {
if (_disposed) break;
_scrollCtrl.duration = Duration(seconds: dur);
_scrollCtrl.reset();
await _scrollCtrl.forward().orCancel;
}
if (_disposed) break;
if (mounted) {
setState(() {
_index = (_index + 1) % widget.texts.length;
});
}
}
} on TickerCanceled {
// Widget disposed while animation was running — exit cleanly
} catch (e) {
if (!_disposed) rethrow;
}
}
@override
void dispose() {
_disposed = true;
_fadeCtrl.dispose();
_scrollCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final text = widget.texts[_index];
if (widget.animType == 'fade') {
return Center(
child: FadeTransition(
opacity: _fadeCtrl,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.info_outline,
color: SacredColors.secondary, size: 16),
const SizedBox(width: 8),
Text(text, style: widget.style, maxLines: 1),
],
),
),
);
}
// Marquee mode
return AnimatedBuilder(
animation: _scrollCtrl,
builder: (context, child) {
final width = MediaQuery.of(context).size.width;
final offset = _scrollCtrl.value * (width + 600);
return Transform.translate(
offset: Offset(width - offset, 0),
child: child,
);
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.info_outline, color: SacredColors.secondary, size: 16),
const SizedBox(width: 8),
Text(text, style: widget.style, maxLines: 1),
const SizedBox(width: 80),
Icon(Icons.circle, color: SacredColors.secondary.withValues(alpha: 0.4), size: 6),
const SizedBox(width: 80),
],
),
);
}
}

View File

@@ -0,0 +1,201 @@
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../core/sacred_tokens.dart';
import '../../providers.dart';
class SlideshowScreen extends ConsumerStatefulWidget {
const SlideshowScreen({super.key});
@override
ConsumerState<SlideshowScreen> createState() => _SlideshowScreenState();
}
class _SlideshowScreenState extends ConsumerState<SlideshowScreen> {
static int _globalImageIndex = 0;
int _localImageIndex = 0;
@override
void initState() {
super.initState();
final settings = ref.read(settingsProvider);
if (settings.slideshowImages.isNotEmpty) {
_localImageIndex = _globalImageIndex;
_globalImageIndex = (_globalImageIndex + 1) % settings.slideshowImages.length;
}
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final s = size.width / 1920;
final screenData = ref.watch(screenStateProvider);
final settings = ref.watch(settingsProvider);
Widget renderImage() {
if (settings.slideshowImages.isEmpty) {
return _buildErrorImage(s);
}
final imgPath = settings.slideshowImages[_localImageIndex % settings.slideshowImages.length];
return Image.file(
File(imgPath),
fit: BoxFit.cover,
errorBuilder: (ctx, err, stack) => _buildErrorImage(s),
);
}
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
// 1. Poster Image
renderImage(),
// 2. Subtle Dark Gradient Overlay at bottom so the "glass bar" pops out cleanly
Positioned(
left: 0,
right: 0,
bottom: 0,
height: 300 * s,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black.withValues(alpha: 0.8),
Colors.transparent,
],
),
),
),
),
// 3. Glassmorphic Bottom Bar (Always showing Clock and Next Prayer)
Positioned(
left: 64 * s,
right: 64 * s,
bottom: 64 * s,
child: ClipRRect(
borderRadius: BorderRadius.circular(SacredRadii.xl),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 40, sigmaY: 40),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 48 * s, vertical: 32 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerLowest.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(SacredRadii.xl),
border: Border.all(color: Colors.white.withValues(alpha: 0.1)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Left: Mosque Name
Text(
settings.masjidName,
style: GoogleFonts.plusJakartaSans(
fontSize: 32 * s,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: 1 * s,
),
),
// Center: Clock
_buildMiniClock(s, screenData),
// Right: Next Prayer
if (screenData.nextPrayer != null && screenData.timeUntilNext != null)
_buildNextPrayer(s, screenData),
],
),
),
),
),
),
],
),
);
}
Widget _buildMiniClock(double s, ScreenStateData data) {
final timeStr = "${data.now.hour.toString().padLeft(2, '0')}:${data.now.minute.toString().padLeft(2, '0')}";
final secStr = data.now.second.toString().padLeft(2, '0');
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
timeStr,
style: GoogleFonts.plusJakartaSans(
fontSize: 64 * s,
fontWeight: FontWeight.w800,
color: Colors.white,
height: 1.0,
),
),
SizedBox(width: 8 * s),
Padding(
padding: EdgeInsets.only(bottom: 8 * s),
child: Text(
secStr,
style: GoogleFonts.plusJakartaSans(
fontSize: 32 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary,
),
),
),
],
);
}
Widget _buildNextPrayer(double s, ScreenStateData data) {
final dur = data.timeUntilNext!;
final h = dur.inHours;
final m = (dur.inMinutes % 60);
final countDownStr = h > 0 ? "-$h jam $m mnt" : "-$m mnt";
final prayerTitle = data.nextPrayer!.id.toUpperCase();
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'MENJELANG $prayerTitle',
style: GoogleFonts.plusJakartaSans(
fontSize: 20 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurfaceVariant.withValues(alpha: 0.9),
letterSpacing: 2 * s,
),
),
SizedBox(height: 4 * s),
Text(
countDownStr,
style: GoogleFonts.manrope(
fontSize: 28 * s,
fontWeight: FontWeight.w800,
color: SacredColors.primary,
),
),
],
);
}
Widget _buildErrorImage(double s) {
return Container(
color: SacredColors.surfaceContainerLow,
child: Center(
child: Icon(Icons.broken_image, size: 64 * s, color: SacredColors.onSurfaceVariant),
),
);
}
}

View File

@@ -0,0 +1,102 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/services/unsplash_service.dart';
import '../../providers.dart';
/// Renders a securely rotating background using Unsplash API.
class UnsplashBackground extends ConsumerStatefulWidget {
const UnsplashBackground({super.key});
@override
ConsumerState<UnsplashBackground> createState() => _UnsplashBackgroundState();
}
class _UnsplashBackgroundState extends ConsumerState<UnsplashBackground> {
List<String> _urls = [];
int _currentIndex = 0;
Timer? _rotationTimer;
String? _lastKeyword;
int? _lastRotationHours;
@override
void initState() {
super.initState();
_initDataAndTimer();
}
void _initDataAndTimer() async {
final settings = ref.read(settingsProvider);
_lastKeyword = settings.unsplashKeyword;
_lastRotationHours = settings.unsplashRotationHours;
await _fetchImages(settings.unsplashKeyword);
_startTimer(settings.unsplashRotationHours);
}
Future<void> _fetchImages(String keyword) async {
final urls = await UnsplashService.instance.fetchLandscapeBackgrounds(keyword);
if (urls.isNotEmpty && mounted) {
setState(() {
_urls = urls;
_currentIndex = 0;
});
}
}
void _startTimer(int hours) {
_rotationTimer?.cancel();
if (hours <= 0) return;
_rotationTimer = Timer.periodic(Duration(hours: hours), (_) {
if (_urls.isNotEmpty && mounted) {
setState(() {
_currentIndex = (_currentIndex + 1) % _urls.length;
});
}
});
}
@override
void dispose() {
_rotationTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final settings = ref.watch(settingsProvider);
// Watch for config changes
if (settings.unsplashKeyword != _lastKeyword) {
_lastKeyword = settings.unsplashKeyword;
// Re-fetch images organically
_fetchImages(settings.unsplashKeyword);
}
if (settings.unsplashRotationHours != _lastRotationHours) {
_lastRotationHours = settings.unsplashRotationHours;
_startTimer(settings.unsplashRotationHours);
}
if (!settings.useUnsplashBackground || _urls.isEmpty) {
return const SizedBox.shrink(); // Fallback to flat background handled underneath
}
return AnimatedSwitcher(
duration: const Duration(seconds: 3),
transitionBuilder: (child, animation) => FadeTransition(opacity: animation, child: child),
child: Image.network(
_urls[_currentIndex],
key: ValueKey(_urls[_currentIndex]),
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
// Soft opacity behind the MainScreen's dark glass vignette
color: Colors.black.withValues(alpha: 0.5),
colorBlendMode: BlendMode.darken,
),
);
}
}

78
lib/main.dart Normal file
View File

@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'core/sacred_tokens.dart';
import 'data/local/models.dart';
import 'features/home/home_view.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Landscape-only for TV
await SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
// Hide system overlays for full-screen kiosk mode
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
// Initialize Hive
await Hive.initFlutter();
Hive.registerAdapter(AppSettingsAdapter());
Hive.registerAdapter(DailyPrayerScheduleAdapter());
await Hive.openBox<AppSettings>(HiveBoxes.settings);
await Hive.openBox<DailyPrayerSchedule>(HiveBoxes.prayerSchedule);
// Seed defaults if first launch
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
if (settingsBox.get('default') == null) {
await settingsBox.put('default', AppSettings());
}
// Initialize date formatting for Indonesian locale
await initializeDateFormatting('id_ID');
// Keep screen awake — CRITICAL for 24/7 TV operation
await WakelockPlus.enable();
runApp(
const ProviderScope(
child: JamShalatApp(),
),
);
}
class JamShalatApp extends ConsumerWidget {
const JamShalatApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// textScaleProvider will be used selectively in child components.
return MaterialApp(
title: 'Jam Shalat Digital',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
fontFamily: 'Manrope',
scaffoldBackgroundColor: SacredColors.background,
colorScheme: const ColorScheme.dark(
primary: SacredColors.primary,
secondary: SacredColors.secondary,
surface: SacredColors.surface,
error: SacredColors.error,
onPrimary: SacredColors.onPrimary,
onSecondary: SacredColors.onSecondary,
onSurface: SacredColors.onSurface,
onError: Color(0xFF690005),
),
),
home: const HomeView(),
);
}
}

295
lib/providers.dart Normal file
View File

@@ -0,0 +1,295 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'core/enums.dart';
import 'data/local/models.dart';
import 'data/services/sync_service.dart';
// ──────────────────────────────────────────────
// SIMULATION (DEVELOPER) MODE
// ──────────────────────────────────────────────
final mockTimeOffsetProvider = StateProvider<Duration>((ref) => Duration.zero);
// ──────────────────────────────────────────────
// CLOCK PROVIDER — fires every second
// ──────────────────────────────────────────────
final clockProvider = StreamProvider<DateTime>((ref) {
final offset = ref.watch(mockTimeOffsetProvider);
return Stream.periodic(
const Duration(seconds: 1),
(_) => DateTime.now().add(offset),
);
});
// ──────────────────────────────────────────────
// SETTINGS PROVIDER
// ──────────────────────────────────────────────
final settingsProvider = StateNotifierProvider<SettingsNotifier, AppSettings>(
(ref) => SettingsNotifier(),
);
class SettingsNotifier extends StateNotifier<AppSettings> {
SettingsNotifier() : super(_loadSettings());
static AppSettings _loadSettings() {
final box = Hive.box<AppSettings>(HiveBoxes.settings);
return box.get('default')?.copyWith() ?? AppSettings();
}
Future<void> updateSettings(AppSettings Function(AppSettings) updater) async {
final updated = updater(state.copyWith());
final box = Hive.box<AppSettings>(HiveBoxes.settings);
await box.put('default', updated);
state = updated;
}
void reload() {
state = _loadSettings();
}
}
// ──────────────────────────────────────────────
// TEXT SCALING PROVIDER
// ──────────────────────────────────────────────
final textScaleProvider = Provider<double>((ref) {
final index = ref.watch(settingsProvider.select((s) => s.textScaleIndex));
switch (index) {
case 0: return 0.85; // Small
case 2: return 1.15; // Large
case 1:
default: return 1.0; // Medium
}
});
// ──────────────────────────────────────────────
// TODAY'S SCHEDULE PROVIDER
// ──────────────────────────────────────────────
final todayScheduleProvider = Provider<DailyPrayerSchedule?>((ref) {
// Re-read whenever clock date changes (auto-advance at midnight)
final clock = ref.watch(clockProvider).valueOrNull;
if (clock == null) return null;
return SyncService.instance.getTodaySchedule(clock);
});
// ──────────────────────────────────────────────
// SCREEN STATE MACHINE PROVIDER
// ──────────────────────────────────────────────
/// Computed state that tells the UI which screen to display.
class ScreenStateData {
final ScreenState state;
final PrayerName? activePrayer; // Current or next prayer
final PrayerName? nextPrayer;
final Duration? timeUntilNext; // Countdown to next prayer time
final Duration? iqomahRemaining; // Countdown during iqomah state
final Duration? blankRemaining; // Countdown during shalat/blank state
final bool isFriday;
final DateTime now;
const ScreenStateData({
required this.state,
this.activePrayer,
this.nextPrayer,
this.timeUntilNext,
this.iqomahRemaining,
this.blankRemaining,
required this.isFriday,
required this.now,
});
}
final screenStateProvider = Provider<ScreenStateData>((ref) {
final clock = ref.watch(clockProvider).valueOrNull ?? DateTime.now();
final schedule = ref.watch(todayScheduleProvider);
final settings = ref.watch(settingsProvider);
final isFriday = clock.weekday == DateTime.friday;
if (schedule == null) {
// No data synced yet — stay in normal mode with no countdown
return ScreenStateData(
state: ScreenState.normal,
isFriday: isFriday,
now: clock,
);
}
final times = schedule.toDateTimeMap(clock);
// Build ordered list of fardhu prayer entries
final fardhList = <MapEntry<PrayerName, DateTime>>[
MapEntry(PrayerName.subuh, times['subuh']!),
MapEntry(PrayerName.dzuhur, times['dzuhur']!),
MapEntry(PrayerName.ashar, times['ashar']!),
MapEntry(PrayerName.maghrib, times['maghrib']!),
MapEntry(PrayerName.isya, times['isya']!),
];
int iqomahMinutes(PrayerName p) {
switch (p) {
case PrayerName.subuh: return settings.iqomahSubuh;
case PrayerName.dzuhur: return settings.iqomahDzuhur;
case PrayerName.ashar: return settings.iqomahAshar;
case PrayerName.maghrib: return settings.iqomahMaghrib;
case PrayerName.isya: return settings.iqomahIsya;
default: return 10;
}
}
int blankMinutes() {
return isFriday ? settings.blankScreenJumat : settings.blankScreenNormal;
}
// Check each prayer window in order (latest first for "current")
for (int i = fardhList.length - 1; i >= 0; i--) {
final prayer = fardhList[i];
final adzanTime = prayer.value;
final preAdzanTime =
adzanTime.subtract(Duration(minutes: settings.preAdzanLead));
final iqomahDuration = Duration(minutes: iqomahMinutes(prayer.key));
final iqomahEnd = adzanTime.add(iqomahDuration);
final blankEnd =
iqomahEnd.add(Duration(minutes: blankMinutes()));
// STATE: SHALAT (Black Screen)
if (clock.isAfter(iqomahEnd) && clock.isBefore(blankEnd)) {
return ScreenStateData(
state: ScreenState.shalat,
activePrayer: prayer.key,
blankRemaining: blankEnd.difference(clock),
isFriday: isFriday,
now: clock,
);
}
// STATE: MENUJU IQOMAH (starts after 2-min adzan alert)
final adzanAlertEnd = adzanTime.add(const Duration(minutes: 2));
if (clock.isAfter(adzanAlertEnd) && clock.isBefore(iqomahEnd)) {
return ScreenStateData(
state: ScreenState.menujuIqomah,
activePrayer: prayer.key,
iqomahRemaining: iqomahEnd.difference(clock),
isFriday: isFriday,
now: clock,
);
}
// STATE: ADZAN (first 2 minutes after adzan time)
if (clock.isAfter(adzanTime) && clock.isBefore(adzanAlertEnd)) {
return ScreenStateData(
state: ScreenState.adzan,
activePrayer: prayer.key,
iqomahRemaining: iqomahEnd.difference(clock),
isFriday: isFriday,
now: clock,
);
}
// STATE: MENUJU ADZAN (pre-adzan lock)
if (clock.isAfter(preAdzanTime) && clock.isBefore(adzanTime)) {
return ScreenStateData(
state: ScreenState.menujuAdzan,
activePrayer: prayer.key,
nextPrayer: prayer.key,
timeUntilNext: adzanTime.difference(clock),
isFriday: isFriday,
now: clock,
);
}
}
// STATE: NORMAL — find next upcoming prayer for countdown
PrayerName? nextPrayer;
Duration? untilNext;
for (final prayer in fardhList) {
if (clock.isBefore(prayer.value)) {
nextPrayer = prayer.key;
untilNext = prayer.value.difference(clock);
break;
}
}
return ScreenStateData(
state: ScreenState.normal,
nextPrayer: nextPrayer,
timeUntilNext: untilNext,
isFriday: isFriday,
now: clock,
);
});
// ──────────────────────────────────────────────
// ROTATION PROVIDER (for Normal state slideshow)
// ──────────────────────────────────────────────
/// Controls the rotation between main screen and slideshow views.
final rotationIndexProvider =
StateNotifierProvider<RotationNotifier, int>((ref) {
return RotationNotifier(ref);
});
class RotationNotifier extends StateNotifier<int> {
final Ref _ref;
Timer? _timer;
int _elapsed = 0;
RotationNotifier(this._ref) : super(0) {
_startRotation();
}
void _startRotation() {
_timer?.cancel();
_elapsed = 0;
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
final screenState = _ref.read(screenStateProvider);
if (screenState.state != ScreenState.normal) {
// Don't rotate during special states, reset elapsed
_elapsed = 0;
return;
}
_elapsed++;
final settings = _ref.read(settingsProvider);
final validSlides = settings.slideshowImages.where((i) => i.trim().isNotEmpty).toList();
final hasContent = validSlides.isNotEmpty;
if (!hasContent) {
_elapsed = 0;
if (state != 0) state = 0; // force main screen state
return;
}
final isMainScreen = state % 2 == 0;
final duration = isMainScreen
? settings.mainScreenDurationSec
: settings.slideDurationSec;
if (_elapsed >= duration) {
_elapsed = 0;
state = state + 1;
}
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
}
/// Whether we're currently showing the main screen or slideshow.
/// Returns true (main) always if no slideshow images are configured AND
/// Unsplash background is disabled — no point rotating to an empty slide.
final isMainScreenProvider = Provider<bool>((ref) {
final settings = ref.watch(settingsProvider);
final validSlides = settings.slideshowImages.where((i) => i.trim().isNotEmpty).toList();
final hasContent = validSlides.isNotEmpty;
if (!hasContent) return true; // always stay on main screen
final index = ref.watch(rotationIndexProvider);
// Even = main, Odd = slideshow
return index % 2 == 0;
});