Compare commits
10 Commits
243add9dc3
...
f361b85612
| Author | SHA1 | Date |
|---|---|---|
|
|
f361b85612 | |
|
|
53c01e694f | |
|
|
80016bd1ef | |
|
|
2ef850c174 | |
|
|
a3c0f20501 | |
|
|
6027d4153a | |
|
|
af238b023b | |
|
|
ed4cf44239 | |
|
|
9971d5d1fe | |
|
|
299ae1709f |
|
|
@ -1,5 +1,4 @@
|
|||
manifest.json,1768956954743,5edb32bebce763b2b7e55774ae4c5c4354a44df248387cc3c174e8710e85aaa1
|
||||
main.dart.js,1782833541900,2b62cd155da049dceeab3413d4498565cd3c240fa1129ddc749ad52d4d0b79f9
|
||||
flutter.js,1767911992319,970356511bee637e57348b9e01b3218a3d1d6c4add478fdc306033b4b3e21ef4
|
||||
favicon.png,1768956954741,19880e004e8357a51f6a64f5d1fdc8828a6432fe9c781f8acb6b730eced2cd13
|
||||
splash/img/light-4x.png,1782356157453,09ff8858378bb70d5f4b0381eb98dc9a889e541aeb2dd7576da7d62f205de578
|
||||
|
|
@ -31,15 +30,16 @@ assets/images/windy.png,1776157890991,b9d34ed004fff60a4fea365c23508823943f3f0793
|
|||
assets/images/logo_klimatologi.png,1777103040742,e042ff556e356926fcd5ff92555519a569a71316ab2f7074f86f4ea4a215f325
|
||||
assets/images/google-icon-logo.png,1776157890990,333cfafcd5c87738a4098359e18e02ff5848b2b1b819ec8aff29424c3f82d32b
|
||||
assets/images/atmosfer.png,1776157890990,c3a9ddfa5676b5f0e1cf833f73c0f09c1dfb9d2e202906c1afcdd7a50dcf2506
|
||||
index.html,1782835713997,3ffbcfc0c9d82b851ce84d2b1bfc85cea0a34b077bdad955e7cf52643a3cf434
|
||||
version.json,1782835787646,237d17e2e585a791361ca905fcf3f475c56ae306611b22c2065ed0802c5045ff
|
||||
assets/FontManifest.json,1782835787808,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
|
||||
flutter_service_worker.js,1782835796465,c47afa60a1c64327c1f6c37a21aedda10acf1924bf27f5dfda86423d398561bc
|
||||
flutter_bootstrap.js,1782835713987,95a9759fb93defde72050109d28cb2a59bf237f9343cded51f999c9d1fa060c1
|
||||
assets/AssetManifest.bin,1782835787808,186d0884c1555d59162e31b14fe7fb0038864e322d7c9f3fb3318a1527be9242
|
||||
assets/AssetManifest.bin.json,1782835787809,bf7d61eab58f76fb4e7c8b7ba7cbd4c92c16dbc887d554d7cf7e71a5435e961d
|
||||
assets/shaders/stretch_effect.frag,1782835787968,586ff23669838dd33070077916f0b971a8800f67093ae8d6eed47e6cea50926f
|
||||
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1782835789876,85140ea453cd3e02bd93650fe8add6a724ae8c0ac73d0c491085b301e7bd21d2
|
||||
assets/shaders/ink_sparkle.frag,1782835787968,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
|
||||
assets/fonts/MaterialIcons-Regular.otf,1782835789881,d7ea214218a5b907538107aad5f49caeb5eae4bc9814b83cf52731bcfc9dbebc
|
||||
assets/NOTICES,1782835787810,0ca1b4f48ad9b11b50a47eda04252140a3044c7a1db622232baa51c99986d210
|
||||
version.json,1783254707058,0dbb38dffa73d06ffde280ea6bba18cb0432f425edd8d3a1360d2ddb1ca69030
|
||||
index.html,1783254589679,3ffbcfc0c9d82b851ce84d2b1bfc85cea0a34b077bdad955e7cf52643a3cf434
|
||||
flutter_service_worker.js,1783254717804,fdc0e463cc4c4c6cc8f3322abc15c745a0a97ec81b408a0b5efe060f1c47416c
|
||||
assets/FontManifest.json,1783254707586,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
|
||||
assets/AssetManifest.bin.json,1783254707586,bf7d61eab58f76fb4e7c8b7ba7cbd4c92c16dbc887d554d7cf7e71a5435e961d
|
||||
flutter_bootstrap.js,1783254589667,0db7582bc466818c161878213d295d1eb10ed401b6ff41f6244a740438139cf2
|
||||
assets/AssetManifest.bin,1783254707586,186d0884c1555d59162e31b14fe7fb0038864e322d7c9f3fb3318a1527be9242
|
||||
assets/shaders/stretch_effect.frag,1783254707739,586ff23669838dd33070077916f0b971a8800f67093ae8d6eed47e6cea50926f
|
||||
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1783254709288,85140ea453cd3e02bd93650fe8add6a724ae8c0ac73d0c491085b301e7bd21d2
|
||||
assets/shaders/ink_sparkle.frag,1783254707739,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
|
||||
assets/fonts/MaterialIcons-Regular.otf,1783254709288,d7ea214218a5b907538107aad5f49caeb5eae4bc9814b83cf52731bcfc9dbebc
|
||||
assets/NOTICES,1783254707586,0ca1b4f48ad9b11b50a47eda04252140a3044c7a1db622232baa51c99986d210
|
||||
main.dart.js,1783254097386,a046576b25f3c8442006a8ffe34c8944c61225cfc5f9f98fb1a4d276d71ed6ba
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import '../../../blocs/authentication_bloc/authentication_bloc.dart';
|
|||
import '../../../blocs/notification_bloc/notification_bloc.dart';
|
||||
import '../../monitoring/wind_speed/blocs/wind_speed_bloc.dart';
|
||||
import '../../monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart';
|
||||
import '../../monitoring/evaporasi/blocs/evaporasi_bloc.dart';
|
||||
import '../../auth/views/welcome_screen.dart';
|
||||
import '../widgets/notification_panel.dart';
|
||||
import '../widgets/sensor_grid.dart';
|
||||
|
|
@ -69,6 +70,12 @@ class _HomeScreenState extends State<HomeScreen>
|
|||
repository: context.read<MonitoringRepository>(),
|
||||
)..add(WatchAtmosphericConditionsStarted()),
|
||||
),
|
||||
BlocProvider<EvaporasiBloc>(
|
||||
create: (context) => EvaporasiBloc(
|
||||
repository: context.read<MonitoringRepository>(),
|
||||
notificationBloc: context.read<NotificationBloc>(),
|
||||
)..add(WatchEvaporasiStarted()),
|
||||
),
|
||||
],
|
||||
child: BlocListener<AuthenticationBloc, AuthenticationState>(
|
||||
listener: (context, state) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import '../../monitoring/wind_speed/views/wind_speed_screen.dart';
|
|||
import '../../monitoring/wind_speed/blocs/wind_speed_bloc.dart';
|
||||
import '../../monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart';
|
||||
import '../../monitoring/atmospheric_conditions/views/atmospheric_screen.dart';
|
||||
import '../../monitoring/evaporasi/blocs/evaporasi_bloc.dart';
|
||||
import '../../monitoring/evaporasi/views/evaporasi_screen.dart';
|
||||
import '../../../blocs/notification_bloc/notification_bloc.dart';
|
||||
|
||||
class MainDrawer extends StatelessWidget {
|
||||
|
|
@ -89,6 +91,26 @@ class MainDrawer extends StatelessWidget {
|
|||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading:
|
||||
Icon(Icons.water_drop, color: Colors.teal.shade600, size: 20),
|
||||
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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Spacer(), // Dorong menu logout ke paling bawah
|
||||
const Divider(),
|
||||
ListTile(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../monitoring/wind_speed/blocs/wind_speed_bloc.dart';
|
||||
import '../../monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart';
|
||||
import '../../monitoring/evaporasi/blocs/evaporasi_bloc.dart';
|
||||
|
||||
/// Period selector khusus untuk dashboard
|
||||
class DashboardPeriodSelector extends StatelessWidget {
|
||||
|
|
@ -115,7 +116,7 @@ class _DashboardChartsState extends State<DashboardCharts> {
|
|||
};
|
||||
return _SensorChartCard(
|
||||
title: 'Kecepatan Angin',
|
||||
unit: 'm/s',
|
||||
unit: 'km/j',
|
||||
color: Colors.blue.shade600,
|
||||
bgColor: Colors.blue.shade50,
|
||||
icon: Icons.air,
|
||||
|
|
@ -128,6 +129,24 @@ class _DashboardChartsState extends State<DashboardCharts> {
|
|||
),
|
||||
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,
|
||||
currentValue: state.currentValue,
|
||||
data: state.chartValues,
|
||||
period: _period,
|
||||
isLoading: state.isLoading,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// 3. Tekanan Udara — hanya realtime (tidak ada history)
|
||||
BlocBuilder<AtmosphericConditionsBloc,
|
||||
AtmosphericConditionsState>(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../monitoring/wind_speed/blocs/wind_speed_bloc.dart';
|
||||
import '../../monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart';
|
||||
import '../../monitoring/evaporasi/blocs/evaporasi_bloc.dart';
|
||||
|
||||
class SensorGrid extends StatelessWidget {
|
||||
const SensorGrid({super.key});
|
||||
|
|
@ -28,7 +29,7 @@ class SensorGrid extends StatelessWidget {
|
|||
value: state.isLoading
|
||||
? '—'
|
||||
: state.currentSpeed.toStringAsFixed(1),
|
||||
unit: 'm/s',
|
||||
unit: 'km/h',
|
||||
isLoading: state.isLoading,
|
||||
),
|
||||
),
|
||||
|
|
@ -47,6 +48,22 @@ class SensorGrid extends StatelessWidget {
|
|||
isLoading: state.isLoading,
|
||||
),
|
||||
),
|
||||
|
||||
// --- EVAPORASI ---
|
||||
BlocBuilder<EvaporasiBloc, EvaporasiState>(
|
||||
builder: (context, state) => SensorCard(
|
||||
width: cardWidth,
|
||||
icon: Icons.water_drop,
|
||||
iconColor: Colors.teal.shade600,
|
||||
iconBgColor: Colors.teal.shade50,
|
||||
label: 'Evaporasi',
|
||||
value: state.isLoading
|
||||
? '—'
|
||||
: state.currentValue.toStringAsFixed(1),
|
||||
unit: 'mm',
|
||||
isLoading: state.isLoading,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,240 @@
|
|||
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_dmax';
|
||||
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);
|
||||
on<EvaporasiRumusKalibrasiChanged>(_onRumusChanged);
|
||||
on<EvaporasiKoreksiOffsetChanged>(_onOffsetChanged);
|
||||
on<EvaporasiPumpStartChanged>(_onPumpStartChanged);
|
||||
on<EvaporasiPumpEndChanged>(_onPumpEndChanged);
|
||||
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),
|
||||
rumusKalibrasi: (data['rumus_kalibrasi'] as String?) ?? 'selisih_max',
|
||||
koreksiOffset: _toDouble(data['koreksi_offset'], 0.0),
|
||||
pumpStartTime: (data['pump_start_time'] as String?) ?? '06:00',
|
||||
pumpEndTime: (data['pump_end_time'] as String?) ?? '18:00',
|
||||
d0: _toInt(data['d0'], 0),
|
||||
dmaxManual: _toInt(data['dmax_manual'], 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,
|
||||
));
|
||||
} 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));
|
||||
|
||||
void _onRumusChanged(
|
||||
EvaporasiRumusKalibrasiChanged event,
|
||||
Emitter<EvaporasiSettingsState> emit,
|
||||
) =>
|
||||
emit(state.copyWith(rumusKalibrasi: event.rumus));
|
||||
|
||||
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 _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 {
|
||||
await _ref.set({
|
||||
'threshold_rendah': state.thresholdRendah,
|
||||
'threshold_tinggi': state.thresholdTinggi,
|
||||
'rumus_kalibrasi': state.rumusKalibrasi,
|
||||
'koreksi_offset': state.koreksiOffset,
|
||||
'pump_start_time': state.pumpStartTime,
|
||||
'pump_end_time': state.pumpEndTime,
|
||||
'd0': state.d0,
|
||||
'dmax_manual': state.dmaxManual,
|
||||
'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) =>
|
||||
v == null ? def : (v as num).toDouble();
|
||||
|
||||
int _toInt(dynamic v, int def) => v == null ? def : (v as num).toInt();
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
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 EvaporasiRumusKalibrasiChanged extends EvaporasiSettingsEvent {
|
||||
final String rumus;
|
||||
const EvaporasiRumusKalibrasiChanged(this.rumus);
|
||||
@override List<Object?> get props => [rumus];
|
||||
}
|
||||
|
||||
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 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 {}
|
||||
|
|
@ -0,0 +1,871 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'evaporasi_settings_bloc.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();
|
||||
|
||||
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();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
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())
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_InfoCard(
|
||||
'Pengaturan disimpan ke Firebase Firestore dan '
|
||||
'langsung berlaku untuk semua perangkat.',
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_sectionTitle('Batas Status Evaporasi'),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Tentukan nilai (mm) untuk klasifikasi status '
|
||||
'Rendah, Normal, dan Tinggi.',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: Colors.grey.shade500),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_SettingsCard(children: [
|
||||
_ThresholdPreview(state: state),
|
||||
const Divider(height: 28),
|
||||
_NumericField(
|
||||
controller: _rendahController,
|
||||
label: 'Batas Rendah–Normal (mm)',
|
||||
hint: 'Contoh: 20.0',
|
||||
helper:
|
||||
'Nilai < batas ini → Status Rendah. Default: 20.0',
|
||||
onChanged: (v) {
|
||||
final d = double.tryParse(v);
|
||||
if (d != null && d >= 0) {
|
||||
bloc.add(EvaporasiThresholdRendahChanged(d));
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_NumericField(
|
||||
controller: _tinggiController,
|
||||
label: 'Batas Normal–Tinggi (mm)',
|
||||
hint: 'Contoh: 30.0',
|
||||
helper:
|
||||
'Nilai ≥ batas ini → Status Tinggi. Default: 30.0',
|
||||
onChanged: (v) {
|
||||
final d = double.tryParse(v);
|
||||
if (d != null && d >= 0) {
|
||||
bloc.add(EvaporasiThresholdTinggiChanged(d));
|
||||
}
|
||||
},
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 24),
|
||||
_sectionTitle('Rumus Kalibrasi E'),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Pilih metode penghitungan nilai evaporasi '
|
||||
'terkalibrasi (E) dari data sensor.',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: Colors.grey.shade500),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_SettingsCard(children: [
|
||||
_RumusSelector(
|
||||
selected: state.rumusKalibrasi,
|
||||
onChanged: (v) =>
|
||||
bloc.add(EvaporasiRumusKalibrasiChanged(v)),
|
||||
),
|
||||
const Divider(height: 28),
|
||||
_FormulaPreview(state: state),
|
||||
const Divider(height: 28),
|
||||
_NumericField(
|
||||
controller: _offsetController,
|
||||
label: 'Koreksi Offset (mm)',
|
||||
hint: 'Contoh: 0.0 atau -1.5',
|
||||
helper:
|
||||
'Nilai ditambahkan ke hasil E. Gunakan negatif untuk koreksi ke bawah.',
|
||||
allowNegative: true,
|
||||
onChanged: (v) {
|
||||
final d = double.tryParse(v);
|
||||
if (d != null) {
|
||||
bloc.add(EvaporasiKoreksiOffsetChanged(d));
|
||||
}
|
||||
},
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 24),
|
||||
_sectionTitle('Interval Pengiriman & Pembacaan'),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Atur seberapa sering ESP32 membaca sensor dan mengirim data. '
|
||||
'Interval lebih pendek = data lebih real-time, baterai lebih boros.',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: Colors.grey.shade500),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_SettingsCard(children: [
|
||||
_IntervalSelector(
|
||||
label: 'Interval Baca Sensor',
|
||||
helper:
|
||||
'Seberapa sering sensor dibaca. Min: 5 detik.',
|
||||
options: const {
|
||||
5000: '5 detik',
|
||||
10000: '10 detik (default)',
|
||||
30000: '30 detik',
|
||||
60000: '1 menit',
|
||||
},
|
||||
selected: state.intervalBaca_ms,
|
||||
onChanged: (v) =>
|
||||
bloc.add(EvaporasiIntervalBacaChanged(v)),
|
||||
),
|
||||
const Divider(height: 24),
|
||||
_IntervalSelector(
|
||||
label: 'Interval Kirim Realtime',
|
||||
helper:
|
||||
'Frekuensi update data real-time ke Firebase.',
|
||||
options: const {
|
||||
60000: '1 menit',
|
||||
300000: '5 menit (default)',
|
||||
600000: '10 menit',
|
||||
},
|
||||
selected: state.intervalRealtime_ms,
|
||||
onChanged: (v) =>
|
||||
bloc.add(EvaporasiIntervalRealtimeChanged(v)),
|
||||
),
|
||||
const Divider(height: 24),
|
||||
_IntervalSelector(
|
||||
label: 'Interval Simpan History',
|
||||
helper: 'Frekuensi pencatatan ke riwayat Firebase.',
|
||||
options: const {
|
||||
300000: '5 menit',
|
||||
600000: '10 menit (default)',
|
||||
1800000: '30 menit',
|
||||
3600000: '1 jam',
|
||||
},
|
||||
selected: state.intervalHistory_ms,
|
||||
onChanged: (v) =>
|
||||
bloc.add(EvaporasiIntervalHistoryChanged(v)),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 24),
|
||||
_sectionTitle('Kontrol Pompa'),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Atur jam mulai dan selesai pompa secara langsung dari aplikasi.',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: Colors.grey.shade500),
|
||||
),
|
||||
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: 16),
|
||||
_TimePickerField(
|
||||
controller: _pumpEndController,
|
||||
label: 'Jam Selesai Pompa',
|
||||
helper: 'Pilih jam berhenti pompa.',
|
||||
onTap: () => _pickPumpTime(
|
||||
context,
|
||||
_pumpEndController,
|
||||
(value) =>
|
||||
bloc.add(EvaporasiPumpEndChanged(value)),
|
||||
state.pumpEndTime,
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 24),
|
||||
_sectionTitle('Kalibrasi Raw Sensor'),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Masukkan nilai D0 dan DMAX secara manual untuk kalibrasi sensor tanpa upload firmware.',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: Colors.grey.shade500),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_SettingsCard(children: [
|
||||
_NumericField(
|
||||
controller: _d0Controller,
|
||||
label: 'Nilai D0',
|
||||
hint: 'Contoh: 120',
|
||||
helper: 'Nilai sensor saat kondisi paling kering.',
|
||||
onChanged: (v) {
|
||||
final d = int.tryParse(v);
|
||||
if (d != null && d >= 0) {
|
||||
bloc.add(EvaporasiD0Changed(d));
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_NumericField(
|
||||
controller: _dmaxManualController,
|
||||
label: 'Nilai DMAX Manual',
|
||||
hint: 'Contoh: 1023',
|
||||
helper:
|
||||
'Kalibrasi nilai maksimum sensor secara manual.',
|
||||
onChanged: (v) {
|
||||
final d = int.tryParse(v);
|
||||
if (d != null && d >= 0) {
|
||||
bloc.add(EvaporasiDmaxManualChanged(d));
|
||||
}
|
||||
},
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 24),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 _RumusSelector extends StatelessWidget {
|
||||
final String selected;
|
||||
final ValueChanged<String> onChanged;
|
||||
const _RumusSelector({required this.selected, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const options = [
|
||||
(
|
||||
value: 'selisih_max',
|
||||
label: 'Selisih Maksimum',
|
||||
subtitle: 'E = max(H kemarin) − max(H hari ini)',
|
||||
icon: Icons.trending_down_rounded,
|
||||
),
|
||||
(
|
||||
value: 'rata_harian',
|
||||
label: 'Rata-rata Harian',
|
||||
subtitle: 'E = rata(H kemarin) − rata(H hari ini)',
|
||||
icon: Icons.bar_chart_rounded,
|
||||
),
|
||||
];
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Metode Kalkulasi',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black54)),
|
||||
const SizedBox(height: 10),
|
||||
...options.map((opt) {
|
||||
final isSelected = opt.value == selected;
|
||||
return GestureDetector(
|
||||
onTap: () => onChanged(opt.value),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? Colors.blue.shade50 : Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color:
|
||||
isSelected ? Colors.blue.shade400 : Colors.grey.shade200,
|
||||
width: isSelected ? 1.5 : 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(opt.icon,
|
||||
color: isSelected
|
||||
? Colors.blue.shade700
|
||||
: Colors.grey.shade500,
|
||||
size: 22),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(opt.label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isSelected
|
||||
? Colors.blue.shade800
|
||||
: Colors.black87)),
|
||||
const SizedBox(height: 2),
|
||||
Text(opt.subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
color: isSelected
|
||||
? Colors.blue.shade600
|
||||
: Colors.grey.shade500)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isSelected)
|
||||
Icon(Icons.check_circle_rounded,
|
||||
color: Colors.blue.shade600, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FormulaPreview extends StatelessWidget {
|
||||
final EvaporasiSettingsState state;
|
||||
const _FormulaPreview({required this.state});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rumus = state.rumusKalibrasi == 'selisih_max'
|
||||
? 'E = max(H₁) − max(H₂) + offset'
|
||||
: 'E = avg(H₁) − avg(H₂) + offset';
|
||||
final offsetStr = state.koreksiOffset >= 0
|
||||
? '+${state.koreksiOffset.toStringAsFixed(2)}'
|
||||
: state.koreksiOffset.toStringAsFixed(2);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade900,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Icon(Icons.functions_rounded,
|
||||
size: 15, 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(rumus,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade300,
|
||||
fontFamily: 'monospace')),
|
||||
const SizedBox(height: 6),
|
||||
Text('offset = $offsetStr mm',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.green.shade300,
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 _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)),
|
||||
// ]),
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
part of 'evaporasi_settings_bloc.dart';
|
||||
|
||||
enum EvaporasiSettingsStatus { loading, loaded, saving, saved, error }
|
||||
|
||||
class EvaporasiSettingsState extends Equatable {
|
||||
final double thresholdRendah;
|
||||
final double thresholdTinggi;
|
||||
final String rumusKalibrasi;
|
||||
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 EvaporasiSettingsStatus status;
|
||||
final String? errorMessage;
|
||||
|
||||
const EvaporasiSettingsState({
|
||||
this.thresholdRendah = 2.0,
|
||||
this.thresholdTinggi = 10.0,
|
||||
this.rumusKalibrasi = 'selisih_max',
|
||||
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.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,
|
||||
String? rumusKalibrasi,
|
||||
double? koreksiOffset,
|
||||
String? pumpStartTime,
|
||||
String? pumpEndTime,
|
||||
int? d0,
|
||||
int? dmaxManual,
|
||||
int? intervalRealtime_ms,
|
||||
int? intervalHistory_ms,
|
||||
int? intervalBaca_ms,
|
||||
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,
|
||||
rumusKalibrasi: rumusKalibrasi ?? this.rumusKalibrasi,
|
||||
koreksiOffset: koreksiOffset ?? this.koreksiOffset,
|
||||
pumpStartTime: pumpStartTime ?? this.pumpStartTime,
|
||||
pumpEndTime: pumpEndTime ?? this.pumpEndTime,
|
||||
d0: d0 ?? this.d0,
|
||||
dmaxManual: dmaxManual ?? this.dmaxManual,
|
||||
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, rumusKalibrasi, koreksiOffset,
|
||||
pumpStartTime, pumpEndTime, d0, dmaxManual,
|
||||
firmwareVersion, wifiConnected, firebaseConnected, activeD0, activeDmax, lastUpdate,
|
||||
intervalRealtime_ms, intervalHistory_ms, intervalBaca_ms,
|
||||
status, errorMessage,
|
||||
dmax, isResettingDmax,
|
||||
];
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ const _kRealtimeOptions = <String, int>{
|
|||
'5 detik': 5000,
|
||||
'30 detik': 30000,
|
||||
'1 menit': 60000,
|
||||
'6 menit': 360000,
|
||||
'1 jam': 3600000,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,388 @@
|
|||
// lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.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;
|
||||
|
||||
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');
|
||||
|
||||
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);
|
||||
_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
|
||||
// ════════════════════════════════════════════════════════════
|
||||
void _onRealtimeUpdated(
|
||||
_EvaporasiRealtimeUpdated event,
|
||||
Emitter<EvaporasiState> emit,
|
||||
) {
|
||||
final updatedHistory = List<Evaporasi>.from(state.history);
|
||||
final dupIdx = updatedHistory.indexWhere(
|
||||
(e) => e.timestamp.toUtc() == event.data.timestamp.toUtc(),
|
||||
);
|
||||
if (dupIdx >= 0) {
|
||||
updatedHistory[dupIdx] = event.data;
|
||||
} else {
|
||||
updatedHistory.add(event.data);
|
||||
}
|
||||
updatedHistory.sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||
|
||||
// Regenerasikan data chart secara dinamis agar selalu sinkron dengan data real-time
|
||||
List<double> updatedChart;
|
||||
List<double> updatedTemp;
|
||||
List<String> updatedLabels = state.chartLabels;
|
||||
|
||||
final isSingle = _isSameDay(state.startDate, state.endDate);
|
||||
|
||||
if (isSingle) {
|
||||
updatedChart = TimeSeriesMapper.toSpecificDate(
|
||||
data: updatedHistory,
|
||||
getTime: (e) => e.timestamp,
|
||||
getValue: (e) => e.evaporasi,
|
||||
targetDate: state.startDate,
|
||||
);
|
||||
updatedTemp = TimeSeriesMapper.toSpecificDate(
|
||||
data: updatedHistory,
|
||||
getTime: (e) => e.timestamp,
|
||||
getValue: (e) => e.suhu,
|
||||
targetDate: state.startDate,
|
||||
);
|
||||
updatedLabels = List.generate(24, (i) => '${i.toString().padLeft(2, '0')}:00');
|
||||
} else {
|
||||
final evapResult = TimeSeriesMapper.toDateRange(
|
||||
data: updatedHistory,
|
||||
getTime: (e) => e.timestamp,
|
||||
getValue: (e) => e.evaporasi,
|
||||
startDate: state.startDate,
|
||||
endDate: state.endDate,
|
||||
);
|
||||
final tempResult = TimeSeriesMapper.toDateRange(
|
||||
data: updatedHistory,
|
||||
getTime: (e) => e.timestamp,
|
||||
getValue: (e) => e.suhu,
|
||||
startDate: state.startDate,
|
||||
endDate: state.endDate,
|
||||
);
|
||||
updatedChart = evapResult.values;
|
||||
updatedTemp = tempResult.values;
|
||||
updatedLabels = evapResult.labels;
|
||||
}
|
||||
|
||||
final (status, willRain) = _computeStatus(event.data.evaporasi);
|
||||
_emitAlert(status, willRain, event.data.evaporasi);
|
||||
|
||||
emit(state.copyWith(
|
||||
history: updatedHistory,
|
||||
filteredHistory: state.selectedDateFilter != null
|
||||
? updatedHistory.where((e) =>
|
||||
e.timestamp.year == state.selectedDateFilter!.year &&
|
||||
e.timestamp.month == state.selectedDateFilter!.month &&
|
||||
e.timestamp.day == state.selectedDateFilter!.day).toList()
|
||||
: updatedHistory,
|
||||
currentValue: event.data.evaporasi,
|
||||
temperature: event.data.suhu,
|
||||
waterLevel: event.data.tinggiAir,
|
||||
chartValues: updatedChart,
|
||||
chartTemperatures: updatedTemp,
|
||||
chartLabels: updatedLabels,
|
||||
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 v) {
|
||||
// Kategori status sesuai permintaan:
|
||||
// - v < 20 => Rendah ("10 mm masih rendah")
|
||||
// - 20 <= v < 30 => Normal/Sedang
|
||||
// - v >= 30 => Tinggi
|
||||
if (v >= 30.0) return ('Tinggi', true);
|
||||
if (v >= 20.0) return ('Normal', false);
|
||||
return ('Rendah', false);
|
||||
}
|
||||
|
||||
|
||||
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];
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
// 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];
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
// 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,
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,667 @@
|
|||
// 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),
|
||||
|
||||
// ── Kontrol Database Evaporasi ───────────────
|
||||
const EvaporasiControlPanel(),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,405 @@
|
|||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
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 Future.wait([
|
||||
FirebaseDatabase.instance.ref('Monitoring/reset_evaporasi').set(true),
|
||||
FirebaseDatabase.instance
|
||||
.ref('Monitoring/realtime/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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,477 @@
|
|||
// ===========================================================
|
||||
// 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)); // terbaru di atas
|
||||
|
||||
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: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Riwayat Data',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87),
|
||||
),
|
||||
Text(
|
||||
filtered
|
||||
? '${DateFormat('dd MMM yyyy', 'id_ID').format(selectedDate!)} • $totalCount data'
|
||||
: '$totalCount data tersimpan',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
),
|
||||
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 StatefulWidget {
|
||||
final String label;
|
||||
final List<Evaporasi> items;
|
||||
|
||||
const _DateGroup({required this.label, required this.items});
|
||||
|
||||
@override
|
||||
State<_DateGroup> createState() => _DateGroupState();
|
||||
}
|
||||
|
||||
class _DateGroupState extends State<_DateGroup> {
|
||||
bool _expanded = false;
|
||||
|
||||
|
||||
double get _avgEvap {
|
||||
if (widget.items.isEmpty) return 0;
|
||||
return widget.items.map((e) => e.evaporasi).reduce((a, b) => a + b) /
|
||||
widget.items.length;
|
||||
}
|
||||
|
||||
double get _maxEvap {
|
||||
if (widget.items.isEmpty) return 0;
|
||||
return widget.items
|
||||
.map((e) => e.evaporasi)
|
||||
.reduce((a, b) => a > b ? a : b);
|
||||
}
|
||||
|
||||
@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: Column(
|
||||
children: [
|
||||
// ── Group header ──────────────────────────────────
|
||||
InkWell(
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(top: Radius.circular(16)),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
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(widget.label,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${widget.items.length} data • rata-rata ${_avgEvap.toStringAsFixed(2)} mm • maks ${_maxEvap.toStringAsFixed(2)} mm',
|
||||
style: TextStyle(
|
||||
fontSize: 11, color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
_expanded
|
||||
? Icons.keyboard_arrow_up_rounded
|
||||
: Icons.keyboard_arrow_down_rounded,
|
||||
color: Colors.grey.shade500,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Item list ─────────────────────────────────────
|
||||
if (_expanded)
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: widget.items.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
Divider(height: 1, color: Colors.grey.shade100),
|
||||
itemBuilder: (context, i) =>
|
||||
_HistoryItemTile(item: widget.items[i]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// Satu baris data history
|
||||
// ════════════════════════════════════════════════════════════
|
||||
class _HistoryItemTile extends StatelessWidget {
|
||||
final Evaporasi item;
|
||||
|
||||
const _HistoryItemTile({required this.item});
|
||||
|
||||
String get _status {
|
||||
if (item.evaporasi > 10.0) return 'Tinggi';
|
||||
if (item.evaporasi >= 2.0) return 'Normal';
|
||||
return 'Rendah';
|
||||
}
|
||||
|
||||
Color get _statusColor {
|
||||
switch (_status) {
|
||||
case 'Tinggi':
|
||||
return Colors.red.shade600;
|
||||
case 'Normal':
|
||||
return Colors.orange.shade700;
|
||||
default:
|
||||
return Colors.green.shade600;
|
||||
}
|
||||
}
|
||||
|
||||
IconData get _statusIcon {
|
||||
switch (_status) {
|
||||
case 'Tinggi':
|
||||
return Icons.warning_rounded;
|
||||
case 'Normal':
|
||||
return Icons.info_outline_rounded;
|
||||
default:
|
||||
return Icons.check_circle_outline_rounded;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── Jam ───────────────────────────────────────────
|
||||
SizedBox(
|
||||
width: 56,
|
||||
child: Text(
|
||||
DateFormat('HH:mm:ss').format(item.timestamp),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'monospace'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// ── Data evaporasi, tinggi air, suhu ─────────────
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Evaporasi
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
style: const TextStyle(color: Colors.black87),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: item.evaporasi.toStringAsFixed(2),
|
||||
style: const TextStyle(
|
||||
fontSize: 15, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const TextSpan(
|
||||
text: ' mm',
|
||||
style:
|
||||
TextStyle(fontSize: 11, color: Colors.black54),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
// Tinggi Air
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.water, size: 11,
|
||||
color: Colors.blue.shade400),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'Tinggi Air: ${item.tinggiAir.toStringAsFixed(1)} cm',
|
||||
style: TextStyle(
|
||||
fontSize: 11, color: Colors.blue.shade600),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// Suhu
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.thermostat, size: 11,
|
||||
color: Colors.orange.shade400),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'Suhu: ${item.suhu.toStringAsFixed(1)} °C',
|
||||
style: TextStyle(
|
||||
fontSize: 11, color: Colors.orange.shade700),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── Badge status ──────────────────────────────────
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: _statusColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border:
|
||||
Border.all(color: _statusColor.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(_statusIcon, size: 12, color: _statusColor),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_status,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: _statusColor,
|
||||
fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
// 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,405 @@
|
|||
// ===========================================================
|
||||
// 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)',
|
||||
'Tinggi Air (cm)',
|
||||
'Suhu (°C)',
|
||||
'Acuan Pagi (cm)',
|
||||
'Status',
|
||||
];
|
||||
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 status = _getStatus(item.evaporasi);
|
||||
final bgColor = i.isEven ? _colorNormal : _colorWhite;
|
||||
final statusBg = _statusBgColor(status);
|
||||
|
||||
_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);
|
||||
_setCellDouble(sheet, rowIdx, 4, item.tinggiAir,
|
||||
bgColor: bgColor, centered: true);
|
||||
// Suhu: tampilkan '-' jika sensor error (< 0)
|
||||
if (item.suhu < 0) {
|
||||
_setCell(sheet, rowIdx, 5, '-', bgColor: bgColor, centered: true);
|
||||
} else {
|
||||
_setCellDouble(sheet, rowIdx, 5, item.suhu,
|
||||
bgColor: bgColor, centered: true);
|
||||
}
|
||||
_setCellDouble(sheet, rowIdx, 6, item.acuanPagi,
|
||||
bgColor: bgColor, centered: true);
|
||||
_setCell(sheet, rowIdx, 7, status,
|
||||
bgColor: statusBg, centered: true, bold: 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: 7, rowIndex: 1),
|
||||
);
|
||||
}
|
||||
|
||||
// Set lebar kolom
|
||||
sheet.setColumnWidth(0, 6);
|
||||
sheet.setColumnWidth(1, 14);
|
||||
sheet.setColumnWidth(2, 12);
|
||||
sheet.setColumnWidth(3, 18);
|
||||
sheet.setColumnWidth(4, 18);
|
||||
sheet.setColumnWidth(5, 14);
|
||||
sheet.setColumnWidth(6, 18);
|
||||
sheet.setColumnWidth(7, 12);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// 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';
|
||||
}
|
||||
}
|
||||
|
|
@ -194,10 +194,8 @@ class WindSpeedExcelService {
|
|||
sheet.merge(CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: secRow),
|
||||
CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: secRow));
|
||||
|
||||
_setCell(sheet, secRow + 1, 0, 'Kecepatan (m/s)', bold: true);
|
||||
_setCellDouble(sheet, secRow + 1, 1, currentSpeed);
|
||||
_setCell(sheet, secRow + 2, 0, 'Kecepatan (km/h)', bold: true);
|
||||
_setCellDouble(sheet, secRow + 2, 1, currentSpeed * 3.6);
|
||||
_setCellDouble(sheet, secRow + 2, 1, currentSpeed);
|
||||
_setCell(sheet, secRow + 3, 0, 'Status', bold: true);
|
||||
_setCell(sheet, secRow + 3, 1, alertLevel,
|
||||
bgColor: _alertBgColor(alertLevel));
|
||||
|
|
@ -282,7 +280,7 @@ class WindSpeedExcelService {
|
|||
for (var i = 0; i < sorted.length; i++) {
|
||||
final item = sorted[i];
|
||||
final rowIdx = i + 1;
|
||||
final kmh = item.speed * 3.6;
|
||||
final kmh = item.speed;
|
||||
final status = _getAlertLevel(item.speed);
|
||||
final bgColor = i.isEven ? _colorNormal : _colorWhite;
|
||||
|
||||
|
|
@ -453,8 +451,8 @@ class WindSpeedExcelService {
|
|||
}
|
||||
|
||||
static String _getAlertLevel(double speed) {
|
||||
if (speed >= 12.5) return 'Bahaya';
|
||||
if (speed >= 8.0) return 'Waspada';
|
||||
if (speed >= 45.5) return 'Bahaya';
|
||||
if (speed >= 25.0) return 'Waspada';
|
||||
return 'Normal';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,7 +109,6 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
|
|||
_WindSpeedRealtimeUpdated event,
|
||||
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,
|
||||
|
|
@ -118,27 +117,12 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
|
|||
return;
|
||||
}
|
||||
|
||||
final updated = List<double>.from(state.dailySpeeds);
|
||||
final index = DateTime.now().hour;
|
||||
double newValue = event.data.speed;
|
||||
final double speed = event.data.speed;
|
||||
|
||||
if (index < updated.length) {
|
||||
final lastValue = updated[index];
|
||||
if (lastValue != 0) {
|
||||
if ((newValue - lastValue).abs() > 15) {
|
||||
newValue = lastValue;
|
||||
} else {
|
||||
newValue = (lastValue + newValue) / 2;
|
||||
}
|
||||
}
|
||||
updated[index] = newValue.clamp(0, 100);
|
||||
}
|
||||
|
||||
_emitWindAlert(newValue);
|
||||
_emitWindAlert(speed);
|
||||
emit(state.copyWith(
|
||||
currentSpeed: newValue,
|
||||
dailySpeeds: updated,
|
||||
alertLevel: _getAlertLevel(newValue),
|
||||
currentSpeed: speed,
|
||||
alertLevel: _getAlertLevel(speed),
|
||||
));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -364,7 +364,7 @@ class _HistoryItemTile extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kmh = item.speed * 3.6;
|
||||
final kmh = item.speed;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
|
|
|
|||
|
|
@ -1136,13 +1136,13 @@ class _HistoryTile extends StatelessWidget {
|
|||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'avg ${item.windwegKm.toStringAsFixed(3)} km',
|
||||
'${item.speed.toStringAsFixed(3)} km/h',
|
||||
style: const TextStyle(
|
||||
fontSize: 12, fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (item.maxWindwegKm > 0)
|
||||
Text(
|
||||
'max ${item.maxWindwegKm.toStringAsFixed(3)} km',
|
||||
'max ${item.maxWindwegKm.toStringAsFixed(3)} km/h',
|
||||
style: TextStyle(
|
||||
fontSize: 10, color: Colors.orange.shade600),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
name: klimatologiot
|
||||
description: 'A new Flutter project.'
|
||||
publish_to: 'none'
|
||||
version: 1.0.4+1
|
||||
version: 1.0.7+1
|
||||
environment:
|
||||
sdk: ^3.1.0
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue