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

317 lines
11 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 ──────────────────────────────────────────────────────
enum ChartRange { daily, monthly }
enum ChartType { suhu, kelembapan }
// ── Supporting providers ───────────────────────────────────────
final iotRepositoryProvider = Provider<IotRepository>((ref) {
final authState = ref.watch(authProvider);
String deviceId = 'esp32-01'; // Default fallback
if (authState is AuthSuccess && authState.user.deviceId != null) {
deviceId = authState.user.deviceId!;
}
return IotRepository(ApiIotService(deviceId));
});
final chartRangeProvider = StateProvider<ChartRange>(
(_) => ChartRange.daily,
);
final chartTypeProvider = StateProvider<ChartType>(
(_) => ChartType.suhu,
);
final chartOffsetProvider = StateProvider<int>(
(_) => 0,
);
// ── State ──────────────────────────────────────────────────────
class MonitoringState {
final SensorModel? latest;
final bool isLoading;
final String? errorMessage;
final List<ChartData> history;
final List<DailyAverage> dailyHistory;
final List<DailyAverage> hourlyHistory;
const MonitoringState({
this.latest,
this.isLoading = false,
this.errorMessage,
this.history = const [],
this.dailyHistory = const [],
this.hourlyHistory = const [],
});
factory MonitoringState.initial() => const MonitoringState(isLoading: true);
double get temperature => latest?.temperature ?? 0;
double get humidity => latest?.humidity ?? 0;
MonitoringStatus get tempStatus {
if (temperature >= 33) return MonitoringStatus.danger;
if (temperature > 30) return MonitoringStatus.warning;
return MonitoringStatus.normal;
}
MonitoringStatus get humidStatus {
if (humidity < 65) return MonitoringStatus.danger;
if (humidity < 70) return MonitoringStatus.warning;
return MonitoringStatus.normal;
}
String get overallStatus {
final statuses = [tempStatus, humidStatus];
if (statuses.contains(MonitoringStatus.danger)) return 'Danger';
if (statuses.contains(MonitoringStatus.warning)) return 'Warning';
return 'Normal';
}
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 = clear 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 ───────────────────────────────────────────────────
class MonitoringNotifier extends StateNotifier<MonitoringState> {
final IotRepository _repo;
final Ref _ref;
Timer? _timer;
MonitoringNotifier(this._repo, this._ref) : super(MonitoringState.initial()) {
_startAutoRefresh();
}
Future<void> _startAutoRefresh() async {
await _initData();
_timer = Timer.periodic(const Duration(seconds: 5), (_) => fetchData());
}
Future<void> _initData() async {
state = state.copyWith(isLoading: true);
try {
final historyData = await _repo.getHistoryData();
final latest = historyData.isNotEmpty ? historyData.last : null;
final historyCharts = historyData.map((s) => ChartData(
timestamp: s.timestamp,
suhu: s.temperature,
kelembapan: s.humidity,
)).toList();
List<DailyAverage> dailyParsed = [];
List<DailyAverage> hourlyParsed = [];
try {
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();
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 belum tersedia atau error (fallback 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());
}
}
Future<void> fetchData({bool showLoading = false}) async {
if (showLoading) {
state = state.copyWith(isLoading: true);
}
try {
final sensor = await _repo.getSensorData();
// Cek apakah data terbaru memiliki timestamp lebih baru dari yang ada di history terakhir
bool isNew = true;
if (state.history.isNotEmpty) {
final lastPoint = state.history.last;
// Gunakan isAfter atau compare timestamp
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);
}
final trimmed = updatedHistory.length > 200
? updatedHistory.sublist(updatedHistory.length - 200)
: updatedHistory;
state = state.copyWith(
latest: sensor,
isLoading: false,
history: trimmed,
errorMessage: null,
);
// Bug #7: Sinkronisasi status pompa dari relay_state yang dikirim ESP32
// Ini memastikan UI selalu mencerminkan kondisi relay yang sesungguhnya
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(),
);
}
}
/// Ambil data history yang sudah difilter berdasarkan range waktu
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();
}
}
void stopRefresh() => _timer?.cancel();
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
}
// ── Main provider ──────────────────────────────────────────────
final monitoringProvider =
StateNotifierProvider<MonitoringNotifier, MonitoringState>(
(ref) => MonitoringNotifier(ref.read(iotRepositoryProvider), ref),
);
// ── Daily average provider ─────────────────────────────────────
class DailyAverage {
final DateTime date;
final double? suhu;
final double? kelembapan;
const DailyAverage({required this.date, this.suhu, this.kelembapan});
}
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) {
totalDays = 24;
final targetDate = now.add(Duration(days: offset)); // offset = 0 is today, -1 is yesterday
dateAt = (i) => DateTime(targetDate.year, targetDate.month, targetDate.day, i);
} else {
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);
}
final days = List.generate(totalDays, dateAt);
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;
}
}
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();
});