431 lines
17 KiB
Dart
431 lines
17 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;
|
|
|
|
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.
|
|
MonitoringStatus tempStatus(double maxSuhu) {
|
|
if (temperature >= maxSuhu + 3) return MonitoringStatus.danger;
|
|
if (temperature > maxSuhu) 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 maxSuhu, double minHum) {
|
|
final statuses = [tempStatus(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,
|
|
}) {
|
|
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<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)
|
|
}
|
|
|
|
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<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;
|
|
|
|
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<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();
|
|
});
|
|
|
|
|