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 ─────────────────────────────────────────────────── final controlProvider = NotifierProvider(ControlNotifier.new); // ── Notifier ─────────────────────────────────────────────────── class ControlNotifier extends Notifier { late ApiControlService _api; String _deviceId = ''; // Sync lock: cegah syncRelayFromSensor menimpa optimistic update // selama 10 detik setelah user melakukan aksi manual (togglePump) DateTime? _syncLockedUntil; // Sync lock: cegah syncModeFromSensor menimpa optimistic update // selama 10 detik setelah user melakukan ganti mode (setDeviceMode) DateTime? _modeSyncLockedUntil; @override ControlState build() { final authState = ref.watch(authProvider); if (authState is AuthSuccess && authState.user.deviceId != null) { _deviceId = authState.user.deviceId!; } _api = ApiControlService(_deviceId); // Fetch initial data secara asinkron tanpa memblokir build if (_deviceId.isNotEmpty) { Future.microtask(() => _initData()); } return ControlState.initial(); } Future _initData() async { state = state.copyWith(isLoading: true); try { final thresholdTask = _api.getThreshold(); final schedulesTask = _api.getSchedules(); final results = await Future.wait([thresholdTask, schedulesTask]); final threshold = results[0] as Map; final schedulesData = results[1] as List; // Ambil mode operasi dari API final currentMode = await _api.getMode(); // Parsing schedules dari backend ke WateringSchedule final parsedSchedules = schedulesData.map((s) { // Backend cron format: "minute hour day month day-of-week" (DISIMPAN DALAM UTC!) // Misal: "30 10 * * 1,3,5" (10:30 UTC = 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 -> WIB (+7 jam) agar tampilan jam di UI sesuai input user final utcTotalMinutes = utcHour * 60 + utcMinute; final wibTotalMinutes = (utcTotalMinutes + 7 * 60) % (24 * 60); final wibHour = wibTotalMinutes ~/ 60; final wibMinute = wibTotalMinutes % 60; // Gunakan utils untuk ekstrak string repeat var daysArray = CronUtils.parseDaysFromCron(cronString); // BUG FIX: Jika penambahan 7 jam menyebabkan hari berganti ke besoknya, geser hari ke depan 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 ────────────────────────────────────── void clearMessage() { state = state.copyWith(clearError: true, clearSuccess: true); } // ── Pompa ──────────────────────────────────────────────────── Future togglePump([int? durationSeconds]) async { final willTurnOn = !state.isPumpOn; // Optimistic update state = state.copyWith( isPumpOn: willTurnOn, activePumpDuration: willTurnOn ? (durationSeconds ?? 30) : null, pumpTurnedOnAt: willTurnOn ? DateTime.now() : null, clearTimer: !willTurnOn, ); // Kunci sync selama 10 detik agar polling dari monitoringProvider tidak // menimpa optimistic update sebelum ESP32 sempat melaporkan state baru _syncLockedUntil = DateTime.now().add(const Duration(seconds: 10)); try { if (willTurnOn) { final duration = durationSeconds ?? 30; // Default 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) { // Revert jika gagal, dan lepas lock agar sync bisa jalan kembali _syncLockedUntil = null; state = state.copyWith(isPumpOn: !willTurnOn, errorMessage: 'Gagal mengontrol pompa'); print('Gagal toggle pompa: $e'); } } /// Sinkronisasi status pompa dari data sensor real-time. /// Dipanggil oleh monitoring_provider setiap kali dapat data sensor baru dari ESP32. /// CATATAN: Sync dikunci selama 10 detik setelah user aksi manual (togglePump) /// untuk mencegah race condition antara optimistic update dan polling. void syncRelayFromSensor(bool relayOn) { // Jika sync sedang dikunci (user baru tekan tombol), abaikan update dari polling if (_syncLockedUntil != null && DateTime.now().isBefore(_syncLockedUntil!)) { return; } // Hanya update jika berbeda, untuk menghindari rebuild yang tidak perlu if (state.isPumpOn != relayOn) { state = state.copyWith( isPumpOn: relayOn, clearTimer: !relayOn, ); } } void syncModeFromSensor(String modeStr) { // Jika sync sedang dikunci (user baru tekan tombol), abaikan update dari polling if (_modeSyncLockedUntil != null && DateTime.now().isBefore(_modeSyncLockedUntil!)) { return; } final realMode = DeviceModeX.fromString(modeStr); // Jangan overwrite jika UI sedang loading ganti mode (optimistic update) if (state.deviceMode != realMode && !state.isModeLoading) { state = state.copyWith(deviceMode: realMode); } } void setPump(bool value) => state = state.copyWith(isPumpOn: value); // ── Batasan Sensor ─────────────────────────────────────────── /// Simpan kedua nilai threshold sekaligus dalam satu API call. /// Dipanggil dari SensorLimitCard saat user menekan tombol Simpan. Future saveThreshold(double suhu, double kelembapan) async { // Optimistic update — UI langsung mencerminkan nilai baru 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 state = state.copyWith( suhuLimit: state.suhuLimit, kelembapanLimit: state.kelembapanLimit, errorMessage: 'Gagal menyimpan batasan sensor', ); print('Gagal save threshold: $e'); } } // ── Jadwal ─────────────────────────────────────────────────── Future addSchedule(TimeOfDay time, List days, int durationS) async { state = state.copyWith(isLoading: true); try { // BullMQ menjalankan cron berdasarkan UTC, sedangkan user input dalam WIB (UTC+7). // Konversikan ke UTC dengan mengurangi 7 jam agar jadwal tepat waktu. final localMinutes = time.hour * 60 + time.minute; final utcMinutes = (localMinutes - 7 * 60) % (24 * 60); // Pastikan tidak negatif (modulo di Dart bisa negatif jika dividennya negatif) final utcTotalMinutes = utcMinutes < 0 ? utcMinutes + 24 * 60 : utcMinutes; final utcHour = utcTotalMinutes ~/ 60; final utcMinute = utcTotalMinutes % 60; // BUG FIX: Geser hari mundur jika pengurangan 7 jam menyebabkan waktu mundur ke hari kemarin 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'); } } Future toggleSchedule(String id) async { // Optimistic update final oldSchedules = state.schedules; 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 state = state.copyWith(schedules: oldSchedules, errorMessage: 'Gagal mengubah status jadwal'); print('Gagal toggle schedule: $e'); } } 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) { state = state.copyWith(schedules: oldSchedules, isLoading: false, errorMessage: 'Gagal menghapus jadwal'); print('Gagal hapus schedule: $e'); } } // ── Mode Operasi ───────────────────────────────────────────── Future setDeviceMode(DeviceMode mode) async { if (state.isModeLoading) return; if (state.deviceMode == mode) return; // Mencegah request berulang jika menekan mode yang sama final prevMode = state.deviceMode; // Optimistic update state = state.copyWith(deviceMode: mode, isModeLoading: true); // Kunci sync selama 10 detik agar polling tidak mengembalikan state lama // sebelum ESP32 sempat mengubah dan mengirim state barunya _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 _modeSyncLockedUntil = null; state = state.copyWith( deviceMode: prevMode, isModeLoading: false, errorMessage: 'Gagal mengubah mode operasi', ); } } }