Merge branch 'branch_percobaan'

This commit is contained in:
kleponijo 2026-05-02 15:13:32 +07:00
commit 70518ad268
29 changed files with 2108 additions and 123 deletions

11
TODO.md Normal file
View File

@ -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

3
devtools_options.yaml Normal file
View File

@ -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:

View File

@ -4,6 +4,7 @@ import 'package:klimatologiot/app_view.dart';
import 'package:user_repository/user_repository.dart'; import 'package:user_repository/user_repository.dart';
import 'package:monitoring_repository/monitoring_repository.dart'; import 'package:monitoring_repository/monitoring_repository.dart';
import 'blocs/authentication_bloc/authentication_bloc.dart'; import 'blocs/authentication_bloc/authentication_bloc.dart';
import 'blocs/notification_bloc/notification_bloc.dart';
class MyApp extends StatelessWidget { class MyApp extends StatelessWidget {
final UserRepository userRepository; final UserRepository userRepository;
@ -19,11 +20,21 @@ class MyApp extends StatelessWidget {
RepositoryProvider<MonitoringRepository>.value( RepositoryProvider<MonitoringRepository>.value(
value: monitoringRepository), value: monitoringRepository),
], ],
child: BlocProvider<AuthenticationBloc>( child: MultiBlocProvider(
providers: [
BlocProvider<AuthenticationBloc>(
create: (context) => AuthenticationBloc( create: (context) => AuthenticationBloc(
userRepository: userRepository, userRepository: userRepository,
), ),
),
BlocProvider<NotificationBloc>(
// tambahan ini
create: (_) => NotificationBloc(),
),
],
child: const MyAppView(), child: const MyAppView(),
)); ),
);
} }
} }
// sadadda

View File

@ -0,0 +1,49 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
part 'notification_event.dart';
part 'notification_state.dart';
class NotificationBloc extends Bloc<NotificationEvent, NotificationState> {
NotificationBloc() : super(const NotificationState()) {
on<SensorAlertAdded>(_onAlertAdded);
on<NotificationsMarkedAsRead>(_onMarkedAsRead);
on<NotificationDismissed>(_onDismissed);
}
void _onAlertAdded(
SensorAlertAdded event,
Emitter<NotificationState> emit,
) {
final updated = Map<String, SensorAlert>.from(state._alertsBySensor);
if (event.alert.severity == AlertSeverity.info) {
// Sensor kembali normal hapus alert-nya
updated.remove(event.alert.sensorId);
} else {
// Ada peringatan tambah atau update entry
updated[event.alert.sensorId] = event.alert;
}
emit(state.copyWith(alertsBySensor: updated));
}
void _onMarkedAsRead(
NotificationsMarkedAsRead event,
Emitter<NotificationState> emit,
) {
final updated = state._alertsBySensor.map(
(key, alert) => MapEntry(key, alert.copyWith(isRead: true)),
);
emit(state.copyWith(alertsBySensor: updated));
}
void _onDismissed(
NotificationDismissed event,
Emitter<NotificationState> emit,
) {
final updated = Map<String, SensorAlert>.from(state._alertsBySensor)
..remove(event.sensorId);
emit(state.copyWith(alertsBySensor: updated));
}
}

View File

@ -0,0 +1,33 @@
part of 'notification_bloc.dart';
sealed class NotificationEvent extends Equatable {
const NotificationEvent();
@override
List<Object> get props => [];
}
/// Tambah atau update alert dari sebuah sensor
final class SensorAlertAdded extends NotificationEvent {
final SensorAlert alert;
const SensorAlertAdded(this.alert);
@override
List<Object> get props => [alert];
}
/// Tandai semua notifikasi sebagai sudah dibaca
final class NotificationsMarkedAsRead extends NotificationEvent {
const NotificationsMarkedAsRead();
}
/// Hapus alert spesifik (opsional, jika ingin dismiss satu per satu)
final class NotificationDismissed extends NotificationEvent {
final String sensorId;
const NotificationDismissed(this.sensorId);
@override
List<Object> get props => [sensorId];
}

View File

