Files
jamshalat-masjid-screen/lib/providers.dart

354 lines
12 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 scheduleCacheStatusProvider = Provider<ScheduleCacheStatus>((ref) {
final clock = ref.watch(clockProvider).valueOrNull ?? DateTime.now();
return SyncService.instance.getCacheStatus(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)
// ──────────────────────────────────────────────
/// Elapsed seconds in the active rotation phase (main/slideshow).
final rotationElapsedProvider = StateProvider<int>((ref) => 0);
/// 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;
_ref.read(rotationElapsedProvider.notifier).state = 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;
_ref.read(rotationElapsedProvider.notifier).state = 0;
return;
}
final settings = _ref.read(settingsProvider);
final validSlides =
settings.slideshowImages.where((i) => i.trim().isNotEmpty).toList();
final hasContent = validSlides.isNotEmpty;
_elapsed++;
if (!hasContent) {
final duration = _resolveMainPhaseDuration(settings);
if (_elapsed >= duration) {
_elapsed = 0;
}
_ref.read(rotationElapsedProvider.notifier).state = _elapsed;
if (state != 0) state = 0; // force main screen state
return;
}
final isMainScreen = state % 2 == 0;
final duration = isMainScreen
? _resolveMainPhaseDuration(settings)
: settings.slideDurationSec.clamp(1, 600);
if (_elapsed >= duration) {
_elapsed = 0;
state = state + 1;
}
_ref.read(rotationElapsedProvider.notifier).state = _elapsed;
});
}
int _resolveMainPhaseDuration(AppSettings settings) {
final centerSlides = settings.textSlides
.map((text) => text.trim())
.where((text) => text.isNotEmpty)
.toList();
if (centerSlides.isEmpty) {
return settings.mainScreenDurationSec.clamp(1, 600);
}
final heroDuration = settings.mainCenterSlideDurationSec.clamp(1, 600);
final perAnnouncement = settings.announcementSlideDurationSec.clamp(1, 600);
return heroDuration + (perAnnouncement * centerSlides.length);
}
@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);
// Keep rotation notifier alive even when slideshow media is empty,
// because main-screen text slides depend on rotation elapsed time.
final index = ref.watch(rotationIndexProvider);
final validSlides =
settings.slideshowImages.where((i) => i.trim().isNotEmpty).toList();
final hasContent = validSlides.isNotEmpty;
if (!hasContent) return true; // always stay on main screen
// Even = main, Odd = slideshow
return index % 2 == 0;
});