pepe
This commit is contained in:
parent
e00d471796
commit
3df7ee1fbe
|
|
@ -16,15 +16,17 @@ class TimeSeriesMapper {
|
||||||
final time = getTime(item);
|
final time = getTime(item);
|
||||||
|
|
||||||
if (_isSameDay(time, now)) {
|
if (_isSameDay(time, now)) {
|
||||||
final hour = time.hour;
|
final hour = time.toLocal().hour; // ✅ FIX: pastikan pakai local hour
|
||||||
sums[hour] += getValue(item);
|
if (hour >= 0 && hour < 24) {
|
||||||
counts[hour]++;
|
sums[hour] += getValue(item);
|
||||||
|
counts[hour]++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return List.generate(24, (i) {
|
return List.generate(24, (i) {
|
||||||
if (counts[i] == 0) return 0;
|
if (counts[i] == 0) return 0;
|
||||||
return sums[i] / counts[i]; // 🔥 average, bukan overwrite
|
return sums[i] / counts[i];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,12 +48,14 @@ class TimeSeriesMapper {
|
||||||
DateTime(startOfWeek.year, startOfWeek.month, startOfWeek.day);
|
DateTime(startOfWeek.year, startOfWeek.month, startOfWeek.day);
|
||||||
|
|
||||||
for (final item in data) {
|
for (final item in data) {
|
||||||
final time = getTime(item);
|
final time = getTime(item).toLocal(); // ✅ FIX: konversi ke local
|
||||||
|
|
||||||
if (time.isAfter(startOfWeek)) {
|
if (!time.isBefore(startOfWeek)) {
|
||||||
final index = time.weekday - 1;
|
final index = time.weekday - 1;
|
||||||
sums[index] += getValue(item);
|
if (index >= 0 && index < 7) {
|
||||||
counts[index]++;
|
sums[index] += getValue(item);
|
||||||
|
counts[index]++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,12 +80,14 @@ class TimeSeriesMapper {
|
||||||
final counts = List<int>.filled(daysInMonth, 0);
|
final counts = List<int>.filled(daysInMonth, 0);
|
||||||
|
|
||||||
for (final item in data) {
|
for (final item in data) {
|
||||||
final time = getTime(item);
|
final time = getTime(item).toLocal(); // ✅ FIX: konversi ke local
|
||||||
|
|
||||||
if (time.month == now.month && time.year == now.year) {
|
if (time.month == now.month && time.year == now.year) {
|
||||||
final index = time.day - 1;
|
final index = time.day - 1;
|
||||||
sums[index] += getValue(item);
|
if (index >= 0 && index < daysInMonth) {
|
||||||
counts[index]++;
|
sums[index] += getValue(item);
|
||||||
|
counts[index]++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,7 +97,7 @@ class TimeSeriesMapper {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// =========================
|
/// =========================
|
||||||
/// 📅 SPECIFIC DATE (24 JAM - TANGGAL KHUSUS)
|
/// 📅 SPECIFIC DATE (24 JAM - TANGGAL KHUSUS)
|
||||||
/// =========================
|
/// =========================
|
||||||
static List<double> toSpecificDate<T>({
|
static List<double> toSpecificDate<T>({
|
||||||
|
|
@ -107,9 +113,11 @@ class TimeSeriesMapper {
|
||||||
final time = getTime(item);
|
final time = getTime(item);
|
||||||
|
|
||||||
if (_isSameDay(time, targetDate)) {
|
if (_isSameDay(time, targetDate)) {
|
||||||
final hour = time.hour;
|
final hour = time.toLocal().hour; // ✅ FIX: pastikan pakai local hour
|
||||||
sums[hour] += getValue(item);
|
if (hour >= 0 && hour < 24) {
|
||||||
counts[hour]++;
|
sums[hour] += getValue(item);
|
||||||
|
counts[hour]++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,21 +128,23 @@ class TimeSeriesMapper {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// =========================
|
/// =========================
|
||||||
/// 🧠 HELPER
|
/// 🧠 HELPER — Bandingkan tanggal secara LOCAL (bukan UTC)
|
||||||
|
/// ✅ FIX: Firebase datetime "2026-05-14 01:25:25" di-parse sebagai local time,
|
||||||
|
/// jadi perbandingan harus pakai local time juga agar tidak mismatch timezone
|
||||||
/// =========================
|
/// =========================
|
||||||
/// Sinkronisasi tanggal untuk menghindari mismatch akibat timezone (UTC vs local).
|
|
||||||
/// Kita bandingkan berdasarkan UTC.
|
|
||||||
static bool _isSameDay(DateTime a, DateTime b) {
|
static bool _isSameDay(DateTime a, DateTime b) {
|
||||||
final au = a.toUtc();
|
final al = a.toLocal();
|
||||||
final bu = b.toUtc();
|
final bl = b.toLocal();
|
||||||
return au.year == bu.year && au.month == bu.month && au.day == bu.day;
|
return al.year == bl.year && al.month == bl.month && al.day == bl.day;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// =========================
|
||||||
|
/// 📈 SMOOTH (moving average 3 titik)
|
||||||
|
/// =========================
|
||||||
static List<double> smooth(List<double> data) {
|
static List<double> smooth(List<double> data) {
|
||||||
if (data.length < 3) return data;
|
if (data.length < 3) return data;
|
||||||
|
|
||||||
List<double> result = [];
|
final List<double> result = [];
|
||||||
|
|
||||||
for (int i = 0; i < data.length; i++) {
|
for (int i = 0; i < data.length; i++) {
|
||||||
if (i == 0 || i == data.length - 1) {
|
if (i == 0 || i == data.length - 1) {
|
||||||
|
|
@ -147,4 +157,4 @@ class TimeSeriesMapper {
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -33,7 +33,6 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
||||||
) async {
|
) async {
|
||||||
emit(state.copyWith(isLoading: true));
|
emit(state.copyWith(isLoading: true));
|
||||||
|
|
||||||
// Ambil history yang akan dipakai untuk list + agregasi grafik.
|
|
||||||
final history = await _repository.getSensorHistory(
|
final history = await _repository.getSensorHistory(
|
||||||
'Monitoring/History',
|
'Monitoring/History',
|
||||||
(json) => Evaporasi.fromJson(json),
|
(json) => Evaporasi.fromJson(json),
|
||||||
|
|
@ -45,7 +44,7 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
||||||
final dailyGraph = TimeSeriesMapper.toDaily(
|
final dailyGraph = TimeSeriesMapper.toDaily(
|
||||||
data: history,
|
data: history,
|
||||||
getTime: (e) => e.timestamp,
|
getTime: (e) => e.timestamp,
|
||||||
getValue: (e) => e.evaporasi, // ⚠️ sesuaikan nama field
|
getValue: (e) => e.evaporasi,
|
||||||
);
|
);
|
||||||
|
|
||||||
final dailyTempGraph = TimeSeriesMapper.toDaily(
|
final dailyTempGraph = TimeSeriesMapper.toDaily(
|
||||||
|
|
@ -112,7 +111,6 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
||||||
_EvaporasiRealtimeUpdated event,
|
_EvaporasiRealtimeUpdated event,
|
||||||
Emitter<EvaporasiState> emit,
|
Emitter<EvaporasiState> emit,
|
||||||
) {
|
) {
|
||||||
// Hindari update dobel: jika timestamp event sama dengan yang terakhir, jangan ubah bucket.
|
|
||||||
final previous =
|
final previous =
|
||||||
state.history.isNotEmpty ? state.history.last.timestamp : null;
|
state.history.isNotEmpty ? state.history.last.timestamp : null;
|
||||||
|
|
||||||
|
|
@ -121,7 +119,6 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
||||||
final isDuplicate =
|
final isDuplicate =
|
||||||
previous != null && event.data.timestamp.toUtc() == previous.toUtc();
|
previous != null && event.data.timestamp.toUtc() == previous.toUtc();
|
||||||
|
|
||||||
// Update bucket berdasarkan timestamp event (bukan jam lokal sekarang).
|
|
||||||
final updated =
|
final updated =
|
||||||
isDuplicate ? state.dailyValues : List<double>.from(state.dailyValues);
|
isDuplicate ? state.dailyValues : List<double>.from(state.dailyValues);
|
||||||
final updatedTemp = isDuplicate
|
final updatedTemp = isDuplicate
|
||||||
|
|
@ -135,7 +132,7 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
||||||
eventTime.toUtc().month == now.toUtc().month &&
|
eventTime.toUtc().month == now.toUtc().month &&
|
||||||
eventTime.toUtc().day == now.toUtc().day;
|
eventTime.toUtc().day == now.toUtc().day;
|
||||||
|
|
||||||
if (isSameDayUtc) {
|
if (isSameDayUtc && !isDuplicate) {
|
||||||
final index = eventTime.hour;
|
final index = eventTime.hour;
|
||||||
if (index >= 0 && index < updated.length) {
|
if (index >= 0 && index < updated.length) {
|
||||||
updated[index] = event.data.evaporasi;
|
updated[index] = event.data.evaporasi;
|
||||||
|
|
@ -210,6 +207,8 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
||||||
dailyTemperatures: updatedTemp,
|
dailyTemperatures: updatedTemp,
|
||||||
chartLabels: _buildChartLabels(period: event.period),
|
chartLabels: _buildChartLabels(period: event.period),
|
||||||
viewMode: EvaporasiViewMode.period,
|
viewMode: EvaporasiViewMode.period,
|
||||||
|
// ✅ FIX: reset selectedDate saat kembali ke mode period
|
||||||
|
clearSelectedDate: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
listData: state.listData,
|
listData: state.listData,
|
||||||
history: state.history,
|
history: state.history,
|
||||||
|
|
@ -242,11 +241,12 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
||||||
targetDate: event.date,
|
targetDate: event.date,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ✅ FIX: update chartLabels untuk custom date (24 jam label)
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
dailyValues: updated,
|
dailyValues: updated,
|
||||||
dailyTemperatures: updatedTemp,
|
dailyTemperatures: updatedTemp,
|
||||||
|
chartLabels: _buildChartLabels(period: 'Tanggal Khusus'),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
// jangan hilangkan list
|
|
||||||
listData: state.listData,
|
listData: state.listData,
|
||||||
history: state.history,
|
history: state.history,
|
||||||
));
|
));
|
||||||
|
|
@ -326,4 +326,4 @@ class _EvaporasiRealtimeUpdated extends EvaporasiEvent {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object> get props => [data];
|
List<Object> get props => [data];
|
||||||
}
|
}
|
||||||
|
|
@ -3,34 +3,26 @@ part of 'evaporasi_bloc.dart';
|
||||||
enum EvaporasiViewMode { period, customDate }
|
enum EvaporasiViewMode { period, customDate }
|
||||||
|
|
||||||
class EvaporasiState extends Equatable {
|
class EvaporasiState extends Equatable {
|
||||||
final double currentValue; // nilai evaporasi realtime
|
final double currentValue;
|
||||||
final double temperature; // suhu (opsional dari firebase)
|
final double temperature;
|
||||||
final double waterLevel; // tinggi air
|
final double waterLevel;
|
||||||
final String selectedPeriod;
|
final String selectedPeriod;
|
||||||
final DateTime? selectedDate; // tanggal spesifik untuk custom date
|
final DateTime? selectedDate;
|
||||||
final EvaporasiViewMode viewMode; // period vs custom date
|
final EvaporasiViewMode viewMode;
|
||||||
|
|
||||||
final List<double> dailyValues; // untuk grafik evaporasi (default)
|
final List<double> dailyValues;
|
||||||
final List<double> weeklyValues;
|
final List<double> weeklyValues;
|
||||||
final List<double> monthlyValues;
|
final List<double> monthlyValues;
|
||||||
|
|
||||||
final List<double> dailyTemperatures; // untuk grafik suhu
|
final List<double> dailyTemperatures;
|
||||||
final List<double> weeklyTemperatures;
|
final List<double> weeklyTemperatures;
|
||||||
final List<double> monthlyTemperatures;
|
final List<double> monthlyTemperatures;
|
||||||
|
|
||||||
/// Label X-axis sesuai agregasi period
|
|
||||||
/// Contoh: Hari Ini => ["00:00","01:00",...]
|
|
||||||
/// Minggu Ini => ["Sen","Sel",...]
|
|
||||||
/// Bulan Ini => ["1","2",...]
|
|
||||||
final List<String> chartLabels;
|
final List<String> chartLabels;
|
||||||
|
|
||||||
/// Data untuk list (timestamp asli dari firebase)
|
|
||||||
final List<Evaporasi> listData;
|
final List<Evaporasi> listData;
|
||||||
|
|
||||||
final List<Evaporasi> history;
|
final List<Evaporasi> history;
|
||||||
final String weatherStatus;
|
final String weatherStatus;
|
||||||
final bool willRain;
|
final bool willRain;
|
||||||
|
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
|
|
||||||
const EvaporasiState({
|
const EvaporasiState({
|
||||||
|
|
@ -54,12 +46,14 @@ class EvaporasiState extends Equatable {
|
||||||
this.isLoading = true,
|
this.isLoading = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ✅ FIX: Tambah clearSelectedDate flag agar selectedDate bisa di-null-kan
|
||||||
EvaporasiState copyWith({
|
EvaporasiState copyWith({
|
||||||
double? currentValue,
|
double? currentValue,
|
||||||
double? temperature,
|
double? temperature,
|
||||||
double? waterLevel,
|
double? waterLevel,
|
||||||
String? selectedPeriod,
|
String? selectedPeriod,
|
||||||
DateTime? selectedDate,
|
DateTime? selectedDate,
|
||||||
|
bool clearSelectedDate = false, // ✅ tambahan flag reset
|
||||||
EvaporasiViewMode? viewMode,
|
EvaporasiViewMode? viewMode,
|
||||||
List<double>? dailyValues,
|
List<double>? dailyValues,
|
||||||
List<double>? weeklyValues,
|
List<double>? weeklyValues,
|
||||||
|
|
@ -79,7 +73,9 @@ class EvaporasiState extends Equatable {
|
||||||
temperature: temperature ?? this.temperature,
|
temperature: temperature ?? this.temperature,
|
||||||
waterLevel: waterLevel ?? this.waterLevel,
|
waterLevel: waterLevel ?? this.waterLevel,
|
||||||
selectedPeriod: selectedPeriod ?? this.selectedPeriod,
|
selectedPeriod: selectedPeriod ?? this.selectedPeriod,
|
||||||
selectedDate: selectedDate ?? this.selectedDate,
|
// ✅ FIX: jika clearSelectedDate=true, set null; jika selectedDate diberikan, pakai itu;
|
||||||
|
// jika tidak, pertahankan yang lama
|
||||||
|
selectedDate: clearSelectedDate ? null : (selectedDate ?? this.selectedDate),
|
||||||
viewMode: viewMode ?? this.viewMode,
|
viewMode: viewMode ?? this.viewMode,
|
||||||
dailyValues: dailyValues ?? this.dailyValues,
|
dailyValues: dailyValues ?? this.dailyValues,
|
||||||
weeklyValues: weeklyValues ?? this.weeklyValues,
|
weeklyValues: weeklyValues ?? this.weeklyValues,
|
||||||
|
|
@ -117,5 +113,4 @@ class EvaporasiState extends Equatable {
|
||||||
willRain,
|
willRain,
|
||||||
isLoading,
|
isLoading,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,8 +101,10 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
// List data (mengikuti chart: period vs custom date)
|
// ✅ FIX: Gunakan BlocBuilder agar list reaktif terhadap perubahan state
|
||||||
_evaporasiList(state),
|
BlocBuilder<EvaporasiBloc, EvaporasiState>(
|
||||||
|
builder: (context, state) => _evaporasiList(state),
|
||||||
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
ExportPdfButton(
|
ExportPdfButton(
|
||||||
onExport: () => PdfExportService.evaporasi(
|
onExport: () => PdfExportService.evaporasi(
|
||||||
|
|
@ -121,9 +123,9 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// =========================
|
// =========================
|
||||||
/// 🔥 MAIN CARD (EVAPORASI)
|
// 🔥 MAIN CARD (EVAPORASI)
|
||||||
/// =========================
|
// =========================
|
||||||
Widget _mainCard(EvaporasiState state) {
|
Widget _mainCard(EvaporasiState state) {
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
|
|
@ -155,9 +157,9 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// =========================
|
// =========================
|
||||||
/// 📊 INFO KECIL (SUHU & AIR)
|
// 📊 INFO KECIL (SUHU & AIR)
|
||||||
/// =========================
|
// =========================
|
||||||
Widget _infoRow(EvaporasiState state) {
|
Widget _infoRow(EvaporasiState state) {
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
|
@ -203,9 +205,9 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// =========================
|
// =========================
|
||||||
/// 📈 STATUS CARD
|
// 📈 STATUS CARD
|
||||||
/// =========================
|
// =========================
|
||||||
Widget _statusCard(EvaporasiState state) {
|
Widget _statusCard(EvaporasiState state) {
|
||||||
Color statusColor;
|
Color statusColor;
|
||||||
IconData statusIcon;
|
IconData statusIcon;
|
||||||
|
|
@ -226,7 +228,6 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Teks peringatan khusus evaporasi (normal/sedang/tinggi)
|
|
||||||
final String warningText;
|
final String warningText;
|
||||||
if (state.weatherStatus == 'Baik') {
|
if (state.weatherStatus == 'Baik') {
|
||||||
warningText = 'Normal — evaporasi stabil, risiko dampak rendah.';
|
warningText = 'Normal — evaporasi stabil, risiko dampak rendah.';
|
||||||
|
|
@ -286,21 +287,84 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// =========================
|
// =========================
|
||||||
/// 🧾 LIST DATA EVAPORASI
|
// 🧾 LIST DATA EVAPORASI — ✅ FIXED FILTER
|
||||||
/// =========================
|
// =========================
|
||||||
Widget _evaporasiList(EvaporasiState state) {
|
Widget _evaporasiList(EvaporasiState state) {
|
||||||
final data = state.viewMode == EvaporasiViewMode.customDate &&
|
final now = DateTime.now();
|
||||||
state.selectedDate != null
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
? state.history
|
|
||||||
.where((e) =>
|
|
||||||
e.timestamp.year == state.selectedDate!.year &&
|
|
||||||
e.timestamp.month == state.selectedDate!.month &&
|
|
||||||
e.timestamp.day == state.selectedDate!.day)
|
|
||||||
.toList()
|
|
||||||
: state.history;
|
|
||||||
|
|
||||||
if (data.isEmpty) {
|
// ✅ Filter list sesuai mode & periode yang aktif
|
||||||
|
List filteredData;
|
||||||
|
|
||||||
|
if (state.viewMode == EvaporasiViewMode.customDate &&
|
||||||
|
state.selectedDate != null) {
|
||||||
|
// Mode custom date: tampilkan hanya data di tanggal yang dipilih
|
||||||
|
final sel = DateTime(
|
||||||
|
state.selectedDate!.year,
|
||||||
|
state.selectedDate!.month,
|
||||||
|
state.selectedDate!.day,
|
||||||
|
);
|
||||||
|
filteredData = state.history
|
||||||
|
.where((e) {
|
||||||
|
final d =
|
||||||
|
DateTime(e.timestamp.year, e.timestamp.month, e.timestamp.day);
|
||||||
|
return d == sel;
|
||||||
|
})
|
||||||
|
.toList()
|
||||||
|
.reversed
|
||||||
|
.toList();
|
||||||
|
} else if (state.selectedPeriod == 'Hari Ini') {
|
||||||
|
// Mode Hari Ini: hanya data hari ini
|
||||||
|
filteredData = state.history
|
||||||
|
.where((e) {
|
||||||
|
final d =
|
||||||
|
DateTime(e.timestamp.year, e.timestamp.month, e.timestamp.day);
|
||||||
|
return d == today;
|
||||||
|
})
|
||||||
|
.toList()
|
||||||
|
.reversed
|
||||||
|
.toList();
|
||||||
|
} else if (state.selectedPeriod == 'Minggu Ini') {
|
||||||
|
// Mode Minggu Ini: 7 hari ke belakang dari hari ini
|
||||||
|
final weekStart = today.subtract(const Duration(days: 6));
|
||||||
|
filteredData = state.history
|
||||||
|
.where((e) {
|
||||||
|
final d =
|
||||||
|
DateTime(e.timestamp.year, e.timestamp.month, e.timestamp.day);
|
||||||
|
return !d.isBefore(weekStart) && !d.isAfter(today);
|
||||||
|
})
|
||||||
|
.toList()
|
||||||
|
.reversed
|
||||||
|
.toList();
|
||||||
|
} else if (state.selectedPeriod == 'Bulan Ini') {
|
||||||
|
// Mode Bulan Ini: hanya data bulan & tahun yang sama
|
||||||
|
filteredData = state.history
|
||||||
|
.where((e) =>
|
||||||
|
e.timestamp.year == now.year && e.timestamp.month == now.month)
|
||||||
|
.toList()
|
||||||
|
.reversed
|
||||||
|
.toList();
|
||||||
|
} else {
|
||||||
|
filteredData = state.history.reversed.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Header label sesuai mode
|
||||||
|
final String listTitle;
|
||||||
|
if (state.viewMode == EvaporasiViewMode.customDate &&
|
||||||
|
state.selectedDate != null) {
|
||||||
|
listTitle = 'Data ${_formatDateInfo(state.selectedDate!)}';
|
||||||
|
} else if (state.selectedPeriod == 'Hari Ini') {
|
||||||
|
listTitle = 'Data Hari Ini';
|
||||||
|
} else if (state.selectedPeriod == 'Minggu Ini') {
|
||||||
|
listTitle = 'Data Minggu Ini';
|
||||||
|
} else if (state.selectedPeriod == 'Bulan Ini') {
|
||||||
|
listTitle = 'Data Bulan Ini';
|
||||||
|
} else {
|
||||||
|
listTitle = 'List Data Evaporasi';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredData.isEmpty) {
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
|
@ -308,9 +372,20 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: Column(
|
||||||
'Belum ada data evaporasi',
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: TextStyle(color: Colors.grey),
|
children: [
|
||||||
|
Text(
|
||||||
|
listTitle,
|
||||||
|
style:
|
||||||
|
const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text(
|
||||||
|
'Belum ada data untuk periode ini',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -325,17 +400,17 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Text(
|
||||||
'List Data Evaporasi',
|
listTitle,
|
||||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 280,
|
height: 280,
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
itemCount: data.length,
|
itemCount: filteredData.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final e = data[index];
|
final e = filteredData[index];
|
||||||
final dateLabel =
|
final dateLabel =
|
||||||
'${DateFormat('dd MMM yyyy', 'id_ID').format(e.timestamp)} • ${DateFormat('HH:mm:ss', 'id_ID').format(e.timestamp)}';
|
'${DateFormat('dd MMM yyyy', 'id_ID').format(e.timestamp)} • ${DateFormat('HH:mm:ss', 'id_ID').format(e.timestamp)}';
|
||||||
|
|
||||||
|
|
@ -407,9 +482,6 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// =========================
|
|
||||||
/// 📅 FORMAT DATE INFO (for custom date display)
|
|
||||||
/// =========================
|
|
||||||
String _statusTextForHistory(double evaporasi) {
|
String _statusTextForHistory(double evaporasi) {
|
||||||
if (evaporasi <= 5.0) return 'Status: Normal';
|
if (evaporasi <= 5.0) return 'Status: Normal';
|
||||||
if (evaporasi <= 10.0) return 'Status: Sedang';
|
if (evaporasi <= 10.0) return 'Status: Sedang';
|
||||||
|
|
@ -423,9 +495,7 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatDateInfo(DateTime date) {
|
String _formatDateInfo(DateTime date) {
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
|
|
||||||
final today = DateTime(now.year, now.month, now.day);
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
final yesterday = today.subtract(const Duration(days: 1));
|
final yesterday = today.subtract(const Duration(days: 1));
|
||||||
final selected = DateTime(date.year, date.month, date.day);
|
final selected = DateTime(date.year, date.month, date.day);
|
||||||
|
|
@ -438,5 +508,4 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
||||||
return DateFormat('EEEE, d MMMM yyyy', 'id_ID').format(date);
|
return DateFormat('EEEE, d MMMM yyyy', 'id_ID').format(date);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,8 +30,7 @@ class EvaporasiChartWidget extends StatelessWidget {
|
||||||
}) {
|
}) {
|
||||||
final tempRange = (tempMax - tempMin);
|
final tempRange = (tempMax - tempMin);
|
||||||
if (tempRange.abs() < 1e-9) return evapMin;
|
if (tempRange.abs() < 1e-9) return evapMin;
|
||||||
|
final normalized = (temp - tempMin) / tempRange;
|
||||||
final normalized = (temp - tempMin) / tempRange; // 0..1
|
|
||||||
return evapMin + normalized * (evapMax - evapMin);
|
return evapMin + normalized * (evapMax - evapMin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,14 +43,21 @@ class EvaporasiChartWidget extends StatelessWidget {
|
||||||
}) {
|
}) {
|
||||||
final evapRange = (evapMax - evapMin);
|
final evapRange = (evapMax - evapMin);
|
||||||
if (evapRange.abs() < 1e-9) return tempMin;
|
if (evapRange.abs() < 1e-9) return tempMin;
|
||||||
|
|
||||||
final normalized = (yEvap - evapMin) / evapRange;
|
final normalized = (yEvap - evapMin) / evapRange;
|
||||||
return tempMin + normalized * (tempMax - tempMin);
|
return tempMin + normalized * (tempMax - tempMin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ✅ FIX: Interval label X-axis disesuaikan per period agar tidak tumpang tindih
|
||||||
|
double _xLabelInterval() {
|
||||||
|
if (period == 'Minggu Ini') return 1; // 7 label → semua tampil
|
||||||
|
if (period == 'Bulan Ini') return 5; // ~31 label → tiap 5 hari
|
||||||
|
// Hari Ini / Tanggal Khusus → 24 jam, tampilkan tiap 3 jam
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
String _getBottomLabel(int index) {
|
String _getBottomLabel(int index) {
|
||||||
if (chartLabels.isEmpty || index < 0 || index >= chartLabels.length) {
|
if (chartLabels.isEmpty || index < 0 || index >= chartLabels.length) {
|
||||||
return index.toString();
|
return '';
|
||||||
}
|
}
|
||||||
return chartLabels[index];
|
return chartLabels[index];
|
||||||
}
|
}
|
||||||
|
|
@ -77,13 +83,12 @@ class EvaporasiChartWidget extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sesuai permintaan: evaporasi 0..20 dan suhu 0..30
|
|
||||||
const evapMin = 0.0;
|
const evapMin = 0.0;
|
||||||
const evapMax = 20.0;
|
const evapMax = 20.0;
|
||||||
const tempMin = 0.0;
|
const tempMin = 0.0;
|
||||||
const tempMax = 30.0;
|
const tempMax = 40.0; // ✅ FIX: naikkan batas suhu ke 40°C agar lebih realistis
|
||||||
|
|
||||||
// Evaporasi: deduplicate X by index
|
// Evaporasi spots
|
||||||
final evapSpotsAll = dailyValues.asMap().entries.map((e) {
|
final evapSpotsAll = dailyValues.asMap().entries.map((e) {
|
||||||
return FlSpot(e.key.toDouble(), _safeValue(e.value));
|
return FlSpot(e.key.toDouble(), _safeValue(e.value));
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
@ -92,6 +97,7 @@ class EvaporasiChartWidget extends StatelessWidget {
|
||||||
for (final s in evapSpotsAll) {
|
for (final s in evapSpotsAll) {
|
||||||
evapByX[s.x.toInt()] = s.y;
|
evapByX[s.x.toInt()] = s.y;
|
||||||
}
|
}
|
||||||
|
|
||||||
final dedupEvapSpots = evapByX.entries.toList()
|
final dedupEvapSpots = evapByX.entries.toList()
|
||||||
..sort((a, b) => a.key.compareTo(b.key));
|
..sort((a, b) => a.key.compareTo(b.key));
|
||||||
|
|
||||||
|
|
@ -99,7 +105,7 @@ class EvaporasiChartWidget extends StatelessWidget {
|
||||||
.map((e) => FlSpot(e.key.toDouble(), e.value.clamp(evapMin, evapMax)))
|
.map((e) => FlSpot(e.key.toDouble(), e.value.clamp(evapMin, evapMax)))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Suhu: skala-kan agar bisa ditampilkan di axis evaporasi
|
// Suhu spots — diproyeksikan ke skala evaporasi
|
||||||
final tempByX = <int, double>{};
|
final tempByX = <int, double>{};
|
||||||
for (final entry in dailyTemperatures.asMap().entries) {
|
for (final entry in dailyTemperatures.asMap().entries) {
|
||||||
tempByX[entry.key] = _safeValue(entry.value);
|
tempByX[entry.key] = _safeValue(entry.value);
|
||||||
|
|
@ -128,34 +134,58 @@ class EvaporasiChartWidget extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final xInterval = _xLabelInterval();
|
||||||
|
|
||||||
final chart = LineChart(
|
final chart = LineChart(
|
||||||
LineChartData(
|
LineChartData(
|
||||||
minY: evapMin,
|
minY: evapMin,
|
||||||
maxY: evapMax,
|
maxY: evapMax,
|
||||||
gridData: FlGridData(show: false),
|
// ✅ FIX: padding kiri/kanan agar garis tidak terpotong di tepi
|
||||||
|
minX: -0.5,
|
||||||
|
maxX: (chartLabels.isNotEmpty ? chartLabels.length - 1 : 23).toDouble() + 0.5,
|
||||||
|
clipData: const FlClipData.all(), // ✅ FIX: clip agar garis tidak keluar area chart
|
||||||
|
gridData: FlGridData(
|
||||||
|
show: true,
|
||||||
|
drawVerticalLine: false,
|
||||||
|
horizontalInterval: (evapMax - evapMin) / 4,
|
||||||
|
getDrawingHorizontalLine: (value) => FlLine(
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
strokeWidth: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
borderData: FlBorderData(show: false),
|
borderData: FlBorderData(show: false),
|
||||||
|
extraLinesData: const ExtraLinesData(horizontalLines: []),
|
||||||
titlesData: FlTitlesData(
|
titlesData: FlTitlesData(
|
||||||
bottomTitles: AxisTitles(
|
bottomTitles: AxisTitles(
|
||||||
sideTitles: SideTitles(
|
sideTitles: SideTitles(
|
||||||
showTitles: true,
|
showTitles: true,
|
||||||
reservedSize: 32,
|
reservedSize: 36, // ✅ FIX: tambah ruang bawah agar label tidak nutup chart
|
||||||
interval: 1,
|
interval: xInterval, // ✅ FIX: interval dinamis per periode
|
||||||
getTitlesWidget: (value, meta) {
|
getTitlesWidget: (value, meta) {
|
||||||
final index = value.toInt();
|
final index = value.toInt();
|
||||||
|
// ✅ FIX: hanya render label di indeks yang valid & tepat interval
|
||||||
|
if (value != value.roundToDouble()) return const SizedBox();
|
||||||
|
if (index < 0 || index >= chartLabels.length) return const SizedBox();
|
||||||
|
if (index % xInterval.toInt() != 0) return const SizedBox();
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(top: 8),
|
padding: const EdgeInsets.only(top: 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
_getBottomLabel(index),
|
_getBottomLabel(index),
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 10),
|
style: const TextStyle(color: Colors.grey, fontSize: 9),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
leftTitles: AxisTitles(
|
leftTitles: AxisTitles(
|
||||||
|
axisNameWidget: const Text(
|
||||||
|
'mm',
|
||||||
|
style: TextStyle(color: Colors.blueGrey, fontSize: 10),
|
||||||
|
),
|
||||||
|
axisNameSize: 16,
|
||||||
sideTitles: SideTitles(
|
sideTitles: SideTitles(
|
||||||
showTitles: true,
|
showTitles: true,
|
||||||
reservedSize: 40,
|
reservedSize: 36,
|
||||||
interval: (evapMax - evapMin) / 4,
|
interval: (evapMax - evapMin) / 4,
|
||||||
getTitlesWidget: (value, meta) {
|
getTitlesWidget: (value, meta) {
|
||||||
return Text(
|
return Text(
|
||||||
|
|
@ -169,9 +199,14 @@ class EvaporasiChartWidget extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
rightTitles: AxisTitles(
|
rightTitles: AxisTitles(
|
||||||
|
axisNameWidget: const Text(
|
||||||
|
'°C',
|
||||||
|
style: TextStyle(color: Colors.brown, fontSize: 10),
|
||||||
|
),
|
||||||
|
axisNameSize: 16,
|
||||||
sideTitles: SideTitles(
|
sideTitles: SideTitles(
|
||||||
showTitles: true,
|
showTitles: true,
|
||||||
reservedSize: 40,
|
reservedSize: 36,
|
||||||
interval: (evapMax - evapMin) / 4,
|
interval: (evapMax - evapMin) / 4,
|
||||||
getTitlesWidget: (value, meta) {
|
getTitlesWidget: (value, meta) {
|
||||||
final t = getRightTitle(value);
|
final t = getRightTitle(value);
|
||||||
|
|
@ -185,28 +220,42 @@ class EvaporasiChartWidget extends StatelessWidget {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
topTitles: AxisTitles(
|
topTitles: const AxisTitles(
|
||||||
sideTitles: SideTitles(showTitles: false),
|
sideTitles: SideTitles(showTitles: false),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
lineTouchData: LineTouchData(
|
lineTouchData: LineTouchData(
|
||||||
handleBuiltInTouches: true,
|
handleBuiltInTouches: true,
|
||||||
touchTooltipData: LineTouchTooltipData(
|
touchTooltipData: LineTouchTooltipData(
|
||||||
getTooltipItems: (spots) {
|
tooltipPadding: const EdgeInsets.all(8),
|
||||||
return spots.map((spot) {
|
getTooltipItems: (touchedSpots) {
|
||||||
final temp = _evapScaleToTemp(
|
if (touchedSpots.isEmpty) return const <LineTooltipItem>[];
|
||||||
yEvap: spot.y,
|
|
||||||
evapMin: evapMin,
|
|
||||||
evapMax: evapMax,
|
|
||||||
tempMin: tempMin,
|
|
||||||
tempMax: tempMax,
|
|
||||||
);
|
|
||||||
|
|
||||||
return LineTooltipItem(
|
final List<LineTooltipItem> items = [];
|
||||||
'Evap: ${spot.y.toStringAsFixed(1)} mm\nSuhu: ${temp.toStringAsFixed(1)} °C',
|
for (int i = 0; i < touchedSpots.length; i++) {
|
||||||
const TextStyle(color: Colors.white, fontSize: 12),
|
final spot = touchedSpots[i];
|
||||||
);
|
final y = _safeValue(spot.y).clamp(evapMin, evapMax);
|
||||||
}).toList();
|
|
||||||
|
if (i == 0) {
|
||||||
|
items.add(LineTooltipItem(
|
||||||
|
'Evap: ${y.toStringAsFixed(1)} mm',
|
||||||
|
const TextStyle(color: Colors.white, fontSize: 12),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
final tempOnly = _evapScaleToTemp(
|
||||||
|
yEvap: y,
|
||||||
|
evapMin: evapMin,
|
||||||
|
evapMax: evapMax,
|
||||||
|
tempMin: tempMin,
|
||||||
|
tempMax: tempMax,
|
||||||
|
);
|
||||||
|
items.add(LineTooltipItem(
|
||||||
|
'Suhu: ${tempOnly.toStringAsFixed(1)} °C',
|
||||||
|
const TextStyle(color: Colors.white, fontSize: 12),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -214,18 +263,23 @@ class EvaporasiChartWidget extends StatelessWidget {
|
||||||
if (evapSpots.isNotEmpty)
|
if (evapSpots.isNotEmpty)
|
||||||
LineChartBarData(
|
LineChartBarData(
|
||||||
spots: evapSpots,
|
spots: evapSpots,
|
||||||
isCurved: false,
|
isCurved: true,
|
||||||
|
curveSmoothness: 0.3,
|
||||||
color: Colors.blue.shade700,
|
color: Colors.blue.shade700,
|
||||||
barWidth: 2,
|
barWidth: 2.5,
|
||||||
dotData: FlDotData(show: false),
|
dotData: FlDotData(show: false),
|
||||||
belowBarData: BarAreaData(show: false),
|
belowBarData: BarAreaData(
|
||||||
|
show: true,
|
||||||
|
color: Colors.blue.shade100.withOpacity(0.3),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (tempSpots.isNotEmpty)
|
if (tempSpots.isNotEmpty)
|
||||||
LineChartBarData(
|
LineChartBarData(
|
||||||
spots: tempSpots,
|
spots: tempSpots,
|
||||||
isCurved: false,
|
isCurved: true,
|
||||||
|
curveSmoothness: 0.3,
|
||||||
color: Colors.orange.shade700,
|
color: Colors.orange.shade700,
|
||||||
barWidth: 2,
|
barWidth: 2.5,
|
||||||
dotData: FlDotData(show: false),
|
dotData: FlDotData(show: false),
|
||||||
belowBarData: BarAreaData(show: false),
|
belowBarData: BarAreaData(show: false),
|
||||||
),
|
),
|
||||||
|
|
@ -234,8 +288,8 @@ class EvaporasiChartWidget extends StatelessWidget {
|
||||||
);
|
);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
height: 380,
|
height: 400, // ✅ FIX: tambah tinggi container agar label bawah tidak terpotong
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.fromLTRB(8, 12, 8, 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(25),
|
borderRadius: BorderRadius.circular(25),
|
||||||
|
|
@ -249,6 +303,7 @@ class EvaporasiChartWidget extends StatelessWidget {
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
// Legend
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -297,9 +352,10 @@ class EvaporasiChartWidget extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
// ✅ FIX: Expanded + ClipRRect memastikan chart mengisi sisa ruang tanpa overflow
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(25),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: chart,
|
child: chart,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -307,5 +363,4 @@ class EvaporasiChartWidget extends StatelessWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -19,117 +19,111 @@ class Evaporasi {
|
||||||
);
|
);
|
||||||
|
|
||||||
factory Evaporasi.fromJson(Map<dynamic, dynamic> json) {
|
factory Evaporasi.fromJson(Map<dynamic, dynamic> json) {
|
||||||
|
// ===========================
|
||||||
|
// 🔧 HELPER: parse angka aman
|
||||||
|
// ===========================
|
||||||
double toDoubleSafe(dynamic v) {
|
double toDoubleSafe(dynamic v) {
|
||||||
if (v is num) return v.toDouble();
|
if (v is num) return v.toDouble();
|
||||||
if (v is String) {
|
if (v is String) {
|
||||||
final s = v.trim();
|
final s = v.trim();
|
||||||
// dukung format seperti "12.3 cm" / "12,3" / "-"
|
|
||||||
final normalized = s.replaceAll(',', '.');
|
final normalized = s.replaceAll(',', '.');
|
||||||
final match = RegExp(r'[-+]?\d*\.?\d+').firstMatch(normalized);
|
final match = RegExp(r'[-+]?\d*\.?\d+').firstMatch(normalized);
|
||||||
if (match != null) {
|
if (match != null) {
|
||||||
return double.tryParse(match.group(0)!) ?? 0;
|
return double.tryParse(match.group(0)!) ?? 0.0;
|
||||||
}
|
}
|
||||||
return double.tryParse(normalized) ?? 0;
|
return double.tryParse(normalized) ?? 0.0;
|
||||||
}
|
}
|
||||||
return 0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// 💧 EVAPORASI
|
||||||
|
// ✅ Firebase: evaporasi_mm → prioritas pertama
|
||||||
|
// ===========================
|
||||||
final evaporasiVal = toDoubleSafe(
|
final evaporasiVal = toDoubleSafe(
|
||||||
json['evaporasi_mm'] ??
|
json['evaporasi_mm'] ??
|
||||||
json['evaporasi'] ??
|
json['evaporasi'] ??
|
||||||
json['evaporasiMm'] ??
|
json['evaporasiMm'] ??
|
||||||
json['evaporation_mm'] ??
|
json['evaporation_mm'] ??
|
||||||
json['evap_mm'] ??
|
json['evap_mm'] ??
|
||||||
json['evaporasi_mm_'] ??
|
|
||||||
json['evaporasi_mm '] ??
|
|
||||||
json['evaporasi_value'] ??
|
json['evaporasi_value'] ??
|
||||||
json['evaporasi_mm_k'] ??
|
|
||||||
json['evaporasi_k'],
|
json['evaporasi_k'],
|
||||||
);
|
);
|
||||||
|
|
||||||
/// =========================
|
// ===========================
|
||||||
/// SUHU
|
// 🌡️ SUHU
|
||||||
/// =========================
|
// ✅ Firebase: suhu_air → prioritas pertama
|
||||||
final suhuParsed = toDoubleSafe(
|
// ===========================
|
||||||
json['suhu'] ??
|
final suhuRaw = toDoubleSafe(
|
||||||
json['suhu_air'] ??
|
json['suhu_air'] ?? // ✅ field utama di Firebase
|
||||||
|
json['suhu'] ??
|
||||||
json['suhuAir'] ??
|
json['suhuAir'] ??
|
||||||
json['temp'] ??
|
json['temp'] ??
|
||||||
json['temperature'],
|
json['temperature'],
|
||||||
);
|
);
|
||||||
|
// Filter nilai tidak masuk akal (sensor error)
|
||||||
|
final suhuVal = (suhuRaw < -50 || suhuRaw > 100) ? 0.0 : suhuRaw;
|
||||||
|
|
||||||
// Filter nilai rusak
|
// ===========================
|
||||||
final suhuVal = (suhuParsed < -50 || suhuParsed > 100) ? 0.0 : suhuParsed;
|
// 💦 TINGGI AIR
|
||||||
toDoubleSafe(
|
// ✅ Firebase: tinggi_air_cm → prioritas pertama
|
||||||
json['suhu'] ??
|
// ===========================
|
||||||
json['suhu_air'] ??
|
|
||||||
json['suhuAir'] ??
|
|
||||||
json['temp'] ??
|
|
||||||
json['temperature'],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Banyak kemungkinan penamaan field tinggi air.
|
|
||||||
// Pakai beberapa alias agar tidak default 0.
|
|
||||||
final tinggiVal = toDoubleSafe(
|
final tinggiVal = toDoubleSafe(
|
||||||
json['tinggi_air_cm'] ??
|
json['tinggi_air_cm'] ?? // ✅ field utama di Firebase
|
||||||
json['tinggi_air'] ??
|
json['tinggi_air'] ??
|
||||||
json['tinggiAir'] ??
|
json['tinggiAir'] ??
|
||||||
json['tinggiAir_cm'] ??
|
json['tinggiAir_cm'] ??
|
||||||
json['tinggi_air_cm_'] ??
|
|
||||||
json['tinggi_air_cm '] ??
|
|
||||||
json['water_level'] ??
|
json['water_level'] ??
|
||||||
json['waterLevel'] ??
|
json['waterLevel'] ??
|
||||||
json['tinggi_air_m'] ??
|
json['tinggi_air_m'] ??
|
||||||
json['tinggiAir_m'],
|
json['tinggiAir_m'],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Default timestamp: fallback now (kalau field waktu tidak ada).
|
// ===========================
|
||||||
// Catatan: untuk Firebase seharusnya timestamp dikirim konsisten (ms atau ISO string).
|
// 🕐 TIMESTAMP
|
||||||
|
// ✅ Firebase: datetime "2026-05-14 01:25:25" → prioritas pertama
|
||||||
|
// ===========================
|
||||||
DateTime timestamp = DateTime.now();
|
DateTime timestamp = DateTime.now();
|
||||||
|
|
||||||
// dukung beberapa kemungkinan penamaan timestamp
|
// Urutan prioritas sesuai field yang ada di Firebase
|
||||||
final rawTimestamp = json['timestamp'] ?? json['time'] ?? json['datetime'];
|
final rawTimestamp =
|
||||||
|
json['datetime'] ?? // ✅ field utama di Firebase
|
||||||
|
json['timestamp'] ??
|
||||||
|
json['time'];
|
||||||
|
|
||||||
if (rawTimestamp != null) {
|
if (rawTimestamp != null) {
|
||||||
if (rawTimestamp is int) {
|
if (rawTimestamp is String) {
|
||||||
|
final s = rawTimestamp.trim();
|
||||||
|
// Coba parse sebagai integer unix ms/s
|
||||||
|
final unixMs = int.tryParse(s);
|
||||||
|
if (unixMs != null) {
|
||||||
|
timestamp = unixMs < 1000000000000
|
||||||
|
? DateTime.fromMillisecondsSinceEpoch(unixMs * 1000)
|
||||||
|
: DateTime.fromMillisecondsSinceEpoch(unixMs);
|
||||||
|
} else {
|
||||||
|
// ✅ Format Firebase: "2026-05-14 01:25:25"
|
||||||
|
// DateTime.tryParse butuh ISO format → ganti spasi dengan T
|
||||||
|
final iso = s.contains('T') ? s : s.replaceFirst(' ', 'T');
|
||||||
|
timestamp = DateTime.tryParse(iso) ?? DateTime.now();
|
||||||
|
}
|
||||||
|
} else if (rawTimestamp is int) {
|
||||||
timestamp = DateTime.fromMillisecondsSinceEpoch(rawTimestamp);
|
timestamp = DateTime.fromMillisecondsSinceEpoch(rawTimestamp);
|
||||||
} else if (rawTimestamp is double) {
|
} else if (rawTimestamp is double) {
|
||||||
timestamp = DateTime.fromMillisecondsSinceEpoch(rawTimestamp.toInt());
|
timestamp = DateTime.fromMillisecondsSinceEpoch(rawTimestamp.toInt());
|
||||||
} else if (rawTimestamp is String) {
|
|
||||||
final s = rawTimestamp.trim();
|
|
||||||
// jika string berupa angka (ms/seconds)
|
|
||||||
final unixMs = int.tryParse(s);
|
|
||||||
if (unixMs != null) {
|
|
||||||
// heuristik: kalau nilainya terlalu kecil kemungkinan seconds
|
|
||||||
if (unixMs < 1000000000000) {
|
|
||||||
timestamp = DateTime.fromMillisecondsSinceEpoch(unixMs * 1000);
|
|
||||||
} else {
|
|
||||||
timestamp = DateTime.fromMillisecondsSinceEpoch(unixMs);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
final parsed = DateTime.tryParse(s);
|
|
||||||
if (parsed != null) {
|
|
||||||
timestamp = parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// legacy fallback dari field terpisah
|
// Legacy fallback: field 'waktu' format "HH:mm:ss"
|
||||||
final datetimeStr = json['datetime'] as String?;
|
|
||||||
if (datetimeStr != null) {
|
|
||||||
final parsed = DateTime.tryParse(datetimeStr);
|
|
||||||
if (parsed != null) timestamp = parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
final waktuStr = json['waktu'] as String?;
|
final waktuStr = json['waktu'] as String?;
|
||||||
if (waktuStr != null && datetimeStr == null) {
|
if (waktuStr != null) {
|
||||||
final parts = waktuStr.split(':');
|
final parts = waktuStr.split(':');
|
||||||
if (parts.length >= 2) {
|
if (parts.length >= 2) {
|
||||||
final jam = int.tryParse(parts[0]) ?? 0;
|
final jam = int.tryParse(parts[0]) ?? 0;
|
||||||
final menit = int.tryParse(parts[1]) ?? 0;
|
final menit = int.tryParse(parts[1]) ?? 0;
|
||||||
final detik = parts.length >= 3 ? (int.tryParse(parts[2]) ?? 0) : 0;
|
final detik =
|
||||||
|
parts.length >= 3 ? (int.tryParse(parts[2]) ?? 0) : 0;
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
timestamp = DateTime(now.year, now.month, now.day, jam, menit, detik);
|
timestamp =
|
||||||
|
DateTime(now.year, now.month, now.day, jam, menit, detik);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -141,4 +135,4 @@ class Evaporasi {
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue