Merge branch 'branch_percobaan'
This commit is contained in:
commit
70518ad268
|
|
@ -0,0 +1,11 @@
|
||||||
|
# TODO: Fitur Baru Evaporasi (Period Selector + Status + Notifikasi)
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
- [x] 1. Edit `evaporasi_state.dart` — tambahkan `weatherStatus` & `willRain`
|
||||||
|
- [x] 2. Edit `evaporasi_bloc.dart` — hitung status & update global notifier
|
||||||
|
- [x] 3. Create `notification_notifier.dart` — global ValueNotifier untuk alert
|
||||||
|
- [x] 4. Create `evaporasi_period_selector.dart` — tombol periode
|
||||||
|
- [x] 5. Edit `evaporasi_screen.dart` — tambahkan period selector & status card
|
||||||
|
- [x] 6. Edit `home_screen.dart` — badge merah di icon lonceng
|
||||||
|
- [x] 7. Verifikasi kompilasi
|
||||||
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
description: This file stores settings for Dart & Flutter DevTools.
|
||||||
|
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||||
|
extensions:
|
||||||
31
lib/app.dart
31
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;
|
||||||
|
|
@ -13,17 +14,27 @@ 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(),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// sadadda
|
||||||
|
|
|
||||||
|
|
@ -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,61 +1,205 @@
|
||||||
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 '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';
|
||||||
|
import '../../monitoring/wind_speed/blocs/wind_speed_bloc.dart';
|
||||||
|
import '../../monitoring/evaporasi/blocs/evaporasi_bloc.dart';
|
||||||
|
import '../../monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart';
|
||||||
|
import '../widgets/notification_panel.dart';
|
||||||
|
import '../widgets/sensor_grid.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 MultiBlocProvider(
|
||||||
drawer: const MainDrawer(),
|
providers: [
|
||||||
appBar: AppBar(
|
BlocProvider<WindSpeedBloc>(
|
||||||
backgroundColor: Colors.white,
|
create: (context) => WindSpeedBloc(
|
||||||
elevation: 0.5, // Kasih sedikit bayangan tipis agar elegan
|
repository: context.read<MonitoringRepository>(),
|
||||||
centerTitle: false,
|
notificationBloc: context.read<NotificationBloc>(),
|
||||||
leading: Builder(
|
)..add(WatchWindSpeedStarted()),
|
||||||
builder: (context) => IconButton(
|
),
|
||||||
icon: const Icon(Icons.menu,
|
BlocProvider<EvaporasiBloc>(
|
||||||
color: Colors.black), // Ikon garis 3 horizontal
|
create: (context) => EvaporasiBloc(
|
||||||
onPressed: () {
|
repository: context.read<MonitoringRepository>(),
|
||||||
// ini kodenya untuk membuka:
|
notificationBloc: context.read<NotificationBloc>(),
|
||||||
Scaffold.of(context).openDrawer();
|
)..add(WatchEvaporasiStarted()),
|
||||||
},
|
),
|
||||||
)),
|
BlocProvider<AtmosphericConditionsBloc>(
|
||||||
title: Row(
|
create: (context) => AtmosphericConditionsBloc(
|
||||||
children: [
|
repository: context.read<MonitoringRepository>(),
|
||||||
// Ganti dengan Image.asset jika sudah ada logo
|
)..add(WatchAtmosphericConditionsStarted()),
|
||||||
Image.asset(
|
),
|
||||||
|
],
|
||||||
|
child: GestureDetector(
|
||||||
|
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),
|
||||||
|
onPressed: () => Scaffold.of(context).openDrawer(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: 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: [
|
||||||
],
|
// 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.logout, color: Colors.black),
|
||||||
|
onPressed: () => context
|
||||||
|
.read<AuthenticationBloc>()
|
||||||
|
.add(AuthenticationLogoutRequested()),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
// Konten utama
|
||||||
|
const _HomeBody(),
|
||||||
|
// 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(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
),
|
||||||
IconButton(
|
);
|
||||||
icon: const Icon(Icons.notifications_none, color: Colors.black),
|
}
|
||||||
onPressed: () {
|
}
|
||||||
// Aksi notifikasi
|
|
||||||
},
|
class _HomeBody extends StatelessWidget {
|
||||||
),
|
const _HomeBody();
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.logout, color: Colors.black),
|
@override
|
||||||
onPressed: () {
|
Widget build(BuildContext context) {
|
||||||
// Trigger Logout di AuthenticationBloc kamu
|
return SingleChildScrollView(
|
||||||
context
|
padding: const EdgeInsets.all(16),
|
||||||
.read<AuthenticationBloc>()
|
child: Column(
|
||||||
.add(AuthenticationLogoutRequested());
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
},
|
children: [
|
||||||
),
|
Text(
|
||||||
const SizedBox(width: 8),
|
'Monitoring Realtime',
|
||||||
],
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
),
|
fontWeight: FontWeight.w600,
|
||||||
body: const Center(
|
color: Colors.grey.shade700,
|
||||||
child: Text("body home page"),
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const SensorGrid(),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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')}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import '../../monitoring/wind_speed/blocs/wind_speed_bloc.dart';
|
||||||
|
import '../../monitoring/evaporasi/blocs/evaporasi_bloc.dart';
|
||||||
|
import '../../monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart';
|
||||||
|
|
||||||
|
class SensorGrid extends StatelessWidget {
|
||||||
|
const SensorGrid({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
// Lebar card: 2 kolom dengan gap 12
|
||||||
|
final cardWidth = (constraints.maxWidth - 12) / 2;
|
||||||
|
|
||||||
|
return Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 12,
|
||||||
|
children: [
|
||||||
|
// --- ANEMOMETER ---
|
||||||
|
BlocBuilder<WindSpeedBloc, WindSpeedState>(
|
||||||
|
builder: (context, state) => SensorCard(
|
||||||
|
width: cardWidth,
|
||||||
|
icon: Icons.air,
|
||||||
|
iconColor: Colors.blue.shade600,
|
||||||
|
iconBgColor: Colors.blue.shade50,
|
||||||
|
label: 'Kecepatan Angin',
|
||||||
|
value: state.isLoading
|
||||||
|
? '—'
|
||||||
|
: state.currentSpeed.toStringAsFixed(1),
|
||||||
|
unit: 'm/s',
|
||||||
|
isLoading: state.isLoading,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// --- EVAPORASI ---
|
||||||
|
BlocBuilder<EvaporasiBloc, EvaporasiState>(
|
||||||
|
builder: (context, state) => SensorCard(
|
||||||
|
width: cardWidth,
|
||||||
|
icon: Icons.water_drop_outlined,
|
||||||
|
iconColor: Colors.teal.shade600,
|
||||||
|
iconBgColor: Colors.teal.shade50,
|
||||||
|
label: 'Evaporasi',
|
||||||
|
value: state.isLoading
|
||||||
|
? '—'
|
||||||
|
: state.currentValue.toStringAsFixed(1),
|
||||||
|
unit: 'mm',
|
||||||
|
isLoading: state.isLoading,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// --- TEKANAN UDARA ---
|
||||||
|
BlocBuilder<AtmosphericConditionsBloc, AtmosphericConditionsState>(
|
||||||
|
builder: (context, state) => SensorCard(
|
||||||
|
width: cardWidth,
|
||||||
|
icon: Icons.compress,
|
||||||
|
iconColor: Colors.purple.shade400,
|
||||||
|
iconBgColor: Colors.purple.shade50,
|
||||||
|
label: 'Tekanan Udara',
|
||||||
|
value:
|
||||||
|
state.isLoading ? '—' : state.pressure.toStringAsFixed(1),
|
||||||
|
unit: 'hPa',
|
||||||
|
isLoading: state.isLoading,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SensorCard extends StatelessWidget {
|
||||||
|
final double width;
|
||||||
|
final IconData icon;
|
||||||
|
final Color iconColor;
|
||||||
|
final Color iconBgColor;
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final String unit;
|
||||||
|
final bool isLoading;
|
||||||
|
|
||||||
|
const SensorCard({
|
||||||
|
super.key,
|
||||||
|
required this.width,
|
||||||
|
required this.icon,
|
||||||
|
required this.iconColor,
|
||||||
|
required this.iconBgColor,
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.unit,
|
||||||
|
this.isLoading = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
width: width,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Ikon
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: iconBgColor,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: iconColor, size: 20),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// Nilai + satuan
|
||||||
|
isLoading
|
||||||
|
? Container(
|
||||||
|
height: 24,
|
||||||
|
width: 60,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
height: 1,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
|
child: Text(
|
||||||
|
unit,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
// Label
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
import 'package:bloc/bloc.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
part 'device_setup_event.dart';
|
||||||
|
part 'device_setup_state.dart';
|
||||||
|
|
||||||
|
class DeviceSetupBloc extends Bloc<DeviceSetupEvent, DeviceSetupState> {
|
||||||
|
DeviceSetupBloc() : super(const DeviceSetupState()) {
|
||||||
|
on<CheckEspConnectionEvent>(_onCheckConnection);
|
||||||
|
on<SendWifiCredentialsEvent>(_onSendCredentials);
|
||||||
|
on<ResetDeviceSetupEvent>(_onReset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dio instance khusus untuk komunikasi ke ESP ──────────────
|
||||||
|
// Timeout pendek karena ESP di jaringan lokal, harusnya cepat.
|
||||||
|
// Kalau timeout → berarti HP belum konek ke hotspot ESP.
|
||||||
|
final Dio _dio = Dio(
|
||||||
|
BaseOptions(
|
||||||
|
connectTimeout: const Duration(seconds: 5),
|
||||||
|
receiveTimeout: const Duration(seconds: 10),
|
||||||
|
sendTimeout: const Duration(seconds: 5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Cek koneksi ke ESP ────────────────────────────────────────
|
||||||
|
Future<void> _onCheckConnection(
|
||||||
|
CheckEspConnectionEvent event,
|
||||||
|
Emitter<DeviceSetupState> emit,
|
||||||
|
) async {
|
||||||
|
emit(state.copyWith(status: DeviceSetupStatus.checkingConn));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ping endpoint root ESP — kalau respond berarti HP sudah
|
||||||
|
// terhubung ke hotspot ESP (192.168.4.1)
|
||||||
|
final response = await _dio.get(
|
||||||
|
'http://${state.espIp}/',
|
||||||
|
options: Options(
|
||||||
|
// Terima semua status code, yang penting server respond
|
||||||
|
validateStatus: (_) => true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != null) {
|
||||||
|
emit(state.copyWith(status: DeviceSetupStatus.connected));
|
||||||
|
} else {
|
||||||
|
emit(state.copyWith(
|
||||||
|
status: DeviceSetupStatus.notConnected,
|
||||||
|
errorMessage:
|
||||||
|
'ESP tidak merespons. Pastikan HP sudah terhubung ke hotspot "Anemometer-Setup".',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} on DioException catch (e) {
|
||||||
|
String msg = 'Tidak bisa terhubung ke ESP.';
|
||||||
|
if (e.type == DioExceptionType.connectionTimeout ||
|
||||||
|
e.type == DioExceptionType.receiveTimeout ||
|
||||||
|
e.type == DioExceptionType.sendTimeout) {
|
||||||
|
msg =
|
||||||
|
'Koneksi timeout. Pastikan HP terhubung ke hotspot "Anemometer-Setup" dan coba lagi.';
|
||||||
|
} else if (e.type == DioExceptionType.connectionError) {
|
||||||
|
msg =
|
||||||
|
'Tidak ada koneksi ke ESP. Sambungkan HP ke hotspot "Anemometer-Setup" terlebih dahulu.';
|
||||||
|
}
|
||||||
|
emit(state.copyWith(
|
||||||
|
status: DeviceSetupStatus.notConnected,
|
||||||
|
errorMessage: msg,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kirim SSID + Password ke ESP ─────────────────────────────
|
||||||
|
Future<void> _onSendCredentials(
|
||||||
|
SendWifiCredentialsEvent event,
|
||||||
|
Emitter<DeviceSetupState> emit,
|
||||||
|
) async {
|
||||||
|
if (event.ssid.trim().isEmpty) {
|
||||||
|
emit(state.copyWith(
|
||||||
|
status: DeviceSetupStatus.failure,
|
||||||
|
errorMessage: 'SSID tidak boleh kosong.',
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(state.copyWith(status: DeviceSetupStatus.sending));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Kirim sebagai form-urlencoded — sama persis dengan
|
||||||
|
// yang diterima WebServer ESP di route POST /save
|
||||||
|
final response = await _dio.post(
|
||||||
|
'http://${state.espIp}/save',
|
||||||
|
data: {
|
||||||
|
'ssid': event.ssid.trim(),
|
||||||
|
'pass': event.password,
|
||||||
|
},
|
||||||
|
options: Options(
|
||||||
|
contentType: Headers.formUrlEncodedContentType,
|
||||||
|
validateStatus: (status) => status != null && status < 500,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
emit(state.copyWith(
|
||||||
|
status: DeviceSetupStatus.success,
|
||||||
|
successMessage:
|
||||||
|
'Berhasil! ESP menyimpan WiFi "${event.ssid}" dan akan restart otomatis.\n\n'
|
||||||
|
'Tunggu beberapa detik, lalu sambungkan HP kembali ke WiFi normal.',
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
emit(state.copyWith(
|
||||||
|
status: DeviceSetupStatus.failure,
|
||||||
|
errorMessage:
|
||||||
|
'ESP menolak permintaan (${response.statusCode}): ${response.data}',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} on DioException catch (e) {
|
||||||
|
// ESP restart setelah terima kredensial → koneksi putus → itu normal!
|
||||||
|
// DioException di sini kemungkinan besar karena ESP sudah restart.
|
||||||
|
if (e.type == DioExceptionType.receiveTimeout ||
|
||||||
|
e.type == DioExceptionType.connectionError ||
|
||||||
|
e.type == DioExceptionType.sendTimeout) {
|
||||||
|
emit(state.copyWith(
|
||||||
|
status: DeviceSetupStatus.success,
|
||||||
|
successMessage:
|
||||||
|
'ESP menerima konfigurasi WiFi dan sedang restart.\n\n'
|
||||||
|
'Sambungkan HP kembali ke WiFi normal dalam beberapa detik.',
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
emit(state.copyWith(
|
||||||
|
status: DeviceSetupStatus.failure,
|
||||||
|
errorMessage: 'Gagal mengirim ke ESP: ${e.message}',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reset state ───────────────────────────────────────────────
|
||||||
|
void _onReset(
|
||||||
|
ResetDeviceSetupEvent event,
|
||||||
|
Emitter<DeviceSetupState> emit,
|
||||||
|
) {
|
||||||
|
emit(const DeviceSetupState());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
part of 'device_setup_bloc.dart';
|
||||||
|
|
||||||
|
abstract class DeviceSetupEvent {}
|
||||||
|
|
||||||
|
/// Cek apakah HP sedang terhubung ke hotspot ESP
|
||||||
|
class CheckEspConnectionEvent extends DeviceSetupEvent {}
|
||||||
|
|
||||||
|
/// Kirim SSID + password baru ke ESP via HTTP
|
||||||
|
class SendWifiCredentialsEvent extends DeviceSetupEvent {
|
||||||
|
final String ssid;
|
||||||
|
final String password;
|
||||||
|
|
||||||
|
SendWifiCredentialsEvent({required this.ssid, required this.password});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset state ke awal (misalnya saat user keluar screen)
|
||||||
|
class ResetDeviceSetupEvent extends DeviceSetupEvent {}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
part of 'device_setup_bloc.dart';
|
||||||
|
|
||||||
|
enum DeviceSetupStatus {
|
||||||
|
idle, // belum ada aksi
|
||||||
|
checkingConn, // sedang cek koneksi ke ESP
|
||||||
|
notConnected, // HP belum konek ke hotspot ESP
|
||||||
|
connected, // HP sudah konek ke hotspot ESP, siap kirim
|
||||||
|
sending, // sedang kirim SSID+password ke ESP
|
||||||
|
success, // ESP berhasil terima & akan restart
|
||||||
|
failure, // gagal (timeout, ESP tidak respond, dll)
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeviceSetupState {
|
||||||
|
final DeviceSetupStatus status;
|
||||||
|
final String? errorMessage;
|
||||||
|
final String? successMessage;
|
||||||
|
final String espIp;
|
||||||
|
|
||||||
|
const DeviceSetupState({
|
||||||
|
this.status = DeviceSetupStatus.idle,
|
||||||
|
this.errorMessage,
|
||||||
|
this.successMessage,
|
||||||
|
this.espIp = '192.168.4.1', // default IP ESP saat AP mode
|
||||||
|
});
|
||||||
|
|
||||||
|
DeviceSetupState copyWith({
|
||||||
|
DeviceSetupStatus? status,
|
||||||
|
String? errorMessage,
|
||||||
|
String? successMessage,
|
||||||
|
String? espIp,
|
||||||
|
}) {
|
||||||
|
return DeviceSetupState(
|
||||||
|
status: status ?? this.status,
|
||||||
|
errorMessage: errorMessage,
|
||||||
|
successMessage: successMessage,
|
||||||
|
espIp: espIp ?? this.espIp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,511 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import '../blocs/device_setup_bloc.dart';
|
||||||
|
import 'package:app_settings/app_settings.dart';
|
||||||
|
|
||||||
|
class DeviceSetupScreen extends StatefulWidget {
|
||||||
|
const DeviceSetupScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DeviceSetupScreen> createState() => _DeviceSetupScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DeviceSetupScreenState extends State<DeviceSetupScreen> {
|
||||||
|
final _ssidController = TextEditingController();
|
||||||
|
final _passwordController = TextEditingController();
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
bool _obscurePassword = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ssidController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocProvider(
|
||||||
|
create: (_) => DeviceSetupBloc(),
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Setup WiFi Anemometer'),
|
||||||
|
centerTitle: true,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
body: BlocConsumer<DeviceSetupBloc, DeviceSetupState>(
|
||||||
|
listener: (context, state) {
|
||||||
|
if (state.status == DeviceSetupStatus.success) {
|
||||||
|
_showResultDialog(context,
|
||||||
|
success: true, message: state.successMessage ?? '');
|
||||||
|
} else if (state.status == DeviceSetupStatus.failure) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(state.errorMessage ?? 'Terjadi kesalahan.'),
|
||||||
|
backgroundColor: Colors.red.shade700,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
builder: (context, state) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// ── Info Banner ───────────────────────────────
|
||||||
|
_InfoBanner(status: state.status),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// ── Step 1: Panduan konek hotspot ─────────────
|
||||||
|
_StepCard(
|
||||||
|
step: 1,
|
||||||
|
title: 'Nyalakan Perangkat Anemometer',
|
||||||
|
description:
|
||||||
|
'Pastikan ESP32 anemometer sudah menyala dan belum pernah '
|
||||||
|
'dikonfigurasi WiFi-nya. LED akan berkedip menandakan '
|
||||||
|
'mode hotspot aktif.',
|
||||||
|
isDone: state.status == DeviceSetupStatus.connected ||
|
||||||
|
state.status == DeviceSetupStatus.sending ||
|
||||||
|
state.status == DeviceSetupStatus.success,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
_StepCard(
|
||||||
|
step: 2,
|
||||||
|
title: 'Sambungkan HP ke Hotspot ESP',
|
||||||
|
description:
|
||||||
|
'Buka Pengaturan WiFi di HP kamu, lalu sambungkan ke:\n\n'
|
||||||
|
'📶 Anemometer-Setup\n\n'
|
||||||
|
'Hotspot ini tidak punya password. Setelah tersambung, '
|
||||||
|
'kembali ke aplikasi ini.',
|
||||||
|
isDone: state.status == DeviceSetupStatus.connected ||
|
||||||
|
state.status == DeviceSetupStatus.sending ||
|
||||||
|
state.status == DeviceSetupStatus.success,
|
||||||
|
trailing: _OpenWifiSettingsButton(status: state.status),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// ── Step 3: Cek koneksi ───────────────────────
|
||||||
|
_StepCard(
|
||||||
|
step: 3,
|
||||||
|
title: 'Verifikasi Koneksi ke ESP',
|
||||||
|
description: state.status == DeviceSetupStatus.notConnected
|
||||||
|
? state.errorMessage ?? 'Belum terhubung ke ESP.'
|
||||||
|
: state.status == DeviceSetupStatus.connected ||
|
||||||
|
state.status == DeviceSetupStatus.sending ||
|
||||||
|
state.status == DeviceSetupStatus.success
|
||||||
|
? '✅ HP sudah terhubung ke ESP. Lanjutkan ke langkah berikutnya.'
|
||||||
|
: 'Tekan tombol di bawah untuk memverifikasi koneksi ke ESP.',
|
||||||
|
isDone: state.status == DeviceSetupStatus.connected ||
|
||||||
|
state.status == DeviceSetupStatus.sending ||
|
||||||
|
state.status == DeviceSetupStatus.success,
|
||||||
|
trailing: state.status == DeviceSetupStatus.connected ||
|
||||||
|
state.status == DeviceSetupStatus.sending ||
|
||||||
|
state.status == DeviceSetupStatus.success
|
||||||
|
? null
|
||||||
|
: _CheckConnectionButton(state: state),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// ── Step 4: Isi form WiFi ─────────────────────
|
||||||
|
_StepCard(
|
||||||
|
step: 4,
|
||||||
|
title: 'Masukkan Kredensial WiFi',
|
||||||
|
description: 'Isi SSID dan password WiFi yang ingin '
|
||||||
|
'digunakan oleh anemometer.',
|
||||||
|
isDone: state.status == DeviceSetupStatus.success,
|
||||||
|
child: state.status == DeviceSetupStatus.connected ||
|
||||||
|
state.status == DeviceSetupStatus.sending
|
||||||
|
? _WifiForm(
|
||||||
|
ssidController: _ssidController,
|
||||||
|
passwordController: _passwordController,
|
||||||
|
formKey: _formKey,
|
||||||
|
obscurePassword: _obscurePassword,
|
||||||
|
onToggleObscure: () => setState(
|
||||||
|
() => _obscurePassword = !_obscurePassword),
|
||||||
|
onSubmit: state.status == DeviceSetupStatus.sending
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
if (_formKey.currentState!.validate()) {
|
||||||
|
context.read<DeviceSetupBloc>().add(
|
||||||
|
SendWifiCredentialsEvent(
|
||||||
|
ssid: _ssidController.text,
|
||||||
|
password:
|
||||||
|
_passwordController.text,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isLoading:
|
||||||
|
state.status == DeviceSetupStatus.sending,
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showResultDialog(
|
||||||
|
BuildContext context, {
|
||||||
|
required bool success,
|
||||||
|
required String message,
|
||||||
|
}) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
success ? Icons.check_circle_rounded : Icons.error_rounded,
|
||||||
|
color: success ? Colors.green : Colors.red,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(success ? 'Berhasil!' : 'Gagal'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: Text(message),
|
||||||
|
actions: [
|
||||||
|
if (!success) // ← tambah tombol Coba Lagi jika gagal
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(dialogContext).pop(); // tutup dialog
|
||||||
|
context.read<DeviceSetupBloc>().add(
|
||||||
|
ResetDeviceSetupEvent()); // kembali ke wind_speed_screen
|
||||||
|
},
|
||||||
|
child: const Text('Coba Lagi'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: Text(success ? 'Selesai' : 'Tutup'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════
|
||||||
|
// Widgets internal
|
||||||
|
// ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class _InfoBanner extends StatelessWidget {
|
||||||
|
final DeviceSetupStatus status;
|
||||||
|
const _InfoBanner({required this.status});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.wifi_tethering_rounded,
|
||||||
|
color: theme.colorScheme.primary, size: 28),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Setup WiFi Anemometer',
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Ikuti langkah-langkah berikut untuk mengkonfigurasi '
|
||||||
|
'koneksi WiFi pada perangkat anemometer.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StepCard extends StatelessWidget {
|
||||||
|
final int step;
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
final bool isDone;
|
||||||
|
final Widget? trailing;
|
||||||
|
final Widget? child;
|
||||||
|
|
||||||
|
const _StepCard({
|
||||||
|
required this.step,
|
||||||
|
required this.title,
|
||||||
|
required this.description,
|
||||||
|
this.isDone = false,
|
||||||
|
this.trailing,
|
||||||
|
this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
side: BorderSide(
|
||||||
|
color:
|
||||||
|
isDone ? Colors.green.shade300 : theme.colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Step badge
|
||||||
|
Container(
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: isDone
|
||||||
|
? Colors.green
|
||||||
|
: theme.colorScheme.primaryContainer,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: isDone
|
||||||
|
? const Icon(Icons.check, size: 16, color: Colors.white)
|
||||||
|
: Text(
|
||||||
|
'$step',
|
||||||
|
style: theme.textTheme.labelMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (trailing != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
trailing!,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 38),
|
||||||
|
child: Text(
|
||||||
|
description,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (child != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 38),
|
||||||
|
child: child!,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OpenWifiSettingsButton extends StatelessWidget {
|
||||||
|
final DeviceSetupStatus status;
|
||||||
|
const _OpenWifiSettingsButton({required this.status});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Tidak perlu tampil kalau sudah connected
|
||||||
|
if (status == DeviceSetupStatus.connected ||
|
||||||
|
status == DeviceSetupStatus.sending ||
|
||||||
|
status == DeviceSetupStatus.success) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
// Buka pengaturan WiFi sistem HP
|
||||||
|
// Butuh package: app_settings (opsional)
|
||||||
|
// AppSettings.openWIFISettings();
|
||||||
|
AppSettings.openAppSettings(type: AppSettingsType.wifi);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.settings_rounded, size: 16),
|
||||||
|
label: const Text('Buka Setting'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CheckConnectionButton extends StatelessWidget {
|
||||||
|
final DeviceSetupState state;
|
||||||
|
const _CheckConnectionButton({required this.state});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isChecking = state.status == DeviceSetupStatus.checkingConn;
|
||||||
|
return ElevatedButton.icon(
|
||||||
|
onPressed: isChecking
|
||||||
|
? null
|
||||||
|
: () =>
|
||||||
|
context.read<DeviceSetupBloc>().add(CheckEspConnectionEvent()),
|
||||||
|
icon: isChecking
|
||||||
|
? const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.wifi_find_rounded, size: 16),
|
||||||
|
label: Text(isChecking ? 'Mengecek...' : 'Cek Koneksi'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||||
|
textStyle: const TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WifiForm extends StatelessWidget {
|
||||||
|
final TextEditingController ssidController;
|
||||||
|
final TextEditingController passwordController;
|
||||||
|
final GlobalKey<FormState> formKey;
|
||||||
|
final bool obscurePassword;
|
||||||
|
final VoidCallback onToggleObscure;
|
||||||
|
final VoidCallback? onSubmit;
|
||||||
|
final bool isLoading;
|
||||||
|
|
||||||
|
const _WifiForm({
|
||||||
|
required this.ssidController,
|
||||||
|
required this.passwordController,
|
||||||
|
required this.formKey,
|
||||||
|
required this.obscurePassword,
|
||||||
|
required this.onToggleObscure,
|
||||||
|
required this.onSubmit,
|
||||||
|
required this.isLoading,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Form(
|
||||||
|
key: formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// SSID
|
||||||
|
TextFormField(
|
||||||
|
controller: ssidController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Nama WiFi (SSID)',
|
||||||
|
hintText: 'Contoh: RumahKu_2.4GHz',
|
||||||
|
prefixIcon: const Icon(Icons.wifi_rounded),
|
||||||
|
border:
|
||||||
|
OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
validator: (v) => (v == null || v.trim().isEmpty)
|
||||||
|
? 'SSID tidak boleh kosong'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Password
|
||||||
|
TextFormField(
|
||||||
|
controller: passwordController,
|
||||||
|
obscureText: obscurePassword,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Password WiFi',
|
||||||
|
hintText: 'Kosongkan jika WiFi terbuka',
|
||||||
|
prefixIcon: const Icon(Icons.lock_rounded),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
obscurePassword
|
||||||
|
? Icons.visibility_rounded
|
||||||
|
: Icons.visibility_off_rounded,
|
||||||
|
),
|
||||||
|
onPressed: onToggleObscure,
|
||||||
|
),
|
||||||
|
border:
|
||||||
|
OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onFieldSubmitted: (_) => onSubmit?.call(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Submit button
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: onSubmit,
|
||||||
|
icon: isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2, color: Colors.white),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.send_rounded),
|
||||||
|
label: Text(
|
||||||
|
isLoading ? 'Mengirim ke ESP...' : 'Kirim ke Anemometer'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (isLoading) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
'⏳ Mengirim konfigurasi WiFi ke ESP...\n'
|
||||||
|
'Koneksi akan terputus sebentar saat ESP restart.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,16 +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 '../../../../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);
|
||||||
|
|
@ -37,25 +42,33 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
||||||
final dailyGraph = TimeSeriesMapper.toDaily(
|
final dailyGraph = TimeSeriesMapper.toDaily(
|
||||||
data: history,
|
data: history,
|
||||||
getTime: (e) => e.timestamp,
|
getTime: (e) => e.timestamp,
|
||||||
getValue: (e) => e.evaporasi, // ⚠️ sesuaikan nama field
|
getValue: (e) => e.evaporasi,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final dailyTempGraph = TimeSeriesMapper.toDaily(
|
||||||
|
data: history,
|
||||||
|
getTime: (e) => e.timestamp,
|
||||||
|
getValue: (e) => e.suhu,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Hitung status dari data terakhir history jika ada
|
||||||
|
final lastValue = history.isNotEmpty ? history.last.evaporasi : 0.0;
|
||||||
|
final (status, rain) = _computeWeatherStatus(lastValue);
|
||||||
|
_emitEvaporasiAlert(status, rain, lastValue);
|
||||||
|
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
history: history,
|
history: history,
|
||||||
dailyValues: dailyGraph,
|
dailyValues: dailyGraph,
|
||||||
|
dailyTemperatures: dailyTempGraph,
|
||||||
|
weatherStatus: status,
|
||||||
|
willRain: rain,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
));
|
));
|
||||||
|
|
||||||
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));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// =========================
|
/// =========================
|
||||||
|
|
@ -66,18 +79,25 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
||||||
Emitter<EvaporasiState> emit,
|
Emitter<EvaporasiState> emit,
|
||||||
) {
|
) {
|
||||||
final updated = List<double>.from(state.dailyValues);
|
final updated = List<double>.from(state.dailyValues);
|
||||||
|
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) {
|
||||||
updated[index] = event.data.evaporasi; // ⚠️ sesuaikan field
|
updated[index] = event.data.evaporasi;
|
||||||
|
updatedTemp[index] = event.data.suhu;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final (status, rain) = _computeWeatherStatus(event.data.evaporasi);
|
||||||
|
_emitEvaporasiAlert(status, rain, event.data.evaporasi);
|
||||||
|
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
currentValue: event.data.evaporasi,
|
currentValue: event.data.evaporasi,
|
||||||
temperature: event.data.suhu,
|
temperature: event.data.suhu,
|
||||||
waterLevel: event.data.tinggiAir,
|
waterLevel: event.data.tinggiAir,
|
||||||
dailyValues: updated,
|
dailyValues: updated,
|
||||||
|
dailyTemperatures: updatedTemp,
|
||||||
|
weatherStatus: status,
|
||||||
|
willRain: rain,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,10 +109,10 @@ 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;
|
||||||
|
List<double> updatedTemp;
|
||||||
|
|
||||||
if (event.period == "Minggu Ini") {
|
if (event.period == "Minggu Ini") {
|
||||||
updated = TimeSeriesMapper.toWeekly(
|
updated = TimeSeriesMapper.toWeekly(
|
||||||
|
|
@ -100,22 +120,39 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
||||||
getTime: (e) => e.timestamp,
|
getTime: (e) => e.timestamp,
|
||||||
getValue: (e) => e.evaporasi,
|
getValue: (e) => e.evaporasi,
|
||||||
);
|
);
|
||||||
|
updatedTemp = TimeSeriesMapper.toWeekly(
|
||||||
|
data: history,
|
||||||
|
getTime: (e) => e.timestamp,
|
||||||
|
getValue: (e) => e.suhu,
|
||||||
|
);
|
||||||
} else if (event.period == "Bulan Ini") {
|
} else if (event.period == "Bulan Ini") {
|
||||||
updated = TimeSeriesMapper.toMonthly(
|
updated = TimeSeriesMapper.toMonthly(
|
||||||
data: history,
|
data: history,
|
||||||
getTime: (e) => e.timestamp,
|
getTime: (e) => e.timestamp,
|
||||||
getValue: (e) => e.evaporasi,
|
getValue: (e) => e.evaporasi,
|
||||||
);
|
);
|
||||||
|
updatedTemp = TimeSeriesMapper.toMonthly(
|
||||||
|
data: history,
|
||||||
|
getTime: (e) => e.timestamp,
|
||||||
|
getValue: (e) => e.suhu,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
updated = TimeSeriesMapper.toDaily(
|
updated = TimeSeriesMapper.toDaily(
|
||||||
data: history,
|
data: history,
|
||||||
getTime: (e) => e.timestamp,
|
getTime: (e) => e.timestamp,
|
||||||
getValue: (e) => e.evaporasi,
|
getValue: (e) => e.evaporasi,
|
||||||
);
|
);
|
||||||
|
updatedTemp = TimeSeriesMapper.toDaily(
|
||||||
|
data: history,
|
||||||
|
getTime: (e) => e.timestamp,
|
||||||
|
getValue: (e) => e.suhu,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pertahankan status cuaca saat ini (tidak berubah karena hanya ganti periode)
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
dailyValues: updated,
|
dailyValues: updated,
|
||||||
|
dailyTemperatures: updatedTemp,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -125,6 +162,43 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
||||||
await _subscription?.cancel();
|
await _subscription?.cancel();
|
||||||
return super.close();
|
return super.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// =========================
|
||||||
|
/// 🌤️ HELPER: Status Cuaca
|
||||||
|
/// =========================
|
||||||
|
static (String status, bool willRain) _computeWeatherStatus(double value) {
|
||||||
|
if (value <= 5.0) return ('Baik', false);
|
||||||
|
if (value <= 10.0) return ('Sedang', true);
|
||||||
|
return ('Buruk', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
severity = AlertSeverity.info; // normal → bersihkan alert
|
||||||
|
message = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
_notificationBloc.add(SensorAlertAdded(
|
||||||
|
SensorAlert(
|
||||||
|
sensorId: 'evaporasi',
|
||||||
|
sensorName: 'Evaporasi',
|
||||||
|
message: message,
|
||||||
|
severity: severity,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// INTERNAL EVENT
|
/// INTERNAL EVENT
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,13 @@ class EvaporasiState extends Equatable {
|
||||||
final double waterLevel; // tinggi air
|
final double waterLevel; // tinggi air
|
||||||
final String selectedPeriod;
|
final String selectedPeriod;
|
||||||
|
|
||||||
final List<double> dailyValues; // untuk grafik
|
final List<double> dailyValues; // untuk grafik evaporasi
|
||||||
|
final List<double> dailyTemperatures; // untuk grafik suhu
|
||||||
final List<Evaporasi> history;
|
final List<Evaporasi> history;
|
||||||
|
|
||||||
|
final String weatherStatus; // Baik / Sedang / Buruk
|
||||||
|
final bool willRain; // true jika status Sedang/Buruk
|
||||||
|
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
|
|
||||||
const EvaporasiState({
|
const EvaporasiState({
|
||||||
|
|
@ -17,7 +21,10 @@ class EvaporasiState extends Equatable {
|
||||||
this.waterLevel = 0.0,
|
this.waterLevel = 0.0,
|
||||||
this.selectedPeriod = "Hari Ini",
|
this.selectedPeriod = "Hari Ini",
|
||||||
this.dailyValues = const [],
|
this.dailyValues = const [],
|
||||||
|
this.dailyTemperatures = const [],
|
||||||
this.history = const [],
|
this.history = const [],
|
||||||
|
this.weatherStatus = "Baik",
|
||||||
|
this.willRain = false,
|
||||||
this.isLoading = true,
|
this.isLoading = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -27,7 +34,10 @@ class EvaporasiState extends Equatable {
|
||||||
double? waterLevel,
|
double? waterLevel,
|
||||||
String? selectedPeriod,
|
String? selectedPeriod,
|
||||||
List<double>? dailyValues,
|
List<double>? dailyValues,
|
||||||
|
List<double>? dailyTemperatures,
|
||||||
List<Evaporasi>? history,
|
List<Evaporasi>? history,
|
||||||
|
String? weatherStatus,
|
||||||
|
bool? willRain,
|
||||||
bool? isLoading,
|
bool? isLoading,
|
||||||
}) {
|
}) {
|
||||||
return EvaporasiState(
|
return EvaporasiState(
|
||||||
|
|
@ -36,7 +46,10 @@ class EvaporasiState extends Equatable {
|
||||||
waterLevel: waterLevel ?? this.waterLevel,
|
waterLevel: waterLevel ?? this.waterLevel,
|
||||||
selectedPeriod: selectedPeriod ?? this.selectedPeriod,
|
selectedPeriod: selectedPeriod ?? this.selectedPeriod,
|
||||||
dailyValues: dailyValues ?? this.dailyValues,
|
dailyValues: dailyValues ?? this.dailyValues,
|
||||||
|
dailyTemperatures: dailyTemperatures ?? this.dailyTemperatures,
|
||||||
history: history ?? this.history,
|
history: history ?? this.history,
|
||||||
|
weatherStatus: weatherStatus ?? this.weatherStatus,
|
||||||
|
willRain: willRain ?? this.willRain,
|
||||||
isLoading: isLoading ?? this.isLoading,
|
isLoading: isLoading ?? this.isLoading,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -48,7 +61,10 @@ class EvaporasiState extends Equatable {
|
||||||
waterLevel,
|
waterLevel,
|
||||||
selectedPeriod,
|
selectedPeriod,
|
||||||
dailyValues,
|
dailyValues,
|
||||||
|
dailyTemperatures,
|
||||||
history,
|
history,
|
||||||
|
weatherStatus,
|
||||||
|
willRain,
|
||||||
isLoading,
|
isLoading,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../blocs/evaporasi_bloc.dart';
|
import '../blocs/evaporasi_bloc.dart';
|
||||||
|
import 'widgets/evaporasi_chart_widget.dart';
|
||||||
|
import 'widgets/evaporasi_period_selector.dart';
|
||||||
import '../../shared/utils/pdf/pdf_export_service.dart';
|
import '../../shared/utils/pdf/pdf_export_service.dart';
|
||||||
import '../../shared/widgets/export_pdf_button.dart';
|
import '../../shared/widgets/export_pdf_button.dart';
|
||||||
|
|
||||||
|
|
@ -44,7 +46,19 @@ class EvaporasiScreen extends StatelessWidget {
|
||||||
const SizedBox(height: 25),
|
const SizedBox(height: 25),
|
||||||
_infoRow(state),
|
_infoRow(state),
|
||||||
const SizedBox(height: 25),
|
const SizedBox(height: 25),
|
||||||
_trendSection(),
|
_statusCard(state),
|
||||||
|
const SizedBox(height: 25),
|
||||||
|
const Text("Tren Evaporasi & Suhu",
|
||||||
|
style:
|
||||||
|
TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 15),
|
||||||
|
const EvaporasiPeriodSelector(),
|
||||||
|
const SizedBox(height: 15),
|
||||||
|
EvaporasiChartWidget(
|
||||||
|
dailyValues: state.dailyValues,
|
||||||
|
dailyTemperatures: state.dailyTemperatures,
|
||||||
|
period: state.selectedPeriod,
|
||||||
|
),
|
||||||
const SizedBox(height: 25),
|
const SizedBox(height: 25),
|
||||||
ExportPdfButton(
|
ExportPdfButton(
|
||||||
onExport: () => PdfExportService.evaporasi(
|
onExport: () => PdfExportService.evaporasi(
|
||||||
|
|
@ -146,21 +160,70 @@ class EvaporasiScreen extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// =========================
|
/// =========================
|
||||||
/// 📈 TREND (SIMPLE PLACEHOLDER)
|
/// 🌤️ STATUS CARD
|
||||||
/// =========================
|
/// =========================
|
||||||
Widget _trendSection() {
|
Widget _statusCard(EvaporasiState state) {
|
||||||
|
Color statusColor;
|
||||||
|
IconData statusIcon;
|
||||||
|
|
||||||
|
switch (state.weatherStatus) {
|
||||||
|
case "Sedang":
|
||||||
|
statusColor = Colors.orange;
|
||||||
|
statusIcon = Icons.warning_amber_rounded;
|
||||||
|
break;
|
||||||
|
case "Buruk":
|
||||||
|
statusColor = Colors.red;
|
||||||
|
statusIcon = Icons.error_outline;
|
||||||
|
break;
|
||||||
|
case "Baik":
|
||||||
|
default:
|
||||||
|
statusColor = Colors.green;
|
||||||
|
statusIcon = Icons.check_circle_outline;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 200,
|
padding: const EdgeInsets.all(20),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: statusColor.withOpacity(0.3), width: 1.5),
|
||||||
),
|
),
|
||||||
child: const Center(
|
child: Row(
|
||||||
child: Text(
|
children: [
|
||||||
"Grafik Evaporasi",
|
Icon(statusIcon, color: statusColor, size: 40),
|
||||||
style: TextStyle(color: Colors.grey),
|
const SizedBox(width: 15),
|
||||||
),
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
"Status Cuaca",
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
state.weatherStatus,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: statusColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (state.willRain)
|
||||||
|
const Text(
|
||||||
|
"⚠️ Potensi hujan tinggi",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.red,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,250 @@
|
||||||
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class EvaporasiChartWidget extends StatelessWidget {
|
||||||
|
final List<double> dailyValues;
|
||||||
|
final List<double> dailyTemperatures;
|
||||||
|
final String period;
|
||||||
|
|
||||||
|
const EvaporasiChartWidget({
|
||||||
|
super.key,
|
||||||
|
required this.dailyValues,
|
||||||
|
required this.dailyTemperatures,
|
||||||
|
required this.period,
|
||||||
|
});
|
||||||
|
|
||||||
|
double _getMaxY(List<double> data) {
|
||||||
|
if (data.isEmpty) return 10;
|
||||||
|
final max = data.reduce((a, b) => a > b ? a : b);
|
||||||
|
return (max + 5).clamp(10, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<double> _safeData(List<double> data) {
|
||||||
|
return data.map((e) {
|
||||||
|
if (e.isNaN || e.isInfinite) return 0.0;
|
||||||
|
return e < 0 ? 0.0 : e;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final safeValues = _safeData(dailyValues);
|
||||||
|
final safeTemps = _safeData(dailyTemperatures);
|
||||||
|
|
||||||
|
// Gunakan max dari kedua dataset agar skala Y sesuai
|
||||||
|
final maxY = _getMaxY([
|
||||||
|
...safeValues,
|
||||||
|
...safeTemps,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
height: 280,
|
||||||
|
padding: const EdgeInsets.fromLTRB(10, 20, 20, 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(25),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 5),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Legend
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
_legendItem(Colors.blue.shade700, "Evaporasi (mm)"),
|
||||||
|
const SizedBox(width: 20),
|
||||||
|
_legendItem(Colors.orange.shade600, "Suhu (°C)"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Expanded(
|
||||||
|
child: LineChart(
|
||||||
|
duration: const Duration(milliseconds: 600),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
LineChartData(
|
||||||
|
minY: 0,
|
||||||
|
maxY: maxY,
|
||||||
|
gridData: const FlGridData(show: false),
|
||||||
|
borderData: FlBorderData(show: false),
|
||||||
|
titlesData: FlTitlesData(
|
||||||
|
show: true,
|
||||||
|
rightTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false)),
|
||||||
|
topTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false)),
|
||||||
|
leftTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false)),
|
||||||
|
bottomTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
reservedSize: 30,
|
||||||
|
interval: _getInterval(),
|
||||||
|
getTitlesWidget: (value, meta) {
|
||||||
|
return SideTitleWidget(
|
||||||
|
meta: meta,
|
||||||
|
space: 8,
|
||||||
|
child: Text(
|
||||||
|
_getBottomTitle(value),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.grey, fontSize: 10),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
lineBarsData: [
|
||||||
|
// === Garis Evaporasi ===
|
||||||
|
LineChartBarData(
|
||||||
|
spots: safeValues.asMap().entries.map((e) {
|
||||||
|
return FlSpot(e.key.toDouble(), e.value);
|
||||||
|
}).toList(),
|
||||||
|
isCurved: true,
|
||||||
|
curveSmoothness: 0.2,
|
||||||
|
preventCurveOverShooting: true,
|
||||||
|
color: Colors.blue.shade700,
|
||||||
|
barWidth: 3,
|
||||||
|
isStrokeCapRound: true,
|
||||||
|
dotData: FlDotData(
|
||||||
|
show: true,
|
||||||
|
getDotPainter: (spot, percent, bar, index) {
|
||||||
|
if (index == safeValues.length - 1) {
|
||||||
|
return FlDotCirclePainter(
|
||||||
|
radius: 5,
|
||||||
|
color: Colors.blue.shade900,
|
||||||
|
strokeWidth: 2,
|
||||||
|
strokeColor: Colors.white,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return FlDotCirclePainter(radius: 0);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
belowBarData: BarAreaData(
|
||||||
|
show: true,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Colors.blue.withOpacity(0.25),
|
||||||
|
Colors.blue.withOpacity(0.05),
|
||||||
|
Colors.transparent
|
||||||
|
],
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// === Garis Suhu ===
|
||||||
|
LineChartBarData(
|
||||||
|
spots: safeTemps.asMap().entries.map((e) {
|
||||||
|
return FlSpot(e.key.toDouble(), e.value);
|
||||||
|
}).toList(),
|
||||||
|
isCurved: true,
|
||||||
|
curveSmoothness: 0.2,
|
||||||
|
preventCurveOverShooting: true,
|
||||||
|
color: Colors.orange.shade600,
|
||||||
|
barWidth: 3,
|
||||||
|
isStrokeCapRound: true,
|
||||||
|
dotData: FlDotData(
|
||||||
|
show: true,
|
||||||
|
getDotPainter: (spot, percent, bar, index) {
|
||||||
|
if (index == safeTemps.length - 1) {
|
||||||
|
return FlDotCirclePainter(
|
||||||
|
radius: 5,
|
||||||
|
color: Colors.orange.shade800,
|
||||||
|
strokeWidth: 2,
|
||||||
|
strokeColor: Colors.white,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return FlDotCirclePainter(radius: 0);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
belowBarData: BarAreaData(
|
||||||
|
show: true,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Colors.orange.withOpacity(0.25),
|
||||||
|
Colors.orange.withOpacity(0.05),
|
||||||
|
Colors.transparent
|
||||||
|
],
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
lineTouchData: LineTouchData(
|
||||||
|
handleBuiltInTouches: true,
|
||||||
|
touchTooltipData: LineTouchTooltipData(
|
||||||
|
getTooltipItems: (touchedSpots) {
|
||||||
|
return touchedSpots.map((spot) {
|
||||||
|
final isEvaporasi = spot.barIndex == 0;
|
||||||
|
final label = isEvaporasi ? "Evaporasi" : "Suhu";
|
||||||
|
final unit = isEvaporasi ? "mm" : "°C";
|
||||||
|
return LineTooltipItem(
|
||||||
|
"$label: ${spot.y.toStringAsFixed(1)} $unit",
|
||||||
|
TextStyle(
|
||||||
|
color: isEvaporasi
|
||||||
|
? Colors.blue.shade100
|
||||||
|
: Colors.orange.shade100,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _legendItem(Color color, String label) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getBottomTitle(double value) {
|
||||||
|
int index = value.toInt();
|
||||||
|
final len = dailyValues.length;
|
||||||
|
if (index < 0 || index >= len) return '';
|
||||||
|
|
||||||
|
if (period == "Hari Ini") {
|
||||||
|
return index % 4 == 0 ? "${index.toString().padLeft(2, '0')}:00" : "";
|
||||||
|
} else if (period == "Minggu Ini") {
|
||||||
|
const days = ["Sen", "Sel", "Rab", "Kam", "Jum", "Sab", "Min"];
|
||||||
|
return days[index % 7];
|
||||||
|
} else {
|
||||||
|
return (index + 1) % 5 == 0 ? "${index + 1}" : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double _getInterval() {
|
||||||
|
if (period == "Hari Ini") return 1;
|
||||||
|
if (period == "Minggu Ini") return 1;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import '../../blocs/evaporasi_bloc.dart';
|
||||||
|
|
||||||
|
class EvaporasiPeriodSelector extends StatelessWidget {
|
||||||
|
const EvaporasiPeriodSelector({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final selectedPeriod =
|
||||||
|
context.select((EvaporasiBloc bloc) => bloc.state.selectedPeriod);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(15),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_buildTab(context, "Hari Ini", selectedPeriod),
|
||||||
|
_buildTab(context, "Minggu Ini", selectedPeriod),
|
||||||
|
_buildTab(context, "Bulan Ini", selectedPeriod),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTab(BuildContext context, String label, String current) {
|
||||||
|
bool isActive = label == current;
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
context.read<EvaporasiBloc>().add(EvaporasiPeriodChanged(label));
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isActive ? Colors.blue : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isActive ? Colors.white : Colors.grey,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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 = 8.0; // Waspada: 8–12.5 m/s (~29–45 km/h)
|
||||||
|
const double _kWindDanger = 12.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());
|
||||||
|
|
@ -40,15 +50,8 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
|
||||||
(json) => MyWindSpeed.fromJson(json),
|
(json) => MyWindSpeed.fromJson(json),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// 2. Mapping default (harian)
|
// ✅ Ini saja yang benar
|
||||||
final raw = TimeSeriesMapper.toDaily(
|
final dailyGraph = TimeSeriesMapper.smooth(
|
||||||
data: history,
|
|
||||||
getTime: (e) => e.timestamp,
|
|
||||||
getValue: (e) => e.speed,
|
|
||||||
);
|
|
||||||
final dailyGraph = TimeSeriesMapper.smooth(raw);
|
|
||||||
|
|
||||||
final daily = TimeSeriesMapper.smooth(
|
|
||||||
TimeSeriesMapper.toDaily(
|
TimeSeriesMapper.toDaily(
|
||||||
data: history,
|
data: history,
|
||||||
getTime: (e) => e.timestamp,
|
getTime: (e) => e.timestamp,
|
||||||
|
|
@ -72,12 +75,20 @@ 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,
|
||||||
weeklySpeeds: weekly,
|
weeklySpeeds: weekly,
|
||||||
monthlySpeeds: monthly,
|
monthlySpeeds: monthly,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
alertLevel: history.isNotEmpty // ← tambah ini
|
||||||
|
? _getAlertLevel(history.last.speed)
|
||||||
|
: "Normal",
|
||||||
));
|
));
|
||||||
|
|
||||||
/// 3. Start realtime stream (manual subscription)
|
/// 3. Start realtime stream (manual subscription)
|
||||||
|
|
@ -101,7 +112,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,13 +126,15 @@ 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,
|
||||||
|
alertLevel: _getAlertLevel(newValue),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -178,6 +190,41 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
|
||||||
await _subscription?.cancel();
|
await _subscription?.cancel();
|
||||||
return super.close();
|
return super.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _getAlertLevel(double speed) {
|
||||||
|
if (speed >= _kWindDanger) return "Bahaya";
|
||||||
|
if (speed >= _kWindWarning) return "Waspada";
|
||||||
|
return "Normal";
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// 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(),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// =========================
|
/// =========================
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,3 @@ class WindSpeedPeriodChanged extends WindSpeedEvent {
|
||||||
final String period;
|
final String period;
|
||||||
const WindSpeedPeriodChanged(this.period);
|
const WindSpeedPeriodChanged(this.period);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event internal: saat data baru masuk dari Firebase
|
|
||||||
class _WindSpeedUpdated extends WindSpeedEvent {
|
|
||||||
final MyWindSpeed data;
|
|
||||||
const _WindSpeedUpdated(this.data);
|
|
||||||
@override
|
|
||||||
List<Object> get props => [data];
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ class WindSpeedState extends Equatable {
|
||||||
final List<double> monthlySpeeds;
|
final List<double> monthlySpeeds;
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
final List<MyWindSpeed> history;
|
final List<MyWindSpeed> history;
|
||||||
|
final String alertLevel; // "Normal" | "Waspada" | "Bahaya"
|
||||||
|
|
||||||
const WindSpeedState({
|
const WindSpeedState({
|
||||||
this.currentSpeed = 0.0,
|
this.currentSpeed = 0.0,
|
||||||
|
|
@ -17,6 +18,7 @@ class WindSpeedState extends Equatable {
|
||||||
this.history = const [],
|
this.history = const [],
|
||||||
this.monthlySpeeds = const [],
|
this.monthlySpeeds = const [],
|
||||||
this.weeklySpeeds = const [],
|
this.weeklySpeeds = const [],
|
||||||
|
this.alertLevel = "Normal",
|
||||||
});
|
});
|
||||||
|
|
||||||
WindSpeedState copyWith({
|
WindSpeedState copyWith({
|
||||||
|
|
@ -27,6 +29,7 @@ class WindSpeedState extends Equatable {
|
||||||
List<double>? monthlySpeeds,
|
List<double>? monthlySpeeds,
|
||||||
bool? isLoading,
|
bool? isLoading,
|
||||||
List<MyWindSpeed>? history,
|
List<MyWindSpeed>? history,
|
||||||
|
String? alertLevel,
|
||||||
}) {
|
}) {
|
||||||
return WindSpeedState(
|
return WindSpeedState(
|
||||||
currentSpeed: currentSpeed ?? this.currentSpeed,
|
currentSpeed: currentSpeed ?? this.currentSpeed,
|
||||||
|
|
@ -36,10 +39,19 @@ class WindSpeedState extends Equatable {
|
||||||
monthlySpeeds: monthlySpeeds ?? this.monthlySpeeds,
|
monthlySpeeds: monthlySpeeds ?? this.monthlySpeeds,
|
||||||
isLoading: isLoading ?? this.isLoading,
|
isLoading: isLoading ?? this.isLoading,
|
||||||
history: history ?? this.history,
|
history: history ?? this.history,
|
||||||
|
alertLevel: alertLevel ?? this.alertLevel,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object> get props =>
|
List<Object> get props => [
|
||||||
[currentSpeed, selectedPeriod, dailySpeeds, isLoading, history];
|
currentSpeed,
|
||||||
|
selectedPeriod,
|
||||||
|
dailySpeeds,
|
||||||
|
weeklySpeeds,
|
||||||
|
monthlySpeeds,
|
||||||
|
isLoading,
|
||||||
|
history,
|
||||||
|
alertLevel,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
// import 'package:fl_chart/fl_chart.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../blocs/wind_speed_bloc.dart';
|
import '../blocs/wind_speed_bloc.dart';
|
||||||
import './widgets/wind_speed_chart_widget.dart';
|
import './widgets/wind_speed_chart_widget.dart';
|
||||||
|
|
@ -7,6 +6,7 @@ import 'widgets/period_selector.dart';
|
||||||
|
|
||||||
import '../../shared/utils/pdf/pdf_export_service.dart';
|
import '../../shared/utils/pdf/pdf_export_service.dart';
|
||||||
import '../../shared/widgets/export_pdf_button.dart';
|
import '../../shared/widgets/export_pdf_button.dart';
|
||||||
|
import '../../device_setup/views/device_setup_screen.dart';
|
||||||
|
|
||||||
class WindSpeedScreen extends StatelessWidget {
|
class WindSpeedScreen extends StatelessWidget {
|
||||||
const WindSpeedScreen({super.key});
|
const WindSpeedScreen({super.key});
|
||||||
|
|
@ -24,6 +24,20 @@ class WindSpeedScreen extends StatelessWidget {
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
foregroundColor: Colors.black,
|
foregroundColor: Colors.black,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Pengaturan Perangkat',
|
||||||
|
icon: const Icon(Icons.settings_rounded),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const DeviceSetupScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
/// === Body area, lokasi dan tata letak Widgets === ///
|
/// === Body area, lokasi dan tata letak Widgets === ///
|
||||||
|
|
@ -40,16 +54,6 @@ class WindSpeedScreen extends StatelessWidget {
|
||||||
_ => state.dailySpeeds,
|
_ => state.dailySpeeds,
|
||||||
};
|
};
|
||||||
|
|
||||||
// List<double> data;
|
|
||||||
|
|
||||||
// if (state.selectedPeriod == "Minggu Ini") {
|
|
||||||
// data = state.weeklySpeeds;
|
|
||||||
// } else if (state.selectedPeriod == "Bulan Ini") {
|
|
||||||
// data = state.monthlySpeeds;
|
|
||||||
// } else {
|
|
||||||
// data = state.dailySpeeds;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Konversi history MyWindSpeed → Map (untuk tabel PDF)
|
// Konversi history MyWindSpeed → Map (untuk tabel PDF)
|
||||||
final historyMaps = state.history
|
final historyMaps = state.history
|
||||||
.map((e) => {
|
.map((e) => {
|
||||||
|
|
@ -81,7 +85,7 @@ class WindSpeedScreen extends StatelessWidget {
|
||||||
const SizedBox(height: 30),
|
const SizedBox(height: 30),
|
||||||
|
|
||||||
// 3. Info Tambahan (Status/Periode)
|
// 3. Info Tambahan (Status/Periode)
|
||||||
_buildDetailRow(state.selectedPeriod),
|
_buildDetailRow(state.selectedPeriod, state.alertLevel),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// ← Tombol Export PDF
|
// ← Tombol Export PDF
|
||||||
|
|
@ -115,7 +119,7 @@ class WindSpeedScreen extends StatelessWidget {
|
||||||
borderRadius: BorderRadius.circular(30),
|
borderRadius: BorderRadius.circular(30),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.blue.withOpacity(0.3),
|
color: Colors.blue.withValues(alpha: 0.3),
|
||||||
blurRadius: 20,
|
blurRadius: 20,
|
||||||
offset: const Offset(0, 10),
|
offset: const Offset(0, 10),
|
||||||
)
|
)
|
||||||
|
|
@ -139,11 +143,16 @@ class WindSpeedScreen extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDetailRow(String period) {
|
Widget _buildDetailRow(String period, String alertLevel) {
|
||||||
|
final (icon, color) = switch (alertLevel) {
|
||||||
|
"Bahaya" => (Icons.warning_rounded, Colors.red),
|
||||||
|
"Waspada" => (Icons.info_outline, Colors.orange),
|
||||||
|
_ => (Icons.check_circle, Colors.green),
|
||||||
|
};
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
_miniInfoCard("Status", "Normal", Icons.check_circle, Colors.green),
|
_miniInfoCard("Status", alertLevel, icon, color),
|
||||||
_miniInfoCard("Periode", period, Icons.timer, Colors.orange),
|
_miniInfoCard("Periode", period, Icons.timer, Colors.orange),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import app_settings
|
||||||
import cloud_firestore
|
import cloud_firestore
|
||||||
import firebase_auth
|
import firebase_auth
|
||||||
import firebase_core
|
import firebase_core
|
||||||
|
|
@ -13,6 +14,7 @@ import google_sign_in_ios
|
||||||
import printing
|
import printing
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
AppSettingsPlugin.register(with: registry.registrar(forPlugin: "AppSettingsPlugin"))
|
||||||
FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin"))
|
FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin"))
|
||||||
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
||||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||||
|
|
|
||||||
32
pubspec.lock
32
pubspec.lock
|
|
@ -9,6 +9,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.66"
|
version: "1.3.66"
|
||||||
|
app_settings:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: app_settings
|
||||||
|
sha256: "64d50e666fd96ae90301bf71205f05019286f940ad6f5fed3d1be19c6af7546a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.0"
|
||||||
archive:
|
archive:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -145,6 +153,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.8"
|
version: "1.0.8"
|
||||||
|
dio:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: dio
|
||||||
|
sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.9.2"
|
||||||
|
dio_web_adapter:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dio_web_adapter
|
||||||
|
sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
equatable:
|
equatable:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -472,6 +496,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
version: "1.17.0"
|
||||||
|
mime:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: mime
|
||||||
|
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.0"
|
||||||
monitoring_repository:
|
monitoring_repository:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,12 @@ version: 1.0.0+1
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.1.0
|
sdk: ^3.1.0
|
||||||
dependencies:
|
dependencies:
|
||||||
|
app_settings: ^7.0.0
|
||||||
bloc: ^8.1.0
|
bloc: ^8.1.0
|
||||||
bloc_concurrency: ^0.2.5
|
bloc_concurrency: ^0.2.5
|
||||||
cloud_firestore: ^6.1.1
|
cloud_firestore: ^6.1.1
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
|
dio: ^5.9.2
|
||||||
equatable: ^2.0.5
|
equatable: ^2.0.5
|
||||||
firebase_auth: ^6.1.3
|
firebase_auth: ^6.1.3
|
||||||
firebase_core: ^4.3.0
|
firebase_core: ^4.3.0
|
||||||
|
|
@ -22,11 +24,11 @@ dependencies:
|
||||||
intl: ^0.20.2
|
intl: ^0.20.2
|
||||||
monitoring_repository:
|
monitoring_repository:
|
||||||
path: packages/monitoring_repository
|
path: packages/monitoring_repository
|
||||||
|
pdf: ^3.11.1
|
||||||
|
printing: ^5.13.1
|
||||||
rxdart: ^0.27.7
|
rxdart: ^0.27.7
|
||||||
user_repository:
|
user_repository:
|
||||||
path: packages/user_repository
|
path: packages/user_repository
|
||||||
pdf: ^3.11.1
|
|
||||||
printing: ^5.13.1
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_lints: ^3.0.0
|
flutter_lints: ^3.0.0
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue