312 lines
11 KiB
Dart
312 lines
11 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:hive_flutter/hive_flutter.dart';
|
|
|
|
import 'core/enums.dart';
|
|
import 'core/hijri_date.dart';
|
|
import 'data/local/models.dart';
|
|
import 'data/services/hijri_service.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);
|
|
});
|
|
|
|
final hijriDateProvider = FutureProvider<String>((ref) async {
|
|
final clock = ref.watch(clockProvider).valueOrNull ?? DateTime.now();
|
|
final hijriOffsetDays =
|
|
ref.watch(settingsProvider.select((s) => s.hijriOffsetDays));
|
|
final dateOnly = DateTime(clock.year, clock.month, clock.day)
|
|
.add(Duration(days: hijriOffsetDays));
|
|
|
|
try {
|
|
return await HijriCalendarService.instance.getHijriLabel(dateOnly);
|
|
} catch (_) {
|
|
return HijriDateFormatter.format(dateOnly);
|
|
}
|
|
});
|
|
|
|
// ──────────────────────────────────────────────
|
|
// 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;
|
|
});
|