429 lines
14 KiB
Dart
429 lines
14 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);
|
|
|
|
enum BackgroundRotateAction { next, previous, random }
|
|
|
|
class BackgroundRotateCommand {
|
|
final int nonce;
|
|
final BackgroundRotateAction action;
|
|
|
|
const BackgroundRotateCommand({
|
|
required this.nonce,
|
|
required this.action,
|
|
});
|
|
|
|
const BackgroundRotateCommand.initial()
|
|
: nonce = 0,
|
|
action = BackgroundRotateAction.random;
|
|
}
|
|
|
|
final backgroundRotateCommandProvider = StateProvider<BackgroundRotateCommand>(
|
|
(ref) => const BackgroundRotateCommand.initial(),
|
|
);
|
|
|
|
// ──────────────────────────────────────────────
|
|
// 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);
|
|
});
|
|
|
|
class RuntimeScheduleData {
|
|
final DailyPrayerSchedule? schedule;
|
|
final bool isFallbackFromPreviousDay;
|
|
|
|
const RuntimeScheduleData({
|
|
required this.schedule,
|
|
this.isFallbackFromPreviousDay = false,
|
|
});
|
|
|
|
const RuntimeScheduleData.empty()
|
|
: schedule = null,
|
|
isFallbackFromPreviousDay = false;
|
|
}
|
|
|
|
/// Runtime schedule used by main display/state machine.
|
|
/// If today's data is missing after midnight, we temporarily fallback to yesterday
|
|
/// so screen remains operational while showing a waiting-for-update notice.
|
|
final runtimeScheduleProvider = Provider<RuntimeScheduleData>((ref) {
|
|
final clock = ref.watch(clockProvider).valueOrNull;
|
|
if (clock == null) return const RuntimeScheduleData.empty();
|
|
|
|
final today = SyncService.instance.getTodaySchedule(clock);
|
|
if (today != null) {
|
|
return RuntimeScheduleData(schedule: today);
|
|
}
|
|
|
|
final previousDay = clock.subtract(const Duration(days: 1));
|
|
final fallback = SyncService.instance.getTodaySchedule(previousDay);
|
|
if (fallback != null) {
|
|
return RuntimeScheduleData(
|
|
schedule: fallback,
|
|
isFallbackFromPreviousDay: true,
|
|
);
|
|
}
|
|
|
|
return const RuntimeScheduleData.empty();
|
|
});
|
|
|
|
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(runtimeScheduleProvider).schedule;
|
|
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);
|
|
});
|
|
|
|
bool _isMainPhaseForSettings(
|
|
int phaseIndex,
|
|
AppSettings settings, {
|
|
required bool hasContent,
|
|
}) {
|
|
if (!hasContent) return true;
|
|
if (settings.slideshowPatternMode == SlideshowPatternMode.burst) {
|
|
final slidesBetweenMain = settings.slideshowSlidesPerMain.clamp(1, 20);
|
|
final cycleLength = slidesBetweenMain + 1; // main + N slides
|
|
return phaseIndex % cycleLength == 0;
|
|
}
|
|
|
|
// Default alternating pattern.
|
|
return phaseIndex % 2 == 0;
|
|
}
|
|
|
|
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 = _isMainPhaseForSettings(
|
|
state,
|
|
settings,
|
|
hasContent: hasContent,
|
|
);
|
|
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;
|
|
return _isMainPhaseForSettings(index, settings, hasContent: hasContent);
|
|
});
|