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.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 { 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 _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; final schedulesData = results[1] as List; // 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, suhuMinLimit: (threshold['temp_min'] as num?)?.toDouble() ?? 22.0, suhuMaxLimit: (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 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 min-maks ([suhuMin], [suhuMax]) dan kelembapan min ([kelembapan]) ke server. Future saveThreshold(double suhuMin, double suhuMax, double kelembapan) async { final oldSuhuMin = state.suhuMinLimit; final oldSuhuMax = state.suhuMaxLimit; final oldKelembapan = state.kelembapanLimit; // Optimistic Update state = state.copyWith( suhuMinLimit: suhuMin, suhuMaxLimit: suhuMax, kelembapanLimit: kelembapan, ); try { await _api.updateThreshold(suhuMin, suhuMax, kelembapan); state = state.copyWith(successMessage: 'Batasan sensor diperbarui'); } catch (e) { // Revert ke nilai lama jika gagal menyimpan state = state.copyWith( suhuMinLimit: oldSuhuMin, suhuMaxLimit: oldSuhuMax, 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 addSchedule(TimeOfDay time, List 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 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 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 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 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', ); } } }