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] 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(), + ), + )); + } } /// =========================