This commit is contained in:
kleponijo 2026-04-30 21:09:49 +07:00
parent f5f177f554
commit a60c6af54b
13 changed files with 626 additions and 132 deletions

View File

@ -4,6 +4,7 @@ import 'package:klimatologiot/app_view.dart';
import 'package:user_repository/user_repository.dart'; import 'package:user_repository/user_repository.dart';
import 'package:monitoring_repository/monitoring_repository.dart'; import 'package:monitoring_repository/monitoring_repository.dart';
import 'blocs/authentication_bloc/authentication_bloc.dart'; import 'blocs/authentication_bloc/authentication_bloc.dart';
import 'blocs/notification_bloc/notification_bloc.dart';
class MyApp extends StatelessWidget { class MyApp extends StatelessWidget {
final UserRepository userRepository; final UserRepository userRepository;
@ -13,17 +14,26 @@ class MyApp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiRepositoryProvider( return MultiRepositoryProvider(
providers: [
// 1. Daftarkan UserRepository
RepositoryProvider<UserRepository>.value(value: userRepository),
RepositoryProvider<MonitoringRepository>.value(
value: monitoringRepository),
],
child: MultiBlocProvider(
providers: [ providers: [
// 1. Daftarkan UserRepository BlocProvider<AuthenticationBloc>(
RepositoryProvider<UserRepository>.value(value: userRepository), create: (context) => AuthenticationBloc(
RepositoryProvider<MonitoringRepository>.value( userRepository: userRepository,
value: monitoringRepository), ),
],
child: BlocProvider<AuthenticationBloc>(
create: (context) => AuthenticationBloc(
userRepository: userRepository,
), ),
child: const MyAppView(), BlocProvider<NotificationBloc>(
)); // tambahan ini
create: (_) => NotificationBloc(),
),
],
child: const MyAppView(),
),
);
} }
} }

View File

@ -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<NotificationEvent, NotificationState> {
NotificationBloc() : super(const NotificationState()) {
on<SensorAlertAdded>(_onAlertAdded);
on<NotificationsMarkedAsRead>(_onMarkedAsRead);
on<NotificationDismissed>(_onDismissed);
}
void _onAlertAdded(
SensorAlertAdded event,
Emitter<NotificationState> emit,
) {
final updated = Map<String, SensorAlert>.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<NotificationState> emit,
) {
final updated = state._alertsBySensor.map(
(key, alert) => MapEntry(key, alert.copyWith(isRead: true)),
);
emit(state.copyWith(alertsBySensor: updated));
}
void _onDismissed(
NotificationDismissed event,
Emitter<NotificationState> emit,
) {
final updated = Map<String, SensorAlert>.from(state._alertsBySensor)
..remove(event.sensorId);
emit(state.copyWith(alertsBySensor: updated));
}
}

View File

@ -0,0 +1,33 @@
part of 'notification_bloc.dart';
sealed class NotificationEvent extends Equatable {
const NotificationEvent();
@override
List<Object> get props => [];
}
/// Tambah atau update alert dari sebuah sensor
final class SensorAlertAdded extends NotificationEvent {
final SensorAlert alert;
const SensorAlertAdded(this.alert);
@override
List<Object> 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<Object> get props => [sensorId];
}

View File

@ -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<Object> 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<String, SensorAlert> _alertsBySensor;
const NotificationState({
Map<String, SensorAlert> alertsBySensor = const {},
}) : _alertsBySensor = alertsBySensor;
/// Semua alert aktif, diurutkan: danger dulu, lalu warning, lalu info
List<SensorAlert> 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<String, SensorAlert>? alertsBySensor,
}) {
return NotificationState(
alertsBySensor: alertsBySensor ?? _alertsBySensor,
);
}
@override
List<Object> get props => [_alertsBySensor];
}

View File

@ -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<bool> hasWeatherAlert = ValueNotifier<bool>(false);
/// Pesan peringatan yang akan ditampilkan
final ValueNotifier<String> weatherAlertMessage = ValueNotifier<String>('');

View File

