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

312 lines
12 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 ───────────────────────────────────────────────────
final controlProvider = NotifierProvider<ControlNotifier, ControlState>(ControlNotifier.new);
// ── Notifier ───────────────────────────────────────────────────
class ControlNotifier extends Notifier<ControlState> {
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<void> _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<String, dynamic>;
final schedulesData = results[1] as List<dynamic>;
// 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<void> 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<void> 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<void> addSchedule(TimeOfDay time, List<int> 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<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');
}
}
Future<void> 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<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) {
state = state.copyWith(schedules: oldSchedules, isLoading: false, errorMessage: 'Gagal menghapus jadwal');
print('Gagal hapus schedule: $e');
}
}
// ── Mode Operasi ─────────────────────────────────────────────
Future<void> 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',
);
}
}
}