@ -0,0 +1,77 @@
part of 'notification_bloc.dart';
/// Tingkat keparahan alert
enum AlertSeverity { info, warning, danger }
/// Model satu alert dari satu sensor
final class SensorAlert extends Equatable {
final String sensorId; // unik per alat, misal 'wind_speed'
final String sensorName; // nama tampilan, misal 'Anemometer'
final String message; // pesan singkat
final AlertSeverity severity;
final DateTime timestamp;
final bool isRead;
const SensorAlert({
required this.sensorId,
required this.sensorName,
required this.message,
required this.severity,
required this.timestamp,
this.isRead = false,
});
SensorAlert copyWith({
String? sensorId,
String? sensorName,
String? message,
AlertSeverity? severity,
DateTime? timestamp,
bool? isRead,
}) {
return SensorAlert(
sensorId: sensorId ?? this.sensorId,
sensorName: sensorName ?? this.sensorName,
message: message ?? this.message,
severity: severity ?? this.severity,
timestamp: timestamp ?? this.timestamp,
isRead: isRead ?? this.isRead,
);
}
@override
List<Object> get props => [sensorId, message, severity, timestamp, isRead];
}
/// State utama NotificationBloc
final class NotificationState extends Equatable {
/// Hanya menyimpan SATU alert per sensor (key = sensorId).
/// Kalau sensor normal, entry-nya dihapus dari map.
final Map<String, SensorAlert> _alertsBySensor;
const NotificationState({
Map<String, SensorAlert> alertsBySensor = const {},
}) : _alertsBySensor = alertsBySensor;
/// Semua alert aktif, diurutkan: danger dulu, lalu warning, lalu info
List<SensorAlert> get activeAlerts {
final list = _alertsBySensor.values.toList();
list.sort((a, b) => b.severity.index.compareTo(a.severity.index));
return list;
}
int get unreadCount => _alertsBySensor.values.where((a) => !a.isRead).length;
bool get hasActiveAlerts => _alertsBySensor.isNotEmpty;
NotificationState copyWith({
Map<String, SensorAlert>? alertsBySensor,
}) {
return NotificationState(
alertsBySensor: alertsBySensor ?? _alertsBySensor,
);
}
@override
List<Object> get props => [_alertsBySensor];
}

View File

@ -1,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(
providers: [
BlocProvider<WindSpeedBloc>(
create: (context) => WindSpeedBloc(
repository: context.read<MonitoringRepository>(),
notificationBloc: context.read<NotificationBloc>(),
)..add(WatchWindSpeedStarted()),
),
BlocProvider<EvaporasiBloc>(
create: (context) => EvaporasiBloc(
repository: context.read<MonitoringRepository>(),
notificationBloc: context.read<NotificationBloc>(),
)..add(WatchEvaporasiStarted()),
),
BlocProvider<AtmosphericConditionsBloc>(
create: (context) => AtmosphericConditionsBloc(
repository: context.read<MonitoringRepository>(),
)..add(WatchAtmosphericConditionsStarted()),
),
],
child: GestureDetector(
onTap: () {
if (_showNotifications) _toggleNotifications();
},
child: Scaffold(
drawer: const MainDrawer(), drawer: const MainDrawer(),
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.white, backgroundColor: Colors.white,
elevation: 0.5, // Kasih sedikit bayangan tipis agar elegan elevation: 0.5,
centerTitle: false, centerTitle: false,
leading: Builder( leading: Builder(
builder: (context) => IconButton( builder: (context) => IconButton(
icon: const Icon(Icons.menu, icon: const Icon(Icons.menu, color: Colors.black),
color: Colors.black), // Ikon garis 3 horizontal onPressed: () => Scaffold.of(context).openDrawer(),
onPressed: () { ),
// ini kodenya untuk membuka: ),
Scaffold.of(context).openDrawer(); title: Image.asset(
},
)),
title: Row(
children: [
// Ganti dengan Image.asset jika sudah ada logo
Image.asset(
'images/logo_klimatologi.png', 'images/logo_klimatologi.png',
height: 90, height: 90,
fit: BoxFit.contain, fit: BoxFit.contain,
), ),
const SizedBox(width: 10),
],
),
actions: [ actions: [
// 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( IconButton(
icon: const Icon(Icons.notifications_none, color: Colors.black), icon: Icon(
onPressed: () { _showNotifications
// Aksi notifikasi ? 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( IconButton(
icon: const Icon(Icons.logout, color: Colors.black), icon: const Icon(Icons.logout, color: Colors.black),
onPressed: () { onPressed: () => context
// Trigger Logout di AuthenticationBloc kamu
context
.read<AuthenticationBloc>() .read<AuthenticationBloc>()
.add(AuthenticationLogoutRequested()); .add(AuthenticationLogoutRequested()),
},
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
], ],
), ),
body: const Center( body: Stack(
child: Text("body home page"), children: [
// Konten utama
const _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(),
),
),
),
],
),
),
),
);
}
}
class _HomeBody extends StatelessWidget {
const _HomeBody();
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Monitoring Realtime',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Colors.grey.shade700,
),
),
const SizedBox(height: 12),
const SensorGrid(),
],
), ),
); );
} }

