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((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.daily, ); /// Provider untuk menyimpan status pilihan tipe grafik yang aktif (Suhu / Kelembapan). final chartTypeProvider = StateProvider( (_) => ChartType.suhu, ); /// Provider untuk menyimpan status offset grafik (untuk navigasi hari/bulan sebelumnya atau setelahnya). final chartOffsetProvider = StateProvider( (_) => 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 history; /// Agregasi riwayat harian rata-rata untuk grafik bulanan. final List dailyHistory; /// Agregasi riwayat per jam rata-rata untuk grafik harian. final List hourlyHistory; const MonitoringState({ this.latest, this.isLoading = false, this.errorMessage, this.history = const [], this.dailyHistory = const [], this.hourlyHistory = const [], }); /// 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 maksimum suhu ([maxSuhu]). /// - Danger: jika suhu melebihi batas maks + 3°C. /// - Warning: jika suhu melebihi batas maks. /// - Normal: jika suhu berada di bawah batas maks. /// 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? history, List? dailyHistory, List? hourlyHistory, }) { 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, ); } } // 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 { 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 _startAutoRefresh() async { await _initData(); _timer = Timer.periodic(const Duration(seconds: 5), (_) => fetchData()); } /// Memuat data sensor awal beserta data riwayat grafik dari API. Future _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 dailyParsed = []; List 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) } state = state.copyWith( latest: latest, history: historyCharts, dailyHistory: dailyParsed, hourlyHistory: hourlyParsed, 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 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 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; state = state.copyWith( latest: sensor, isLoading: false, history: trimmed, 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 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 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( (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>((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 = {}; 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(); });