Initial project import and stabilization baseline

This commit is contained in:
dwindown
2026-03-30 21:28:44 +07:00
commit ad33b01231
186 changed files with 20445 additions and 0 deletions

295
lib/providers.dart Normal file
View 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;
});