Merge branch 'branch' of https://github.com/kleponijo/klimatologi into branch_percobaan

This commit is contained in:
kleponijo 2026-05-31 18:57:33 +07:00
commit 221f34a236
6 changed files with 563 additions and 162 deletions

View File

@ -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;
emit(state.copyWith(dmax: val));
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,

View File

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

View File

@ -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(
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,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.orange.shade100),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.info_outline_rounded, size: 16, color: Colors.orange.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)),
),
),
),
],
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)),
]),
);
}
}

View File

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

View File

@ -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 {
@ -319,19 +319,18 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
: () => _showExportDialog(context, state),
),
),
IconButton(
tooltip: 'Pengaturan',
icon: const Icon(Icons.settings_outlined),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const EvaporasiSettingsScreen(),
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) {
@ -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;

View File

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