import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:mobile_monitoring/data/services/hidroponik_data_service.dart'; import 'package:mobile_monitoring/data/models/service_result.dart'; import 'package:mobile_monitoring/data/services/planting_cycles_service.dart'; import 'package:mobile_monitoring/core/utils/cycle_phase_utils.dart'; class GraphPreparedData { final List dayLabels; final List phValues; final List nutrientValues; GraphPreparedData({ required this.dayLabels, required this.phValues, required this.nutrientValues, }); } class WeeklyStatsData { final double minPh; final double maxPh; final double avgPh; final double minTds; final double maxTds; final double avgTds; WeeklyStatsData({ required this.minPh, required this.maxPh, required this.avgPh, required this.minTds, required this.maxTds, required this.avgTds, }); } class GraphComponentService { final HidroponikDataService _dataService; final PlantingCyclesService _cyclesService; GraphComponentService({ HidroponikDataService? dataService, PlantingCyclesService? cyclesService, }) : _dataService = dataService ?? HidroponikDataService(), _cyclesService = cyclesService ?? PlantingCyclesService(); Stream?> cycleStream(String userId) { if (userId.isEmpty) { return Stream.value(null); } return _cyclesService.getActiveCycleStream(userId: userId); } Stream>> historyStream({ required String? userId, int limit = 500, }) { return _dataService.historyStream(limit: limit, userId: userId); } DateTime? extractStartDate(Map? cycleData) { return CyclePhaseUtils.extractStartDate(cycleData); } bool isAuthError(Object error) { return error.toString().contains('terautentikasi'); } ServiceResult prepareGraphData( List> docs, DateTime? startDate, ) { if (docs.isEmpty) { return ServiceResult.failure( message: 'Belum ada data monitoring', errorCode: 'no_data', ); } final validDocs = >[]; for (final doc in docs) { final timestamp = doc['timestamp']; DateTime? dt; if (timestamp is Timestamp) { dt = timestamp.toDate(); } else if (timestamp is DateTime) { dt = timestamp; } if (dt != null) validDocs.add({...doc, 'datetime': dt}); } if (validDocs.isEmpty) { return ServiceResult.failure( message: 'Belum ada data valid untuk ditampilkan', errorCode: 'invalid_data', ); } validDocs.sort( (a, b) => (a['datetime'] as DateTime).compareTo(b['datetime'] as DateTime), ); final now = DateTime.now(); final filterEndDate = DateTime(now.year, now.month, now.day, 23, 59, 59); DateTime filterStartDate; if (startDate != null) { final plantingStart = DateTime( startDate.year, startDate.month, startDate.day, ); final sevenDaysAgo = now.subtract(const Duration(days: 6)); filterStartDate = plantingStart.isBefore(sevenDaysAgo) ? sevenDaysAgo : plantingStart; } else { filterStartDate = now.subtract(const Duration(days: 6)); } final filteredDocs = validDocs.where((doc) { final dt = doc['datetime'] as DateTime; return dt.compareTo(filterStartDate) >= 0 && dt.compareTo(filterEndDate) <= 0; }).toList(); if (filteredDocs.isEmpty) { return ServiceResult.failure( message: 'Belum ada data dalam rentang waktu grafik', errorCode: 'no_data_in_range', ); } final dayCount = filterEndDate.difference(filterStartDate).inDays + 1; final buckets = List.generate(dayCount, (_) => _DailyBucket()); for (final doc in filteredDocs) { final dt = doc['datetime'] as DateTime; final phVal = (doc['pH_value'] as num?)?.toDouble() ?? 0.0; final nutVal = (doc['nutrient_level'] as num?)?.toDouble() ?? 0.0; final dayIndex = dt.difference(filterStartDate).inDays; if (dayIndex >= 0 && dayIndex < dayCount) { final slotIndex = _slotIndexForHour(dt.hour); buckets[dayIndex].add( slotIndex: slotIndex, ph: phVal, nutrient: nutVal, ); } } if (!buckets.any((b) => b.hasData)) { return ServiceResult.failure( message: 'Belum ada data agregat yang bisa ditampilkan', errorCode: 'no_aggregate_data', ); } const dayNames = ['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min']; final dayLabels = []; for (var i = 0; i < dayCount; i++) { final dayDate = filterStartDate.add(Duration(days: i)); dayLabels.add( '${dayNames[dayDate.weekday - 1]}\n${dayDate.day}/${dayDate.month}', ); } final phValues = List.generate(dayCount, (i) => buckets[i].phAverage); final nutrientValues = List.generate( dayCount, (i) => buckets[i].nutrientAverage, ); return ServiceResult.success( message: 'Data grafik berhasil diproses', data: GraphPreparedData( dayLabels: dayLabels, phValues: phValues, nutrientValues: nutrientValues, ), ); } ServiceResult calculateWeeklyStats( List> docs, ) { if (docs.isEmpty) { return ServiceResult.failure( message: 'Belum ada data statistik', errorCode: 'no_data', ); } final phValues = []; final tdsValues = []; for (final data in docs) { phValues.add((data['pH_value'] as num?)?.toDouble() ?? 0.0); tdsValues.add((data['nutrient_level'] as num?)?.toDouble() ?? 0.0); } if (phValues.isEmpty || tdsValues.isEmpty) { return ServiceResult.failure( message: 'Data statistik tidak valid', errorCode: 'invalid_data', ); } final minPh = phValues.reduce((a, b) => a < b ? a : b); final maxPh = phValues.reduce((a, b) => a > b ? a : b); final avgPh = phValues.reduce((a, b) => a + b) / phValues.length; final minTds = tdsValues.reduce((a, b) => a < b ? a : b); final maxTds = tdsValues.reduce((a, b) => a > b ? a : b); final avgTds = tdsValues.reduce((a, b) => a + b) / tdsValues.length; return ServiceResult.success( message: 'Statistik mingguan berhasil dihitung', data: WeeklyStatsData( minPh: minPh, maxPh: maxPh, avgPh: avgPh, minTds: minTds, maxTds: maxTds, avgTds: avgTds, ), ); } int _slotIndexForHour(int hour) { if (hour >= 5 && hour < 12) return 0; if (hour >= 12 && hour < 18) return 1; return 2; } } class _DailyBucket { final _slots = List.generate(3, (_) => _DailySlot()); void add({required int slotIndex, double? ph, double? nutrient}) { _slots[slotIndex].add(ph: ph, nutrient: nutrient); } bool get hasData => _slots.any((slot) => slot.hasData); double get phAverage { final slotAverages = _slots .where((s) => s.phCount > 0) .map((s) => s.phSum / s.phCount) .toList(); return slotAverages.isEmpty ? 0 : slotAverages.reduce((a, b) => a + b) / slotAverages.length; } double get nutrientAverage { final slotAverages = _slots .where((s) => s.nutrientCount > 0) .map((s) => s.nutrientSum / s.nutrientCount) .toList(); return slotAverages.isEmpty ? 0 : slotAverages.reduce((a, b) => a + b) / slotAverages.length; } } class _DailySlot { double phSum = 0; double nutrientSum = 0; int phCount = 0; int nutrientCount = 0; void add({double? ph, double? nutrient}) { if (ph != null) { phSum += ph; phCount++; } if (nutrient != null) { nutrientSum += nutrient; nutrientCount++; } } bool get hasData => phCount > 0 || nutrientCount > 0; }