diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..828454f --- /dev/null +++ b/TODO.md @@ -0,0 +1,11 @@ +# TODO: Fitur Baru Evaporasi (Period Selector + Status + Notifikasi) + +## Steps +- [x] 1. Edit `evaporasi_state.dart` — tambahkan `weatherStatus` & `willRain` +- [x] 2. Edit `evaporasi_bloc.dart` — hitung status & update global notifier +- [x] 3. Create `notification_notifier.dart` — global ValueNotifier untuk alert +- [x] 4. Create `evaporasi_period_selector.dart` — tombol periode +- [x] 5. Edit `evaporasi_screen.dart` — tambahkan period selector & status card +- [x] 6. Edit `home_screen.dart` — badge merah di icon lonceng +- [x] 7. Verifikasi kompilasi + diff --git a/lib/core/notification_notifier.dart b/lib/core/notification_notifier.dart new file mode 100644 index 0000000..e28aa38 --- /dev/null +++ b/lib/core/notification_notifier.dart @@ -0,0 +1,9 @@ +import 'package:flutter/foundation.dart'; + +/// Global notifier untuk peringatan cuaca di dashboard +/// Di-update dari EvaporasiBloc, di-listen di HomeScreen +final ValueNotifier hasWeatherAlert = ValueNotifier(false); + +/// Pesan peringatan yang akan ditampilkan +final ValueNotifier weatherAlertMessage = ValueNotifier(''); + diff --git a/lib/screens/home/views/home_screen.dart b/lib/screens/home/views/home_screen.dart index 81e5624..7574783 100644 --- a/lib/screens/home/views/home_screen.dart +++ b/lib/screens/home/views/home_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../blocs/authentication_bloc/authentication_bloc.dart'; +import '../../../core/notification_notifier.dart'; import 'main_drawer.dart'; class HomeScreen extends StatelessWidget { @@ -36,10 +37,42 @@ class HomeScreen extends StatelessWidget { ], ), actions: [ - IconButton( - icon: const Icon(Icons.notifications_none, color: Colors.black), - onPressed: () { - // Aksi notifikasi + ValueListenableBuilder( + valueListenable: hasWeatherAlert, + builder: (context, hasAlert, child) { + return Stack( + children: [ + IconButton( + icon: const Icon(Icons.notifications_none, + color: Colors.black), + onPressed: () { + // Aksi notifikasi + if (hasAlert) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(weatherAlertMessage.value), + backgroundColor: Colors.orange.shade800, + behavior: SnackBarBehavior.floating, + ), + ); + } + }, + ), + if (hasAlert) + Positioned( + right: 8, + top: 8, + child: Container( + width: 10, + height: 10, + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + ), + ), + ], + ); }, ), IconButton( diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart index 1e49412..1520855 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart @@ -4,6 +4,7 @@ import 'package:equatable/equatable.dart'; import 'package:monitoring_repository/monitoring_repository.dart'; import '../../../../core/utils/time_series_mapper.dart'; +import '../../../../core/notification_notifier.dart'; part 'evaporasi_event.dart'; part 'evaporasi_state.dart'; @@ -37,12 +38,26 @@ 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( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.suhu, + ); + + // Hitung status dari data terakhir history jika ada + final lastValue = history.isNotEmpty ? history.last.evaporasi : 0.0; + final (status, rain) = _computeWeatherStatus(lastValue); + _updateGlobalNotifier(status, rain); + emit(state.copyWith( history: history, dailyValues: dailyGraph, + dailyTemperatures: dailyTempGraph, + weatherStatus: status, + willRain: rain, isLoading: false, )); @@ -66,18 +81,26 @@ class EvaporasiBloc extends Bloc { Emitter emit, ) { final updated = List.from(state.dailyValues); + final updatedTemp = List.from(state.dailyTemperatures); final index = DateTime.now().hour; if (index < updated.length) { - updated[index] = event.data.evaporasi; // ⚠️ sesuaikan field + updated[index] = event.data.evaporasi; + updatedTemp[index] = event.data.suhu; } + final (status, rain) = _computeWeatherStatus(event.data.evaporasi); + _updateGlobalNotifier(status, rain); + emit(state.copyWith( currentValue: event.data.evaporasi, temperature: event.data.suhu, waterLevel: event.data.tinggiAir, dailyValues: updated, + dailyTemperatures: updatedTemp, + weatherStatus: status, + willRain: rain, )); } @@ -93,6 +116,7 @@ class EvaporasiBloc extends Bloc { final history = state.history; List updated; + List updatedTemp; if (event.period == "Minggu Ini") { updated = TimeSeriesMapper.toWeekly( @@ -100,22 +124,39 @@ class EvaporasiBloc extends Bloc { getTime: (e) => e.timestamp, getValue: (e) => e.evaporasi, ); + updatedTemp = TimeSeriesMapper.toWeekly( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.suhu, + ); } else if (event.period == "Bulan Ini") { updated = TimeSeriesMapper.toMonthly( data: history, getTime: (e) => e.timestamp, getValue: (e) => e.evaporasi, ); + updatedTemp = TimeSeriesMapper.toMonthly( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.suhu, + ); } else { updated = TimeSeriesMapper.toDaily( data: history, getTime: (e) => e.timestamp, getValue: (e) => e.evaporasi, ); + updatedTemp = TimeSeriesMapper.toDaily( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.suhu, + ); } + // Pertahankan status cuaca saat ini (tidak berubah karena hanya ganti periode) emit(state.copyWith( dailyValues: updated, + dailyTemperatures: updatedTemp, isLoading: false, )); } @@ -125,6 +166,29 @@ class EvaporasiBloc extends Bloc { await _subscription?.cancel(); return super.close(); } + + /// ========================= + /// 🌤️ HELPER: Status Cuaca + /// ========================= + static (String status, bool willRain) _computeWeatherStatus(double value) { + if (value <= 5.0) { + return ("Baik", false); + } else if (value > 5.0 && value <= 10.0) { + return ("Sedang", true); + } else { + return ("Buruk", true); + } + } + + static void _updateGlobalNotifier(String status, bool willRain) { + hasWeatherAlert.value = willRain; + if (willRain) { + weatherAlertMessage.value = + 'Status evaporasi $status — potensi hujan tinggi'; + } else { + weatherAlertMessage.value = ''; + } + } } /// INTERNAL EVENT diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart index b0487b7..742ce96 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart @@ -6,9 +6,13 @@ class EvaporasiState extends Equatable { final double waterLevel; // tinggi air final String selectedPeriod; - final List dailyValues; // untuk grafik + final List dailyValues; // untuk grafik evaporasi + final List dailyTemperatures; // untuk grafik suhu final List history; + final String weatherStatus; // Baik / Sedang / Buruk + final bool willRain; // true jika status Sedang/Buruk + final bool isLoading; const EvaporasiState({ @@ -17,7 +21,10 @@ class EvaporasiState extends Equatable { this.waterLevel = 0.0, this.selectedPeriod = "Hari Ini", this.dailyValues = const [], + this.dailyTemperatures = const [], this.history = const [], + this.weatherStatus = "Baik", + this.willRain = false, this.isLoading = true, }); @@ -27,7 +34,10 @@ class EvaporasiState extends Equatable { double? waterLevel, String? selectedPeriod, List? dailyValues, + List? dailyTemperatures, List? history, + String? weatherStatus, + bool? willRain, bool? isLoading, }) { return EvaporasiState( @@ -36,7 +46,10 @@ class EvaporasiState extends Equatable { waterLevel: waterLevel ?? this.waterLevel, selectedPeriod: selectedPeriod ?? this.selectedPeriod, dailyValues: dailyValues ?? this.dailyValues, + dailyTemperatures: dailyTemperatures ?? this.dailyTemperatures, history: history ?? this.history, + weatherStatus: weatherStatus ?? this.weatherStatus, + willRain: willRain ?? this.willRain, isLoading: isLoading ?? this.isLoading, ); } @@ -48,7 +61,10 @@ class EvaporasiState extends Equatable { waterLevel, selectedPeriod, dailyValues, + dailyTemperatures, history, + weatherStatus, + willRain, isLoading, ]; } diff --git a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart index e0e33d4..629029b 100644 --- a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart +++ b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../blocs/evaporasi_bloc.dart'; +import 'widgets/evaporasi_chart_widget.dart'; +import 'widgets/evaporasi_period_selector.dart'; import '../../shared/utils/pdf/pdf_export_service.dart'; import '../../shared/widgets/export_pdf_button.dart'; @@ -44,7 +46,19 @@ class EvaporasiScreen extends StatelessWidget { const SizedBox(height: 25), _infoRow(state), const SizedBox(height: 25), - _trendSection(), + _statusCard(state), + const SizedBox(height: 25), + const Text("Tren Evaporasi & Suhu", + style: + TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 15), + const EvaporasiPeriodSelector(), + const SizedBox(height: 15), + EvaporasiChartWidget( + dailyValues: state.dailyValues, + dailyTemperatures: state.dailyTemperatures, + period: state.selectedPeriod, + ), const SizedBox(height: 25), ExportPdfButton( onExport: () => PdfExportService.evaporasi( @@ -146,21 +160,70 @@ class EvaporasiScreen extends StatelessWidget { } /// ========================= - /// 📈 TREND (SIMPLE PLACEHOLDER) + /// 🌤️ STATUS CARD /// ========================= - Widget _trendSection() { + Widget _statusCard(EvaporasiState state) { + Color statusColor; + IconData statusIcon; + + switch (state.weatherStatus) { + case "Sedang": + statusColor = Colors.orange; + statusIcon = Icons.warning_amber_rounded; + break; + case "Buruk": + statusColor = Colors.red; + statusIcon = Icons.error_outline; + break; + case "Baik": + default: + statusColor = Colors.green; + statusIcon = Icons.check_circle_outline; + break; + } + return Container( width: double.infinity, - height: 200, + padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(20), + border: Border.all(color: statusColor.withOpacity(0.3), width: 1.5), ), - child: const Center( - child: Text( - "Grafik Evaporasi", - style: TextStyle(color: Colors.grey), - ), + child: Row( + children: [ + Icon(statusIcon, color: statusColor, size: 40), + const SizedBox(width: 15), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Status Cuaca", + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + const SizedBox(height: 4), + Text( + state.weatherStatus, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: statusColor, + ), + ), + if (state.willRain) + const Text( + "⚠️ Potensi hujan tinggi", + style: TextStyle( + fontSize: 12, + color: Colors.red, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], ), ); } diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart new file mode 100644 index 0000000..fe0a686 --- /dev/null +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart @@ -0,0 +1,250 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; + +class EvaporasiChartWidget extends StatelessWidget { + final List dailyValues; + final List dailyTemperatures; + final String period; + + const EvaporasiChartWidget({ + super.key, + required this.dailyValues, + required this.dailyTemperatures, + required this.period, + }); + + double _getMaxY(List data) { + if (data.isEmpty) return 10; + final max = data.reduce((a, b) => a > b ? a : b); + return (max + 5).clamp(10, 100); + } + + List _safeData(List data) { + return data.map((e) { + if (e.isNaN || e.isInfinite) return 0.0; + return e < 0 ? 0.0 : e; + }).toList(); + } + + @override + Widget build(BuildContext context) { + final safeValues = _safeData(dailyValues); + final safeTemps = _safeData(dailyTemperatures); + + // Gunakan max dari kedua dataset agar skala Y sesuai + final maxY = _getMaxY([ + ...safeValues, + ...safeTemps, + ]); + + return Container( + height: 280, + padding: const EdgeInsets.fromLTRB(10, 20, 20, 10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(25), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 5), + ) + ], + ), + child: Column( + children: [ + // Legend + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _legendItem(Colors.blue.shade700, "Evaporasi (mm)"), + const SizedBox(width: 20), + _legendItem(Colors.orange.shade600, "Suhu (°C)"), + ], + ), + const SizedBox(height: 10), + Expanded( + child: LineChart( + duration: const Duration(milliseconds: 600), + curve: Curves.easeOutCubic, + LineChartData( + minY: 0, + maxY: maxY, + gridData: const FlGridData(show: false), + borderData: FlBorderData(show: false), + titlesData: FlTitlesData( + show: true, + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), + leftTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 30, + interval: _getInterval(), + getTitlesWidget: (value, meta) { + return SideTitleWidget( + meta: meta, + space: 8, + child: Text( + _getBottomTitle(value), + style: const TextStyle( + color: Colors.grey, fontSize: 10), + ), + ); + }, + ), + ), + ), + lineBarsData: [ + // === Garis Evaporasi === + LineChartBarData( + spots: safeValues.asMap().entries.map((e) { + return FlSpot(e.key.toDouble(), e.value); + }).toList(), + isCurved: true, + curveSmoothness: 0.2, + preventCurveOverShooting: true, + color: Colors.blue.shade700, + barWidth: 3, + isStrokeCapRound: true, + dotData: FlDotData( + show: true, + getDotPainter: (spot, percent, bar, index) { + if (index == safeValues.length - 1) { + return FlDotCirclePainter( + radius: 5, + color: Colors.blue.shade900, + strokeWidth: 2, + strokeColor: Colors.white, + ); + } + return FlDotCirclePainter(radius: 0); + }, + ), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + colors: [ + Colors.blue.withOpacity(0.25), + Colors.blue.withOpacity(0.05), + Colors.transparent + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + ), + // === Garis Suhu === + LineChartBarData( + spots: safeTemps.asMap().entries.map((e) { + return FlSpot(e.key.toDouble(), e.value); + }).toList(), + isCurved: true, + curveSmoothness: 0.2, + preventCurveOverShooting: true, + color: Colors.orange.shade600, + barWidth: 3, + isStrokeCapRound: true, + dotData: FlDotData( + show: true, + getDotPainter: (spot, percent, bar, index) { + if (index == safeTemps.length - 1) { + return FlDotCirclePainter( + radius: 5, + color: Colors.orange.shade800, + strokeWidth: 2, + strokeColor: Colors.white, + ); + } + return FlDotCirclePainter(radius: 0); + }, + ), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + colors: [ + Colors.orange.withOpacity(0.25), + Colors.orange.withOpacity(0.05), + Colors.transparent + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + ), + ], + lineTouchData: LineTouchData( + handleBuiltInTouches: true, + touchTooltipData: LineTouchTooltipData( + getTooltipItems: (touchedSpots) { + return touchedSpots.map((spot) { + final isEvaporasi = spot.barIndex == 0; + final label = isEvaporasi ? "Evaporasi" : "Suhu"; + final unit = isEvaporasi ? "mm" : "°C"; + return LineTooltipItem( + "$label: ${spot.y.toStringAsFixed(1)} $unit", + TextStyle( + color: isEvaporasi + ? Colors.blue.shade100 + : Colors.orange.shade100, + fontWeight: FontWeight.bold, + ), + ); + }).toList(); + }, + ), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _legendItem(Color color, String label) { + return Row( + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(3), + ), + ), + const SizedBox(width: 6), + Text( + label, + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), + ], + ); + } + + String _getBottomTitle(double value) { + int index = value.toInt(); + final len = dailyValues.length; + if (index < 0 || index >= len) return ''; + + if (period == "Hari Ini") { + return index % 4 == 0 ? "${index.toString().padLeft(2, '0')}:00" : ""; + } else if (period == "Minggu Ini") { + const days = ["Sen", "Sel", "Rab", "Kam", "Jum", "Sab", "Min"]; + return days[index % 7]; + } else { + return (index + 1) % 5 == 0 ? "${index + 1}" : ""; + } + } + + double _getInterval() { + if (period == "Hari Ini") return 1; + if (period == "Minggu Ini") return 1; + return 1; + } +} + diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart new file mode 100644 index 0000000..dafe16f --- /dev/null +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../blocs/evaporasi_bloc.dart'; + +class EvaporasiPeriodSelector extends StatelessWidget { + const EvaporasiPeriodSelector({super.key}); + + @override + Widget build(BuildContext context) { + final selectedPeriod = + context.select((EvaporasiBloc bloc) => bloc.state.selectedPeriod); + + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildTab(context, "Hari Ini", selectedPeriod), + _buildTab(context, "Minggu Ini", selectedPeriod), + _buildTab(context, "Bulan Ini", selectedPeriod), + ], + ), + ); + } + + Widget _buildTab(BuildContext context, String label, String current) { + bool isActive = label == current; + + return GestureDetector( + onTap: () { + context.read().add(EvaporasiPeriodChanged(label)); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: isActive ? Colors.blue : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + label, + style: TextStyle( + color: isActive ? Colors.white : Colors.grey, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ); + } +} +