@ -1,94 +1,160 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
// import 'package:google_fonts/google_fonts.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../blocs/authentication_bloc/authentication_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'; import 'main_drawer.dart';
class HomeScreen extends StatelessWidget { class HomeScreen extends StatefulWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen>
with SingleTickerProviderStateMixin {
bool _showNotifications = false;
late final AnimationController _animController;
late final Animation<double> _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<NotificationBloc>().add(const NotificationsMarkedAsRead());
} else {
_animController.reverse();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return GestureDetector(
drawer: const MainDrawer(), // Tap di luar panel tutup
appBar: AppBar( onTap: () {
backgroundColor: Colors.white, if (_showNotifications) _toggleNotifications();
elevation: 0.5, // Kasih sedikit bayangan tipis agar elegan },
centerTitle: false, child: Scaffold(
leading: Builder( drawer: const MainDrawer(),
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0.5,
centerTitle: false,
leading: Builder(
builder: (context) => IconButton( builder: (context) => IconButton(
icon: const Icon(Icons.menu, icon: const Icon(Icons.menu, color: Colors.black),
color: Colors.black), // Ikon garis 3 horizontal onPressed: () => Scaffold.of(context).openDrawer(),
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,
), ),
const SizedBox(width: 10), ),
], title: Row(
), children: [
actions: [ Image.asset(
ValueListenableBuilder<bool>( 'images/logo_klimatologi.png',
valueListenable: hasWeatherAlert, height: 90,
builder: (context, hasAlert, child) { fit: BoxFit.contain,
return Stack( ),
children: [ ],
IconButton( ),
icon: const Icon(Icons.notifications_none, actions: [
color: Colors.black), // Icon lonceng dengan badge
onPressed: () { BlocBuilder<NotificationBloc, NotificationState>(
// Aksi notifikasi buildWhen: (prev, cur) =>
if (hasAlert) { prev.unreadCount != cur.unreadCount ||
ScaffoldMessenger.of(context).showSnackBar( prev.hasActiveAlerts != cur.hasActiveAlerts,
SnackBar( builder: (context, state) {
content: Text(weatherAlertMessage.value), return Stack(
backgroundColor: Colors.orange.shade800, children: [
behavior: SnackBarBehavior.floating, 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(
IconButton( icon: const Icon(Icons.logout, color: Colors.black),
icon: const Icon(Icons.logout, color: Colors.black), onPressed: () => context
onPressed: () {
// Trigger Logout di AuthenticationBloc kamu
context
.read<AuthenticationBloc>() .read<AuthenticationBloc>()
.add(AuthenticationLogoutRequested()); .add(AuthenticationLogoutRequested()),
}, ),
), const SizedBox(width: 8),
const SizedBox(width: 8), ],
], ),
), body: Stack(
body: const Center( children: [
child: Text("body home page"), // 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(),
),
),
),
],
),
), ),
); );
} }

View File

@ -8,6 +8,7 @@ import '../../monitoring/wind_speed/blocs/wind_speed_bloc.dart';
import '../../monitoring/evaporasi/views/evaporasi_screen.dart'; import '../../monitoring/evaporasi/views/evaporasi_screen.dart';
import '../../monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart'; import '../../monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart';
import '../../monitoring/atmospheric_conditions/views/atmospheric_screen.dart'; import '../../monitoring/atmospheric_conditions/views/atmospheric_screen.dart';
import '../../../blocs/notification_bloc/notification_bloc.dart';
class MainDrawer extends StatelessWidget { class MainDrawer extends StatelessWidget {
const MainDrawer({super.key}); const MainDrawer({super.key});
@ -59,10 +60,9 @@ class MainDrawer extends StatelessWidget {
MaterialPageRoute( MaterialPageRoute(
builder: (context) => BlocProvider<WindSpeedBloc>( builder: (context) => BlocProvider<WindSpeedBloc>(
create: (context) => WindSpeedBloc( create: (context) => WindSpeedBloc(
// AMBIL MonitoringRepository
repository: context.read<MonitoringRepository>(), repository: context.read<MonitoringRepository>(),
)..add( notificationBloc: context.read<NotificationBloc>(),
WatchWindSpeedStarted()), // Langsung mulai monitoring )..add(WatchWindSpeedStarted()),
child: const WindSpeedScreen(), child: const WindSpeedScreen(),
), ),
), ),
@ -80,6 +80,7 @@ class MainDrawer extends StatelessWidget {
builder: (context) => BlocProvider<EvaporasiBloc>( builder: (context) => BlocProvider<EvaporasiBloc>(
create: (context) => EvaporasiBloc( create: (context) => EvaporasiBloc(
repository: context.read<MonitoringRepository>(), repository: context.read<MonitoringRepository>(),
notificationBloc: context.read<NotificationBloc>(),
)..add(WatchEvaporasiStarted()), )..add(WatchEvaporasiStarted()),
child: const EvaporasiScreen(), child: const EvaporasiScreen(),
), ),

View File

@ -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<NotificationBloc, NotificationState>(
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<NotificationBloc>()
.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<NotificationBloc>()
.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')}';
}
}

View File

@ -4,17 +4,21 @@ import 'package:equatable/equatable.dart';
import 'package:monitoring_repository/monitoring_repository.dart'; import 'package:monitoring_repository/monitoring_repository.dart';
import '../../../../core/utils/time_series_mapper.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_event.dart';
part 'evaporasi_state.dart'; part 'evaporasi_state.dart';
class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> { class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
final MonitoringRepository _repository; final MonitoringRepository _repository;
final NotificationBloc _notificationBloc;
StreamSubscription<Evaporasi>? _subscription; StreamSubscription<Evaporasi>? _subscription;
EvaporasiBloc({required MonitoringRepository repository}) EvaporasiBloc({
: _repository = repository, required MonitoringRepository repository,
required NotificationBloc notificationBloc,
}) : _repository = repository,
_notificationBloc = notificationBloc,
super(const EvaporasiState()) { super(const EvaporasiState()) {
on<WatchEvaporasiStarted>(_onStarted); on<WatchEvaporasiStarted>(_onStarted);
on<_EvaporasiRealtimeUpdated>(_onRealtimeUpdated); on<_EvaporasiRealtimeUpdated>(_onRealtimeUpdated);
@ -50,7 +54,7 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
// Hitung status dari data terakhir history jika ada // Hitung status dari data terakhir history jika ada
final lastValue = history.isNotEmpty ? history.last.evaporasi : 0.0; final lastValue = history.isNotEmpty ? history.last.evaporasi : 0.0;
final (status, rain) = _computeWeatherStatus(lastValue); final (status, rain) = _computeWeatherStatus(lastValue);
_updateGlobalNotifier(status, rain); _emitEvaporasiAlert(status, rain, lastValue);
emit(state.copyWith( emit(state.copyWith(
history: history, history: history,
@ -62,15 +66,9 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
)); ));
await _subscription?.cancel(); await _subscription?.cancel();
_subscription = _repository _subscription = _repository
.getSensorStream( .getSensorStream('Monitoring', (json) => Evaporasi.fromJson(json))
'Monitoring', .listen((data) => add(_EvaporasiRealtimeUpdated(data)));
(json) => Evaporasi.fromJson(json),
)
.listen((data) {
add(_EvaporasiRealtimeUpdated(data));
});
} }
/// ========================= /// =========================
@ -82,7 +80,6 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
) { ) {
final updated = List<double>.from(state.dailyValues); final updated = List<double>.from(state.dailyValues);
final updatedTemp = List<double>.from(state.dailyTemperatures); final updatedTemp = List<double>.from(state.dailyTemperatures);
final index = DateTime.now().hour; final index = DateTime.now().hour;
if (index < updated.length) { if (index < updated.length) {
@ -91,7 +88,7 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
} }
final (status, rain) = _computeWeatherStatus(event.data.evaporasi); final (status, rain) = _computeWeatherStatus(event.data.evaporasi);
_updateGlobalNotifier(status, rain); _emitEvaporasiAlert(status, rain, event.data.evaporasi);
emit(state.copyWith( emit(state.copyWith(
currentValue: event.data.evaporasi, currentValue: event.data.evaporasi,
@ -112,7 +109,6 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
Emitter<EvaporasiState> emit, Emitter<EvaporasiState> emit,
) async { ) async {
emit(state.copyWith(isLoading: true, selectedPeriod: event.period)); emit(state.copyWith(isLoading: true, selectedPeriod: event.period));
final history = state.history; final history = state.history;
List<double> updated; List<double> updated;
@ -171,23 +167,37 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
/// 🌤 HELPER: Status Cuaca /// 🌤 HELPER: Status Cuaca
/// ========================= /// =========================
static (String status, bool willRain) _computeWeatherStatus(double value) { static (String status, bool willRain) _computeWeatherStatus(double value) {
if (value <= 5.0) { if (value <= 5.0) return ('Baik', false);
return ("Baik", false); if (value <= 10.0) return ('Sedang', true);
} else if (value > 5.0 && value <= 10.0) { return ('Buruk', true);
return ("Sedang", true);
} else {
return ("Buruk", true);
}
} }
static void _updateGlobalNotifier(String status, bool willRain) { void _emitEvaporasiAlert(String status, bool willRain, double value) {
hasWeatherAlert.value = willRain; final AlertSeverity severity;
if (willRain) { final String message;
weatherAlertMessage.value =
'Status evaporasi $status — potensi hujan tinggi'; 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 { } 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(),
),
));
} }
} }

View File

@ -4,16 +4,26 @@ import 'package:equatable/equatable.dart';
import 'package:monitoring_repository/monitoring_repository.dart'; import 'package:monitoring_repository/monitoring_repository.dart';
import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:bloc_concurrency/bloc_concurrency.dart';
import '../../../../core/utils/time_series_mapper.dart'; import '../../../../core/utils/time_series_mapper.dart';
import '../../../../blocs/notification_bloc/notification_bloc.dart';
part 'wind_speed_event.dart'; part 'wind_speed_event.dart';
part 'wind_speed_state.dart'; part 'wind_speed_state.dart';
/// Threshold kecepatan angin (satuan m/s)
/// Referensi: Beaufort scale & BMKG
const double _kWindWarning = 4.0; // Waspada: 812.5 m/s (~2945 km/h)
const double _kWindDanger = 5.5; // Bahaya: > 12.5 m/s (> 45 km/h)
class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> { class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
final MonitoringRepository _repository; final MonitoringRepository _repository;
final NotificationBloc _notificationBloc;
StreamSubscription<MyWindSpeed>? _subscription; StreamSubscription<MyWindSpeed>? _subscription;
WindSpeedBloc({required MonitoringRepository repository}) WindSpeedBloc(
{required MonitoringRepository repository,
required NotificationBloc notificationBloc})
: _repository = repository, : _repository = repository,
_notificationBloc = notificationBloc,
super(const WindSpeedState()) { super(const WindSpeedState()) {
/// 🔥 START MONITORING /// 🔥 START MONITORING
on<WatchWindSpeedStarted>(_onStarted, transformer: restartable()); on<WatchWindSpeedStarted>(_onStarted, transformer: restartable());
@ -72,6 +82,11 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
), ),
); );
// Cek kondisi awal dari history
if (history.isNotEmpty) {
_emitWindAlert(history.last.speed);
}
emit(state.copyWith( emit(state.copyWith(
history: history, history: history,
dailySpeeds: dailyGraph, dailySpeeds: dailyGraph,
@ -101,7 +116,6 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
Emitter<WindSpeedState> emit, Emitter<WindSpeedState> emit,
) { ) {
final updated = List<double>.from(state.dailySpeeds); final updated = List<double>.from(state.dailySpeeds);
final index = DateTime.now().hour; final index = DateTime.now().hour;
double newValue = event.data.speed; double newValue = event.data.speed;
@ -116,10 +130,11 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
newValue = (lastValue + newValue) / 2; // smoothing newValue = (lastValue + newValue) / 2; // smoothing
} }
} }
updated[index] = newValue.clamp(0, 100); updated[index] = newValue.clamp(0, 100);
} }
_emitWindAlert(newValue);
emit(state.copyWith( emit(state.copyWith(
currentSpeed: newValue, currentSpeed: newValue,
dailySpeeds: updated, dailySpeeds: updated,
@ -178,6 +193,35 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
await _subscription?.cancel(); await _subscription?.cancel();
return super.close(); 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(),
),
));
}
} }
/// ========================= /// =========================