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((ref) => Duration.zero); // ────────────────────────────────────────────── // CLOCK PROVIDER — fires every second // ────────────────────────────────────────────── final clockProvider = StreamProvider((ref) { final offset = ref.watch(mockTimeOffsetProvider); return Stream.periodic( const Duration(seconds: 1), (_) => DateTime.now().add(offset), ); }); // ────────────────────────────────────────────── // SETTINGS PROVIDER // ────────────────────────────────────────────── final settingsProvider = StateNotifierProvider( (ref) => SettingsNotifier(), ); class SettingsNotifier extends StateNotifier { SettingsNotifier() : super(_loadSettings()); static AppSettings _loadSettings() { final box = Hive.box(HiveBoxes.settings); return box.get('default')?.copyWith() ?? AppSettings(); } Future updateSettings(AppSettings Function(AppSettings) updater) async { final updated = updater(state.copyWith()); final box = Hive.box(HiveBoxes.settings); await box.put('default', updated); state = updated; } void reload() { state = _loadSettings(); } } // ────────────────────────────────────────────── // TEXT SCALING PROVIDER // ────────────────────────────────────────────── final textScaleProvider = Provider((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((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((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((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.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((ref) { return RotationNotifier(ref); }); class RotationNotifier extends StateNotifier { 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((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; });