View File

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

View File

@ -0,0 +1,216 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../blocs/notification_bloc/notification_bloc.dart';
class NotificationPanel extends StatelessWidget {
const NotificationPanel({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<NotificationBloc, NotificationState>(
builder: (context, state) {
final alerts = state.activeAlerts;
return Container(
margin: const EdgeInsets.fromLTRB(12, 4, 12, 0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.5,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_PanelHeader(unreadCount: state.unreadCount),
if (alerts.isEmpty)
const _EmptyState()
else
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: EdgeInsets.zero,
itemCount: alerts.length,
separatorBuilder: (_, __) => Divider(
height: 1,
color: Theme.of(context).colorScheme.outlineVariant,
),
itemBuilder: (context, i) => _AlertTile(alert: alerts[i]),
),
],
),
);
},
);
}
}
class _PanelHeader extends StatelessWidget {
final int unreadCount;
const _PanelHeader({required this.unreadCount});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
const Text(
'Notifikasi',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
),
if (unreadCount > 0) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.red.shade600,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'$unreadCount baru',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
),
],
const Spacer(),
if (unreadCount > 0)
TextButton(
onPressed: () => context
.read<NotificationBloc>()
.add(const NotificationsMarkedAsRead()),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(
'Tandai dibaca',
style: TextStyle(fontSize: 11, color: Colors.blue.shade700),
),
),
],
),
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Column(
children: [
Icon(Icons.check_circle_outline,
size: 32, color: Colors.green.shade400),
const SizedBox(height: 8),
Text(
'Semua sensor normal',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
),
],
),
);
}
}
class _AlertTile extends StatelessWidget {
final SensorAlert alert;
const _AlertTile({required this.alert});
@override
Widget build(BuildContext context) {
final (color, icon) = switch (alert.severity) {
AlertSeverity.danger => (Colors.red.shade600, Icons.warning_rounded),
AlertSeverity.warning => (Colors.orange.shade600, Icons.info_outline),
AlertSeverity.info => (Colors.blue.shade600, Icons.check_circle_outline),
};
return InkWell(
onTap: () => context
.read<NotificationBloc>()
.add(NotificationDismissed(alert.sensorId)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: color, size: 20),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
alert.sensorName,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
if (!alert.isRead) ...[
const SizedBox(width: 6),
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
],
],
),
const SizedBox(height: 2),
Text(
alert.message,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade700,
),
),
const SizedBox(height: 4),
Text(
_formatTime(alert.timestamp),
style: TextStyle(fontSize: 11, color: Colors.grey.shade500),
),
],
),
),
Icon(Icons.close, size: 14, color: Colors.grey.shade400),
],
),
),
);
}
String _formatTime(DateTime dt) {
final now = DateTime.now();
final diff = now.difference(dt);
if (diff.inMinutes < 1) return 'Baru saja';
if (diff.inMinutes < 60) return '${diff.inMinutes} menit lalu';
if (diff.inHours < 24) return '${diff.inHours} jam lalu';
return '${dt.day}/${dt.month} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
}
}

View File

@ -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,
),
],
),
),
);
}
}

View File

@ -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());
}
}

View File

@ -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 {}

View File

@ -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,
);
}
}

View File

@ -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,
),
),
],
],
),
);
}
}

View File

@ -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

View File

@ -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,
]; ];
} }

View File

@ -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,
),
),
],
),
),
],
), ),
); );
} }

View File

@ -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;
}
}

View File

@ -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,
),
),
),
);
}
}

View File

@ -4,16 +4,26 @@ import 'package:equatable/equatable.dart';
import 'package:monitoring_repository/monitoring_repository.dart'; import 'package:monitoring_repository/monitoring_repository.dart';
import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:bloc_concurrency/bloc_concurrency.dart';
import '../../../../core/utils/time_series_mapper.dart'; import '../../../../core/utils/time_series_mapper.dart';
import '../../../../blocs/notification_bloc/notification_bloc.dart';
part 'wind_speed_event.dart'; part 'wind_speed_event.dart';
part 'wind_speed_state.dart'; part 'wind_speed_state.dart';
/// Threshold kecepatan angin (satuan m/s)
/// Referensi: Beaufort scale & BMKG
const double _kWindWarning = 8.0; // Waspada: 812.5 m/s (~2945 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(),
),
));
}
} }
/// ========================= /// =========================

View File

@ -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];
}

View File

@ -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,
];
} }

View File

@ -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),
], ],
); );

View File

@ -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"))

View File

@ -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:

View File

@ -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