oke oke
This commit is contained in:
parent
f5f177f554
commit
a60c6af54b
30
lib/app.dart
30
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<UserRepository>.value(value: userRepository),
|
||||
RepositoryProvider<MonitoringRepository>.value(
|
||||
value: monitoringRepository),
|
||||
],
|
||||
child: MultiBlocProvider(
|
||||
providers: [
|
||||
// 1. Daftarkan UserRepository
|
||||
RepositoryProvider<UserRepository>.value(value: userRepository),
|
||||
RepositoryProvider<MonitoringRepository>.value(
|
||||
value: monitoringRepository),
|
||||
],
|
||||
child: BlocProvider<AuthenticationBloc>(
|
||||
create: (context) => AuthenticationBloc(
|
||||
userRepository: userRepository,
|
||||
BlocProvider<AuthenticationBloc>(
|
||||
create: (context) => AuthenticationBloc(
|
||||
userRepository: userRepository,
|
||||
),
|
||||
),
|
||||
child: const MyAppView(),
|
||||
));
|
||||
BlocProvider<NotificationBloc>(
|
||||
// ← tambahan ini
|
||||
create: (_) => NotificationBloc(),
|
||||
),
|
||||
],
|
||||
child: const MyAppView(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
|
||||
|
|
@ -1 +0,0 @@
|
|||
|
||||
|
|
@ -1 +0,0 @@
|
|||
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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>('');
|
||||
|
||||
|
|
@ -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<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(
|
||||
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<bool>(
|
||||
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<NotificationBloc, NotificationState>(
|
||||
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<AuthenticationBloc>()
|
||||
.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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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')}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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(),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// =========================
|
||||
|
|
|
|||
Loading…
Reference in New Issue