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

461 lines
18 KiB
Dart

import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/chart_data.dart';
import '../../../models/monitoring_status.dart';
import '../../../models/sensor_model.dart';
import '../../../repositories/iot_repository.dart';
import '../../../services/api_iot_service.dart';
import '../../auth/auth_notifier.dart';
import '../../auth/auth_state.dart';
import 'control_notifier.dart';
// ── Enums ──────────────────────────────────────────────────────
/// Rentang waktu grafik: [daily] untuk harian, [monthly] untuk bulanan.
enum ChartRange { daily, monthly }
/// Tipe data yang ditampilkan di grafik: [suhu] (temperatur) atau [kelembapan] (humidity).
enum ChartType { suhu, kelembapan }
// ── Supporting providers ───────────────────────────────────────
/// Provider untuk menyediakan instance [IotRepository].
/// Secara dinamis mengambil `deviceId` dari pengguna yang sedang login ([authProvider]).
/// Jika pengguna belum login atau tidak memiliki `deviceId`, akan menggunakan fallback default 'esp32-01'.
final iotRepositoryProvider = Provider<IotRepository>((ref) {
final authState = ref.watch(authProvider);
String deviceId = 'esp32-01'; // Default fallback jika deviceId tidak ditemukan
if (authState is AuthSuccess && authState.user.deviceId != null) {
deviceId = authState.user.deviceId!;
}
return IotRepository(ApiIotService(deviceId));
});
/// Provider untuk menyimpan status pilihan rentang waktu grafik (Harian / Bulanan).
final chartRangeProvider = StateProvider<ChartRange>(
(_) => ChartRange.daily,
);
/// Provider untuk menyimpan status pilihan tipe grafik yang aktif (Suhu / Kelembapan).
final chartTypeProvider = StateProvider<ChartType>(
(_) => ChartType.suhu,
);
/// Provider untuk menyimpan status offset grafik (untuk navigasi hari/bulan sebelumnya atau setelahnya).
final chartOffsetProvider = StateProvider<int>(
(_) => 0,
);
// ── State ──────────────────────────────────────────────────────
/// State yang merepresentasikan semua data pemantauan IoT di UI.
class MonitoringState {
/// Data sensor terbaru yang diterima dari ESP32.
final SensorModel? latest;
/// Status loading saat aplikasi pertama kali memuat data atau memperbarui secara manual.
final bool isLoading;
/// Pesan error jika terjadi kegagalan pemanggilan API.
final String? errorMessage;
/// Riwayat data sensor (real-time) untuk grafik harian berjalan (maksimal 200 poin).
final List<ChartData> history;
/// Agregasi riwayat harian rata-rata untuk grafik bulanan.
final List<DailyAverage> dailyHistory;
/// Agregasi riwayat per jam rata-rata untuk grafik harian.
final List<DailyAverage> hourlyHistory;
/// Status keaktifan perangkat berdasarkan LWT MQTT di database.
final bool isDeviceOnline;
const MonitoringState({
this.latest,
this.isLoading = false,
this.errorMessage,
this.history = const [],
this.dailyHistory = const [],
this.hourlyHistory = const [],
this.isDeviceOnline = false,
});
/// State awal saat provider pertama kali diinisialisasi (status loading aktif).
factory MonitoringState.initial() => const MonitoringState(isLoading: true);
/// Helper getter untuk mendapatkan nilai suhu saat ini. Default: 0 jika null.
double get temperature => latest?.temperature ?? 0;
/// Helper getter untuk mendapatkan nilai kelembapan saat ini. Default: 0 jika null.
double get humidity => latest?.humidity ?? 0;
/// Menentukan status suhu berdasarkan batas minimum ([minSuhu]) dan maksimum ([maxSuhu]).
/// - Danger: jika suhu melebihi batas maks + 3°C atau di bawah batas min - 3°C.
/// - Warning: jika suhu melebihi batas maks atau di bawah batas min.
/// - Normal: jika suhu berada di dalam rentang.
MonitoringStatus tempStatus(double minSuhu, double maxSuhu) {
if (temperature >= maxSuhu + 3 || temperature <= minSuhu - 3) return MonitoringStatus.danger;
if (temperature > maxSuhu || temperature < minSuhu) return MonitoringStatus.warning;
return MonitoringStatus.normal;
}
/// Menentukan status kelembapan berdasarkan batas minimum kelembapan ([minHum]).
/// - Danger: jika kelembapan berada di bawah batas min - 5%.
/// - Warning: jika kelembapan berada di bawah batas min.
/// - Normal: jika kelembapan berada di atas batas min.
MonitoringStatus humidStatus(double minHum) {
if (humidity < minHum - 5) return MonitoringStatus.danger;
if (humidity < minHum) return MonitoringStatus.warning;
return MonitoringStatus.normal;
}
/// Menentukan status keseluruhan kumbung jamur berdasarkan status suhu dan kelembapan.
/// Menghasilkan string 'Danger', 'Warning', atau 'Normal'.
String overallStatus(double minSuhu, double maxSuhu, double minHum) {
final statuses = [tempStatus(minSuhu, maxSuhu), humidStatus(minHum)];
if (statuses.contains(MonitoringStatus.danger)) return 'Danger';
if (statuses.contains(MonitoringStatus.warning)) return 'Warning';
return 'Normal';
}
/// Menggandakan [MonitoringState] dengan memperbarui beberapa parameter saja (Immutable State).
MonitoringState copyWith({
Object? latest = _sentinel,
bool? isLoading,
String? errorMessage,
List<ChartData>? history,
List<DailyAverage>? dailyHistory,
List<DailyAverage>? hourlyHistory,
bool? isDeviceOnline,
}) {
return MonitoringState(
latest: latest == _sentinel ? this.latest : latest as SensorModel?,
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage, // null = menghapus pesan error
history: history ?? this.history,
dailyHistory: dailyHistory ?? this.dailyHistory,
hourlyHistory: hourlyHistory ?? this.hourlyHistory,
isDeviceOnline: isDeviceOnline ?? this.isDeviceOnline,
);
}
}
// Sentinel object untuk membedakan "tidak diisi" vs "diisi null" di copyWith
const _sentinel = Object();
// ── Notifier ───────────────────────────────────────────────────
/// Notifier yang mengelola logika bisnis pemantauan sensor real-time.
/// Menghubungi repository secara berkala untuk mengambil data sensor dan riwayatnya.
class MonitoringNotifier extends StateNotifier<MonitoringState> {
final IotRepository _repo;
final Ref _ref;
Timer? _timer;
MonitoringNotifier(this._repo, this._ref) : super(MonitoringState.initial()) {
_startAutoRefresh();
}
/// Memulai auto-refresh berkala (polling) setiap 5 detik setelah memuat data awal.
Future<void> _startAutoRefresh() async {
await _initData();
_timer = Timer.periodic(const Duration(seconds: 5), (_) => fetchData());
}
/// Memuat data sensor awal beserta data riwayat grafik dari API.
Future<void> _initData() async {
final authState = _ref.read(authProvider);
final hasDevice = authState is AuthSuccess && authState.user.deviceId != null;
if (!hasDevice) {
// Jika pengguna belum terhubung dengan alat, loading dimatikan
state = state.copyWith(isLoading: false);
return;
}
state = state.copyWith(isLoading: true);
try {
// 1. Ambil data riwayat sensor mentah dari backend
final historyData = await _repo.getHistoryData();
final latest = historyData.isNotEmpty ? historyData.last : null;
// Konversi riwayat sensor mentah ke format ChartData untuk grafik
final historyCharts = historyData.map((s) => ChartData(
timestamp: s.timestamp,
suhu: s.temperature,
kelembapan: s.humidity,
)).toList();
List<DailyAverage> dailyParsed = [];
List<DailyAverage> hourlyParsed = [];
try {
// 2. Ambil riwayat agregasi harian (30 hari terakhir) untuk grafik bulanan
final dailyRaw = await _repo.getDailyHistory(days: 30);
dailyParsed = dailyRaw.map((map) {
final String dateStr = map['date'] ?? map['log_date'] ?? map['created_at'] ?? '';
final DateTime date = (DateTime.tryParse(dateStr) ?? DateTime.now()).toLocal();
final num? t = map['avg_temp'] ?? map['avg_temperature'] ?? map['temperature'];
final num? h = map['avg_hum'] ?? map['avg_humidity'] ?? map['humidity'];
return DailyAverage(date: date, suhu: t?.toDouble(), kelembapan: h?.toDouble());
}).toList();
// 3. Ambil riwayat agregasi per jam (7 hari terakhir) untuk grafik harian
final hourlyRaw = await _repo.getHourlyHistory(days: 7);
hourlyParsed = hourlyRaw.map((map) {
final String dateStr = map['date'] ?? map['log_date'] ?? map['created_at'] ?? '';
final DateTime date = (DateTime.tryParse(dateStr) ?? DateTime.now()).toLocal();
final num? t = map['avg_temp'] ?? map['avg_temperature'] ?? map['temperature'];
final num? h = map['avg_hum'] ?? map['avg_humidity'] ?? map['humidity'];
return DailyAverage(date: date, suhu: t?.toDouble(), kelembapan: h?.toDouble());
}).toList();
} catch (e) {
// Abaikan jika endpoint agregasi belum tersedia atau terjadi error (chart akan kosong)
}
// Ambil status keaktifan perangkat dari database
bool isDeviceOnline = false;
try {
final deviceStatus = await _repo.getDeviceStatus();
isDeviceOnline = deviceStatus['is_online'] as bool? ?? false;
} catch (e) {
if (latest != null) {
final diff = DateTime.now().difference(latest.timestamp);
isDeviceOnline = diff.inMinutes < 5;
}
}
state = state.copyWith(
latest: latest,
history: historyCharts,
dailyHistory: dailyParsed,
hourlyHistory: hourlyParsed,
isDeviceOnline: isDeviceOnline,
isLoading: false,
errorMessage: null,
);
} catch (e) {
state = state.copyWith(isLoading: false, errorMessage: e.toString());
}
}
/// Mengambil data sensor terbaru dari backend secara real-time.
/// Dipanggil oleh [Timer] setiap 5 detik sekali.
Future<void> fetchData({bool showLoading = false}) async {
final authState = _ref.read(authProvider);
final hasDevice = authState is AuthSuccess && authState.user.deviceId != null;
if (!hasDevice) return;
if (showLoading) {
state = state.copyWith(isLoading: true);
}
try {
final sensor = await _repo.getSensorData();
// Verifikasi apakah data yang masuk benar-benar baru berdasarkan timestamp-nya
bool isNew = true;
if (state.history.isNotEmpty) {
final lastPoint = state.history.last;
if (!sensor.timestamp.isAfter(lastPoint.timestamp)) {
isNew = false;
}
}
List<ChartData> updatedHistory = List.from(state.history);
if (isNew) {
final newPoint = ChartData(
timestamp: sensor.timestamp,
suhu: sensor.temperature,
kelembapan: sensor.humidity,
);
updatedHistory.add(newPoint);
}
// Membatasi panjang riwayat real-time maksimal 200 data poin agar hemat RAM
final trimmed = updatedHistory.length > 200
? updatedHistory.sublist(updatedHistory.length - 200)
: updatedHistory;
// Ambil status keaktifan perangkat dari database
bool isDeviceOnline = false;
try {
final deviceStatus = await _repo.getDeviceStatus();
isDeviceOnline = deviceStatus['is_online'] as bool? ?? false;
} catch (e) {
final diff = DateTime.now().difference(sensor.timestamp);
isDeviceOnline = diff.inMinutes < 5;
}
state = state.copyWith(
latest: sensor,
isLoading: false,
history: trimmed,
isDeviceOnline: isDeviceOnline,
errorMessage: null,
);
// Sinkronisasi status pompa dan mode yang didapat dari payload data sensor ESP32
// Ini memastikan UI sinkron dengan hardware asli jika dikendalikan secara eksternal
try {
_ref.read(controlProvider.notifier).syncRelayFromSensor(sensor.relayOn);
_ref.read(controlProvider.notifier).syncModeFromSensor(sensor.mode);
} catch (_) {
// Abaikan jika controlProvider belum siap (misal saat startup)
}
} catch (e) {
state = state.copyWith(
isLoading: false,
errorMessage: e.toString(),
);
}
}
/// Memuat riwayat data agregasi harian/bulanan dengan offset tertentu (navigasi grafik).
Future<void> fetchHistoryForOffset({
required ChartRange range,
required int offset,
}) async {
try {
if (range == ChartRange.daily) {
final int targetDays = offset.abs() + 1;
final int fetchDays = targetDays < 7 ? 7 : targetDays;
final hourlyRaw = await _repo.getHourlyHistory(days: fetchDays);
final hourlyParsed = hourlyRaw.map((map) {
final String dateStr = map['date'] ?? map['log_date'] ?? map['created_at'] ?? '';
final DateTime date = (DateTime.tryParse(dateStr) ?? DateTime.now()).toLocal();
final num? t = map['avg_temp'] ?? map['avg_temperature'] ?? map['temperature'];
final num? h = map['avg_hum'] ?? map['avg_humidity'] ?? map['humidity'];
return DailyAverage(date: date, suhu: t?.toDouble(), kelembapan: h?.toDouble());
}).toList();
state = state.copyWith(
hourlyHistory: hourlyParsed,
errorMessage: null,
);
} else {
final int targetDays = (offset.abs() + 1) * 31;
final int fetchDays = targetDays < 30 ? 30 : targetDays;
final dailyRaw = await _repo.getDailyHistory(days: fetchDays);
final dailyParsed = dailyRaw.map((map) {
final String dateStr = map['date'] ?? map['log_date'] ?? map['created_at'] ?? '';
final DateTime date = (DateTime.tryParse(dateStr) ?? DateTime.now()).toLocal();
final num? t = map['avg_temp'] ?? map['avg_temperature'] ?? map['temperature'];
final num? h = map['avg_hum'] ?? map['avg_humidity'] ?? map['humidity'];
return DailyAverage(date: date, suhu: t?.toDouble(), kelembapan: h?.toDouble());
}).toList();
state = state.copyWith(
dailyHistory: dailyParsed,
errorMessage: null,
);
}
} catch (e) {
// Abaikan agar tidak memutus polling data sensor real-time
}
}
/// Ambil data riwayat yang sudah difilter berdasarkan rentang waktu hari ini/bulan ini.
List<ChartData> getFilteredData(ChartRange range) {
final now = DateTime.now();
if (range == ChartRange.daily) {
final startOfDay = DateTime(now.year, now.month, now.day);
final endOfDay = startOfDay.add(const Duration(days: 1));
return state.history.where((d) =>
!d.timestamp.isBefore(startOfDay) &&
d.timestamp.isBefore(endOfDay),
).toList();
} else {
final firstDay = DateTime(now.year, now.month, 1);
final firstNext = DateTime(now.year, now.month + 1, 1);
return state.history.where((d) =>
d.timestamp.isAfter(firstDay.subtract(const Duration(seconds: 1))) &&
d.timestamp.isBefore(firstNext),
).toList();
}
}
/// Menghentikan polling berkala.
void stopRefresh() => _timer?.cancel();
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
}
// ── Main provider ──────────────────────────────────────────────
/// Provider utama untuk memantau data sensor. Digunakan oleh UI halaman Monitoring.
final monitoringProvider =
StateNotifierProvider<MonitoringNotifier, MonitoringState>(
(ref) => MonitoringNotifier(ref.watch(iotRepositoryProvider), ref),
);
// ── Daily average provider ─────────────────────────────────────
/// Model penampung nilai rata-rata suhu & kelembapan pada jam/hari tertentu.
class DailyAverage {
final DateTime date;
final double? suhu;
final double? kelembapan;
const DailyAverage({required this.date, this.suhu, this.kelembapan});
}
/// Provider yang memproses & mencocokkan riwayat data sensor dengan kalender lokal.
/// Menghasilkan daftar koordinat titik grafik yang siap digambar oleh FL Chart.
final dailyAverageProvider = Provider<List<DailyAverage>>((ref) {
final range = ref.watch(chartRangeProvider);
final monitoring = ref.watch(monitoringProvider);
final offset = ref.watch(chartOffsetProvider);
final now = DateTime.now();
final int totalDays;
final DateTime Function(int) dateAt;
if (range == ChartRange.daily) {
// Grafik Harian: 24 titik grafik (jam 00 s.d 23)
totalDays = 24;
final targetDate = now.add(Duration(days: offset)); // offset = 0 adalah hari ini, -1 kemarin, dst.
dateAt = (i) => DateTime(targetDate.year, targetDate.month, targetDate.day, i);
} else {
// Grafik Bulanan: Titik sesuai jumlah hari dalam bulan terpilih (28 s.d 31)
final targetMonth = DateTime(now.year, now.month + offset, 1);
final firstNext = DateTime(targetMonth.year, targetMonth.month + 1, 1);
final lastDay = firstNext.subtract(const Duration(days: 1));
totalDays = lastDay.day;
dateAt = (i) => DateTime(targetMonth.year, targetMonth.month, i + 1);
}
// Membuat daftar DateTime kosong untuk setiap jam/hari
final days = List.generate(totalDays, dateAt);
// Menyusun lookup map agar pencarian data agregasi dari API lebih cepat (O(1))
final lookup = <DateTime, DailyAverage>{};
if (range == ChartRange.daily) {
for (final item in monitoring.hourlyHistory) {
final key = DateTime(item.date.year, item.date.month, item.date.day, item.date.hour);
lookup[key] = item;
}
} else {
for (final item in monitoring.dailyHistory) {
final key = DateTime(item.date.year, item.date.month, item.date.day);
lookup[key] = item;
}
}
// Menggabungkan kalender kosong dengan data riwayat dari API (jika ada data di jam/hari tersebut)
return days.map((date) {
final apiItem = lookup[date];
if (apiItem != null) {
return DailyAverage(date: date, suhu: apiItem.suhu, kelembapan: apiItem.kelembapan);
}
return DailyAverage(date: date);
}).toList();
});