From 3df7ee1fbe56331dc5b82e6f9b1a4b17abf901e7 Mon Sep 17 00:00:00 2001 From: Mochamad ongki ramadani Date: Thu, 14 May 2026 19:58:43 +0700 Subject: [PATCH] pepe --- lib/core/utils/time_series_mapper.dart | 58 ++++--- .../evaporasi/blocs/evaporasi_bloc.dart | 14 +- .../evaporasi/blocs/evaporasi_state.dart | 31 ++-- .../evaporasi/views/evaporasi_screen.dart | 149 +++++++++++++----- .../views/widgets/evaporasi_chart_widget.dart | 133 +++++++++++----- .../lib/src/models/evaporasi.dart | 116 +++++++------- 6 files changed, 312 insertions(+), 189 deletions(-) diff --git a/lib/core/utils/time_series_mapper.dart b/lib/core/utils/time_series_mapper.dart index 12b1a59..3d61bf4 100644 --- a/lib/core/utils/time_series_mapper.dart +++ b/lib/core/utils/time_series_mapper.dart @@ -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.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 toSpecificDate({ @@ -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 smooth(List data) { if (data.length < 3) return data; - List result = []; + final List result = []; for (int i = 0; i < data.length; i++) { if (i == 0 || i == data.length - 1) { @@ -147,4 +157,4 @@ class TimeSeriesMapper { return result; } -} +} \ No newline at end of file diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart index a3f1e56..97ef996 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart @@ -33,7 +33,6 @@ class EvaporasiBloc extends Bloc { ) 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 { 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 { _EvaporasiRealtimeUpdated event, Emitter 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 { 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.from(state.dailyValues); final updatedTemp = isDuplicate @@ -135,7 +132,7 @@ class EvaporasiBloc extends Bloc { 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 { 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 { 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 get props => [data]; -} +} \ No newline at end of file diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart index d84efea..cc60eb7 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart @@ -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 dailyValues; // untuk grafik evaporasi (default) + final List dailyValues; final List weeklyValues; final List monthlyValues; - final List dailyTemperatures; // untuk grafik suhu + final List dailyTemperatures; final List weeklyTemperatures; final List monthlyTemperatures; - /// Label X-axis sesuai agregasi period - /// Contoh: Hari Ini => ["00:00","01:00",...] - /// Minggu Ini => ["Sen","Sel",...] - /// Bulan Ini => ["1","2",...] final List chartLabels; - - /// Data untuk list (timestamp asli dari firebase) final List listData; - final List 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? dailyValues, List? 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, ]; -} - +} \ No newline at end of file diff --git a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart index 3cdfdca..9b2abbe 100644 --- a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart +++ b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart @@ -101,8 +101,10 @@ class _EvaporasiScreenState extends State { }, ), const SizedBox(height: 20), - // List data (mengikuti chart: period vs custom date) - _evaporasiList(state), + // โœ… FIX: Gunakan BlocBuilder agar list reaktif terhadap perubahan state + BlocBuilder( + builder: (context, state) => _evaporasiList(state), + ), const SizedBox(height: 10), ExportPdfButton( onExport: () => PdfExportService.evaporasi( @@ -121,9 +123,9 @@ class _EvaporasiScreenState extends State { ); } - /// ========================= - /// ๐Ÿ”ฅ MAIN CARD (EVAPORASI) - /// ========================= + // ========================= + // ๐Ÿ”ฅ MAIN CARD (EVAPORASI) + // ========================= Widget _mainCard(EvaporasiState state) { return Container( width: double.infinity, @@ -155,9 +157,9 @@ class _EvaporasiScreenState extends State { ); } - /// ========================= - /// ๐Ÿ“Š 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 { ); } - /// ========================= - /// ๐Ÿ“ˆ STATUS CARD - /// ========================= + // ========================= + // ๐Ÿ“ˆ STATUS CARD + // ========================= Widget _statusCard(EvaporasiState state) { Color statusColor; IconData statusIcon; @@ -226,7 +228,6 @@ class _EvaporasiScreenState extends State { 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 { ); } - /// ========================= - /// ๐Ÿงพ 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 { 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 { 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 { ); } - /// ========================= - /// ๐Ÿ“… 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 { } 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 { return DateFormat('EEEE, d MMMM yyyy', 'id_ID').format(date); } } -} - +} \ No newline at end of file diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart index 6f1ad4c..6976d8f 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart @@ -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 = {}; 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 []; - return LineTooltipItem( - 'Evap: ${spot.y.toStringAsFixed(1)} mm\nSuhu: ${temp.toStringAsFixed(1)} ยฐC', - const TextStyle(color: Colors.white, fontSize: 12), - ); - }).toList(); + final List 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 { ), ); } -} - +} \ No newline at end of file diff --git a/packages/monitoring_repository/lib/src/models/evaporasi.dart b/packages/monitoring_repository/lib/src/models/evaporasi.dart index 2f8b91a..5ea122e 100644 --- a/packages/monitoring_repository/lib/src/models/evaporasi.dart +++ b/packages/monitoring_repository/lib/src/models/evaporasi.dart @@ -19,117 +19,111 @@ class Evaporasi { ); factory Evaporasi.fromJson(Map 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, ); } -} +} \ No newline at end of file