288 lines
7.9 KiB
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;
|
|
}
|