This commit is contained in:
Mochamad ongki ramadani 2026-05-14 19:58:43 +07:00
parent e00d471796
commit 3df7ee1fbe
6 changed files with 312 additions and 189 deletions

View File

@ -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;
} }
} }

View File

@ -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];
} }

View File

@ -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,
]; ];
} }

View File

@ -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);
} }
} }
} }

View File

@ -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 {
), ),
); );
} }
} }

View File

@ -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,
); );
} }
} }