Compare commits
10 Commits
f713294638
...
85dd04c850
| Author | SHA1 | Date |
|---|---|---|
|
|
85dd04c850 | |
|
|
7d0b5b04c2 | |
|
|
898c784359 | |
|
|
a6e74d4da0 | |
|
|
74a1609c21 | |
|
|
4fd56dc5f9 | |
|
|
f976556d99 | |
|
|
d503f05c99 | |
|
|
e6c6eeba6a | |
|
|
4b9ea8598c |
|
|
@ -1,14 +1,15 @@
|
||||||
manifest.json,1768956954743,5edb32bebce763b2b7e55774ae4c5c4354a44df248387cc3c174e8710e85aaa1
|
manifest.json,1768956954743,5edb32bebce763b2b7e55774ae4c5c4354a44df248387cc3c174e8710e85aaa1
|
||||||
|
main.dart.js,1782833541900,2b62cd155da049dceeab3413d4498565cd3c240fa1129ddc749ad52d4d0b79f9
|
||||||
flutter.js,1767911992319,970356511bee637e57348b9e01b3218a3d1d6c4add478fdc306033b4b3e21ef4
|
flutter.js,1767911992319,970356511bee637e57348b9e01b3218a3d1d6c4add478fdc306033b4b3e21ef4
|
||||||
favicon.png,1768956954741,19880e004e8357a51f6a64f5d1fdc8828a6432fe9c781f8acb6b730eced2cd13
|
favicon.png,1768956954741,19880e004e8357a51f6a64f5d1fdc8828a6432fe9c781f8acb6b730eced2cd13
|
||||||
splash/img/light-4x.png,1779442242118,09ff8858378bb70d5f4b0381eb98dc9a889e541aeb2dd7576da7d62f205de578
|
splash/img/light-4x.png,1782356157453,09ff8858378bb70d5f4b0381eb98dc9a889e541aeb2dd7576da7d62f205de578
|
||||||
splash/img/light-3x.png,1779442242118,a1a4d566718037d913ad3287e9b7d1e6d0df8569c285ce3d3680db82b6a96812
|
splash/img/light-3x.png,1782356157453,a1a4d566718037d913ad3287e9b7d1e6d0df8569c285ce3d3680db82b6a96812
|
||||||
splash/img/light-2x.png,1779442242116,e8cc8f611a58a4926632edfcd3cb7d95925b6895791632ea1c1739c4f454c8fa
|
splash/img/light-2x.png,1782356157453,e8cc8f611a58a4926632edfcd3cb7d95925b6895791632ea1c1739c4f454c8fa
|
||||||
splash/img/light-1x.png,1779442242116,c4d52b9b4b1cfdd74194c0192c462e0f14934e1428ab7ff99165f08dc17f9010
|
splash/img/light-1x.png,1782356157453,c4d52b9b4b1cfdd74194c0192c462e0f14934e1428ab7ff99165f08dc17f9010
|
||||||
splash/img/dark-4x.png,1779442242116,09ff8858378bb70d5f4b0381eb98dc9a889e541aeb2dd7576da7d62f205de578
|
splash/img/dark-4x.png,1782356157445,09ff8858378bb70d5f4b0381eb98dc9a889e541aeb2dd7576da7d62f205de578
|
||||||
splash/img/dark-3x.png,1779442242115,a1a4d566718037d913ad3287e9b7d1e6d0df8569c285ce3d3680db82b6a96812
|
splash/img/dark-3x.png,1782356157445,a1a4d566718037d913ad3287e9b7d1e6d0df8569c285ce3d3680db82b6a96812
|
||||||
splash/img/dark-2x.png,1779442242115,e8cc8f611a58a4926632edfcd3cb7d95925b6895791632ea1c1739c4f454c8fa
|
splash/img/dark-2x.png,1782356157445,e8cc8f611a58a4926632edfcd3cb7d95925b6895791632ea1c1739c4f454c8fa
|
||||||
splash/img/dark-1x.png,1779442242114,c4d52b9b4b1cfdd74194c0192c462e0f14934e1428ab7ff99165f08dc17f9010
|
splash/img/dark-1x.png,1782356157445,c4d52b9b4b1cfdd74194c0192c462e0f14934e1428ab7ff99165f08dc17f9010
|
||||||
icons/Icon-maskable-512.png,1768956954742,ea58e5c49bc4dcdc5db0a9fe2f25b2a3152f85ef9094f0d020de0aa318451a1d
|
icons/Icon-maskable-512.png,1768956954742,ea58e5c49bc4dcdc5db0a9fe2f25b2a3152f85ef9094f0d020de0aa318451a1d
|
||||||
icons/Icon-maskable-192.png,1768956954742,f7ac56197638aec92f195c5344d15395c2cbf8a5ae74e846c5b4156b64f05e38
|
icons/Icon-maskable-192.png,1768956954742,f7ac56197638aec92f195c5344d15395c2cbf8a5ae74e846c5b4156b64f05e38
|
||||||
icons/Icon-512.png,1768956954742,1de493563deb283e6ad70f0d5a89cfd61e37343df3b919c9862c5a41699ac5bc
|
icons/Icon-512.png,1768956954742,1de493563deb283e6ad70f0d5a89cfd61e37343df3b919c9862c5a41699ac5bc
|
||||||
|
|
@ -25,20 +26,20 @@ canvaskit/canvaskit.js,1767911992193,777116800268972381a4832500bb0aafd96ab254607
|
||||||
canvaskit/chromium/canvaskit.wasm,1767911992256,06f70f0703803c7329be30338b79530564ddc1ab2c3789d608431697a9a30ba2
|
canvaskit/chromium/canvaskit.wasm,1767911992256,06f70f0703803c7329be30338b79530564ddc1ab2c3789d608431697a9a30ba2
|
||||||
canvaskit/chromium/canvaskit.js.symbols,1767911992241,4a8d181b0622d7a4b4254deb591c43f48dd89623c621dec6f84d0a0b2b8f77a7
|
canvaskit/chromium/canvaskit.js.symbols,1767911992241,4a8d181b0622d7a4b4254deb591c43f48dd89623c621dec6f84d0a0b2b8f77a7
|
||||||
canvaskit/chromium/canvaskit.js,1767911992241,e0f423bfd63202fb53a4dd5cffe330143bfb9eabf5e4e8198bb605442a49d3e7
|
canvaskit/chromium/canvaskit.js,1767911992241,e0f423bfd63202fb53a4dd5cffe330143bfb9eabf5e4e8198bb605442a49d3e7
|
||||||
|
assets/packages/flutter_local_notifications_web/web/notifications_service_worker.js,1782056575362,c7f1afa176e81b5c890e014218c499566ee78f96c6ed6b9aee1b04af84e2d752
|
||||||
assets/images/windy.png,1776157890991,b9d34ed004fff60a4fea365c23508823943f3f0793375f34ca9202069d9c9955
|
assets/images/windy.png,1776157890991,b9d34ed004fff60a4fea365c23508823943f3f0793375f34ca9202069d9c9955
|
||||||
assets/images/logo_klimatologi.png,1777103040742,e042ff556e356926fcd5ff92555519a569a71316ab2f7074f86f4ea4a215f325
|
assets/images/logo_klimatologi.png,1777103040742,e042ff556e356926fcd5ff92555519a569a71316ab2f7074f86f4ea4a215f325
|
||||||
assets/images/google-icon-logo.png,1776157890990,333cfafcd5c87738a4098359e18e02ff5848b2b1b819ec8aff29424c3f82d32b
|
assets/images/google-icon-logo.png,1776157890990,333cfafcd5c87738a4098359e18e02ff5848b2b1b819ec8aff29424c3f82d32b
|
||||||
assets/images/atmosfer.png,1776157890990,c3a9ddfa5676b5f0e1cf833f73c0f09c1dfb9d2e202906c1afcdd7a50dcf2506
|
assets/images/atmosfer.png,1776157890990,c3a9ddfa5676b5f0e1cf833f73c0f09c1dfb9d2e202906c1afcdd7a50dcf2506
|
||||||
assets/FontManifest.json,1781681527475,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
|
index.html,1782835713997,3ffbcfc0c9d82b851ce84d2b1bfc85cea0a34b077bdad955e7cf52643a3cf434
|
||||||
flutter_bootstrap.js,1781681370886,d40fa5f2a178bdfc66f0b475c280935265441a37ec8a6ad5403b5d461bd3cbc1
|
version.json,1782835787646,237d17e2e585a791361ca905fcf3f475c56ae306611b22c2065ed0802c5045ff
|
||||||
assets/AssetManifest.bin.json,1781681527475,e23e10111498cc3b8b40e6549dcde13cffa6f45dc78e88194f4a7e284bb68294
|
assets/FontManifest.json,1782835787808,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
|
||||||
version.json,1781681527139,775a8c9add54713424070cf2a482daab682760ea30791f2bb9d43dab791859a1
|
flutter_service_worker.js,1782835796465,c47afa60a1c64327c1f6c37a21aedda10acf1924bf27f5dfda86423d398561bc
|
||||||
index.html,1781681370975,3ffbcfc0c9d82b851ce84d2b1bfc85cea0a34b077bdad955e7cf52643a3cf434
|
flutter_bootstrap.js,1782835713987,95a9759fb93defde72050109d28cb2a59bf237f9343cded51f999c9d1fa060c1
|
||||||
assets/shaders/stretch_effect.frag,1781681527613,586ff23669838dd33070077916f0b971a8800f67093ae8d6eed47e6cea50926f
|
assets/AssetManifest.bin,1782835787808,186d0884c1555d59162e31b14fe7fb0038864e322d7c9f3fb3318a1527be9242
|
||||||
assets/AssetManifest.bin,1781681527474,fe7319a65158e186661b7edfcc1c2b6c0a6bc6823b923a13e1e932cd66407fa2
|
assets/AssetManifest.bin.json,1782835787809,bf7d61eab58f76fb4e7c8b7ba7cbd4c92c16dbc887d554d7cf7e71a5435e961d
|
||||||
assets/shaders/ink_sparkle.frag,1781681527614,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
|
assets/shaders/stretch_effect.frag,1782835787968,586ff23669838dd33070077916f0b971a8800f67093ae8d6eed47e6cea50926f
|
||||||
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1781681529273,85140ea453cd3e02bd93650fe8add6a724ae8c0ac73d0c491085b301e7bd21d2
|
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1782835789876,85140ea453cd3e02bd93650fe8add6a724ae8c0ac73d0c491085b301e7bd21d2
|
||||||
flutter_service_worker.js,1781681536807,5b8b35ad829187fd60496a78f000b405f702b5dda19c9fb9a8299f61285fc896
|
assets/shaders/ink_sparkle.frag,1782835787968,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
|
||||||
assets/fonts/MaterialIcons-Regular.otf,1781681529273,46a334e22309dd625d6529f6ac802f10e1c0a16d090bfa9eccfe0eb500676c79
|
assets/fonts/MaterialIcons-Regular.otf,1782835789881,d7ea214218a5b907538107aad5f49caeb5eae4bc9814b83cf52731bcfc9dbebc
|
||||||
assets/NOTICES,1781681527476,62963000bb4f8b466d5b97db77a0f9563f047723e134974be6bf170da0f821b2
|
assets/NOTICES,1782835787810,0ca1b4f48ad9b11b50a47eda04252140a3044c7a1db622232baa51c99986d210
|
||||||
main.dart.js,1781681464949,c5f4aa33c07c4e058a1b913c8be9aa7557d0488318a155200451422802caa50d
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ android {
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
|
|
@ -40,6 +41,10 @@ android {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||||
|
}
|
||||||
|
|
||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ 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 '../../../blocs/notification_bloc/notification_bloc.dart';
|
||||||
import '../../monitoring/wind_speed/blocs/wind_speed_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 '../../monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart';
|
||||||
import '../../auth/views/welcome_screen.dart';
|
import '../../auth/views/welcome_screen.dart';
|
||||||
import '../widgets/notification_panel.dart';
|
import '../widgets/notification_panel.dart';
|
||||||
|
|
@ -65,12 +64,6 @@ class _HomeScreenState extends State<HomeScreen>
|
||||||
notificationBloc: context.read<NotificationBloc>(),
|
notificationBloc: context.read<NotificationBloc>(),
|
||||||
)..add(WatchWindSpeedStarted()),
|
)..add(WatchWindSpeedStarted()),
|
||||||
),
|
),
|
||||||
BlocProvider<EvaporasiBloc>(
|
|
||||||
create: (context) => EvaporasiBloc(
|
|
||||||
repository: context.read<MonitoringRepository>(),
|
|
||||||
notificationBloc: context.read<NotificationBloc>(),
|
|
||||||
)..add(WatchEvaporasiStarted()),
|
|
||||||
),
|
|
||||||
BlocProvider<AtmosphericConditionsBloc>(
|
BlocProvider<AtmosphericConditionsBloc>(
|
||||||
create: (context) => AtmosphericConditionsBloc(
|
create: (context) => AtmosphericConditionsBloc(
|
||||||
repository: context.read<MonitoringRepository>(),
|
repository: context.read<MonitoringRepository>(),
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
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 'package:klimatologiot/screens/monitoring/evaporasi/blocs/evaporasi_bloc.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 '../../monitoring/wind_speed/views/wind_speed_screen.dart';
|
import '../../monitoring/wind_speed/views/wind_speed_screen.dart';
|
||||||
import '../../monitoring/wind_speed/blocs/wind_speed_bloc.dart';
|
import '../../monitoring/wind_speed/blocs/wind_speed_bloc.dart';
|
||||||
import '../../monitoring/evaporasi/views/evaporasi_screen.dart';
|
|
||||||
import '../../monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart';
|
import '../../monitoring/atmospheric_conditions/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';
|
import '../../../blocs/notification_bloc/notification_bloc.dart';
|
||||||
|
|
@ -69,25 +67,6 @@ class MainDrawer extends StatelessWidget {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.water_drop),
|
|
||||||
title: const Text("Evaporasi"),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => BlocProvider<EvaporasiBloc>(
|
|
||||||
create: (context) => EvaporasiBloc(
|
|
||||||
repository: context.read<MonitoringRepository>(),
|
|
||||||
notificationBloc: context.read<NotificationBloc>(),
|
|
||||||
)..add(WatchEvaporasiStarted()),
|
|
||||||
child: const EvaporasiScreen(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Image.asset(
|
leading: Image.asset(
|
||||||
'images/atmosfer.png',
|
'images/atmosfer.png',
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
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 '../../monitoring/wind_speed/blocs/wind_speed_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 '../../monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart';
|
||||||
|
|
||||||
/// Period selector khusus untuk dashboard
|
/// Period selector khusus untuk dashboard
|
||||||
|
|
@ -74,29 +73,6 @@ class _DashboardChartsState extends State<DashboardCharts> {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
|
|
||||||
context.read<WindSpeedBloc>().add(WindSpeedPeriodChanged(p));
|
context.read<WindSpeedBloc>().add(WindSpeedPeriodChanged(p));
|
||||||
context.read<EvaporasiBloc>().add(
|
|
||||||
EvaporasiDateRangeChanged(
|
|
||||||
startDate: _evaporasiStartOfPeriod(p, now),
|
|
||||||
endDate: _evaporasiEndOfPeriod(now),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
// AtmosphericBloc tidak punya period (hanya realtime)
|
|
||||||
}
|
|
||||||
|
|
||||||
DateTime _evaporasiStartOfPeriod(String period, DateTime now) {
|
|
||||||
final today = DateTime(now.year, now.month, now.day);
|
|
||||||
if (period == "Minggu Ini") {
|
|
||||||
final monday = today.subtract(Duration(days: today.weekday - 1));
|
|
||||||
return monday;
|
|
||||||
}
|
|
||||||
if (period == "Bulan Ini") {
|
|
||||||
return DateTime(today.year, today.month, 1);
|
|
||||||
}
|
|
||||||
return today;
|
|
||||||
}
|
|
||||||
|
|
||||||
DateTime _evaporasiEndOfPeriod(DateTime now) {
|
|
||||||
return DateTime(now.year, now.month, now.day);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -152,24 +128,6 @@ class _DashboardChartsState extends State<DashboardCharts> {
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
// 2. Evaporasi
|
|
||||||
BlocBuilder<EvaporasiBloc, EvaporasiState>(
|
|
||||||
builder: (context, state) {
|
|
||||||
return _SensorChartCard(
|
|
||||||
title: 'Evaporasi',
|
|
||||||
unit: 'mm',
|
|
||||||
color: Colors.teal.shade600,
|
|
||||||
bgColor: Colors.teal.shade50,
|
|
||||||
icon: Icons.water_drop_outlined,
|
|
||||||
currentValue: state.currentValue,
|
|
||||||
data: state.chartValues,
|
|
||||||
period: _period,
|
|
||||||
isLoading: state.isLoading,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
|
|
||||||
// 3. Tekanan Udara — hanya realtime (tidak ada history)
|
// 3. Tekanan Udara — hanya realtime (tidak ada history)
|
||||||
BlocBuilder<AtmosphericConditionsBloc,
|
BlocBuilder<AtmosphericConditionsBloc,
|
||||||
AtmosphericConditionsState>(
|
AtmosphericConditionsState>(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
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 '../../monitoring/wind_speed/blocs/wind_speed_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 '../../monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart';
|
||||||
|
|
||||||
class SensorGrid extends StatelessWidget {
|
class SensorGrid extends StatelessWidget {
|
||||||
|
|
@ -34,23 +33,6 @@ class SensorGrid extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// --- 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',
|
|
||||||
status: state.currentData?.status,
|
|
||||||
isLoading: state.isLoading,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// --- TEKANAN UDARA ---
|
// --- TEKANAN UDARA ---
|
||||||
BlocBuilder<AtmosphericConditionsBloc, AtmosphericConditionsState>(
|
BlocBuilder<AtmosphericConditionsBloc, AtmosphericConditionsState>(
|
||||||
builder: (context, state) => SensorCard(
|
builder: (context, state) => SensorCard(
|
||||||
|
|
@ -170,11 +152,14 @@ class SensorCard extends StatelessWidget {
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: (status == 'Tinggi'
|
color: (status == 'Tinggi'
|
||||||
? Colors.red.shade50
|
? Colors.red.shade50
|
||||||
: (status == 'Rendah' ? Colors.blue.shade50 : Colors.green.shade50)),
|
: (status == 'Rendah'
|
||||||
|
? Colors.blue.shade50
|
||||||
|
: Colors.green.shade50)),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|
@ -184,7 +169,9 @@ class SensorCard extends StatelessWidget {
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: (status == 'Tinggi'
|
color: (status == 'Tinggi'
|
||||||
? Colors.red.shade600
|
? Colors.red.shade600
|
||||||
: (status == 'Rendah' ? Colors.blue.shade600 : Colors.green.shade600)),
|
: (status == 'Rendah'
|
||||||
|
? Colors.blue.shade600
|
||||||
|
: Colors.green.shade600)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
part of 'device_setup_bloc.dart';
|
part of 'device_setup_wind_speed_bloc.dart';
|
||||||
|
|
||||||
abstract class DeviceSetupEvent {}
|
abstract class DeviceSetupEvent {}
|
||||||
|
|
||||||
|
|
@ -19,27 +19,21 @@ class DeviceIdChanged extends DeviceSetupEvent {
|
||||||
DeviceIdChanged(this.deviceId);
|
DeviceIdChanged(this.deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
class KFaktorChanged extends DeviceSetupEvent {
|
|
||||||
final double value;
|
|
||||||
KFaktorChanged(this.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
class RadiusChanged extends DeviceSetupEvent {
|
|
||||||
final double value;
|
|
||||||
RadiusChanged(this.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
class IntervalRealtimeChanged extends DeviceSetupEvent {
|
class IntervalRealtimeChanged extends DeviceSetupEvent {
|
||||||
final int ms;
|
final int ms;
|
||||||
IntervalRealtimeChanged(this.ms);
|
IntervalRealtimeChanged(this.ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class IntervalAverageChanged extends DeviceSetupEvent {
|
||||||
|
final int ms;
|
||||||
|
IntervalAverageChanged(this.ms);
|
||||||
|
}
|
||||||
|
|
||||||
class IntervalHistoryChanged extends DeviceSetupEvent {
|
class IntervalHistoryChanged extends DeviceSetupEvent {
|
||||||
final int ms;
|
final int ms;
|
||||||
IntervalHistoryChanged(this.ms);
|
IntervalHistoryChanged(this.ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// User ubah jumlah magnet (1 atau 3)
|
|
||||||
class MagnetCountChanged extends DeviceSetupEvent {
|
class MagnetCountChanged extends DeviceSetupEvent {
|
||||||
final int count;
|
final int count;
|
||||||
MagnetCountChanged(this.count);
|
MagnetCountChanged(this.count);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
part of 'device_setup_bloc.dart';
|
part of 'device_setup_wind_speed_bloc.dart';
|
||||||
|
|
||||||
enum DeviceSetupStatus {
|
enum DeviceSetupStatus {
|
||||||
idle,
|
idle,
|
||||||
|
|
@ -22,9 +22,8 @@ class DeviceSetupState {
|
||||||
final String espIp;
|
final String espIp;
|
||||||
|
|
||||||
final String deviceId;
|
final String deviceId;
|
||||||
final double kFaktor;
|
|
||||||
final double radiusM;
|
|
||||||
final int intervalRealtimeMs;
|
final int intervalRealtimeMs;
|
||||||
|
final int intervalAverageMs;
|
||||||
final int intervalHistoryMs;
|
final int intervalHistoryMs;
|
||||||
final int magnetCount;
|
final int magnetCount;
|
||||||
final List<Map<String, dynamic>> logs;
|
final List<Map<String, dynamic>> logs;
|
||||||
|
|
@ -38,9 +37,8 @@ class DeviceSetupState {
|
||||||
this.successMessage,
|
this.successMessage,
|
||||||
this.espIp = '192.168.4.1',
|
this.espIp = '192.168.4.1',
|
||||||
this.deviceId = 'esp_lapangan',
|
this.deviceId = 'esp_lapangan',
|
||||||
this.kFaktor = 50.0,
|
|
||||||
this.radiusM = 0.08,
|
|
||||||
this.intervalRealtimeMs = 1000,
|
this.intervalRealtimeMs = 1000,
|
||||||
|
this.intervalAverageMs = 60000,
|
||||||
this.intervalHistoryMs = 3600000,
|
this.intervalHistoryMs = 3600000,
|
||||||
this.magnetCount = 1, // ← default 1 magnet
|
this.magnetCount = 1, // ← default 1 magnet
|
||||||
this.logs = const [],
|
this.logs = const [],
|
||||||
|
|
@ -58,9 +56,8 @@ class DeviceSetupState {
|
||||||
String? successMessage,
|
String? successMessage,
|
||||||
String? espIp,
|
String? espIp,
|
||||||
String? deviceId,
|
String? deviceId,
|
||||||
double? kFaktor,
|
|
||||||
double? radiusM,
|
|
||||||
int? intervalRealtimeMs,
|
int? intervalRealtimeMs,
|
||||||
|
int? intervalAverageMs,
|
||||||
int? intervalHistoryMs,
|
int? intervalHistoryMs,
|
||||||
int? magnetCount,
|
int? magnetCount,
|
||||||
List<Map<String, dynamic>>? logs,
|
List<Map<String, dynamic>>? logs,
|
||||||
|
|
@ -74,9 +71,8 @@ class DeviceSetupState {
|
||||||
successMessage: successMessage,
|
successMessage: successMessage,
|
||||||
espIp: espIp ?? this.espIp,
|
espIp: espIp ?? this.espIp,
|
||||||
deviceId: deviceId ?? this.deviceId,
|
deviceId: deviceId ?? this.deviceId,
|
||||||
kFaktor: kFaktor ?? this.kFaktor,
|
|
||||||
radiusM: radiusM ?? this.radiusM,
|
|
||||||
intervalRealtimeMs: intervalRealtimeMs ?? this.intervalRealtimeMs,
|
intervalRealtimeMs: intervalRealtimeMs ?? this.intervalRealtimeMs,
|
||||||
|
intervalAverageMs: intervalAverageMs ?? this.intervalAverageMs,
|
||||||
intervalHistoryMs: intervalHistoryMs ?? this.intervalHistoryMs,
|
intervalHistoryMs: intervalHistoryMs ?? this.intervalHistoryMs,
|
||||||
magnetCount: magnetCount ?? this.magnetCount,
|
magnetCount: magnetCount ?? this.magnetCount,
|
||||||
logs: logs ?? this.logs,
|
logs: logs ?? this.logs,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// device_setup_wind_speed_bloc.dart
|
||||||
import 'package:bloc/bloc.dart';
|
import 'package:bloc/bloc.dart';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
@ -26,10 +27,10 @@ class DeviceSetupBloc extends Bloc<DeviceSetupEvent, DeviceSetupState> {
|
||||||
// Settings
|
// Settings
|
||||||
on<DeviceSettingsStarted>(_onSettingsStarted);
|
on<DeviceSettingsStarted>(_onSettingsStarted);
|
||||||
on<DeviceIdChanged>(_onDeviceIdChanged);
|
on<DeviceIdChanged>(_onDeviceIdChanged);
|
||||||
on<KFaktorChanged>((e, emit) => emit(state.copyWith(kFaktor: e.value)));
|
|
||||||
on<RadiusChanged>((e, emit) => emit(state.copyWith(radiusM: e.value)));
|
|
||||||
on<IntervalRealtimeChanged>(
|
on<IntervalRealtimeChanged>(
|
||||||
(e, emit) => emit(state.copyWith(intervalRealtimeMs: e.ms)));
|
(e, emit) => emit(state.copyWith(intervalRealtimeMs: e.ms)));
|
||||||
|
on<IntervalAverageChanged>(
|
||||||
|
(e, emit) => emit(state.copyWith(intervalAverageMs: e.ms)));
|
||||||
on<IntervalHistoryChanged>(
|
on<IntervalHistoryChanged>(
|
||||||
(e, emit) => emit(state.copyWith(intervalHistoryMs: e.ms)));
|
(e, emit) => emit(state.copyWith(intervalHistoryMs: e.ms)));
|
||||||
on<MagnetCountChanged>(
|
on<MagnetCountChanged>(
|
||||||
|
|
@ -56,19 +57,14 @@ class DeviceSetupBloc extends Bloc<DeviceSetupEvent, DeviceSetupState> {
|
||||||
// Baca device ID tersimpan lokal
|
// Baca device ID tersimpan lokal
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final savedId = prefs.getString(_kDeviceIdKey) ?? state.deviceId;
|
final savedId = prefs.getString(_kDeviceIdKey) ?? state.deviceId;
|
||||||
|
|
||||||
// Baca settings dari Firebase
|
|
||||||
final s = await _repository.getAnemometerSettings();
|
final s = await _repository.getAnemometerSettings();
|
||||||
|
|
||||||
// Baca logs
|
|
||||||
final logs = await _repository.getDeviceLogs(savedId);
|
final logs = await _repository.getDeviceLogs(savedId);
|
||||||
|
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
status: DeviceSetupStatus.settingsLoaded,
|
status: DeviceSetupStatus.settingsLoaded,
|
||||||
deviceId: savedId,
|
deviceId: savedId,
|
||||||
kFaktor: s['k_faktor'] as double,
|
|
||||||
radiusM: s['radius_m'] as double,
|
|
||||||
intervalRealtimeMs: s['interval_realtime_ms'] as int,
|
intervalRealtimeMs: s['interval_realtime_ms'] as int,
|
||||||
|
intervalAverageMs: s['interval_average_ms'] as int,
|
||||||
intervalHistoryMs: s['interval_history_ms'] as int,
|
intervalHistoryMs: s['interval_history_ms'] as int,
|
||||||
magnetCount: (s['magnet_count'] as int?) ?? 1,
|
magnetCount: (s['magnet_count'] as int?) ?? 1,
|
||||||
logs: logs,
|
logs: logs,
|
||||||
|
|
@ -104,9 +100,8 @@ class DeviceSetupBloc extends Bloc<DeviceSetupEvent, DeviceSetupState> {
|
||||||
emit(state.copyWith(status: DeviceSetupStatus.settingsSaving));
|
emit(state.copyWith(status: DeviceSetupStatus.settingsSaving));
|
||||||
try {
|
try {
|
||||||
await _repository.updateAnemometerSettings(
|
await _repository.updateAnemometerSettings(
|
||||||
kFaktor: state.kFaktor,
|
|
||||||
radiusM: state.radiusM,
|
|
||||||
intervalRealtimeMs: state.intervalRealtimeMs,
|
intervalRealtimeMs: state.intervalRealtimeMs,
|
||||||
|
intervalAverageMs: state.intervalAverageMs,
|
||||||
intervalHistoryMs: state.intervalHistoryMs,
|
intervalHistoryMs: state.intervalHistoryMs,
|
||||||
magnetCount: state.magnetCount,
|
magnetCount: state.magnetCount,
|
||||||
);
|
);
|
||||||
|
|
@ -335,9 +330,8 @@ class DeviceSetupBloc extends Bloc<DeviceSetupEvent, DeviceSetupState> {
|
||||||
) {
|
) {
|
||||||
emit(DeviceSetupState(
|
emit(DeviceSetupState(
|
||||||
deviceId: state.deviceId,
|
deviceId: state.deviceId,
|
||||||
kFaktor: state.kFaktor,
|
|
||||||
radiusM: state.radiusM,
|
|
||||||
intervalRealtimeMs: state.intervalRealtimeMs,
|
intervalRealtimeMs: state.intervalRealtimeMs,
|
||||||
|
intervalAverageMs: state.intervalAverageMs,
|
||||||
intervalHistoryMs: state.intervalHistoryMs,
|
intervalHistoryMs: state.intervalHistoryMs,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -1,291 +0,0 @@
|
||||||
import 'package:bloc/bloc.dart';
|
|
||||||
import 'package:equatable/equatable.dart';
|
|
||||||
import 'package:firebase_database/firebase_database.dart';
|
|
||||||
|
|
||||||
part 'evaporasi_settings_event.dart';
|
|
||||||
part 'evaporasi_settings_state.dart';
|
|
||||||
|
|
||||||
class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettingsState> {
|
|
||||||
final DatabaseReference _ref;
|
|
||||||
|
|
||||||
static const _path = 'Monitoring/settings/evaporasi';
|
|
||||||
static const _rtdbResetPath = 'Monitoring/reset_evaporasi';
|
|
||||||
static const _rtdbRealtimePath = 'Monitoring/realtime/dmax_saat_ini';
|
|
||||||
|
|
||||||
EvaporasiSettingsBloc({DatabaseReference? ref})
|
|
||||||
: _ref = ref ?? FirebaseDatabase.instance.ref(_path),
|
|
||||||
super(const EvaporasiSettingsState()) {
|
|
||||||
on<EvaporasiSettingsStarted>(_onStarted);
|
|
||||||
on<EvaporasiThresholdRendahChanged>(_onThresholdRendahChanged);
|
|
||||||
on<EvaporasiThresholdTinggiChanged>(_onThresholdTinggiChanged);
|
|
||||||
// rumusKalibrasi removed: calculation is handled on ESP32 hardware
|
|
||||||
on<EvaporasiKoreksiOffsetChanged>(_onOffsetChanged);
|
|
||||||
on<EvaporasiPumpStartChanged>(_onPumpStartChanged);
|
|
||||||
on<EvaporasiPumpEndChanged>(_onPumpEndChanged);
|
|
||||||
on<EvaporasiStandarTinggiChanged>(_onStandarTinggiChanged);
|
|
||||||
on<EvaporasiBatasKritisChanged>(_onBatasKritisChanged);
|
|
||||||
on<EvaporasiD0Changed>(_onD0Changed);
|
|
||||||
on<EvaporasiDmaxManualChanged>(_onDmaxManualChanged);
|
|
||||||
on<EvaporasiIntervalRealtimeChanged>(_onIntervalRealtimeChanged);
|
|
||||||
on<EvaporasiIntervalHistoryChanged>(_onIntervalHistoryChanged);
|
|
||||||
on<EvaporasiIntervalBacaChanged>(_onIntervalBacaChanged);
|
|
||||||
on<EvaporasiDmaxResetRequested>(_onDmaxReset);
|
|
||||||
on<EvaporasiSettingsSaved>(_onSaved);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onStarted(
|
|
||||||
EvaporasiSettingsStarted event,
|
|
||||||
Emitter<EvaporasiSettingsState> emit,
|
|
||||||
) async {
|
|
||||||
emit(state.copyWith(status: EvaporasiSettingsStatus.loading));
|
|
||||||
try {
|
|
||||||
final snap = await _ref.get();
|
|
||||||
if (snap.exists && snap.value != null) {
|
|
||||||
final data = Map<String, dynamic>.from(snap.value as Map);
|
|
||||||
emit(state.copyWith(
|
|
||||||
thresholdRendah: _toDouble(data['threshold_rendah'], 2.0),
|
|
||||||
thresholdTinggi: _toDouble(data['threshold_tinggi'], 10.0),
|
|
||||||
koreksiOffset: _toDouble(data['koreksi_offset'], 0.0),
|
|
||||||
pumpStartTime: (data['pump_start_time'] as String?) ??
|
|
||||||
(data['jam_pompa_mulai'] != null ? '${_toInt(data['jam_pompa_mulai'], 6).toString().padLeft(2, '0')}:00' : '06:00'),
|
|
||||||
pumpEndTime: (data['pump_end_time'] as String?) ??
|
|
||||||
(data['jam_pompa_selesai'] != null ? '${_toInt(data['jam_pompa_selesai'], 18).toString().padLeft(2, '0')}:00' : '18:00'),
|
|
||||||
d0: _toInt(data['d0'], 0),
|
|
||||||
dmaxManual: _toInt(data['dmax_manual'], 0),
|
|
||||||
standarTinggiCm: _toDouble(data['standar_tinggi_cm'] ?? data['standar_tinggi'], 18.0),
|
|
||||||
batasKritisCm: _toDouble(data['batas_kritis_cm'] ?? data['batas_kritis'], 15.0),
|
|
||||||
intervalRealtime_ms: _toInt(data['interval_realtime_ms'], 300000),
|
|
||||||
intervalHistory_ms: _toInt(data['interval_history_ms'], 600000),
|
|
||||||
intervalBaca_ms: _toInt(data['interval_baca_ms'], 10000),
|
|
||||||
status: EvaporasiSettingsStatus.loaded,
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
emit(state.copyWith(status: EvaporasiSettingsStatus.loaded));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
emit(state.copyWith(
|
|
||||||
status: EvaporasiSettingsStatus.error,
|
|
||||||
errorMessage: e.toString(),
|
|
||||||
));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final rtRef = FirebaseDatabase.instance.ref('Monitoring/realtime');
|
|
||||||
final rtSnap = await rtRef.get();
|
|
||||||
if (rtSnap.exists && rtSnap.value != null) {
|
|
||||||
final rt = Map<String, dynamic>.from(rtSnap.value as Map);
|
|
||||||
final val = _toInt(rt['dmax_saat_ini'], 0);
|
|
||||||
final firmware = (rt['firmware_version'] as String?) ?? '--';
|
|
||||||
final wifi = rt['wifi_connected'] is bool ? (rt['wifi_connected'] as bool) : (rt['wifi_connected'] == 1);
|
|
||||||
final firebase = rt['firebase_connected'] is bool ? (rt['firebase_connected'] as bool) : (rt['firebase_connected'] == 1);
|
|
||||||
final activeD0 = _toInt(rt['d0_active'], state.d0);
|
|
||||||
final activeDmax = _toInt(rt['dmax_active'], val);
|
|
||||||
DateTime? lastUpd;
|
|
||||||
try {
|
|
||||||
final lu = rt['last_update'];
|
|
||||||
if (lu != null) {
|
|
||||||
final ms = (lu is num) ? lu.toInt() : int.tryParse(lu.toString()) ?? 0;
|
|
||||||
if (ms > 0) lastUpd = DateTime.fromMillisecondsSinceEpoch(ms);
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
emit(state.copyWith(
|
|
||||||
dmax: val,
|
|
||||||
firmwareVersion: firmware,
|
|
||||||
wifiConnected: wifi ?? false,
|
|
||||||
firebaseConnected: firebase ?? false,
|
|
||||||
activeD0: activeD0,
|
|
||||||
activeDmax: activeDmax,
|
|
||||||
lastUpdate: lastUpd,
|
|
||||||
sensorError: _toBool(rt['sensor_error'], state.sensorError),
|
|
||||||
ntpSync: _toBool(rt['ntp_sync'], state.ntpSync),
|
|
||||||
snapshotCm: _toDouble(rt['snapshot_cm'] ?? rt['snapshot'], state.snapshotCm),
|
|
||||||
standarTinggiCm: _toDouble(rt['standar_tinggi'] ?? rt['standar_tinggi_cm'], state.standarTinggiCm),
|
|
||||||
batasKritisCm: _toDouble(rt['batas_kritis'] ?? rt['batas_kritis_cm'], state.batasKritisCm),
|
|
||||||
tempCompActive: _toBool(rt['temp_comp_aktif'], state.tempCompActive),
|
|
||||||
tempCompCoef: _toDouble(rt['temp_comp_koef'] ?? rt['tempCompKoef'] ?? 0.0, state.tempCompCoef),
|
|
||||||
tempRefC: _toDouble(rt['temp_ref_c'] ?? rt['temp_ref'] ?? 0.0, state.tempRefC),
|
|
||||||
otaTrigger: _toBool(rt['ota_trigger'], state.otaTrigger),
|
|
||||||
relayAktif: _toBool(rt['selenoid'], state.relayAktif),
|
|
||||||
historyCount: _toInt(rt['history_count'], state.historyCount),
|
|
||||||
lastRealtime: _parseDateTime(rt['datetime']) ?? lastUpd,
|
|
||||||
otaStatus: (rt['ota_status'] as String?) ?? state.otaStatus,
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
// fallback: try single path
|
|
||||||
final snap = await FirebaseDatabase.instance.ref(_rtdbRealtimePath).get();
|
|
||||||
final val = snap.exists ? (snap.value as num?)?.toInt() ?? 0 : 0;
|
|
||||||
emit(state.copyWith(dmax: val));
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// ignore: avoid_catching_errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onThresholdRendahChanged(
|
|
||||||
EvaporasiThresholdRendahChanged event,
|
|
||||||
Emitter<EvaporasiSettingsState> emit,
|
|
||||||
) => emit(state.copyWith(thresholdRendah: event.value));
|
|
||||||
|
|
||||||
void _onThresholdTinggiChanged(
|
|
||||||
EvaporasiThresholdTinggiChanged event,
|
|
||||||
Emitter<EvaporasiSettingsState> emit,
|
|
||||||
) => emit(state.copyWith(thresholdTinggi: event.value));
|
|
||||||
|
|
||||||
// rumusKalibrasi handling removed — device firmware defines the formula
|
|
||||||
|
|
||||||
void _onOffsetChanged(
|
|
||||||
EvaporasiKoreksiOffsetChanged event,
|
|
||||||
Emitter<EvaporasiSettingsState> emit,
|
|
||||||
) => emit(state.copyWith(koreksiOffset: event.value));
|
|
||||||
|
|
||||||
void _onIntervalRealtimeChanged(
|
|
||||||
EvaporasiIntervalRealtimeChanged event,
|
|
||||||
Emitter<EvaporasiSettingsState> emit,
|
|
||||||
) => emit(state.copyWith(intervalRealtime_ms: event.value));
|
|
||||||
|
|
||||||
void _onIntervalHistoryChanged(
|
|
||||||
EvaporasiIntervalHistoryChanged event,
|
|
||||||
Emitter<EvaporasiSettingsState> emit,
|
|
||||||
) => emit(state.copyWith(intervalHistory_ms: event.value));
|
|
||||||
|
|
||||||
void _onIntervalBacaChanged(
|
|
||||||
EvaporasiIntervalBacaChanged event,
|
|
||||||
Emitter<EvaporasiSettingsState> emit,
|
|
||||||
) => emit(state.copyWith(intervalBaca_ms: event.value));
|
|
||||||
|
|
||||||
void _onPumpStartChanged(
|
|
||||||
EvaporasiPumpStartChanged event,
|
|
||||||
Emitter<EvaporasiSettingsState> emit,
|
|
||||||
) => emit(state.copyWith(pumpStartTime: event.value));
|
|
||||||
|
|
||||||
void _onPumpEndChanged(
|
|
||||||
EvaporasiPumpEndChanged event,
|
|
||||||
Emitter<EvaporasiSettingsState> emit,
|
|
||||||
) => emit(state.copyWith(pumpEndTime: event.value));
|
|
||||||
|
|
||||||
void _onStandarTinggiChanged(
|
|
||||||
EvaporasiStandarTinggiChanged event,
|
|
||||||
Emitter<EvaporasiSettingsState> emit,
|
|
||||||
) => emit(state.copyWith(standarTinggiCm: event.value));
|
|
||||||
|
|
||||||
void _onBatasKritisChanged(
|
|
||||||
EvaporasiBatasKritisChanged event,
|
|
||||||
Emitter<EvaporasiSettingsState> emit,
|
|
||||||
) => emit(state.copyWith(batasKritisCm: event.value));
|
|
||||||
|
|
||||||
void _onD0Changed(
|
|
||||||
EvaporasiD0Changed event,
|
|
||||||
Emitter<EvaporasiSettingsState> emit,
|
|
||||||
) => emit(state.copyWith(d0: event.value));
|
|
||||||
|
|
||||||
void _onDmaxManualChanged(
|
|
||||||
EvaporasiDmaxManualChanged event,
|
|
||||||
Emitter<EvaporasiSettingsState> emit,
|
|
||||||
) => emit(state.copyWith(dmaxManual: event.value));
|
|
||||||
|
|
||||||
Future<void> _onDmaxReset(
|
|
||||||
EvaporasiDmaxResetRequested event,
|
|
||||||
Emitter<EvaporasiSettingsState> emit,
|
|
||||||
) async {
|
|
||||||
emit(state.copyWith(isResettingDmax: true));
|
|
||||||
try {
|
|
||||||
await FirebaseDatabase.instance.ref(_rtdbResetPath).set(true);
|
|
||||||
await Future.delayed(const Duration(seconds: 6));
|
|
||||||
final snap = await FirebaseDatabase.instance.ref(_rtdbRealtimePath).get();
|
|
||||||
final newDmax = snap.exists ? (snap.value as num?)?.toInt() ?? 0 : 0;
|
|
||||||
emit(state.copyWith(dmax: newDmax, isResettingDmax: false));
|
|
||||||
} catch (e) {
|
|
||||||
emit(state.copyWith(isResettingDmax: false, errorMessage: e.toString()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onSaved(
|
|
||||||
EvaporasiSettingsSaved event,
|
|
||||||
Emitter<EvaporasiSettingsState> emit,
|
|
||||||
) async {
|
|
||||||
if (state.thresholdRendah >= state.thresholdTinggi) {
|
|
||||||
emit(state.copyWith(
|
|
||||||
status: EvaporasiSettingsStatus.error,
|
|
||||||
errorMessage: 'Batas Rendah harus lebih kecil dari batas Tinggi.',
|
|
||||||
));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
emit(state.copyWith(status: EvaporasiSettingsStatus.saving));
|
|
||||||
try {
|
|
||||||
final pumpStartHour = int.tryParse(state.pumpStartTime.split(':').first) ?? 0;
|
|
||||||
final pumpEndHour = int.tryParse(state.pumpEndTime.split(':').first) ?? 0;
|
|
||||||
await FirebaseDatabase.instance
|
|
||||||
.ref('Monitoring/settings/kalibrasi')
|
|
||||||
.update({
|
|
||||||
'd0': state.d0,
|
|
||||||
'dmax': state.dmaxManual,
|
|
||||||
});
|
|
||||||
await _ref.update({
|
|
||||||
'threshold_rendah': state.thresholdRendah,
|
|
||||||
'threshold_tinggi': state.thresholdTinggi,
|
|
||||||
'koreksi_offset': state.koreksiOffset,
|
|
||||||
'pump_start_time': state.pumpStartTime,
|
|
||||||
'pump_end_time': state.pumpEndTime,
|
|
||||||
'jam_pompa_mulai': pumpStartHour,
|
|
||||||
'jam_pompa_selesai': pumpEndHour,
|
|
||||||
'standar_tinggi_cm': state.standarTinggiCm,
|
|
||||||
'batas_kritis_cm': state.batasKritisCm,
|
|
||||||
'interval_realtime_ms': state.intervalRealtime_ms,
|
|
||||||
'interval_history_ms': state.intervalHistory_ms,
|
|
||||||
'interval_baca_ms': state.intervalBaca_ms,
|
|
||||||
'updated_at': ServerValue.timestamp,
|
|
||||||
});
|
|
||||||
emit(state.copyWith(status: EvaporasiSettingsStatus.saved));
|
|
||||||
await Future.delayed(const Duration(seconds: 2));
|
|
||||||
emit(state.copyWith(status: EvaporasiSettingsStatus.loaded));
|
|
||||||
} catch (e) {
|
|
||||||
emit(state.copyWith(
|
|
||||||
status: EvaporasiSettingsStatus.error,
|
|
||||||
errorMessage: e.toString(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
double _toDouble(dynamic v, double def) {
|
|
||||||
if (v == null) return def;
|
|
||||||
if (v is num) return v.toDouble();
|
|
||||||
if (v is String) {
|
|
||||||
return double.tryParse(v) ?? def;
|
|
||||||
}
|
|
||||||
return def;
|
|
||||||
}
|
|
||||||
|
|
||||||
int _toInt(dynamic v, int def) {
|
|
||||||
if (v == null) return def;
|
|
||||||
if (v is num) return v.toInt();
|
|
||||||
if (v is String) {
|
|
||||||
return int.tryParse(v) ?? def;
|
|
||||||
}
|
|
||||||
return def;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _toBool(dynamic v, bool def) {
|
|
||||||
if (v == null) return def;
|
|
||||||
if (v is bool) return v;
|
|
||||||
if (v is num) return v != 0;
|
|
||||||
if (v is String) {
|
|
||||||
final lower = v.toLowerCase().trim();
|
|
||||||
return lower == 'true' || lower == '1' || lower == 'yes' || lower == 'aktif';
|
|
||||||
}
|
|
||||||
return def;
|
|
||||||
}
|
|
||||||
|
|
||||||
DateTime? _parseDateTime(dynamic raw) {
|
|
||||||
if (raw == null) return null;
|
|
||||||
if (raw is int) return DateTime.fromMillisecondsSinceEpoch(raw).toLocal();
|
|
||||||
if (raw is num) return DateTime.fromMillisecondsSinceEpoch(raw.toInt()).toLocal();
|
|
||||||
if (raw is String) {
|
|
||||||
final normalized = raw.replaceAll(' ', 'T');
|
|
||||||
return DateTime.tryParse(normalized)?.toLocal();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
part of 'evaporasi_settings_bloc.dart';
|
|
||||||
|
|
||||||
abstract class EvaporasiSettingsEvent extends Equatable {
|
|
||||||
const EvaporasiSettingsEvent();
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [];
|
|
||||||
}
|
|
||||||
|
|
||||||
class EvaporasiSettingsStarted extends EvaporasiSettingsEvent {}
|
|
||||||
|
|
||||||
class EvaporasiThresholdRendahChanged extends EvaporasiSettingsEvent {
|
|
||||||
final double value;
|
|
||||||
const EvaporasiThresholdRendahChanged(this.value);
|
|
||||||
@override List<Object?> get props => [value];
|
|
||||||
}
|
|
||||||
|
|
||||||
class EvaporasiThresholdTinggiChanged extends EvaporasiSettingsEvent {
|
|
||||||
final double value;
|
|
||||||
const EvaporasiThresholdTinggiChanged(this.value);
|
|
||||||
@override List<Object?> get props => [value];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class EvaporasiKoreksiOffsetChanged extends EvaporasiSettingsEvent {
|
|
||||||
final double value;
|
|
||||||
const EvaporasiKoreksiOffsetChanged(this.value);
|
|
||||||
@override List<Object?> get props => [value];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Interval baru ──────────────────────────────────────────
|
|
||||||
class EvaporasiIntervalRealtimeChanged extends EvaporasiSettingsEvent {
|
|
||||||
final int value; // ms
|
|
||||||
const EvaporasiIntervalRealtimeChanged(this.value);
|
|
||||||
@override List<Object?> get props => [value];
|
|
||||||
}
|
|
||||||
|
|
||||||
class EvaporasiIntervalHistoryChanged extends EvaporasiSettingsEvent {
|
|
||||||
final int value; // ms
|
|
||||||
const EvaporasiIntervalHistoryChanged(this.value);
|
|
||||||
@override List<Object?> get props => [value];
|
|
||||||
}
|
|
||||||
|
|
||||||
class EvaporasiIntervalBacaChanged extends EvaporasiSettingsEvent {
|
|
||||||
final int value; // ms
|
|
||||||
const EvaporasiIntervalBacaChanged(this.value);
|
|
||||||
@override List<Object?> get props => [value];
|
|
||||||
}
|
|
||||||
|
|
||||||
class EvaporasiPumpStartChanged extends EvaporasiSettingsEvent {
|
|
||||||
final String value; // HH:mm
|
|
||||||
const EvaporasiPumpStartChanged(this.value);
|
|
||||||
@override List<Object?> get props => [value];
|
|
||||||
}
|
|
||||||
|
|
||||||
class EvaporasiPumpEndChanged extends EvaporasiSettingsEvent {
|
|
||||||
final String value; // HH:mm
|
|
||||||
const EvaporasiPumpEndChanged(this.value);
|
|
||||||
@override List<Object?> get props => [value];
|
|
||||||
}
|
|
||||||
|
|
||||||
class EvaporasiStandarTinggiChanged extends EvaporasiSettingsEvent {
|
|
||||||
final double value;
|
|
||||||
const EvaporasiStandarTinggiChanged(this.value);
|
|
||||||
@override List<Object?> get props => [value];
|
|
||||||
}
|
|
||||||
|
|
||||||
class EvaporasiBatasKritisChanged extends EvaporasiSettingsEvent {
|
|
||||||
final double value;
|
|
||||||
const EvaporasiBatasKritisChanged(this.value);
|
|
||||||
@override List<Object?> get props => [value];
|
|
||||||
}
|
|
||||||
|
|
||||||
class EvaporasiD0Changed extends EvaporasiSettingsEvent {
|
|
||||||
final int value;
|
|
||||||
const EvaporasiD0Changed(this.value);
|
|
||||||
@override List<Object?> get props => [value];
|
|
||||||
}
|
|
||||||
|
|
||||||
class EvaporasiDmaxManualChanged extends EvaporasiSettingsEvent {
|
|
||||||
final int value;
|
|
||||||
const EvaporasiDmaxManualChanged(this.value);
|
|
||||||
@override List<Object?> get props => [value];
|
|
||||||
}
|
|
||||||
|
|
||||||
class EvaporasiSettingsSaved extends EvaporasiSettingsEvent {}
|
|
||||||
|
|
||||||
class EvaporasiDmaxResetRequested extends EvaporasiSettingsEvent {}
|
|
||||||
|
|
@ -1,953 +0,0 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:firebase_database/firebase_database.dart';
|
|
||||||
|
|
||||||
import 'evaporasi_settings_bloc.dart';
|
|
||||||
|
|
||||||
import '../../evaporasi/views/widgets/evaporasi_control_panel.dart';
|
|
||||||
import '../../evaporasi/views/evaporasi_screen.dart';
|
|
||||||
|
|
||||||
class EvaporasiSettingsScreen extends StatefulWidget {
|
|
||||||
const EvaporasiSettingsScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<EvaporasiSettingsScreen> createState() =>
|
|
||||||
_EvaporasiSettingsScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
|
|
||||||
final _rendahController = TextEditingController();
|
|
||||||
final _tinggiController = TextEditingController();
|
|
||||||
final _offsetController = TextEditingController();
|
|
||||||
final _pumpStartController = TextEditingController();
|
|
||||||
final _pumpEndController = TextEditingController();
|
|
||||||
final _d0Controller = TextEditingController();
|
|
||||||
final _dmaxManualController = TextEditingController();
|
|
||||||
final _standarController = TextEditingController();
|
|
||||||
final _batasController = TextEditingController();
|
|
||||||
|
|
||||||
void _syncControllers(EvaporasiSettingsState s) {
|
|
||||||
final rendah = s.thresholdRendah.toStringAsFixed(1);
|
|
||||||
final tinggi = s.thresholdTinggi.toStringAsFixed(1);
|
|
||||||
final offset = s.koreksiOffset.toStringAsFixed(2);
|
|
||||||
final pumpStart = s.pumpStartTime;
|
|
||||||
final pumpEnd = s.pumpEndTime;
|
|
||||||
final d0 = s.d0 == 0 ? '' : s.d0.toString();
|
|
||||||
final dmaxManual = s.dmaxManual == 0 ? '' : s.dmaxManual.toString();
|
|
||||||
final standar = s.standarTinggiCm.toStringAsFixed(1);
|
|
||||||
final batas = s.batasKritisCm.toStringAsFixed(1);
|
|
||||||
|
|
||||||
if (_rendahController.text != rendah) {
|
|
||||||
_rendahController.text = rendah;
|
|
||||||
}
|
|
||||||
if (_tinggiController.text != tinggi) {
|
|
||||||
_tinggiController.text = tinggi;
|
|
||||||
}
|
|
||||||
if (_offsetController.text != offset) {
|
|
||||||
_offsetController.text = offset;
|
|
||||||
}
|
|
||||||
if (_pumpStartController.text != pumpStart) {
|
|
||||||
_pumpStartController.text = pumpStart;
|
|
||||||
}
|
|
||||||
if (_pumpEndController.text != pumpEnd) {
|
|
||||||
_pumpEndController.text = pumpEnd;
|
|
||||||
}
|
|
||||||
if (_d0Controller.text != d0) {
|
|
||||||
_d0Controller.text = d0;
|
|
||||||
}
|
|
||||||
if (_dmaxManualController.text != dmaxManual) {
|
|
||||||
_dmaxManualController.text = dmaxManual;
|
|
||||||
}
|
|
||||||
if (_standarController.text != standar) {
|
|
||||||
_standarController.text = standar;
|
|
||||||
}
|
|
||||||
if (_batasController.text != batas) {
|
|
||||||
_batasController.text = batas;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _pickPumpTime(
|
|
||||||
BuildContext context,
|
|
||||||
TextEditingController controller,
|
|
||||||
ValueChanged<String> onSelected,
|
|
||||||
String initialValue) async {
|
|
||||||
final parts = initialValue.split(':');
|
|
||||||
final initial = TimeOfDay(
|
|
||||||
hour: int.tryParse(parts[0]) ?? 6,
|
|
||||||
minute: int.tryParse(parts[1]) ?? 0,
|
|
||||||
);
|
|
||||||
final picked = await showTimePicker(
|
|
||||||
context: context,
|
|
||||||
initialTime: initial,
|
|
||||||
helpText: 'Pilih Jam Pompa',
|
|
||||||
);
|
|
||||||
if (picked != null) {
|
|
||||||
final value =
|
|
||||||
'${picked.hour.toString().padLeft(2, '0')}:${picked.minute.toString().padLeft(2, '0')}';
|
|
||||||
controller.text = value;
|
|
||||||
onSelected(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_rendahController.dispose();
|
|
||||||
_tinggiController.dispose();
|
|
||||||
_offsetController.dispose();
|
|
||||||
_pumpStartController.dispose();
|
|
||||||
_pumpEndController.dispose();
|
|
||||||
_d0Controller.dispose();
|
|
||||||
_dmaxManualController.dispose();
|
|
||||||
_standarController.dispose();
|
|
||||||
_batasController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return BlocProvider(
|
|
||||||
create: (_) => EvaporasiSettingsBloc()..add(EvaporasiSettingsStarted()),
|
|
||||||
child: BlocConsumer<EvaporasiSettingsBloc, EvaporasiSettingsState>(
|
|
||||||
listener: (context, state) {
|
|
||||||
if (state.status == EvaporasiSettingsStatus.loaded ||
|
|
||||||
state.status == EvaporasiSettingsStatus.saved) {
|
|
||||||
_syncControllers(state);
|
|
||||||
}
|
|
||||||
if (state.status == EvaporasiSettingsStatus.saved) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
|
||||||
content: const Row(children: [
|
|
||||||
Icon(Icons.check_circle, color: Colors.white),
|
|
||||||
SizedBox(width: 10),
|
|
||||||
Text('Pengaturan berhasil disimpan ke Firebase.'),
|
|
||||||
]),
|
|
||||||
backgroundColor: Colors.green.shade600,
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(10)),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if (state.status == EvaporasiSettingsStatus.error &&
|
|
||||||
state.errorMessage != null) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
|
||||||
content: Text('Error: ${state.errorMessage}'),
|
|
||||||
backgroundColor: Colors.red.shade600,
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
builder: (context, state) {
|
|
||||||
final bloc = context.read<EvaporasiSettingsBloc>();
|
|
||||||
final isLoading = state.status == EvaporasiSettingsStatus.loading;
|
|
||||||
final isSaving = state.status == EvaporasiSettingsStatus.saving;
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: Colors.grey.shade100,
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text(
|
|
||||||
'Pengaturan Evaporasi',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
centerTitle: true,
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
foregroundColor: Colors.black,
|
|
||||||
elevation: 0,
|
|
||||||
),
|
|
||||||
body: isLoading
|
|
||||||
? const Center(child: CircularProgressIndicator())
|
|
||||||
: DefaultTabController(
|
|
||||||
length: 3,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: TabBar(
|
|
||||||
labelColor: Colors.blue.shade700,
|
|
||||||
unselectedLabelColor: Colors.grey.shade600,
|
|
||||||
indicatorColor: Colors.blue.shade700,
|
|
||||||
tabs: const [
|
|
||||||
Tab(text: 'Sistem & Kalibrasi'),
|
|
||||||
Tab(text: 'Aktuator & Kontrol'),
|
|
||||||
Tab(text: 'OTA & Firmware'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: TabBarView(
|
|
||||||
children: [
|
|
||||||
SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_InfoCard(
|
|
||||||
'Pengaturan disimpan ke Firebase Realtime DB dan langsung berlaku untuk perangkat.',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_groupTitle('Informasi Sistem & Sensor'),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_SettingsCard(children: [
|
|
||||||
_RealtimeInfo(state: state),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_CompensationCard(state: state),
|
|
||||||
]),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
_groupTitle('Interval Pengumpulan Data'),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_SettingsCard(children: [
|
|
||||||
_CollectionIntervalCard(
|
|
||||||
state: state,
|
|
||||||
onHistoryIntervalChanged: (v) {
|
|
||||||
final bloc = context
|
|
||||||
.read<EvaporasiSettingsBloc>();
|
|
||||||
bloc.add(
|
|
||||||
EvaporasiIntervalHistoryChanged(
|
|
||||||
v));
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_groupTitle('Aktuator & Pengaturan'),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_SettingsCard(children: [
|
|
||||||
_TimePickerField(
|
|
||||||
controller: _pumpStartController,
|
|
||||||
label: 'Jam Mulai Pompa',
|
|
||||||
helper:
|
|
||||||
'Pilih jam mulai pompa bekerja.',
|
|
||||||
onTap: () => _pickPumpTime(
|
|
||||||
context,
|
|
||||||
_pumpStartController,
|
|
||||||
(value) => bloc.add(
|
|
||||||
EvaporasiPumpStartChanged(value)),
|
|
||||||
state.pumpStartTime,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_TimePickerField(
|
|
||||||
controller: _pumpEndController,
|
|
||||||
label: 'Jam Selesai Pompa',
|
|
||||||
helper: 'Pilih jam berhenti pompa.',
|
|
||||||
onTap: () => _pickPumpTime(
|
|
||||||
context,
|
|
||||||
_pumpEndController,
|
|
||||||
(value) => bloc.add(
|
|
||||||
EvaporasiPumpEndChanged(value)),
|
|
||||||
state.pumpEndTime,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(height: 20),
|
|
||||||
_NumericField(
|
|
||||||
controller: _standarController,
|
|
||||||
label: 'Standar Tinggi Air (cm)',
|
|
||||||
hint: 'Contoh: 18.0',
|
|
||||||
helper:
|
|
||||||
'Nilai tinggi standar untuk selenoid (cm).',
|
|
||||||
onChanged: (v) {
|
|
||||||
final d = double.tryParse(v);
|
|
||||||
if (d != null && d >= 0) {
|
|
||||||
bloc.add(
|
|
||||||
EvaporasiStandarTinggiChanged(
|
|
||||||
d));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_NumericField(
|
|
||||||
controller: _batasController,
|
|
||||||
label: 'Batas Kritis (cm)',
|
|
||||||
hint: 'Contoh: 15.0',
|
|
||||||
helper:
|
|
||||||
'Jika di bawah nilai ini → pompa ON paksa.',
|
|
||||||
onChanged: (v) {
|
|
||||||
final d = double.tryParse(v);
|
|
||||||
if (d != null && d >= 0) {
|
|
||||||
bloc.add(
|
|
||||||
EvaporasiBatasKritisChanged(d));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
EvaporasiControlPanel(),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_groupTitle('OTA & Firmware'),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_SettingsCard(children: [
|
|
||||||
_InfoTile(
|
|
||||||
label: 'Versi Firmware',
|
|
||||||
value: state.firmwareVersion),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_InfoTile(
|
|
||||||
label: 'Status OTA',
|
|
||||||
value: state.otaStatus),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_InfoTile(
|
|
||||||
label: 'Riwayat Data',
|
|
||||||
value: '${state.historyCount} entri'),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_OtaCard(state: state),
|
|
||||||
]),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
onPressed: isSaving
|
|
||||||
? null
|
|
||||||
: () => bloc.add(EvaporasiSettingsSaved()),
|
|
||||||
icon: isSaving
|
|
||||||
? const SizedBox(
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2, color: Colors.white),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.save_rounded),
|
|
||||||
label: Text(isSaving
|
|
||||||
? 'Menyimpan...'
|
|
||||||
: 'Simpan ke Firebase'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.blue.shade700,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
padding:
|
|
||||||
const EdgeInsets.symmetric(vertical: 16),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(14)),
|
|
||||||
textStyle: const TextStyle(
|
|
||||||
fontSize: 15, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _groupTitle(String text) => Text(
|
|
||||||
text,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 15, fontWeight: FontWeight.bold, color: Colors.blueGrey),
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget _sectionTitle(String text) => Text(
|
|
||||||
text,
|
|
||||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ThresholdPreview extends StatelessWidget {
|
|
||||||
final EvaporasiSettingsState state;
|
|
||||||
const _ThresholdPreview({required this.state});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final rendah = state.thresholdRendah;
|
|
||||||
final tinggi = state.thresholdTinggi;
|
|
||||||
final isValid = rendah < tinggi;
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text('Preview Klasifikasi',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Colors.black54)),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
_StatusChip(
|
|
||||||
'Rendah', '< ${rendah.toStringAsFixed(0)} mm', Colors.green),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_StatusChip(
|
|
||||||
'Normal',
|
|
||||||
'${rendah.toStringAsFixed(0)}–${tinggi.toStringAsFixed(0)} mm',
|
|
||||||
Colors.orange),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_StatusChip(
|
|
||||||
'Tinggi', '≥ ${tinggi.toStringAsFixed(0)} mm', Colors.red),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (!isValid) ...[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Row(children: [
|
|
||||||
Icon(Icons.warning_rounded, size: 14, color: Colors.red.shade600),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text('Batas Rendah harus < batas Tinggi',
|
|
||||||
style: TextStyle(fontSize: 11, color: Colors.red.shade600)),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _StatusChip extends StatelessWidget {
|
|
||||||
final String label;
|
|
||||||
final String range;
|
|
||||||
final Color color;
|
|
||||||
const _StatusChip(this.label, this.range, this.color);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Expanded(
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: color.withOpacity(0.1),
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
border: Border.all(color: color.withOpacity(0.3)),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Text(label,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 11, fontWeight: FontWeight.bold, color: color)),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(range,
|
|
||||||
style: TextStyle(fontSize: 9, color: color),
|
|
||||||
textAlign: TextAlign.center),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class _InfoCard extends StatelessWidget {
|
|
||||||
final String text;
|
|
||||||
const _InfoCard(this.text);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.blue.shade50,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: Colors.blue.shade100),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.info_outline_rounded,
|
|
||||||
size: 18, color: Colors.blue.shade600),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(
|
|
||||||
child: Text(text,
|
|
||||||
style: TextStyle(fontSize: 12, color: Colors.blue.shade800)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SettingsCard extends StatelessWidget {
|
|
||||||
final List<Widget> children;
|
|
||||||
const _SettingsCard({required this.children});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 6),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start, children: children),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _NumericField extends StatelessWidget {
|
|
||||||
final TextEditingController controller;
|
|
||||||
final String label;
|
|
||||||
final String hint;
|
|
||||||
final String helper;
|
|
||||||
final bool allowNegative;
|
|
||||||
final ValueChanged<String> onChanged;
|
|
||||||
|
|
||||||
const _NumericField({
|
|
||||||
required this.controller,
|
|
||||||
required this.label,
|
|
||||||
required this.hint,
|
|
||||||
required this.helper,
|
|
||||||
required this.onChanged,
|
|
||||||
this.allowNegative = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return TextField(
|
|
||||||
controller: controller,
|
|
||||||
keyboardType:
|
|
||||||
const TextInputType.numberWithOptions(decimal: true, signed: true),
|
|
||||||
inputFormatters: [
|
|
||||||
FilteringTextInputFormatter.allow(
|
|
||||||
allowNegative ? RegExp(r'^-?[0-9]*\.?[0-9]*') : RegExp(r'[0-9.]'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: label,
|
|
||||||
hintText: hint,
|
|
||||||
helperText: helper,
|
|
||||||
helperMaxLines: 2,
|
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
|
||||||
contentPadding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
|
||||||
isDense: true,
|
|
||||||
),
|
|
||||||
onChanged: onChanged,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _TimePickerField extends StatelessWidget {
|
|
||||||
final TextEditingController controller;
|
|
||||||
final String label;
|
|
||||||
final String helper;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
|
|
||||||
const _TimePickerField({
|
|
||||||
required this.controller,
|
|
||||||
required this.label,
|
|
||||||
required this.helper,
|
|
||||||
required this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return TextField(
|
|
||||||
controller: controller,
|
|
||||||
readOnly: true,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: label,
|
|
||||||
hintText: 'HH:mm',
|
|
||||||
helperText: helper,
|
|
||||||
helperMaxLines: 2,
|
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
|
||||||
suffixIcon: const Icon(Icons.schedule_rounded),
|
|
||||||
contentPadding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
|
||||||
isDense: true,
|
|
||||||
),
|
|
||||||
onTap: onTap,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _IntervalSelector extends StatelessWidget {
|
|
||||||
final String label;
|
|
||||||
final String helper;
|
|
||||||
final Map<int, String> options;
|
|
||||||
final int selected;
|
|
||||||
final ValueChanged<int> onChanged;
|
|
||||||
|
|
||||||
const _IntervalSelector({
|
|
||||||
required this.label,
|
|
||||||
required this.helper,
|
|
||||||
required this.options,
|
|
||||||
required this.selected,
|
|
||||||
required this.onChanged,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(label,
|
|
||||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(helper,
|
|
||||||
style: TextStyle(fontSize: 11, color: Colors.grey.shade500)),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: options.entries.map((e) {
|
|
||||||
final isSelected = e.key == selected;
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () => onChanged(e.key),
|
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 180),
|
|
||||||
padding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color:
|
|
||||||
isSelected ? Colors.blue.shade700 : Colors.grey.shade100,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
border: Border.all(
|
|
||||||
color: isSelected
|
|
||||||
? Colors.blue.shade700
|
|
||||||
: Colors.grey.shade300,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
e.value,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: isSelected ? Colors.white : Colors.black87,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CompensationCard extends StatelessWidget {
|
|
||||||
final EvaporasiSettingsState state;
|
|
||||||
const _CompensationCard({required this.state});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade50,
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
border: Border.all(color: Colors.grey.shade200),
|
|
||||||
),
|
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
||||||
Row(children: [
|
|
||||||
Icon(Icons.thermostat_rounded,
|
|
||||||
size: 15, color: Colors.orange.shade700),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text('Kalibrasi & Kompensasi',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.orange.shade700)),
|
|
||||||
]),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Wrap(spacing: 12, runSpacing: 8, children: [
|
|
||||||
_InfoTile(
|
|
||||||
label: 'Snapshot (cm)',
|
|
||||||
value: state.snapshotCm > 0
|
|
||||||
? state.snapshotCm.toStringAsFixed(2)
|
|
||||||
: '--'),
|
|
||||||
_InfoTile(
|
|
||||||
label: 'Koef. Kompensasi',
|
|
||||||
value: state.tempCompCoef.toStringAsFixed(2)),
|
|
||||||
_InfoTile(
|
|
||||||
label: 'Ref Suhu (°C)', value: state.tempRefC.toStringAsFixed(1)),
|
|
||||||
_InfoTile(
|
|
||||||
label: 'Komp. Aktif',
|
|
||||||
value: state.tempCompActive ? 'Ya' : 'Tidak'),
|
|
||||||
_InfoTile(
|
|
||||||
label: 'Sensor Error', value: state.sensorError ? 'YA' : 'TIDAK'),
|
|
||||||
_InfoTile(label: 'NTP Sync', value: state.ntpSync ? 'OK' : 'GAGAL'),
|
|
||||||
])
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _OtaCard extends StatelessWidget {
|
|
||||||
final EvaporasiSettingsState state;
|
|
||||||
const _OtaCard({required this.state});
|
|
||||||
|
|
||||||
Future<void> _triggerOta(BuildContext context) async {
|
|
||||||
try {
|
|
||||||
await FirebaseDatabase.instance.ref('Monitoring/ota_trigger').set(true);
|
|
||||||
ScaffoldMessenger.of(context)
|
|
||||||
.showSnackBar(const SnackBar(content: Text('Trigger OTA dikirim.')));
|
|
||||||
} catch (e) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
|
||||||
content: Text('Gagal trigger OTA: $e'),
|
|
||||||
backgroundColor: Colors.red.shade600));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
||||||
Text('OTA kontrol',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Colors.blue.shade800)),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Row(children: [
|
|
||||||
Expanded(
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
onPressed: () => _triggerOta(context),
|
|
||||||
icon: const Icon(Icons.system_update_alt_rounded),
|
|
||||||
label: const Text('Trigger OTA'),
|
|
||||||
style:
|
|
||||||
ElevatedButton.styleFrom(backgroundColor: Colors.blue.shade700),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: () => Navigator.of(context)
|
|
||||||
.push(MaterialPageRoute(builder: (_) => const EvaporasiScreen())),
|
|
||||||
icon: const Icon(Icons.history_rounded),
|
|
||||||
label: const Text('Lihat Riwayat'),
|
|
||||||
),
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RealtimeInfo extends StatelessWidget {
|
|
||||||
final EvaporasiSettingsState state;
|
|
||||||
const _RealtimeInfo({required this.state});
|
|
||||||
|
|
||||||
String _fmtDate(DateTime? d) {
|
|
||||||
if (d == null) return '--';
|
|
||||||
final l = d.toLocal();
|
|
||||||
return '${l.day.toString().padLeft(2, '0')}/${l.month.toString().padLeft(2, '0')}/${l.year} ${l.hour.toString().padLeft(2, '0')}:${l.minute.toString().padLeft(2, '0')}:${l.second.toString().padLeft(2, '0')}';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade50,
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
border: Border.all(color: Colors.grey.shade200),
|
|
||||||
),
|
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
||||||
Row(children: [
|
|
||||||
Icon(Icons.devices_rounded, size: 15, color: Colors.blue.shade700),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text('Informasi Perangkat',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.blue.shade700)),
|
|
||||||
]),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Wrap(spacing: 12, runSpacing: 8, children: [
|
|
||||||
_InfoTile(label: 'Firmware', value: state.firmwareVersion),
|
|
||||||
_InfoTile(
|
|
||||||
label: 'WiFi',
|
|
||||||
value: state.wifiConnected ? 'Terhubung' : 'Tidak'),
|
|
||||||
_InfoTile(
|
|
||||||
label: 'Firebase',
|
|
||||||
value: state.firebaseConnected ? 'Terhubung' : 'Tidak'),
|
|
||||||
_InfoTile(
|
|
||||||
label: 'D0 aktif',
|
|
||||||
value: state.activeD0 == 0 ? '--' : state.activeD0.toString()),
|
|
||||||
_InfoTile(
|
|
||||||
label: 'DMAX aktif',
|
|
||||||
value:
|
|
||||||
state.activeDmax == 0 ? '--' : state.activeDmax.toString()),
|
|
||||||
_InfoTile(label: 'Terakhir', value: _fmtDate(state.lastUpdate)),
|
|
||||||
])
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _InfoTile extends StatelessWidget {
|
|
||||||
final String label;
|
|
||||||
final String value;
|
|
||||||
const _InfoTile({required this.label, required this.value});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
border: Border.all(color: Colors.grey.shade100),
|
|
||||||
),
|
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
||||||
Text(label,
|
|
||||||
style: TextStyle(fontSize: 11, color: Colors.grey.shade600)),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(value,
|
|
||||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold)),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
// Data Collection Interval Card
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
class _CollectionIntervalCard extends StatelessWidget {
|
|
||||||
final EvaporasiSettingsState state;
|
|
||||||
final ValueChanged<int> onHistoryIntervalChanged;
|
|
||||||
|
|
||||||
const _CollectionIntervalCard({
|
|
||||||
required this.state,
|
|
||||||
required this.onHistoryIntervalChanged,
|
|
||||||
});
|
|
||||||
|
|
||||||
static final Map<int, String> historyOptions = {
|
|
||||||
600000: '10 menit',
|
|
||||||
1800000: '30 menit',
|
|
||||||
3600000: '1 jam',
|
|
||||||
7200000: '2 jam',
|
|
||||||
14400000: '4 jam',
|
|
||||||
};
|
|
||||||
|
|
||||||
static String _getHistoryLabel(int ms) {
|
|
||||||
return historyOptions[ms] ?? '${ms ~/ 1000}s';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final currentInterval = state.intervalHistory_ms;
|
|
||||||
final dataPerDay = (24 * 60 * 60 * 1000) ~/ currentInterval;
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.schedule_outlined,
|
|
||||||
size: 15, color: Colors.teal.shade700),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
'Interval Pengumpulan Data',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.teal.shade700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
'Pilih interval untuk menyimpan data ke Firebase:',
|
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: historyOptions.entries.map((e) {
|
|
||||||
final isSelected = e.key == currentInterval;
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () => onHistoryIntervalChanged(e.key),
|
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 180),
|
|
||||||
padding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color:
|
|
||||||
isSelected ? Colors.teal.shade600 : Colors.grey.shade100,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
border: Border.all(
|
|
||||||
color: isSelected
|
|
||||||
? Colors.teal.shade600
|
|
||||||
: Colors.grey.shade300,
|
|
||||||
width: isSelected ? 2 : 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
e.value,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: isSelected ? Colors.white : Colors.black87,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.teal.shade50,
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
border: Border.all(color: Colors.teal.shade100),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.info_outline_rounded,
|
|
||||||
size: 14, color: Colors.teal.shade700),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
'Info',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.teal.shade700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(
|
|
||||||
'Interval saat ini: ${_getHistoryLabel(currentInterval)}',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.teal.shade700,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'Perkiraan data: ~$dataPerDay data per 24 jam',
|
|
||||||
style: TextStyle(fontSize: 11, color: Colors.teal.shade600),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'Interval lebih singkat = data lebih detail tapi lebih banyak penyimpanan',
|
|
||||||
style: TextStyle(fontSize: 10, color: Colors.teal.shade500),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
part of 'evaporasi_settings_bloc.dart';
|
|
||||||
|
|
||||||
enum EvaporasiSettingsStatus { loading, loaded, saving, saved, error }
|
|
||||||
|
|
||||||
class EvaporasiSettingsState extends Equatable {
|
|
||||||
final double thresholdRendah;
|
|
||||||
final double thresholdTinggi;
|
|
||||||
final double koreksiOffset;
|
|
||||||
final String pumpStartTime;
|
|
||||||
final String pumpEndTime;
|
|
||||||
final int d0;
|
|
||||||
final int dmaxManual;
|
|
||||||
final int dmax;
|
|
||||||
final bool isResettingDmax;
|
|
||||||
final String firmwareVersion;
|
|
||||||
final bool wifiConnected;
|
|
||||||
final bool firebaseConnected;
|
|
||||||
final int activeD0;
|
|
||||||
final int activeDmax;
|
|
||||||
final DateTime? lastUpdate;
|
|
||||||
|
|
||||||
// Interval (milidetik) — dikirim ke ESP32 via RTDB
|
|
||||||
final int intervalRealtime_ms; // default 300000 = 5 menit
|
|
||||||
final int intervalHistory_ms; // default 600000 = 10 menit
|
|
||||||
final int intervalBaca_ms; // default 10000 = 10 detik
|
|
||||||
|
|
||||||
final double standarTinggiCm;
|
|
||||||
final double batasKritisCm;
|
|
||||||
final bool tempCompActive;
|
|
||||||
final double tempCompCoef;
|
|
||||||
final double tempRefC;
|
|
||||||
final bool sensorError;
|
|
||||||
final bool ntpSync;
|
|
||||||
final double snapshotCm;
|
|
||||||
final bool otaTrigger;
|
|
||||||
final bool relayAktif;
|
|
||||||
final String otaStatus;
|
|
||||||
final int historyCount;
|
|
||||||
final DateTime? lastRealtime;
|
|
||||||
|
|
||||||
final EvaporasiSettingsStatus status;
|
|
||||||
final String? errorMessage;
|
|
||||||
|
|
||||||
const EvaporasiSettingsState({
|
|
||||||
this.thresholdRendah = 2.0,
|
|
||||||
this.thresholdTinggi = 10.0,
|
|
||||||
this.koreksiOffset = 0.0,
|
|
||||||
this.pumpStartTime = '06:00',
|
|
||||||
this.pumpEndTime = '18:00',
|
|
||||||
this.d0 = 0,
|
|
||||||
this.dmaxManual = 0,
|
|
||||||
this.intervalRealtime_ms = 300000,
|
|
||||||
this.intervalHistory_ms = 600000,
|
|
||||||
this.intervalBaca_ms = 10000,
|
|
||||||
this.standarTinggiCm = 18.0,
|
|
||||||
this.batasKritisCm = 15.0,
|
|
||||||
this.tempCompActive = true,
|
|
||||||
this.tempCompCoef = 500.0,
|
|
||||||
this.tempRefC = 25.0,
|
|
||||||
this.sensorError = false,
|
|
||||||
this.ntpSync = false,
|
|
||||||
this.snapshotCm = 0.0,
|
|
||||||
this.otaTrigger = false,
|
|
||||||
this.relayAktif = false,
|
|
||||||
this.otaStatus = '--',
|
|
||||||
this.historyCount = 0,
|
|
||||||
this.lastRealtime,
|
|
||||||
this.status = EvaporasiSettingsStatus.loading,
|
|
||||||
this.errorMessage,
|
|
||||||
this.dmax = 0,
|
|
||||||
this.isResettingDmax = false,
|
|
||||||
this.firmwareVersion = '--',
|
|
||||||
this.wifiConnected = false,
|
|
||||||
this.firebaseConnected = false,
|
|
||||||
this.activeD0 = 0,
|
|
||||||
this.activeDmax = 0,
|
|
||||||
this.lastUpdate,
|
|
||||||
});
|
|
||||||
|
|
||||||
EvaporasiSettingsState copyWith({
|
|
||||||
double? thresholdRendah,
|
|
||||||
double? thresholdTinggi,
|
|
||||||
double? koreksiOffset,
|
|
||||||
String? pumpStartTime,
|
|
||||||
String? pumpEndTime,
|
|
||||||
int? d0,
|
|
||||||
int? dmaxManual,
|
|
||||||
int? intervalRealtime_ms,
|
|
||||||
int? intervalHistory_ms,
|
|
||||||
int? intervalBaca_ms,
|
|
||||||
double? standarTinggiCm,
|
|
||||||
double? batasKritisCm,
|
|
||||||
bool? tempCompActive,
|
|
||||||
double? tempCompCoef,
|
|
||||||
double? tempRefC,
|
|
||||||
bool? sensorError,
|
|
||||||
bool? ntpSync,
|
|
||||||
double? snapshotCm,
|
|
||||||
bool? otaTrigger,
|
|
||||||
bool? relayAktif,
|
|
||||||
String? otaStatus,
|
|
||||||
int? historyCount,
|
|
||||||
DateTime? lastRealtime,
|
|
||||||
EvaporasiSettingsStatus? status,
|
|
||||||
String? errorMessage,
|
|
||||||
int? dmax,
|
|
||||||
bool? isResettingDmax,
|
|
||||||
String? firmwareVersion,
|
|
||||||
bool? wifiConnected,
|
|
||||||
bool? firebaseConnected,
|
|
||||||
int? activeD0,
|
|
||||||
int? activeDmax,
|
|
||||||
DateTime? lastUpdate,
|
|
||||||
}) {
|
|
||||||
return EvaporasiSettingsState(
|
|
||||||
thresholdRendah: thresholdRendah ?? this.thresholdRendah,
|
|
||||||
thresholdTinggi: thresholdTinggi ?? this.thresholdTinggi,
|
|
||||||
koreksiOffset: koreksiOffset ?? this.koreksiOffset,
|
|
||||||
pumpStartTime: pumpStartTime ?? this.pumpStartTime,
|
|
||||||
pumpEndTime: pumpEndTime ?? this.pumpEndTime,
|
|
||||||
d0: d0 ?? this.d0,
|
|
||||||
dmaxManual: dmaxManual ?? this.dmaxManual,
|
|
||||||
standarTinggiCm: standarTinggiCm ?? this.standarTinggiCm,
|
|
||||||
batasKritisCm: batasKritisCm ?? this.batasKritisCm,
|
|
||||||
tempCompActive: tempCompActive ?? this.tempCompActive,
|
|
||||||
tempCompCoef: tempCompCoef ?? this.tempCompCoef,
|
|
||||||
tempRefC: tempRefC ?? this.tempRefC,
|
|
||||||
sensorError: sensorError ?? this.sensorError,
|
|
||||||
ntpSync: ntpSync ?? this.ntpSync,
|
|
||||||
snapshotCm: snapshotCm ?? this.snapshotCm,
|
|
||||||
otaTrigger: otaTrigger ?? this.otaTrigger,
|
|
||||||
relayAktif: relayAktif ?? this.relayAktif,
|
|
||||||
otaStatus: otaStatus ?? this.otaStatus,
|
|
||||||
historyCount: historyCount ?? this.historyCount,
|
|
||||||
lastRealtime: lastRealtime ?? this.lastRealtime,
|
|
||||||
firmwareVersion: firmwareVersion ?? this.firmwareVersion,
|
|
||||||
wifiConnected: wifiConnected ?? this.wifiConnected,
|
|
||||||
firebaseConnected: firebaseConnected ?? this.firebaseConnected,
|
|
||||||
activeD0: activeD0 ?? this.activeD0,
|
|
||||||
activeDmax: activeDmax ?? this.activeDmax,
|
|
||||||
lastUpdate: lastUpdate ?? this.lastUpdate,
|
|
||||||
intervalRealtime_ms: intervalRealtime_ms ?? this.intervalRealtime_ms,
|
|
||||||
intervalHistory_ms: intervalHistory_ms ?? this.intervalHistory_ms,
|
|
||||||
intervalBaca_ms: intervalBaca_ms ?? this.intervalBaca_ms,
|
|
||||||
status: status ?? this.status,
|
|
||||||
errorMessage: errorMessage ?? this.errorMessage,
|
|
||||||
dmax: dmax ?? this.dmax,
|
|
||||||
isResettingDmax: isResettingDmax ?? this.isResettingDmax,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [
|
|
||||||
thresholdRendah, thresholdTinggi, koreksiOffset,
|
|
||||||
pumpStartTime, pumpEndTime, d0, dmaxManual,
|
|
||||||
intervalRealtime_ms, intervalHistory_ms, intervalBaca_ms,
|
|
||||||
standarTinggiCm, batasKritisCm, tempCompActive, tempCompCoef, tempRefC,
|
|
||||||
sensorError, ntpSync, snapshotCm, otaTrigger, relayAktif, historyCount, lastRealtime,
|
|
||||||
firmwareVersion, wifiConnected, firebaseConnected, activeD0, activeDmax, lastUpdate,
|
|
||||||
status, errorMessage,
|
|
||||||
dmax, isResettingDmax,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
|
// device_setup_wind_speed_screen.dart
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:monitoring_repository/monitoring_repository.dart';
|
import 'package:monitoring_repository/monitoring_repository.dart';
|
||||||
import 'package:app_settings/app_settings.dart';
|
import 'package:app_settings/app_settings.dart';
|
||||||
import '../blocs/device_setup_bloc.dart';
|
import '../blocs/device_setup_wind_speed_bloc.dart';
|
||||||
|
|
||||||
// ── Opsi device yang tersedia ─────────────────────────────────
|
// ── Opsi device yang tersedia ─────────────────────────────────
|
||||||
const _kDeviceOptions = ['esp_lapangan', 'esp_percobaan'];
|
const _kDeviceOptions = ['esp_lapangan', 'esp_percobaan'];
|
||||||
|
|
@ -18,8 +19,18 @@ const _kRealtimeOptions = <String, int>{
|
||||||
'1 jam': 3600000,
|
'1 jam': 3600000,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _kAverageOptions = <String, int>{
|
||||||
|
'30 detik': 30000,
|
||||||
|
'1 menit': 60000,
|
||||||
|
'5 menit': 300000,
|
||||||
|
'10 menit': 600000,
|
||||||
|
'30 menit': 1800000,
|
||||||
|
};
|
||||||
|
|
||||||
// ── Opsi interval history ─────────────────────────────────────
|
// ── Opsi interval history ─────────────────────────────────────
|
||||||
const _kHistoryOptions = <String, int>{
|
const _kHistoryOptions = <String, int>{
|
||||||
|
'30 detik': 30000,
|
||||||
|
'1 menit': 60000,
|
||||||
'10 menit': 600000,
|
'10 menit': 600000,
|
||||||
'30 menit': 1800000,
|
'30 menit': 1800000,
|
||||||
'1 jam': 3600000,
|
'1 jam': 3600000,
|
||||||
|
|
@ -45,8 +56,6 @@ class _DeviceSetupScreenState extends State<DeviceSetupScreen>
|
||||||
bool _obscurePassword = true;
|
bool _obscurePassword = true;
|
||||||
|
|
||||||
// Settings form controllers
|
// Settings form controllers
|
||||||
final _kFaktorController = TextEditingController();
|
|
||||||
final _radiusController = TextEditingController();
|
|
||||||
String? _customDeviceId;
|
String? _customDeviceId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -60,20 +69,11 @@ class _DeviceSetupScreenState extends State<DeviceSetupScreen>
|
||||||
_tabController.dispose();
|
_tabController.dispose();
|
||||||
_ssidController.dispose();
|
_ssidController.dispose();
|
||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
_kFaktorController.dispose();
|
|
||||||
_radiusController.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sync text controllers dengan state saat settings loaded ──
|
// ── Sync text controllers dengan state saat settings loaded ──
|
||||||
void _syncControllers(DeviceSetupState state) {
|
void _syncControllers(DeviceSetupState state) {}
|
||||||
if (_kFaktorController.text != state.kFaktor.toString()) {
|
|
||||||
_kFaktorController.text = state.kFaktor.toStringAsFixed(2);
|
|
||||||
}
|
|
||||||
if (_radiusController.text != state.radiusM.toString()) {
|
|
||||||
_radiusController.text = state.radiusM.toStringAsFixed(3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -144,8 +144,6 @@ class _DeviceSetupScreenState extends State<DeviceSetupScreen>
|
||||||
children: [
|
children: [
|
||||||
_SensorSettingsTab(
|
_SensorSettingsTab(
|
||||||
state: state,
|
state: state,
|
||||||
kFaktorController: _kFaktorController,
|
|
||||||
radiusController: _radiusController,
|
|
||||||
customDeviceId: _customDeviceId,
|
customDeviceId: _customDeviceId,
|
||||||
onCustomDeviceIdChanged: (v) =>
|
onCustomDeviceIdChanged: (v) =>
|
||||||
setState(() => _customDeviceId = v),
|
setState(() => _customDeviceId = v),
|
||||||
|
|
@ -215,15 +213,11 @@ class _DeviceSetupScreenState extends State<DeviceSetupScreen>
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
class _SensorSettingsTab extends StatelessWidget {
|
class _SensorSettingsTab extends StatelessWidget {
|
||||||
final DeviceSetupState state;
|
final DeviceSetupState state;
|
||||||
final TextEditingController kFaktorController;
|
|
||||||
final TextEditingController radiusController;
|
|
||||||
final String? customDeviceId;
|
final String? customDeviceId;
|
||||||
final ValueChanged<String?> onCustomDeviceIdChanged;
|
final ValueChanged<String?> onCustomDeviceIdChanged;
|
||||||
|
|
||||||
const _SensorSettingsTab({
|
const _SensorSettingsTab({
|
||||||
required this.state,
|
required this.state,
|
||||||
required this.kFaktorController,
|
|
||||||
required this.radiusController,
|
|
||||||
required this.customDeviceId,
|
required this.customDeviceId,
|
||||||
required this.onCustomDeviceIdChanged,
|
required this.onCustomDeviceIdChanged,
|
||||||
});
|
});
|
||||||
|
|
@ -259,36 +253,6 @@ class _SensorSettingsTab extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// ── Kalibrasi ────────────────────────────────────────
|
|
||||||
_sectionTitle('Kalibrasi'),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
_SettingsCard(children: [
|
|
||||||
_NumericField(
|
|
||||||
controller: kFaktorController,
|
|
||||||
label: 'K-Faktor',
|
|
||||||
hint: 'Contoh: 50.0',
|
|
||||||
suffix: '×',
|
|
||||||
helper: 'Konstanta kalibrasi vs AWS. Awal: 50.0',
|
|
||||||
onChanged: (v) {
|
|
||||||
final d = double.tryParse(v);
|
|
||||||
if (d != null && d > 0) bloc.add(KFaktorChanged(d));
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const Divider(height: 24),
|
|
||||||
_NumericField(
|
|
||||||
controller: radiusController,
|
|
||||||
label: 'Jari-jari Lengan',
|
|
||||||
hint: 'Contoh: 0.08',
|
|
||||||
suffix: 'm',
|
|
||||||
helper: 'Jarak pusat ke ujung cup anemometer. Default: 0.08 m',
|
|
||||||
onChanged: (v) {
|
|
||||||
final d = double.tryParse(v);
|
|
||||||
if (d != null && d > 0) bloc.add(RadiusChanged(d));
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// ── Jumlah Magnet ─────────────────────────────────────
|
// ── Jumlah Magnet ─────────────────────────────────────
|
||||||
_sectionTitle('Jumlah Magnet'),
|
_sectionTitle('Jumlah Magnet'),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
|
@ -320,6 +284,21 @@ class _SensorSettingsTab extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
_sectionTitle('Interval Rata-rata (Average)'),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Seberapa sering data rata-rata dikirim ke Firebase. '
|
||||||
|
'Harus lebih pendek dari interval history.',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_ChipSelector(
|
||||||
|
options: _kAverageOptions,
|
||||||
|
selectedMs: state.intervalAverageMs,
|
||||||
|
onSelected: (ms) => bloc.add(IntervalAverageChanged(ms)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// ── Interval History ──────────────────────────────────
|
// ── Interval History ──────────────────────────────────
|
||||||
_sectionTitle('Interval History (Rata-rata Jangka Panjang)'),
|
_sectionTitle('Interval History (Rata-rata Jangka Panjang)'),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
|
@ -336,14 +315,6 @@ class _SensorSettingsTab extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
// ── Rumus preview ─────────────────────────────────────
|
|
||||||
_FormulaPreview(
|
|
||||||
kFaktor: state.kFaktor,
|
|
||||||
radiusM: state.radiusM,
|
|
||||||
magnetCount: state.magnetCount,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
|
|
||||||
// ── Tombol Simpan ─────────────────────────────────────
|
// ── Tombol Simpan ─────────────────────────────────────
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
|
|
@ -977,135 +948,6 @@ class _ChipSelector extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _NumericField extends StatelessWidget {
|
|
||||||
final TextEditingController controller;
|
|
||||||
final String label;
|
|
||||||
final String hint;
|
|
||||||
final String suffix;
|
|
||||||
final String helper;
|
|
||||||
final ValueChanged<String> onChanged;
|
|
||||||
|
|
||||||
const _NumericField({
|
|
||||||
required this.controller,
|
|
||||||
required this.label,
|
|
||||||
required this.hint,
|
|
||||||
required this.suffix,
|
|
||||||
required this.helper,
|
|
||||||
required this.onChanged,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return TextField(
|
|
||||||
controller: controller,
|
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
||||||
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.]'))],
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: label,
|
|
||||||
hintText: hint,
|
|
||||||
suffixText: suffix,
|
|
||||||
helperText: helper,
|
|
||||||
helperMaxLines: 2,
|
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
|
||||||
contentPadding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
|
||||||
isDense: true,
|
|
||||||
),
|
|
||||||
onChanged: onChanged,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SettingsCard extends StatelessWidget {
|
|
||||||
final List<Widget> children;
|
|
||||||
const _SettingsCard({required this.children});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 6),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start, children: children),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _FormulaPreview extends StatelessWidget {
|
|
||||||
final double kFaktor;
|
|
||||||
final double radiusM;
|
|
||||||
final int magnetCount;
|
|
||||||
|
|
||||||
const _FormulaPreview({
|
|
||||||
required this.kFaktor,
|
|
||||||
required this.radiusM,
|
|
||||||
required this.magnetCount,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
// Contoh: 1 pulsa per detik
|
|
||||||
final exampleRps = 1.0 / magnetCount;
|
|
||||||
final exampleSpeed = 2 * 3.14159 * radiusM * exampleRps * kFaktor;
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade900,
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(children: [
|
|
||||||
Icon(Icons.functions_rounded,
|
|
||||||
size: 16, color: Colors.amber.shade300),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text('Preview Rumus',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.amber.shade300,
|
|
||||||
fontWeight: FontWeight.bold)),
|
|
||||||
]),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Text(
|
|
||||||
'speed = 2π × radius × (pulsa / (dt × magnet)) × k',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.grey.shade400,
|
|
||||||
fontFamily: 'monospace'),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(
|
|
||||||
' = 2π × ${radiusM.toStringAsFixed(3)}m × (p / (dt × $magnetCount)) × ${kFaktor.toStringAsFixed(1)}',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12, color: Colors.white70, fontFamily: 'monospace'),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Text(
|
|
||||||
'@ 1 pulsa/detik → ${exampleSpeed.toStringAsFixed(3)} m/s',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
color: Colors.green.shade300,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontFamily: 'monospace'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── WIDGET BARU: _MagnetCountSelector ─────────────────────────
|
|
||||||
// Tambahkan class baru ini di bagian bawah file,
|
|
||||||
// setelah class _FormulaPreview
|
|
||||||
|
|
||||||
class _MagnetCountSelector extends StatelessWidget {
|
class _MagnetCountSelector extends StatelessWidget {
|
||||||
final int selected;
|
final int selected;
|
||||||
final ValueChanged<int> onSelected;
|
final ValueChanged<int> onSelected;
|
||||||
|
|
@ -1,362 +0,0 @@
|
||||||
// lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart
|
|
||||||
|
|
||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:bloc/bloc.dart';
|
|
||||||
import 'package:equatable/equatable.dart';
|
|
||||||
import 'package:firebase_database/firebase_database.dart';
|
|
||||||
import 'package:monitoring_repository/monitoring_repository.dart';
|
|
||||||
|
|
||||||
import '../../../../blocs/notification_bloc/notification_bloc.dart';
|
|
||||||
import '../../../../core/utils/time_series_mapper.dart';
|
|
||||||
|
|
||||||
part 'evaporasi_event.dart';
|
|
||||||
part 'evaporasi_state.dart';
|
|
||||||
|
|
||||||
class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
|
||||||
static List<DateTime> _buildDayList(DateTime start, DateTime end) {
|
|
||||||
final s = DateTime(start.year, start.month, start.day);
|
|
||||||
final e = DateTime(end.year, end.month, end.day);
|
|
||||||
final days = <DateTime>[];
|
|
||||||
DateTime cur = s;
|
|
||||||
while (!cur.isAfter(e)) {
|
|
||||||
days.add(cur);
|
|
||||||
cur = cur.add(const Duration(days: 1));
|
|
||||||
}
|
|
||||||
return days;
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<String> _buildLabels(List<DateTime> days) {
|
|
||||||
return days.map((d) {
|
|
||||||
if (days.length <= 14) {
|
|
||||||
return '${d.day} ${_bulan(d.month)}';
|
|
||||||
}
|
|
||||||
return '${d.day}/${d.month}';
|
|
||||||
}).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _bulan(int m) {
|
|
||||||
const b = [
|
|
||||||
'',
|
|
||||||
'Jan',
|
|
||||||
'Feb',
|
|
||||||
'Mar',
|
|
||||||
'Apr',
|
|
||||||
'Mei',
|
|
||||||
'Jun',
|
|
||||||
'Jul',
|
|
||||||
'Agu',
|
|
||||||
'Sep',
|
|
||||||
'Okt',
|
|
||||||
'Nov',
|
|
||||||
'Des'
|
|
||||||
];
|
|
||||||
return b[m];
|
|
||||||
}
|
|
||||||
|
|
||||||
final MonitoringRepository _repository;
|
|
||||||
final NotificationBloc _notificationBloc;
|
|
||||||
StreamSubscription<Evaporasi>? _subscription;
|
|
||||||
double _thresholdRendah = 2.0;
|
|
||||||
double _thresholdTinggi = 10.0;
|
|
||||||
|
|
||||||
EvaporasiBloc({
|
|
||||||
required MonitoringRepository repository,
|
|
||||||
required NotificationBloc notificationBloc,
|
|
||||||
}) : _repository = repository,
|
|
||||||
_notificationBloc = notificationBloc,
|
|
||||||
super(EvaporasiState()) {
|
|
||||||
on<WatchEvaporasiStarted>(_onStarted);
|
|
||||||
on<_EvaporasiRealtimeUpdated>(_onRealtimeUpdated);
|
|
||||||
on<EvaporasiDateRangeChanged>(_onDateRangeChanged);
|
|
||||||
on<EvaporasiDateFilterChanged>(_onDateFilterChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
// START
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
Future<void> _onStarted(
|
|
||||||
WatchEvaporasiStarted event,
|
|
||||||
Emitter<EvaporasiState> emit,
|
|
||||||
) async {
|
|
||||||
emit(state.copyWith(isLoading: true));
|
|
||||||
|
|
||||||
final history = List<Evaporasi>.from(
|
|
||||||
await _repository.getSensorHistory(
|
|
||||||
'Monitoring/History',
|
|
||||||
(json) => Evaporasi.fromJson(json),
|
|
||||||
),
|
|
||||||
)..sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
|
||||||
final now = DateTime.now();
|
|
||||||
|
|
||||||
// Default: tampilkan hari ini (per jam)
|
|
||||||
final dailyEvap = TimeSeriesMapper.toDaily(
|
|
||||||
data: history,
|
|
||||||
getTime: (e) => e.timestamp,
|
|
||||||
getValue: (e) => e.evaporasi,
|
|
||||||
);
|
|
||||||
final dailyTemp = TimeSeriesMapper.toDaily(
|
|
||||||
data: history,
|
|
||||||
getTime: (e) => e.timestamp,
|
|
||||||
getValue: (e) => e.suhu,
|
|
||||||
);
|
|
||||||
final labels = List.generate(
|
|
||||||
24, (i) => '${i.toString().padLeft(2, '0')}:00');
|
|
||||||
|
|
||||||
await _loadThresholdSettings();
|
|
||||||
|
|
||||||
final lastValue = history.isNotEmpty ? history.last.evaporasi : 0.0;
|
|
||||||
final lastWater = history.isNotEmpty ? history.last.tinggiAir : 0.0;
|
|
||||||
final lastTemp = history.isNotEmpty ? history.last.suhu : 0.0;
|
|
||||||
|
|
||||||
final (status, willRain) = computeStatus(
|
|
||||||
lastValue,
|
|
||||||
thresholdRendah: _thresholdRendah,
|
|
||||||
thresholdTinggi: _thresholdTinggi,
|
|
||||||
);
|
|
||||||
_emitAlert(status, willRain, lastValue);
|
|
||||||
|
|
||||||
emit(state.copyWith(
|
|
||||||
history: history,
|
|
||||||
filteredHistory: history,
|
|
||||||
currentValue: lastValue,
|
|
||||||
waterLevel: lastWater,
|
|
||||||
temperature: lastTemp,
|
|
||||||
startDate: DateTime(now.year, now.month, now.day),
|
|
||||||
endDate: DateTime(now.year, now.month, now.day),
|
|
||||||
chartValues: dailyEvap,
|
|
||||||
chartTemperatures: dailyTemp,
|
|
||||||
chartLabels: labels,
|
|
||||||
weatherStatus: status,
|
|
||||||
willRain: willRain,
|
|
||||||
currentData: history.isNotEmpty ? history.last : null,
|
|
||||||
isLoading: false,
|
|
||||||
));
|
|
||||||
|
|
||||||
await _subscription?.cancel();
|
|
||||||
_subscription = _repository
|
|
||||||
.getSensorStream(
|
|
||||||
'Monitoring/realtime',
|
|
||||||
(json) {
|
|
||||||
return Evaporasi.fromJson(json);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.listen((data) => add(_EvaporasiRealtimeUpdated(data)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
// REALTIME UPDATE
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
Future<void> _onRealtimeUpdated(
|
|
||||||
_EvaporasiRealtimeUpdated event,
|
|
||||||
Emitter<EvaporasiState> emit,
|
|
||||||
) async {
|
|
||||||
await _loadThresholdSettings();
|
|
||||||
|
|
||||||
final (status, willRain) = computeStatus(
|
|
||||||
event.data.evaporasi,
|
|
||||||
thresholdRendah: _thresholdRendah,
|
|
||||||
thresholdTinggi: _thresholdTinggi,
|
|
||||||
);
|
|
||||||
_emitAlert(status, willRain, event.data.evaporasi);
|
|
||||||
|
|
||||||
emit(state.copyWith(
|
|
||||||
currentValue: event.data.evaporasi,
|
|
||||||
temperature: event.data.suhu,
|
|
||||||
waterLevel: event.data.tinggiAir,
|
|
||||||
weatherStatus: status,
|
|
||||||
willRain: willRain,
|
|
||||||
currentData: event.data,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
// DATE RANGE CHANGED
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
Future<void> _onDateRangeChanged(
|
|
||||||
EvaporasiDateRangeChanged event,
|
|
||||||
Emitter<EvaporasiState> emit,
|
|
||||||
) async {
|
|
||||||
emit(state.copyWith(isLoading: true));
|
|
||||||
|
|
||||||
final history = state.history;
|
|
||||||
final start = event.startDate;
|
|
||||||
final end = event.endDate;
|
|
||||||
|
|
||||||
final isSingle = _isSameDay(start, end);
|
|
||||||
|
|
||||||
List<double> values;
|
|
||||||
List<double> temps;
|
|
||||||
List<String> labels;
|
|
||||||
|
|
||||||
|
|
||||||
if (isSingle) {
|
|
||||||
// 1 hari → per jam
|
|
||||||
values = TimeSeriesMapper.toSpecificDate(
|
|
||||||
data: history,
|
|
||||||
getTime: (e) => e.timestamp,
|
|
||||||
getValue: (e) => e.evaporasi,
|
|
||||||
targetDate: start,
|
|
||||||
);
|
|
||||||
temps = TimeSeriesMapper.toSpecificDate(
|
|
||||||
data: history,
|
|
||||||
getTime: (e) => e.timestamp,
|
|
||||||
getValue: (e) => e.suhu,
|
|
||||||
targetDate: start,
|
|
||||||
);
|
|
||||||
labels = List.generate(24, (i) => '${i.toString().padLeft(2, '0')}:00');
|
|
||||||
} else {
|
|
||||||
// Range → per hari (tapi evaporasi dikalibrasi dengan rumus:
|
|
||||||
// E(hari ke-2) = max(evap hari ke-1) - max(evap hari ke-2))
|
|
||||||
final days = _buildDayList(start, end);
|
|
||||||
|
|
||||||
|
|
||||||
final dailyMaxEvap = List<double>.filled(days.length, 0.0);
|
|
||||||
final dailyMaxTemp = List<double>.filled(days.length, 0.0);
|
|
||||||
final dailyHasValue = List<bool>.filled(days.length, false);
|
|
||||||
|
|
||||||
|
|
||||||
for (final item in history) {
|
|
||||||
final d = DateTime(item.timestamp.year, item.timestamp.month, item.timestamp.day);
|
|
||||||
final idx = days.indexWhere((x) => x == d);
|
|
||||||
if (idx < 0) continue;
|
|
||||||
|
|
||||||
final evap = item.evaporasi;
|
|
||||||
final temp = item.suhu;
|
|
||||||
|
|
||||||
if (!dailyHasValue[idx]) {
|
|
||||||
dailyHasValue[idx] = true;
|
|
||||||
dailyMaxEvap[idx] = evap;
|
|
||||||
dailyMaxTemp[idx] = temp;
|
|
||||||
} else {
|
|
||||||
if (evap > dailyMaxEvap[idx]) dailyMaxEvap[idx] = evap;
|
|
||||||
if (temp > dailyMaxTemp[idx]) dailyMaxTemp[idx] = temp;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hitung E berbasis pasangan H1 (maks hari sebelumnya) - H2 (maks hari ini).
|
|
||||||
// Definisi sesuai permintaan: E untuk hari i = maxEvap(hari i-1) - maxEvap(hari i)
|
|
||||||
// Mulai tanggal 21 Mei s/d sekarang: untuk hari pertama di rentang, nilainya 0
|
|
||||||
final calibrated = List<double>.filled(days.length, 0.0);
|
|
||||||
for (int i = 1; i < days.length; i++) {
|
|
||||||
final e = dailyMaxEvap[i - 1] - dailyMaxEvap[i];
|
|
||||||
calibrated[i] = e < 0 ? 0.0 : e;
|
|
||||||
}
|
|
||||||
|
|
||||||
values = calibrated;
|
|
||||||
temps = dailyMaxTemp;
|
|
||||||
labels = _buildLabels(days);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
emit(state.copyWith(
|
|
||||||
startDate: start,
|
|
||||||
endDate: end,
|
|
||||||
chartValues: values,
|
|
||||||
chartTemperatures: temps,
|
|
||||||
chartLabels: labels,
|
|
||||||
isLoading: false,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
// DATE FILTER (LIST)
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
void _onDateFilterChanged(
|
|
||||||
EvaporasiDateFilterChanged event,
|
|
||||||
Emitter<EvaporasiState> emit,
|
|
||||||
) {
|
|
||||||
final date = event.date;
|
|
||||||
if (date == null) {
|
|
||||||
emit(state.copyWith(
|
|
||||||
filteredHistory: state.history,
|
|
||||||
clearSelectedDateFilter: true,
|
|
||||||
));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final filtered = state.history.where((item) =>
|
|
||||||
item.timestamp.year == date.year &&
|
|
||||||
item.timestamp.month == date.month &&
|
|
||||||
item.timestamp.day == date.day).toList();
|
|
||||||
|
|
||||||
emit(state.copyWith(
|
|
||||||
filteredHistory: filtered,
|
|
||||||
selectedDateFilter: date,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
// HELPERS
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
static bool _isSameDay(DateTime a, DateTime b) =>
|
|
||||||
a.year == b.year && a.month == b.month && a.day == b.day;
|
|
||||||
|
|
||||||
static (String, bool) computeStatus(
|
|
||||||
double value, {
|
|
||||||
double thresholdRendah = 2.0,
|
|
||||||
double thresholdTinggi = 10.0,
|
|
||||||
}) {
|
|
||||||
if (value >= thresholdTinggi) return ('Tinggi', true);
|
|
||||||
if (value >= thresholdRendah) return ('Normal', false);
|
|
||||||
return ('Rendah', false);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadThresholdSettings() async {
|
|
||||||
try {
|
|
||||||
final snap = await FirebaseDatabase.instance
|
|
||||||
.ref('Monitoring/settings/evaporasi')
|
|
||||||
.get();
|
|
||||||
if (!snap.exists || snap.value == null) return;
|
|
||||||
|
|
||||||
final data = Map<String, dynamic>.from(snap.value as Map);
|
|
||||||
_thresholdRendah = _toDouble(data['threshold_rendah'], _thresholdRendah);
|
|
||||||
_thresholdTinggi = _toDouble(data['threshold_tinggi'], _thresholdTinggi);
|
|
||||||
} catch (_) {
|
|
||||||
// Tetap pakai nilai default jika pembacaan gagal.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static double _toDouble(dynamic value, double fallback) {
|
|
||||||
if (value == null) return fallback;
|
|
||||||
if (value is num) return value.toDouble();
|
|
||||||
if (value is String) return double.tryParse(value) ?? fallback;
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _emitAlert(String status, bool willRain, double value) {
|
|
||||||
final AlertSeverity severity;
|
|
||||||
final String message;
|
|
||||||
if (status == 'Tinggi') {
|
|
||||||
severity = AlertSeverity.danger;
|
|
||||||
message = 'Evaporasi ${value.toStringAsFixed(1)} mm — TINGGI';
|
|
||||||
} else if (status == 'Normal') {
|
|
||||||
severity = AlertSeverity.warning;
|
|
||||||
message = 'Evaporasi ${value.toStringAsFixed(1)} mm — Normal';
|
|
||||||
} else {
|
|
||||||
severity = AlertSeverity.info;
|
|
||||||
message = '';
|
|
||||||
}
|
|
||||||
_notificationBloc.add(SensorAlertAdded(SensorAlert(
|
|
||||||
sensorId: 'evaporasi',
|
|
||||||
sensorName: 'Evaporasi',
|
|
||||||
message: message,
|
|
||||||
severity: severity,
|
|
||||||
timestamp: DateTime.now(),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> close() async {
|
|
||||||
await _subscription?.cancel();
|
|
||||||
return super.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _EvaporasiRealtimeUpdated extends EvaporasiEvent {
|
|
||||||
final Evaporasi data;
|
|
||||||
const _EvaporasiRealtimeUpdated(this.data);
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [data];
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
// lib/screens/monitoring/evaporasi/blocs/evaporasi_event.dart
|
|
||||||
part of 'evaporasi_bloc.dart';
|
|
||||||
|
|
||||||
abstract class EvaporasiEvent extends Equatable {
|
|
||||||
const EvaporasiEvent();
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mulai monitoring
|
|
||||||
class WatchEvaporasiStarted extends EvaporasiEvent {}
|
|
||||||
|
|
||||||
/// Pilih rentang tanggal untuk grafik
|
|
||||||
/// startDate == endDate → tampilkan per jam (1 hari)
|
|
||||||
/// startDate != endDate → tampilkan per hari (range)
|
|
||||||
class EvaporasiDateRangeChanged extends EvaporasiEvent {
|
|
||||||
final DateTime startDate;
|
|
||||||
final DateTime endDate;
|
|
||||||
|
|
||||||
const EvaporasiDateRangeChanged({
|
|
||||||
required this.startDate,
|
|
||||||
required this.endDate,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [startDate, endDate];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Filter list history berdasarkan tanggal
|
|
||||||
/// Kirim date = null untuk reset
|
|
||||||
class EvaporasiDateFilterChanged extends EvaporasiEvent {
|
|
||||||
final DateTime? date;
|
|
||||||
const EvaporasiDateFilterChanged(this.date);
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [date];
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
// lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart
|
|
||||||
part of 'evaporasi_bloc.dart';
|
|
||||||
|
|
||||||
class EvaporasiState extends Equatable {
|
|
||||||
final double currentValue;
|
|
||||||
final double temperature;
|
|
||||||
final double waterLevel;
|
|
||||||
|
|
||||||
// Rentang tanggal aktif untuk grafik
|
|
||||||
final DateTime startDate;
|
|
||||||
final DateTime endDate;
|
|
||||||
|
|
||||||
// Data grafik
|
|
||||||
final List<double> chartValues;
|
|
||||||
final List<double> chartTemperatures;
|
|
||||||
final List<String> chartLabels;
|
|
||||||
|
|
||||||
// Data list
|
|
||||||
final List<Evaporasi> history;
|
|
||||||
final List<Evaporasi> filteredHistory;
|
|
||||||
|
|
||||||
// Filter list
|
|
||||||
final DateTime? selectedDateFilter;
|
|
||||||
|
|
||||||
final String weatherStatus;
|
|
||||||
final bool willRain;
|
|
||||||
final Evaporasi? currentData;
|
|
||||||
final bool isLoading;
|
|
||||||
|
|
||||||
EvaporasiState({
|
|
||||||
this.currentValue = 0.0,
|
|
||||||
this.temperature = 0.0,
|
|
||||||
this.waterLevel = 0.0,
|
|
||||||
DateTime? startDate,
|
|
||||||
DateTime? endDate,
|
|
||||||
this.chartValues = const [],
|
|
||||||
this.chartTemperatures = const [],
|
|
||||||
this.chartLabels = const [],
|
|
||||||
this.history = const [],
|
|
||||||
this.filteredHistory = const [],
|
|
||||||
this.selectedDateFilter,
|
|
||||||
this.weatherStatus = 'Rendah',
|
|
||||||
this.willRain = false,
|
|
||||||
this.currentData,
|
|
||||||
this.isLoading = true,
|
|
||||||
}) : startDate = startDate ?? DateTime.now(),
|
|
||||||
endDate = endDate ?? DateTime.now();
|
|
||||||
|
|
||||||
EvaporasiState copyWith({
|
|
||||||
double? currentValue,
|
|
||||||
double? temperature,
|
|
||||||
double? waterLevel,
|
|
||||||
DateTime? startDate,
|
|
||||||
DateTime? endDate,
|
|
||||||
List<double>? chartValues,
|
|
||||||
List<double>? chartTemperatures,
|
|
||||||
List<String>? chartLabels,
|
|
||||||
List<Evaporasi>? history,
|
|
||||||
List<Evaporasi>? filteredHistory,
|
|
||||||
DateTime? selectedDateFilter,
|
|
||||||
bool clearSelectedDateFilter = false,
|
|
||||||
String? weatherStatus,
|
|
||||||
bool? willRain,
|
|
||||||
Evaporasi? currentData,
|
|
||||||
bool? isLoading,
|
|
||||||
}) {
|
|
||||||
return EvaporasiState(
|
|
||||||
currentValue: currentValue ?? this.currentValue,
|
|
||||||
temperature: temperature ?? this.temperature,
|
|
||||||
waterLevel: waterLevel ?? this.waterLevel,
|
|
||||||
startDate: startDate ?? this.startDate,
|
|
||||||
endDate: endDate ?? this.endDate,
|
|
||||||
chartValues: chartValues ?? this.chartValues,
|
|
||||||
chartTemperatures: chartTemperatures ?? this.chartTemperatures,
|
|
||||||
chartLabels: chartLabels ?? this.chartLabels,
|
|
||||||
history: history ?? this.history,
|
|
||||||
filteredHistory: filteredHistory ?? this.filteredHistory,
|
|
||||||
selectedDateFilter: clearSelectedDateFilter
|
|
||||||
? null
|
|
||||||
: (selectedDateFilter ?? this.selectedDateFilter),
|
|
||||||
weatherStatus: weatherStatus ?? this.weatherStatus,
|
|
||||||
willRain: willRain ?? this.willRain,
|
|
||||||
currentData: currentData ?? this.currentData,
|
|
||||||
isLoading: isLoading ?? this.isLoading,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1 hari = tampil per jam, > 1 hari = tampil per hari
|
|
||||||
bool get isSingleDay {
|
|
||||||
final s = DateTime(startDate.year, startDate.month, startDate.day);
|
|
||||||
final e = DateTime(endDate.year, endDate.month, endDate.day);
|
|
||||||
return s == e;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [
|
|
||||||
currentValue, temperature, waterLevel,
|
|
||||||
startDate, endDate,
|
|
||||||
chartValues, chartTemperatures, chartLabels,
|
|
||||||
history, filteredHistory, selectedDateFilter,
|
|
||||||
weatherStatus, willRain, currentData, isLoading,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
@ -1,664 +0,0 @@
|
||||||
// lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart
|
|
||||||
|
|
||||||
import '../../device_setup/blocs/evaporasi_settings_screen.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
import '../blocs/evaporasi_bloc.dart';
|
|
||||||
import '../../shared/utils/pdf/pdf_export_service.dart';
|
|
||||||
import '../../shared/widgets/export_pdf_button.dart';
|
|
||||||
import 'widgets/evaporasi_chart_widget.dart';
|
|
||||||
import 'widgets/evaporasi_control_panel.dart';
|
|
||||||
import 'widgets/evaporasi_range_selector.dart';
|
|
||||||
import 'widgets/evaporasi_history_list.dart';
|
|
||||||
import '../../shared/utils/excel/evaporasi_excel_service.dart';
|
|
||||||
|
|
||||||
class EvaporasiScreen extends StatefulWidget {
|
|
||||||
const EvaporasiScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<EvaporasiScreen> createState() => _EvaporasiScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
|
||||||
// ── Dialog export: nama file + date range ───────────────────
|
|
||||||
Future<void> _showExportDialog(
|
|
||||||
BuildContext context, EvaporasiState state) async {
|
|
||||||
DateTime firstDate = DateTime.now().subtract(const Duration(days: 365));
|
|
||||||
DateTime lastDate = DateTime.now();
|
|
||||||
if (state.history.isNotEmpty) {
|
|
||||||
final sorted = [...state.history]
|
|
||||||
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
|
||||||
firstDate = DateTime(sorted.first.timestamp.year,
|
|
||||||
sorted.first.timestamp.month, sorted.first.timestamp.day);
|
|
||||||
lastDate = DateTime(sorted.last.timestamp.year,
|
|
||||||
sorted.last.timestamp.month, sorted.last.timestamp.day);
|
|
||||||
}
|
|
||||||
|
|
||||||
final nameController = TextEditingController(
|
|
||||||
text: 'evaporasi_${DateFormat('ddMMyyyy').format(DateTime.now())}',
|
|
||||||
);
|
|
||||||
DateTime? dateFrom;
|
|
||||||
DateTime? dateTo;
|
|
||||||
|
|
||||||
await showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => StatefulBuilder(
|
|
||||||
builder: (ctx, setDialogState) {
|
|
||||||
final fmt = DateFormat('dd MMM yyyy', 'id_ID');
|
|
||||||
|
|
||||||
Future<void> pickRange() async {
|
|
||||||
final range = await showDateRangePicker(
|
|
||||||
context: ctx,
|
|
||||||
firstDate: firstDate,
|
|
||||||
lastDate: lastDate,
|
|
||||||
initialDateRange: dateFrom != null && dateTo != null
|
|
||||||
? DateTimeRange(start: dateFrom!, end: dateTo!)
|
|
||||||
: null,
|
|
||||||
locale: const Locale('id', 'ID'),
|
|
||||||
builder: (ctx, child) => Theme(
|
|
||||||
data: Theme.of(ctx).copyWith(
|
|
||||||
colorScheme: ColorScheme.light(
|
|
||||||
primary: Colors.blue.shade700,
|
|
||||||
onPrimary: Colors.white,
|
|
||||||
surface: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: child!,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (range != null) {
|
|
||||||
setDialogState(() {
|
|
||||||
dateFrom = range.start;
|
|
||||||
dateTo = range.end;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return AlertDialog(
|
|
||||||
shape:
|
|
||||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
|
||||||
title: Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.file_download_outlined, color: Colors.blue.shade700),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
const Text('Export Excel',
|
|
||||||
style:
|
|
||||||
TextStyle(fontSize: 17, fontWeight: FontWeight.bold)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
content: SizedBox(
|
|
||||||
width: 340,
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// ── Nama file ──────────────────────────────
|
|
||||||
const Text('Nama file',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Colors.black54)),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
TextField(
|
|
||||||
controller: nameController,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'nama_file',
|
|
||||||
suffixText: '.xlsx',
|
|
||||||
filled: true,
|
|
||||||
fillColor: Colors.grey.shade100,
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 14, vertical: 10),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
|
||||||
),
|
|
||||||
enabledBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
|
||||||
),
|
|
||||||
focusedBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
borderSide:
|
|
||||||
BorderSide(color: Colors.blue.shade600, width: 1.5),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
|
|
||||||
// ── Range tanggal ──────────────────────────
|
|
||||||
const Text('Filter rentang tanggal',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Colors.black54)),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
InkWell(
|
|
||||||
onTap: pickRange,
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
child: Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 14, vertical: 12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade100,
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
border: Border.all(color: Colors.grey.shade300),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.date_range_rounded,
|
|
||||||
size: 18, color: Colors.blue.shade600),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
dateFrom != null && dateTo != null
|
|
||||||
? '${fmt.format(dateFrom!)} → ${fmt.format(dateTo!)}'
|
|
||||||
: 'Semua data (tanpa filter)',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
color: dateFrom != null
|
|
||||||
? Colors.black87
|
|
||||||
: Colors.grey.shade500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (dateFrom != null)
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => setDialogState(() {
|
|
||||||
dateFrom = null;
|
|
||||||
dateTo = null;
|
|
||||||
}),
|
|
||||||
child: Icon(Icons.close_rounded,
|
|
||||||
size: 16, color: Colors.grey.shade500),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (dateFrom != null) ...[
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(
|
|
||||||
'${state.history.where((e) {
|
|
||||||
final d = DateTime(e.timestamp.year, e.timestamp.month,
|
|
||||||
e.timestamp.day);
|
|
||||||
final from = DateTime(
|
|
||||||
dateFrom!.year, dateFrom!.month, dateFrom!.day);
|
|
||||||
final to =
|
|
||||||
DateTime(dateTo!.year, dateTo!.month, dateTo!.day);
|
|
||||||
return !d.isBefore(from) && !d.isAfter(to);
|
|
||||||
}).length} data dalam rentang ini',
|
|
||||||
style:
|
|
||||||
TextStyle(fontSize: 11, color: Colors.blue.shade600),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx),
|
|
||||||
child: Text('Batal',
|
|
||||||
style: TextStyle(color: Colors.grey.shade600)),
|
|
||||||
),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pop(ctx);
|
|
||||||
_doExport(
|
|
||||||
context,
|
|
||||||
state,
|
|
||||||
nameController.text.trim(),
|
|
||||||
dateFrom,
|
|
||||||
dateTo,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.download_rounded, size: 16),
|
|
||||||
label: const Text('Export'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.blue.shade700,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(10)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
nameController.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Proses export setelah dialog ditutup ────────────────────
|
|
||||||
Future<void> _doExport(
|
|
||||||
BuildContext context,
|
|
||||||
EvaporasiState state,
|
|
||||||
String customName,
|
|
||||||
DateTime? dateFrom,
|
|
||||||
DateTime? dateTo,
|
|
||||||
) async {
|
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
|
||||||
final fileName = customName.isEmpty
|
|
||||||
? 'evaporasi_${DateFormat('ddMMyyyy').format(DateTime.now())}'
|
|
||||||
: customName;
|
|
||||||
|
|
||||||
try {
|
|
||||||
messenger.showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Row(children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2, color: Colors.white)),
|
|
||||||
SizedBox(width: 12),
|
|
||||||
Text('Membuat file Excel...'),
|
|
||||||
]),
|
|
||||||
duration: Duration(seconds: 30),
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await EvaporasiExcelService.export(
|
|
||||||
currentValue: state.currentValue,
|
|
||||||
temperature: state.temperature,
|
|
||||||
waterLevel: state.waterLevel,
|
|
||||||
acuanPagi: state.currentData?.acuanPagi ?? 0.0,
|
|
||||||
weatherStatus: state.weatherStatus,
|
|
||||||
history: state.history,
|
|
||||||
fileName: fileName,
|
|
||||||
dateFrom: dateFrom,
|
|
||||||
dateTo: dateTo,
|
|
||||||
);
|
|
||||||
|
|
||||||
messenger.hideCurrentSnackBar();
|
|
||||||
messenger.showSnackBar(SnackBar(
|
|
||||||
content: Row(children: [
|
|
||||||
const Icon(Icons.check_circle, color: Colors.white),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(child: Text('$fileName.xlsx berhasil dibuat!')),
|
|
||||||
]),
|
|
||||||
backgroundColor: Colors.green.shade600,
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
|
||||||
duration: const Duration(seconds: 3),
|
|
||||||
));
|
|
||||||
} catch (e) {
|
|
||||||
messenger.hideCurrentSnackBar();
|
|
||||||
messenger.showSnackBar(SnackBar(
|
|
||||||
content: Text('Gagal: $e'),
|
|
||||||
backgroundColor: Colors.red.shade600,
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: Colors.grey.shade100,
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text(
|
|
||||||
'Evaporasi',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
centerTitle: true,
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
elevation: 0,
|
|
||||||
foregroundColor: Colors.black,
|
|
||||||
actions: [
|
|
||||||
BlocBuilder<EvaporasiBloc, EvaporasiState>(
|
|
||||||
builder: (context, state) => IconButton(
|
|
||||||
tooltip: 'Export Excel',
|
|
||||||
icon: const Icon(Icons.file_download_outlined),
|
|
||||||
onPressed: state.history.isEmpty
|
|
||||||
? null
|
|
||||||
: () => _showExportDialog(context, state),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: 'Pengaturan',
|
|
||||||
icon: const Icon(Icons.settings_outlined),
|
|
||||||
onPressed: () => Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => const EvaporasiSettingsScreen(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: BlocBuilder<EvaporasiBloc, EvaporasiState>(
|
|
||||||
builder: (context, state) {
|
|
||||||
if (state.isLoading) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
final historyMaps = state.history
|
|
||||||
.map((e) => {
|
|
||||||
'timestamp': e.timestamp,
|
|
||||||
'evaporasi': e.evaporasi,
|
|
||||||
'suhu': e.suhu,
|
|
||||||
'tinggiAir': e.tinggiAir,
|
|
||||||
})
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
return SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// ── Main Card ───────────────────────────────
|
|
||||||
_mainCard(state),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
|
|
||||||
// ── Info Row ────────────────────────────────
|
|
||||||
_infoRow(state),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
|
|
||||||
// ── Status Card ─────────────────────────────
|
|
||||||
_statusCard(state),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
|
|
||||||
// ── Range Selector ──────────────────────────
|
|
||||||
const Text(
|
|
||||||
'Tren Evaporasi & Suhu',
|
|
||||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
const EvaporasiRangeSelector(),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
|
|
||||||
// ── Block nilai E (kalibrasi H1 - H2) ──────────────────
|
|
||||||
BlocBuilder<EvaporasiBloc, EvaporasiState>(
|
|
||||||
builder: (context, s) {
|
|
||||||
if (s.chartValues.isEmpty) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Untuk range > 1 hari, chartValues berisi E per hari
|
|
||||||
final isRange = !s.isSingleDay;
|
|
||||||
if (!isRange) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tampilkan E pada hari terakhir rentang (hari ke-N)
|
|
||||||
final eLast = s.chartValues.last;
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
border: Border.all(color: Colors.blue.shade100),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: const [
|
|
||||||
Icon(Icons.calculate_rounded,
|
|
||||||
color: Colors.blue, size: 20),
|
|
||||||
SizedBox(width: 10),
|
|
||||||
Text(
|
|
||||||
'Nilai Evaporasi Terkalibrasi (E)',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'E terakhir (hari terakhir rentang): ${eLast.toStringAsFixed(2)} mm',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.blue.shade700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(
|
|
||||||
'Rumus: E = max(H1) − max(H2)',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.blueGrey.shade700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
|
|
||||||
// ── Chart ───────────────────────────────────
|
|
||||||
BlocBuilder<EvaporasiBloc, EvaporasiState>(
|
|
||||||
builder: (context, s) => EvaporasiChartWidget(
|
|
||||||
dailyValues: s.chartValues,
|
|
||||||
dailyTemperatures: s.chartTemperatures,
|
|
||||||
period: s.isSingleDay ? 'Hari Ini' : 'Range',
|
|
||||||
chartLabels: s.chartLabels,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
|
|
||||||
// ── Riwayat Data ────────────────────────────
|
|
||||||
BlocBuilder<EvaporasiBloc, EvaporasiState>(
|
|
||||||
builder: (context, s) => EvaporasiHistoryList(
|
|
||||||
history: s.filteredHistory,
|
|
||||||
selectedDate: s.selectedDateFilter,
|
|
||||||
onPickDate: () async {
|
|
||||||
final picked = await showDatePicker(
|
|
||||||
context: context,
|
|
||||||
initialDate: DateTime.now(),
|
|
||||||
firstDate: DateTime(2024),
|
|
||||||
lastDate: DateTime.now(),
|
|
||||||
locale: const Locale('id', 'ID'),
|
|
||||||
);
|
|
||||||
if (picked != null && context.mounted) {
|
|
||||||
context.read<EvaporasiBloc>().add(
|
|
||||||
EvaporasiDateFilterChanged(picked),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onClearDate: () {
|
|
||||||
context.read<EvaporasiBloc>().add(
|
|
||||||
const EvaporasiDateFilterChanged(null),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
|
|
||||||
// ── Export PDF ──────────────────────────────
|
|
||||||
ExportPdfButton(
|
|
||||||
onExport: () => PdfExportService.evaporasi(
|
|
||||||
evaporasi: state.currentValue,
|
|
||||||
suhu: state.temperature,
|
|
||||||
tinggiAir: state.waterLevel,
|
|
||||||
timestamp: DateTime.now(),
|
|
||||||
historyData: historyMaps.isNotEmpty ? historyMaps : null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _mainCard(EvaporasiState state) {
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [Colors.blue.shade400, Colors.blue.shade800],
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(25),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.water_drop, color: Colors.white, size: 45),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Text(
|
|
||||||
state.currentValue.toStringAsFixed(2),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 70,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Text(
|
|
||||||
'mm',
|
|
||||||
style: TextStyle(color: Colors.white70, fontSize: 18),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _infoRow(EvaporasiState state) {
|
|
||||||
return LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final cardWidth = (constraints.maxWidth - 12) / 2;
|
|
||||||
return Wrap(
|
|
||||||
spacing: 12,
|
|
||||||
runSpacing: 12,
|
|
||||||
children: [
|
|
||||||
_miniCard(
|
|
||||||
cardWidth,
|
|
||||||
'Suhu Air',
|
|
||||||
state.temperature < 0
|
|
||||||
? '- °C'
|
|
||||||
: '${state.temperature.toStringAsFixed(1)} °C',
|
|
||||||
Icons.thermostat,
|
|
||||||
Colors.orange,
|
|
||||||
),
|
|
||||||
_miniCard(
|
|
||||||
cardWidth,
|
|
||||||
'Tinggi Air',
|
|
||||||
'${state.waterLevel.toStringAsFixed(1)} cm',
|
|
||||||
Icons.water,
|
|
||||||
Colors.blue,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _miniCard(
|
|
||||||
double width, String title, String value, IconData icon, Color color) {
|
|
||||||
return Container(
|
|
||||||
width: width,
|
|
||||||
padding: const EdgeInsets.all(15),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(icon, color: color),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
value,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 13,
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _statusCard(EvaporasiState state) {
|
|
||||||
Color statusColor;
|
|
||||||
IconData statusIcon;
|
|
||||||
String warningText;
|
|
||||||
|
|
||||||
switch (state.weatherStatus) {
|
|
||||||
case 'Normal':
|
|
||||||
statusColor = Colors.orange;
|
|
||||||
statusIcon = Icons.warning_amber_rounded;
|
|
||||||
warningText = 'Sedang — evaporasi dalam batas normal, pantau kondisi.';
|
|
||||||
break;
|
|
||||||
case 'Tinggi':
|
|
||||||
statusColor = Colors.red;
|
|
||||||
statusIcon = Icons.error_outline;
|
|
||||||
warningText =
|
|
||||||
'Tinggi — evaporasi signifikan, berpotensi memengaruhi kondisi lingkungan.';
|
|
||||||
break;
|
|
||||||
case 'Rendah':
|
|
||||||
default:
|
|
||||||
statusColor = Colors.green;
|
|
||||||
statusIcon = Icons.check_circle_outline;
|
|
||||||
warningText = 'Rendah — evaporasi stabil, risiko dampak rendah.';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(statusIcon, color: statusColor, size: 40),
|
|
||||||
const SizedBox(width: 15),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text('Status Evaporasi',
|
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey)),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
state.weatherStatus,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: statusColor),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
warningText,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: statusColor.withValues(alpha: 0.8),
|
|
||||||
fontWeight: FontWeight.w500),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// oioioioioiioioio
|
|
||||||
|
|
@ -1,405 +0,0 @@
|
||||||
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;
|
|
||||||
final List<String> chartLabels;
|
|
||||||
|
|
||||||
const EvaporasiChartWidget({
|
|
||||||
super.key,
|
|
||||||
required this.dailyValues,
|
|
||||||
required this.dailyTemperatures,
|
|
||||||
required this.period,
|
|
||||||
required this.chartLabels,
|
|
||||||
});
|
|
||||||
|
|
||||||
double _safeValue(double value) {
|
|
||||||
if (value.isNaN || value.isInfinite) return 0.0;
|
|
||||||
|
|
||||||
// anti spike
|
|
||||||
if (value > 1000 || value < -1000) return 0.0;
|
|
||||||
|
|
||||||
return value < 0 ? 0.0 : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
double _tempToEvapScale({
|
|
||||||
required double temp,
|
|
||||||
required double evapMin,
|
|
||||||
required double evapMax,
|
|
||||||
required double tempMin,
|
|
||||||
required double tempMax,
|
|
||||||
}) {
|
|
||||||
final tempRange = (tempMax - tempMin);
|
|
||||||
if (tempRange.abs() < 1e-9) return evapMin;
|
|
||||||
|
|
||||||
final normalized = (temp - tempMin) / tempRange;
|
|
||||||
return evapMin + normalized * (evapMax - evapMin);
|
|
||||||
}
|
|
||||||
|
|
||||||
double _evapScaleToTemp({
|
|
||||||
required double yEvap,
|
|
||||||
required double evapMin,
|
|
||||||
required double evapMax,
|
|
||||||
required double tempMin,
|
|
||||||
required double tempMax,
|
|
||||||
}) {
|
|
||||||
final evapRange = (evapMax - evapMin);
|
|
||||||
if (evapRange.abs() < 1e-9) return tempMin;
|
|
||||||
|
|
||||||
final normalized = (yEvap - evapMin) / evapRange;
|
|
||||||
return tempMin + normalized * (tempMax - tempMin);
|
|
||||||
}
|
|
||||||
|
|
||||||
double _xLabelInterval() {
|
|
||||||
if (period == 'Minggu Ini') return 1; // 7 label
|
|
||||||
if (period == 'Bulan Ini') return 5; // ~31 label tiap 5 hari
|
|
||||||
return 3; // 24 jam tiap 3 jam
|
|
||||||
}
|
|
||||||
|
|
||||||
String _getBottomLabel(int index) {
|
|
||||||
if (chartLabels.isEmpty || index < 0 || index >= chartLabels.length) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return chartLabels[index];
|
|
||||||
}
|
|
||||||
|
|
||||||
double _maxOf(List<double> values) {
|
|
||||||
if (values.isEmpty) return 0.0;
|
|
||||||
double max = values.first;
|
|
||||||
for (final v in values) {
|
|
||||||
if (v > max) max = v;
|
|
||||||
}
|
|
||||||
return max;
|
|
||||||
}
|
|
||||||
|
|
||||||
double _minOf(List<double> values) {
|
|
||||||
if (values.isEmpty) return 0.0;
|
|
||||||
double min = values.first;
|
|
||||||
for (final v in values) {
|
|
||||||
if (v < min) min = v;
|
|
||||||
}
|
|
||||||
return min;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<FlSpot> _buildEvapSpots(double evapMin, double evapMax) {
|
|
||||||
if (dailyValues.isEmpty) return const [];
|
|
||||||
|
|
||||||
return dailyValues.asMap().entries
|
|
||||||
.where((e) => e.value >= 0) // Hanya tampilkan data yang ada / valid
|
|
||||||
.map((e) {
|
|
||||||
final x = e.key.toDouble();
|
|
||||||
final y = _safeValue(e.value).clamp(evapMin, evapMax);
|
|
||||||
return FlSpot(x, y);
|
|
||||||
}).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
if (dailyValues.isEmpty && dailyTemperatures.isEmpty) {
|
|
||||||
return Container(
|
|
||||||
height: 240,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(25),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withAlpha(13),
|
|
||||||
blurRadius: 10,
|
|
||||||
offset: const Offset(0, 5),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: const Text('Tidak ada data chart'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const evapMin = 0.0;
|
|
||||||
|
|
||||||
final evapMaxRaw = _maxOf(dailyValues);
|
|
||||||
final tempMinRaw = _minOf(dailyTemperatures);
|
|
||||||
final tempMaxRaw = _maxOf(dailyTemperatures);
|
|
||||||
|
|
||||||
// ── FIXED COORD SYSTEM SUHU MALFUNCTION ──
|
|
||||||
// Jika data suhu flat 0 atau sangat rendah, kita kunci range visualnya dari 0 sampai 40 derajat Celsius
|
|
||||||
final tempMin = tempMaxRaw < 2.0 ? 0.0 : tempMinRaw;
|
|
||||||
final tempMax = tempMaxRaw < 2.0 ? 40.0 : (tempMaxRaw == tempMinRaw ? tempMinRaw + 1 : tempMaxRaw);
|
|
||||||
|
|
||||||
// Evaporasi: Berikan buffer / ruang kosong (+ 20%) di bagian atas grafik agar tidak terpotong rata
|
|
||||||
final double evapMaxBase = evapMaxRaw < 1e-9 ? 20.0 : (evapMaxRaw > 50 ? 50.0 : evapMaxRaw);
|
|
||||||
final evapMax = evapMaxBase * 1.2; // Tambahan padding atas 20%
|
|
||||||
|
|
||||||
final evapSpots = _buildEvapSpots(evapMin, evapMax);
|
|
||||||
|
|
||||||
// Suhu diproyeksikan ke skala evaporasi
|
|
||||||
final tempSpots = dailyTemperatures.asMap().entries
|
|
||||||
.where((entry) => entry.value >= 0) // Hanya tampilkan data yang ada / valid
|
|
||||||
.map((entry) {
|
|
||||||
final x = entry.key.toDouble();
|
|
||||||
final temp = _safeValue(entry.value);
|
|
||||||
final y = _tempToEvapScale(
|
|
||||||
temp: temp,
|
|
||||||
evapMin: evapMin,
|
|
||||||
evapMax: evapMax,
|
|
||||||
tempMin: tempMin,
|
|
||||||
tempMax: tempMax,
|
|
||||||
);
|
|
||||||
return FlSpot(x, y.clamp(evapMin, evapMax));
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
double getRightTitle(double y) {
|
|
||||||
if (tempSpots.isEmpty) return tempMin;
|
|
||||||
return _evapScaleToTemp(
|
|
||||||
yEvap: y,
|
|
||||||
evapMin: evapMin,
|
|
||||||
evapMax: evapMax,
|
|
||||||
tempMin: tempMin,
|
|
||||||
tempMax: tempMax,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final xInterval = _xLabelInterval().toInt().clamp(1, 1000);
|
|
||||||
|
|
||||||
final chart = LineChart(
|
|
||||||
LineChartData(
|
|
||||||
minY: evapMin,
|
|
||||||
maxY: evapMax,
|
|
||||||
minX: -0.5,
|
|
||||||
maxX: (chartLabels.isNotEmpty ? chartLabels.length - 1 : 23)
|
|
||||||
.toDouble() +
|
|
||||||
0.5,
|
|
||||||
// Diubah ke clipData false agar lengkungan ujung titik teratas tidak ter-crop kaku
|
|
||||||
clipData: const FlClipData.none(),
|
|
||||||
gridData: FlGridData(
|
|
||||||
show: true,
|
|
||||||
drawVerticalLine: false,
|
|
||||||
horizontalInterval: (evapMax - evapMin) / 4,
|
|
||||||
getDrawingHorizontalLine: (value) => FlLine(
|
|
||||||
color: Colors.grey.shade200,
|
|
||||||
strokeWidth: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
borderData: FlBorderData(show: false),
|
|
||||||
extraLinesData: const ExtraLinesData(horizontalLines: []),
|
|
||||||
titlesData: FlTitlesData(
|
|
||||||
bottomTitles: AxisTitles(
|
|
||||||
sideTitles: SideTitles(
|
|
||||||
showTitles: true,
|
|
||||||
reservedSize: 36,
|
|
||||||
interval: xInterval.toDouble(),
|
|
||||||
getTitlesWidget: (value, meta) {
|
|
||||||
final index = value.toInt();
|
|
||||||
if (value != value.roundToDouble()) return const SizedBox();
|
|
||||||
if (index < 0 || index >= chartLabels.length) {
|
|
||||||
return const SizedBox();
|
|
||||||
}
|
|
||||||
if (index % xInterval != 0) return const SizedBox();
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 8),
|
|
||||||
child: Text(
|
|
||||||
_getBottomLabel(index),
|
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 9),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
leftTitles: AxisTitles(
|
|
||||||
axisNameWidget: const Text(
|
|
||||||
'mm',
|
|
||||||
style: TextStyle(color: Colors.blueGrey, fontSize: 10),
|
|
||||||
),
|
|
||||||
axisNameSize: 16,
|
|
||||||
sideTitles: SideTitles(
|
|
||||||
showTitles: true,
|
|
||||||
reservedSize: 36,
|
|
||||||
interval: (evapMax - evapMin) / 4,
|
|
||||||
getTitlesWidget: (value, meta) {
|
|
||||||
return Text(
|
|
||||||
value.toStringAsFixed(0),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.blueGrey,
|
|
||||||
fontSize: 10,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
rightTitles: AxisTitles(
|
|
||||||
axisNameWidget: const Text(
|
|
||||||
'°C',
|
|
||||||
style: TextStyle(color: Colors.brown, fontSize: 10),
|
|
||||||
),
|
|
||||||
axisNameSize: 16,
|
|
||||||
sideTitles: SideTitles(
|
|
||||||
showTitles: true,
|
|
||||||
reservedSize: 36,
|
|
||||||
interval: (evapMax - evapMin) / 4,
|
|
||||||
getTitlesWidget: (value, meta) {
|
|
||||||
final t = getRightTitle(value);
|
|
||||||
// Menampilkan label dengan 1 angka desimal jika nilainya kecil,
|
|
||||||
// atau bulat murni jika data menggunakan fallback sistem (0-40)
|
|
||||||
return Text(
|
|
||||||
t > 5 ? t.toStringAsFixed(0) : t.toStringAsFixed(1),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.brown,
|
|
||||||
fontSize: 10,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
topTitles: const AxisTitles(
|
|
||||||
sideTitles: SideTitles(showTitles: false),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
lineTouchData: LineTouchData(
|
|
||||||
handleBuiltInTouches: true,
|
|
||||||
touchTooltipData: LineTouchTooltipData(
|
|
||||||
tooltipPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
|
||||||
showOnTopOfTheChartBoxArea: true,
|
|
||||||
fitInsideHorizontally: true,
|
|
||||||
fitInsideVertically: true,
|
|
||||||
getTooltipItems: (touchedSpots) {
|
|
||||||
if (touchedSpots.isEmpty) return const <LineTooltipItem>[];
|
|
||||||
|
|
||||||
// Ambil spot pertama yang disentuh untuk mencari index X
|
|
||||||
final firstSpot = touchedSpots.first;
|
|
||||||
final idx = firstSpot.x.round();
|
|
||||||
|
|
||||||
// Validasi batas index
|
|
||||||
if (idx < 0 || idx >= dailyValues.length) {
|
|
||||||
return const <LineTooltipItem>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ambil data evaporasi dan suhu langsung dari array data berdasarkan index X
|
|
||||||
final evapVal = dailyValues[idx];
|
|
||||||
final tempVal = idx < dailyTemperatures.length ? dailyTemperatures[idx] : 0.0;
|
|
||||||
|
|
||||||
final tooltipText = 'Evap: ${evapVal.toStringAsFixed(1)} mm\n'
|
|
||||||
'Suhu: ${tempVal.toStringAsFixed(1)} °C';
|
|
||||||
|
|
||||||
return touchedSpots.asMap().entries.map((entry) {
|
|
||||||
final index = entry.key;
|
|
||||||
if (index == 0) {
|
|
||||||
return LineTooltipItem(
|
|
||||||
tooltipText,
|
|
||||||
const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
height: 1.4,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return const LineTooltipItem(
|
|
||||||
'',
|
|
||||||
TextStyle(color: Colors.transparent, fontSize: 0),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}).toList();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
lineBarsData: [
|
|
||||||
if (evapSpots.isNotEmpty)
|
|
||||||
LineChartBarData(
|
|
||||||
spots: evapSpots,
|
|
||||||
isCurved: true,
|
|
||||||
curveSmoothness: 0.3,
|
|
||||||
color: Colors.blue.shade700,
|
|
||||||
barWidth: 2.5,
|
|
||||||
dotData: FlDotData(show: false),
|
|
||||||
belowBarData: BarAreaData(
|
|
||||||
show: true,
|
|
||||||
color: Colors.blue.shade100.withOpacity(0.3),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (tempSpots.isNotEmpty)
|
|
||||||
LineChartBarData(
|
|
||||||
spots: tempSpots,
|
|
||||||
isCurved: true,
|
|
||||||
curveSmoothness: 0.3,
|
|
||||||
color: Colors.orange.shade700,
|
|
||||||
barWidth: 2.5,
|
|
||||||
dotData: FlDotData(show: false),
|
|
||||||
belowBarData: BarAreaData(show: false),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'',
|
|
||||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
SizedBox(
|
|
||||||
height: 220,
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
// Mengubah ClipBehavior ke none agar garis di titik maksimum visual luar aman tidak ter-crop
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 10, right: 4),
|
|
||||||
child: chart,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: Colors.blue,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
const Text(
|
|
||||||
'Evaporasi (mm)',
|
|
||||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: Colors.orange,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
const Text(
|
|
||||||
'Suhu (°C)',
|
|
||||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,224 +0,0 @@
|
||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:firebase_database/firebase_database.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class EvaporasiControlPanel extends StatefulWidget {
|
|
||||||
const EvaporasiControlPanel({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<EvaporasiControlPanel> createState() => _EvaporasiControlPanelState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _EvaporasiControlPanelState extends State<EvaporasiControlPanel> {
|
|
||||||
bool _selenoid = false;
|
|
||||||
bool _isTogglingSelenoid = false;
|
|
||||||
bool _isResettingEvaporasi = false;
|
|
||||||
StreamSubscription<DatabaseEvent>? _subscription;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_subscription = FirebaseDatabase.instance
|
|
||||||
.ref('Monitoring/realtime')
|
|
||||||
.onValue
|
|
||||||
.listen(_onRealtimeUpdated);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onRealtimeUpdated(DatabaseEvent event) {
|
|
||||||
final data = event.snapshot.value;
|
|
||||||
if (data is Map) {
|
|
||||||
final rawSelenoid = data['selenoid'];
|
|
||||||
final selenoid = _toBool(rawSelenoid, _selenoid);
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_selenoid = selenoid;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _toBool(dynamic raw, bool fallback) {
|
|
||||||
if (raw is bool) return raw;
|
|
||||||
if (raw is num) return raw != 0;
|
|
||||||
if (raw is String) {
|
|
||||||
final lower = raw.toLowerCase();
|
|
||||||
return lower == 'true' || lower == '1';
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _toggleSelenoid() async {
|
|
||||||
if (_isTogglingSelenoid) return;
|
|
||||||
final nextValue = !_selenoid;
|
|
||||||
setState(() {
|
|
||||||
_isTogglingSelenoid = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Future.wait([
|
|
||||||
FirebaseDatabase.instance.ref('Monitoring/selenoid').set(nextValue),
|
|
||||||
FirebaseDatabase.instance
|
|
||||||
.ref('Monitoring/realtime/selenoid')
|
|
||||||
.set(nextValue),
|
|
||||||
]);
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_selenoid = nextValue;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
|
||||||
content:
|
|
||||||
Text('Perintah selenoid dikirim: ${nextValue ? 'ON' : 'OFF'}'),
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
|
||||||
content: Text('Gagal mengirim perintah selenoid: $e'),
|
|
||||||
backgroundColor: Colors.red.shade600,
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_isTogglingSelenoid = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _resetEvaporasi() async {
|
|
||||||
if (_isResettingEvaporasi) return;
|
|
||||||
setState(() {
|
|
||||||
_isResettingEvaporasi = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await FirebaseDatabase.instance
|
|
||||||
.ref('Monitoring/reset_evaporasi')
|
|
||||||
.set(true);
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
|
||||||
content: Text('Perintah reset evaporasi berhasil dikirim.'),
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
|
||||||
content: Text('Gagal mengirim perintah reset evaporasi: $e'),
|
|
||||||
backgroundColor: Colors.red.shade600,
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_isResettingEvaporasi = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_subscription?.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
border: Border.all(color: Colors.grey.shade200),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.api_rounded, color: Colors.blue.shade700),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
const Expanded(
|
|
||||||
child: Text(
|
|
||||||
'Kontrol Database Evaporasi',
|
|
||||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
'Tombol ini terhubung ke path Firebase yang belum ada di UI utama: reset evaporasi dan kontrol selenoid.',
|
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Wrap(
|
|
||||||
spacing: 12,
|
|
||||||
runSpacing: 12,
|
|
||||||
children: [
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: _isResettingEvaporasi ? null : _resetEvaporasi,
|
|
||||||
icon: _isResettingEvaporasi
|
|
||||||
? const SizedBox(
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2, color: Colors.white),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.refresh_rounded),
|
|
||||||
label: Text(
|
|
||||||
_isResettingEvaporasi ? 'Mengirim...' : 'Reset Evaporasi'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.red.shade600,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
padding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: _isTogglingSelenoid ? null : _toggleSelenoid,
|
|
||||||
icon: _isTogglingSelenoid
|
|
||||||
? const SizedBox(
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: Icon(
|
|
||||||
_selenoid
|
|
||||||
? Icons.toggle_on_rounded
|
|
||||||
: Icons.toggle_off_rounded,
|
|
||||||
color: _selenoid ? Colors.green : Colors.grey),
|
|
||||||
label:
|
|
||||||
Text(_selenoid ? 'Matikan Selenoid' : 'Nyalakan Selenoid'),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor:
|
|
||||||
_selenoid ? Colors.green.shade700 : Colors.grey.shade800,
|
|
||||||
side: BorderSide(
|
|
||||||
color: _selenoid
|
|
||||||
? Colors.green.shade300
|
|
||||||
: Colors.grey.shade300),
|
|
||||||
padding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Status selenoid saat ini: ${_selenoid ? 'AKTIF' : 'MATI'}',
|
|
||||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class EvaporasiDateSearchBar extends StatefulWidget {
|
|
||||||
final String initialQuery;
|
|
||||||
final ValueChanged<String> onQueryChanged;
|
|
||||||
|
|
||||||
const EvaporasiDateSearchBar({
|
|
||||||
super.key,
|
|
||||||
required this.initialQuery,
|
|
||||||
required this.onQueryChanged,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<EvaporasiDateSearchBar> createState() => _EvaporasiDateSearchBarState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _EvaporasiDateSearchBarState extends State<EvaporasiDateSearchBar> {
|
|
||||||
late final TextEditingController _controller;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_controller = TextEditingController(text: widget.initialQuery);
|
|
||||||
_controller.addListener(() {
|
|
||||||
setState(() {});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_controller.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return TextField(
|
|
||||||
controller: _controller,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'Cari tanggal (dd/MM/yyyy)',
|
|
||||||
prefixIcon: const Icon(Icons.search),
|
|
||||||
suffixIcon: _controller.text.isNotEmpty
|
|
||||||
? IconButton(
|
|
||||||
icon: const Icon(Icons.clear),
|
|
||||||
onPressed: () {
|
|
||||||
_controller.clear();
|
|
||||||
widget.onQueryChanged('');
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
|
||||||
isDense: true,
|
|
||||||
),
|
|
||||||
onChanged: (v) {
|
|
||||||
widget.onQueryChanged(v);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,276 +0,0 @@
|
||||||
// ===========================================================
|
|
||||||
// evaporasi_history_list.dart
|
|
||||||
// Lokasi: lib/screens/monitoring/evaporasi/views/widgets/
|
|
||||||
// ===========================================================
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:monitoring_repository/monitoring_repository.dart';
|
|
||||||
|
|
||||||
class EvaporasiHistoryList extends StatelessWidget {
|
|
||||||
final List<Evaporasi> history;
|
|
||||||
final DateTime? selectedDate;
|
|
||||||
final VoidCallback onPickDate;
|
|
||||||
final VoidCallback onClearDate;
|
|
||||||
|
|
||||||
const EvaporasiHistoryList({
|
|
||||||
super.key,
|
|
||||||
required this.history,
|
|
||||||
required this.selectedDate,
|
|
||||||
required this.onPickDate,
|
|
||||||
required this.onClearDate,
|
|
||||||
});
|
|
||||||
|
|
||||||
Map<String, List<Evaporasi>> _groupByDate(List<Evaporasi> list) {
|
|
||||||
final map = <String, List<Evaporasi>>{};
|
|
||||||
for (final item in list) {
|
|
||||||
final key = DateFormat('yyyy-MM-dd').format(item.timestamp);
|
|
||||||
map.putIfAbsent(key, () => []).add(item);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final grouped = _groupByDate(history);
|
|
||||||
final sortedKeys = grouped.keys.toList()
|
|
||||||
..sort((a, b) => b.compareTo(a));
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_HeaderBar(
|
|
||||||
selectedDate: selectedDate,
|
|
||||||
totalCount: history.length,
|
|
||||||
onPickDate: onPickDate,
|
|
||||||
onClearDate: onClearDate,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
if (history.isEmpty)
|
|
||||||
_EmptyState(hasFilter: selectedDate != null)
|
|
||||||
else
|
|
||||||
ListView.builder(
|
|
||||||
shrinkWrap: true,
|
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
|
||||||
itemCount: sortedKeys.length,
|
|
||||||
itemBuilder: (context, idx) {
|
|
||||||
final dateKey = sortedKeys[idx];
|
|
||||||
final items = grouped[dateKey]!
|
|
||||||
..sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
|
||||||
final label = _formatDateLabel(dateKey);
|
|
||||||
return _DateGroup(label: label, items: items);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatDateLabel(String key) {
|
|
||||||
final dt = DateTime.parse(key);
|
|
||||||
final today = DateTime.now();
|
|
||||||
if (dt.year == today.year && dt.month == today.month && dt.day == today.day) {
|
|
||||||
return 'Hari Ini — ${DateFormat('dd MMMM yyyy', 'id_ID').format(dt)}';
|
|
||||||
}
|
|
||||||
final yesterday = today.subtract(const Duration(days: 1));
|
|
||||||
if (dt.year == yesterday.year && dt.month == yesterday.month && dt.day == yesterday.day) {
|
|
||||||
return 'Kemarin — ${DateFormat('dd MMMM yyyy', 'id_ID').format(dt)}';
|
|
||||||
}
|
|
||||||
return DateFormat('EEEE, dd MMMM yyyy', 'id_ID').format(dt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
// Header bar
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
class _HeaderBar extends StatelessWidget {
|
|
||||||
final DateTime? selectedDate;
|
|
||||||
final int totalCount;
|
|
||||||
final VoidCallback onPickDate;
|
|
||||||
final VoidCallback onClearDate;
|
|
||||||
|
|
||||||
const _HeaderBar({
|
|
||||||
required this.selectedDate,
|
|
||||||
required this.totalCount,
|
|
||||||
required this.onPickDate,
|
|
||||||
required this.onClearDate,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final filtered = selectedDate != null;
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'History Evaporasi Harian',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.black87,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
if (filtered)
|
|
||||||
_ChipButton(
|
|
||||||
label: DateFormat('dd MMM yyyy', 'id_ID').format(selectedDate!),
|
|
||||||
icon: Icons.close_rounded,
|
|
||||||
color: Colors.blue.shade700,
|
|
||||||
onTap: onClearDate,
|
|
||||||
)
|
|
||||||
else
|
|
||||||
_ChipButton(
|
|
||||||
label: 'Filter Tanggal',
|
|
||||||
icon: Icons.calendar_month_rounded,
|
|
||||||
color: Colors.blue.shade700,
|
|
||||||
onTap: onPickDate,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ChipButton extends StatelessWidget {
|
|
||||||
final String label;
|
|
||||||
final IconData icon;
|
|
||||||
final Color color;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
|
|
||||||
const _ChipButton({
|
|
||||||
required this.label,
|
|
||||||
required this.icon,
|
|
||||||
required this.color,
|
|
||||||
required this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: color.withValues(alpha: 0.12),
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
border: Border.all(color: color.withValues(alpha: 0.4)),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(icon, size: 14, color: color),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(label,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12, color: color, fontWeight: FontWeight.w600)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
// Group per tanggal
|
|
||||||
// ════════════════════════════════════════════════════
|
|
||||||
class _DateGroup extends StatelessWidget {
|
|
||||||
final String label;
|
|
||||||
final List<Evaporasi> items;
|
|
||||||
|
|
||||||
const _DateGroup({required this.label, required this.items});
|
|
||||||
|
|
||||||
double get _avgEvap {
|
|
||||||
if (items.isEmpty) return 0;
|
|
||||||
return items.map((e) => e.evaporasi).reduce((a, b) => a + b) / items.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
double get _avgTemp {
|
|
||||||
if (items.isEmpty) return 0;
|
|
||||||
final validTemps = items.where((e) => e.suhu >= -50 && e.suhu <= 100).toList();
|
|
||||||
if (validTemps.isEmpty) return 0;
|
|
||||||
return validTemps.map((e) => e.suhu).reduce((a, b) => a + b) / validTemps.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
|
||||||
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: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 4,
|
|
||||||
height: 36,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.blue.shade600,
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(label,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold, fontSize: 13)),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(
|
|
||||||
'rata-rata evaporasi ${_avgEvap.toStringAsFixed(2)} mm • rata-rata suhu ${_avgTemp.toStringAsFixed(1)} °C',
|
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
// Empty state
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
class _EmptyState extends StatelessWidget {
|
|
||||||
final bool hasFilter;
|
|
||||||
const _EmptyState({required this.hasFilter});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
hasFilter ? Icons.search_off_rounded : Icons.inbox_rounded,
|
|
||||||
size: 48,
|
|
||||||
color: Colors.grey.shade300,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
hasFilter
|
|
||||||
? 'Tidak ada data untuk tanggal ini'
|
|
||||||
: 'Belum ada data history',
|
|
||||||
style: TextStyle(color: Colors.grey.shade500, fontSize: 14),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,206 +0,0 @@
|
||||||
// lib/screens/monitoring/evaporasi/views/widgets/evaporasi_range_selector.dart
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import '../../blocs/evaporasi_bloc.dart';
|
|
||||||
|
|
||||||
class EvaporasiRangeSelector extends StatelessWidget {
|
|
||||||
const EvaporasiRangeSelector({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final state = context.watch<EvaporasiBloc>().state;
|
|
||||||
final start = state.startDate;
|
|
||||||
final end = state.endDate;
|
|
||||||
final isSingle = state.isSingleDay;
|
|
||||||
|
|
||||||
final label = isSingle
|
|
||||||
? _formatSingle(start)
|
|
||||||
: '${_fmt(start)} → ${_fmt(end)}';
|
|
||||||
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () => _pickRange(context, start, end),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withValues(alpha: 0.06),
|
|
||||||
blurRadius: 8,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
// ── Label rentang ──────────────────────────────
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
isSingle ? 'Tampil per Jam' : 'Tampil per Hari',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
color: Colors.grey.shade500,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.black87,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
// ── Tombol pilih ───────────────────────────────
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
// Shortcut: Hari Ini
|
|
||||||
_ShortcutChip(
|
|
||||||
label: 'Hari Ini',
|
|
||||||
isActive: isSingle && _isToday(start),
|
|
||||||
onTap: () {
|
|
||||||
final today = DateTime.now();
|
|
||||||
context.read<EvaporasiBloc>().add(
|
|
||||||
EvaporasiDateRangeChanged(
|
|
||||||
startDate: DateTime(today.year, today.month, today.day),
|
|
||||||
endDate: DateTime(today.year, today.month, today.day),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
// Tombol pilih range bebas
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => _pickRange(context, start, end),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 12, vertical: 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.blue.shade600,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
child: const Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.date_range_rounded,
|
|
||||||
size: 14, color: Colors.white),
|
|
||||||
SizedBox(width: 5),
|
|
||||||
Text(
|
|
||||||
'Pilih Tanggal',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _pickRange(
|
|
||||||
BuildContext context, DateTime start, DateTime end) async {
|
|
||||||
final picked = await showDateRangePicker(
|
|
||||||
context: context,
|
|
||||||
firstDate: DateTime(2024),
|
|
||||||
lastDate: DateTime.now(),
|
|
||||||
initialDateRange: DateTimeRange(start: start, end: end),
|
|
||||||
locale: const Locale('id', 'ID'),
|
|
||||||
builder: (context, child) {
|
|
||||||
return Theme(
|
|
||||||
data: Theme.of(context).copyWith(
|
|
||||||
colorScheme: ColorScheme.light(
|
|
||||||
primary: Colors.blue.shade700,
|
|
||||||
onPrimary: Colors.white,
|
|
||||||
surface: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: child!,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (picked != null && context.mounted) {
|
|
||||||
context.read<EvaporasiBloc>().add(
|
|
||||||
EvaporasiDateRangeChanged(
|
|
||||||
startDate: picked.start,
|
|
||||||
endDate: picked.end,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _isToday(DateTime d) {
|
|
||||||
final now = DateTime.now();
|
|
||||||
return d.year == now.year && d.month == now.month && d.day == now.day;
|
|
||||||
}
|
|
||||||
|
|
||||||
String _fmt(DateTime d) => DateFormat('dd MMM yyyy', 'id_ID').format(d);
|
|
||||||
|
|
||||||
String _formatSingle(DateTime d) {
|
|
||||||
if (_isToday(d)) return 'Hari Ini — ${_fmt(d)}';
|
|
||||||
final yesterday = DateTime.now().subtract(const Duration(days: 1));
|
|
||||||
if (d.year == yesterday.year &&
|
|
||||||
d.month == yesterday.month &&
|
|
||||||
d.day == yesterday.day) {
|
|
||||||
return 'Kemarin — ${_fmt(d)}';
|
|
||||||
}
|
|
||||||
return DateFormat('EEEE, dd MMMM yyyy', 'id_ID').format(d);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ShortcutChip extends StatelessWidget {
|
|
||||||
final String label;
|
|
||||||
final bool isActive;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
|
|
||||||
const _ShortcutChip({
|
|
||||||
required this.label,
|
|
||||||
required this.isActive,
|
|
||||||
required this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: onTap,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isActive ? Colors.blue.shade50 : Colors.grey.shade100,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
border: Border.all(
|
|
||||||
color: isActive
|
|
||||||
? Colors.blue.shade400
|
|
||||||
: Colors.grey.shade300,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: isActive ? Colors.blue.shade700 : Colors.grey.shade600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,391 +0,0 @@
|
||||||
// ===========================================================
|
|
||||||
// evaporasi_excel_service.dart
|
|
||||||
// Lokasi: lib/screens/monitoring/shared/utils/excel/
|
|
||||||
// ===========================================================
|
|
||||||
|
|
||||||
import 'dart:typed_data';
|
|
||||||
import 'package:excel/excel.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:monitoring_repository/monitoring_repository.dart';
|
|
||||||
|
|
||||||
import 'excel_saver_stub.dart'
|
|
||||||
if (dart.library.html) 'excel_saver_web.dart'
|
|
||||||
if (dart.library.io) 'excel_saver_mobile.dart';
|
|
||||||
|
|
||||||
class EvaporasiExcelService {
|
|
||||||
// ── Format helper ──────────────────────────────────────────
|
|
||||||
// static final _dateFmt = DateFormat('dd/MM/yyyy HH:mm:ss', 'id_ID');
|
|
||||||
static final _headerFmt = DateFormat('dd MMMM yyyy, HH:mm', 'id_ID');
|
|
||||||
|
|
||||||
// ── Warna tema ─────────────────────────────────────────────
|
|
||||||
static const _colorHeader = '1A4A8C'; // biru tua
|
|
||||||
static const _colorSubHead = '2E75B6'; // biru medium
|
|
||||||
static const _colorNormal = 'E8F4FD'; // biru sangat muda
|
|
||||||
static const _colorTinggi = 'F8D7DA'; // merah muda
|
|
||||||
static const _colorRendah = 'D1ECF1'; // biru muda
|
|
||||||
static const _colorWhite = 'FFFFFF';
|
|
||||||
static const _colorWaspada = 'FFF3CD'; // kuning muda
|
|
||||||
|
|
||||||
/// Export data evaporasi ke file Excel dan buka share sheet
|
|
||||||
static Future<void> export({
|
|
||||||
required double currentValue,
|
|
||||||
required double temperature,
|
|
||||||
required double waterLevel,
|
|
||||||
required double acuanPagi,
|
|
||||||
required String weatherStatus,
|
|
||||||
required List<Evaporasi> history,
|
|
||||||
required String fileName,
|
|
||||||
DateTime? dateFrom,
|
|
||||||
DateTime? dateTo,
|
|
||||||
}) async {
|
|
||||||
final excel = Excel.createExcel();
|
|
||||||
excel.delete('Sheet1');
|
|
||||||
|
|
||||||
_buildSummarySheet(
|
|
||||||
excel,
|
|
||||||
currentValue,
|
|
||||||
temperature,
|
|
||||||
waterLevel,
|
|
||||||
acuanPagi,
|
|
||||||
weatherStatus,
|
|
||||||
history,
|
|
||||||
dateFrom,
|
|
||||||
dateTo,
|
|
||||||
);
|
|
||||||
_buildHistorySheet(excel, history, dateFrom, dateTo);
|
|
||||||
|
|
||||||
// ── Simpan file ─────────────────────────────────────────
|
|
||||||
final bytes = excel.save();
|
|
||||||
if (bytes == null) throw Exception('Gagal membuat file Excel');
|
|
||||||
|
|
||||||
final safeName = fileName.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_');
|
|
||||||
await saveAndShareExcel(Uint8List.fromList(bytes), '$safeName.xlsx');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
// SHEET 1: Ringkasan
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
static void _buildSummarySheet(
|
|
||||||
Excel excel,
|
|
||||||
double currentValue,
|
|
||||||
double temperature,
|
|
||||||
double waterLevel,
|
|
||||||
double acuanPagi,
|
|
||||||
String weatherStatus,
|
|
||||||
List<Evaporasi> history,
|
|
||||||
DateTime? dateFrom,
|
|
||||||
DateTime? dateTo,
|
|
||||||
) {
|
|
||||||
final sheet = excel['Ringkasan'];
|
|
||||||
|
|
||||||
// -- Judul --
|
|
||||||
_setCell(sheet, 0, 0, 'DATA EVAPORASI – WEATHER STATION',
|
|
||||||
bold: true,
|
|
||||||
fontSize: 14,
|
|
||||||
bgColor: _colorHeader,
|
|
||||||
fontColor: _colorWhite);
|
|
||||||
sheet.merge(
|
|
||||||
CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 0),
|
|
||||||
CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: 0),
|
|
||||||
);
|
|
||||||
|
|
||||||
// -- Metadata --
|
|
||||||
_setCell(sheet, 1, 0, 'Diekspor pada',
|
|
||||||
bold: true, bgColor: _colorSubHead, fontColor: _colorWhite);
|
|
||||||
_setCell(sheet, 1, 1, _headerFmt.format(DateTime.now()),
|
|
||||||
bgColor: _colorNormal);
|
|
||||||
|
|
||||||
if (dateFrom != null && dateTo != null) {
|
|
||||||
_setCell(sheet, 2, 0, 'Filter tanggal',
|
|
||||||
bold: true, bgColor: _colorSubHead, fontColor: _colorWhite);
|
|
||||||
_setCell(
|
|
||||||
sheet,
|
|
||||||
2,
|
|
||||||
1,
|
|
||||||
'${DateFormat('dd MMMM yyyy', 'id_ID').format(dateFrom)} → ${DateFormat('dd MMMM yyyy', 'id_ID').format(dateTo)}',
|
|
||||||
bgColor: _colorNormal,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Nilai saat ini --
|
|
||||||
_setCell(sheet, 4, 0, 'DATA SAAT INI',
|
|
||||||
bold: true, bgColor: _colorSubHead, fontColor: _colorWhite);
|
|
||||||
sheet.merge(
|
|
||||||
CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 4),
|
|
||||||
CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: 4),
|
|
||||||
);
|
|
||||||
|
|
||||||
_setCell(sheet, 5, 0, 'Evaporasi (mm)', bold: true);
|
|
||||||
_setCellDouble(sheet, 5, 1, currentValue);
|
|
||||||
|
|
||||||
_setCell(sheet, 6, 0, 'Suhu Air (°C)', bold: true);
|
|
||||||
if (temperature < 0) {
|
|
||||||
_setCell(sheet, 6, 1, '-');
|
|
||||||
} else {
|
|
||||||
_setCellDouble(sheet, 6, 1, temperature);
|
|
||||||
}
|
|
||||||
|
|
||||||
_setCell(sheet, 7, 0, 'Tinggi Air (cm)', bold: true);
|
|
||||||
_setCellDouble(sheet, 7, 1, waterLevel);
|
|
||||||
|
|
||||||
_setCell(sheet, 8, 0, 'Acuan Air Pagi (cm)', bold: true);
|
|
||||||
_setCellDouble(sheet, 8, 1, acuanPagi);
|
|
||||||
|
|
||||||
_setCell(sheet, 9, 0, 'Status', bold: true);
|
|
||||||
_setCell(sheet, 9, 1, weatherStatus,
|
|
||||||
bgColor: _statusBgColor(weatherStatus));
|
|
||||||
|
|
||||||
// -- Statistik ringkasan --
|
|
||||||
if (history.isNotEmpty) {
|
|
||||||
final evapValues = history.map((e) => e.evaporasi).toList();
|
|
||||||
final tempValues =
|
|
||||||
history.map((e) => e.suhu).where((s) => s >= 0).toList();
|
|
||||||
|
|
||||||
final evapAvg = evapValues.reduce((a, b) => a + b) / evapValues.length;
|
|
||||||
final evapMax = evapValues.reduce((a, b) => a > b ? a : b);
|
|
||||||
final evapMin = evapValues.reduce((a, b) => a < b ? a : b);
|
|
||||||
|
|
||||||
_setCell(sheet, 11, 0, 'STATISTIK HISTORY',
|
|
||||||
bold: true, bgColor: _colorSubHead, fontColor: _colorWhite);
|
|
||||||
sheet.merge(
|
|
||||||
CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 11),
|
|
||||||
CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: 11),
|
|
||||||
);
|
|
||||||
|
|
||||||
_setCell(sheet, 12, 0, 'Jumlah data', bold: true);
|
|
||||||
_setCellInt(sheet, 12, 1, history.length);
|
|
||||||
|
|
||||||
_setCell(sheet, 13, 0, 'Rata-rata evaporasi (mm)', bold: true);
|
|
||||||
_setCellDouble(sheet, 13, 1, evapAvg);
|
|
||||||
|
|
||||||
_setCell(sheet, 14, 0, 'Evaporasi maksimum (mm)', bold: true);
|
|
||||||
_setCellDouble(sheet, 14, 1, evapMax);
|
|
||||||
|
|
||||||
_setCell(sheet, 15, 0, 'Evaporasi minimum (mm)', bold: true);
|
|
||||||
_setCellDouble(sheet, 15, 1, evapMin);
|
|
||||||
|
|
||||||
if (tempValues.isNotEmpty) {
|
|
||||||
final tempAvg = tempValues.reduce((a, b) => a + b) / tempValues.length;
|
|
||||||
final tempMax = tempValues.reduce((a, b) => a > b ? a : b);
|
|
||||||
final tempMin = tempValues.reduce((a, b) => a < b ? a : b);
|
|
||||||
|
|
||||||
_setCell(sheet, 16, 0, 'Rata-rata suhu (°C)', bold: true);
|
|
||||||
_setCellDouble(sheet, 16, 1, tempAvg);
|
|
||||||
|
|
||||||
_setCell(sheet, 17, 0, 'Suhu maksimum (°C)', bold: true);
|
|
||||||
_setCellDouble(sheet, 17, 1, tempMax);
|
|
||||||
|
|
||||||
_setCell(sheet, 18, 0, 'Suhu minimum (°C)', bold: true);
|
|
||||||
_setCellDouble(sheet, 18, 1, tempMin);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set lebar kolom
|
|
||||||
sheet.setColumnWidth(0, 26);
|
|
||||||
sheet.setColumnWidth(1, 22);
|
|
||||||
sheet.setColumnWidth(2, 18);
|
|
||||||
sheet.setColumnWidth(3, 18);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
// SHEET 2: Data History
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
static void _buildHistorySheet(
|
|
||||||
Excel excel,
|
|
||||||
List<Evaporasi> history,
|
|
||||||
DateTime? dateFrom,
|
|
||||||
DateTime? dateTo,
|
|
||||||
) {
|
|
||||||
final sheet = excel['Data History'];
|
|
||||||
|
|
||||||
// -- Header kolom --
|
|
||||||
final headers = [
|
|
||||||
'No',
|
|
||||||
'Tanggal',
|
|
||||||
'Waktu',
|
|
||||||
'Evaporasi (mm)',
|
|
||||||
'Suhu (°C)',
|
|
||||||
];
|
|
||||||
for (var i = 0; i < headers.length; i++) {
|
|
||||||
_setCell(sheet, 0, i, headers[i],
|
|
||||||
bold: true,
|
|
||||||
bgColor: _colorHeader,
|
|
||||||
fontColor: _colorWhite,
|
|
||||||
centered: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter berdasarkan rentang tanggal (inklusif kedua ujung)
|
|
||||||
final data = (dateFrom != null && dateTo != null)
|
|
||||||
? history.where((e) {
|
|
||||||
final d =
|
|
||||||
DateTime(e.timestamp.year, e.timestamp.month, e.timestamp.day);
|
|
||||||
final from = DateTime(dateFrom.year, dateFrom.month, dateFrom.day);
|
|
||||||
final to = DateTime(dateTo.year, dateTo.month, dateTo.day);
|
|
||||||
return !d.isBefore(from) && !d.isAfter(to);
|
|
||||||
}).toList()
|
|
||||||
: history;
|
|
||||||
|
|
||||||
// Urutkan terbaru di atas
|
|
||||||
final sorted = [...data]
|
|
||||||
..sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
|
||||||
|
|
||||||
// -- Isi baris data --
|
|
||||||
for (var i = 0; i < sorted.length; i++) {
|
|
||||||
final item = sorted[i];
|
|
||||||
final rowIdx = i + 1;
|
|
||||||
final bgColor = i.isEven ? _colorNormal : _colorWhite;
|
|
||||||
|
|
||||||
_setCellInt(sheet, rowIdx, 0, i + 1, bgColor: bgColor, centered: true);
|
|
||||||
_setCell(sheet, rowIdx, 1,
|
|
||||||
DateFormat('dd/MM/yyyy', 'id_ID').format(item.timestamp),
|
|
||||||
bgColor: bgColor);
|
|
||||||
_setCell(sheet, rowIdx, 2, DateFormat('HH:mm:ss').format(item.timestamp),
|
|
||||||
bgColor: bgColor, centered: true);
|
|
||||||
_setCellDouble(sheet, rowIdx, 3, item.evaporasi,
|
|
||||||
bgColor: bgColor, centered: true);
|
|
||||||
// Suhu: tampilkan '-' jika sensor error (< 0)
|
|
||||||
if (item.suhu < 0) {
|
|
||||||
_setCell(sheet, rowIdx, 4, '-', bgColor: bgColor, centered: true);
|
|
||||||
} else {
|
|
||||||
_setCellDouble(sheet, rowIdx, 4, item.suhu,
|
|
||||||
bgColor: bgColor, centered: true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Footer jika kosong --
|
|
||||||
if (sorted.isEmpty) {
|
|
||||||
_setCell(sheet, 1, 0, 'Tidak ada data untuk tanggal yang dipilih',
|
|
||||||
bgColor: _colorWaspada, centered: true);
|
|
||||||
sheet.merge(
|
|
||||||
CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 1),
|
|
||||||
CellIndex.indexByColumnRow(columnIndex: 4, rowIndex: 1),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set lebar kolom
|
|
||||||
sheet.setColumnWidth(0, 6);
|
|
||||||
sheet.setColumnWidth(1, 14);
|
|
||||||
sheet.setColumnWidth(2, 12);
|
|
||||||
sheet.setColumnWidth(3, 18);
|
|
||||||
sheet.setColumnWidth(4, 14);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
// HELPER CELLS
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
static void _setCell(
|
|
||||||
Sheet sheet,
|
|
||||||
int row,
|
|
||||||
int col,
|
|
||||||
String value, {
|
|
||||||
bool bold = false,
|
|
||||||
double fontSize = 11,
|
|
||||||
String bgColor = _colorWhite,
|
|
||||||
String fontColor = '000000',
|
|
||||||
bool centered = false,
|
|
||||||
}) {
|
|
||||||
final cell =
|
|
||||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: col, rowIndex: row));
|
|
||||||
cell.value = TextCellValue(value);
|
|
||||||
cell.cellStyle = CellStyle(
|
|
||||||
bold: bold,
|
|
||||||
fontSize: fontSize.toInt(),
|
|
||||||
backgroundColorHex: ExcelColor.fromHexString('#$bgColor'),
|
|
||||||
fontColorHex: ExcelColor.fromHexString('#$fontColor'),
|
|
||||||
horizontalAlign: centered ? HorizontalAlign.Center : HorizontalAlign.Left,
|
|
||||||
verticalAlign: VerticalAlign.Center,
|
|
||||||
textWrapping: TextWrapping.WrapText,
|
|
||||||
leftBorder: Border(
|
|
||||||
borderStyle: BorderStyle.Thin,
|
|
||||||
borderColorHex: ExcelColor.fromHexString('#CCCCCC')),
|
|
||||||
rightBorder: Border(
|
|
||||||
borderStyle: BorderStyle.Thin,
|
|
||||||
borderColorHex: ExcelColor.fromHexString('#CCCCCC')),
|
|
||||||
topBorder: Border(
|
|
||||||
borderStyle: BorderStyle.Thin,
|
|
||||||
borderColorHex: ExcelColor.fromHexString('#CCCCCC')),
|
|
||||||
bottomBorder: Border(
|
|
||||||
borderStyle: BorderStyle.Thin,
|
|
||||||
borderColorHex: ExcelColor.fromHexString('#CCCCCC')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void _setCellDouble(
|
|
||||||
Sheet sheet,
|
|
||||||
int row,
|
|
||||||
int col,
|
|
||||||
double value, {
|
|
||||||
String bgColor = _colorWhite,
|
|
||||||
bool centered = false,
|
|
||||||
}) {
|
|
||||||
final cell =
|
|
||||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: col, rowIndex: row));
|
|
||||||
cell.value = DoubleCellValue(double.parse(value.toStringAsFixed(4)));
|
|
||||||
cell.cellStyle = CellStyle(
|
|
||||||
backgroundColorHex: ExcelColor.fromHexString('#$bgColor'),
|
|
||||||
horizontalAlign:
|
|
||||||
centered ? HorizontalAlign.Center : HorizontalAlign.Right,
|
|
||||||
verticalAlign: VerticalAlign.Center,
|
|
||||||
leftBorder: Border(
|
|
||||||
borderStyle: BorderStyle.Thin,
|
|
||||||
borderColorHex: ExcelColor.fromHexString('#CCCCCC')),
|
|
||||||
rightBorder: Border(
|
|
||||||
borderStyle: BorderStyle.Thin,
|
|
||||||
borderColorHex: ExcelColor.fromHexString('#CCCCCC')),
|
|
||||||
topBorder: Border(
|
|
||||||
borderStyle: BorderStyle.Thin,
|
|
||||||
borderColorHex: ExcelColor.fromHexString('#CCCCCC')),
|
|
||||||
bottomBorder: Border(
|
|
||||||
borderStyle: BorderStyle.Thin,
|
|
||||||
borderColorHex: ExcelColor.fromHexString('#CCCCCC')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void _setCellInt(
|
|
||||||
Sheet sheet,
|
|
||||||
int row,
|
|
||||||
int col,
|
|
||||||
int value, {
|
|
||||||
String bgColor = _colorWhite,
|
|
||||||
bool centered = false,
|
|
||||||
}) {
|
|
||||||
final cell =
|
|
||||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: col, rowIndex: row));
|
|
||||||
cell.value = IntCellValue(value);
|
|
||||||
cell.cellStyle = CellStyle(
|
|
||||||
backgroundColorHex: ExcelColor.fromHexString('#$bgColor'),
|
|
||||||
horizontalAlign:
|
|
||||||
centered ? HorizontalAlign.Center : HorizontalAlign.Right,
|
|
||||||
verticalAlign: VerticalAlign.Center,
|
|
||||||
leftBorder: Border(
|
|
||||||
borderStyle: BorderStyle.Thin,
|
|
||||||
borderColorHex: ExcelColor.fromHexString('#CCCCCC')),
|
|
||||||
rightBorder: Border(
|
|
||||||
borderStyle: BorderStyle.Thin,
|
|
||||||
borderColorHex: ExcelColor.fromHexString('#CCCCCC')),
|
|
||||||
topBorder: Border(
|
|
||||||
borderStyle: BorderStyle.Thin,
|
|
||||||
borderColorHex: ExcelColor.fromHexString('#CCCCCC')),
|
|
||||||
bottomBorder: Border(
|
|
||||||
borderStyle: BorderStyle.Thin,
|
|
||||||
borderColorHex: ExcelColor.fromHexString('#CCCCCC')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _statusBgColor(String status) {
|
|
||||||
switch (status) {
|
|
||||||
case 'Tinggi':
|
|
||||||
return _colorTinggi;
|
|
||||||
case 'Normal':
|
|
||||||
return _colorWaspada;
|
|
||||||
default: // Rendah
|
|
||||||
return _colorRendah;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _getStatus(double evaporasi) {
|
|
||||||
if (evaporasi > 10.0) return 'Tinggi';
|
|
||||||
if (evaporasi >= 2.0) return 'Normal';
|
|
||||||
return 'Rendah';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -309,38 +309,38 @@ class WindSpeedExcelService {
|
||||||
sheet.setColumnWidth(5, 12);
|
sheet.setColumnWidth(5, 12);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
// // ════════════════════════════════════════════════════════════
|
||||||
// HELPER CELLS
|
// // HELPER CELLS
|
||||||
// ════════════════════════════════════════════════════════════
|
// // ════════════════════════════════════════════════════════════
|
||||||
static CellStyle _baseStyle({
|
// static CellStyle _baseStyle({
|
||||||
String bgColor = _colorWhite,
|
// String bgColor = _colorWhite,
|
||||||
String fontColor = '000000',
|
// String fontColor = '000000',
|
||||||
bool bold = false,
|
// bool bold = false,
|
||||||
int fontSize = 11,
|
// int fontSize = 11,
|
||||||
bool centered = false,
|
// bool centered = false,
|
||||||
}) =>
|
// }) =>
|
||||||
CellStyle(
|
// CellStyle(
|
||||||
bold: bold,
|
// bold: bold,
|
||||||
fontSize: fontSize,
|
// fontSize: fontSize,
|
||||||
backgroundColorHex: ExcelColor.fromHexString('#$bgColor'),
|
// backgroundColorHex: ExcelColor.fromHexString('#$bgColor'),
|
||||||
fontColorHex: ExcelColor.fromHexString('#$fontColor'),
|
// fontColorHex: ExcelColor.fromHexString('#$fontColor'),
|
||||||
horizontalAlign:
|
// horizontalAlign:
|
||||||
centered ? HorizontalAlign.Center : HorizontalAlign.Left,
|
// centered ? HorizontalAlign.Center : HorizontalAlign.Left,
|
||||||
verticalAlign: VerticalAlign.Center,
|
// verticalAlign: VerticalAlign.Center,
|
||||||
textWrapping: TextWrapping.WrapText,
|
// textWrapping: TextWrapping.WrapText,
|
||||||
leftBorder: Border(
|
// leftBorder: Border(
|
||||||
borderStyle: BorderStyle.Thin,
|
// borderStyle: BorderStyle.Thin,
|
||||||
borderColorHex: ExcelColor.fromHexString('#CCCCCC')),
|
// borderColorHex: ExcelColor.fromHexString('#CCCCCC')),
|
||||||
rightBorder: Border(
|
// rightBorder: Border(
|
||||||
borderStyle: BorderStyle.Thin,
|
// borderStyle: BorderStyle.Thin,
|
||||||
borderColorHex: ExcelColor.fromHexString('#CCCCCC')),
|
// borderColorHex: ExcelColor.fromHexString('#CCCCCC')),
|
||||||
topBorder: Border(
|
// topBorder: Border(
|
||||||
borderStyle: BorderStyle.Thin,
|
// borderStyle: BorderStyle.Thin,
|
||||||
borderColorHex: ExcelColor.fromHexString('#CCCCCC')),
|
// borderColorHex: ExcelColor.fromHexString('#CCCCCC')),
|
||||||
bottomBorder: Border(
|
// bottomBorder: Border(
|
||||||
borderStyle: BorderStyle.Thin,
|
// borderStyle: BorderStyle.Thin,
|
||||||
borderColorHex: ExcelColor.fromHexString('#CCCCCC')),
|
// borderColorHex: ExcelColor.fromHexString('#CCCCCC')),
|
||||||
);
|
// );
|
||||||
|
|
||||||
static void _setCell(
|
static void _setCell(
|
||||||
Sheet sheet,
|
Sheet sheet,
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,8 @@ 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)
|
const double _kWindWarning = 25.0;
|
||||||
/// Referensi: Beaufort scale & BMKG
|
const double _kWindDanger = 45.5;
|
||||||
const double _kWindWarning = 25.0; // Waspada: 8–12.5 m/s (~29–45 km/h)
|
|
||||||
const double _kWindDanger = 45.5; // Bahaya: > 12.5 m/s (> 45 km/h)
|
|
||||||
const _kDeviceIdKey = 'selected_device_id';
|
const _kDeviceIdKey = 'selected_device_id';
|
||||||
const _kDefaultDeviceId = 'esp_lapangan';
|
const _kDefaultDeviceId = 'esp_lapangan';
|
||||||
|
|
||||||
|
|
@ -21,6 +19,7 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
|
||||||
final MonitoringRepository _repository;
|
final MonitoringRepository _repository;
|
||||||
final NotificationBloc _notificationBloc;
|
final NotificationBloc _notificationBloc;
|
||||||
StreamSubscription<MyWindSpeed>? _subscription;
|
StreamSubscription<MyWindSpeed>? _subscription;
|
||||||
|
StreamSubscription<Map<String, MyWindSpeed>>? _historySubscription;
|
||||||
|
|
||||||
WindSpeedBloc({
|
WindSpeedBloc({
|
||||||
required MonitoringRepository repository,
|
required MonitoringRepository repository,
|
||||||
|
|
@ -31,77 +30,76 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
|
||||||
on<WatchWindSpeedStarted>(_onStarted, transformer: restartable());
|
on<WatchWindSpeedStarted>(_onStarted, transformer: restartable());
|
||||||
on<_WindSpeedRealtimeUpdated>(_onRealtimeUpdated);
|
on<_WindSpeedRealtimeUpdated>(_onRealtimeUpdated);
|
||||||
on<WindSpeedPeriodChanged>(_onPeriodChanged);
|
on<WindSpeedPeriodChanged>(_onPeriodChanged);
|
||||||
on<WindSpeedDateFilterChanged>(_onDateFilterChanged); // ← baru
|
on<WindSpeedDateFilterChanged>(_onDateFilterChanged);
|
||||||
|
// Delete handlers
|
||||||
|
on<WindSpeedDeleteAllRequested>(_onDeleteAll);
|
||||||
|
on<WindSpeedDeleteByDateRequested>(_onDeleteByDate);
|
||||||
|
on<WindSpeedDeleteByDateRangeRequested>(_onDeleteByDateRange);
|
||||||
|
on<WindSpeedDeleteByHourRangeRequested>(_onDeleteByHourRange);
|
||||||
|
on<_WindSpeedHistoryUpdated>(_onHistoryUpdated);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Baca device ID dari SharedPreferences ─────────────────
|
|
||||||
Future<String> _getDeviceId() async {
|
Future<String> _getDeviceId() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
return prefs.getString(_kDeviceIdKey) ?? _kDefaultDeviceId;
|
return prefs.getString(_kDeviceIdKey) ?? _kDefaultDeviceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
// START
|
// START — sekarang fetch dengan keys untuk delete support
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
Future<void> _onStarted(
|
Future<void> _onStarted(
|
||||||
WatchWindSpeedStarted event,
|
WatchWindSpeedStarted event,
|
||||||
Emitter<WindSpeedState> emit,
|
Emitter<WindSpeedState> emit,
|
||||||
) async {
|
) async {
|
||||||
emit(state.copyWith(isLoading: true));
|
await _subscription?.cancel();
|
||||||
|
await _historySubscription?.cancel();
|
||||||
|
_subscription = null;
|
||||||
|
_historySubscription = null;
|
||||||
|
|
||||||
// Baca device ID aktif (dari SharedPreferences, diset di DeviceSetupBloc)
|
emit(state.copyWith(isLoading: true, currentSpeed: 0));
|
||||||
final deviceId = await _getDeviceId();
|
final deviceId = await _getDeviceId();
|
||||||
|
|
||||||
final history = await _repository.getSensorHistory(
|
// ── History stream (realtime, otomatis update) ──
|
||||||
'anemometer/$deviceId/history',
|
_historySubscription = _repository
|
||||||
(json) => MyWindSpeed.fromJson(json),
|
.getSensorHistoryStream(
|
||||||
);
|
'anemometer/$deviceId/history',
|
||||||
|
(json) => MyWindSpeed.fromJson(json),
|
||||||
|
)
|
||||||
|
.listen((newMap) => add(_WindSpeedHistoryUpdated(newMap)));
|
||||||
|
|
||||||
final dailyGraph = TimeSeriesMapper.smooth(
|
// ── Realtime stream ──
|
||||||
TimeSeriesMapper.toDaily(
|
|
||||||
data: history,
|
|
||||||
getTime: (e) => e.timestamp,
|
|
||||||
getValue: (e) => e.speed,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final weekly = TimeSeriesMapper.smooth(
|
|
||||||
TimeSeriesMapper.toWeekly(
|
|
||||||
data: history,
|
|
||||||
getTime: (e) => e.timestamp,
|
|
||||||
getValue: (e) => e.speed,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final monthly = TimeSeriesMapper.smooth(
|
|
||||||
TimeSeriesMapper.toMonthly(
|
|
||||||
data: history,
|
|
||||||
getTime: (e) => e.timestamp,
|
|
||||||
getValue: (e) => e.speed,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (history.isNotEmpty) {
|
|
||||||
_emitWindAlert(history.last.speed);
|
|
||||||
}
|
|
||||||
|
|
||||||
emit(state.copyWith(
|
|
||||||
history: history,
|
|
||||||
filteredHistory: history, // awal = semua data
|
|
||||||
dailySpeeds: dailyGraph,
|
|
||||||
weeklySpeeds: weekly,
|
|
||||||
monthlySpeeds: monthly,
|
|
||||||
isLoading: false,
|
|
||||||
alertLevel:
|
|
||||||
history.isNotEmpty ? _getAlertLevel(history.last.speed) : 'Normal',
|
|
||||||
));
|
|
||||||
|
|
||||||
// Subscribe realtime dari path device aktif
|
|
||||||
await _subscription?.cancel();
|
|
||||||
_subscription = _repository
|
_subscription = _repository
|
||||||
.getSensorStream(
|
.getSensorStream(
|
||||||
'anemometer/$deviceId/realtime',
|
'anemometer/$deviceId/realtime',
|
||||||
(json) => MyWindSpeed.fromJson(json),
|
(json) => MyWindSpeed.fromJson(json),
|
||||||
)
|
)
|
||||||
.listen((data) => add(_WindSpeedRealtimeUpdated(data)));
|
.listen((data) => add(_WindSpeedRealtimeUpdated(data)));
|
||||||
|
|
||||||
|
// ── Loading selesai, state awal kosong dulu ──
|
||||||
|
// _onHistoryUpdated akan otomatis mengisi data begitu stream pertama emit
|
||||||
|
emit(state.copyWith(isLoading: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onHistoryUpdated(
|
||||||
|
_WindSpeedHistoryUpdated event,
|
||||||
|
Emitter<WindSpeedState> emit,
|
||||||
|
) {
|
||||||
|
final history = _sortedList(event.historyMap);
|
||||||
|
final graphs = _buildGraphs(history);
|
||||||
|
final filtered = state.selectedDate != null
|
||||||
|
? history
|
||||||
|
.where((e) => _isSameDate(e.timestamp, state.selectedDate!))
|
||||||
|
.toList()
|
||||||
|
: history;
|
||||||
|
|
||||||
|
emit(state.copyWith(
|
||||||
|
historyMap: event.historyMap,
|
||||||
|
history: history,
|
||||||
|
filteredHistory: filtered,
|
||||||
|
dailySpeeds: graphs['daily'],
|
||||||
|
weeklySpeeds: graphs['weekly'],
|
||||||
|
monthlySpeeds: graphs['monthly'],
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
@ -111,6 +109,15 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
|
||||||
_WindSpeedRealtimeUpdated event,
|
_WindSpeedRealtimeUpdated event,
|
||||||
Emitter<WindSpeedState> emit,
|
Emitter<WindSpeedState> emit,
|
||||||
) {
|
) {
|
||||||
|
// Guard: abaikan data jika windweg 0 dan pulse 0 (ESP tidak kirim data nyata)
|
||||||
|
if (event.data.windwegKm == 0 && event.data.totalPulse == 0) {
|
||||||
|
emit(state.copyWith(
|
||||||
|
currentSpeed: 0,
|
||||||
|
alertLevel: 'Normal',
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|
@ -128,7 +135,6 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
_emitWindAlert(newValue);
|
_emitWindAlert(newValue);
|
||||||
|
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
currentSpeed: newValue,
|
currentSpeed: newValue,
|
||||||
dailySpeeds: updated,
|
dailySpeeds: updated,
|
||||||
|
|
@ -168,7 +174,6 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
final updatedGraph = TimeSeriesMapper.smooth(raw);
|
final updatedGraph = TimeSeriesMapper.smooth(raw);
|
||||||
|
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
dailySpeeds:
|
dailySpeeds:
|
||||||
event.period == 'Hari Ini' ? updatedGraph : state.dailySpeeds,
|
event.period == 'Hari Ini' ? updatedGraph : state.dailySpeeds,
|
||||||
|
|
@ -176,45 +181,236 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
|
||||||
event.period == 'Minggu Ini' ? updatedGraph : state.weeklySpeeds,
|
event.period == 'Minggu Ini' ? updatedGraph : state.weeklySpeeds,
|
||||||
monthlySpeeds:
|
monthlySpeeds:
|
||||||
event.period == 'Bulan Ini' ? updatedGraph : state.monthlySpeeds,
|
event.period == 'Bulan Ini' ? updatedGraph : state.monthlySpeeds,
|
||||||
isLoading: false,
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
// DATE FILTER CHANGED ← baru
|
// DATE FILTER
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
void _onDateFilterChanged(
|
void _onDateFilterChanged(
|
||||||
WindSpeedDateFilterChanged event,
|
WindSpeedDateFilterChanged event,
|
||||||
Emitter<WindSpeedState> emit,
|
Emitter<WindSpeedState> emit,
|
||||||
) {
|
) {
|
||||||
final date = event.date;
|
final date = event.date;
|
||||||
final allHistory = state.history;
|
|
||||||
|
|
||||||
if (date == null) {
|
if (date == null) {
|
||||||
// Reset: tampilkan semua
|
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
filteredHistory: allHistory,
|
filteredHistory: state.history,
|
||||||
clearSelectedDate: true,
|
clearSelectedDate: true,
|
||||||
));
|
));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter data yang tanggalnya sama dengan [date]
|
final filtered =
|
||||||
final filtered = allHistory.where((item) {
|
state.history.where((e) => _isSameDate(e.timestamp, date)).toList();
|
||||||
return item.timestamp.year == date.year &&
|
|
||||||
item.timestamp.month == date.month &&
|
|
||||||
item.timestamp.day == date.day;
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(filteredHistory: filtered, selectedDate: date));
|
||||||
filteredHistory: filtered,
|
}
|
||||||
selectedDate: date,
|
|
||||||
));
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// DELETE ALL
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
Future<void> _onDeleteAll(
|
||||||
|
WindSpeedDeleteAllRequested event,
|
||||||
|
Emitter<WindSpeedState> emit,
|
||||||
|
) async {
|
||||||
|
emit(state.copyWith(isDeleting: true, clearDeleteError: true));
|
||||||
|
try {
|
||||||
|
final deviceId = await _getDeviceId();
|
||||||
|
await _repository.deleteAllHistory('anemometer/$deviceId/history');
|
||||||
|
final emptyGraphs = _buildGraphs(const []);
|
||||||
|
emit(state.copyWith(
|
||||||
|
isDeleting: false,
|
||||||
|
historyMap: const {},
|
||||||
|
history: const [],
|
||||||
|
filteredHistory: const [],
|
||||||
|
clearSelectedDate: true,
|
||||||
|
dailySpeeds: emptyGraphs['daily'],
|
||||||
|
weeklySpeeds: emptyGraphs['weekly'],
|
||||||
|
monthlySpeeds: emptyGraphs['monthly'],
|
||||||
|
));
|
||||||
|
} catch (e) {
|
||||||
|
emit(state.copyWith(isDeleting: false, deleteError: e.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// DELETE BY DATE
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
Future<void> _onDeleteByDate(
|
||||||
|
WindSpeedDeleteByDateRequested event,
|
||||||
|
Emitter<WindSpeedState> emit,
|
||||||
|
) async {
|
||||||
|
emit(state.copyWith(isDeleting: true, clearDeleteError: true));
|
||||||
|
try {
|
||||||
|
final deviceId = await _getDeviceId();
|
||||||
|
|
||||||
|
final keys = state.historyMap.entries
|
||||||
|
.where((e) => _isSameDate(e.value.timestamp, event.date))
|
||||||
|
.map((e) => e.key)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (keys.isNotEmpty) {
|
||||||
|
await _repository.deleteHistoryByKeys(
|
||||||
|
'anemometer/$deviceId/history', keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
_emitAfterDelete(emit, _removeKeys(state.historyMap, keys));
|
||||||
|
} catch (e) {
|
||||||
|
emit(state.copyWith(isDeleting: false, deleteError: e.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// DELETE BY DATE RANGE
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
Future<void> _onDeleteByDateRange(
|
||||||
|
WindSpeedDeleteByDateRangeRequested event,
|
||||||
|
Emitter<WindSpeedState> emit,
|
||||||
|
) async {
|
||||||
|
emit(state.copyWith(isDeleting: true, clearDeleteError: true));
|
||||||
|
try {
|
||||||
|
final deviceId = await _getDeviceId();
|
||||||
|
|
||||||
|
final startDay =
|
||||||
|
DateTime(event.start.year, event.start.month, event.start.day);
|
||||||
|
final endInclusive =
|
||||||
|
DateTime(event.end.year, event.end.month, event.end.day, 23, 59, 59);
|
||||||
|
|
||||||
|
final keys = state.historyMap.entries
|
||||||
|
.where((e) {
|
||||||
|
final t = e.value.timestamp;
|
||||||
|
return !t.isBefore(startDay) && !t.isAfter(endInclusive);
|
||||||
|
})
|
||||||
|
.map((e) => e.key)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (keys.isNotEmpty) {
|
||||||
|
await _repository.deleteHistoryByKeys(
|
||||||
|
'anemometer/$deviceId/history', keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
_emitAfterDelete(emit, _removeKeys(state.historyMap, keys));
|
||||||
|
} catch (e) {
|
||||||
|
emit(state.copyWith(isDeleting: false, deleteError: e.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// DELETE BY HOUR RANGE
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
Future<void> _onDeleteByHourRange(
|
||||||
|
WindSpeedDeleteByHourRangeRequested event,
|
||||||
|
Emitter<WindSpeedState> emit,
|
||||||
|
) async {
|
||||||
|
emit(state.copyWith(isDeleting: true, clearDeleteError: true));
|
||||||
|
try {
|
||||||
|
final deviceId = await _getDeviceId();
|
||||||
|
|
||||||
|
final keys = state.historyMap.entries
|
||||||
|
.where((e) {
|
||||||
|
final t = e.value.timestamp;
|
||||||
|
return _isSameDate(t, event.date) &&
|
||||||
|
t.hour >= event.startHour &&
|
||||||
|
t.hour <= event.endHour;
|
||||||
|
})
|
||||||
|
.map((e) => e.key)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (keys.isNotEmpty) {
|
||||||
|
await _repository.deleteHistoryByKeys(
|
||||||
|
'anemometer/$deviceId/history', keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-apply filter aktif jika ada
|
||||||
|
final newMap = _removeKeys(state.historyMap, keys);
|
||||||
|
final newHistory = _sortedList(newMap);
|
||||||
|
final newFiltered = state.selectedDate != null
|
||||||
|
? newHistory
|
||||||
|
.where((e) => _isSameDate(e.timestamp, state.selectedDate!))
|
||||||
|
.toList()
|
||||||
|
: newHistory;
|
||||||
|
final graphs = _buildGraphs(newHistory);
|
||||||
|
|
||||||
|
emit(state.copyWith(
|
||||||
|
isDeleting: false,
|
||||||
|
historyMap: newMap,
|
||||||
|
history: newHistory,
|
||||||
|
filteredHistory: newFiltered,
|
||||||
|
dailySpeeds: graphs['daily'],
|
||||||
|
weeklySpeeds: graphs['weekly'],
|
||||||
|
monthlySpeeds: graphs['monthly'],
|
||||||
|
));
|
||||||
|
} catch (e) {
|
||||||
|
emit(state.copyWith(isDeleting: false, deleteError: e.toString()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
// HELPERS
|
// HELPERS
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// Emit state setelah delete all/date/range (selalu reset filter)
|
||||||
|
void _emitAfterDelete(
|
||||||
|
Emitter<WindSpeedState> emit,
|
||||||
|
Map<String, MyWindSpeed> newMap,
|
||||||
|
) {
|
||||||
|
final newHistory = _sortedList(newMap);
|
||||||
|
final graphs = _buildGraphs(newHistory);
|
||||||
|
emit(state.copyWith(
|
||||||
|
isDeleting: false,
|
||||||
|
historyMap: newMap,
|
||||||
|
history: newHistory,
|
||||||
|
filteredHistory: newHistory,
|
||||||
|
clearSelectedDate: true,
|
||||||
|
dailySpeeds: graphs['daily'],
|
||||||
|
weeklySpeeds: graphs['weekly'],
|
||||||
|
monthlySpeeds: graphs['monthly'],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, List<double>> _buildGraphs(List<MyWindSpeed> history) {
|
||||||
|
return {
|
||||||
|
'daily': TimeSeriesMapper.smooth(
|
||||||
|
TimeSeriesMapper.toDaily(
|
||||||
|
data: history,
|
||||||
|
getTime: (e) => e.timestamp,
|
||||||
|
getValue: (e) => e.speed,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'weekly': TimeSeriesMapper.smooth(
|
||||||
|
TimeSeriesMapper.toWeekly(
|
||||||
|
data: history,
|
||||||
|
getTime: (e) => e.timestamp,
|
||||||
|
getValue: (e) => e.speed,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'monthly': TimeSeriesMapper.smooth(
|
||||||
|
TimeSeriesMapper.toMonthly(
|
||||||
|
data: history,
|
||||||
|
getTime: (e) => e.timestamp,
|
||||||
|
getValue: (e) => e.speed,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isSameDate(DateTime a, DateTime b) =>
|
||||||
|
a.year == b.year && a.month == b.month && a.day == b.day;
|
||||||
|
|
||||||
|
Map<String, MyWindSpeed> _removeKeys(
|
||||||
|
Map<String, MyWindSpeed> map,
|
||||||
|
List<String> keys,
|
||||||
|
) {
|
||||||
|
final newMap = Map<String, MyWindSpeed>.from(map);
|
||||||
|
for (final k in keys) newMap.remove(k);
|
||||||
|
return newMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<MyWindSpeed> _sortedList(Map<String, MyWindSpeed> map) {
|
||||||
|
return map.values.toList()
|
||||||
|
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||||
|
}
|
||||||
|
|
||||||
String _getAlertLevel(double speed) {
|
String _getAlertLevel(double speed) {
|
||||||
if (speed >= _kWindDanger) return 'Bahaya';
|
if (speed >= _kWindDanger) return 'Bahaya';
|
||||||
if (speed >= _kWindWarning) return 'Waspada';
|
if (speed >= _kWindWarning) return 'Waspada';
|
||||||
|
|
@ -236,25 +432,23 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
|
||||||
message = '';
|
message = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
_notificationBloc.add(SensorAlertAdded(
|
_notificationBloc.add(SensorAlertAdded(SensorAlert(
|
||||||
SensorAlert(
|
sensorId: 'wind_speed',
|
||||||
sensorId: 'wind_speed',
|
sensorName: 'Anemometer',
|
||||||
sensorName: 'Anemometer',
|
message: message,
|
||||||
message: message,
|
severity: severity,
|
||||||
severity: severity,
|
timestamp: DateTime.now(),
|
||||||
timestamp: DateTime.now(),
|
)));
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> close() async {
|
Future<void> close() async {
|
||||||
await _subscription?.cancel();
|
await _subscription?.cancel();
|
||||||
|
await _historySubscription?.cancel();
|
||||||
return super.close();
|
return super.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Internal event — tidak diekspos ke luar
|
|
||||||
class _WindSpeedRealtimeUpdated extends WindSpeedEvent {
|
class _WindSpeedRealtimeUpdated extends WindSpeedEvent {
|
||||||
final MyWindSpeed data;
|
final MyWindSpeed data;
|
||||||
const _WindSpeedRealtimeUpdated(this.data);
|
const _WindSpeedRealtimeUpdated(this.data);
|
||||||
|
|
|
||||||
|
|
@ -30,3 +30,56 @@ class WindSpeedDateFilterChanged extends WindSpeedEvent {
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [date];
|
List<Object?> get props => [date];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// DELETE EVENTS
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// Hapus SEMUA riwayat dari Firebase
|
||||||
|
class WindSpeedDeleteAllRequested extends WindSpeedEvent {
|
||||||
|
const WindSpeedDeleteAllRequested();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hapus riwayat pada tanggal tertentu
|
||||||
|
class WindSpeedDeleteByDateRequested extends WindSpeedEvent {
|
||||||
|
final DateTime date;
|
||||||
|
const WindSpeedDeleteByDateRequested(this.date);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [date];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hapus riwayat dalam rentang tanggal (inklusif)
|
||||||
|
class WindSpeedDeleteByDateRangeRequested extends WindSpeedEvent {
|
||||||
|
final DateTime start;
|
||||||
|
final DateTime end;
|
||||||
|
const WindSpeedDeleteByDateRangeRequested({
|
||||||
|
required this.start,
|
||||||
|
required this.end,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [start, end];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hapus riwayat dalam rentang jam pada tanggal tertentu
|
||||||
|
class WindSpeedDeleteByHourRangeRequested extends WindSpeedEvent {
|
||||||
|
final DateTime date;
|
||||||
|
final int startHour; // 0–23
|
||||||
|
final int endHour; // 0–23, harus >= startHour
|
||||||
|
const WindSpeedDeleteByHourRangeRequested({
|
||||||
|
required this.date,
|
||||||
|
required this.startHour,
|
||||||
|
required this.endHour,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [date, startHour, endHour];
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WindSpeedHistoryUpdated extends WindSpeedEvent {
|
||||||
|
final Map<String, MyWindSpeed> historyMap;
|
||||||
|
const _WindSpeedHistoryUpdated(this.historyMap);
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [historyMap];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ part of 'wind_speed_bloc.dart';
|
||||||
|
|
||||||
class WindSpeedState extends Equatable {
|
class WindSpeedState extends Equatable {
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
|
final bool isDeleting;
|
||||||
|
final String? deleteError;
|
||||||
final double currentSpeed;
|
final double currentSpeed;
|
||||||
final String alertLevel;
|
final String alertLevel;
|
||||||
final String selectedPeriod;
|
final String selectedPeriod;
|
||||||
|
|
@ -16,8 +18,13 @@ class WindSpeedState extends Equatable {
|
||||||
final List<MyWindSpeed> filteredHistory; // setelah difilter tanggal
|
final List<MyWindSpeed> filteredHistory; // setelah difilter tanggal
|
||||||
final DateTime? selectedDate; // null = tampilkan semua
|
final DateTime? selectedDate; // null = tampilkan semua
|
||||||
|
|
||||||
|
/// Map { Firebase push-key → data } — dipakai untuk operasi delete
|
||||||
|
final Map<String, MyWindSpeed> historyMap;
|
||||||
|
|
||||||
const WindSpeedState({
|
const WindSpeedState({
|
||||||
this.isLoading = false,
|
this.isLoading = false,
|
||||||
|
this.isDeleting = false,
|
||||||
|
this.deleteError,
|
||||||
this.currentSpeed = 0.0,
|
this.currentSpeed = 0.0,
|
||||||
this.alertLevel = 'Normal',
|
this.alertLevel = 'Normal',
|
||||||
this.selectedPeriod = 'Hari Ini',
|
this.selectedPeriod = 'Hari Ini',
|
||||||
|
|
@ -27,10 +34,14 @@ class WindSpeedState extends Equatable {
|
||||||
this.history = const [],
|
this.history = const [],
|
||||||
this.filteredHistory = const [],
|
this.filteredHistory = const [],
|
||||||
this.selectedDate,
|
this.selectedDate,
|
||||||
|
this.historyMap = const {},
|
||||||
});
|
});
|
||||||
|
|
||||||
WindSpeedState copyWith({
|
WindSpeedState copyWith({
|
||||||
bool? isLoading,
|
bool? isLoading,
|
||||||
|
bool? isDeleting,
|
||||||
|
String? deleteError,
|
||||||
|
bool clearDeleteError = false,
|
||||||
double? currentSpeed,
|
double? currentSpeed,
|
||||||
String? alertLevel,
|
String? alertLevel,
|
||||||
String? selectedPeriod,
|
String? selectedPeriod,
|
||||||
|
|
@ -41,9 +52,12 @@ class WindSpeedState extends Equatable {
|
||||||
List<MyWindSpeed>? filteredHistory,
|
List<MyWindSpeed>? filteredHistory,
|
||||||
DateTime? selectedDate,
|
DateTime? selectedDate,
|
||||||
bool clearSelectedDate = false,
|
bool clearSelectedDate = false,
|
||||||
|
Map<String, MyWindSpeed>? historyMap,
|
||||||
}) {
|
}) {
|
||||||
return WindSpeedState(
|
return WindSpeedState(
|
||||||
isLoading: isLoading ?? this.isLoading,
|
isLoading: isLoading ?? this.isLoading,
|
||||||
|
isDeleting: isDeleting ?? this.isDeleting,
|
||||||
|
deleteError: clearDeleteError ? null : (deleteError ?? this.deleteError),
|
||||||
currentSpeed: currentSpeed ?? this.currentSpeed,
|
currentSpeed: currentSpeed ?? this.currentSpeed,
|
||||||
alertLevel: alertLevel ?? this.alertLevel,
|
alertLevel: alertLevel ?? this.alertLevel,
|
||||||
selectedPeriod: selectedPeriod ?? this.selectedPeriod,
|
selectedPeriod: selectedPeriod ?? this.selectedPeriod,
|
||||||
|
|
@ -54,12 +68,15 @@ class WindSpeedState extends Equatable {
|
||||||
filteredHistory: filteredHistory ?? this.filteredHistory,
|
filteredHistory: filteredHistory ?? this.filteredHistory,
|
||||||
selectedDate:
|
selectedDate:
|
||||||
clearSelectedDate ? null : (selectedDate ?? this.selectedDate),
|
clearSelectedDate ? null : (selectedDate ?? this.selectedDate),
|
||||||
|
historyMap: historyMap ?? this.historyMap,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [
|
List<Object?> get props => [
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isDeleting,
|
||||||
|
deleteError,
|
||||||
currentSpeed,
|
currentSpeed,
|
||||||
alertLevel,
|
alertLevel,
|
||||||
selectedPeriod,
|
selectedPeriod,
|
||||||
|
|
@ -69,5 +86,6 @@ class WindSpeedState extends Equatable {
|
||||||
history,
|
history,
|
||||||
filteredHistory,
|
filteredHistory,
|
||||||
selectedDate,
|
selectedDate,
|
||||||
|
historyMap,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ class WindSpeedHistoryList extends StatelessWidget {
|
||||||
final DateTime? selectedDate;
|
final DateTime? selectedDate;
|
||||||
final VoidCallback onPickDate;
|
final VoidCallback onPickDate;
|
||||||
final VoidCallback onClearDate;
|
final VoidCallback onClearDate;
|
||||||
|
final VoidCallback onDeleteTap;
|
||||||
|
final bool isDeleting;
|
||||||
|
|
||||||
const WindSpeedHistoryList({
|
const WindSpeedHistoryList({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -19,6 +21,8 @@ class WindSpeedHistoryList extends StatelessWidget {
|
||||||
required this.selectedDate,
|
required this.selectedDate,
|
||||||
required this.onPickDate,
|
required this.onPickDate,
|
||||||
required this.onClearDate,
|
required this.onClearDate,
|
||||||
|
required this.onDeleteTap,
|
||||||
|
this.isDeleting = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Grouping per tanggal ─────────────────────────────────────
|
// ── Grouping per tanggal ─────────────────────────────────────
|
||||||
|
|
@ -46,6 +50,8 @@ class WindSpeedHistoryList extends StatelessWidget {
|
||||||
totalCount: history.length,
|
totalCount: history.length,
|
||||||
onPickDate: onPickDate,
|
onPickDate: onPickDate,
|
||||||
onClearDate: onClearDate,
|
onClearDate: onClearDate,
|
||||||
|
onDeleteTap: onDeleteTap, // ← baru
|
||||||
|
isDeleting: isDeleting,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
|
@ -98,12 +104,16 @@ class _HeaderBar extends StatelessWidget {
|
||||||
final int totalCount;
|
final int totalCount;
|
||||||
final VoidCallback onPickDate;
|
final VoidCallback onPickDate;
|
||||||
final VoidCallback onClearDate;
|
final VoidCallback onClearDate;
|
||||||
|
final VoidCallback onDeleteTap;
|
||||||
|
final bool isDeleting;
|
||||||
|
|
||||||
const _HeaderBar({
|
const _HeaderBar({
|
||||||
required this.selectedDate,
|
required this.selectedDate,
|
||||||
required this.totalCount,
|
required this.totalCount,
|
||||||
required this.onPickDate,
|
required this.onPickDate,
|
||||||
required this.onClearDate,
|
required this.onClearDate,
|
||||||
|
required this.onDeleteTap,
|
||||||
|
required this.isDeleting,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -148,6 +158,25 @@ class _HeaderBar extends StatelessWidget {
|
||||||
color: Colors.blue.shade700,
|
color: Colors.blue.shade700,
|
||||||
onTap: onPickDate,
|
onTap: onPickDate,
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
|
||||||
|
// ── DELETE BUTTON ← baru ──────────────────────────────
|
||||||
|
isDeleting
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.all(10),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2.5),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
icon: const Icon(Icons.delete_sweep_outlined,
|
||||||
|
color: Colors.redAccent),
|
||||||
|
tooltip: 'Hapus riwayat',
|
||||||
|
onPressed: totalCount == 0 ? null : onDeleteTap,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import 'package:monitoring_repository/monitoring_repository.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';
|
||||||
import 'widgets/period_selector.dart';
|
import 'widgets/period_selector.dart';
|
||||||
import '../../device_setup/views/device_setup_screen.dart';
|
import '../../device_setup/views/device_setup_wind_speed_screen.dart';
|
||||||
import '../../shared/utils/excel/wind_speed_excel_service.dart';
|
import '../../shared/utils/excel/wind_speed_excel_service.dart';
|
||||||
|
|
||||||
class WindSpeedScreen extends StatefulWidget {
|
class WindSpeedScreen extends StatefulWidget {
|
||||||
|
|
@ -601,6 +601,24 @@ class _WindSpeedScreenState extends State<WindSpeedScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// Delete bottom sheet
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
void _showDeleteSheet(BuildContext context) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useSafeArea: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (_) => BlocProvider.value(
|
||||||
|
value: context.read<WindSpeedBloc>(),
|
||||||
|
child: const _DeleteSheet(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
@ -634,7 +652,16 @@ class _WindSpeedScreenState extends State<WindSpeedScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: BlocBuilder<WindSpeedBloc, WindSpeedState>(
|
body: BlocConsumer<WindSpeedBloc, WindSpeedState>(
|
||||||
|
listenWhen: (prev, curr) =>
|
||||||
|
curr.deleteError != null && prev.deleteError != curr.deleteError,
|
||||||
|
listener: (context, state) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||||
|
content: Text('Gagal menghapus: ${state.deleteError}'),
|
||||||
|
backgroundColor: Colors.red.shade700,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
));
|
||||||
|
},
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state.isLoading) {
|
if (state.isLoading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
|
@ -720,7 +747,7 @@ class _WindSpeedScreenState extends State<WindSpeedScreen> {
|
||||||
const Icon(Icons.air, color: Colors.white, size: 46),
|
const Icon(Icons.air, color: Colors.white, size: 46),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
speed.toStringAsFixed(1),
|
speed.toStringAsFixed(2),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 80,
|
fontSize: 80,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
|
@ -728,11 +755,11 @@ class _WindSpeedScreenState extends State<WindSpeedScreen> {
|
||||||
height: 1,
|
height: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Text('m/s',
|
const Text('km/h',
|
||||||
style: TextStyle(fontSize: 20, color: Colors.white70)),
|
style: TextStyle(fontSize: 20, color: Colors.white70)),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'${(speed * 3.6).toStringAsFixed(1)} km/h',
|
'${(speed / 3.6).toStringAsFixed(3)} m/s',
|
||||||
style: const TextStyle(fontSize: 14, color: Colors.white54),
|
style: const TextStyle(fontSize: 14, color: Colors.white54),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
|
|
@ -876,6 +903,26 @@ class _WindSpeedScreenState extends State<WindSpeedScreen> {
|
||||||
icon: Icons.calendar_month_rounded,
|
icon: Icons.calendar_month_rounded,
|
||||||
onTap: () => _pickDate(context, state),
|
onTap: () => _pickDate(context, state),
|
||||||
),
|
),
|
||||||
|
// ── Tombol Delete ──────────────────────────────────────────
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
state.isDeleting
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.all(10),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2.5),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
icon: const Icon(Icons.delete_sweep_outlined,
|
||||||
|
color: Colors.redAccent),
|
||||||
|
tooltip: 'Hapus riwayat',
|
||||||
|
onPressed: state.history.isEmpty
|
||||||
|
? null
|
||||||
|
: () => _showDeleteSheet(context),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
@ -1009,8 +1056,8 @@ class _HistoryTile extends StatelessWidget {
|
||||||
const _HistoryTile({required this.item, required this.index});
|
const _HistoryTile({required this.item, required this.index});
|
||||||
|
|
||||||
String get _alertLevel {
|
String get _alertLevel {
|
||||||
if (item.speed >= 12.5) return 'Bahaya';
|
if (item.speed >= 45.0) return 'Bahaya';
|
||||||
if (item.speed >= 8.0) return 'Waspada';
|
if (item.speed >= 25.5) return 'Waspada';
|
||||||
return 'Normal';
|
return 'Normal';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1038,94 +1085,491 @@ class _HistoryTile extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final kmh = item.speed * 3.6;
|
|
||||||
final bg = index.isEven ? Colors.grey.shade50 : Colors.white;
|
final bg = index.isEven ? Colors.grey.shade50 : Colors.white;
|
||||||
|
final hasTotal = item.totalWindwegKm > 0 || item.totalPulse > 0;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
color: bg,
|
color: bg,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
child: Row(
|
// ← child pakai Column, bukan Row langsung
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Nomor
|
// ── Baris utama ───────────────────────────────────
|
||||||
SizedBox(
|
Row(
|
||||||
width: 28,
|
children: [
|
||||||
child: Text(
|
// Nomor
|
||||||
'${index + 1}',
|
SizedBox(
|
||||||
style: TextStyle(
|
width: 28,
|
||||||
fontSize: 12,
|
child: Text(
|
||||||
color: Colors.grey.shade400,
|
'${index + 1}',
|
||||||
fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Tanggal + jam
|
|
||||||
Expanded(
|
|
||||||
flex: 3,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
DateFormat('dd MMM yyyy', 'id_ID').format(item.timestamp),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12, fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
DateFormat('HH:mm:ss').format(item.timestamp),
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 12,
|
||||||
color: Colors.grey.shade500,
|
color: Colors.grey.shade400,
|
||||||
fontFamily: 'monospace'),
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
|
||||||
|
// Tanggal + jam
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
DateFormat('dd MMM yyyy', 'id_ID').format(item.timestamp),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
DateFormat('HH:mm:ss').format(item.timestamp),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Avg + max windweg
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'avg ${item.windwegKm.toStringAsFixed(3)} km',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
if (item.maxWindwegKm > 0)
|
||||||
|
Text(
|
||||||
|
'max ${item.maxWindwegKm.toStringAsFixed(3)} km',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10, color: Colors.orange.shade600),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
|
||||||
|
// Badge status
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _alertColor.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: _alertColor.withValues(alpha: 0.4)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(_alertIcon, size: 11, color: _alertColor),
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
Text(
|
||||||
|
_alertLevel,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: _alertColor,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
// Kecepatan
|
// ── Baris kedua: total + pulsa + sampel ───────────
|
||||||
Expanded(
|
if (hasTotal) ...[
|
||||||
flex: 2,
|
const SizedBox(height: 6),
|
||||||
child: Column(
|
Padding(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
padding: const EdgeInsets.only(left: 28),
|
||||||
children: [
|
child: Wrap(
|
||||||
Text(
|
spacing: 6,
|
||||||
'${item.speed.toStringAsFixed(3)} m/s',
|
runSpacing: 4,
|
||||||
style: const TextStyle(
|
children: [
|
||||||
fontSize: 13, fontWeight: FontWeight.bold),
|
_StatChip(
|
||||||
),
|
icon: Icons.route_rounded,
|
||||||
Text(
|
label: '${item.totalWindwegKm.toStringAsFixed(3)} km total',
|
||||||
'${kmh.toStringAsFixed(2)} km/h',
|
tooltip: 'Total jarak angin periode ini',
|
||||||
style: TextStyle(fontSize: 11, color: Colors.grey.shade400),
|
color: Colors.blue.shade700,
|
||||||
),
|
),
|
||||||
],
|
_StatChip(
|
||||||
|
icon: Icons.bolt_rounded,
|
||||||
|
label: '${item.totalPulse} pulsa',
|
||||||
|
tooltip: 'Total pulsa terdeteksi',
|
||||||
|
color: Colors.purple.shade600,
|
||||||
|
),
|
||||||
|
if (item.sampleCount > 0)
|
||||||
|
_StatChip(
|
||||||
|
icon: Icons.analytics_outlined,
|
||||||
|
label: '${item.sampleCount} sampel',
|
||||||
|
tooltip: 'Jumlah interval average dalam periode ini',
|
||||||
|
color: Colors.teal.shade600,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
|
|
||||||
// Badge status
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: _alertColor.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
border: Border.all(color: _alertColor.withValues(alpha: 0.4)),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(_alertIcon, size: 11, color: _alertColor),
|
|
||||||
const SizedBox(width: 3),
|
|
||||||
Text(
|
|
||||||
_alertLevel,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
color: _alertColor,
|
|
||||||
fontWeight: FontWeight.w700),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Chip kecil untuk stat di history tile ──────────────────────
|
||||||
|
class _StatChip extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final String tooltip;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
const _StatChip({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.tooltip,
|
||||||
|
required this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Tooltip(
|
||||||
|
message: tooltip,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: color.withValues(alpha: 0.25)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 11, color: color),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: color,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// Delete Bottom Sheet
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
class _DeleteSheet extends StatefulWidget {
|
||||||
|
const _DeleteSheet();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_DeleteSheet> createState() => _DeleteSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DeleteSheetState extends State<_DeleteSheet> {
|
||||||
|
int _mode = 0; // 0=all, 1=date, 2=range, 3=hour
|
||||||
|
DateTime? _byDate;
|
||||||
|
DateTime? _rangeStart;
|
||||||
|
DateTime? _rangeEnd;
|
||||||
|
DateTime? _hourDate;
|
||||||
|
int _startHour = 0;
|
||||||
|
int _endHour = 23;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
16, 16, 16, MediaQuery.of(context).viewInsets.bottom + 24),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(children: [
|
||||||
|
const Icon(Icons.delete_sweep, color: Colors.redAccent),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
const Text('Hapus Riwayat Angin',
|
||||||
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: () => Navigator.pop(context)),
|
||||||
|
]),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
_modeCard(0, Icons.select_all_rounded, 'Hapus Semua',
|
||||||
|
'Menghapus seluruh riwayat dari Firebase'),
|
||||||
|
_modeCard(1, Icons.event_outlined, 'Berdasarkan Tanggal',
|
||||||
|
'Hapus data pada satu tanggal tertentu'),
|
||||||
|
_modeCard(2, Icons.date_range_outlined, 'Rentang Tanggal',
|
||||||
|
'Hapus data dalam rentang tanggal (inklusif)'),
|
||||||
|
_modeCard(3, Icons.schedule_outlined, 'Rentang Jam',
|
||||||
|
'Hapus data berdasarkan jam pada suatu tanggal'),
|
||||||
|
if (_mode > 0) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
child: _buildSubInput(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 48,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10)),
|
||||||
|
),
|
||||||
|
onPressed: _valid ? _confirm : null,
|
||||||
|
icon: const Icon(Icons.delete_forever),
|
||||||
|
label: const Text('Hapus Sekarang',
|
||||||
|
style:
|
||||||
|
TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_mode == 0)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 8),
|
||||||
|
child: Center(
|
||||||
|
child: Text('⚠️ Tindakan ini tidak dapat dibatalkan',
|
||||||
|
style: TextStyle(fontSize: 11, color: Colors.deepOrange)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _modeCard(int value, IconData icon, String title, String sub) {
|
||||||
|
final sel = _mode == value;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => setState(() => _mode = value),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 3),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: sel ? Colors.red.withOpacity(0.07) : Colors.transparent,
|
||||||
|
border: Border.all(
|
||||||
|
color: sel ? Colors.red.shade300 : Colors.grey.shade200),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Row(children: [
|
||||||
|
Icon(icon, size: 20, color: sel ? Colors.red : Colors.grey),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child:
|
||||||
|
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text(title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 13,
|
||||||
|
color: sel ? Colors.red : Colors.black87)),
|
||||||
|
Text(sub,
|
||||||
|
style: TextStyle(fontSize: 11, color: Colors.grey.shade600)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
if (sel) const Icon(Icons.check_circle, color: Colors.red, size: 18),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSubInput() {
|
||||||
|
switch (_mode) {
|
||||||
|
case 1:
|
||||||
|
return _datePicker(
|
||||||
|
'Pilih Tanggal', _byDate, (d) => setState(() => _byDate = d),
|
||||||
|
key: const ValueKey('by_date'));
|
||||||
|
case 2:
|
||||||
|
return Column(key: const ValueKey('range'), children: [
|
||||||
|
_datePicker('Tanggal Mulai', _rangeStart,
|
||||||
|
(d) => setState(() => _rangeStart = d)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_datePicker(
|
||||||
|
'Tanggal Akhir', _rangeEnd, (d) => setState(() => _rangeEnd = d)),
|
||||||
|
if (_rangeStart != null &&
|
||||||
|
_rangeEnd != null &&
|
||||||
|
_rangeEnd!.isBefore(_rangeStart!))
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 4),
|
||||||
|
child: Text('Tanggal akhir harus ≥ tanggal mulai',
|
||||||
|
style: TextStyle(color: Colors.red, fontSize: 11)),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
case 3:
|
||||||
|
return Column(
|
||||||
|
key: const ValueKey('hour'),
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_datePicker('Pilih Tanggal', _hourDate,
|
||||||
|
(d) => setState(() => _hourDate = d)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text('Rentang Jam',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(children: [
|
||||||
|
Expanded(
|
||||||
|
child: _hourDrop('Jam Mulai', _startHour,
|
||||||
|
(v) => setState(() => _startHour = v))),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 10),
|
||||||
|
child: Text('—', style: TextStyle(fontSize: 18))),
|
||||||
|
Expanded(
|
||||||
|
child: _hourDrop('Jam Akhir', _endHour,
|
||||||
|
(v) => setState(() => _endHour = v))),
|
||||||
|
]),
|
||||||
|
if (_startHour > _endHour)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 4),
|
||||||
|
child: Text('Jam mulai harus ≤ jam akhir',
|
||||||
|
style: TextStyle(color: Colors.red, fontSize: 11)),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
default:
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _datePicker(
|
||||||
|
String hint, DateTime? value, ValueChanged<DateTime> onPick,
|
||||||
|
{Key? key}) {
|
||||||
|
return GestureDetector(
|
||||||
|
key: key,
|
||||||
|
onTap: () async {
|
||||||
|
final d = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: value ?? DateTime.now(),
|
||||||
|
firstDate: DateTime(2020),
|
||||||
|
lastDate: DateTime.now(),
|
||||||
|
confirmText: 'Pilih',
|
||||||
|
cancelText: 'Batal',
|
||||||
|
);
|
||||||
|
if (d != null) onPick(d);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade50,
|
||||||
|
border: Border.all(
|
||||||
|
color:
|
||||||
|
value != null ? Colors.red.shade300 : Colors.grey.shade300),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(children: [
|
||||||
|
Icon(Icons.calendar_month_outlined,
|
||||||
|
size: 16,
|
||||||
|
color: value != null ? Colors.red : Colors.grey.shade400),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
value != null
|
||||||
|
? DateFormat('d MMMM yyyy', 'id_ID').format(value)
|
||||||
|
: hint,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: value != null ? Colors.black87 : Colors.grey.shade400),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _hourDrop(String label, int value, ValueChanged<int> onChange) {
|
||||||
|
return DropdownButtonFormField<int>(
|
||||||
|
value: value,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
labelStyle: const TextStyle(fontSize: 11),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
items: List.generate(
|
||||||
|
24,
|
||||||
|
(i) => DropdownMenuItem(
|
||||||
|
value: i,
|
||||||
|
child: Text('${i.toString().padLeft(2, '0')}:00',
|
||||||
|
style: const TextStyle(fontSize: 13)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (v) => onChange(v!),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _valid {
|
||||||
|
switch (_mode) {
|
||||||
|
case 0:
|
||||||
|
return true;
|
||||||
|
case 1:
|
||||||
|
return _byDate != null;
|
||||||
|
case 2:
|
||||||
|
return _rangeStart != null &&
|
||||||
|
_rangeEnd != null &&
|
||||||
|
!_rangeEnd!.isBefore(_rangeStart!);
|
||||||
|
case 3:
|
||||||
|
return _hourDate != null && _startHour <= _endHour;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _confirm() {
|
||||||
|
final bloc = context.read<WindSpeedBloc>();
|
||||||
|
if (_mode == 0) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Hapus Semua?'),
|
||||||
|
content:
|
||||||
|
const Text('Seluruh riwayat akan dihapus permanen dari Firebase. '
|
||||||
|
'Tindakan ini tidak dapat dibatalkan.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Batal')),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(backgroundColor: Colors.red),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
Navigator.pop(context);
|
||||||
|
bloc.add(const WindSpeedDeleteAllRequested());
|
||||||
|
},
|
||||||
|
child: const Text('Ya, Hapus Semua'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (_mode) {
|
||||||
|
case 1:
|
||||||
|
bloc.add(WindSpeedDeleteByDateRequested(_byDate!));
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
bloc.add(WindSpeedDeleteByDateRangeRequested(
|
||||||
|
start: _rangeStart!, end: _rangeEnd!));
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
bloc.add(WindSpeedDeleteByHourRangeRequested(
|
||||||
|
date: _hourDate!, startHour: _startHour, endHour: _endHour));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import cloud_firestore
|
||||||
import firebase_auth
|
import firebase_auth
|
||||||
import firebase_core
|
import firebase_core
|
||||||
import firebase_database
|
import firebase_database
|
||||||
|
import flutter_local_notifications
|
||||||
import flutter_localization
|
import flutter_localization
|
||||||
import google_sign_in_ios
|
import google_sign_in_ios
|
||||||
import printing
|
import printing
|
||||||
|
|
@ -22,6 +23,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
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"))
|
||||||
FLTFirebaseDatabasePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseDatabasePlugin"))
|
FLTFirebaseDatabasePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseDatabasePlugin"))
|
||||||
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
FlutterLocalizationPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalizationPlugin"))
|
FlutterLocalizationPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalizationPlugin"))
|
||||||
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
||||||
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
|
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
|
||||||
|
|
|
||||||
|
|
@ -61,51 +61,91 @@ class FirebaseMonitoringRepo implements MonitoringRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, T>> getSensorHistoryWithKeys<T>(
|
||||||
|
String path,
|
||||||
|
T Function(Map<dynamic, dynamic> json) mapper,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final snapshot = await _db.ref(path).get();
|
||||||
|
if (snapshot.exists && snapshot.value is Map) {
|
||||||
|
final data = snapshot.value as Map<dynamic, dynamic>;
|
||||||
|
return {
|
||||||
|
for (final entry in data.entries)
|
||||||
|
entry.key.toString(): mapper(
|
||||||
|
entry.value is Map ? entry.value as Map<dynamic, dynamic> : {},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
} catch (_) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteHistoryByKeys(String path, List<String> keys) async {
|
||||||
|
if (keys.isEmpty) return;
|
||||||
|
// Firebase multi-path delete: set tiap path ke null dalam satu update
|
||||||
|
final Map<String, dynamic> updates = {
|
||||||
|
for (final key in keys) '$path/$key': null,
|
||||||
|
};
|
||||||
|
await _db.ref().update(updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteAllHistory(String path) async {
|
||||||
|
await _db.ref(path).remove();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Anemometer Settings ──────────────────────────────────────
|
// ── Anemometer Settings ──────────────────────────────────────
|
||||||
@override
|
@override
|
||||||
Future<Map<String, dynamic>> getAnemometerSettings() async {
|
Future<Map<String, dynamic>> getAnemometerSettings() async {
|
||||||
// Default sama dengan cfg_config.h di ESP
|
// Default sama dengan cfg_config.h di ESP
|
||||||
const defaults = <String, dynamic>{
|
const defaults = <String, dynamic>{
|
||||||
'k_faktor': 50.0,
|
|
||||||
'radius_m': 0.08,
|
|
||||||
'interval_realtime_ms': 1000,
|
'interval_realtime_ms': 1000,
|
||||||
|
'interval_average_ms': 60000,
|
||||||
'interval_history_ms': 3600000,
|
'interval_history_ms': 3600000,
|
||||||
|
'magnet_count': 3,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
final snapshot = await _db.ref('anemometer/settings').get();
|
final snapshot = await _db.ref('anemometer/settings').get();
|
||||||
if (snapshot.exists && snapshot.value is Map) {
|
if (snapshot.exists && snapshot.value is Map) {
|
||||||
final raw = snapshot.value as Map<dynamic, dynamic>;
|
final raw = snapshot.value as Map<dynamic, dynamic>;
|
||||||
return {
|
return {
|
||||||
'k_faktor': (raw['k_faktor'] ?? defaults['k_faktor']).toDouble(),
|
|
||||||
'radius_m': (raw['radius_m'] ?? defaults['radius_m']).toDouble(),
|
|
||||||
'interval_realtime_ms':
|
'interval_realtime_ms':
|
||||||
(raw['interval_realtime_ms'] ?? defaults['interval_realtime_ms'])
|
(raw['interval_realtime_ms'] ?? defaults['interval_realtime_ms'])
|
||||||
as int,
|
as int,
|
||||||
|
'interval_average_ms':
|
||||||
|
(raw['interval_average_ms'] ?? defaults['interval_average_ms'])
|
||||||
|
as int,
|
||||||
'interval_history_ms':
|
'interval_history_ms':
|
||||||
(raw['interval_history_ms'] ?? defaults['interval_history_ms'])
|
(raw['interval_history_ms'] ?? defaults['interval_history_ms'])
|
||||||
as int,
|
as int,
|
||||||
|
'magnet_count':
|
||||||
|
(raw['magnet_count'] as int? ?? defaults['magnet_count']) as int,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
return defaults;
|
return defaults;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tulis settings ke Firebase (dari app) ────────────────────
|
// ── Tulis settings ke Firebase (dari app) ────────────────────
|
||||||
@override
|
@override
|
||||||
Future<void> updateAnemometerSettings({
|
Future<void> updateAnemometerSettings({
|
||||||
double? kFaktor,
|
|
||||||
double? radiusM,
|
|
||||||
int? intervalRealtimeMs,
|
int? intervalRealtimeMs,
|
||||||
|
int? intervalAverageMs,
|
||||||
int? intervalHistoryMs,
|
int? intervalHistoryMs,
|
||||||
int? magnetCount,
|
int? magnetCount,
|
||||||
}) async {
|
}) async {
|
||||||
final updates = <String, dynamic>{};
|
final updates = <String, dynamic>{};
|
||||||
if (kFaktor != null) updates['k_faktor'] = kFaktor;
|
|
||||||
if (intervalRealtimeMs != null)
|
if (intervalRealtimeMs != null)
|
||||||
updates['interval_realtime_ms'] = intervalRealtimeMs;
|
updates['interval_realtime_ms'] = intervalRealtimeMs;
|
||||||
|
if (intervalAverageMs != null)
|
||||||
|
updates['interval_average_ms'] = intervalAverageMs;
|
||||||
if (intervalHistoryMs != null)
|
if (intervalHistoryMs != null)
|
||||||
updates['interval_history_ms'] = intervalHistoryMs;
|
updates['interval_history_ms'] = intervalHistoryMs;
|
||||||
|
if (magnetCount != null) updates['magnet_count'] = magnetCount;
|
||||||
if (updates.isNotEmpty) {
|
if (updates.isNotEmpty) {
|
||||||
await _db.ref('anemometer/settings').update(updates);
|
await _db.ref('anemometer/settings').update(updates);
|
||||||
}
|
}
|
||||||
|
|
@ -160,4 +200,21 @@ class FirebaseMonitoringRepo implements MonitoringRepository {
|
||||||
Future<void> sendRemoteRestart(String deviceId) async {
|
Future<void> sendRemoteRestart(String deviceId) async {
|
||||||
await _db.ref('anemometer/$deviceId/command/restart').set(true);
|
await _db.ref('anemometer/$deviceId/command/restart').set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<Map<String, T>> getSensorHistoryStream<T>(
|
||||||
|
String path,
|
||||||
|
T Function(Map<dynamic, dynamic> json) mapper,
|
||||||
|
) {
|
||||||
|
return _db.ref(path).onValue.map((event) {
|
||||||
|
if (!event.snapshot.exists || event.snapshot.value is! Map) return {};
|
||||||
|
final data = event.snapshot.value as Map<dynamic, dynamic>;
|
||||||
|
return {
|
||||||
|
for (final entry in data.entries)
|
||||||
|
entry.key.toString(): mapper(
|
||||||
|
entry.value is Map ? entry.value as Map<dynamic, dynamic> : {},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,41 @@
|
||||||
class MyWindSpeed {
|
class MyWindSpeed {
|
||||||
final double speed; // realtime: speed_ms | history: avg_ms
|
final double windwegKm;
|
||||||
final double maxSpeed; // history only (0 jika dari realtime)
|
final double totalWindwegKm;
|
||||||
final double kFaktor;
|
final double maxWindwegKm;
|
||||||
|
final int totalPulse;
|
||||||
final int sampleCount;
|
final int sampleCount;
|
||||||
final DateTime timestamp;
|
final DateTime timestamp;
|
||||||
|
|
||||||
MyWindSpeed({
|
MyWindSpeed({
|
||||||
required this.speed,
|
required this.windwegKm,
|
||||||
this.maxSpeed = 0.0,
|
this.totalWindwegKm = 0.0,
|
||||||
this.kFaktor = 1.0,
|
this.maxWindwegKm = 0.0,
|
||||||
|
this.totalPulse = 0,
|
||||||
this.sampleCount = 0,
|
this.sampleCount = 0,
|
||||||
required this.timestamp,
|
required this.timestamp,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Getter alias untuk kompatibilitas dengan bloc/chart yang pakai .speed
|
||||||
|
double get speed => windwegKm;
|
||||||
|
double get maxSpeed => maxWindwegKm;
|
||||||
|
|
||||||
static final empty = MyWindSpeed(
|
static final empty = MyWindSpeed(
|
||||||
speed: 0.0,
|
windwegKm: 0.0,
|
||||||
timestamp: DateTime.fromMillisecondsSinceEpoch(0),
|
timestamp: DateTime.fromMillisecondsSinceEpoch(0),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Dipakai untuk realtime node
|
|
||||||
factory MyWindSpeed.fromJson(Map<dynamic, dynamic> json) {
|
factory MyWindSpeed.fromJson(Map<dynamic, dynamic> json) {
|
||||||
return MyWindSpeed(
|
return MyWindSpeed(
|
||||||
// support field lama 'speed' dan baru 'speed_ms'/'avg_ms'
|
windwegKm:
|
||||||
speed: ((json['speed_ms'] ?? json['avg_ms'] ?? json['speed']) ?? 0)
|
((json['windweg_km'] ??
|
||||||
.toDouble(),
|
json['avg_windweg_km'] ??
|
||||||
maxSpeed: (json['max_ms'] ?? 0).toDouble(),
|
json['speed_ms'] ?? // fallback data lama
|
||||||
kFaktor: (json['k_faktor'] ?? 1.0).toDouble(),
|
json['speed'] ??
|
||||||
|
0))
|
||||||
|
.toDouble(),
|
||||||
|
totalWindwegKm: (json['total_windweg_km'] ?? 0).toDouble(),
|
||||||
|
maxWindwegKm: (json['max_windweg_km'] ?? 0).toDouble(),
|
||||||
|
totalPulse: (json['total_pulse'] ?? 0) as int,
|
||||||
sampleCount: (json['sample_count'] ?? 0) as int,
|
sampleCount: (json['sample_count'] ?? 0) as int,
|
||||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||||
(json['timestamp'] ?? 0) * 1000,
|
(json['timestamp'] ?? 0) * 1000,
|
||||||
|
|
|
||||||
|
|
@ -17,18 +17,11 @@ abstract class MonitoringRepository {
|
||||||
T Function(Map<dynamic, dynamic> json) mapper,
|
T Function(Map<dynamic, dynamic> json) mapper,
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Anemometer Settings ──────────────────────────────────────
|
|
||||||
/// Baca semua settings dari /anemometer/settings/
|
|
||||||
/// Return map dengan keys:
|
|
||||||
/// k_faktor (double), radius_m (double),
|
|
||||||
/// interval_realtime_ms (int), interval_history_ms (int)
|
|
||||||
Future<Map<String, dynamic>> getAnemometerSettings();
|
Future<Map<String, dynamic>> getAnemometerSettings();
|
||||||
|
|
||||||
/// Tulis settings ke /anemometer/settings/ (field yang null tidak ditulis)
|
|
||||||
Future<void> updateAnemometerSettings({
|
Future<void> updateAnemometerSettings({
|
||||||
double? kFaktor,
|
|
||||||
double? radiusM,
|
|
||||||
int? intervalRealtimeMs,
|
int? intervalRealtimeMs,
|
||||||
|
int? intervalAverageMs,
|
||||||
int? intervalHistoryMs,
|
int? intervalHistoryMs,
|
||||||
int? magnetCount,
|
int? magnetCount,
|
||||||
});
|
});
|
||||||
|
|
@ -41,6 +34,17 @@ abstract class MonitoringRepository {
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<void> sendRemoteRestart(String deviceId);
|
Future<void> sendRemoteRestart(String deviceId);
|
||||||
|
|
||||||
Future<void> deleteDeviceLogs(String deviceId, List<String> keys);
|
Future<void> deleteDeviceLogs(String deviceId, List<String> keys);
|
||||||
|
Future<Map<String, T>> getSensorHistoryWithKeys<T>(
|
||||||
|
String path,
|
||||||
|
T Function(Map<dynamic, dynamic> json) mapper,
|
||||||
|
);
|
||||||
|
Future<void> deleteHistoryByKeys(String path, List<String> keys);
|
||||||
|
Future<void> deleteAllHistory(String path);
|
||||||
|
|
||||||
|
// Tambah di abstract class MonitoringRepository:
|
||||||
|
Stream<Map<String, T>> getSensorHistoryStream<T>(
|
||||||
|
String path,
|
||||||
|
T Function(Map<dynamic, dynamic> json) mapper,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
60
pubspec.lock
60
pubspec.lock
|
|
@ -193,6 +193,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.8"
|
version: "1.0.8"
|
||||||
|
dbus:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dbus
|
||||||
|
sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.14"
|
||||||
dio:
|
dio:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -374,6 +382,46 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.2"
|
version: "3.0.2"
|
||||||
|
flutter_local_notifications:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications
|
||||||
|
sha256: a0d7141f14cabcee42967470a858dfc99dd6cfb70d3cab404bacfcafa9e84e70
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "22.0.1"
|
||||||
|
flutter_local_notifications_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_linux
|
||||||
|
sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.0.1"
|
||||||
|
flutter_local_notifications_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_platform_interface
|
||||||
|
sha256: ff0013eae795e8dc8fad4a8992a209e64d3ba2fbd8bf5e43c36bf448f95bd814
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "12.0.0"
|
||||||
|
flutter_local_notifications_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_web
|
||||||
|
sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
|
flutter_local_notifications_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_windows
|
||||||
|
sha256: "6f43bdd03b171b7a90f22647506fea33e2bb12294b7c7c7a3d690e960a382945"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.1"
|
||||||
flutter_localization:
|
flutter_localization:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -945,6 +993,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.7"
|
version: "0.7.7"
|
||||||
|
timezone:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: timezone
|
||||||
|
sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.11.0"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1036,10 +1092,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: win32
|
name: win32
|
||||||
sha256: a1fc9eb9248baa05dfc12ed5b66e377b3e23f095eec078e0371622b9033810d9
|
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.2.0"
|
version: "6.3.0"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
name: klimatologiot
|
name: klimatologiot
|
||||||
description: 'A new Flutter project.'
|
description: 'A new Flutter project.'
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.0.3+1
|
version: 1.0.4+1
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.1.0
|
sdk: ^3.1.0
|
||||||
|
|
||||||
|
|
@ -29,6 +29,7 @@ dependencies:
|
||||||
path: packages/monitoring_repository
|
path: packages/monitoring_repository
|
||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
pdf: ^3.11.1
|
pdf: ^3.11.1
|
||||||
|
flutter_local_notifications: ^22.0.1
|
||||||
printing: ^5.13.1
|
printing: ^5.13.1
|
||||||
rxdart: ^0.27.7
|
rxdart: ^0.27.7
|
||||||
share_plus: ^13.1.0
|
share_plus: ^13.1.0
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
flutter_local_notifications_windows
|
||||||
jni
|
jni
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue