pepe
This commit is contained in:
parent
e00d471796
commit
3df7ee1fbe
|
|
@ -16,15 +16,17 @@ class TimeSeriesMapper {
|
|||
final time = getTime(item);
|
||||
|
||||
if (_isSameDay(time, now)) {
|
||||
final hour = time.hour;
|
||||
final hour = time.toLocal().hour; // ✅ FIX: pastikan pakai local hour
|
||||
if (hour >= 0 && hour < 24) {
|
||||
sums[hour] += getValue(item);
|
||||
counts[hour]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return List.generate(24, (i) {
|
||||
if (counts[i] == 0) return 0;
|
||||
return sums[i] / counts[i]; // 🔥 average, bukan overwrite
|
||||
return sums[i] / counts[i];
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -46,14 +48,16 @@ class TimeSeriesMapper {
|
|||
DateTime(startOfWeek.year, startOfWeek.month, startOfWeek.day);
|
||||
|
||||
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;
|
||||
if (index >= 0 && index < 7) {
|
||||
sums[index] += getValue(item);
|
||||
counts[index]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return List.generate(7, (i) {
|
||||
if (counts[i] == 0) return 0;
|
||||
|
|
@ -76,14 +80,16 @@ class TimeSeriesMapper {
|
|||
final counts = List<int>.filled(daysInMonth, 0);
|
||||
|
||||
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) {
|
||||
final index = time.day - 1;
|
||||
if (index >= 0 && index < daysInMonth) {
|
||||
sums[index] += getValue(item);
|
||||
counts[index]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return List.generate(daysInMonth, (i) {
|
||||
if (counts[i] == 0) return 0;
|
||||
|
|
@ -91,7 +97,7 @@ class TimeSeriesMapper {
|
|||
});
|
||||
}
|
||||
|
||||
/// =========================
|
||||
/// =========================
|
||||
/// 📅 SPECIFIC DATE (24 JAM - TANGGAL KHUSUS)
|
||||
/// =========================
|
||||
static List<double> toSpecificDate<T>({
|
||||
|
|
@ -107,11 +113,13 @@ class TimeSeriesMapper {
|
|||
final time = getTime(item);
|
||||
|
||||
if (_isSameDay(time, targetDate)) {
|
||||
final hour = time.hour;
|
||||
final hour = time.toLocal().hour; // ✅ FIX: pastikan pakai local hour
|
||||
if (hour >= 0 && hour < 24) {
|
||||
sums[hour] += getValue(item);
|
||||
counts[hour]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return List.generate(24, (i) {
|
||||
if (counts[i] == 0) return 0;
|
||||
|
|
@ -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) {
|
||||
final au = a.toUtc();
|
||||
final bu = b.toUtc();
|
||||
return au.year == bu.year && au.month == bu.month && au.day == bu.day;
|
||||
final al = a.toLocal();
|
||||
final bl = b.toLocal();
|
||||
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) {
|
||||
if (data.length < 3) return data;
|
||||
|
||||
List<double> result = [];
|
||||
final List<double> result = [];
|
||||
|
||||
for (int i = 0; i < data.length; i++) {
|
||||
if (i == 0 || i == data.length - 1) {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
|||
) async {
|
||||
emit(state.copyWith(isLoading: true));
|
||||
|
||||
// Ambil history yang akan dipakai untuk list + agregasi grafik.
|
||||
final history = await _repository.getSensorHistory(
|
||||
'Monitoring/History',
|
||||
(json) => Evaporasi.fromJson(json),
|
||||
|
|
@ -45,7 +44,7 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
|||
final dailyGraph = TimeSeriesMapper.toDaily(
|
||||
data: history,
|
||||
getTime: (e) => e.timestamp,
|
||||
getValue: (e) => e.evaporasi, // ⚠️ sesuaikan nama field
|
||||
getValue: (e) => e.evaporasi,
|
||||
);
|
||||
|
||||
final dailyTempGraph = TimeSeriesMapper.toDaily(
|
||||
|
|
@ -112,7 +111,6 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
|||
_EvaporasiRealtimeUpdated event,
|
||||
Emitter<EvaporasiState> emit,
|
||||
) {
|
||||
// Hindari update dobel: jika timestamp event sama dengan yang terakhir, jangan ubah bucket.
|
||||
final previous =
|
||||
state.history.isNotEmpty ? state.history.last.timestamp : null;
|
||||
|
||||
|
|
@ -121,7 +119,6 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
|||
final isDuplicate =
|
||||
previous != null && event.data.timestamp.toUtc() == previous.toUtc();
|
||||
|
||||
// Update bucket berdasarkan timestamp event (bukan jam lokal sekarang).
|
||||
final updated =
|
||||
isDuplicate ? state.dailyValues : List<double>.from(state.dailyValues);
|
||||
final updatedTemp = isDuplicate
|
||||
|
|
@ -135,7 +132,7 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
|||
eventTime.toUtc().month == now.toUtc().month &&
|
||||
eventTime.toUtc().day == now.toUtc().day;
|
||||
|
||||
if (isSameDayUtc) {
|
||||
if (isSameDayUtc && !isDuplicate) {
|
||||
final index = eventTime.hour;
|
||||
if (index >= 0 && index < updated.length) {
|
||||
updated[index] = event.data.evaporasi;
|
||||
|
|
@ -210,6 +207,8 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
|||
dailyTemperatures: updatedTemp,
|
||||
chartLabels: _buildChartLabels(period: event.period),
|
||||
viewMode: EvaporasiViewMode.period,
|
||||
// ✅ FIX: reset selectedDate saat kembali ke mode period
|
||||
clearSelectedDate: true,
|
||||
isLoading: false,
|
||||
listData: state.listData,
|
||||
history: state.history,
|
||||
|
|
@ -242,11 +241,12 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
|||
targetDate: event.date,
|
||||
);
|
||||
|
||||
// ✅ FIX: update chartLabels untuk custom date (24 jam label)
|
||||
emit(state.copyWith(
|
||||
dailyValues: updated,
|
||||
dailyTemperatures: updatedTemp,
|
||||
chartLabels: _buildChartLabels(period: 'Tanggal Khusus'),
|
||||
isLoading: false,
|
||||
// jangan hilangkan list
|
||||
listData: state.listData,
|
||||
history: state.history,
|
||||
));
|
||||
|
|
|
|||
|
|
@ -3,34 +3,26 @@ part of 'evaporasi_bloc.dart';
|
|||
enum EvaporasiViewMode { period, customDate }
|
||||
|
||||
class EvaporasiState extends Equatable {
|
||||
final double currentValue; // nilai evaporasi realtime
|
||||
final double temperature; // suhu (opsional dari firebase)
|
||||
final double waterLevel; // tinggi air
|
||||
final double currentValue;
|
||||
final double temperature;
|
||||
final double waterLevel;
|
||||
final String selectedPeriod;
|
||||
final DateTime? selectedDate; // tanggal spesifik untuk custom date
|
||||
final EvaporasiViewMode viewMode; // period vs custom date
|
||||
final DateTime? selectedDate;
|
||||
final EvaporasiViewMode viewMode;
|
||||
|
||||
final List<double> dailyValues; // untuk grafik evaporasi (default)
|
||||
final List<double> dailyValues;
|
||||
final List<double> weeklyValues;
|
||||
final List<double> monthlyValues;
|
||||
|
||||
final List<double> dailyTemperatures; // untuk grafik suhu
|
||||
final List<double> dailyTemperatures;
|
||||
final List<double> weeklyTemperatures;
|
||||
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;
|
||||
|
||||
/// Data untuk list (timestamp asli dari firebase)
|
||||
final List<Evaporasi> listData;
|
||||
|
||||
final List<Evaporasi> history;
|
||||
final String weatherStatus;
|
||||
final bool willRain;
|
||||
|
||||
final bool isLoading;
|
||||
|
||||
const EvaporasiState({
|
||||
|
|
@ -54,12 +46,14 @@ class EvaporasiState extends Equatable {
|
|||
this.isLoading = true,
|
||||
});
|
||||
|
||||
// ✅ FIX: Tambah clearSelectedDate flag agar selectedDate bisa di-null-kan
|
||||
EvaporasiState copyWith({
|
||||
double? currentValue,
|
||||
double? temperature,
|
||||
double? waterLevel,
|
||||
String? selectedPeriod,
|
||||
DateTime? selectedDate,
|
||||
bool clearSelectedDate = false, // ✅ tambahan flag reset
|
||||
EvaporasiViewMode? viewMode,
|
||||
List<double>? dailyValues,
|
||||
List<double>? weeklyValues,
|
||||
|
|
@ -79,7 +73,9 @@ class EvaporasiState extends Equatable {
|
|||
temperature: temperature ?? this.temperature,
|
||||
waterLevel: waterLevel ?? this.waterLevel,
|
||||
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,
|
||||
dailyValues: dailyValues ?? this.dailyValues,
|
||||
weeklyValues: weeklyValues ?? this.weeklyValues,
|
||||
|
|
@ -118,4 +114,3 @@ class EvaporasiState extends Equatable {
|
|||
isLoading,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -101,8 +101,10 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
|||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// List data (mengikuti chart: period vs custom date)
|
||||
_evaporasiList(state),
|
||||
// ✅ FIX: Gunakan BlocBuilder agar list reaktif terhadap perubahan state
|
||||
BlocBuilder<EvaporasiBloc, EvaporasiState>(
|
||||
builder: (context, state) => _evaporasiList(state),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
ExportPdfButton(
|
||||
onExport: () => PdfExportService.evaporasi(
|
||||
|
|
@ -121,9 +123,9 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
/// =========================
|
||||
/// 🔥 MAIN CARD (EVAPORASI)
|
||||
/// =========================
|
||||
// =========================
|
||||
// 🔥 MAIN CARD (EVAPORASI)
|
||||
// =========================
|
||||
Widget _mainCard(EvaporasiState state) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
|
|
@ -155,9 +157,9 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
/// =========================
|
||||
/// 📊 INFO KECIL (SUHU & AIR)
|
||||
/// =========================
|
||||
// =========================
|
||||
// 📊 INFO KECIL (SUHU & AIR)
|
||||
// =========================
|
||||
Widget _infoRow(EvaporasiState state) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
|
|
@ -203,9 +205,9 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
/// =========================
|
||||
/// 📈 STATUS CARD
|
||||
/// =========================
|
||||
// =========================
|
||||
// 📈 STATUS CARD
|
||||
// =========================
|
||||
Widget _statusCard(EvaporasiState state) {
|
||||
Color statusColor;
|
||||
IconData statusIcon;
|
||||
|
|
@ -226,7 +228,6 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
|||
break;
|
||||
}
|
||||
|
||||
// Teks peringatan khusus evaporasi (normal/sedang/tinggi)
|
||||
final String warningText;
|
||||
if (state.weatherStatus == 'Baik') {
|
||||
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) {
|
||||
final data = state.viewMode == EvaporasiViewMode.customDate &&
|
||||
state.selectedDate != null
|
||||
? 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;
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
|
||||
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(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
|
|
@ -308,10 +372,21 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
|||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Text(
|
||||
'Belum ada data evaporasi',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'List Data Evaporasi',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
Text(
|
||||
listTitle,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 280,
|
||||
child: ListView.separated(
|
||||
itemCount: data.length,
|
||||
itemCount: filteredData.length,
|
||||
itemBuilder: (context, index) {
|
||||
final e = data[index];
|
||||
final e = filteredData[index];
|
||||
final dateLabel =
|
||||
'${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) {
|
||||
if (evaporasi <= 5.0) return 'Status: Normal';
|
||||
if (evaporasi <= 10.0) return 'Status: Sedang';
|
||||
|
|
@ -423,9 +495,7 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
|||
}
|
||||
|
||||
String _formatDateInfo(DateTime date) {
|
||||
|
||||
final now = DateTime.now();
|
||||
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final yesterday = today.subtract(const Duration(days: 1));
|
||||
final selected = DateTime(date.year, date.month, date.day);
|
||||
|
|
@ -439,4 +509,3 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,8 +30,7 @@ class EvaporasiChartWidget extends StatelessWidget {
|
|||
}) {
|
||||
final tempRange = (tempMax - tempMin);
|
||||
if (tempRange.abs() < 1e-9) return evapMin;
|
||||
|
||||
final normalized = (temp - tempMin) / tempRange; // 0..1
|
||||
final normalized = (temp - tempMin) / tempRange;
|
||||
return evapMin + normalized * (evapMax - evapMin);
|
||||
}
|
||||
|
||||
|
|
@ -44,14 +43,21 @@ class EvaporasiChartWidget extends StatelessWidget {
|
|||
}) {
|
||||
final evapRange = (evapMax - evapMin);
|
||||
if (evapRange.abs() < 1e-9) return tempMin;
|
||||
|
||||
final normalized = (yEvap - evapMin) / evapRange;
|
||||
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) {
|
||||
if (chartLabels.isEmpty || index < 0 || index >= chartLabels.length) {
|
||||
return index.toString();
|
||||
return '';
|
||||
}
|
||||
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 evapMax = 20.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) {
|
||||
return FlSpot(e.key.toDouble(), _safeValue(e.value));
|
||||
}).toList();
|
||||
|
|
@ -92,6 +97,7 @@ class EvaporasiChartWidget extends StatelessWidget {
|
|||
for (final s in evapSpotsAll) {
|
||||
evapByX[s.x.toInt()] = s.y;
|
||||
}
|
||||
|
||||
final dedupEvapSpots = evapByX.entries.toList()
|
||||
..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)))
|
||||
.toList();
|
||||
|
||||
// Suhu: skala-kan agar bisa ditampilkan di axis evaporasi
|
||||
// Suhu spots — diproyeksikan ke skala evaporasi
|
||||
final tempByX = <int, double>{};
|
||||
for (final entry in dailyTemperatures.asMap().entries) {
|
||||
tempByX[entry.key] = _safeValue(entry.value);
|
||||
|
|
@ -128,34 +134,58 @@ class EvaporasiChartWidget extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
final xInterval = _xLabelInterval();
|
||||
|
||||
final chart = LineChart(
|
||||
LineChartData(
|
||||
minY: evapMin,
|
||||
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),
|
||||
extraLinesData: const ExtraLinesData(horizontalLines: []),
|
||||
titlesData: FlTitlesData(
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 32,
|
||||
interval: 1,
|
||||
reservedSize: 36, // ✅ FIX: tambah ruang bawah agar label tidak nutup chart
|
||||
interval: xInterval, // ✅ FIX: interval dinamis per periode
|
||||
getTitlesWidget: (value, meta) {
|
||||
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(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
_getBottomLabel(index),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 10),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 9),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
leftTitles: AxisTitles(
|
||||
axisNameWidget: const Text(
|
||||
'mm',
|
||||
style: TextStyle(color: Colors.blueGrey, fontSize: 10),
|
||||
),
|
||||
axisNameSize: 16,
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 40,
|
||||
reservedSize: 36,
|
||||
interval: (evapMax - evapMin) / 4,
|
||||
getTitlesWidget: (value, meta) {
|
||||
return Text(
|
||||
|
|
@ -169,9 +199,14 @@ class EvaporasiChartWidget extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
rightTitles: AxisTitles(
|
||||
axisNameWidget: const Text(
|
||||
'°C',
|
||||
style: TextStyle(color: Colors.brown, fontSize: 10),
|
||||
),
|
||||
axisNameSize: 16,
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 40,
|
||||
reservedSize: 36,
|
||||
interval: (evapMax - evapMin) / 4,
|
||||
getTitlesWidget: (value, meta) {
|
||||
final t = getRightTitle(value);
|
||||
|
|
@ -185,28 +220,42 @@ class EvaporasiChartWidget extends StatelessWidget {
|
|||
},
|
||||
),
|
||||
),
|
||||
topTitles: AxisTitles(
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
),
|
||||
lineTouchData: LineTouchData(
|
||||
handleBuiltInTouches: true,
|
||||
touchTooltipData: LineTouchTooltipData(
|
||||
getTooltipItems: (spots) {
|
||||
return spots.map((spot) {
|
||||
final temp = _evapScaleToTemp(
|
||||
yEvap: spot.y,
|
||||
tooltipPadding: const EdgeInsets.all(8),
|
||||
getTooltipItems: (touchedSpots) {
|
||||
if (touchedSpots.isEmpty) return const <LineTooltipItem>[];
|
||||
|
||||
final List<LineTooltipItem> items = [];
|
||||
for (int i = 0; i < touchedSpots.length; i++) {
|
||||
final spot = touchedSpots[i];
|
||||
final y = _safeValue(spot.y).clamp(evapMin, evapMax);
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
return LineTooltipItem(
|
||||
'Evap: ${spot.y.toStringAsFixed(1)} mm\nSuhu: ${temp.toStringAsFixed(1)} °C',
|
||||
items.add(LineTooltipItem(
|
||||
'Suhu: ${tempOnly.toStringAsFixed(1)} °C',
|
||||
const TextStyle(color: Colors.white, fontSize: 12),
|
||||
);
|
||||
}).toList();
|
||||
));
|
||||
}
|
||||
}
|
||||
return items;
|
||||
},
|
||||
),
|
||||
),
|
||||
|
|
@ -214,18 +263,23 @@ class EvaporasiChartWidget extends StatelessWidget {
|
|||
if (evapSpots.isNotEmpty)
|
||||
LineChartBarData(
|
||||
spots: evapSpots,
|
||||
isCurved: false,
|
||||
isCurved: true,
|
||||
curveSmoothness: 0.3,
|
||||
color: Colors.blue.shade700,
|
||||
barWidth: 2,
|
||||
barWidth: 2.5,
|
||||
dotData: FlDotData(show: false),
|
||||
belowBarData: BarAreaData(show: false),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
color: Colors.blue.shade100.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
if (tempSpots.isNotEmpty)
|
||||
LineChartBarData(
|
||||
spots: tempSpots,
|
||||
isCurved: false,
|
||||
isCurved: true,
|
||||
curveSmoothness: 0.3,
|
||||
color: Colors.orange.shade700,
|
||||
barWidth: 2,
|
||||
barWidth: 2.5,
|
||||
dotData: FlDotData(show: false),
|
||||
belowBarData: BarAreaData(show: false),
|
||||
),
|
||||
|
|
@ -234,8 +288,8 @@ class EvaporasiChartWidget extends StatelessWidget {
|
|||
);
|
||||
|
||||
return Container(
|
||||
height: 380,
|
||||
padding: const EdgeInsets.all(12),
|
||||
height: 400, // ✅ FIX: tambah tinggi container agar label bawah tidak terpotong
|
||||
padding: const EdgeInsets.fromLTRB(8, 12, 8, 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
|
|
@ -249,6 +303,7 @@ class EvaporasiChartWidget extends StatelessWidget {
|
|||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Legend
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
|
|
@ -297,9 +352,10 @@ class EvaporasiChartWidget extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// ✅ FIX: Expanded + ClipRRect memastikan chart mengisi sisa ruang tanpa overflow
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: chart,
|
||||
),
|
||||
),
|
||||
|
|
@ -308,4 +364,3 @@ class EvaporasiChartWidget extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,117 +19,111 @@ class Evaporasi {
|
|||
);
|
||||
|
||||
factory Evaporasi.fromJson(Map<dynamic, dynamic> json) {
|
||||
// ===========================
|
||||
// 🔧 HELPER: parse angka aman
|
||||
// ===========================
|
||||
double toDoubleSafe(dynamic v) {
|
||||
if (v is num) return v.toDouble();
|
||||
if (v is String) {
|
||||
final s = v.trim();
|
||||
// dukung format seperti "12.3 cm" / "12,3" / "-"
|
||||
final normalized = s.replaceAll(',', '.');
|
||||
final match = RegExp(r'[-+]?\d*\.?\d+').firstMatch(normalized);
|
||||
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(
|
||||
json['evaporasi_mm'] ??
|
||||
json['evaporasi'] ??
|
||||
json['evaporasiMm'] ??
|
||||
json['evaporation_mm'] ??
|
||||
json['evap_mm'] ??
|
||||
json['evaporasi_mm_'] ??
|
||||
json['evaporasi_mm '] ??
|
||||
json['evaporasi_value'] ??
|
||||
json['evaporasi_mm_k'] ??
|
||||
json['evaporasi_k'],
|
||||
);
|
||||
|
||||
/// =========================
|
||||
/// SUHU
|
||||
/// =========================
|
||||
final suhuParsed = toDoubleSafe(
|
||||
// ===========================
|
||||
// 🌡️ SUHU
|
||||
// ✅ Firebase: suhu_air → prioritas pertama
|
||||
// ===========================
|
||||
final suhuRaw = toDoubleSafe(
|
||||
json['suhu_air'] ?? // ✅ field utama di Firebase
|
||||
json['suhu'] ??
|
||||
json['suhu_air'] ??
|
||||
json['suhuAir'] ??
|
||||
json['temp'] ??
|
||||
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;
|
||||
toDoubleSafe(
|
||||
json['suhu'] ??
|
||||
json['suhu_air'] ??
|
||||
json['suhuAir'] ??
|
||||
json['temp'] ??
|
||||
json['temperature'],
|
||||
);
|
||||
|
||||
// Banyak kemungkinan penamaan field tinggi air.
|
||||
// Pakai beberapa alias agar tidak default 0.
|
||||
// ===========================
|
||||
// 💦 TINGGI AIR
|
||||
// ✅ Firebase: tinggi_air_cm → prioritas pertama
|
||||
// ===========================
|
||||
final tinggiVal = toDoubleSafe(
|
||||
json['tinggi_air_cm'] ??
|
||||
json['tinggi_air_cm'] ?? // ✅ field utama di Firebase
|
||||
json['tinggi_air'] ??
|
||||
json['tinggiAir'] ??
|
||||
json['tinggiAir_cm'] ??
|
||||
json['tinggi_air_cm_'] ??
|
||||
json['tinggi_air_cm '] ??
|
||||
json['water_level'] ??
|
||||
json['waterLevel'] ??
|
||||
json['tinggi_air_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();
|
||||
|
||||
// dukung beberapa kemungkinan penamaan timestamp
|
||||
final rawTimestamp = json['timestamp'] ?? json['time'] ?? json['datetime'];
|
||||
// Urutan prioritas sesuai field yang ada di Firebase
|
||||
final rawTimestamp =
|
||||
json['datetime'] ?? // ✅ field utama di Firebase
|
||||
json['timestamp'] ??
|
||||
json['time'];
|
||||
|
||||
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);
|
||||
} else if (rawTimestamp is double) {
|
||||
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 {
|
||||
// legacy fallback dari field terpisah
|
||||
final datetimeStr = json['datetime'] as String?;
|
||||
if (datetimeStr != null) {
|
||||
final parsed = DateTime.tryParse(datetimeStr);
|
||||
if (parsed != null) timestamp = parsed;
|
||||
}
|
||||
|
||||
// Legacy fallback: field 'waktu' format "HH:mm:ss"
|
||||
final waktuStr = json['waktu'] as String?;
|
||||
if (waktuStr != null && datetimeStr == null) {
|
||||
if (waktuStr != null) {
|
||||
final parts = waktuStr.split(':');
|
||||
if (parts.length >= 2) {
|
||||
final jam = int.tryParse(parts[0]) ?? 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();
|
||||
timestamp = DateTime(now.year, now.month, now.day, jam, menit, detik);
|
||||
timestamp =
|
||||
DateTime(now.year, now.month, now.day, jam, menit, detik);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue