327 lines
13 KiB
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',
|
|
);
|
|
}
|
|
}
|
|
}
|