diff --git a/lib/core/utils/time_series_mapper.dart b/lib/core/utils/time_series_mapper.dart index 8005c4a..7c2692a 100644 --- a/lib/core/utils/time_series_mapper.dart +++ b/lib/core/utils/time_series_mapper.dart @@ -51,21 +51,24 @@ class TimeSeriesMapper { required double Function(T) getValue, }) { final now = DateTime.now(); - final sums = List.filled(24, 0.0); - final counts = List.filled(24, 0); + final values = List.filled(24, -1.0); + final latestTimes = List.filled(24, null); + for (final item in data) { final time = getTime(item); if (_isSameDay(time, now)) { - final hour = time.toLocal().hour; + final localTime = time.toLocal(); + final hour = localTime.hour; if (hour >= 0 && hour < 24) { - sums[hour] += getValue(item); - counts[hour]++; + final existingTime = latestTimes[hour]; + if (existingTime == null || localTime.isAfter(existingTime)) { + values[hour] = getValue(item); + latestTimes[hour] = localTime; + } } } } - final raw = List.generate(24, (i) => - counts[i] == 0 ? -1.0 : sums[i] / counts[i]); - return _interpolate(_filterSpike(raw)); + return _interpolate(_filterSpike(values)); } // ============================================================ @@ -132,21 +135,24 @@ class TimeSeriesMapper { required double Function(T) getValue, required DateTime targetDate, }) { - final sums = List.filled(24, 0.0); - final counts = List.filled(24, 0); + final values = List.filled(24, -1.0); + final latestTimes = List.filled(24, null); + for (final item in data) { final time = getTime(item); if (_isSameDay(time, targetDate)) { - final hour = time.toLocal().hour; + final localTime = time.toLocal(); + final hour = localTime.hour; if (hour >= 0 && hour < 24) { - sums[hour] += getValue(item); - counts[hour]++; + final existingTime = latestTimes[hour]; + if (existingTime == null || localTime.isAfter(existingTime)) { + values[hour] = getValue(item); + latestTimes[hour] = localTime; + } } } } - final raw = List.generate(24, (i) => - counts[i] == 0 ? -1.0 : sums[i] / counts[i]); - return _interpolate(_filterSpike(raw)); + return _interpolate(_filterSpike(values)); } // ============================================================ diff --git a/lib/screens/home/widgets/dashboard_charts.dart b/lib/screens/home/widgets/dashboard_charts.dart index 4dc5c45..5e57f0b 100644 --- a/lib/screens/home/widgets/dashboard_charts.dart +++ b/lib/screens/home/widgets/dashboard_charts.dart @@ -72,7 +72,25 @@ class _DashboardChartsState extends State { void _onPeriodChanged(String p) { setState(() => _period = p); context.read().add(WindSpeedPeriodChanged(p)); - context.read().add(EvaporasiPeriodChanged(p)); + final now = DateTime.now(); + DateTime startDate; + DateTime endDate = now; + + switch (p) { + case "Minggu Ini": + startDate = now.subtract(Duration(days: now.weekday - 1)); + break; + case "Bulan Ini": + startDate = DateTime(now.year, now.month, 1); + break; + default: + startDate = now; + break; + } + + context.read().add( + EvaporasiDateRangeChanged(startDate: startDate, endDate: endDate), + ); // AtmosphericBloc tidak punya period (hanya realtime) } @@ -132,11 +150,6 @@ class _DashboardChartsState extends State { // 2. Evaporasi BlocBuilder( builder: (context, state) { - final data = switch (_period) { - "Minggu Ini" => state.dailyValues, // weekly jika ada - "Bulan Ini" => state.dailyValues, - _ => state.dailyValues, - }; return _SensorChartCard( title: 'Evaporasi', unit: 'mm', @@ -144,7 +157,7 @@ class _DashboardChartsState extends State { bgColor: Colors.teal.shade50, icon: Icons.water_drop_outlined, currentValue: state.currentValue, - data: data, + data: state.chartValues, period: _period, isLoading: state.isLoading, ); diff --git a/lib/screens/home/widgets/sensor_grid.dart b/lib/screens/home/widgets/sensor_grid.dart index 0f0b56b..bae59e2 100644 --- a/lib/screens/home/widgets/sensor_grid.dart +++ b/lib/screens/home/widgets/sensor_grid.dart @@ -46,6 +46,7 @@ class SensorGrid extends StatelessWidget { ? '—' : state.currentValue.toStringAsFixed(1), unit: 'mm', + status: state.currentData?.status, isLoading: state.isLoading, ), ), @@ -79,6 +80,7 @@ class SensorCard extends StatelessWidget { final String label; final String value; final String unit; + final String? status; final bool isLoading; const SensorCard({ @@ -90,6 +92,7 @@ class SensorCard extends StatelessWidget { required this.label, required this.value, required this.unit, + this.status, this.isLoading = false, }); @@ -162,6 +165,32 @@ class SensorCard extends StatelessWidget { ), ], ), + if (status != null && !isLoading) ...[ + const SizedBox(height: 5), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: (status == 'Tinggi' + ? Colors.red.shade50 + : (status == 'Rendah' ? Colors.blue.shade50 : Colors.green.shade50)), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + status!, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: (status == 'Tinggi' + ? Colors.red.shade600 + : (status == 'Rendah' ? Colors.blue.shade600 : Colors.green.shade600)), + ), + ), + ), + ], + ), + ], const SizedBox(height: 4), // Label Text( diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart index eeaacd6..04a5b9b 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart @@ -44,7 +44,6 @@ class EvaporasiBloc extends Bloc { (json) => Evaporasi.fromJson(json), ), )..sort((a, b) => a.timestamp.compareTo(b.timestamp)); - final now = DateTime.now(); // Default: tampilkan hari ini (per jam) @@ -88,10 +87,9 @@ class EvaporasiBloc extends Bloc { await _subscription?.cancel(); _subscription = _repository .getSensorStream( - 'Monitoring', + 'Monitoring/realtime', (json) { - final f = Map.from(json)..remove('History'); - return Evaporasi.fromJson(f); + return Evaporasi.fromJson(json); }, ) .listen((data) => add(_EvaporasiRealtimeUpdated(data))); @@ -104,8 +102,6 @@ class EvaporasiBloc extends Bloc { _EvaporasiRealtimeUpdated event, Emitter emit, ) { - if (event.data.timestamp.millisecondsSinceEpoch == 0) return; - final updatedHistory = List.from(state.history); final dupIdx = updatedHistory.indexWhere( (e) => e.timestamp.toUtc() == event.data.timestamp.toUtc(), @@ -117,26 +113,45 @@ class EvaporasiBloc extends Bloc { } updatedHistory.sort((a, b) => a.timestamp.compareTo(b.timestamp)); - // Update chart jika sedang tampil hari ini per jam - List updatedChart = state.chartValues; - List updatedTemp = state.chartTemperatures; + // Regenerasikan data chart secara dinamis agar selalu sinkron dengan data real-time + List updatedChart; + List updatedTemp; + List updatedLabels = state.chartLabels; - if (state.isSingleDay) { - final eventTime = event.data.timestamp.toLocal(); - final now = DateTime.now(); - final isToday = eventTime.year == now.year && - eventTime.month == now.month && - eventTime.day == now.day; + final isSingle = _isSameDay(state.startDate, state.endDate); - if (isToday && dupIdx < 0) { - updatedChart = List.from(state.chartValues); - updatedTemp = List.from(state.chartTemperatures); - final hour = eventTime.hour; - if (hour >= 0 && hour < 24) { - updatedChart[hour] = event.data.evaporasi; - updatedTemp[hour] = event.data.suhu; - } - } + if (isSingle) { + updatedChart = TimeSeriesMapper.toSpecificDate( + data: updatedHistory, + getTime: (e) => e.timestamp, + getValue: (e) => e.evaporasi, + targetDate: state.startDate, + ); + updatedTemp = TimeSeriesMapper.toSpecificDate( + data: updatedHistory, + getTime: (e) => e.timestamp, + getValue: (e) => e.suhu, + targetDate: state.startDate, + ); + updatedLabels = List.generate(24, (i) => '${i.toString().padLeft(2, '0')}:00'); + } else { + final evapResult = TimeSeriesMapper.toDateRange( + data: updatedHistory, + getTime: (e) => e.timestamp, + getValue: (e) => e.evaporasi, + startDate: state.startDate, + endDate: state.endDate, + ); + final tempResult = TimeSeriesMapper.toDateRange( + data: updatedHistory, + getTime: (e) => e.timestamp, + getValue: (e) => e.suhu, + startDate: state.startDate, + endDate: state.endDate, + ); + updatedChart = evapResult.values; + updatedTemp = tempResult.values; + updatedLabels = evapResult.labels; } final (status, willRain) = _computeStatus(event.data.evaporasi); @@ -155,6 +170,7 @@ class EvaporasiBloc extends Bloc { waterLevel: event.data.tinggiAir, chartValues: updatedChart, chartTemperatures: updatedTemp, + chartLabels: updatedLabels, weatherStatus: status, willRain: willRain, currentData: event.data, diff --git a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart index 464011e..4f87856 100644 --- a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart +++ b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart @@ -165,23 +165,46 @@ class _EvaporasiScreenState extends State { } Widget _infoRow(EvaporasiState state) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _miniCard('Suhu Air', - '${state.temperature.toStringAsFixed(1)} °C', - Icons.thermostat, Colors.orange), - _miniCard('Tinggi Air', - '${state.waterLevel.toStringAsFixed(1)} cm', - Icons.water, Colors.blue), - ], + return LayoutBuilder( + builder: (context, constraints) { + final cardWidth = (constraints.maxWidth - 12) / 2; + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _miniCard( + cardWidth, + 'Suhu Air', + state.temperature < 0 + ? '- °C' + : '${state.temperature.toStringAsFixed(1)} °C', + Icons.thermostat, + Colors.orange, + ), + _miniCard( + cardWidth, + 'Tinggi Air', + '${state.waterLevel.toStringAsFixed(1)} cm', + Icons.water, + Colors.blue, + ), + _miniCard( + cardWidth, + 'Acuan Air Pagi', + '${(state.currentData?.acuanPagi ?? 0.0).toStringAsFixed(1)} cm', + Icons.wb_sunny_outlined, + Colors.amber.shade700, + ), + ], + ); + }, ); } Widget _miniCard( - String title, String value, IconData icon, Color color) { + double width, String title, String value, IconData icon, Color color) { return Container( - width: 160, + width: width, padding: const EdgeInsets.all(15), decoration: BoxDecoration( color: Colors.white, @@ -191,15 +214,26 @@ class _EvaporasiScreenState extends State { children: [ Icon(icon, color: color), const SizedBox(width: 10), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: const TextStyle(fontSize: 11, color: Colors.grey), + overflow: TextOverflow.ellipsis, + ), + Text( + value, style: const TextStyle( - fontSize: 12, color: Colors.grey)), - Text(value, - style: const TextStyle(fontWeight: FontWeight.bold)), - ], + fontWeight: FontWeight.bold, + fontSize: 13, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), ), ], ), 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 722514f..7c06562 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart @@ -86,11 +86,13 @@ class EvaporasiChartWidget extends StatelessWidget { List _buildEvapSpots(double evapMin, double evapMax) { if (dailyValues.isEmpty) return const []; - return dailyValues.asMap().entries.map((e) { - final x = e.key.toDouble(); - final y = _safeValue(e.value).clamp(evapMin, evapMax); - return FlSpot(x, y); - }).toList(); + return dailyValues.asMap().entries + .where((e) => e.value >= 0) // Hanya tampilkan data yang ada / valid + .map((e) { + final x = e.key.toDouble(); + final y = _safeValue(e.value).clamp(evapMin, evapMax); + return FlSpot(x, y); + }).toList(); } @override @@ -120,27 +122,32 @@ class EvaporasiChartWidget extends StatelessWidget { final tempMinRaw = _minOf(dailyTemperatures); final tempMaxRaw = _maxOf(dailyTemperatures); - final tempMin = tempMinRaw; - final tempMax = tempMaxRaw == tempMinRaw ? tempMinRaw + 1 : tempMaxRaw; + // ── FIXED COORD SYSTEM SUHU MALFUNCTION ── + // Jika data suhu flat 0 atau sangat rendah, kita kunci range visualnya dari 0 sampai 40 derajat Celsius + final tempMin = tempMaxRaw < 2.0 ? 0.0 : tempMinRaw; + final tempMax = tempMaxRaw < 2.0 ? 40.0 : (tempMaxRaw == tempMinRaw ? tempMinRaw + 1 : tempMaxRaw); - // Evaporasi: batasi maksimum supaya tetap masuk akal - final evapMax = evapMaxRaw < 1e-9 ? 20.0 : (evapMaxRaw > 50 ? 50.0 : evapMaxRaw); + // Evaporasi: Berikan buffer / ruang kosong (+ 20%) di bagian atas grafik agar tidak terpotong rata + final double evapMaxBase = evapMaxRaw < 1e-9 ? 20.0 : (evapMaxRaw > 50 ? 50.0 : evapMaxRaw); + final evapMax = evapMaxBase * 1.2; // Tambahan padding atas 20% final evapSpots = _buildEvapSpots(evapMin, evapMax); // Suhu diproyeksikan ke skala evaporasi - final tempSpots = dailyTemperatures.asMap().entries.map((entry) { - final x = entry.key.toDouble(); - final temp = _safeValue(entry.value); - final y = _tempToEvapScale( - temp: temp, - evapMin: evapMin, - evapMax: evapMax, - tempMin: tempMin, - tempMax: tempMax, - ); - return FlSpot(x, y.clamp(evapMin, evapMax)); - }).toList(); + final tempSpots = dailyTemperatures.asMap().entries + .where((entry) => entry.value >= 0) // Hanya tampilkan data yang ada / valid + .map((entry) { + final x = entry.key.toDouble(); + final temp = _safeValue(entry.value); + final y = _tempToEvapScale( + temp: temp, + evapMin: evapMin, + evapMax: evapMax, + tempMin: tempMin, + tempMax: tempMax, + ); + return FlSpot(x, y.clamp(evapMin, evapMax)); + }).toList(); double getRightTitle(double y) { if (tempSpots.isEmpty) return tempMin; @@ -163,7 +170,8 @@ class EvaporasiChartWidget extends StatelessWidget { maxX: (chartLabels.isNotEmpty ? chartLabels.length - 1 : 23) .toDouble() + 0.5, - clipData: const FlClipData.all(), + // Diubah ke clipData false agar lengkungan ujung titik teratas tidak ter-crop kaku + clipData: const FlClipData.none(), gridData: FlGridData( show: true, drawVerticalLine: false, @@ -232,8 +240,10 @@ class EvaporasiChartWidget extends StatelessWidget { interval: (evapMax - evapMin) / 4, getTitlesWidget: (value, meta) { final t = getRightTitle(value); + // Menampilkan label dengan 1 angka desimal jika nilainya kecil, + // atau bulat murni jika data menggunakan fallback sistem (0-40) return Text( - t.toStringAsFixed(0), + t > 5 ? t.toStringAsFixed(0) : t.toStringAsFixed(1), style: const TextStyle( color: Colors.brown, fontSize: 10, @@ -249,36 +259,48 @@ class EvaporasiChartWidget extends StatelessWidget { lineTouchData: LineTouchData( handleBuiltInTouches: true, touchTooltipData: LineTouchTooltipData( - tooltipPadding: const EdgeInsets.all(8), + tooltipPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + showOnTopOfTheChartBoxArea: true, + fitInsideHorizontally: true, + fitInsideVertically: true, getTooltipItems: (touchedSpots) { if (touchedSpots.isEmpty) return const []; - final items = []; - for (int i = 0; i < touchedSpots.length; i++) { - final spot = touchedSpots[i]; - final y = _safeValue(spot.y).clamp(evapMin, evapMax); + // Ambil spot pertama yang disentuh untuk mencari index X + final firstSpot = touchedSpots.first; + final idx = firstSpot.x.round(); - 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), - )); - } + // Validasi batas index + if (idx < 0 || idx >= dailyValues.length) { + return const []; } - return items; + + // Ambil data evaporasi dan suhu langsung dari array data berdasarkan index X + final evapVal = dailyValues[idx]; + final tempVal = idx < dailyTemperatures.length ? dailyTemperatures[idx] : 0.0; + + final tooltipText = 'Evap: ${evapVal.toStringAsFixed(1)} mm\n' + 'Suhu: ${tempVal.toStringAsFixed(1)} °C'; + + return touchedSpots.asMap().entries.map((entry) { + final index = entry.key; + if (index == 0) { + return LineTooltipItem( + tooltipText, + const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.bold, + height: 1.4, + ), + ); + } else { + return const LineTooltipItem( + '', + TextStyle(color: Colors.transparent, fontSize: 0), + ); + } + }).toList(); }, ), ), @@ -322,8 +344,6 @@ class EvaporasiChartWidget extends StatelessWidget { children: [ const Text( '', - // NOTE: placeholder; judul seragam dengan AtmosphericScreen. - // Jika ingin judul Evaporasi, ganti sesuai kebutuhan. style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), const SizedBox(height: 12), @@ -331,7 +351,11 @@ class EvaporasiChartWidget extends StatelessWidget { height: 220, child: ClipRRect( borderRadius: BorderRadius.circular(16), - child: chart, + // Mengubah ClipBehavior ke none agar garis di titik maksimum visual luar aman tidak ter-crop + child: Padding( + padding: const EdgeInsets.only(top: 10, right: 4), + child: chart, + ), ), ), const SizedBox(height: 12), @@ -378,5 +402,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 c3007e3..70ce899 100644 --- a/packages/monitoring_repository/lib/src/models/evaporasi.dart +++ b/packages/monitoring_repository/lib/src/models/evaporasi.dart @@ -4,12 +4,16 @@ class Evaporasi { final double evaporasi; final double suhu; final double tinggiAir; + final double acuanPagi; + final String status; final DateTime timestamp; Evaporasi({ required this.evaporasi, required this.suhu, required this.tinggiAir, + required this.acuanPagi, + required this.status, required this.timestamp, }); @@ -17,6 +21,8 @@ class Evaporasi { evaporasi: 0.0, suhu: 0.0, tinggiAir: 0.0, + acuanPagi: 0.0, + status: "Normal", timestamp: DateTime.fromMillisecondsSinceEpoch(0), ); @@ -56,7 +62,7 @@ class Evaporasi { json['temp'] ?? json['temperature'], ); - final suhuVal = (suhuRaw < -50 || suhuRaw > 100) ? 0.0 : suhuRaw; + final suhuVal = (suhuRaw < -50 || suhuRaw > 100) ? -1.0 : suhuRaw; // ── Tinggi Air (cm) ────────────────────────────────── final tinggiVal = toDoubleSafe( @@ -76,6 +82,14 @@ class Evaporasi { final tinggiFiltered = (tinggiVal < 0 || tinggiVal > 100) ? 0.0 : tinggiVal; + // ── Acuan Pagi (cm) ────────────────────────────────── + final acuanPagiVal = toDoubleSafe( + json['acuan_pagi_cm'] ?? json['acuan_pagi'] ?? 0.0, + ); + + // ── Status ─────────────────────────────────────────── + final statusVal = json['status']?.toString() ?? "Normal"; + // ── Parse Timestamp ────────────────────────────────── // FIX: Tambahkan offset +07:00 (WIB) jika string tidak punya info timezone, // agar tidak terjadi mismatch 7 jam antara Firebase dan Flutter. @@ -157,6 +171,8 @@ class Evaporasi { evaporasi: evaporasiFiltered, suhu: suhuVal, tinggiAir: tinggiFiltered, + acuanPagi: acuanPagiVal, + status: statusVal, timestamp: timestamp, ); }