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: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;
@ -19,11 +20,20 @@ class MyApp extends StatelessWidget {
RepositoryProvider<MonitoringRepository>.value(
value: monitoringRepository),
],
child: BlocProvider<AuthenticationBloc>(
child: MultiBlocProvider(
providers: [
BlocProvider<AuthenticationBloc>(
create: (context) => AuthenticationBloc(
userRepository: userRepository,
),
),
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,74 +1,124 @@
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<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
Widget build(BuildContext context) {
return Scaffold(
return GestureDetector(
// Tap di luar panel tutup
onTap: () {
if (_showNotifications) _toggleNotifications();
},
child: Scaffold(
drawer: const MainDrawer(),
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0.5, // Kasih sedikit bayangan tipis agar elegan
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();
},
)),
icon: const Icon(Icons.menu, color: Colors.black),
onPressed: () => 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),
],
),
actions: [
ValueListenableBuilder<bool>(
valueListenable: hasWeatherAlert,
builder: (context, hasAlert, child) {
// Icon lonceng dengan badge
BlocBuilder<NotificationBloc, NotificationState>(
buildWhen: (prev, cur) =>
prev.unreadCount != cur.unreadCount ||
prev.hasActiveAlerts != cur.hasActiveAlerts,
builder: (context, state) {
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,
icon: Icon(
_showNotifications
? Icons.notifications
: Icons.notifications_none,
color: Colors.black,
),
);
}
},
onPressed: _toggleNotifications,
),
if (hasAlert)
if (state.unreadCount > 0)
Positioned(
right: 8,
top: 8,
right: 6,
top: 6,
child: Container(
width: 10,
height: 10,
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,
),
),
),
],
@ -77,18 +127,34 @@ class HomeScreen extends StatelessWidget {
),
IconButton(
icon: const Icon(Icons.logout, color: Colors.black),
onPressed: () {
// Trigger Logout di AuthenticationBloc kamu
context
onPressed: () => context
.read<AuthenticationBloc>()
.add(AuthenticationLogoutRequested());
},
.add(AuthenticationLogoutRequested()),
),
const SizedBox(width: 8),
],
),
body: const Center(
child: Text("body home page"),
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(),
),
),
),
],
),
),
);
}

View File

@ -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<WindSpeedBloc>(
create: (context) => WindSpeedBloc(
// AMBIL MonitoringRepository
repository: context.read<MonitoringRepository>(),
)..add(
WatchWindSpeedStarted()), // Langsung mulai monitoring
notificationBloc: context.read<NotificationBloc>(),
)..add(WatchWindSpeedStarted()),
child: const WindSpeedScreen(),
),
),
@ -80,6 +80,7 @@ class MainDrawer extends StatelessWidget {
builder: (context) => BlocProvider<EvaporasiBloc>(
create: (context) => EvaporasiBloc(
repository: context.read<MonitoringRepository>(),
notificationBloc: context.read<NotificationBloc>(),
)..add(WatchEvaporasiStarted()),
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 '../../../../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<EvaporasiEvent, EvaporasiState> {
final MonitoringRepository _repository;
final NotificationBloc _notificationBloc;
StreamSubscription<Evaporasi>? _subscription;
EvaporasiBloc({required MonitoringRepository repository})
: _repository = repository,
EvaporasiBloc({
required MonitoringRepository repository,
required NotificationBloc notificationBloc,
}) : _repository = repository,
_notificationBloc = notificationBloc,
super(const EvaporasiState()) {
on<WatchEvaporasiStarted>(_onStarted);
on<_EvaporasiRealtimeUpdated>(_onRealtimeUpdated);
@ -50,7 +54,7 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
// 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<EvaporasiEvent, EvaporasiState> {
));
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<EvaporasiEvent, EvaporasiState> {
) {
final updated = List<double>.from(state.dailyValues);
final updatedTemp = List<double>.from(state.dailyTemperatures);
final index = DateTime.now().hour;
if (index < updated.length) {
@ -91,7 +88,7 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
}
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<EvaporasiEvent, EvaporasiState> {
Emitter<EvaporasiState> emit,
) async {
emit(state.copyWith(isLoading: true, selectedPeriod: event.period));
final history = state.history;
List<double> updated;
@ -171,23 +167,37 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
/// 🌤 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(),
),
));
}
}

View File

@ -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: 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> {
final MonitoringRepository _repository;
final NotificationBloc _notificationBloc;
StreamSubscription<MyWindSpeed>? _subscription;
WindSpeedBloc({required MonitoringRepository repository})
WindSpeedBloc(
{required MonitoringRepository repository,
required NotificationBloc notificationBloc})
: _repository = repository,
_notificationBloc = notificationBloc,
super(const WindSpeedState()) {
/// 🔥 START MONITORING
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(
history: history,
dailySpeeds: dailyGraph,
@ -101,7 +116,6 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
Emitter<WindSpeedState> emit,
) {
final updated = List<double>.from(state.dailySpeeds);
final index = DateTime.now().hour;
double newValue = event.data.speed;
@ -116,10 +130,11 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
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<WindSpeedEvent, WindSpeedState> {
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(),
),
));
}
}
/// =========================