Initial project import and stabilization baseline
This commit is contained in:
52
lib/core/enums.dart
Normal file
52
lib/core/enums.dart
Normal 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
74
lib/core/hijri_date.dart
Normal 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
174
lib/core/sacred_tokens.dart
Normal 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
420
lib/data/local/models.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
111
lib/data/services/myquran_service.dart
Normal file
111
lib/data/services/myquran_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
46
lib/data/services/sound_service.dart
Normal file
46
lib/data/services/sound_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
93
lib/data/services/sync_service.dart
Normal file
93
lib/data/services/sync_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
48
lib/data/services/unsplash_service.dart
Normal file
48
lib/data/services/unsplash_service.dart
Normal 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 [];
|
||||
}
|
||||
}
|
||||
1697
lib/features/admin/admin_screen.dart
Normal file
1697
lib/features/admin/admin_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
330
lib/features/home/adzan_screen.dart
Normal file
330
lib/features/home/adzan_screen.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
108
lib/features/home/black_screen.dart
Normal file
108
lib/features/home/black_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
145
lib/features/home/home_view.dart
Normal file
145
lib/features/home/home_view.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
367
lib/features/home/iqomah_screen.dart
Normal file
367
lib/features/home/iqomah_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
530
lib/features/home/jumat_screen.dart
Normal file
530
lib/features/home/jumat_screen.dart
Normal 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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
202
lib/features/home/khutbah_screen.dart
Normal file
202
lib/features/home/khutbah_screen.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
791
lib/features/home/main_screen.dart
Normal file
791
lib/features/home/main_screen.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
201
lib/features/home/slideshow_screen.dart
Normal file
201
lib/features/home/slideshow_screen.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
102
lib/features/home/unsplash_background.dart
Normal file
102
lib/features/home/unsplash_background.dart
Normal 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
78
lib/main.dart
Normal 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
295
lib/providers.dart
Normal 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;
|
||||
});
|
||||
Reference in New Issue
Block a user