diff --git a/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_bloc.dart b/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_bloc.dart index e1bb8a5..85e1d11 100644 --- a/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_bloc.dart +++ b/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_bloc.dart @@ -22,6 +22,8 @@ class EvaporasiSettingsBloc extends Bloc(_onOffsetChanged); on(_onPumpStartChanged); on(_onPumpEndChanged); + on(_onStandarTinggiChanged); + on(_onBatasKritisChanged); on(_onD0Changed); on(_onDmaxManualChanged); on(_onIntervalRealtimeChanged); @@ -44,10 +46,16 @@ class EvaporasiSettingsBloc extends Bloc emit, ) => emit(state.copyWith(pumpEndTime: event.value)); + void _onStandarTinggiChanged( + EvaporasiStandarTinggiChanged event, + Emitter emit, + ) => emit(state.copyWith(standarTinggiCm: event.value)); + + void _onBatasKritisChanged( + EvaporasiBatasKritisChanged event, + Emitter emit, + ) => emit(state.copyWith(batasKritisCm: event.value)); + void _onD0Changed( EvaporasiD0Changed event, Emitter emit, @@ -188,6 +219,8 @@ class EvaporasiSettingsBloc extends Bloc v == null ? def : (v as num).toDouble(); + double _toDouble(dynamic v, double def) { + if (v == null) return def; + if (v is num) return v.toDouble(); + if (v is String) { + return double.tryParse(v) ?? def; + } + return def; + } - int _toInt(dynamic v, int def) => v == null ? def : (v as num).toInt(); + int _toInt(dynamic v, int def) { + if (v == null) return def; + if (v is num) return v.toInt(); + if (v is String) { + return int.tryParse(v) ?? def; + } + return def; + } + + bool _toBool(dynamic v, bool def) { + if (v == null) return def; + if (v is bool) return v; + if (v is num) return v != 0; + if (v is String) { + final lower = v.toLowerCase().trim(); + return lower == 'true' || lower == '1' || lower == 'yes' || lower == 'aktif'; + } + return def; + } + + DateTime? _parseDateTime(dynamic raw) { + if (raw == null) return null; + if (raw is int) return DateTime.fromMillisecondsSinceEpoch(raw).toLocal(); + if (raw is num) return DateTime.fromMillisecondsSinceEpoch(raw.toInt()).toLocal(); + if (raw is String) { + final normalized = raw.replaceAll(' ', 'T'); + return DateTime.tryParse(normalized)?.toLocal(); + } + return null; + } } diff --git a/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_event.dart b/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_event.dart index 8ee87d4..6de6de7 100644 --- a/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_event.dart +++ b/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_event.dart @@ -63,6 +63,18 @@ class EvaporasiPumpEndChanged extends EvaporasiSettingsEvent { @override List get props => [value]; } +class EvaporasiStandarTinggiChanged extends EvaporasiSettingsEvent { + final double value; + const EvaporasiStandarTinggiChanged(this.value); + @override List get props => [value]; +} + +class EvaporasiBatasKritisChanged extends EvaporasiSettingsEvent { + final double value; + const EvaporasiBatasKritisChanged(this.value); + @override List get props => [value]; +} + class EvaporasiD0Changed extends EvaporasiSettingsEvent { final int value; const EvaporasiD0Changed(this.value); diff --git a/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_screen.dart b/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_screen.dart index cd48b1e..27f80f1 100644 --- a/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_screen.dart +++ b/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_screen.dart @@ -19,6 +19,8 @@ class _EvaporasiSettingsScreenState extends State { final _pumpEndController = TextEditingController(); final _d0Controller = TextEditingController(); final _dmaxManualController = TextEditingController(); + final _standarController = TextEditingController(); + final _batasController = TextEditingController(); void _syncControllers(EvaporasiSettingsState s) { final rendah = s.thresholdRendah.toStringAsFixed(1); @@ -28,6 +30,8 @@ class _EvaporasiSettingsScreenState extends State { final pumpEnd = s.pumpEndTime; final d0 = s.d0 == 0 ? '' : s.d0.toString(); final dmaxManual = s.dmaxManual == 0 ? '' : s.dmaxManual.toString(); + final standar = s.standarTinggiCm.toStringAsFixed(1); + final batas = s.batasKritisCm.toStringAsFixed(1); if (_rendahController.text != rendah) { _rendahController.text = rendah; @@ -50,6 +54,12 @@ class _EvaporasiSettingsScreenState extends State { if (_dmaxManualController.text != dmaxManual) { _dmaxManualController.text = dmaxManual; } + if (_standarController.text != standar) { + _standarController.text = standar; + } + if (_batasController.text != batas) { + _batasController.text = batas; + } } Future _pickPumpTime(BuildContext context, TextEditingController controller, ValueChanged onSelected, String initialValue) async { @@ -79,6 +89,8 @@ class _EvaporasiSettingsScreenState extends State { _pumpEndController.dispose(); _d0Controller.dispose(); _dmaxManualController.dispose(); + _standarController.dispose(); + _batasController.dispose(); super.dispose(); } @@ -142,6 +154,8 @@ class _EvaporasiSettingsScreenState extends State { 'langsung berlaku untuk semua perangkat.', ), const SizedBox(height: 24), + _groupTitle('Grup A — Sistem & Kalibrasi'), + const SizedBox(height: 12), _sectionTitle('Batas Status Evaporasi'), const SizedBox(height: 6), Text( @@ -259,6 +273,8 @@ class _EvaporasiSettingsScreenState extends State { ), ]), const SizedBox(height: 24), + _groupTitle('Grup B — Aktuator & Pengaturan'), + const SizedBox(height: 12), _sectionTitle('Kontrol Pompa'), const SizedBox(height: 6), Text( @@ -325,6 +341,32 @@ class _EvaporasiSettingsScreenState extends State { } }, ), + const SizedBox(height: 16), + _NumericField( + controller: _standarController, + label: 'Standar Tinggi Air (cm)', + hint: 'Contoh: 18.0', + helper: 'Nilai tinggi standar untuk selenoid (cm).', + onChanged: (v) { + final d = double.tryParse(v); + if (d != null && d >= 0) { + bloc.add(EvaporasiStandarTinggiChanged(d)); + } + }, + ), + const SizedBox(height: 16), + _NumericField( + controller: _batasController, + label: 'Batas Kritis (cm)', + hint: 'Contoh: 15.0', + helper: 'Jika di bawah nilai ini → pompa ON paksa.', + onChanged: (v) { + final d = double.tryParse(v); + if (d != null && d >= 0) { + bloc.add(EvaporasiBatasKritisChanged(d)); + } + }, + ), ]), const SizedBox(height: 24), SizedBox( @@ -358,6 +400,11 @@ class _EvaporasiSettingsScreenState extends State { ); } + Widget _groupTitle(String text) => Text( + text, + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.blueGrey), + ); + Widget _sectionTitle(String text) => Text( text, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), diff --git a/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_state.dart b/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_state.dart index c8d735b..3ad2281 100644 --- a/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_state.dart +++ b/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_state.dart @@ -25,6 +25,20 @@ class EvaporasiSettingsState extends Equatable { final int intervalHistory_ms; // default 600000 = 10 menit final int intervalBaca_ms; // default 10000 = 10 detik + final double standarTinggiCm; + final double batasKritisCm; + final bool tempCompActive; + final double tempCompCoef; + final double tempRefC; + final bool sensorError; + final bool ntpSync; + final double snapshotCm; + final bool otaTrigger; + final bool relayAktif; + final String otaStatus; + final int historyCount; + final DateTime? lastRealtime; + final EvaporasiSettingsStatus status; final String? errorMessage; @@ -40,6 +54,19 @@ class EvaporasiSettingsState extends Equatable { this.intervalRealtime_ms = 300000, this.intervalHistory_ms = 600000, this.intervalBaca_ms = 10000, + this.standarTinggiCm = 18.0, + this.batasKritisCm = 15.0, + this.tempCompActive = true, + this.tempCompCoef = 500.0, + this.tempRefC = 25.0, + this.sensorError = false, + this.ntpSync = false, + this.snapshotCm = 0.0, + this.otaTrigger = false, + this.relayAktif = false, + this.otaStatus = '--', + this.historyCount = 0, + this.lastRealtime, this.status = EvaporasiSettingsStatus.loading, this.errorMessage, this.dmax = 0, @@ -64,6 +91,19 @@ class EvaporasiSettingsState extends Equatable { int? intervalRealtime_ms, int? intervalHistory_ms, int? intervalBaca_ms, + double? standarTinggiCm, + double? batasKritisCm, + bool? tempCompActive, + double? tempCompCoef, + double? tempRefC, + bool? sensorError, + bool? ntpSync, + double? snapshotCm, + bool? otaTrigger, + bool? relayAktif, + String? otaStatus, + int? historyCount, + DateTime? lastRealtime, EvaporasiSettingsStatus? status, String? errorMessage, int? dmax, @@ -84,6 +124,19 @@ class EvaporasiSettingsState extends Equatable { pumpEndTime: pumpEndTime ?? this.pumpEndTime, d0: d0 ?? this.d0, dmaxManual: dmaxManual ?? this.dmaxManual, + standarTinggiCm: standarTinggiCm ?? this.standarTinggiCm, + batasKritisCm: batasKritisCm ?? this.batasKritisCm, + tempCompActive: tempCompActive ?? this.tempCompActive, + tempCompCoef: tempCompCoef ?? this.tempCompCoef, + tempRefC: tempRefC ?? this.tempRefC, + sensorError: sensorError ?? this.sensorError, + ntpSync: ntpSync ?? this.ntpSync, + snapshotCm: snapshotCm ?? this.snapshotCm, + otaTrigger: otaTrigger ?? this.otaTrigger, + relayAktif: relayAktif ?? this.relayAktif, + otaStatus: otaStatus ?? this.otaStatus, + historyCount: historyCount ?? this.historyCount, + lastRealtime: lastRealtime ?? this.lastRealtime, firmwareVersion: firmwareVersion ?? this.firmwareVersion, wifiConnected: wifiConnected ?? this.wifiConnected, firebaseConnected: firebaseConnected ?? this.firebaseConnected, @@ -104,8 +157,10 @@ class EvaporasiSettingsState extends Equatable { List get props => [ thresholdRendah, thresholdTinggi, rumusKalibrasi, koreksiOffset, pumpStartTime, pumpEndTime, d0, dmaxManual, - firmwareVersion, wifiConnected, firebaseConnected, activeD0, activeDmax, lastUpdate, intervalRealtime_ms, intervalHistory_ms, intervalBaca_ms, + standarTinggiCm, batasKritisCm, tempCompActive, tempCompCoef, tempRefC, + sensorError, ntpSync, snapshotCm, otaTrigger, relayAktif, historyCount, lastRealtime, + firmwareVersion, wifiConnected, firebaseConnected, activeD0, activeDmax, lastUpdate, status, errorMessage, dmax, isResettingDmax, ]; diff --git a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart index c405d5d..6c3c3ee 100644 --- a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart +++ b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart @@ -599,6 +599,66 @@ class _EvaporasiScreenState extends State { ); } + Widget _detailCard( + double width, + String title, + String value, + IconData icon, + Color color, { + String? subtitle, + }) { + return Container( + width: width, + 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(icon, color: color, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + title, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + value, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: color, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 6), + Text( + subtitle, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade700, + ), + ), + ], + ], + ), + ); + } + + Widget _statusCard(EvaporasiState state) { Color statusColor; IconData statusIcon; diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_control_panel.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_control_panel.dart index 87e3c82..8b4712e 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_control_panel.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_control_panel.dart @@ -14,6 +14,7 @@ class _EvaporasiControlPanelState extends State { bool _selenoid = false; bool _isTogglingSelenoid = false; bool _isResettingEvaporasi = false; + bool _isTriggeringOta = false; StreamSubscription? _subscription; @override @@ -127,6 +128,42 @@ class _EvaporasiControlPanelState extends State { } } + Future _triggerOTA() async { + if (_isTriggeringOta) return; + setState(() { + _isTriggeringOta = true; + }); + + try { + await Future.wait([ + FirebaseDatabase.instance.ref('Monitoring/ota_trigger').set(true), + FirebaseDatabase.instance + .ref('Monitoring/realtime/ota_trigger') + .set(true), + ]); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('Perintah OTA trigger berhasil dikirim.'), + behavior: SnackBarBehavior.floating, + )); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Gagal mengirim perintah OTA trigger: $e'), + backgroundColor: Colors.red.shade600, + behavior: SnackBarBehavior.floating, + )); + } + } finally { + if (mounted) { + setState(() { + _isTriggeringOta = false; + }); + } + } + } + @override void dispose() { _subscription?.cancel(); @@ -160,7 +197,7 @@ class _EvaporasiControlPanelState extends State { ), const SizedBox(height: 12), Text( - 'Tombol ini terhubung ke path Firebase yang belum ada di UI utama: reset evaporasi dan kontrol selenoid.', + 'Tombol ini terhubung ke Firebase untuk reset evaporasi, kontrol selenoid, dan trigger OTA.', style: TextStyle(fontSize: 12, color: Colors.grey.shade600), ), const SizedBox(height: 16), @@ -213,6 +250,24 @@ class _EvaporasiControlPanelState extends State { const EdgeInsets.symmetric(horizontal: 16, vertical: 14), ), ), + ElevatedButton.icon( + onPressed: _isTriggeringOta ? null : _triggerOTA, + icon: _isTriggeringOta + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.cloud_upload_rounded), + label: Text(_isTriggeringOta ? 'Memanggil OTA...' : 'Trigger OTA'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue.shade700, + foregroundColor: Colors.white, + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ), + ), ], ), const SizedBox(height: 16), diff --git a/packages/monitoring_repository/lib/src/models/evaporasi.dart b/packages/monitoring_repository/lib/src/models/evaporasi.dart index 386a4c6..1ad51b3 100644 --- a/packages/monitoring_repository/lib/src/models/evaporasi.dart +++ b/packages/monitoring_repository/lib/src/models/evaporasi.dart @@ -7,6 +7,20 @@ class Evaporasi { final double acuanPagi; final String status; final DateTime timestamp; + final bool sensorError; + final bool ntpSync; + final double snapshotCm; + final double standarTinggi; + final double batasKritis; + final int jamPompaMulai; + final int jamPompaSelesai; + final bool tempCompActive; + final double tempCompCoef; + final double tempRefC; + final String otaVersion; + final String otaStatus; + final bool otaTrigger; + final bool relayAktif; Evaporasi({ required this.evaporasi, @@ -15,6 +29,20 @@ class Evaporasi { required this.acuanPagi, required this.status, required this.timestamp, + this.sensorError = false, + this.ntpSync = false, + this.snapshotCm = 0.0, + this.standarTinggi = 0.0, + this.batasKritis = 0.0, + this.jamPompaMulai = 0, + this.jamPompaSelesai = 0, + this.tempCompActive = false, + this.tempCompCoef = 0.0, + this.tempRefC = 0.0, + this.otaVersion = '-', + this.otaStatus = '-', + this.otaTrigger = false, + this.relayAktif = false, }); static final empty = Evaporasi( @@ -41,6 +69,30 @@ class Evaporasi { return 0.0; } + bool toBoolSafe(dynamic v) { + if (v is bool) return v; + if (v is num) return v != 0; + if (v is String) { + final lower = v.trim().toLowerCase(); + return lower == 'true' || lower == '1' || lower == 'yes' || lower == 'aktif'; + } + return false; + } + + int toIntSafe(dynamic v) { + if (v is int) return v; + if (v is num) return v.toInt(); + if (v is String) { + return int.tryParse(v.trim()) ?? 0; + } + return 0; + } + + String toStringSafe(dynamic v) { + if (v == null) return '-'; + return v.toString(); + } + // ── Evaporasi (mm) ─────────────────────────────────── final evaporasiRaw = toDoubleSafe( json['evaporasi_mm'] ?? @@ -51,13 +103,11 @@ class Evaporasi { json['evaporasi_value'] ?? json['evaporasi_k'], ); - // Sudah dalam mm, tidak perlu konversi final evaporasiVal = evaporasiRaw; // ── Suhu (°C) ──────────────────────────────────────── - // FIX: tambah 'suhu_air_c' sesuai field yang dikirim ESP32 final suhuRaw = toDoubleSafe( - json['suhu_air_c'] ?? // ← ESP32 kirim field ini + json['suhu_air_c'] ?? json['suhu_air'] ?? json['suhu'] ?? json['suhuAir'] ?? @@ -77,7 +127,6 @@ class Evaporasi { json['tinggi_air_m'] ?? json['tinggiAir_m'], ); - // Sudah dalam cm, tidak perlu konversi final tinggiVal = tinggiRaw; // ── Sanity check ───────────────────────────────────── @@ -92,9 +141,79 @@ class Evaporasi { // ── Status ─────────────────────────────────────────── final statusVal = json['status']?.toString() ?? "Normal"; - // ── Parse Timestamp ────────────────────────────────── - // FIX: Tambahkan offset +07:00 (WIB) jika string tidak punya info timezone, - // agar tidak terjadi mismatch 7 jam antara Firebase dan Flutter. + final sensorErrorVal = toBoolSafe( + json['sensor_error'] ?? + json['sensorError'] ?? + json['sensor'] ?? + json['status_sensor'], + ); + final ntpSyncVal = toBoolSafe( + json['ntp_sync'] ?? + json['ntpSync'] ?? + json['ntp'], + ); + final snapshotVal = toDoubleSafe( + json['snapshot_cm'] ?? + json['snapshot'] ?? + json['acuan_pagi_cm'] ?? + 0.0, + ); + final standarTinggiVal = toDoubleSafe( + json['standar_tinggi'] ?? + json['standarTinggi'] ?? + json['standard_height'] ?? + 0.0, + ); + final batasKritisVal = toDoubleSafe( + json['batas_kritis'] ?? + json['batasKritis'] ?? + json['critical_level'] ?? + 0.0, + ); + final jamPompaMulaiVal = toIntSafe( + json['jam_pompa_mulai'] ?? + json['jamPompaMulai'] ?? + json['pump_start'] ?? + 0, + ); + final jamPompaSelesaiVal = toIntSafe( + json['jam_pompa_selesai'] ?? + json['jamPompaSelesai'] ?? + json['pump_end'] ?? + 0, + ); + final tempCompActiveVal = toBoolSafe( + json['temp_comp_aktif'] ?? + json['temp_comp_aktif'] ?? + json['tempCompAktif'] ?? + json['temp_comp_active'] ?? + false, + ); + final tempCompCoefVal = toDoubleSafe( + json['temp_comp_koef'] ?? + json['tempCompKoef'] ?? + json['temp_comp_coefficient'] ?? + 0.0, + ); + final tempRefCVal = toDoubleSafe( + json['temp_ref_c'] ?? + json['tempRefC'] ?? + json['temp_ref'] ?? + 0.0, + ); + final otaVersionVal = toStringSafe( + json['ota_version'] ?? json['otaVersion'] ?? json['firmware_version'] ?? '-', + ); + final otaStatusVal = toStringSafe( + json['ota_status'] ?? json['otaStatus'] ?? '-', + ); + final otaTriggerVal = toBoolSafe( + json['ota_trigger'] ?? json['otaTrigger'] ?? false, + ); + final relayAktifVal = toBoolSafe( + json['selenoid'] ?? json['selenoid_on'] ?? json['relay'], + ); + DateTime parseTimestamp(dynamic rawTimestamp) { try { if (rawTimestamp is int) { @@ -117,7 +236,6 @@ class Evaporasi { if (rawTimestamp is String) { String s = rawTimestamp.trim(); - // UNIX string final unixValue = int.tryParse(s); if (unixValue != null) { if (unixValue < 1000000000000) { @@ -128,13 +246,10 @@ class Evaporasi { return DateTime.fromMillisecondsSinceEpoch(unixValue).toLocal(); } - // Format "YYYY-MM-DD HH:mm:ss" → tambah 'T' agar bisa diparsing if (s.contains(' ') && !s.contains('T')) { s = s.replaceFirst(' ', 'T'); } - // FIX: Jika tidak ada info timezone, anggap WIB (UTC+7) - // agar jam di chart tidak mismatch 7 jam if (!s.contains('+') && !s.contains('Z') && !s.contains('-', 10)) { s = '${s}+07:00'; } @@ -143,18 +258,15 @@ class Evaporasi { if (parsed != null) return parsed.toLocal(); } } catch (_) {} - return DateTime.fromMillisecondsSinceEpoch(0); } DateTime timestamp = DateTime.fromMillisecondsSinceEpoch(0); - final rawTimestamp = json['timestamp'] ?? json['time'] ?? json['datetime']; if (rawTimestamp != null) { timestamp = parseTimestamp(rawTimestamp); } else { - // Legacy fallback: field "waktu" format "HH:mm:ss" final waktuStr = json['waktu'] as String?; if (waktuStr != null) { final parts = waktuStr.split(':'); @@ -175,6 +287,20 @@ class Evaporasi { acuanPagi: acuanPagiVal, status: statusVal, timestamp: timestamp, + sensorError: sensorErrorVal, + ntpSync: ntpSyncVal, + snapshotCm: snapshotVal, + standarTinggi: standarTinggiVal, + batasKritis: batasKritisVal, + jamPompaMulai: jamPompaMulaiVal, + jamPompaSelesai: jamPompaSelesaiVal, + tempCompActive: tempCompActiveVal, + tempCompCoef: tempCompCoefVal, + tempRefC: tempRefCVal, + otaVersion: otaVersionVal, + otaStatus: otaStatusVal, + otaTrigger: otaTriggerVal, + relayAktif: relayAktifVal, ); } }