Initial project import and stabilization baseline
This commit is contained in:
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