HydroNutrify_Ari_e41221567/lib/ui/widgets/graph_component_service.dart

288 lines
7.9 KiB
Dart

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<String> dayLabels;
final List<double> phValues;
final List<double> 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<Map<String, dynamic>?> cycleStream(String userId) {
if (userId.isEmpty) {
return Stream.value(null);
}
return _cyclesService.getActiveCycleStream(userId: userId);
}
Stream<List<Map<String, dynamic>>> historyStream({
required String? userId,
int limit = 500,
}) {
return _dataService.historyStream(limit: limit, userId: userId);
}
DateTime? extractStartDate(Map<String, dynamic>? cycleData) {
return CyclePhaseUtils.extractStartDate(cycleData);
}
bool isAuthError(Object error) {
return error.toString().contains('terautentikasi');
}
ServiceResult<GraphPreparedData> prepareGraphData(
List<Map<String, dynamic>> docs,
DateTime? startDate,
) {
if (docs.isEmpty) {
return ServiceResult.failure(
message: 'Belum ada data monitoring',
errorCode: 'no_data',
);
}
final validDocs = <Map<String, dynamic>>[];
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 = <String>[];
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<WeeklyStatsData> calculateWeeklyStats(
List<Map<String, dynamic>> docs,
) {
if (docs.isEmpty) {
return ServiceResult.failure(
message: 'Belum ada data statistik',
errorCode: 'no_data',
);
}
final phValues = <double>[];
final tdsValues = <double>[];
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;
}