344 lines
9.5 KiB
Dart
344 lines
9.5 KiB
Dart
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart' show Color;
|
|
import 'package:mobile_monitoring/data/models/service_result.dart';
|
|
import 'package:mobile_monitoring/core/utils/cycle_phase_utils.dart';
|
|
|
|
/// Model untuk monitoring data di Flow page
|
|
class FlowMonitoringData {
|
|
final double ph;
|
|
final double tds;
|
|
final String phStatus;
|
|
final String tdsStatus;
|
|
final Color phColor;
|
|
final Color tdsColor;
|
|
final bool pumpPhUp;
|
|
final bool pumpPhDown;
|
|
final bool pumpNutrient;
|
|
final bool pumpWater;
|
|
|
|
FlowMonitoringData({
|
|
required this.ph,
|
|
required this.tds,
|
|
required this.phStatus,
|
|
required this.tdsStatus,
|
|
required this.phColor,
|
|
required this.tdsColor,
|
|
required this.pumpPhUp,
|
|
required this.pumpPhDown,
|
|
required this.pumpNutrient,
|
|
required this.pumpWater,
|
|
});
|
|
}
|
|
|
|
/// Model untuk recap row
|
|
class RecapRow {
|
|
final String day;
|
|
final String morning;
|
|
final String afternoon;
|
|
final String night;
|
|
final DateTime date;
|
|
|
|
RecapRow({
|
|
required this.day,
|
|
required this.morning,
|
|
required this.afternoon,
|
|
required this.night,
|
|
required this.date,
|
|
});
|
|
|
|
Map<String, dynamic> toMap() {
|
|
return {
|
|
'day': day,
|
|
'morning': morning,
|
|
'afternoon': afternoon,
|
|
'night': night,
|
|
'date': date,
|
|
};
|
|
}
|
|
}
|
|
|
|
/// Model untuk aggregate data per slot waktu
|
|
class _SlotAggregate {
|
|
double? _phSum;
|
|
double? _tdsSum;
|
|
int _count = 0;
|
|
|
|
void add({required double ph, required double tds}) {
|
|
_phSum = (_phSum ?? 0) + ph;
|
|
_tdsSum = (_tdsSum ?? 0) + tds;
|
|
_count++;
|
|
}
|
|
|
|
bool get hasData => _count > 0;
|
|
double? get phAverage => _count > 0 ? _phSum! / _count : null;
|
|
double? get tdsAverage => _count > 0 ? _tdsSum! / _count : null;
|
|
}
|
|
|
|
/// Error info untuk Flow page
|
|
class FlowDataError {
|
|
final String displayMessage;
|
|
final String detailMessage;
|
|
final bool isAuthError;
|
|
|
|
FlowDataError({
|
|
required this.displayMessage,
|
|
required this.detailMessage,
|
|
required this.isAuthError,
|
|
});
|
|
|
|
factory FlowDataError.fromException(Object error) {
|
|
final errorMsg = error.toString();
|
|
debugPrint('[FlowService] Error: $errorMsg');
|
|
|
|
bool isNotAuthError = errorMsg.contains('terautentikasi');
|
|
|
|
if (isNotAuthError) {
|
|
return FlowDataError(
|
|
displayMessage: 'Silakan Login',
|
|
detailMessage: 'Sesi Anda telah berakhir, login kembali',
|
|
isAuthError: true,
|
|
);
|
|
} else {
|
|
return FlowDataError(
|
|
displayMessage: 'Gagal memuat data',
|
|
detailMessage: 'Pastikan koneksi internet stabil',
|
|
isAuthError: false,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Service untuk Flow Page - Backend Logic
|
|
/// Tidak ada dependency ke UI (no BuildContext, no Color from material)
|
|
class FlowService {
|
|
static const List<String> dayNames = [
|
|
'Sen',
|
|
'Sel',
|
|
'Rab',
|
|
'Kam',
|
|
'Jum',
|
|
'Sab',
|
|
'Min',
|
|
];
|
|
|
|
FlowMonitoringData _parseMonitoringData(Map<String, dynamic> data) {
|
|
debugPrint('[FlowService] parseMonitoringData()');
|
|
|
|
// Extract values
|
|
final double ph = (data['pH_value'] as num?)?.toDouble() ?? 0.0;
|
|
final double tds = (data['nutrient_level'] as num?)?.toDouble() ?? 0.0;
|
|
|
|
// Extract pump status
|
|
final bool pumpPhUp = data['pump_ph_up_status'] ?? false;
|
|
final bool pumpPhDown = data['pump_ph_down_status'] ?? false;
|
|
final bool pumpNutrient = data['pump_nutrient_status'] ?? false;
|
|
final bool pumpWater = data['pump_water_status'] ?? false;
|
|
|
|
// Determine abnormality
|
|
final bool phPumpOn = pumpPhUp || pumpPhDown;
|
|
final bool nutrientPumpOn = pumpNutrient || pumpWater;
|
|
|
|
// Determine status and colors
|
|
final String phStatus = phPumpOn ? 'Abnormal' : 'Normal';
|
|
final String tdsStatus = nutrientPumpOn ? 'Abnormal' : 'Normal';
|
|
|
|
// Use color codes instead of Color objects for backend
|
|
// Frontend will convert these to Color objects
|
|
final Color phColor = phPumpOn
|
|
? const Color(0xFFFF9800)
|
|
: const Color(0xFF4CAF50);
|
|
final Color tdsColor = nutrientPumpOn
|
|
? const Color(0xFFFF9800)
|
|
: const Color(0xFF4CAF50);
|
|
|
|
debugPrint('[FlowService] pH: $ph, status: $phStatus');
|
|
debugPrint('[FlowService] TDS: $tds, status: $tdsStatus');
|
|
|
|
return FlowMonitoringData(
|
|
ph: ph,
|
|
tds: tds,
|
|
phStatus: phStatus,
|
|
tdsStatus: tdsStatus,
|
|
phColor: phColor,
|
|
tdsColor: tdsColor,
|
|
pumpPhUp: pumpPhUp,
|
|
pumpPhDown: pumpPhDown,
|
|
pumpNutrient: pumpNutrient,
|
|
pumpWater: pumpWater,
|
|
);
|
|
}
|
|
|
|
ServiceResult<FlowMonitoringData> parseMonitoringDataResult(
|
|
Map<String, dynamic> data,
|
|
) {
|
|
try {
|
|
final parsed = _parseMonitoringData(data);
|
|
return ServiceResult.success(
|
|
message: 'Data flow monitoring berhasil diproses',
|
|
data: parsed,
|
|
);
|
|
} catch (e) {
|
|
return ServiceResult.failure(
|
|
message: 'Gagal memproses data flow monitoring',
|
|
errorCode: 'parse_error',
|
|
);
|
|
}
|
|
}
|
|
|
|
List<RecapRow> _generateRecapRows(
|
|
List<Map<String, dynamic>> docs,
|
|
DateTime? startDate,
|
|
) {
|
|
debugPrint('[FlowService] generateRecapRows()');
|
|
debugPrint('[FlowService] Total docs: ${docs.length}');
|
|
debugPrint('[FlowService] Start date: $startDate');
|
|
|
|
final now = DateTime.now();
|
|
final filterEndDate = DateTime(now.year, now.month, now.day, 23, 59, 59);
|
|
|
|
// Determine filter start date
|
|
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 dayCount = filterEndDate.difference(filterStartDate).inDays + 1;
|
|
debugPrint(
|
|
'[FlowService] Filter start: $filterStartDate, end: $filterEndDate',
|
|
);
|
|
debugPrint('[FlowService] Day count: $dayCount');
|
|
|
|
// Create buckets for each day and time slot
|
|
final buckets = List.generate(
|
|
dayCount,
|
|
(_) => List.generate(3, (_) => _SlotAggregate()),
|
|
);
|
|
|
|
// Aggregate data into buckets
|
|
int processedCount = 0;
|
|
for (final data in docs) {
|
|
final timestamp = data['timestamp'];
|
|
DateTime? dt;
|
|
if (timestamp is Timestamp) {
|
|
dt = timestamp.toDate();
|
|
} else if (timestamp is DateTime) {
|
|
dt = timestamp;
|
|
}
|
|
|
|
if (dt == null ||
|
|
dt.isBefore(filterStartDate) ||
|
|
dt.isAfter(filterEndDate)) {
|
|
continue;
|
|
}
|
|
|
|
final ph = (data['pH_value'] as num?)?.toDouble() ?? 0.0;
|
|
final tds = (data['nutrient_level'] as num?)?.toDouble() ?? 0.0;
|
|
|
|
final dayIdx = dt.difference(filterStartDate).inDays;
|
|
if (dayIdx >= 0 && dayIdx < dayCount) {
|
|
final slotIdx = _getSlotIndexForHour(dt.hour);
|
|
buckets[dayIdx][slotIdx].add(ph: ph, tds: tds);
|
|
processedCount++;
|
|
}
|
|
}
|
|
|
|
debugPrint('[FlowService] Processed $processedCount data points');
|
|
|
|
// Generate rows
|
|
final rows = <RecapRow>[];
|
|
for (var i = 0; i < dayCount; i++) {
|
|
final date = filterStartDate.add(Duration(days: i));
|
|
final dayName =
|
|
'${dayNames[(date.weekday - 1) % 7]} ${date.day}/${date.month}';
|
|
rows.add(
|
|
RecapRow(
|
|
day: dayName,
|
|
morning: _formatSlotText(buckets[i][0]),
|
|
afternoon: _formatSlotText(buckets[i][1]),
|
|
night: _formatSlotText(buckets[i][2]),
|
|
date: date,
|
|
),
|
|
);
|
|
}
|
|
|
|
debugPrint('[FlowService] Generated ${rows.length} recap rows');
|
|
return rows;
|
|
}
|
|
|
|
ServiceResult<List<RecapRow>> generateRecapRowsResult(
|
|
List<Map<String, dynamic>> docs,
|
|
DateTime? startDate,
|
|
) {
|
|
try {
|
|
final rows = _generateRecapRows(docs, startDate);
|
|
return ServiceResult.success(
|
|
message: 'Rekap monitoring berhasil dibuat',
|
|
data: rows,
|
|
);
|
|
} catch (e) {
|
|
return ServiceResult.failure(
|
|
message: 'Gagal membuat rekap monitoring',
|
|
errorCode: 'recap_error',
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Get slot index based on hour (0=morning, 1=afternoon, 2=night)
|
|
int _getSlotIndexForHour(int hour) {
|
|
if (hour >= 5 && hour < 12) {
|
|
return 0; // Morning (5-12)
|
|
} else if (hour >= 12 && hour < 17) {
|
|
return 1; // Afternoon (12-17)
|
|
} else {
|
|
return 2; // Night (17-5)
|
|
}
|
|
}
|
|
|
|
/// Format slot aggregate data to display text
|
|
String _formatSlotText(_SlotAggregate agg) {
|
|
if (!agg.hasData) return '-';
|
|
final phAvg = agg.phAverage?.toStringAsFixed(1);
|
|
final tdsAvg = agg.tdsAverage?.toStringAsFixed(0);
|
|
final phPart = phAvg != null ? 'pH $phAvg' : '';
|
|
final tdsPart = tdsAvg != null
|
|
? '${phPart.isNotEmpty ? ' / ' : ''}${tdsAvg}ppm'
|
|
: '';
|
|
return (phPart + tdsPart).isEmpty ? '-' : phPart + tdsPart;
|
|
}
|
|
|
|
ServiceResult<FlowDataError> getErrorInfoResult(Object error) {
|
|
return ServiceResult.success(
|
|
message: 'Error flow berhasil dipetakan',
|
|
data: FlowDataError.fromException(error),
|
|
);
|
|
}
|
|
|
|
ServiceResult<bool> hasActiveCycleResult(Map<String, dynamic>? cycleData) {
|
|
return ServiceResult.success(
|
|
message: 'Status siklus aktif berhasil diperiksa',
|
|
data: cycleData != null && cycleData.isNotEmpty,
|
|
);
|
|
}
|
|
|
|
ServiceResult<DateTime?> extractStartDateResult(
|
|
Map<String, dynamic>? cycleData,
|
|
) {
|
|
final startDate = CyclePhaseUtils.extractStartDate(cycleData);
|
|
return ServiceResult.success(
|
|
message: 'Tanggal mulai siklus berhasil diambil',
|
|
data: startDate,
|
|
);
|
|
}
|
|
}
|