TKK_E32230206/lib/features/iot/providers/control_notifier.dart

327 lines
13 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/utils/cron_utils.dart';
import '../../../services/api_control_service.dart';
import '../../auth/auth_notifier.dart';
import '../../auth/auth_state.dart';
import 'control_state.dart';
// ── Provider ───────────────────────────────────────────────────
/// Provider global untuk [ControlNotifier] yang mengelola kendali pompa dan penjadwalan.
final controlProvider = NotifierProvider<ControlNotifier, ControlState>(ControlNotifier.new);
// ── Notifier ───────────────────────────────────────────────────
/// Notifier yang menangani logika kontrol hardware (ESP32) seperti menyalakan pompa,
/// mengubah mode kerja (Auto/Manual), batas sensor (threshold), serta tambah/hapus jadwal penyiraman.
class ControlNotifier extends Notifier<ControlState> {
late ApiControlService _api;
String _deviceId = '';
/// Kunci sinkronisasi pompa (Sync Lock): mencegah status relay yang dilaporkan oleh polling berkala
/// menimpa status tombol di UI (Optimistic Update) selama 10 detik pertama setelah user menekan tombol.
DateTime? _syncLockedUntil;
/// Kunci sinkronisasi mode (Sync Lock): mencegah status mode operasi yang dilaporkan oleh polling berkala
/// menimpa pilihan mode di UI selama 10 detik pertama setelah user mengubah mode.
DateTime? _modeSyncLockedUntil;
@override
ControlState build() {
// Memantau perubahan status login pengguna untuk mengambil `deviceId`
final authState = ref.watch(authProvider);
if (authState is AuthSuccess && authState.user.deviceId != null) {
_deviceId = authState.user.deviceId!;
}
// Inisialisasi ApiControlService dengan deviceId milik user
_api = ApiControlService(_deviceId);
// Memicu pemuatan data awal dari API secara asinkron setelah build widget selesai
if (_deviceId.isNotEmpty) {
Future.microtask(() => _initData());
}
return ControlState.initial();
}
/// Mengambil seluruh data konfigurasi awal (threshold, jadwal, mode operasi) dari API.
Future<void> _initData() async {
state = state.copyWith(isLoading: true);
try {
final thresholdTask = _api.getThreshold();
final schedulesTask = _api.getSchedules();
// Menjalankan request threshold dan jadwal secara paralel untuk efisiensi
final results = await Future.wait([thresholdTask, schedulesTask]);
final threshold = results[0] as Map<String, dynamic>;
final schedulesData = results[1] as List<dynamic>;
// Mengambil status mode operasi saat ini dari backend
final currentMode = await _api.getMode();
// Melakukan parsing data jadwal mentah dari backend ke objek WateringSchedule
final parsedSchedules = schedulesData.map((s) {
// Backend menyimpan format cron di zona waktu UTC: "minute hour day month day-of-week"
// Misal: "30 10 * * 1,3,5" (Jam 10:30 UTC = Jam 17:30 WIB)
final cronString = s['cron'].toString();
final cronParts = cronString.split(' ');
int utcMinute = 0;
int utcHour = 0;
if (cronParts.length >= 2) {
utcMinute = int.tryParse(cronParts[0]) ?? 0;
utcHour = int.tryParse(cronParts[1]) ?? 0;
}
// Konversi UTC ke zona waktu lokal WIB (+7 jam) agar jam yang tampil di UI cocok dengan input pengguna
final utcTotalMinutes = utcHour * 60 + utcMinute;
final wibTotalMinutes = (utcTotalMinutes + 7 * 60) % (24 * 60);
final wibHour = wibTotalMinutes ~/ 60;
final wibMinute = wibTotalMinutes % 60;
// Mengekstrak daftar hari aktif dari string cron
var daysArray = CronUtils.parseDaysFromCron(cronString);
// JIKA konversi WIB (+7 jam) menyebabkan pergantian hari (ke esok hari),
// maka daftar hari aktif harus digeser maju (+1 hari).
if (utcTotalMinutes + 7 * 60 >= 24 * 60) {
daysArray = daysArray.map((d) => (d + 1) % 7).toList();
}
final repeatText = CronUtils.getRepeatText(daysArray);
return WateringSchedule(
id: s['id'].toString(),
time: TimeOfDay(hour: wibHour, minute: wibMinute),
repeat: repeatText,
isEnabled: s['is_active'] ?? true,
cron: cronString,
durationS: s['duration_s'] ?? 30,
);
}).toList();
state = state.copyWith(
isLoading: false,
suhuLimit: (threshold['temp_max'] as num?)?.toDouble() ?? 30.0,
kelembapanLimit: (threshold['hum_max'] as num?)?.toDouble() ?? 75.0,
schedules: parsedSchedules,
deviceMode: DeviceModeX.fromString(currentMode),
);
} catch (e) {
state = state.copyWith(
isLoading: false,
errorMessage: 'Gagal memuat data awal. Periksa koneksi internet.',
);
print('Gagal _initData Control: $e');
}
}
// ── Pesan Error & Success ──────────────────────────────────────
/// Membersihkan pesan error dan sukses dari state saat ini.
void clearMessage() {
state = state.copyWith(clearError: true, clearSuccess: true);
}
// ── Pompa ────────────────────────────────────────────────────
/// Mengaktifkan atau menonaktifkan status pompa secara langsung (Manual Control).
/// Menerapkan Optimistic Update di mana UI langsung merespon sebelum request ke server selesai.
Future<void> togglePump([int? durationSeconds]) async {
final willTurnOn = !state.isPumpOn;
// 1. Optimistic Update (UI berganti status secara instan)
state = state.copyWith(
isPumpOn: willTurnOn,
activePumpDuration: willTurnOn ? (durationSeconds ?? 30) : null,
pumpTurnedOnAt: willTurnOn ? DateTime.now() : null,
clearTimer: !willTurnOn,
);
// 2. Kunci sync lock selama 10 detik agar polling tidak langsung menimpa status tombol
_syncLockedUntil = DateTime.now().add(const Duration(seconds: 10));
try {
if (willTurnOn) {
final duration = durationSeconds ?? 30; // Default penyiraman 30 detik
await _api.siramManual(duration);
state = state.copyWith(successMessage: 'Menyalakan pompa (${duration}s)');
} else {
await _api.stopPump();
state = state.copyWith(successMessage: 'Pompa dimatikan');
}
} catch (e) {
// 3. Batalkan/Revert status tombol jika request API mengalami kegagalan
_syncLockedUntil = null;
state = state.copyWith(isPumpOn: !willTurnOn, errorMessage: 'Gagal mengontrol pompa');
print('Gagal toggle pompa: $e');
}
}
/// Sinkronisasi status pompa dari data sensor real-time yang didapat dari ESP32.
/// Dipanggil oleh `monitoringProvider` setiap 5 detik.
/// CATATAN: Update akan diabaikan jika sync lock manual (10 detik) masih berjalan.
void syncRelayFromSensor(bool relayOn) {
if (_syncLockedUntil != null && DateTime.now().isBefore(_syncLockedUntil!)) {
return; // Abaikan update sensor karena user baru saja memicu aksi manual
}
if (state.isPumpOn != relayOn) {
state = state.copyWith(
isPumpOn: relayOn,
clearTimer: !relayOn,
);
}
}
/// Sinkronisasi status mode operasi dari data sensor real-time yang didapat dari ESP32.
/// CATATAN: Update akan diabaikan jika sync lock mode (10 detik) masih aktif atau mode sedang loading.
void syncModeFromSensor(String modeStr) {
if (_modeSyncLockedUntil != null && DateTime.now().isBefore(_modeSyncLockedUntil!)) {
return; // Abaikan update sensor karena user baru saja mengganti mode secara manual
}
final realMode = DeviceModeX.fromString(modeStr);
if (state.deviceMode != realMode && !state.isModeLoading) {
state = state.copyWith(deviceMode: realMode);
}
}
/// Memperbarui status menyala pompa di state secara langsung.
void setPump(bool value) => state = state.copyWith(isPumpOn: value);
// ── Batasan Sensor ───────────────────────────────────────────
/// Menyimpan batasan suhu maks ([suhu]) dan kelembapan min ([kelembapan]) ke server.
Future<void> saveThreshold(double suhu, double kelembapan) async {
final oldSuhu = state.suhuLimit;
final oldKelembapan = state.kelembapanLimit;
// Optimistic Update
state = state.copyWith(suhuLimit: suhu, kelembapanLimit: kelembapan);
try {
await _api.updateThreshold(suhu, kelembapan);
state = state.copyWith(successMessage: 'Batasan sensor diperbarui');
} catch (e) {
// Revert ke nilai lama jika gagal menyimpan
state = state.copyWith(
suhuLimit: oldSuhu,
kelembapanLimit: oldKelembapan,
errorMessage: 'Gagal menyimpan batasan sensor',
);
print('Gagal save threshold: $e');
}
}
// ── Jadwal ───────────────────────────────────────────────────
/// Menambahkan jadwal penyiraman otomatis baru.
/// Melakukan konversi waktu lokal (WIB) ke format Cron UTC sebelum dikirim ke API.
Future<void> addSchedule(TimeOfDay time, List<int> days, int durationS) async {
state = state.copyWith(isLoading: true);
try {
// Konversi WIB (UTC+7) ke UTC dengan mengurangi waktu sebanyak 7 jam
final localMinutes = time.hour * 60 + time.minute;
final utcMinutes = (localMinutes - 7 * 60) % (24 * 60);
final utcTotalMinutes = utcMinutes < 0 ? utcMinutes + 24 * 60 : utcMinutes;
final utcHour = utcTotalMinutes ~/ 60;
final utcMinute = utcTotalMinutes % 60;
// JIKA pengurangan waktu WIB menyebabkan hari bergeser mundur (misal jam 05 WIB menjadi jam 22 UTC hari kemarin),
// maka daftar hari aktif harus digeser mundur (-1 hari).
List<int> adjustedDays = List.from(days);
if (localMinutes - 7 * 60 < 0) {
adjustedDays = days.map((d) => (d - 1) < 0 ? 6 : (d - 1)).toList();
}
final daysStr = CronUtils.daysToCron(adjustedDays);
final cron = '$utcMinute $utcHour * * $daysStr';
final result = await _api.addSchedule(cron, durationS, 'Jadwal Otomatis');
final newSchedule = WateringSchedule(
id: result['id'].toString(),
time: time,
repeat: CronUtils.getRepeatText(days),
isEnabled: result['is_active'] ?? true,
cron: result['cron']?.toString(),
durationS: (result['duration_s'] as num?)?.toInt() ?? durationS,
);
state = state.copyWith(
isLoading: false,
schedules: [...state.schedules, newSchedule],
successMessage: 'Jadwal baru ditambahkan',
);
} catch (e) {
state = state.copyWith(isLoading: false, errorMessage: 'Gagal menambah jadwal');
print('Gagal tambah schedule: $e');
}
}
/// Mengaktifkan atau menonaktifkan jadwal penyiraman tertentu berdasarkan [id].
Future<void> toggleSchedule(String id) async {
final oldSchedules = state.schedules;
// Optimistic Update
state = state.copyWith(
schedules: state.schedules.map((s) {
return s.id == id ? s.copyWith(isEnabled: !s.isEnabled) : s;
}).toList(),
);
try {
await _api.toggleSchedule(id);
} catch (e) {
// Revert ke status jadwal semula jika request API gagal
state = state.copyWith(schedules: oldSchedules, errorMessage: 'Gagal mengubah status jadwal');
print('Gagal toggle schedule: $e');
}
}
/// Menghapus jadwal penyiraman otomatis berdasarkan [id].
Future<void> removeSchedule(String id) async {
final oldSchedules = state.schedules;
state = state.copyWith(
schedules: state.schedules.where((s) => s.id != id).toList(),
isLoading: true,
);
try {
await _api.deleteSchedule(id);
state = state.copyWith(isLoading: false, successMessage: 'Jadwal berhasil dihapus');
} catch (e) {
// Revert jika gagal menghapus
state = state.copyWith(schedules: oldSchedules, isLoading: false, errorMessage: 'Gagal menghapus jadwal');
print('Gagal hapus schedule: $e');
}
}
// ── Mode Operasi ─────────────────────────────────────────────
/// Mengganti mode kerja alat (Otomatis ke Manual, atau sebaliknya).
Future<void> setDeviceMode(DeviceMode mode) async {
if (state.isModeLoading) return;
if (state.deviceMode == mode) return; // Mencegah request ganda untuk mode yang sama
final prevMode = state.deviceMode;
// Optimistic Update
state = state.copyWith(deviceMode: mode, isModeLoading: true);
// Kunci sync lock mode selama 10 detik agar status mode dari sensor tidak langsung menimpa UI sebelum hardware beres merespon
_modeSyncLockedUntil = DateTime.now().add(const Duration(seconds: 10));
try {
await _api.setMode(mode.apiValue);
state = state.copyWith(
isModeLoading: false,
successMessage: 'Mode diubah ke ${mode.label}',
);
} catch (e) {
// Revert mode jika gagal menyimpan ke server
_modeSyncLockedUntil = null;
state = state.copyWith(
deviceMode: prevMode,
isModeLoading: false,
errorMessage: 'Gagal mengubah mode operasi',
);
}
}
}