This commit is contained in:
kleponijo 2026-07-13 13:57:55 +07:00
parent 53c01e694f
commit f361b85612
2 changed files with 224 additions and 130 deletions

View File

@ -5,7 +5,8 @@ import 'package:firebase_database/firebase_database.dart';
part 'evaporasi_settings_event.dart'; part 'evaporasi_settings_event.dart';
part 'evaporasi_settings_state.dart'; part 'evaporasi_settings_state.dart';
class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettingsState> { class EvaporasiSettingsBloc
extends Bloc<EvaporasiSettingsEvent, EvaporasiSettingsState> {
final DatabaseReference _ref; final DatabaseReference _ref;
static const _path = 'Monitoring/settings/evaporasi'; static const _path = 'Monitoring/settings/evaporasi';
@ -44,10 +45,12 @@ class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettin
thresholdRendah: _toDouble(data['threshold_rendah'], 2.0), thresholdRendah: _toDouble(data['threshold_rendah'], 2.0),
thresholdTinggi: _toDouble(data['threshold_tinggi'], 10.0), thresholdTinggi: _toDouble(data['threshold_tinggi'], 10.0),
rumusKalibrasi: (data['rumus_kalibrasi'] as String?) ?? 'selisih_max', rumusKalibrasi: (data['rumus_kalibrasi'] as String?) ?? 'selisih_max',
koreksiOffset: _toDouble(data['koreksi_offset'], 0.0), pumpStartTime: (data['pump_start_time'] as String?) ?? '06:00', 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', pumpEndTime: (data['pump_end_time'] as String?) ?? '18:00',
d0: _toInt(data['d0'], 0), d0: _toInt(data['d0'], 0),
dmaxManual: _toInt(data['dmax_manual'], 0), intervalRealtime_ms: _toInt(data['interval_realtime_ms'], 300000), dmaxManual: _toInt(data['dmax_manual'], 0),
intervalRealtime_ms: _toInt(data['interval_realtime_ms'], 300000),
intervalHistory_ms: _toInt(data['interval_history_ms'], 600000), intervalHistory_ms: _toInt(data['interval_history_ms'], 600000),
intervalBaca_ms: _toInt(data['interval_baca_ms'], 10000), intervalBaca_ms: _toInt(data['interval_baca_ms'], 10000),
status: EvaporasiSettingsStatus.loaded, status: EvaporasiSettingsStatus.loaded,
@ -70,15 +73,20 @@ class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettin
final rt = Map<String, dynamic>.from(rtSnap.value as Map); final rt = Map<String, dynamic>.from(rtSnap.value as Map);
final val = _toInt(rt['dmax_saat_ini'], 0); final val = _toInt(rt['dmax_saat_ini'], 0);
final firmware = (rt['firmware_version'] as String?) ?? '--'; final firmware = (rt['firmware_version'] as String?) ?? '--';
final wifi = rt['wifi_connected'] is bool ? (rt['wifi_connected'] as bool) : (rt['wifi_connected'] == 1); final wifi = rt['wifi_connected'] is bool
final firebase = rt['firebase_connected'] is bool ? (rt['firebase_connected'] as bool) : (rt['firebase_connected'] == 1); ? (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 activeD0 = _toInt(rt['d0_active'], state.d0);
final activeDmax = _toInt(rt['dmax_active'], val); final activeDmax = _toInt(rt['dmax_active'], val);
DateTime? lastUpd; DateTime? lastUpd;
try { try {
final lu = rt['last_update']; final lu = rt['last_update'];
if (lu != null) { if (lu != null) {
final ms = (lu is num) ? lu.toInt() : int.tryParse(lu.toString()) ?? 0; final ms =
(lu is num) ? lu.toInt() : int.tryParse(lu.toString()) ?? 0;
if (ms > 0) lastUpd = DateTime.fromMillisecondsSinceEpoch(ms); if (ms > 0) lastUpd = DateTime.fromMillisecondsSinceEpoch(ms);
} }
} catch (_) {} } catch (_) {}
@ -86,15 +94,16 @@ class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettin
emit(state.copyWith( emit(state.copyWith(
dmax: val, dmax: val,
firmwareVersion: firmware, firmwareVersion: firmware,
wifiConnected: wifi ?? false, // wifiConnected: wifi ?? false,
firebaseConnected: firebase ?? false, // firebaseConnected: firebase ?? false,
activeD0: activeD0, activeD0: activeD0,
activeDmax: activeDmax, activeDmax: activeDmax,
lastUpdate: lastUpd, lastUpdate: lastUpd,
)); ));
} else { } else {
// fallback: try single path // fallback: try single path
final snap = await FirebaseDatabase.instance.ref(_rtdbRealtimePath).get(); final snap =
await FirebaseDatabase.instance.ref(_rtdbRealtimePath).get();
final val = snap.exists ? (snap.value as num?)?.toInt() ?? 0 : 0; final val = snap.exists ? (snap.value as num?)?.toInt() ?? 0 : 0;
emit(state.copyWith(dmax: val)); emit(state.copyWith(dmax: val));
} }
@ -106,57 +115,68 @@ class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettin
void _onThresholdRendahChanged( void _onThresholdRendahChanged(
EvaporasiThresholdRendahChanged event, EvaporasiThresholdRendahChanged event,
Emitter<EvaporasiSettingsState> emit, Emitter<EvaporasiSettingsState> emit,
) => emit(state.copyWith(thresholdRendah: event.value)); ) =>
emit(state.copyWith(thresholdRendah: event.value));
void _onThresholdTinggiChanged( void _onThresholdTinggiChanged(
EvaporasiThresholdTinggiChanged event, EvaporasiThresholdTinggiChanged event,
Emitter<EvaporasiSettingsState> emit, Emitter<EvaporasiSettingsState> emit,
) => emit(state.copyWith(thresholdTinggi: event.value)); ) =>
emit(state.copyWith(thresholdTinggi: event.value));
void _onRumusChanged( void _onRumusChanged(
EvaporasiRumusKalibrasiChanged event, EvaporasiRumusKalibrasiChanged event,
Emitter<EvaporasiSettingsState> emit, Emitter<EvaporasiSettingsState> emit,
) => emit(state.copyWith(rumusKalibrasi: event.rumus)); ) =>
emit(state.copyWith(rumusKalibrasi: event.rumus));
void _onOffsetChanged( void _onOffsetChanged(
EvaporasiKoreksiOffsetChanged event, EvaporasiKoreksiOffsetChanged event,
Emitter<EvaporasiSettingsState> emit, Emitter<EvaporasiSettingsState> emit,
) => emit(state.copyWith(koreksiOffset: event.value)); ) =>
emit(state.copyWith(koreksiOffset: event.value));
void _onIntervalRealtimeChanged( void _onIntervalRealtimeChanged(
EvaporasiIntervalRealtimeChanged event, EvaporasiIntervalRealtimeChanged event,
Emitter<EvaporasiSettingsState> emit, Emitter<EvaporasiSettingsState> emit,
) => emit(state.copyWith(intervalRealtime_ms: event.value)); ) =>
emit(state.copyWith(intervalRealtime_ms: event.value));
void _onIntervalHistoryChanged( void _onIntervalHistoryChanged(
EvaporasiIntervalHistoryChanged event, EvaporasiIntervalHistoryChanged event,
Emitter<EvaporasiSettingsState> emit, Emitter<EvaporasiSettingsState> emit,
) => emit(state.copyWith(intervalHistory_ms: event.value)); ) =>
emit(state.copyWith(intervalHistory_ms: event.value));
void _onIntervalBacaChanged( void _onIntervalBacaChanged(
EvaporasiIntervalBacaChanged event, EvaporasiIntervalBacaChanged event,
Emitter<EvaporasiSettingsState> emit, Emitter<EvaporasiSettingsState> emit,
) => emit(state.copyWith(intervalBaca_ms: event.value)); ) =>
emit(state.copyWith(intervalBaca_ms: event.value));
void _onPumpStartChanged( void _onPumpStartChanged(
EvaporasiPumpStartChanged event, EvaporasiPumpStartChanged event,
Emitter<EvaporasiSettingsState> emit, Emitter<EvaporasiSettingsState> emit,
) => emit(state.copyWith(pumpStartTime: event.value)); ) =>
emit(state.copyWith(pumpStartTime: event.value));
void _onPumpEndChanged( void _onPumpEndChanged(
EvaporasiPumpEndChanged event, EvaporasiPumpEndChanged event,
Emitter<EvaporasiSettingsState> emit, Emitter<EvaporasiSettingsState> emit,
) => emit(state.copyWith(pumpEndTime: event.value)); ) =>
emit(state.copyWith(pumpEndTime: event.value));
void _onD0Changed( void _onD0Changed(
EvaporasiD0Changed event, EvaporasiD0Changed event,
Emitter<EvaporasiSettingsState> emit, Emitter<EvaporasiSettingsState> emit,
) => emit(state.copyWith(d0: event.value)); ) =>
emit(state.copyWith(d0: event.value));
void _onDmaxManualChanged( void _onDmaxManualChanged(
EvaporasiDmaxManualChanged event, EvaporasiDmaxManualChanged event,
Emitter<EvaporasiSettingsState> emit, Emitter<EvaporasiSettingsState> emit,
) => emit(state.copyWith(dmaxManual: event.value)); ) =>
emit(state.copyWith(dmaxManual: event.value));
Future<void> _onDmaxReset( Future<void> _onDmaxReset(
EvaporasiDmaxResetRequested event, EvaporasiDmaxResetRequested event,
@ -213,7 +233,8 @@ class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettin
} }
} }
double _toDouble(dynamic v, double def) => v == null ? def : (v as num).toDouble(); 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(); int _toInt(dynamic v, int def) => v == null ? def : (v as num).toInt();
} }

