317 lines
11 KiB
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();
|
|
});
|
|
|
|
|