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);
if (_isSameDay(time, now)) {
final hour = time.hour;
sums[hour] += getValue(item);
counts[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,12 +48,14 @@ 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;
sums[index] += getValue(item);
counts[index]++;
if (index >= 0 && index < 7) {
sums[index] += getValue(item);
counts[index]++;
}
}
}
@ -76,12 +80,14 @@ 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;
sums[index] += getValue(item);
counts[index]++;
if (index >= 0 && index < daysInMonth) {
sums[index] += getValue(item);
counts[index]++;
}
}
}
@ -91,7 +97,7 @@ class TimeSeriesMapper {
});
}
/// =========================
/// =========================
/// 📅 SPECIFIC DATE (24 JAM - TANGGAL KHUSUS)
/// =========================
static List<double> toSpecificDate<T>({
@ -107,9 +113,11 @@ class TimeSeriesMapper {
final time = getTime(item);
if (_isSameDay(time, targetDate)) {
final hour = time.hour;
sums[hour] += getValue(item);
counts[hour]++;
final hour = time.toLocal().hour; // FIX: pastikan pakai local hour
if (hour >= 0 && hour < 24) {
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) {
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) {
@ -147,4 +157,4 @@ class TimeSeriesMapper {
return result;
}
}
}

View File

@ -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,
));
@ -326,4 +326,4 @@ class _EvaporasiRealtimeUpdated extends EvaporasiEvent {
@override
List<Object> get props => [data];
}
}

View File

@ -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,
@ -117,5 +113,4 @@ class EvaporasiState extends Equatable {
willRain,
isLoading,
];
}
}

View File

@ -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,9 +372,20 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
color: Colors.white,
borderRadius: BorderRadius.circular(20),
),
child: const Text(
'Belum ada data evaporasi',
style: TextStyle(color: Colors.grey),
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);
@ -438,5 +508,4 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
return DateFormat('EEEE, d MMMM yyyy', 'id_ID').format(date);
}
}
}
}

View File

@ -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,
evapMin: evapMin,
evapMax: evapMax,
tempMin: tempMin,
tempMax: tempMax,
);
tooltipPadding: const EdgeInsets.all(8),
getTooltipItems: (touchedSpots) {
if (touchedSpots.isEmpty) return const <LineTooltipItem>[];
return LineTooltipItem(
'Evap: ${spot.y.toStringAsFixed(1)} mm\nSuhu: ${temp.toStringAsFixed(1)} °C',
const TextStyle(color: Colors.white, fontSize: 12),
);
}).toList();
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,
);
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)
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,
),
),
@ -307,5 +363,4 @@ class EvaporasiChartWidget extends StatelessWidget {
),
);
}
}
}

View File

@ -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(
json['suhu'] ??
json['suhu_air'] ??
// ===========================
// 🌡 SUHU
// Firebase: suhu_air prioritas pertama
// ===========================
final suhuRaw = toDoubleSafe(
json['suhu_air'] ?? // field utama di Firebase
json['suhu'] ??
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);
}
}
}
@ -141,4 +135,4 @@ class Evaporasi {
timestamp: timestamp,
);
}
}
}