Merge branch 'branch' of https://github.com/kleponijo/klimatologi into branch_percobaan
This commit is contained in:
commit
221f34a236
|
|
@ -20,6 +20,10 @@ class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettin
|
|||
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);
|
||||
|
|
@ -40,8 +44,10 @@ class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettin
|
|||
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),
|
||||
intervalRealtime_ms: _toInt(data['interval_realtime_ms'], 300000),
|
||||
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,
|
||||
|
|
@ -58,9 +64,40 @@ class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettin
|
|||
}
|
||||
|
||||
try {
|
||||
final rtSnap = await FirebaseDatabase.instance.ref(_rtdbRealtimePath).get();
|
||||
final val = rtSnap.exists ? (rtSnap.value as num?)?.toInt() ?? 0 : 0;
|
||||
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
|
||||
}
|
||||
|
|
@ -101,6 +138,26 @@ class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettin
|
|||
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,
|
||||
|
|
@ -136,6 +193,10 @@ class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettin
|
|||
'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,
|
||||
|
|
|
|||
|
|
@ -51,6 +51,30 @@ class EvaporasiIntervalBacaChanged extends EvaporasiSettingsEvent {
|
|||
@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 {}
|
||||
|
|
@ -15,11 +15,20 @@ 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;
|
||||
}
|
||||
|
|
@ -29,46 +38,36 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
||||
void _showResetDmaxDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
title: const Row(children: [
|
||||
Icon(Icons.restart_alt_rounded, color: Colors.orange),
|
||||
SizedBox(width: 8),
|
||||
Text('Reset DMAX'),
|
||||
]),
|
||||
content: const Text(
|
||||
'DMAX akan direset ke nilai default.\n\n'
|
||||
'ESP32 akan mencari nilai DMAX baru secara otomatis '
|
||||
'pada pembacaan berikutnya.\n\n'
|
||||
'Lakukan ini hanya jika sensor diganti atau '
|
||||
'kalibrasi ulang diperlukan.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: Text('Batal', style: TextStyle(color: Colors.grey.shade600)),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
context.read<EvaporasiSettingsBloc>().add(EvaporasiDmaxResetRequested());
|
||||
},
|
||||
icon: const Icon(Icons.restart_alt_rounded, size: 16),
|
||||
label: const Text('Reset DMAX'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.orange.shade700,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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
|
||||
|
|
@ -76,6 +75,10 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
|
|||
_rendahController.dispose();
|
||||
_tinggiController.dispose();
|
||||
_offsetController.dispose();
|
||||
_pumpStartController.dispose();
|
||||
_pumpEndController.dispose();
|
||||
_d0Controller.dispose();
|
||||
_dmaxManualController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -256,20 +259,71 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
|
|||
),
|
||||
]),
|
||||
const SizedBox(height: 24),
|
||||
_sectionTitle('Kalibrasi DMAX'),
|
||||
_sectionTitle('Kontrol Pompa'),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'DMAX adalah nilai raw sensor saat panci penuh. '
|
||||
'Diperbarui otomatis oleh ESP32. Reset hanya '
|
||||
'jika sensor diganti atau kalibrasi ulang diperlukan.',
|
||||
'Atur jam mulai dan selesai pompa secara langsung dari aplikasi.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_SettingsCard(children: [
|
||||
_DmaxPanel(
|
||||
dmax: state.dmax,
|
||||
isResetting: state.isResettingDmax,
|
||||
onReset: () => _showResetDmaxDialog(context),
|
||||
_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),
|
||||
|
|
@ -588,6 +642,39 @@ class _NumericField extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
@ -647,106 +734,65 @@ class _IntervalSelector extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
class _DmaxPanel extends StatelessWidget {
|
||||
final int dmax;
|
||||
final bool isResetting;
|
||||
final VoidCallback onReset;
|
||||
class _RealtimeInfo extends StatelessWidget {
|
||||
final EvaporasiSettingsState state;
|
||||
const _RealtimeInfo({required this.state});
|
||||
|
||||
const _DmaxPanel({
|
||||
required this.dmax,
|
||||
required this.isResetting,
|
||||
required this.onReset,
|
||||
});
|
||||
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 Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Nilai DMAX Saat Ini',
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.black54),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade900,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Icon(Icons.memory_rounded, size: 15, color: Colors.amber.shade300),
|
||||
const SizedBox(width: 8),
|
||||
Text('Raw Sensor Value',
|
||||
style: TextStyle(fontSize: 12, color: Colors.amber.shade300, fontWeight: FontWeight.bold)),
|
||||
]),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
dmax == 0 ? '-- (belum terbaca)' : dmax.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: dmax == 0 ? Colors.grey.shade500 : Colors.green.shade300,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Diperbarui otomatis oleh ESP32',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey.shade500),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.shade50,
|
||||
color: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.orange.shade100),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.info_outline_rounded, size: 16, color: Colors.orange.shade700),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Icon(Icons.devices_rounded, size: 15, color: Colors.blue.shade700),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'DMAX = nilai ADC saat panci penuh (tinggi air maksimum). '
|
||||
'Semakin besar DMAX, semakin akurat pembacaan tinggi air.',
|
||||
style: TextStyle(fontSize: 11, color: Colors.orange.shade800),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: isResetting ? null : onReset,
|
||||
icon: isResetting
|
||||
? SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.orange.shade700),
|
||||
)
|
||||
: const Icon(Icons.restart_alt_rounded),
|
||||
label: Text(isResetting ? 'Mereset...' : 'Reset DMAX ke Default'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.orange.shade700,
|
||||
side: BorderSide(color: Colors.orange.shade300),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
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)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,18 @@ class EvaporasiSettingsState extends Equatable {
|
|||
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
|
||||
|
|
@ -23,6 +33,10 @@ class EvaporasiSettingsState extends Equatable {
|
|||
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,
|
||||
|
|
@ -30,6 +44,12 @@ class EvaporasiSettingsState extends Equatable {
|
|||
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({
|
||||
|
|
@ -37,6 +57,10 @@ class EvaporasiSettingsState extends Equatable {
|
|||
double? thresholdTinggi,
|
||||
String? rumusKalibrasi,
|
||||
double? koreksiOffset,
|
||||
String? pumpStartTime,
|
||||
String? pumpEndTime,
|
||||
int? d0,
|
||||
int? dmaxManual,
|
||||
int? intervalRealtime_ms,
|
||||
int? intervalHistory_ms,
|
||||
int? intervalBaca_ms,
|
||||
|
|
@ -44,12 +68,28 @@ class EvaporasiSettingsState extends Equatable {
|
|||
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,
|
||||
|
|
@ -63,6 +103,8 @@ class EvaporasiSettingsState extends Equatable {
|
|||
@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,
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ 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';
|
||||
// TAMBAH baris ini setelah import yang sudah ada:
|
||||
import '../../shared/utils/excel/evaporasi_excel_service.dart';
|
||||
|
||||
class EvaporasiScreen extends StatefulWidget {
|
||||
|
|
@ -331,7 +331,6 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
|
||||
body: BlocBuilder<EvaporasiBloc, EvaporasiState>(
|
||||
builder: (context, state) {
|
||||
if (state.isLoading) {
|
||||
|
|
@ -364,6 +363,10 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
|||
_statusCard(state),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Kontrol Database Evaporasi ───────────────
|
||||
const EvaporasiControlPanel(),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Range Selector ──────────────────────────
|
||||
const Text(
|
||||
'Tren Evaporasi & Suhu',
|
||||
|
|
@ -449,7 +452,6 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
|||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
|
||||
// ── Riwayat Data ────────────────────────────
|
||||
BlocBuilder<EvaporasiBloc, EvaporasiState>(
|
||||
builder: (context, s) => EvaporasiHistoryList(
|
||||
|
|
@ -597,7 +599,6 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _statusCard(EvaporasiState state) {
|
||||
Color statusColor;
|
||||
IconData statusIcon;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue