oke oke
This commit is contained in:
parent
f5f177f554
commit
a60c6af54b
14
lib/app.dart
14
lib/app.dart
|
|
@ -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;
|
||||||
|
|
@ -19,11 +20,20 @@ class MyApp extends StatelessWidget {
|
||||||
RepositoryProvider<MonitoringRepository>.value(
|
RepositoryProvider<MonitoringRepository>.value(
|
||||||
value: monitoringRepository),
|
value: monitoringRepository),
|
||||||
],
|
],
|
||||||
child: BlocProvider<AuthenticationBloc>(
|
child: MultiBlocProvider(
|
||||||
|
providers: [
|
||||||
|
BlocProvider<AuthenticationBloc>(
|
||||||
create: (context) => AuthenticationBloc(
|
create: (context) => AuthenticationBloc(
|
||||||
userRepository: userRepository,
|
userRepository: userRepository,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
BlocProvider<NotificationBloc>(
|
||||||
|
// ← tambahan ini
|
||||||
|
create: (_) => NotificationBloc(),
|
||||||
|
),
|
||||||
|
],
|
||||||
child: const MyAppView(),
|
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,74 +1,124 @@
|
||||||
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(
|
||||||
|
// Tap di luar panel → tutup
|
||||||
|
onTap: () {
|
||||||
|
if (_showNotifications) _toggleNotifications();
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
drawer: const MainDrawer(),
|
drawer: const MainDrawer(),
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
elevation: 0.5, // Kasih sedikit bayangan tipis agar elegan
|
elevation: 0.5,
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
leading: Builder(
|
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(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
// Ganti dengan Image.asset jika sudah ada logo
|
|
||||||
Image.asset(
|
Image.asset(
|
||||||
'images/logo_klimatologi.png',
|
'images/logo_klimatologi.png',
|
||||||
height: 90,
|
height: 90,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
ValueListenableBuilder<bool>(
|
// Icon lonceng dengan badge
|
||||||
valueListenable: hasWeatherAlert,
|
BlocBuilder<NotificationBloc, NotificationState>(
|
||||||
builder: (context, hasAlert, child) {
|
buildWhen: (prev, cur) =>
|
||||||
|
prev.unreadCount != cur.unreadCount ||
|
||||||
|
prev.hasActiveAlerts != cur.hasActiveAlerts,
|
||||||
|
builder: (context, state) {
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.notifications_none,
|
icon: Icon(
|
||||||
color: Colors.black),
|
_showNotifications
|
||||||
onPressed: () {
|
? Icons.notifications
|
||||||
// Aksi notifikasi
|
: Icons.notifications_none,
|
||||||
if (hasAlert) {
|
color: Colors.black,
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(weatherAlertMessage.value),
|
|
||||||
backgroundColor: Colors.orange.shade800,
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
),
|
),
|
||||||
);
|
onPressed: _toggleNotifications,
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
if (hasAlert)
|
if (state.unreadCount > 0)
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 8,
|
right: 6,
|
||||||
top: 8,
|
top: 6,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 10,
|
padding: const EdgeInsets.all(3),
|
||||||
height: 10,
|
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: Colors.red,
|
color: Colors.red,
|
||||||
shape: BoxShape.circle,
|
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(
|
IconButton(
|
||||||
icon: const Icon(Icons.logout, color: Colors.black),
|
icon: const Icon(Icons.logout, color: Colors.black),
|
||||||
onPressed: () {
|
onPressed: () => context
|
||||||
// Trigger Logout di AuthenticationBloc kamu
|
|
||||||
context
|
|
||||||
.read<AuthenticationBloc>()
|
.read<AuthenticationBloc>()
|
||||||
.add(AuthenticationLogoutRequested());
|
.add(AuthenticationLogoutRequested()),
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: const Center(
|
body: Stack(
|
||||||
child: Text("body home page"),
|
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/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(),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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 '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(),
|
||||||
|
),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: 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> {
|
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(),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// =========================
|
/// =========================
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue