From 6f38d3b287476acae09ac60b0c975629d61e4a9b Mon Sep 17 00:00:00 2001 From: Mochamad ongki ramadani Date: Wed, 29 Apr 2026 11:33:57 +0700 Subject: [PATCH 01/11] update evaporsi --- TODO.md | 11 + lib/core/notification_notifier.dart | 9 + lib/screens/home/views/home_screen.dart | 41 ++- .../evaporasi/blocs/evaporasi_bloc.dart | 68 ++++- .../evaporasi/blocs/evaporasi_state.dart | 18 +- .../evaporasi/views/evaporasi_screen.dart | 81 +++++- .../views/widgets/evaporasi_chart_widget.dart | 250 ++++++++++++++++++ .../widgets/evaporasi_period_selector.dart | 55 ++++ 8 files changed, 517 insertions(+), 16 deletions(-) create mode 100644 TODO.md create mode 100644 lib/core/notification_notifier.dart create mode 100644 lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart create mode 100644 lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart 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, + ), + ), + ), + ); + } +} + From 7b8066c856efe6c0b5a13b3dfc93226c481ab549 Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:42:05 +0700 Subject: [PATCH 02/11] coba --- lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart b/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart index b04f9cd..1fd7b42 100644 --- a/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart +++ b/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart @@ -41,7 +41,7 @@ class WindSpeedScreen extends StatelessWidget { }; // List data; - +// sadadasda // if (state.selectedPeriod == "Minggu Ini") { // data = state.weeklySpeeds; // } else if (state.selectedPeriod == "Bulan Ini") { From efbbf8bb2140703d760ba3ceea5bc731017af43f Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:04:57 +0700 Subject: [PATCH 03/11] Update home_screen.dart --- lib/screens/home/views/home_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/screens/home/views/home_screen.dart b/lib/screens/home/views/home_screen.dart index 7574783..51f1ac1 100644 --- a/lib/screens/home/views/home_screen.dart +++ b/lib/screens/home/views/home_screen.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.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'; From de534b0277c4d5a8cdf09ab28eed27a561acca79 Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:35:18 +0700 Subject: [PATCH 04/11] joss --- devtools_options.yaml | 3 + .../device_setup/blocs/device_setup_bloc.dart | 141 +++++ .../blocs/device_setup_event.dart | 17 + .../blocs/device_setup_state.dart | 39 ++ .../views/device_setup_screen.dart | 507 ++++++++++++++++++ .../wind_speed/views/wind_speed_screen.dart | 15 + pubspec.lock | 24 + pubspec.yaml | 5 +- 8 files changed, 749 insertions(+), 2 deletions(-) create mode 100644 devtools_options.yaml create mode 100644 lib/screens/monitoring/device_setup/blocs/device_setup_bloc.dart create mode 100644 lib/screens/monitoring/device_setup/blocs/device_setup_event.dart create mode 100644 lib/screens/monitoring/device_setup/blocs/device_setup_state.dart create mode 100644 lib/screens/monitoring/device_setup/views/device_setup_screen.dart diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/lib/screens/monitoring/device_setup/blocs/device_setup_bloc.dart b/lib/screens/monitoring/device_setup/blocs/device_setup_bloc.dart new file mode 100644 index 0000000..8043ac7 --- /dev/null +++ b/lib/screens/monitoring/device_setup/blocs/device_setup_bloc.dart @@ -0,0 +1,141 @@ +import 'package:bloc/bloc.dart'; +import 'package:dio/dio.dart'; + +part 'device_setup_event.dart'; +part 'device_setup_state.dart'; + +class DeviceSetupBloc extends Bloc { + DeviceSetupBloc() : super(const DeviceSetupState()) { + on(_onCheckConnection); + on(_onSendCredentials); + on(_onReset); + } + + // ── Dio instance khusus untuk komunikasi ke ESP ────────────── + // Timeout pendek karena ESP di jaringan lokal, harusnya cepat. + // Kalau timeout → berarti HP belum konek ke hotspot ESP. + final Dio _dio = Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 5), + receiveTimeout: const Duration(seconds: 10), + sendTimeout: const Duration(seconds: 5), + ), + ); + + // ── Cek koneksi ke ESP ──────────────────────────────────────── + Future _onCheckConnection( + CheckEspConnectionEvent event, + Emitter emit, + ) async { + emit(state.copyWith(status: DeviceSetupStatus.checkingConn)); + + try { + // Ping endpoint root ESP — kalau respond berarti HP sudah + // terhubung ke hotspot ESP (192.168.4.1) + final response = await _dio.get( + 'http://${state.espIp}/', + options: Options( + // Terima semua status code, yang penting server respond + validateStatus: (_) => true, + ), + ); + + if (response.statusCode != null) { + emit(state.copyWith(status: DeviceSetupStatus.connected)); + } else { + emit(state.copyWith( + status: DeviceSetupStatus.notConnected, + errorMessage: + 'ESP tidak merespons. Pastikan HP sudah terhubung ke hotspot "Anemometer-Setup".', + )); + } + } on DioException catch (e) { + String msg = 'Tidak bisa terhubung ke ESP.'; + if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.receiveTimeout || + e.type == DioExceptionType.sendTimeout) { + msg = + 'Koneksi timeout. Pastikan HP terhubung ke hotspot "Anemometer-Setup" dan coba lagi.'; + } else if (e.type == DioExceptionType.connectionError) { + msg = + 'Tidak ada koneksi ke ESP. Sambungkan HP ke hotspot "Anemometer-Setup" terlebih dahulu.'; + } + emit(state.copyWith( + status: DeviceSetupStatus.notConnected, + errorMessage: msg, + )); + } + } + + // ── Kirim SSID + Password ke ESP ───────────────────────────── + Future _onSendCredentials( + SendWifiCredentialsEvent event, + Emitter emit, + ) async { + if (event.ssid.trim().isEmpty) { + emit(state.copyWith( + status: DeviceSetupStatus.failure, + errorMessage: 'SSID tidak boleh kosong.', + )); + return; + } + + emit(state.copyWith(status: DeviceSetupStatus.sending)); + + try { + // Kirim sebagai form-urlencoded — sama persis dengan + // yang diterima WebServer ESP di route POST /save + final response = await _dio.post( + 'http://${state.espIp}/save', + data: { + 'ssid': event.ssid.trim(), + 'pass': event.password, + }, + options: Options( + contentType: Headers.formUrlEncodedContentType, + validateStatus: (status) => status != null && status < 500, + ), + ); + + if (response.statusCode == 200) { + emit(state.copyWith( + status: DeviceSetupStatus.success, + successMessage: + 'Berhasil! ESP menyimpan WiFi "${event.ssid}" dan akan restart otomatis.\n\n' + 'Tunggu beberapa detik, lalu sambungkan HP kembali ke WiFi normal.', + )); + } else { + emit(state.copyWith( + status: DeviceSetupStatus.failure, + errorMessage: + 'ESP menolak permintaan (${response.statusCode}): ${response.data}', + )); + } + } on DioException catch (e) { + // ESP restart setelah terima kredensial → koneksi putus → itu normal! + // DioException di sini kemungkinan besar karena ESP sudah restart. + if (e.type == DioExceptionType.receiveTimeout || + e.type == DioExceptionType.connectionError) { + emit(state.copyWith( + status: DeviceSetupStatus.success, + successMessage: + 'ESP menerima konfigurasi WiFi dan sedang restart.\n\n' + 'Sambungkan HP kembali ke WiFi normal dalam beberapa detik.', + )); + } else { + emit(state.copyWith( + status: DeviceSetupStatus.failure, + errorMessage: 'Gagal mengirim ke ESP: ${e.message}', + )); + } + } + } + + // ── Reset state ─────────────────────────────────────────────── + void _onReset( + ResetDeviceSetupEvent event, + Emitter emit, + ) { + emit(const DeviceSetupState()); + } +} diff --git a/lib/screens/monitoring/device_setup/blocs/device_setup_event.dart b/lib/screens/monitoring/device_setup/blocs/device_setup_event.dart new file mode 100644 index 0000000..9a198c0 --- /dev/null +++ b/lib/screens/monitoring/device_setup/blocs/device_setup_event.dart @@ -0,0 +1,17 @@ +part of 'device_setup_bloc.dart'; + +abstract class DeviceSetupEvent {} + +/// Cek apakah HP sedang terhubung ke hotspot ESP +class CheckEspConnectionEvent extends DeviceSetupEvent {} + +/// Kirim SSID + password baru ke ESP via HTTP +class SendWifiCredentialsEvent extends DeviceSetupEvent { + final String ssid; + final String password; + + SendWifiCredentialsEvent({required this.ssid, required this.password}); +} + +/// Reset state ke awal (misalnya saat user keluar screen) +class ResetDeviceSetupEvent extends DeviceSetupEvent {} diff --git a/lib/screens/monitoring/device_setup/blocs/device_setup_state.dart b/lib/screens/monitoring/device_setup/blocs/device_setup_state.dart new file mode 100644 index 0000000..ec58f45 --- /dev/null +++ b/lib/screens/monitoring/device_setup/blocs/device_setup_state.dart @@ -0,0 +1,39 @@ +part of 'device_setup_bloc.dart'; + +enum DeviceSetupStatus { + idle, // belum ada aksi + checkingConn, // sedang cek koneksi ke ESP + notConnected, // HP belum konek ke hotspot ESP + connected, // HP sudah konek ke hotspot ESP, siap kirim + sending, // sedang kirim SSID+password ke ESP + success, // ESP berhasil terima & akan restart + failure, // gagal (timeout, ESP tidak respond, dll) +} + +class DeviceSetupState { + final DeviceSetupStatus status; + final String? errorMessage; + final String? successMessage; + final String espIp; + + const DeviceSetupState({ + this.status = DeviceSetupStatus.idle, + this.errorMessage, + this.successMessage, + this.espIp = '192.168.4.1', // default IP ESP saat AP mode + }); + + DeviceSetupState copyWith({ + DeviceSetupStatus? status, + String? errorMessage, + String? successMessage, + String? espIp, + }) { + return DeviceSetupState( + status: status ?? this.status, + errorMessage: errorMessage, + successMessage: successMessage, + espIp: espIp ?? this.espIp, + ); + } +} diff --git a/lib/screens/monitoring/device_setup/views/device_setup_screen.dart b/lib/screens/monitoring/device_setup/views/device_setup_screen.dart new file mode 100644 index 0000000..b6a4811 --- /dev/null +++ b/lib/screens/monitoring/device_setup/views/device_setup_screen.dart @@ -0,0 +1,507 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../blocs/device_setup_bloc.dart'; + +class DeviceSetupScreen extends StatefulWidget { + const DeviceSetupScreen({super.key}); + + @override + State createState() => _DeviceSetupScreenState(); +} + +class _DeviceSetupScreenState extends State { + final _ssidController = TextEditingController(); + final _passwordController = TextEditingController(); + final _formKey = GlobalKey(); + bool _obscurePassword = true; + + @override + void dispose() { + _ssidController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => DeviceSetupBloc(), + child: Scaffold( + appBar: AppBar( + title: const Text('Setup WiFi Anemometer'), + centerTitle: true, + elevation: 0, + ), + body: BlocConsumer( + listener: (context, state) { + if (state.status == DeviceSetupStatus.success) { + _showResultDialog(context, + success: true, message: state.successMessage ?? ''); + } else if (state.status == DeviceSetupStatus.failure) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.errorMessage ?? 'Terjadi kesalahan.'), + backgroundColor: Colors.red.shade700, + behavior: SnackBarBehavior.floating, + ), + ); + } + }, + builder: (context, state) { + return SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Info Banner ─────────────────────────────── + _InfoBanner(status: state.status), + const SizedBox(height: 24), + + // ── Step 1: Panduan konek hotspot ───────────── + _StepCard( + step: 1, + title: 'Nyalakan Perangkat Anemometer', + description: + 'Pastikan ESP32 anemometer sudah menyala dan belum pernah ' + 'dikonfigurasi WiFi-nya. LED akan berkedip menandakan ' + 'mode hotspot aktif.', + isDone: state.status == DeviceSetupStatus.connected || + state.status == DeviceSetupStatus.sending || + state.status == DeviceSetupStatus.success, + ), + const SizedBox(height: 12), + + _StepCard( + step: 2, + title: 'Sambungkan HP ke Hotspot ESP', + description: + 'Buka Pengaturan WiFi di HP kamu, lalu sambungkan ke:\n\n' + '📶 Anemometer-Setup\n\n' + 'Hotspot ini tidak punya password. Setelah tersambung, ' + 'kembali ke aplikasi ini.', + isDone: state.status == DeviceSetupStatus.connected || + state.status == DeviceSetupStatus.sending || + state.status == DeviceSetupStatus.success, + trailing: _OpenWifiSettingsButton(status: state.status), + ), + const SizedBox(height: 12), + + // ── Step 3: Cek koneksi ─────────────────────── + _StepCard( + step: 3, + title: 'Verifikasi Koneksi ke ESP', + description: state.status == DeviceSetupStatus.notConnected + ? state.errorMessage ?? 'Belum terhubung ke ESP.' + : state.status == DeviceSetupStatus.connected || + state.status == DeviceSetupStatus.sending || + state.status == DeviceSetupStatus.success + ? '✅ HP sudah terhubung ke ESP. Lanjutkan ke langkah berikutnya.' + : 'Tekan tombol di bawah untuk memverifikasi koneksi ke ESP.', + isDone: state.status == DeviceSetupStatus.connected || + state.status == DeviceSetupStatus.sending || + state.status == DeviceSetupStatus.success, + trailing: state.status == DeviceSetupStatus.connected || + state.status == DeviceSetupStatus.sending || + state.status == DeviceSetupStatus.success + ? null + : _CheckConnectionButton(state: state), + ), + const SizedBox(height: 12), + + // ── Step 4: Isi form WiFi ───────────────────── + _StepCard( + step: 4, + title: 'Masukkan Kredensial WiFi', + description: 'Isi SSID dan password WiFi yang ingin ' + 'digunakan oleh anemometer.', + isDone: state.status == DeviceSetupStatus.success, + child: state.status == DeviceSetupStatus.connected || + state.status == DeviceSetupStatus.sending + ? _WifiForm( + ssidController: _ssidController, + passwordController: _passwordController, + formKey: _formKey, + obscurePassword: _obscurePassword, + onToggleObscure: () => setState( + () => _obscurePassword = !_obscurePassword), + onSubmit: state.status == DeviceSetupStatus.sending + ? null + : () { + if (_formKey.currentState!.validate()) { + context.read().add( + SendWifiCredentialsEvent( + ssid: _ssidController.text, + password: + _passwordController.text, + ), + ); + } + }, + isLoading: + state.status == DeviceSetupStatus.sending, + ) + : const SizedBox.shrink(), + ), + ], + ), + ); + }, + ), + ), + ); + } + + void _showResultDialog( + BuildContext context, { + required bool success, + required String message, + }) { + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: Row( + children: [ + Icon( + success ? Icons.check_circle_rounded : Icons.error_rounded, + color: success ? Colors.green : Colors.red, + ), + const SizedBox(width: 8), + Text(success ? 'Berhasil!' : 'Gagal'), + ], + ), + content: Text(message), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); // tutup dialog + Navigator.of(context).pop(); // kembali ke wind_speed_screen + }, + child: const Text('Selesai'), + ), + ], + ), + ); + } +} + +// ══════════════════════════════════════════════════════════════════ +// Widgets internal +// ══════════════════════════════════════════════════════════════════ + +class _InfoBanner extends StatelessWidget { + final DeviceSetupStatus status; + const _InfoBanner({required this.status}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.wifi_tethering_rounded, + color: theme.colorScheme.primary, size: 28), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Setup WiFi Anemometer', + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + Text( + 'Ikuti langkah-langkah berikut untuk mengkonfigurasi ' + 'koneksi WiFi pada perangkat anemometer.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onPrimaryContainer, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _StepCard extends StatelessWidget { + final int step; + final String title; + final String description; + final bool isDone; + final Widget? trailing; + final Widget? child; + + const _StepCard({ + required this.step, + required this.title, + required this.description, + this.isDone = false, + this.trailing, + this.child, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: + isDone ? Colors.green.shade300 : theme.colorScheme.outlineVariant, + ), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Step badge + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isDone + ? Colors.green + : theme.colorScheme.primaryContainer, + ), + child: Center( + child: isDone + ? const Icon(Icons.check, size: 16, color: Colors.white) + : Text( + '$step', + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + if (trailing != null) ...[ + const SizedBox(width: 8), + trailing!, + ], + ], + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.only(left: 38), + child: Text( + description, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + height: 1.5, + ), + ), + ), + if (child != null) ...[ + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.only(left: 38), + child: child!, + ), + ], + ], + ), + ), + ); + } +} + +class _OpenWifiSettingsButton extends StatelessWidget { + final DeviceSetupStatus status; + const _OpenWifiSettingsButton({required this.status}); + + @override + Widget build(BuildContext context) { + // Tidak perlu tampil kalau sudah connected + if (status == DeviceSetupStatus.connected || + status == DeviceSetupStatus.sending || + status == DeviceSetupStatus.success) { + return const SizedBox.shrink(); + } + return TextButton.icon( + onPressed: () { + // Buka pengaturan WiFi sistem HP + // Butuh package: app_settings (opsional) + // AppSettings.openWIFISettings(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Buka Pengaturan WiFi HP → sambungkan ke "Anemometer-Setup"'), + behavior: SnackBarBehavior.floating, + ), + ); + }, + icon: const Icon(Icons.settings_rounded, size: 16), + label: const Text('Buka Setting'), + ); + } +} + +class _CheckConnectionButton extends StatelessWidget { + final DeviceSetupState state; + const _CheckConnectionButton({required this.state}); + + @override + Widget build(BuildContext context) { + final isChecking = state.status == DeviceSetupStatus.checkingConn; + return ElevatedButton.icon( + onPressed: isChecking + ? null + : () => + context.read().add(CheckEspConnectionEvent()), + icon: isChecking + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.wifi_find_rounded, size: 16), + label: Text(isChecking ? 'Mengecek...' : 'Cek Koneksi'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + textStyle: const TextStyle(fontSize: 13), + ), + ); + } +} + +class _WifiForm extends StatelessWidget { + final TextEditingController ssidController; + final TextEditingController passwordController; + final GlobalKey formKey; + final bool obscurePassword; + final VoidCallback onToggleObscure; + final VoidCallback? onSubmit; + final bool isLoading; + + const _WifiForm({ + required this.ssidController, + required this.passwordController, + required this.formKey, + required this.obscurePassword, + required this.onToggleObscure, + required this.onSubmit, + required this.isLoading, + }); + + @override + Widget build(BuildContext context) { + return Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // SSID + TextFormField( + controller: ssidController, + decoration: InputDecoration( + labelText: 'Nama WiFi (SSID)', + hintText: 'Contoh: RumahKu_2.4GHz', + prefixIcon: const Icon(Icons.wifi_rounded), + border: + OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + contentPadding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + ), + textInputAction: TextInputAction.next, + validator: (v) => (v == null || v.trim().isEmpty) + ? 'SSID tidak boleh kosong' + : null, + ), + const SizedBox(height: 12), + + // Password + TextFormField( + controller: passwordController, + obscureText: obscurePassword, + decoration: InputDecoration( + labelText: 'Password WiFi', + hintText: 'Kosongkan jika WiFi terbuka', + prefixIcon: const Icon(Icons.lock_rounded), + suffixIcon: IconButton( + icon: Icon( + obscurePassword + ? Icons.visibility_rounded + : Icons.visibility_off_rounded, + ), + onPressed: onToggleObscure, + ), + border: + OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + contentPadding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + ), + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => onSubmit?.call(), + ), + const SizedBox(height: 16), + + // Submit button + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: onSubmit, + icon: isLoading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.send_rounded), + label: Text( + isLoading ? 'Mengirim ke ESP...' : 'Kirim ke Anemometer'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + ), + ), + ), + + if (isLoading) ...[ + const SizedBox(height: 10), + Text( + '⏳ Mengirim konfigurasi WiFi ke ESP...\n' + 'Koneksi akan terputus sebentar saat ESP restart.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + height: 1.5, + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart b/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart index 1fd7b42..04f1c96 100644 --- a/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart +++ b/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart @@ -7,6 +7,7 @@ import 'widgets/period_selector.dart'; import '../../shared/utils/pdf/pdf_export_service.dart'; import '../../shared/widgets/export_pdf_button.dart'; +import '../../device_setup/views/device_setup_screen.dart'; class WindSpeedScreen extends StatelessWidget { const WindSpeedScreen({super.key}); @@ -24,6 +25,20 @@ class WindSpeedScreen extends StatelessWidget { elevation: 0, backgroundColor: Colors.transparent, foregroundColor: Colors.black, + actions: [ + IconButton( + tooltip: 'Pengaturan Perangkat', + icon: const Icon(Icons.settings_rounded), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const DeviceSetupScreen(), + ), + ); + }, + ), + ], ), /// === Body area, lokasi dan tata letak Widgets === /// diff --git a/pubspec.lock b/pubspec.lock index 2a56299..ffc5bd8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -145,6 +145,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dio: + dependency: "direct main" + description: + name: dio + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c + url: "https://pub.dev" + source: hosted + version: "5.9.2" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" + url: "https://pub.dev" + source: hosted + version: "2.1.2" equatable: dependency: "direct main" description: @@ -472,6 +488,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" monitoring_repository: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 8465d9f..4a46cde 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,6 +9,7 @@ dependencies: bloc_concurrency: ^0.2.5 cloud_firestore: ^6.1.1 cupertino_icons: ^1.0.8 + dio: ^5.9.2 equatable: ^2.0.5 firebase_auth: ^6.1.3 firebase_core: ^4.3.0 @@ -22,11 +23,11 @@ dependencies: intl: ^0.20.2 monitoring_repository: path: packages/monitoring_repository + pdf: ^3.11.1 + printing: ^5.13.1 rxdart: ^0.27.7 user_repository: path: packages/user_repository - pdf: ^3.11.1 - printing: ^5.13.1 dev_dependencies: flutter_lints: ^3.0.0 From f5f177f55415e83c4fed151a4c5eade24d3ba817 Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:19:03 +0700 Subject: [PATCH 05/11] josssss --- .../wind_speed/views/wind_speed_screen.dart | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart b/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart index 04f1c96..2025e10 100644 --- a/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart +++ b/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -// import 'package:fl_chart/fl_chart.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../blocs/wind_speed_bloc.dart'; import './widgets/wind_speed_chart_widget.dart'; @@ -55,16 +54,6 @@ class WindSpeedScreen extends StatelessWidget { _ => state.dailySpeeds, }; - // List data; -// sadadasda - // if (state.selectedPeriod == "Minggu Ini") { - // data = state.weeklySpeeds; - // } else if (state.selectedPeriod == "Bulan Ini") { - // data = state.monthlySpeeds; - // } else { - // data = state.dailySpeeds; - // } - // Konversi history MyWindSpeed → Map (untuk tabel PDF) final historyMaps = state.history .map((e) => { @@ -130,7 +119,7 @@ class WindSpeedScreen extends StatelessWidget { borderRadius: BorderRadius.circular(30), boxShadow: [ BoxShadow( - color: Colors.blue.withOpacity(0.3), + color: Colors.blue.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10), ) From a60c6af54b9c4561dd9138aa7b8faab4138bf008 Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Thu, 30 Apr 2026 21:09:49 +0700 Subject: [PATCH 06/11] oke oke --- lib/app.dart | 30 ++- .../monitoring_bloc/monitoring_bloc.dart | 1 - .../monitoring_bloc/monitoring_event.dart | 1 - .../monitoring_bloc/monitoring_state.dart | 1 - .../notification_bloc/notification_bloc.dart | 49 ++++ .../notification_bloc/notification_event.dart | 33 +++ .../notification_bloc/notification_state.dart | 77 +++++++ lib/core/notification_notifier.dart | 9 - lib/screens/home/views/home_screen.dart | 218 ++++++++++++------ lib/screens/home/views/main_drawer.dart | 7 +- .../home/widgets/notification_panel.dart | 216 +++++++++++++++++ .../evaporasi/blocs/evaporasi_bloc.dart | 66 +++--- .../wind_speed/blocs/wind_speed_bloc.dart | 50 +++- 13 files changed, 626 insertions(+), 132 deletions(-) delete mode 100644 lib/blocs/monitoring_bloc/monitoring_bloc.dart delete mode 100644 lib/blocs/monitoring_bloc/monitoring_event.dart delete mode 100644 lib/blocs/monitoring_bloc/monitoring_state.dart create mode 100644 lib/blocs/notification_bloc/notification_bloc.dart create mode 100644 lib/blocs/notification_bloc/notification_event.dart create mode 100644 lib/blocs/notification_bloc/notification_state.dart delete mode 100644 lib/core/notification_notifier.dart create mode 100644 lib/screens/home/widgets/notification_panel.dart diff --git a/lib/app.dart b/lib/app.dart index 7393d3e..e6cb315 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -4,6 +4,7 @@ import 'package:klimatologiot/app_view.dart'; import 'package:user_repository/user_repository.dart'; import 'package:monitoring_repository/monitoring_repository.dart'; import 'blocs/authentication_bloc/authentication_bloc.dart'; +import 'blocs/notification_bloc/notification_bloc.dart'; class MyApp extends StatelessWidget { final UserRepository userRepository; @@ -13,17 +14,26 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MultiRepositoryProvider( + providers: [ + // 1. Daftarkan UserRepository + RepositoryProvider.value(value: userRepository), + RepositoryProvider.value( + value: monitoringRepository), + ], + child: MultiBlocProvider( providers: [ - // 1. Daftarkan UserRepository - RepositoryProvider.value(value: userRepository), - RepositoryProvider.value( - value: monitoringRepository), - ], - child: BlocProvider( - create: (context) => AuthenticationBloc( - userRepository: userRepository, + BlocProvider( + create: (context) => AuthenticationBloc( + userRepository: userRepository, + ), ), - child: const MyAppView(), - )); + BlocProvider( + // ← tambahan ini + create: (_) => NotificationBloc(), + ), + ], + child: const MyAppView(), + ), + ); } } diff --git a/lib/blocs/monitoring_bloc/monitoring_bloc.dart b/lib/blocs/monitoring_bloc/monitoring_bloc.dart deleted file mode 100644 index 8b13789..0000000 --- a/lib/blocs/monitoring_bloc/monitoring_bloc.dart +++ /dev/null @@ -1 +0,0 @@ - diff --git a/lib/blocs/monitoring_bloc/monitoring_event.dart b/lib/blocs/monitoring_bloc/monitoring_event.dart deleted file mode 100644 index 8b13789..0000000 --- a/lib/blocs/monitoring_bloc/monitoring_event.dart +++ /dev/null @@ -1 +0,0 @@ - diff --git a/lib/blocs/monitoring_bloc/monitoring_state.dart b/lib/blocs/monitoring_bloc/monitoring_state.dart deleted file mode 100644 index 8b13789..0000000 --- a/lib/blocs/monitoring_bloc/monitoring_state.dart +++ /dev/null @@ -1 +0,0 @@ - diff --git a/lib/blocs/notification_bloc/notification_bloc.dart b/lib/blocs/notification_bloc/notification_bloc.dart new file mode 100644 index 0000000..a026a51 --- /dev/null +++ b/lib/blocs/notification_bloc/notification_bloc.dart @@ -0,0 +1,49 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; + +part 'notification_event.dart'; +part 'notification_state.dart'; + +class NotificationBloc extends Bloc { + NotificationBloc() : super(const NotificationState()) { + on(_onAlertAdded); + on(_onMarkedAsRead); + on(_onDismissed); + } + + void _onAlertAdded( + SensorAlertAdded event, + Emitter emit, + ) { + final updated = Map.from(state._alertsBySensor); + + if (event.alert.severity == AlertSeverity.info) { + // Sensor kembali normal → hapus alert-nya + updated.remove(event.alert.sensorId); + } else { + // Ada peringatan → tambah atau update entry + updated[event.alert.sensorId] = event.alert; + } + + emit(state.copyWith(alertsBySensor: updated)); + } + + void _onMarkedAsRead( + NotificationsMarkedAsRead event, + Emitter emit, + ) { + final updated = state._alertsBySensor.map( + (key, alert) => MapEntry(key, alert.copyWith(isRead: true)), + ); + emit(state.copyWith(alertsBySensor: updated)); + } + + void _onDismissed( + NotificationDismissed event, + Emitter emit, + ) { + final updated = Map.from(state._alertsBySensor) + ..remove(event.sensorId); + emit(state.copyWith(alertsBySensor: updated)); + } +} diff --git a/lib/blocs/notification_bloc/notification_event.dart b/lib/blocs/notification_bloc/notification_event.dart new file mode 100644 index 0000000..6fd8862 --- /dev/null +++ b/lib/blocs/notification_bloc/notification_event.dart @@ -0,0 +1,33 @@ +part of 'notification_bloc.dart'; + +sealed class NotificationEvent extends Equatable { + const NotificationEvent(); + + @override + List get props => []; +} + +/// Tambah atau update alert dari sebuah sensor +final class SensorAlertAdded extends NotificationEvent { + final SensorAlert alert; + + const SensorAlertAdded(this.alert); + + @override + List get props => [alert]; +} + +/// Tandai semua notifikasi sebagai sudah dibaca +final class NotificationsMarkedAsRead extends NotificationEvent { + const NotificationsMarkedAsRead(); +} + +/// Hapus alert spesifik (opsional, jika ingin dismiss satu per satu) +final class NotificationDismissed extends NotificationEvent { + final String sensorId; + + const NotificationDismissed(this.sensorId); + + @override + List get props => [sensorId]; +} diff --git a/lib/blocs/notification_bloc/notification_state.dart b/lib/blocs/notification_bloc/notification_state.dart new file mode 100644 index 0000000..49da34a --- /dev/null +++ b/lib/blocs/notification_bloc/notification_state.dart @@ -0,0 +1,77 @@ +part of 'notification_bloc.dart'; + +/// Tingkat keparahan alert +enum AlertSeverity { info, warning, danger } + +/// Model satu alert dari satu sensor +final class SensorAlert extends Equatable { + final String sensorId; // unik per alat, misal 'wind_speed' + final String sensorName; // nama tampilan, misal 'Anemometer' + final String message; // pesan singkat + final AlertSeverity severity; + final DateTime timestamp; + final bool isRead; + + const SensorAlert({ + required this.sensorId, + required this.sensorName, + required this.message, + required this.severity, + required this.timestamp, + this.isRead = false, + }); + + SensorAlert copyWith({ + String? sensorId, + String? sensorName, + String? message, + AlertSeverity? severity, + DateTime? timestamp, + bool? isRead, + }) { + return SensorAlert( + sensorId: sensorId ?? this.sensorId, + sensorName: sensorName ?? this.sensorName, + message: message ?? this.message, + severity: severity ?? this.severity, + timestamp: timestamp ?? this.timestamp, + isRead: isRead ?? this.isRead, + ); + } + + @override + List get props => [sensorId, message, severity, timestamp, isRead]; +} + +/// State utama NotificationBloc +final class NotificationState extends Equatable { + /// Hanya menyimpan SATU alert per sensor (key = sensorId). + /// Kalau sensor normal, entry-nya dihapus dari map. + final Map _alertsBySensor; + + const NotificationState({ + Map alertsBySensor = const {}, + }) : _alertsBySensor = alertsBySensor; + + /// Semua alert aktif, diurutkan: danger dulu, lalu warning, lalu info + List get activeAlerts { + final list = _alertsBySensor.values.toList(); + list.sort((a, b) => b.severity.index.compareTo(a.severity.index)); + return list; + } + + int get unreadCount => _alertsBySensor.values.where((a) => !a.isRead).length; + + bool get hasActiveAlerts => _alertsBySensor.isNotEmpty; + + NotificationState copyWith({ + Map? alertsBySensor, + }) { + return NotificationState( + alertsBySensor: alertsBySensor ?? _alertsBySensor, + ); + } + + @override + List get props => [_alertsBySensor]; +} diff --git a/lib/core/notification_notifier.dart b/lib/core/notification_notifier.dart deleted file mode 100644 index e28aa38..0000000 --- a/lib/core/notification_notifier.dart +++ /dev/null @@ -1,9 +0,0 @@ -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 51f1ac1..2e98f38 100644 --- a/lib/screens/home/views/home_screen.dart +++ b/lib/screens/home/views/home_screen.dart @@ -1,94 +1,160 @@ 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 '../../../blocs/notification_bloc/notification_bloc.dart'; +import '../widgets/notification_panel.dart'; import 'main_drawer.dart'; -class HomeScreen extends StatelessWidget { +class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State + with SingleTickerProviderStateMixin { + bool _showNotifications = false; + late final AnimationController _animController; + late final Animation _fadeAnim; + + @override + void initState() { + super.initState(); + _animController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + ); + _fadeAnim = CurvedAnimation( + parent: _animController, + curve: Curves.easeOut, + ); + } + + @override + void dispose() { + _animController.dispose(); + super.dispose(); + } + + void _toggleNotifications() { + setState(() => _showNotifications = !_showNotifications); + if (_showNotifications) { + _animController.forward(); + // Tandai semua sebagai dibaca saat panel dibuka + context.read().add(const NotificationsMarkedAsRead()); + } else { + _animController.reverse(); + } + } + @override Widget build(BuildContext context) { - return Scaffold( - drawer: const MainDrawer(), - appBar: AppBar( - backgroundColor: Colors.white, - elevation: 0.5, // Kasih sedikit bayangan tipis agar elegan - centerTitle: false, - leading: Builder( + return GestureDetector( + // Tap di luar panel → tutup + onTap: () { + if (_showNotifications) _toggleNotifications(); + }, + child: Scaffold( + drawer: const MainDrawer(), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0.5, + centerTitle: false, + leading: Builder( builder: (context) => IconButton( - icon: const Icon(Icons.menu, - color: Colors.black), // Ikon garis 3 horizontal - onPressed: () { - // ini kodenya untuk membuka: - Scaffold.of(context).openDrawer(); - }, - )), - title: Row( - children: [ - // Ganti dengan Image.asset jika sudah ada logo - Image.asset( - 'images/logo_klimatologi.png', - height: 90, - fit: BoxFit.contain, + icon: const Icon(Icons.menu, color: Colors.black), + onPressed: () => Scaffold.of(context).openDrawer(), ), - const SizedBox(width: 10), - ], - ), - actions: [ - 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, + ), + title: Row( + children: [ + Image.asset( + 'images/logo_klimatologi.png', + height: 90, + fit: BoxFit.contain, + ), + ], + ), + actions: [ + // Icon lonceng dengan badge + BlocBuilder( + buildWhen: (prev, cur) => + prev.unreadCount != cur.unreadCount || + prev.hasActiveAlerts != cur.hasActiveAlerts, + builder: (context, state) { + return Stack( + children: [ + IconButton( + icon: Icon( + _showNotifications + ? Icons.notifications + : Icons.notifications_none, + color: Colors.black, + ), + onPressed: _toggleNotifications, + ), + if (state.unreadCount > 0) + Positioned( + right: 6, + top: 6, + child: Container( + padding: const EdgeInsets.all(3), + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + constraints: const BoxConstraints( + minWidth: 16, + minHeight: 16, + ), + child: Text( + state.unreadCount > 9 + ? '9+' + : '${state.unreadCount}', + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, ), - ); - } - }, - ), - if (hasAlert) - Positioned( - right: 8, - top: 8, - child: Container( - width: 10, - height: 10, - decoration: const BoxDecoration( - color: Colors.red, - shape: BoxShape.circle, ), ), - ), - ], - ); - }, - ), - IconButton( - icon: const Icon(Icons.logout, color: Colors.black), - onPressed: () { - // Trigger Logout di AuthenticationBloc kamu - context + ], + ); + }, + ), + IconButton( + icon: const Icon(Icons.logout, color: Colors.black), + onPressed: () => context .read() - .add(AuthenticationLogoutRequested()); - }, - ), - const SizedBox(width: 8), - ], - ), - body: const Center( - child: Text("body home page"), + .add(AuthenticationLogoutRequested()), + ), + const SizedBox(width: 8), + ], + ), + body: Stack( + children: [ + // Konten utama + const Center(child: Text('body home page')), + + // Dropdown notification panel + if (_showNotifications) + Positioned( + top: 0, + left: 0, + right: 0, + child: FadeTransition( + opacity: _fadeAnim, + child: GestureDetector( + onTap: () {}, // cegah tap di panel menutup panel + child: const NotificationPanel(), + ), + ), + ), + ], + ), ), ); } diff --git a/lib/screens/home/views/main_drawer.dart b/lib/screens/home/views/main_drawer.dart index 4e08d0a..871feda 100644 --- a/lib/screens/home/views/main_drawer.dart +++ b/lib/screens/home/views/main_drawer.dart @@ -8,6 +8,7 @@ import '../../monitoring/wind_speed/blocs/wind_speed_bloc.dart'; import '../../monitoring/evaporasi/views/evaporasi_screen.dart'; import '../../monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart'; import '../../monitoring/atmospheric_conditions/views/atmospheric_screen.dart'; +import '../../../blocs/notification_bloc/notification_bloc.dart'; class MainDrawer extends StatelessWidget { const MainDrawer({super.key}); @@ -59,10 +60,9 @@ class MainDrawer extends StatelessWidget { MaterialPageRoute( builder: (context) => BlocProvider( create: (context) => WindSpeedBloc( - // AMBIL MonitoringRepository repository: context.read(), - )..add( - WatchWindSpeedStarted()), // Langsung mulai monitoring + notificationBloc: context.read(), + )..add(WatchWindSpeedStarted()), child: const WindSpeedScreen(), ), ), @@ -80,6 +80,7 @@ class MainDrawer extends StatelessWidget { builder: (context) => BlocProvider( create: (context) => EvaporasiBloc( repository: context.read(), + notificationBloc: context.read(), )..add(WatchEvaporasiStarted()), child: const EvaporasiScreen(), ), diff --git a/lib/screens/home/widgets/notification_panel.dart b/lib/screens/home/widgets/notification_panel.dart new file mode 100644 index 0000000..296820c --- /dev/null +++ b/lib/screens/home/widgets/notification_panel.dart @@ -0,0 +1,216 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../blocs/notification_bloc/notification_bloc.dart'; + +class NotificationPanel extends StatelessWidget { + const NotificationPanel({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final alerts = state.activeAlerts; + + return Container( + margin: const EdgeInsets.fromLTRB(12, 4, 12, 0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + width: 0.5, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _PanelHeader(unreadCount: state.unreadCount), + if (alerts.isEmpty) + const _EmptyState() + else + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + itemCount: alerts.length, + separatorBuilder: (_, __) => Divider( + height: 1, + color: Theme.of(context).colorScheme.outlineVariant, + ), + itemBuilder: (context, i) => _AlertTile(alert: alerts[i]), + ), + ], + ), + ); + }, + ); + } +} + +class _PanelHeader extends StatelessWidget { + final int unreadCount; + const _PanelHeader({required this.unreadCount}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + const Text( + 'Notifikasi', + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14), + ), + if (unreadCount > 0) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.red.shade600, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '$unreadCount baru', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + const Spacer(), + if (unreadCount > 0) + TextButton( + onPressed: () => context + .read() + .add(const NotificationsMarkedAsRead()), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + 'Tandai dibaca', + style: TextStyle(fontSize: 11, color: Colors.blue.shade700), + ), + ), + ], + ), + ); + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Column( + children: [ + Icon(Icons.check_circle_outline, + size: 32, color: Colors.green.shade400), + const SizedBox(height: 8), + Text( + 'Semua sensor normal', + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade600, + ), + ), + ], + ), + ); + } +} + +class _AlertTile extends StatelessWidget { + final SensorAlert alert; + const _AlertTile({required this.alert}); + + @override + Widget build(BuildContext context) { + final (color, icon) = switch (alert.severity) { + AlertSeverity.danger => (Colors.red.shade600, Icons.warning_rounded), + AlertSeverity.warning => (Colors.orange.shade600, Icons.info_outline), + AlertSeverity.info => (Colors.blue.shade600, Icons.check_circle_outline), + }; + + return InkWell( + onTap: () => context + .read() + .add(NotificationDismissed(alert.sensorId)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: color, size: 20), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + alert.sensorName, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + if (!alert.isRead) ...[ + const SizedBox(width: 6), + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + ], + ], + ), + const SizedBox(height: 2), + Text( + alert.message, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade700, + ), + ), + const SizedBox(height: 4), + Text( + _formatTime(alert.timestamp), + style: TextStyle(fontSize: 11, color: Colors.grey.shade500), + ), + ], + ), + ), + Icon(Icons.close, size: 14, color: Colors.grey.shade400), + ], + ), + ), + ); + } + + String _formatTime(DateTime dt) { + final now = DateTime.now(); + final diff = now.difference(dt); + if (diff.inMinutes < 1) return 'Baru saja'; + if (diff.inMinutes < 60) return '${diff.inMinutes} menit lalu'; + if (diff.inHours < 24) return '${diff.inHours} jam lalu'; + return '${dt.day}/${dt.month} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + } +} diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart index 1520855..7758d67 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart @@ -4,17 +4,21 @@ import 'package:equatable/equatable.dart'; import 'package:monitoring_repository/monitoring_repository.dart'; import '../../../../core/utils/time_series_mapper.dart'; -import '../../../../core/notification_notifier.dart'; +import '../../../../blocs/notification_bloc/notification_bloc.dart'; part 'evaporasi_event.dart'; part 'evaporasi_state.dart'; class EvaporasiBloc extends Bloc { final MonitoringRepository _repository; + final NotificationBloc _notificationBloc; StreamSubscription? _subscription; - EvaporasiBloc({required MonitoringRepository repository}) - : _repository = repository, + EvaporasiBloc({ + required MonitoringRepository repository, + required NotificationBloc notificationBloc, + }) : _repository = repository, + _notificationBloc = notificationBloc, super(const EvaporasiState()) { on(_onStarted); on<_EvaporasiRealtimeUpdated>(_onRealtimeUpdated); @@ -50,7 +54,7 @@ class EvaporasiBloc extends Bloc { // 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); + _emitEvaporasiAlert(status, rain, lastValue); emit(state.copyWith( history: history, @@ -62,15 +66,9 @@ class EvaporasiBloc extends Bloc { )); await _subscription?.cancel(); - _subscription = _repository - .getSensorStream( - 'Monitoring', - (json) => Evaporasi.fromJson(json), - ) - .listen((data) { - add(_EvaporasiRealtimeUpdated(data)); - }); + .getSensorStream('Monitoring', (json) => Evaporasi.fromJson(json)) + .listen((data) => add(_EvaporasiRealtimeUpdated(data))); } /// ========================= @@ -82,7 +80,6 @@ class EvaporasiBloc extends Bloc { ) { final updated = List.from(state.dailyValues); final updatedTemp = List.from(state.dailyTemperatures); - final index = DateTime.now().hour; if (index < updated.length) { @@ -91,7 +88,7 @@ class EvaporasiBloc extends Bloc { } final (status, rain) = _computeWeatherStatus(event.data.evaporasi); - _updateGlobalNotifier(status, rain); + _emitEvaporasiAlert(status, rain, event.data.evaporasi); emit(state.copyWith( currentValue: event.data.evaporasi, @@ -112,7 +109,6 @@ class EvaporasiBloc extends Bloc { Emitter emit, ) async { emit(state.copyWith(isLoading: true, selectedPeriod: event.period)); - final history = state.history; List updated; @@ -171,23 +167,37 @@ class EvaporasiBloc extends Bloc { /// 🌤️ 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); - } + if (value <= 5.0) return ('Baik', false); + if (value <= 10.0) return ('Sedang', true); + return ('Buruk', true); } - static void _updateGlobalNotifier(String status, bool willRain) { - hasWeatherAlert.value = willRain; - if (willRain) { - weatherAlertMessage.value = - 'Status evaporasi $status — potensi hujan tinggi'; + void _emitEvaporasiAlert(String status, bool willRain, double value) { + final AlertSeverity severity; + final String message; + + if (status == 'Buruk') { + severity = AlertSeverity.danger; + message = + 'Evaporasi ${value.toStringAsFixed(1)} mm — status BURUK, potensi hujan tinggi'; + } else if (status == 'Sedang') { + severity = AlertSeverity.warning; + message = + 'Evaporasi ${value.toStringAsFixed(1)} mm — status sedang, potensi hujan'; } else { - weatherAlertMessage.value = ''; + severity = AlertSeverity.info; // normal → bersihkan alert + message = ''; } + + _notificationBloc.add(SensorAlertAdded( + SensorAlert( + sensorId: 'evaporasi', + sensorName: 'Evaporasi', + message: message, + severity: severity, + timestamp: DateTime.now(), + ), + )); } } diff --git a/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart b/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart index a9878fd..c957cda 100644 --- a/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart +++ b/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart @@ -4,16 +4,26 @@ import 'package:equatable/equatable.dart'; import 'package:monitoring_repository/monitoring_repository.dart'; import 'package:bloc_concurrency/bloc_concurrency.dart'; import '../../../../core/utils/time_series_mapper.dart'; +import '../../../../blocs/notification_bloc/notification_bloc.dart'; part 'wind_speed_event.dart'; part 'wind_speed_state.dart'; +/// Threshold kecepatan angin (satuan m/s) +/// Referensi: Beaufort scale & BMKG +const double _kWindWarning = 4.0; // Waspada: 8–12.5 m/s (~29–45 km/h) +const double _kWindDanger = 5.5; // Bahaya: > 12.5 m/s (> 45 km/h) + class WindSpeedBloc extends Bloc { final MonitoringRepository _repository; + final NotificationBloc _notificationBloc; StreamSubscription? _subscription; - WindSpeedBloc({required MonitoringRepository repository}) + WindSpeedBloc( + {required MonitoringRepository repository, + required NotificationBloc notificationBloc}) : _repository = repository, + _notificationBloc = notificationBloc, super(const WindSpeedState()) { /// 🔥 START MONITORING on(_onStarted, transformer: restartable()); @@ -72,6 +82,11 @@ class WindSpeedBloc extends Bloc { ), ); + // Cek kondisi awal dari history + if (history.isNotEmpty) { + _emitWindAlert(history.last.speed); + } + emit(state.copyWith( history: history, dailySpeeds: dailyGraph, @@ -101,7 +116,6 @@ class WindSpeedBloc extends Bloc { Emitter emit, ) { final updated = List.from(state.dailySpeeds); - final index = DateTime.now().hour; double newValue = event.data.speed; @@ -116,10 +130,11 @@ class WindSpeedBloc extends Bloc { newValue = (lastValue + newValue) / 2; // smoothing } } - updated[index] = newValue.clamp(0, 100); } + _emitWindAlert(newValue); + emit(state.copyWith( currentSpeed: newValue, dailySpeeds: updated, @@ -178,6 +193,35 @@ class WindSpeedBloc extends Bloc { await _subscription?.cancel(); return super.close(); } + +// ========================= + // HELPER: kirim alert ke NotificationBloc + // ========================= + void _emitWindAlert(double speed) { + final AlertSeverity severity; + final String message; + + if (speed >= _kWindDanger) { + severity = AlertSeverity.danger; + message = 'Kecepatan angin ${speed.toStringAsFixed(1)} m/s — BAHAYA'; + } else if (speed >= _kWindWarning) { + severity = AlertSeverity.warning; + message = 'Kecepatan angin ${speed.toStringAsFixed(1)} m/s — waspada'; + } else { + severity = AlertSeverity.info; // normal → bersihkan alert + message = ''; + } + + _notificationBloc.add(SensorAlertAdded( + SensorAlert( + sensorId: 'wind_speed', + sensorName: 'Anemometer', + message: message, + severity: severity, + timestamp: DateTime.now(), + ), + )); + } } /// ========================= From 469c500053da574c1b8de70e3fb22e67797b3a41 Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Fri, 1 May 2026 18:05:30 +0700 Subject: [PATCH 07/11] oke --- lib/screens/home/views/home_screen.dart | 239 +++++++++------- lib/screens/home/widgets/sensor_grid.dart | 257 ++++++++++++++++++ .../wind_speed/blocs/wind_speed_bloc.dart | 4 +- 3 files changed, 401 insertions(+), 99 deletions(-) create mode 100644 lib/screens/home/widgets/sensor_grid.dart diff --git a/lib/screens/home/views/home_screen.dart b/lib/screens/home/views/home_screen.dart index 2e98f38..71a1af0 100644 --- a/lib/screens/home/views/home_screen.dart +++ b/lib/screens/home/views/home_screen.dart @@ -1,8 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:monitoring_repository/monitoring_repository.dart'; import '../../../blocs/authentication_bloc/authentication_bloc.dart'; import '../../../blocs/notification_bloc/notification_bloc.dart'; +import '../../monitoring/wind_speed/blocs/wind_speed_bloc.dart'; +import '../../monitoring/evaporasi/blocs/evaporasi_bloc.dart'; +import '../../monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart'; import '../widgets/notification_panel.dart'; +import '../widgets/sensor_grid.dart'; import 'main_drawer.dart'; class HomeScreen extends StatefulWidget { @@ -50,112 +55,152 @@ class _HomeScreenState extends State @override Widget build(BuildContext context) { - return GestureDetector( - // Tap di luar panel → tutup - onTap: () { - if (_showNotifications) _toggleNotifications(); - }, - child: Scaffold( - drawer: const MainDrawer(), - appBar: AppBar( - backgroundColor: Colors.white, - elevation: 0.5, - centerTitle: false, - leading: Builder( - builder: (context) => IconButton( - icon: const Icon(Icons.menu, color: Colors.black), - onPressed: () => Scaffold.of(context).openDrawer(), - ), - ), - title: Row( - children: [ - Image.asset( - 'images/logo_klimatologi.png', - height: 90, - fit: BoxFit.contain, + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => WindSpeedBloc( + repository: context.read(), + notificationBloc: context.read(), + )..add(WatchWindSpeedStarted()), + ), + BlocProvider( + create: (context) => EvaporasiBloc( + repository: context.read(), + notificationBloc: context.read(), + )..add(WatchEvaporasiStarted()), + ), + BlocProvider( + create: (context) => AtmosphericConditionsBloc( + repository: context.read(), + )..add(WatchAtmosphericConditionsStarted()), + ), + ], + child: GestureDetector( + onTap: () { + if (_showNotifications) _toggleNotifications(); + }, + child: Scaffold( + drawer: const MainDrawer(), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0.5, + centerTitle: false, + leading: Builder( + builder: (context) => IconButton( + icon: const Icon(Icons.menu, color: Colors.black), + onPressed: () => Scaffold.of(context).openDrawer(), ), - ], - ), - actions: [ - // Icon lonceng dengan badge - BlocBuilder( - buildWhen: (prev, cur) => - prev.unreadCount != cur.unreadCount || - prev.hasActiveAlerts != cur.hasActiveAlerts, - builder: (context, state) { - return Stack( - children: [ - IconButton( - icon: Icon( - _showNotifications - ? Icons.notifications - : Icons.notifications_none, - color: Colors.black, + ), + title: Image.asset( + 'images/logo_klimatologi.png', + height: 90, + fit: BoxFit.contain, + ), + actions: [ + // Icon lonceng dengan badge + BlocBuilder( + buildWhen: (prev, cur) => + prev.unreadCount != cur.unreadCount || + prev.hasActiveAlerts != cur.hasActiveAlerts, + builder: (context, state) { + return Stack( + children: [ + IconButton( + icon: Icon( + _showNotifications + ? Icons.notifications + : Icons.notifications_none, + color: Colors.black, + ), + onPressed: _toggleNotifications, ), - onPressed: _toggleNotifications, - ), - if (state.unreadCount > 0) - Positioned( - right: 6, - top: 6, - child: Container( - padding: const EdgeInsets.all(3), - decoration: const BoxDecoration( - color: Colors.red, - shape: BoxShape.circle, - ), - constraints: const BoxConstraints( - minWidth: 16, - minHeight: 16, - ), - child: Text( - state.unreadCount > 9 - ? '9+' - : '${state.unreadCount}', - style: const TextStyle( - color: Colors.white, - fontSize: 9, - fontWeight: FontWeight.bold, + if (state.unreadCount > 0) + Positioned( + right: 6, + top: 6, + child: Container( + padding: const EdgeInsets.all(3), + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + constraints: const BoxConstraints( + minWidth: 16, + minHeight: 16, + ), + child: Text( + state.unreadCount > 9 + ? '9+' + : '${state.unreadCount}', + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, ), ), - ), - ], - ); - }, - ), - IconButton( - icon: const Icon(Icons.logout, color: Colors.black), - onPressed: () => context - .read() - .add(AuthenticationLogoutRequested()), - ), - const SizedBox(width: 8), - ], - ), - body: Stack( - children: [ - // Konten utama - const Center(child: Text('body home page')), - - // Dropdown notification panel - if (_showNotifications) - Positioned( - top: 0, - left: 0, - right: 0, - child: FadeTransition( - opacity: _fadeAnim, - child: GestureDetector( - onTap: () {}, // cegah tap di panel menutup panel - child: const NotificationPanel(), + ], + ); + }, + ), + IconButton( + icon: const Icon(Icons.logout, color: Colors.black), + onPressed: () => context + .read() + .add(AuthenticationLogoutRequested()), + ), + const SizedBox(width: 8), + ], + ), + body: Stack( + children: [ + // Konten utama + const _HomeBody(), + // Dropdown notification panel + if (_showNotifications) + Positioned( + top: 0, + left: 0, + right: 0, + child: FadeTransition( + opacity: _fadeAnim, + child: GestureDetector( + onTap: () {}, // cegah tap di panel menutup panel + child: const NotificationPanel(), + ), ), ), - ), - ], + ], + ), ), ), ); } } + +class _HomeBody extends StatelessWidget { + const _HomeBody(); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Monitoring Realtime', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: Colors.grey.shade700, + ), + ), + const SizedBox(height: 12), + const SensorGrid(), + ], + ), + ); + } +} diff --git a/lib/screens/home/widgets/sensor_grid.dart b/lib/screens/home/widgets/sensor_grid.dart new file mode 100644 index 0000000..86500cd --- /dev/null +++ b/lib/screens/home/widgets/sensor_grid.dart @@ -0,0 +1,257 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../monitoring/wind_speed/blocs/wind_speed_bloc.dart'; +import '../../monitoring/evaporasi/blocs/evaporasi_bloc.dart'; +import '../../monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart'; + +class SensorGrid extends StatelessWidget { + const SensorGrid({super.key}); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + // Lebar card: 2 kolom dengan gap 12 + final cardWidth = (constraints.maxWidth - 12) / 2; + + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + // --- ANEMOMETER --- + BlocBuilder( + builder: (context, state) => SensorCard( + width: cardWidth, + icon: Icons.air, + iconColor: Colors.blue.shade600, + iconBgColor: Colors.blue.shade50, + label: 'Kecepatan Angin', + value: state.isLoading + ? '—' + : state.currentSpeed.toStringAsFixed(1), + unit: 'm/s', + isLoading: state.isLoading, + ), + ), + + // --- EVAPORASI --- + BlocBuilder( + builder: (context, state) => SensorCard( + width: cardWidth, + icon: Icons.water_drop_outlined, + iconColor: Colors.teal.shade600, + iconBgColor: Colors.teal.shade50, + label: 'Evaporasi', + value: state.isLoading + ? '—' + : state.currentValue.toStringAsFixed(1), + unit: 'mm', + isLoading: state.isLoading, + ), + ), + + // --- SUHU AIR (dari Evaporasi) --- + BlocBuilder( + builder: (context, state) => SensorCard( + width: cardWidth, + icon: Icons.thermostat_outlined, + iconColor: Colors.orange.shade600, + iconBgColor: Colors.orange.shade50, + label: 'Suhu Air', + value: state.isLoading + ? '—' + : state.temperature.toStringAsFixed(1), + unit: '°C', + isLoading: state.isLoading, + ), + ), + + // --- TINGGI AIR (dari Evaporasi) --- + BlocBuilder( + builder: (context, state) => SensorCard( + width: cardWidth, + icon: Icons.straighten_outlined, + iconColor: Colors.cyan.shade600, + iconBgColor: Colors.cyan.shade50, + label: 'Tinggi Air', + value: + state.isLoading ? '—' : state.waterLevel.toStringAsFixed(1), + unit: 'cm', + isLoading: state.isLoading, + ), + ), + + // --- SUHU UDARA (dari Atmospheric) --- + BlocBuilder( + builder: (context, state) => SensorCard( + width: cardWidth, + icon: Icons.device_thermostat, + iconColor: Colors.red.shade400, + iconBgColor: Colors.red.shade50, + label: 'Suhu Udara', + value: state.isLoading + ? '—' + : state.temperature.toStringAsFixed(1), + unit: '°C', + isLoading: state.isLoading, + ), + ), + + // --- KELEMBAPAN --- + BlocBuilder( + builder: (context, state) => SensorCard( + width: cardWidth, + icon: Icons.water_outlined, + iconColor: Colors.indigo.shade400, + iconBgColor: Colors.indigo.shade50, + label: 'Kelembapan', + value: + state.isLoading ? '—' : state.humidity.toStringAsFixed(1), + unit: '%', + isLoading: state.isLoading, + ), + ), + + // --- TEKANAN UDARA --- + BlocBuilder( + builder: (context, state) => SensorCard( + width: cardWidth, + icon: Icons.compress, + iconColor: Colors.purple.shade400, + iconBgColor: Colors.purple.shade50, + label: 'Tekanan Udara', + value: + state.isLoading ? '—' : state.pressure.toStringAsFixed(1), + unit: 'hPa', + isLoading: state.isLoading, + ), + ), + + // --- KETINGGIAN --- + BlocBuilder( + builder: (context, state) => SensorCard( + width: cardWidth, + icon: Icons.terrain_outlined, + iconColor: Colors.green.shade600, + iconBgColor: Colors.green.shade50, + label: 'Ketinggian', + value: + state.isLoading ? '—' : state.altitude.toStringAsFixed(1), + unit: 'm', + isLoading: state.isLoading, + ), + ), + ], + ); + }, + ); + } +} + +class SensorCard extends StatelessWidget { + final double width; + final IconData icon; + final Color iconColor; + final Color iconBgColor; + final String label; + final String value; + final String unit; + final bool isLoading; + + const SensorCard({ + super.key, + required this.width, + required this.icon, + required this.iconColor, + required this.iconBgColor, + required this.label, + required this.value, + required this.unit, + this.isLoading = false, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Ikon + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: iconBgColor, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: iconColor, size: 20), + ), + const SizedBox(height: 12), + // Nilai + satuan + isLoading + ? Container( + height: 24, + width: 60, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(4), + ), + ) + : Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Flexible( + child: Text( + value, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + height: 1, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 3), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + unit, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade500, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + // Label + Text( + label, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade500, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart b/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart index c957cda..0b7fbed 100644 --- a/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart +++ b/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart @@ -11,8 +11,8 @@ part 'wind_speed_state.dart'; /// Threshold kecepatan angin (satuan m/s) /// Referensi: Beaufort scale & BMKG -const double _kWindWarning = 4.0; // Waspada: 8–12.5 m/s (~29–45 km/h) -const double _kWindDanger = 5.5; // Bahaya: > 12.5 m/s (> 45 km/h) +const double _kWindWarning = 2.0; // Waspada: 8–12.5 m/s (~29–45 km/h) +const double _kWindDanger = 4.0; // Bahaya: > 12.5 m/s (> 45 km/h) class WindSpeedBloc extends Bloc { final MonitoringRepository _repository; From 08aefa563450380cf8987d71607ebe9690674792 Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Fri, 1 May 2026 18:36:57 +0700 Subject: [PATCH 08/11] oke joss kontol --- lib/app.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/app.dart b/lib/app.dart index e6cb315..244ea36 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -37,3 +37,4 @@ class MyApp extends StatelessWidget { ); } } +// sadadda From d19c5fcaf8f28fed989923ac8febded0576f3675 Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Sat, 2 May 2026 10:46:03 +0700 Subject: [PATCH 09/11] oke --- lib/screens/home/widgets/sensor_grid.dart | 77 ----------------------- 1 file changed, 77 deletions(-) diff --git a/lib/screens/home/widgets/sensor_grid.dart b/lib/screens/home/widgets/sensor_grid.dart index 86500cd..83ea239 100644 --- a/lib/screens/home/widgets/sensor_grid.dart +++ b/lib/screens/home/widgets/sensor_grid.dart @@ -50,68 +50,6 @@ class SensorGrid extends StatelessWidget { ), ), - // --- SUHU AIR (dari Evaporasi) --- - BlocBuilder( - builder: (context, state) => SensorCard( - width: cardWidth, - icon: Icons.thermostat_outlined, - iconColor: Colors.orange.shade600, - iconBgColor: Colors.orange.shade50, - label: 'Suhu Air', - value: state.isLoading - ? '—' - : state.temperature.toStringAsFixed(1), - unit: '°C', - isLoading: state.isLoading, - ), - ), - - // --- TINGGI AIR (dari Evaporasi) --- - BlocBuilder( - builder: (context, state) => SensorCard( - width: cardWidth, - icon: Icons.straighten_outlined, - iconColor: Colors.cyan.shade600, - iconBgColor: Colors.cyan.shade50, - label: 'Tinggi Air', - value: - state.isLoading ? '—' : state.waterLevel.toStringAsFixed(1), - unit: 'cm', - isLoading: state.isLoading, - ), - ), - - // --- SUHU UDARA (dari Atmospheric) --- - BlocBuilder( - builder: (context, state) => SensorCard( - width: cardWidth, - icon: Icons.device_thermostat, - iconColor: Colors.red.shade400, - iconBgColor: Colors.red.shade50, - label: 'Suhu Udara', - value: state.isLoading - ? '—' - : state.temperature.toStringAsFixed(1), - unit: '°C', - isLoading: state.isLoading, - ), - ), - - // --- KELEMBAPAN --- - BlocBuilder( - builder: (context, state) => SensorCard( - width: cardWidth, - icon: Icons.water_outlined, - iconColor: Colors.indigo.shade400, - iconBgColor: Colors.indigo.shade50, - label: 'Kelembapan', - value: - state.isLoading ? '—' : state.humidity.toStringAsFixed(1), - unit: '%', - isLoading: state.isLoading, - ), - ), - // --- TEKANAN UDARA --- BlocBuilder( builder: (context, state) => SensorCard( @@ -126,21 +64,6 @@ class SensorGrid extends StatelessWidget { isLoading: state.isLoading, ), ), - - // --- KETINGGIAN --- - BlocBuilder( - builder: (context, state) => SensorCard( - width: cardWidth, - icon: Icons.terrain_outlined, - iconColor: Colors.green.shade600, - iconBgColor: Colors.green.shade50, - label: 'Ketinggian', - value: - state.isLoading ? '—' : state.altitude.toStringAsFixed(1), - unit: 'm', - isLoading: state.isLoading, - ), - ), ], ); }, From 47fc79455ba017b2b6dde67c23413ea400770bf0 Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Sat, 2 May 2026 14:22:34 +0700 Subject: [PATCH 10/11] looooo --- lib/screens/home/widgets/sensor_grid.dart | 2 +- .../wind_speed/blocs/wind_speed_bloc.dart | 21 +++++++++++-------- .../wind_speed/blocs/wind_speed_state.dart | 15 +++++++++++-- .../wind_speed/views/wind_speed_screen.dart | 11 +++++++--- 4 files changed, 34 insertions(+), 15 deletions(-) diff --git a/lib/screens/home/widgets/sensor_grid.dart b/lib/screens/home/widgets/sensor_grid.dart index 83ea239..0f0b56b 100644 --- a/lib/screens/home/widgets/sensor_grid.dart +++ b/lib/screens/home/widgets/sensor_grid.dart @@ -104,7 +104,7 @@ class SensorCard extends StatelessWidget { borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.05), + color: Colors.black.withValues(alpha: 0.05), blurRadius: 8, offset: const Offset(0, 2), ), diff --git a/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart b/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart index 0b7fbed..062787d 100644 --- a/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart +++ b/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart @@ -50,15 +50,8 @@ class WindSpeedBloc extends Bloc { (json) => MyWindSpeed.fromJson(json), ); - /// 2. Mapping default (harian) - final raw = TimeSeriesMapper.toDaily( - data: history, - getTime: (e) => e.timestamp, - getValue: (e) => e.speed, - ); - final dailyGraph = TimeSeriesMapper.smooth(raw); - - final daily = TimeSeriesMapper.smooth( + // ✅ Ini saja yang benar + final dailyGraph = TimeSeriesMapper.smooth( TimeSeriesMapper.toDaily( data: history, getTime: (e) => e.timestamp, @@ -93,6 +86,9 @@ class WindSpeedBloc extends Bloc { weeklySpeeds: weekly, monthlySpeeds: monthly, isLoading: false, + alertLevel: history.isNotEmpty // ← tambah ini + ? _getAlertLevel(history.last.speed) + : "Normal", )); /// 3. Start realtime stream (manual subscription) @@ -138,6 +134,7 @@ class WindSpeedBloc extends Bloc { emit(state.copyWith( currentSpeed: newValue, dailySpeeds: updated, + alertLevel: _getAlertLevel(newValue), )); } @@ -194,6 +191,12 @@ class WindSpeedBloc extends Bloc { return super.close(); } + String _getAlertLevel(double speed) { + if (speed >= _kWindDanger) return "Bahaya"; + if (speed >= _kWindWarning) return "Waspada"; + return "Normal"; + } + // ========================= // HELPER: kirim alert ke NotificationBloc // ========================= diff --git a/lib/screens/monitoring/wind_speed/blocs/wind_speed_state.dart b/lib/screens/monitoring/wind_speed/blocs/wind_speed_state.dart index 3276997..f61404d 100644 --- a/lib/screens/monitoring/wind_speed/blocs/wind_speed_state.dart +++ b/lib/screens/monitoring/wind_speed/blocs/wind_speed_state.dart @@ -8,6 +8,7 @@ class WindSpeedState extends Equatable { final List monthlySpeeds; final bool isLoading; final List history; + final String alertLevel; // "Normal" | "Waspada" | "Bahaya" const WindSpeedState({ this.currentSpeed = 0.0, @@ -17,6 +18,7 @@ class WindSpeedState extends Equatable { this.history = const [], this.monthlySpeeds = const [], this.weeklySpeeds = const [], + this.alertLevel = "Normal", }); WindSpeedState copyWith({ @@ -27,6 +29,7 @@ class WindSpeedState extends Equatable { List? monthlySpeeds, bool? isLoading, List? history, + String? alertLevel, }) { return WindSpeedState( currentSpeed: currentSpeed ?? this.currentSpeed, @@ -36,10 +39,18 @@ class WindSpeedState extends Equatable { monthlySpeeds: monthlySpeeds ?? this.monthlySpeeds, isLoading: isLoading ?? this.isLoading, history: history ?? this.history, + alertLevel: alertLevel ?? this.alertLevel, ); } @override - List get props => - [currentSpeed, selectedPeriod, dailySpeeds, isLoading, history]; + List get props => [ + currentSpeed, + selectedPeriod, + dailySpeeds, + weeklySpeeds, + monthlySpeeds, + isLoading, + history + ]; } diff --git a/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart b/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart index 2025e10..f741f4c 100644 --- a/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart +++ b/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart @@ -85,7 +85,7 @@ class WindSpeedScreen extends StatelessWidget { const SizedBox(height: 30), // 3. Info Tambahan (Status/Periode) - _buildDetailRow(state.selectedPeriod), + _buildDetailRow(state.selectedPeriod, state.alertLevel), const SizedBox(height: 24), // ← Tombol Export PDF @@ -143,11 +143,16 @@ class WindSpeedScreen extends StatelessWidget { ); } - Widget _buildDetailRow(String period) { + Widget _buildDetailRow(String period, String alertLevel) { + final (icon, color) = switch (alertLevel) { + "Bahaya" => (Icons.warning_rounded, Colors.red), + "Waspada" => (Icons.info_outline, Colors.orange), + _ => (Icons.check_circle, Colors.green), + }; return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - _miniInfoCard("Status", "Normal", Icons.check_circle, Colors.green), + _miniInfoCard("Status", alertLevel, icon, color), _miniInfoCard("Periode", period, Icons.timer, Colors.orange), ], ); From 07d5cb369a6d1e227337c3319088138f6483c085 Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Sat, 2 May 2026 15:07:33 +0700 Subject: [PATCH 11/11] joss jiss --- .../device_setup/blocs/device_setup_bloc.dart | 3 ++- .../views/device_setup_screen.dart | 26 +++++++++++-------- .../wind_speed/blocs/wind_speed_bloc.dart | 4 +-- .../wind_speed/blocs/wind_speed_event.dart | 8 ------ .../wind_speed/blocs/wind_speed_state.dart | 3 ++- macos/Flutter/GeneratedPluginRegistrant.swift | 2 ++ pubspec.lock | 8 ++++++ pubspec.yaml | 1 + 8 files changed, 32 insertions(+), 23 deletions(-) diff --git a/lib/screens/monitoring/device_setup/blocs/device_setup_bloc.dart b/lib/screens/monitoring/device_setup/blocs/device_setup_bloc.dart index 8043ac7..5a42d4c 100644 --- a/lib/screens/monitoring/device_setup/blocs/device_setup_bloc.dart +++ b/lib/screens/monitoring/device_setup/blocs/device_setup_bloc.dart @@ -115,7 +115,8 @@ class DeviceSetupBloc extends Bloc { // ESP restart setelah terima kredensial → koneksi putus → itu normal! // DioException di sini kemungkinan besar karena ESP sudah restart. if (e.type == DioExceptionType.receiveTimeout || - e.type == DioExceptionType.connectionError) { + e.type == DioExceptionType.connectionError || + e.type == DioExceptionType.sendTimeout) { emit(state.copyWith( status: DeviceSetupStatus.success, successMessage: diff --git a/lib/screens/monitoring/device_setup/views/device_setup_screen.dart b/lib/screens/monitoring/device_setup/views/device_setup_screen.dart index b6a4811..ada34d6 100644 --- a/lib/screens/monitoring/device_setup/views/device_setup_screen.dart +++ b/lib/screens/monitoring/device_setup/views/device_setup_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../blocs/device_setup_bloc.dart'; +import 'package:app_settings/app_settings.dart'; class DeviceSetupScreen extends StatefulWidget { const DeviceSetupScreen({super.key}); @@ -159,7 +160,7 @@ class _DeviceSetupScreenState extends State { showDialog( context: context, barrierDismissible: false, - builder: (_) => AlertDialog( + builder: (dialogContext) => AlertDialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), title: Row( children: [ @@ -173,12 +174,21 @@ class _DeviceSetupScreenState extends State { ), content: Text(message), actions: [ + if (!success) // ← tambah tombol Coba Lagi jika gagal + TextButton( + onPressed: () { + Navigator.of(dialogContext).pop(); // tutup dialog + context.read().add( + ResetDeviceSetupEvent()); // kembali ke wind_speed_screen + }, + child: const Text('Coba Lagi'), + ), TextButton( onPressed: () { - Navigator.of(context).pop(); // tutup dialog - Navigator.of(context).pop(); // kembali ke wind_speed_screen + Navigator.of(dialogContext).pop(); + Navigator.of(context).pop(); }, - child: const Text('Selesai'), + child: Text(success ? 'Selesai' : 'Tutup'), ), ], ), @@ -353,13 +363,7 @@ class _OpenWifiSettingsButton extends StatelessWidget { // Buka pengaturan WiFi sistem HP // Butuh package: app_settings (opsional) // AppSettings.openWIFISettings(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Buka Pengaturan WiFi HP → sambungkan ke "Anemometer-Setup"'), - behavior: SnackBarBehavior.floating, - ), - ); + AppSettings.openAppSettings(type: AppSettingsType.wifi); }, icon: const Icon(Icons.settings_rounded, size: 16), label: const Text('Buka Setting'), diff --git a/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart b/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart index 062787d..9a995a7 100644 --- a/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart +++ b/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart @@ -11,8 +11,8 @@ part 'wind_speed_state.dart'; /// Threshold kecepatan angin (satuan m/s) /// Referensi: Beaufort scale & BMKG -const double _kWindWarning = 2.0; // Waspada: 8–12.5 m/s (~29–45 km/h) -const double _kWindDanger = 4.0; // Bahaya: > 12.5 m/s (> 45 km/h) +const double _kWindWarning = 8.0; // Waspada: 8–12.5 m/s (~29–45 km/h) +const double _kWindDanger = 12.5; // Bahaya: > 12.5 m/s (> 45 km/h) class WindSpeedBloc extends Bloc { final MonitoringRepository _repository; diff --git a/lib/screens/monitoring/wind_speed/blocs/wind_speed_event.dart b/lib/screens/monitoring/wind_speed/blocs/wind_speed_event.dart index 445c8c6..e966119 100644 --- a/lib/screens/monitoring/wind_speed/blocs/wind_speed_event.dart +++ b/lib/screens/monitoring/wind_speed/blocs/wind_speed_event.dart @@ -14,11 +14,3 @@ class WindSpeedPeriodChanged extends WindSpeedEvent { final String period; const WindSpeedPeriodChanged(this.period); } - -// Event internal: saat data baru masuk dari Firebase -class _WindSpeedUpdated extends WindSpeedEvent { - final MyWindSpeed data; - const _WindSpeedUpdated(this.data); - @override - List get props => [data]; -} diff --git a/lib/screens/monitoring/wind_speed/blocs/wind_speed_state.dart b/lib/screens/monitoring/wind_speed/blocs/wind_speed_state.dart index f61404d..7ff5572 100644 --- a/lib/screens/monitoring/wind_speed/blocs/wind_speed_state.dart +++ b/lib/screens/monitoring/wind_speed/blocs/wind_speed_state.dart @@ -51,6 +51,7 @@ class WindSpeedState extends Equatable { weeklySpeeds, monthlySpeeds, isLoading, - history + history, + alertLevel, ]; } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index bf298b9..9b58f8a 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,7 @@ import FlutterMacOS import Foundation +import app_settings import cloud_firestore import firebase_auth import firebase_core @@ -13,6 +14,7 @@ import google_sign_in_ios import printing func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AppSettingsPlugin.register(with: registry.registrar(forPlugin: "AppSettingsPlugin")) FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) diff --git a/pubspec.lock b/pubspec.lock index ffc5bd8..4867820 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.66" + app_settings: + dependency: "direct main" + description: + name: app_settings + sha256: "64d50e666fd96ae90301bf71205f05019286f940ad6f5fed3d1be19c6af7546a" + url: "https://pub.dev" + source: hosted + version: "7.0.0" archive: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4a46cde..fff0541 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -5,6 +5,7 @@ version: 1.0.0+1 environment: sdk: ^3.1.0 dependencies: + app_settings: ^7.0.0 bloc: ^8.1.0 bloc_concurrency: ^0.2.5 cloud_firestore: ^6.1.1