View File

@ -8,7 +8,8 @@ class EvaporasiSettingsScreen extends StatefulWidget {
const EvaporasiSettingsScreen({super.key}); const EvaporasiSettingsScreen({super.key});
@override @override
State<EvaporasiSettingsScreen> createState() => _EvaporasiSettingsScreenState(); State<EvaporasiSettingsScreen> createState() =>
_EvaporasiSettingsScreenState();
} }
class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> { class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
@ -52,7 +53,11 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
} }
} }
Future<void> _pickPumpTime(BuildContext context, TextEditingController controller, ValueChanged<String> onSelected, String initialValue) async { Future<void> _pickPumpTime(
BuildContext context,
TextEditingController controller,
ValueChanged<String> onSelected,
String initialValue) async {
final parts = initialValue.split(':'); final parts = initialValue.split(':');
final initial = TimeOfDay( final initial = TimeOfDay(
hour: int.tryParse(parts[0]) ?? 6, hour: int.tryParse(parts[0]) ?? 6,
@ -64,7 +69,8 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
helpText: 'Pilih Jam Pompa', helpText: 'Pilih Jam Pompa',
); );
if (picked != null) { if (picked != null) {
final value = '${picked.hour.toString().padLeft(2, '0')}:${picked.minute.toString().padLeft(2, '0')}'; final value =
'${picked.hour.toString().padLeft(2, '0')}:${picked.minute.toString().padLeft(2, '0')}';
controller.text = value; controller.text = value;
onSelected(value); onSelected(value);
} }
@ -101,7 +107,8 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
]), ]),
backgroundColor: Colors.green.shade600, backgroundColor: Colors.green.shade600,
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
)); ));
} }
if (state.status == EvaporasiSettingsStatus.error && if (state.status == EvaporasiSettingsStatus.error &&
@ -147,7 +154,8 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
Text( Text(
'Tentukan nilai (mm) untuk klasifikasi status ' 'Tentukan nilai (mm) untuk klasifikasi status '
'Rendah, Normal, dan Tinggi.', 'Rendah, Normal, dan Tinggi.',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500), style: TextStyle(
fontSize: 12, color: Colors.grey.shade500),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_SettingsCard(children: [ _SettingsCard(children: [
@ -157,7 +165,8 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
controller: _rendahController, controller: _rendahController,
label: 'Batas RendahNormal (mm)', label: 'Batas RendahNormal (mm)',
hint: 'Contoh: 20.0', hint: 'Contoh: 20.0',
helper: 'Nilai < batas ini → Status Rendah. Default: 20.0', helper:
'Nilai < batas ini → Status Rendah. Default: 20.0',
onChanged: (v) { onChanged: (v) {
final d = double.tryParse(v); final d = double.tryParse(v);
if (d != null && d >= 0) { if (d != null && d >= 0) {
@ -170,7 +179,8 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
controller: _tinggiController, controller: _tinggiController,
label: 'Batas NormalTinggi (mm)', label: 'Batas NormalTinggi (mm)',
hint: 'Contoh: 30.0', hint: 'Contoh: 30.0',
helper: 'Nilai ≥ batas ini → Status Tinggi. Default: 30.0', helper:
'Nilai ≥ batas ini → Status Tinggi. Default: 30.0',
onChanged: (v) { onChanged: (v) {
final d = double.tryParse(v); final d = double.tryParse(v);
if (d != null && d >= 0) { if (d != null && d >= 0) {
@ -185,13 +195,15 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
Text( Text(
'Pilih metode penghitungan nilai evaporasi ' 'Pilih metode penghitungan nilai evaporasi '
'terkalibrasi (E) dari data sensor.', 'terkalibrasi (E) dari data sensor.',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500), style: TextStyle(
fontSize: 12, color: Colors.grey.shade500),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_SettingsCard(children: [ _SettingsCard(children: [
_RumusSelector( _RumusSelector(
selected: state.rumusKalibrasi, selected: state.rumusKalibrasi,
onChanged: (v) => bloc.add(EvaporasiRumusKalibrasiChanged(v)), onChanged: (v) =>
bloc.add(EvaporasiRumusKalibrasiChanged(v)),
), ),
const Divider(height: 28), const Divider(height: 28),
_FormulaPreview(state: state), _FormulaPreview(state: state),
@ -200,7 +212,8 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
controller: _offsetController, controller: _offsetController,
label: 'Koreksi Offset (mm)', label: 'Koreksi Offset (mm)',
hint: 'Contoh: 0.0 atau -1.5', hint: 'Contoh: 0.0 atau -1.5',
helper: 'Nilai ditambahkan ke hasil E. Gunakan negatif untuk koreksi ke bawah.', helper:
'Nilai ditambahkan ke hasil E. Gunakan negatif untuk koreksi ke bawah.',
allowNegative: true, allowNegative: true,
onChanged: (v) { onChanged: (v) {
final d = double.tryParse(v); final d = double.tryParse(v);
@ -216,13 +229,15 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
Text( Text(
'Atur seberapa sering ESP32 membaca sensor dan mengirim data. ' 'Atur seberapa sering ESP32 membaca sensor dan mengirim data. '
'Interval lebih pendek = data lebih real-time, baterai lebih boros.', 'Interval lebih pendek = data lebih real-time, baterai lebih boros.',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500), style: TextStyle(
fontSize: 12, color: Colors.grey.shade500),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_SettingsCard(children: [ _SettingsCard(children: [
_IntervalSelector( _IntervalSelector(
label: 'Interval Baca Sensor', label: 'Interval Baca Sensor',
helper: 'Seberapa sering sensor dibaca. Min: 5 detik.', helper:
'Seberapa sering sensor dibaca. Min: 5 detik.',
options: const { options: const {
5000: '5 detik', 5000: '5 detik',
10000: '10 detik (default)', 10000: '10 detik (default)',
@ -230,19 +245,22 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
60000: '1 menit', 60000: '1 menit',
}, },
selected: state.intervalBaca_ms, selected: state.intervalBaca_ms,
onChanged: (v) => bloc.add(EvaporasiIntervalBacaChanged(v)), onChanged: (v) =>
bloc.add(EvaporasiIntervalBacaChanged(v)),
), ),
const Divider(height: 24), const Divider(height: 24),
_IntervalSelector( _IntervalSelector(
label: 'Interval Kirim Realtime', label: 'Interval Kirim Realtime',
helper: 'Frekuensi update data real-time ke Firebase.', helper:
'Frekuensi update data real-time ke Firebase.',
options: const { options: const {
60000: '1 menit', 60000: '1 menit',
300000: '5 menit (default)', 300000: '5 menit (default)',
600000: '10 menit', 600000: '10 menit',
}, },
selected: state.intervalRealtime_ms, selected: state.intervalRealtime_ms,
onChanged: (v) => bloc.add(EvaporasiIntervalRealtimeChanged(v)), onChanged: (v) =>
bloc.add(EvaporasiIntervalRealtimeChanged(v)),
), ),
const Divider(height: 24), const Divider(height: 24),
_IntervalSelector( _IntervalSelector(
@ -255,7 +273,8 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
3600000: '1 jam', 3600000: '1 jam',
}, },
selected: state.intervalHistory_ms, selected: state.intervalHistory_ms,
onChanged: (v) => bloc.add(EvaporasiIntervalHistoryChanged(v)), onChanged: (v) =>
bloc.add(EvaporasiIntervalHistoryChanged(v)),
), ),
]), ]),
const SizedBox(height: 24), const SizedBox(height: 24),
@ -263,7 +282,8 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
'Atur jam mulai dan selesai pompa secara langsung dari aplikasi.', 'Atur jam mulai dan selesai pompa secara langsung dari aplikasi.',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500), style: TextStyle(
fontSize: 12, color: Colors.grey.shade500),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_SettingsCard(children: [ _SettingsCard(children: [
@ -274,7 +294,8 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
onTap: () => _pickPumpTime( onTap: () => _pickPumpTime(
context, context,
_pumpStartController, _pumpStartController,
(value) => bloc.add(EvaporasiPumpStartChanged(value)), (value) =>
bloc.add(EvaporasiPumpStartChanged(value)),
state.pumpStartTime, state.pumpStartTime,
), ),
), ),
@ -286,7 +307,8 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
onTap: () => _pickPumpTime( onTap: () => _pickPumpTime(
context, context,
_pumpEndController, _pumpEndController,
(value) => bloc.add(EvaporasiPumpEndChanged(value)), (value) =>
bloc.add(EvaporasiPumpEndChanged(value)),
state.pumpEndTime, state.pumpEndTime,
), ),
), ),
@ -296,7 +318,8 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
'Masukkan nilai D0 dan DMAX secara manual untuk kalibrasi sensor tanpa upload firmware.', 'Masukkan nilai D0 dan DMAX secara manual untuk kalibrasi sensor tanpa upload firmware.',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500), style: TextStyle(
fontSize: 12, color: Colors.grey.shade500),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_SettingsCard(children: [ _SettingsCard(children: [
@ -317,7 +340,8 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
controller: _dmaxManualController, controller: _dmaxManualController,
label: 'Nilai DMAX Manual', label: 'Nilai DMAX Manual',
hint: 'Contoh: 1023', hint: 'Contoh: 1023',
helper: 'Kalibrasi nilai maksimum sensor secara manual.', helper:
'Kalibrasi nilai maksimum sensor secara manual.',
onChanged: (v) { onChanged: (v) {
final d = int.tryParse(v); final d = int.tryParse(v);
if (d != null && d >= 0) { if (d != null && d >= 0) {
@ -330,21 +354,28 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: isSaving ? null : () => bloc.add(EvaporasiSettingsSaved()), onPressed: isSaving
? null
: () => bloc.add(EvaporasiSettingsSaved()),
icon: isSaving icon: isSaving
? const SizedBox( ? const SizedBox(
width: 18, width: 18,
height: 18, height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
) )
: const Icon(Icons.save_rounded), : const Icon(Icons.save_rounded),
label: Text(isSaving ? 'Menyimpan...' : 'Simpan ke Firebase'), label: Text(isSaving
? 'Menyimpan...'
: 'Simpan ke Firebase'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue.shade700, backgroundColor: Colors.blue.shade700,
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), shape: RoundedRectangleBorder(
textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold), borderRadius: BorderRadius.circular(14)),
textStyle: const TextStyle(
fontSize: 15, fontWeight: FontWeight.bold),
), ),
), ),
), ),
@ -378,15 +409,23 @@ class _ThresholdPreview extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text('Preview Klasifikasi', const Text('Preview Klasifikasi',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.black54)), style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.black54)),
const SizedBox(height: 10), const SizedBox(height: 10),
Row( Row(
children: [ children: [
_StatusChip('Rendah', '< ${rendah.toStringAsFixed(0)} mm', Colors.green), _StatusChip(
'Rendah', '< ${rendah.toStringAsFixed(0)} mm', Colors.green),
const SizedBox(width: 8), const SizedBox(width: 8),
_StatusChip('Normal', '${rendah.toStringAsFixed(0)}${tinggi.toStringAsFixed(0)} mm', Colors.orange), _StatusChip(
'Normal',
'${rendah.toStringAsFixed(0)}${tinggi.toStringAsFixed(0)} mm',
Colors.orange),
const SizedBox(width: 8), const SizedBox(width: 8),
_StatusChip('Tinggi', '${tinggi.toStringAsFixed(0)} mm', Colors.red), _StatusChip(
'Tinggi', '${tinggi.toStringAsFixed(0)} mm', Colors.red),
], ],
), ),
if (!isValid) ...[ if (!isValid) ...[
@ -422,9 +461,12 @@ class _StatusChip extends StatelessWidget {
child: Column( child: Column(
children: [ children: [
Text(label, Text(label,
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: color)), style: TextStyle(
fontSize: 11, fontWeight: FontWeight.bold, color: color)),
const SizedBox(height: 2), const SizedBox(height: 2),
Text(range, style: TextStyle(fontSize: 9, color: color), textAlign: TextAlign.center), Text(range,
style: TextStyle(fontSize: 9, color: color),
textAlign: TextAlign.center),
], ],
), ),
), ),
@ -458,7 +500,10 @@ class _RumusSelector extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text('Metode Kalkulasi', const Text('Metode Kalkulasi',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.black54)), style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.black54)),
const SizedBox(height: 10), const SizedBox(height: 10),
...options.map((opt) { ...options.map((opt) {
final isSelected = opt.value == selected; final isSelected = opt.value == selected;
@ -472,13 +517,18 @@ class _RumusSelector extends StatelessWidget {
color: isSelected ? Colors.blue.shade50 : Colors.grey.shade50, color: isSelected ? Colors.blue.shade50 : Colors.grey.shade50,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: isSelected ? Colors.blue.shade400 : Colors.grey.shade200, color:
isSelected ? Colors.blue.shade400 : Colors.grey.shade200,
width: isSelected ? 1.5 : 1, width: isSelected ? 1.5 : 1,
), ),
), ),
child: Row( child: Row(
children: [ children: [
Icon(opt.icon, color: isSelected ? Colors.blue.shade700 : Colors.grey.shade500, size: 22), Icon(opt.icon,
color: isSelected
? Colors.blue.shade700
: Colors.grey.shade500,
size: 22),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
@ -488,18 +538,23 @@ class _RumusSelector extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: isSelected ? Colors.blue.shade800 : Colors.black87)), color: isSelected
? Colors.blue.shade800
: Colors.black87)),
const SizedBox(height: 2), const SizedBox(height: 2),
Text(opt.subtitle, Text(opt.subtitle,
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
fontFamily: 'monospace', fontFamily: 'monospace',
color: isSelected ? Colors.blue.shade600 : Colors.grey.shade500)), color: isSelected
? Colors.blue.shade600
: Colors.grey.shade500)),
], ],
), ),
), ),
if (isSelected) if (isSelected)
Icon(Icons.check_circle_rounded, color: Colors.blue.shade600, size: 20), Icon(Icons.check_circle_rounded,
color: Colors.blue.shade600, size: 20),
], ],
), ),
), ),
@ -533,14 +588,21 @@ class _FormulaPreview extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row(children: [ Row(children: [
Icon(Icons.functions_rounded, size: 15, color: Colors.amber.shade300), Icon(Icons.functions_rounded,
size: 15, color: Colors.amber.shade300),
const SizedBox(width: 8), const SizedBox(width: 8),
Text('Preview Rumus', Text('Preview Rumus',
style: TextStyle(fontSize: 12, color: Colors.amber.shade300, fontWeight: FontWeight.bold)), style: TextStyle(
fontSize: 12,
color: Colors.amber.shade300,
fontWeight: FontWeight.bold)),
]), ]),
const SizedBox(height: 10), const SizedBox(height: 10),
Text(rumus, Text(rumus,
style: TextStyle(fontSize: 12, color: Colors.grey.shade300, fontFamily: 'monospace')), style: TextStyle(
fontSize: 12,
color: Colors.grey.shade300,
fontFamily: 'monospace')),
const SizedBox(height: 6), const SizedBox(height: 6),
Text('offset = $offsetStr mm', Text('offset = $offsetStr mm',
style: TextStyle( style: TextStyle(
@ -570,10 +632,12 @@ class _InfoCard extends StatelessWidget {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon(Icons.info_outline_rounded, size: 18, color: Colors.blue.shade600), Icon(Icons.info_outline_rounded,
size: 18, color: Colors.blue.shade600),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Text(text, style: TextStyle(fontSize: 12, color: Colors.blue.shade800)), child: Text(text,
style: TextStyle(fontSize: 12, color: Colors.blue.shade800)),
), ),
], ],
), ),
@ -596,7 +660,8 @@ class _SettingsCard extends StatelessWidget {
BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 6), BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 6),
], ],
), ),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: children), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: children),
); );
} }
} }
@ -622,7 +687,8 @@ class _NumericField extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return TextField( return TextField(
controller: controller, controller: controller,
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true), keyboardType:
const TextInputType.numberWithOptions(decimal: true, signed: true),
inputFormatters: [ inputFormatters: [
FilteringTextInputFormatter.allow( FilteringTextInputFormatter.allow(
allowNegative ? RegExp(r'^-?[0-9]*\.?[0-9]*') : RegExp(r'[0-9.]'), allowNegative ? RegExp(r'^-?[0-9]*\.?[0-9]*') : RegExp(r'[0-9.]'),
@ -634,7 +700,8 @@ class _NumericField extends StatelessWidget {
helperText: helper, helperText: helper,
helperMaxLines: 2, helperMaxLines: 2,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), contentPadding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
isDense: true, isDense: true,
), ),
onChanged: onChanged, onChanged: onChanged,
@ -667,7 +734,8 @@ class _TimePickerField extends StatelessWidget {
helperMaxLines: 2, helperMaxLines: 2,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
suffixIcon: const Icon(Icons.schedule_rounded), suffixIcon: const Icon(Icons.schedule_rounded),
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), contentPadding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
isDense: true, isDense: true,
), ),
onTap: onTap, onTap: onTap,
@ -698,7 +766,8 @@ class _IntervalSelector extends StatelessWidget {
Text(label, Text(label,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(helper, style: TextStyle(fontSize: 11, color: Colors.grey.shade500)), Text(helper,
style: TextStyle(fontSize: 11, color: Colors.grey.shade500)),
const SizedBox(height: 10), const SizedBox(height: 10),
Wrap( Wrap(
spacing: 8, spacing: 8,
@ -709,12 +778,16 @@ class _IntervalSelector extends StatelessWidget {
onTap: () => onChanged(e.key), onTap: () => onChanged(e.key),
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 180), duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? Colors.blue.shade700 : Colors.grey.shade100, color:
isSelected ? Colors.blue.shade700 : Colors.grey.shade100,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all( border: Border.all(
color: isSelected ? Colors.blue.shade700 : Colors.grey.shade300, color: isSelected
? Colors.blue.shade700
: Colors.grey.shade300,
), ),
), ),
child: Text( child: Text(
@ -734,65 +807,65 @@ class _IntervalSelector extends StatelessWidget {
} }
} }
class _RealtimeInfo extends StatelessWidget { // class _RealtimeInfo extends StatelessWidget {
final EvaporasiSettingsState state; // final EvaporasiSettingsState state;
const _RealtimeInfo({required this.state}); // const _RealtimeInfo({required this.state});
String _fmtDate(DateTime? d) { // String _fmtDate(DateTime? d) {
if (d == null) return '--'; // if (d == null) return '--';
final l = d.toLocal(); // 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')}'; // 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 // @override
Widget build(BuildContext context) { // Widget build(BuildContext context) {
return Container( // return Container(
width: double.infinity, // width: double.infinity,
padding: const EdgeInsets.all(12), // padding: const EdgeInsets.all(12),
decoration: BoxDecoration( // decoration: BoxDecoration(
color: Colors.grey.shade50, // color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(10), // borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.grey.shade200), // border: Border.all(color: Colors.grey.shade200),
), // ),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ // child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [ // Row(children: [
Icon(Icons.devices_rounded, size: 15, color: Colors.blue.shade700), // Icon(Icons.devices_rounded, size: 15, color: Colors.blue.shade700),
const SizedBox(width: 8), // const SizedBox(width: 8),
Text('Informasi Perangkat', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.blue.shade700)), // Text('Informasi Perangkat', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.blue.shade700)),
]), // ]),
const SizedBox(height: 10), // const SizedBox(height: 10),
Wrap(spacing: 12, runSpacing: 8, children: [ // Wrap(spacing: 12, runSpacing: 8, children: [
_InfoTile(label: 'Firmware', value: state.firmwareVersion), // _InfoTile(label: 'Firmware', value: state.firmwareVersion),
_InfoTile(label: 'WiFi', value: state.wifiConnected ? 'Terhubung' : 'Tidak'), // _InfoTile(label: 'WiFi', value: state.wifiConnected ? 'Terhubung' : 'Tidak'),
_InfoTile(label: 'Firebase', value: state.firebaseConnected ? 'Terhubung' : 'Tidak'), // _InfoTile(label: 'Firebase', value: state.firebaseConnected ? 'Terhubung' : 'Tidak'),
_InfoTile(label: 'D0 aktif', value: state.activeD0 == 0 ? '--' : state.activeD0.toString()), // _InfoTile(label: 'D0 aktif', value: state.activeD0 == 0 ? '--' : state.activeD0.toString()),
_InfoTile(label: 'DMAX aktif', value: state.activeDmax == 0 ? '--' : state.activeDmax.toString()), // _InfoTile(label: 'DMAX aktif', value: state.activeDmax == 0 ? '--' : state.activeDmax.toString()),
_InfoTile(label: 'Terakhir', value: _fmtDate(state.lastUpdate)), // _InfoTile(label: 'Terakhir', value: _fmtDate(state.lastUpdate)),
]) // ])
]), // ]),
); // );
} // }
} // }
class _InfoTile extends StatelessWidget { // class _InfoTile extends StatelessWidget {
final String label; // final String label;
final String value; // final String value;
const _InfoTile({required this.label, required this.value}); // const _InfoTile({required this.label, required this.value});
@override // @override
Widget build(BuildContext context) { // Widget build(BuildContext context) {
return Container( // return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), // padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration( // decoration: BoxDecoration(
color: Colors.white, // color: Colors.white,
borderRadius: BorderRadius.circular(8), // borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade100), // border: Border.all(color: Colors.grey.shade100),
), // ),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ // child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: TextStyle(fontSize: 11, color: Colors.grey.shade600)), // Text(label, style: TextStyle(fontSize: 11, color: Colors.grey.shade600)),
const SizedBox(height: 6), // const SizedBox(height: 6),
Text(value, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold)), // Text(value, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold)),
]), // ]),
); // );
} // }
} // }