From a28b2224ab697246c739dfb941e94509ddcd382a Mon Sep 17 00:00:00 2001 From: Mochamad ongki ramadani Date: Wed, 10 Jun 2026 07:26:11 +0700 Subject: [PATCH] update dashboard evaporasi --- .../blocs/evaporasi_settings_screen.dart | 803 ++++++++++++------ .../evaporasi/blocs/evaporasi_bloc.dart | 58 +- .../evaporasi/views/evaporasi_screen.dart | 60 -- .../widgets/evaporasi_control_panel.dart | 66 +- .../views/widgets/evaporasi_history_list.dart | 67 +- .../lib/src/models/evaporasi.dart | 154 +--- test/evaporasi_bloc_test.dart | 28 + 7 files changed, 655 insertions(+), 581 deletions(-) create mode 100644 test/evaporasi_bloc_test.dart 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 27f80f1..c5b0130 100644 --- a/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_screen.dart +++ b/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_screen.dart @@ -1,14 +1,19 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:firebase_database/firebase_database.dart'; import 'evaporasi_settings_bloc.dart'; +import '../../evaporasi/views/widgets/evaporasi_control_panel.dart'; +import '../../evaporasi/views/evaporasi_screen.dart'; + class EvaporasiSettingsScreen extends StatefulWidget { const EvaporasiSettingsScreen({super.key}); @override - State createState() => _EvaporasiSettingsScreenState(); + State createState() => + _EvaporasiSettingsScreenState(); } class _EvaporasiSettingsScreenState extends State { @@ -62,7 +67,11 @@ class _EvaporasiSettingsScreenState extends State { } } - Future _pickPumpTime(BuildContext context, TextEditingController controller, ValueChanged onSelected, String initialValue) async { + Future _pickPumpTime( + BuildContext context, + TextEditingController controller, + ValueChanged onSelected, + String initialValue) async { final parts = initialValue.split(':'); final initial = TimeOfDay( hour: int.tryParse(parts[0]) ?? 6, @@ -74,7 +83,8 @@ class _EvaporasiSettingsScreenState extends State { helpText: 'Pilih Jam Pompa', ); 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; onSelected(value); } @@ -113,7 +123,8 @@ class _EvaporasiSettingsScreenState extends State { ]), backgroundColor: Colors.green.shade600, behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), )); } if (state.status == EvaporasiSettingsStatus.error && @@ -144,253 +155,195 @@ class _EvaporasiSettingsScreenState extends State { ), body: isLoading ? const Center(child: CircularProgressIndicator()) - : SingleChildScrollView( - padding: const EdgeInsets.all(20), + : DefaultTabController( + length: 3, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - _InfoCard( - 'Pengaturan disimpan ke Firebase Firestore dan ' - 'langsung berlaku untuk semua perangkat.', + Material( + color: Colors.transparent, + child: TabBar( + labelColor: Colors.blue.shade700, + unselectedLabelColor: Colors.grey.shade600, + indicatorColor: Colors.blue.shade700, + tabs: const [ + Tab(text: 'Sistem & Kalibrasi'), + Tab(text: 'Aktuator & Kontrol'), + Tab(text: 'OTA & Firmware'), + ], + ), ), - const SizedBox(height: 24), - _groupTitle('Grup A — Sistem & Kalibrasi'), - const SizedBox(height: 12), - _sectionTitle('Batas Status Evaporasi'), - const SizedBox(height: 6), - Text( - 'Tentukan nilai (mm) untuk klasifikasi status ' - 'Rendah, Normal, dan Tinggi.', - style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + Expanded( + child: TabBarView( + children: [ + SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _InfoCard( + 'Pengaturan disimpan ke Firebase Realtime DB dan langsung berlaku untuk perangkat.', + ), + const SizedBox(height: 16), + _groupTitle('Informasi Sistem & Sensor'), + const SizedBox(height: 12), + _SettingsCard(children: [ + _RealtimeInfo(state: state), + const SizedBox(height: 12), + _CompensationCard(state: state), + ]), + const SizedBox(height: 20), + _groupTitle('Interval Pengumpulan Data'), + const SizedBox(height: 12), + _SettingsCard(children: [ + _CollectionIntervalCard( + state: state, + onHistoryIntervalChanged: (v) { + final bloc = context + .read(); + bloc.add( + EvaporasiIntervalHistoryChanged( + v)); + }, + ), + ]), + const SizedBox(height: 20), + ], + ), + ), + SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _groupTitle('Aktuator & Pengaturan'), + const SizedBox(height: 12), + _SettingsCard(children: [ + _TimePickerField( + controller: _pumpStartController, + label: 'Jam Mulai Pompa', + helper: + 'Pilih jam mulai pompa bekerja.', + onTap: () => _pickPumpTime( + context, + _pumpStartController, + (value) => bloc.add( + EvaporasiPumpStartChanged(value)), + state.pumpStartTime, + ), + ), + const SizedBox(height: 12), + _TimePickerField( + controller: _pumpEndController, + label: 'Jam Selesai Pompa', + helper: 'Pilih jam berhenti pompa.', + onTap: () => _pickPumpTime( + context, + _pumpEndController, + (value) => bloc.add( + EvaporasiPumpEndChanged(value)), + state.pumpEndTime, + ), + ), + const Divider(height: 20), + _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: 12), + _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: 16), + const SizedBox(height: 12), + EvaporasiControlPanel(), + const SizedBox(height: 20), + ], + ), + ), + SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _groupTitle('OTA & Firmware'), + const SizedBox(height: 12), + _SettingsCard(children: [ + _InfoTile( + label: 'Versi Firmware', + value: state.firmwareVersion), + const SizedBox(height: 8), + _InfoTile( + label: 'Status OTA', + value: state.otaStatus), + const SizedBox(height: 8), + _InfoTile( + label: 'Riwayat Data', + value: '${state.historyCount} entri'), + const SizedBox(height: 12), + _OtaCard(state: state), + ]), + const SizedBox(height: 20), + ], + ), + ), + ], + ), ), - const SizedBox(height: 12), - _SettingsCard(children: [ - _ThresholdPreview(state: state), - const Divider(height: 28), - _NumericField( - controller: _rendahController, - label: 'Batas Rendah–Normal (mm)', - hint: 'Contoh: 20.0', - helper: 'Nilai < batas ini → Status Rendah. Default: 20.0', - onChanged: (v) { - final d = double.tryParse(v); - if (d != null && d >= 0) { - bloc.add(EvaporasiThresholdRendahChanged(d)); - } - }, - ), - const SizedBox(height: 16), - _NumericField( - controller: _tinggiController, - label: 'Batas Normal–Tinggi (mm)', - hint: 'Contoh: 30.0', - helper: 'Nilai ≥ batas ini → Status Tinggi. Default: 30.0', - onChanged: (v) { - final d = double.tryParse(v); - if (d != null && d >= 0) { - bloc.add(EvaporasiThresholdTinggiChanged(d)); - } - }, - ), - ]), - const SizedBox(height: 24), - _sectionTitle('Rumus Kalibrasi E'), - const SizedBox(height: 6), - Text( - 'Pilih metode penghitungan nilai evaporasi ' - 'terkalibrasi (E) dari data sensor.', - style: TextStyle(fontSize: 12, color: Colors.grey.shade500), - ), - const SizedBox(height: 12), - _SettingsCard(children: [ - _RumusSelector( - selected: state.rumusKalibrasi, - onChanged: (v) => bloc.add(EvaporasiRumusKalibrasiChanged(v)), - ), - const Divider(height: 28), - _FormulaPreview(state: state), - const Divider(height: 28), - _NumericField( - controller: _offsetController, - label: 'Koreksi Offset (mm)', - hint: 'Contoh: 0.0 atau -1.5', - helper: 'Nilai ditambahkan ke hasil E. Gunakan negatif untuk koreksi ke bawah.', - allowNegative: true, - onChanged: (v) { - final d = double.tryParse(v); - if (d != null) { - bloc.add(EvaporasiKoreksiOffsetChanged(d)); - } - }, - ), - ]), - const SizedBox(height: 24), - _sectionTitle('Interval Pengiriman & Pembacaan'), - const SizedBox(height: 6), - Text( - 'Atur seberapa sering ESP32 membaca sensor dan mengirim data. ' - 'Interval lebih pendek = data lebih real-time, baterai lebih boros.', - style: TextStyle(fontSize: 12, color: Colors.grey.shade500), - ), - const SizedBox(height: 12), - _SettingsCard(children: [ - _IntervalSelector( - label: 'Interval Baca Sensor', - helper: 'Seberapa sering sensor dibaca. Min: 5 detik.', - options: const { - 5000: '5 detik', - 10000: '10 detik (default)', - 30000: '30 detik', - 60000: '1 menit', - }, - selected: state.intervalBaca_ms, - onChanged: (v) => bloc.add(EvaporasiIntervalBacaChanged(v)), - ), - const Divider(height: 24), - _IntervalSelector( - label: 'Interval Kirim Realtime', - helper: 'Frekuensi update data real-time ke Firebase.', - options: const { - 60000: '1 menit', - 300000: '5 menit (default)', - 600000: '10 menit', - }, - selected: state.intervalRealtime_ms, - onChanged: (v) => bloc.add(EvaporasiIntervalRealtimeChanged(v)), - ), - const Divider(height: 24), - _IntervalSelector( - label: 'Interval Simpan History', - helper: 'Frekuensi pencatatan ke riwayat Firebase.', - options: const { - 300000: '5 menit', - 600000: '10 menit (default)', - 1800000: '30 menit', - 3600000: '1 jam', - }, - selected: state.intervalHistory_ms, - onChanged: (v) => bloc.add(EvaporasiIntervalHistoryChanged(v)), - ), - ]), - const SizedBox(height: 24), - _groupTitle('Grup B — Aktuator & Pengaturan'), - const SizedBox(height: 12), - _sectionTitle('Kontrol Pompa'), - const SizedBox(height: 6), - Text( - 'Atur jam mulai dan selesai pompa secara langsung dari aplikasi.', - style: TextStyle(fontSize: 12, color: Colors.grey.shade500), - ), - const SizedBox(height: 12), - _SettingsCard(children: [ - _TimePickerField( - controller: _pumpStartController, - label: 'Jam Mulai Pompa', - helper: 'Pilih jam mulai pompa bekerja.', - onTap: () => _pickPumpTime( - context, - _pumpStartController, - (value) => bloc.add(EvaporasiPumpStartChanged(value)), - state.pumpStartTime, - ), - ), - const SizedBox(height: 16), - _TimePickerField( - controller: _pumpEndController, - label: 'Jam Selesai Pompa', - helper: 'Pilih jam berhenti pompa.', - onTap: () => _pickPumpTime( - context, - _pumpEndController, - (value) => bloc.add(EvaporasiPumpEndChanged(value)), - state.pumpEndTime, - ), - ), - ]), - const SizedBox(height: 24), - _sectionTitle('Kalibrasi Raw Sensor'), - const SizedBox(height: 6), - Text( - 'Masukkan nilai D0 dan DMAX secara manual untuk kalibrasi sensor tanpa upload firmware.', - style: TextStyle(fontSize: 12, color: Colors.grey.shade500), - ), - const SizedBox(height: 12), - _SettingsCard(children: [ - _NumericField( - controller: _d0Controller, - label: 'Nilai D0', - hint: 'Contoh: 120', - helper: 'Nilai sensor saat kondisi paling kering.', - onChanged: (v) { - final d = int.tryParse(v); - if (d != null && d >= 0) { - bloc.add(EvaporasiD0Changed(d)); - } - }, - ), - const SizedBox(height: 16), - _NumericField( - controller: _dmaxManualController, - label: 'Nilai DMAX Manual', - hint: 'Contoh: 1023', - helper: 'Kalibrasi nilai maksimum sensor secara manual.', - onChanged: (v) { - final d = int.tryParse(v); - if (d != null && d >= 0) { - bloc.add(EvaporasiDmaxManualChanged(d)); - } - }, - ), - const SizedBox(height: 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( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: isSaving ? null : () => bloc.add(EvaporasiSettingsSaved()), - icon: isSaving - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), - ) - : const Icon(Icons.save_rounded), - label: Text(isSaving ? 'Menyimpan...' : 'Simpan ke Firebase'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue.shade700, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), - textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold), + Padding( + padding: const EdgeInsets.all(16.0), + child: SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: isSaving + ? null + : () => bloc.add(EvaporasiSettingsSaved()), + icon: isSaving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.save_rounded), + label: Text(isSaving + ? 'Menyimpan...' + : 'Simpan ke Firebase'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue.shade700, + foregroundColor: Colors.white, + padding: + const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14)), + textStyle: const TextStyle( + fontSize: 15, fontWeight: FontWeight.bold), + ), ), ), ), - const SizedBox(height: 20), ], ), ), @@ -402,7 +355,8 @@ class _EvaporasiSettingsScreenState extends State { Widget _groupTitle(String text) => Text( text, - style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.blueGrey), + style: const TextStyle( + fontSize: 15, fontWeight: FontWeight.bold, color: Colors.blueGrey), ); Widget _sectionTitle(String text) => Text( @@ -425,15 +379,23 @@ class _ThresholdPreview extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ 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), Row( children: [ - _StatusChip('Rendah', '< ${rendah.toStringAsFixed(0)} mm', Colors.green), + _StatusChip( + 'Rendah', '< ${rendah.toStringAsFixed(0)} mm', Colors.green), 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), - _StatusChip('Tinggi', '≥ ${tinggi.toStringAsFixed(0)} mm', Colors.red), + _StatusChip( + 'Tinggi', '≥ ${tinggi.toStringAsFixed(0)} mm', Colors.red), ], ), if (!isValid) ...[ @@ -469,9 +431,12 @@ class _StatusChip extends StatelessWidget { child: Column( children: [ Text(label, - style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: color)), + style: TextStyle( + fontSize: 11, fontWeight: FontWeight.bold, color: color)), 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), ], ), ), @@ -505,7 +470,10 @@ class _RumusSelector extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ 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), ...options.map((opt) { final isSelected = opt.value == selected; @@ -519,13 +487,18 @@ class _RumusSelector extends StatelessWidget { color: isSelected ? Colors.blue.shade50 : Colors.grey.shade50, borderRadius: BorderRadius.circular(12), border: Border.all( - color: isSelected ? Colors.blue.shade400 : Colors.grey.shade200, + color: + isSelected ? Colors.blue.shade400 : Colors.grey.shade200, width: isSelected ? 1.5 : 1, ), ), child: Row( children: [ - Icon(opt.icon, color: isSelected ? Colors.blue.shade700 : Colors.grey.shade500, size: 22), + Icon(opt.icon, + color: isSelected + ? Colors.blue.shade700 + : Colors.grey.shade500, + size: 22), const SizedBox(width: 12), Expanded( child: Column( @@ -535,18 +508,23 @@ class _RumusSelector extends StatelessWidget { style: TextStyle( fontSize: 13, fontWeight: FontWeight.bold, - color: isSelected ? Colors.blue.shade800 : Colors.black87)), + color: isSelected + ? Colors.blue.shade800 + : Colors.black87)), const SizedBox(height: 2), Text(opt.subtitle, style: TextStyle( fontSize: 11, fontFamily: 'monospace', - color: isSelected ? Colors.blue.shade600 : Colors.grey.shade500)), + color: isSelected + ? Colors.blue.shade600 + : Colors.grey.shade500)), ], ), ), if (isSelected) - Icon(Icons.check_circle_rounded, color: Colors.blue.shade600, size: 20), + Icon(Icons.check_circle_rounded, + color: Colors.blue.shade600, size: 20), ], ), ), @@ -580,14 +558,21 @@ class _FormulaPreview extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, 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), 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), 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), Text('offset = $offsetStr mm', style: TextStyle( @@ -617,10 +602,12 @@ class _InfoCard extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, 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), Expanded( - child: Text(text, style: TextStyle(fontSize: 12, color: Colors.blue.shade800)), + child: Text(text, + style: TextStyle(fontSize: 12, color: Colors.blue.shade800)), ), ], ), @@ -643,7 +630,8 @@ class _SettingsCard extends StatelessWidget { BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 6), ], ), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: children), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: children), ); } } @@ -669,7 +657,8 @@ class _NumericField extends StatelessWidget { Widget build(BuildContext context) { return TextField( controller: controller, - keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true), + keyboardType: + const TextInputType.numberWithOptions(decimal: true, signed: true), inputFormatters: [ FilteringTextInputFormatter.allow( allowNegative ? RegExp(r'^-?[0-9]*\.?[0-9]*') : RegExp(r'[0-9.]'), @@ -681,7 +670,8 @@ class _NumericField extends StatelessWidget { helperText: helper, helperMaxLines: 2, border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), - contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + contentPadding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 12), isDense: true, ), onChanged: onChanged, @@ -714,7 +704,8 @@ class _TimePickerField extends StatelessWidget { helperMaxLines: 2, border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), suffixIcon: const Icon(Icons.schedule_rounded), - contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + contentPadding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 12), isDense: true, ), onTap: onTap, @@ -745,7 +736,8 @@ class _IntervalSelector extends StatelessWidget { Text(label, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), 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), Wrap( spacing: 8, @@ -756,12 +748,16 @@ class _IntervalSelector extends StatelessWidget { onTap: () => onChanged(e.key), child: AnimatedContainer( duration: const Duration(milliseconds: 180), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 8), decoration: BoxDecoration( - color: isSelected ? Colors.blue.shade700 : Colors.grey.shade100, + color: + isSelected ? Colors.blue.shade700 : Colors.grey.shade100, borderRadius: BorderRadius.circular(20), border: Border.all( - color: isSelected ? Colors.blue.shade700 : Colors.grey.shade300, + color: isSelected + ? Colors.blue.shade700 + : Colors.grey.shade300, ), ), child: Text( @@ -781,6 +777,102 @@ class _IntervalSelector extends StatelessWidget { } } +class _CompensationCard extends StatelessWidget { + final EvaporasiSettingsState state; + const _CompensationCard({required this.state}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.grey.shade200), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Icon(Icons.thermostat_rounded, + size: 15, color: Colors.orange.shade700), + const SizedBox(width: 8), + Text('Kalibrasi & Kompensasi', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: Colors.orange.shade700)), + ]), + const SizedBox(height: 10), + Wrap(spacing: 12, runSpacing: 8, children: [ + _InfoTile( + label: 'Snapshot (cm)', + value: state.snapshotCm > 0 + ? state.snapshotCm.toStringAsFixed(2) + : '--'), + _InfoTile( + label: 'Koef. Kompensasi', + value: state.tempCompCoef.toStringAsFixed(2)), + _InfoTile( + label: 'Ref Suhu (°C)', value: state.tempRefC.toStringAsFixed(1)), + _InfoTile( + label: 'Komp. Aktif', + value: state.tempCompActive ? 'Ya' : 'Tidak'), + _InfoTile( + label: 'Sensor Error', value: state.sensorError ? 'YA' : 'TIDAK'), + _InfoTile(label: 'NTP Sync', value: state.ntpSync ? 'OK' : 'GAGAL'), + ]) + ]), + ); + } +} + +class _OtaCard extends StatelessWidget { + final EvaporasiSettingsState state; + const _OtaCard({required this.state}); + + Future _triggerOta(BuildContext context) async { + try { + await FirebaseDatabase.instance.ref('Monitoring/ota_trigger').set(true); + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Trigger OTA dikirim.'))); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Gagal trigger OTA: $e'), + backgroundColor: Colors.red.shade600)); + } + } + + @override + Widget build(BuildContext context) { + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('OTA kontrol', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.blue.shade800)), + const SizedBox(height: 8), + Row(children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () => _triggerOta(context), + icon: const Icon(Icons.system_update_alt_rounded), + label: const Text('Trigger OTA'), + style: + ElevatedButton.styleFrom(backgroundColor: Colors.blue.shade700), + ), + ), + const SizedBox(width: 12), + OutlinedButton.icon( + onPressed: () => Navigator.of(context) + .push(MaterialPageRoute(builder: (_) => const EvaporasiScreen())), + icon: const Icon(Icons.history_rounded), + label: const Text('Lihat Riwayat'), + ), + ]) + ]); + } +} + class _RealtimeInfo extends StatelessWidget { final EvaporasiSettingsState state; const _RealtimeInfo({required this.state}); @@ -805,15 +897,28 @@ class _RealtimeInfo extends StatelessWidget { 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)), + 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: '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)), ]) ]), @@ -836,10 +941,154 @@ class _InfoTile extends StatelessWidget { border: Border.all(color: Colors.grey.shade100), ), 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), - Text(value, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold)), + Text(value, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold)), ]), ); } } + +// ════════════════════════════════════════════════════════════ +// Data Collection Interval Card +// ════════════════════════════════════════════════════════════ + +class _CollectionIntervalCard extends StatelessWidget { + final EvaporasiSettingsState state; + final ValueChanged onHistoryIntervalChanged; + + const _CollectionIntervalCard({ + required this.state, + required this.onHistoryIntervalChanged, + }); + + static final Map historyOptions = { + 600000: '10 menit', + 1800000: '30 menit', + 3600000: '1 jam', + 7200000: '2 jam', + 14400000: '4 jam', + }; + + static String _getHistoryLabel(int ms) { + return historyOptions[ms] ?? '${ms ~/ 1000}s'; + } + + @override + Widget build(BuildContext context) { + final currentInterval = state.intervalHistory_ms; + final dataPerDay = (24 * 60 * 60 * 1000) ~/ currentInterval; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.schedule_outlined, + size: 15, color: Colors.teal.shade700), + const SizedBox(width: 8), + Text( + 'Interval Pengumpulan Data', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: Colors.teal.shade700, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + 'Pilih interval untuk menyimpan data ke Firebase:', + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: historyOptions.entries.map((e) { + final isSelected = e.key == currentInterval; + return GestureDetector( + onTap: () => onHistoryIntervalChanged(e.key), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: + isSelected ? Colors.teal.shade600 : Colors.grey.shade100, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isSelected + ? Colors.teal.shade600 + : Colors.grey.shade300, + width: isSelected ? 2 : 1, + ), + ), + child: Text( + e.value, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: isSelected ? Colors.white : Colors.black87, + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.teal.shade50, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.teal.shade100), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.info_outline_rounded, + size: 14, color: Colors.teal.shade700), + const SizedBox(width: 8), + Text( + 'Info', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.teal.shade700, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + 'Interval saat ini: ${_getHistoryLabel(currentInterval)}', + style: TextStyle( + fontSize: 12, + color: Colors.teal.shade700, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + 'Perkiraan data: ~$dataPerDay data per 24 jam', + style: TextStyle(fontSize: 11, color: Colors.teal.shade600), + ), + const SizedBox(height: 4), + Text( + 'Interval lebih singkat = data lebih detail tapi lebih banyak penyimpanan', + style: TextStyle(fontSize: 10, color: Colors.teal.shade500), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart index 4e0d5da..110b08b 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; +import 'package:firebase_database/firebase_database.dart'; import 'package:monitoring_repository/monitoring_repository.dart'; import '../../../../blocs/notification_bloc/notification_bloc.dart'; @@ -56,6 +57,8 @@ class EvaporasiBloc extends Bloc { final MonitoringRepository _repository; final NotificationBloc _notificationBloc; StreamSubscription? _subscription; + double _thresholdRendah = 2.0; + double _thresholdTinggi = 10.0; EvaporasiBloc({ required MonitoringRepository repository, @@ -100,11 +103,17 @@ class EvaporasiBloc extends Bloc { final labels = List.generate( 24, (i) => '${i.toString().padLeft(2, '0')}:00'); + await _loadThresholdSettings(); + final lastValue = history.isNotEmpty ? history.last.evaporasi : 0.0; final lastWater = history.isNotEmpty ? history.last.tinggiAir : 0.0; final lastTemp = history.isNotEmpty ? history.last.suhu : 0.0; - final (status, willRain) = _computeStatus(lastValue); + final (status, willRain) = computeStatus( + lastValue, + thresholdRendah: _thresholdRendah, + thresholdTinggi: _thresholdTinggi, + ); _emitAlert(status, willRain, lastValue); emit(state.copyWith( @@ -138,10 +147,10 @@ class EvaporasiBloc extends Bloc { // ════════════════════════════════════════════════════════════ // REALTIME UPDATE // ════════════════════════════════════════════════════════════ - void _onRealtimeUpdated( + Future _onRealtimeUpdated( _EvaporasiRealtimeUpdated event, Emitter emit, - ) { + ) async { final updatedHistory = List.from(state.history); final dupIdx = updatedHistory.indexWhere( (e) => e.timestamp.toUtc() == event.data.timestamp.toUtc(), @@ -194,7 +203,13 @@ class EvaporasiBloc extends Bloc { updatedLabels = evapResult.labels; } - final (status, willRain) = _computeStatus(event.data.evaporasi); + await _loadThresholdSettings(); + + final (status, willRain) = computeStatus( + event.data.evaporasi, + thresholdRendah: _thresholdRendah, + thresholdTinggi: _thresholdTinggi, + ); _emitAlert(status, willRain, event.data.evaporasi); emit(state.copyWith( @@ -339,16 +354,37 @@ class EvaporasiBloc extends Bloc { static bool _isSameDay(DateTime a, DateTime b) => a.year == b.year && a.month == b.month && a.day == b.day; - static (String, bool) _computeStatus(double v) { - // Kategori status sesuai permintaan: - // - v < 20 => Rendah ("10 mm masih rendah") - // - 20 <= v < 30 => Normal/Sedang - // - v >= 30 => Tinggi - if (v >= 30.0) return ('Tinggi', true); - if (v >= 20.0) return ('Normal', false); + static (String, bool) computeStatus( + double value, { + double thresholdRendah = 2.0, + double thresholdTinggi = 10.0, + }) { + if (value >= thresholdTinggi) return ('Tinggi', true); + if (value >= thresholdRendah) return ('Normal', false); return ('Rendah', false); } + Future _loadThresholdSettings() async { + try { + final snap = await FirebaseDatabase.instance + .ref('Monitoring/settings/evaporasi') + .get(); + if (!snap.exists || snap.value == null) return; + + final data = Map.from(snap.value as Map); + _thresholdRendah = _toDouble(data['threshold_rendah'], _thresholdRendah); + _thresholdTinggi = _toDouble(data['threshold_tinggi'], _thresholdTinggi); + } catch (_) { + // Tetap pakai nilai default jika pembacaan gagal. + } + } + + static double _toDouble(dynamic value, double fallback) { + if (value == null) return fallback; + if (value is num) return value.toDouble(); + if (value is String) return double.tryParse(value) ?? fallback; + return fallback; + } void _emitAlert(String status, bool willRain, double value) { final AlertSeverity severity; diff --git a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart index 6c3c3ee..c405d5d 100644 --- a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart +++ b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart @@ -599,66 +599,6 @@ 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 8b4712e..aeb353a 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_control_panel.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_control_panel.dart @@ -14,7 +14,6 @@ class _EvaporasiControlPanelState extends State { bool _selenoid = false; bool _isTogglingSelenoid = false; bool _isResettingEvaporasi = false; - bool _isTriggeringOta = false; StreamSubscription? _subscription; @override @@ -99,12 +98,9 @@ class _EvaporasiControlPanelState extends State { }); try { - await Future.wait([ - FirebaseDatabase.instance.ref('Monitoring/reset_evaporasi').set(true), - FirebaseDatabase.instance - .ref('Monitoring/realtime/reset_evaporasi') - .set(true), - ]); + await FirebaseDatabase.instance + .ref('Monitoring/reset_evaporasi') + .set(true); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar( content: Text('Perintah reset evaporasi berhasil dikirim.'), @@ -128,42 +124,6 @@ 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(); @@ -197,7 +157,7 @@ class _EvaporasiControlPanelState extends State { ), const SizedBox(height: 12), Text( - 'Tombol ini terhubung ke Firebase untuk reset evaporasi, kontrol selenoid, dan trigger OTA.', + '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), @@ -250,24 +210,6 @@ 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/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_history_list.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_history_list.dart index 47dc4ef..1da088f 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_history_list.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_history_list.dart @@ -174,9 +174,7 @@ class _ChipButton extends StatelessWidget { const SizedBox(width: 6), Text(label, style: TextStyle( - fontSize: 12, - color: color, - fontWeight: FontWeight.w600)), + fontSize: 12, color: color, fontWeight: FontWeight.w600)), ], ), ), @@ -200,7 +198,6 @@ class _DateGroup extends StatefulWidget { class _DateGroupState extends State<_DateGroup> { bool _expanded = false; - double get _avgEvap { if (widget.items.isEmpty) return 0; return widget.items.map((e) => e.evaporasi).reduce((a, b) => a + b) / @@ -209,9 +206,21 @@ class _DateGroupState extends State<_DateGroup> { double get _maxEvap { if (widget.items.isEmpty) return 0; - return widget.items - .map((e) => e.evaporasi) - .reduce((a, b) => a > b ? a : b); + return widget.items.map((e) => e.evaporasi).reduce((a, b) => a > b ? a : b); + } + + double get _avgTemp { + if (widget.items.isEmpty) return 0; + final validTemps = + widget.items.where((e) => e.suhu >= -50 && e.suhu <= 100).toList(); + if (validTemps.isEmpty) return 0; + return validTemps.map((e) => e.suhu).reduce((a, b) => a + b) / + validTemps.length; + } + + double get _maxTemp { + if (widget.items.isEmpty) return 0; + return widget.items.map((e) => e.suhu).reduce((a, b) => a > b ? a : b); } @override @@ -234,11 +243,9 @@ class _DateGroupState extends State<_DateGroup> { // ── Group header ────────────────────────────────── InkWell( onTap: () => setState(() => _expanded = !_expanded), - borderRadius: - const BorderRadius.vertical(top: Radius.circular(16)), + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ Container( @@ -256,14 +263,19 @@ class _DateGroupState extends State<_DateGroup> { children: [ Text(widget.label, style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 13)), + fontWeight: FontWeight.bold, fontSize: 13)), const SizedBox(height: 2), Text( '${widget.items.length} data • rata-rata ${_avgEvap.toStringAsFixed(2)} mm • maks ${_maxEvap.toStringAsFixed(2)} mm', style: TextStyle( fontSize: 11, color: Colors.grey.shade600), ), + const SizedBox(height: 2), + Text( + 'rata-rata suhu ${_avgTemp.toStringAsFixed(1)} °C • maks suhu ${_maxTemp.toStringAsFixed(1)} °C', + style: TextStyle( + fontSize: 11, color: Colors.grey.shade600), + ), ], ), ), @@ -368,8 +380,7 @@ class _HistoryItemTile extends StatelessWidget { ), const TextSpan( text: ' mm', - style: - TextStyle(fontSize: 11, color: Colors.black54), + style: TextStyle(fontSize: 11, color: Colors.black54), ), ], ), @@ -378,13 +389,12 @@ class _HistoryItemTile extends StatelessWidget { // Tinggi Air Row( children: [ - Icon(Icons.water, size: 11, - color: Colors.blue.shade400), + Icon(Icons.water, size: 11, color: Colors.blue.shade400), const SizedBox(width: 3), Text( 'Tinggi Air: ${item.tinggiAir.toStringAsFixed(1)} cm', - style: TextStyle( - fontSize: 11, color: Colors.blue.shade600), + style: + TextStyle(fontSize: 11, color: Colors.blue.shade600), ), ], ), @@ -392,8 +402,8 @@ class _HistoryItemTile extends StatelessWidget { // Suhu Row( children: [ - Icon(Icons.thermostat, size: 11, - color: Colors.orange.shade400), + Icon(Icons.thermostat, + size: 11, color: Colors.orange.shade400), const SizedBox(width: 3), Text( 'Suhu: ${item.suhu.toStringAsFixed(1)} °C', @@ -408,13 +418,11 @@ class _HistoryItemTile extends StatelessWidget { // ── Badge status ────────────────────────────────── Container( - padding: - const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: _statusColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(20), - border: - Border.all(color: _statusColor.withValues(alpha: 0.4)), + border: Border.all(color: _statusColor.withValues(alpha: 0.4)), ), child: Row( mainAxisSize: MainAxisSize.min, @@ -456,9 +464,7 @@ class _EmptyState extends StatelessWidget { child: Column( children: [ Icon( - hasFilter - ? Icons.search_off_rounded - : Icons.inbox_rounded, + hasFilter ? Icons.search_off_rounded : Icons.inbox_rounded, size: 48, color: Colors.grey.shade300, ), @@ -467,11 +473,10 @@ class _EmptyState extends StatelessWidget { hasFilter ? 'Tidak ada data untuk tanggal ini' : 'Belum ada data history', - style: - TextStyle(color: Colors.grey.shade500, fontSize: 14), + style: TextStyle(color: Colors.grey.shade500, fontSize: 14), ), ], ), ); } -} \ No newline at end of file +} diff --git a/packages/monitoring_repository/lib/src/models/evaporasi.dart b/packages/monitoring_repository/lib/src/models/evaporasi.dart index 1ad51b3..386a4c6 100644 --- a/packages/monitoring_repository/lib/src/models/evaporasi.dart +++ b/packages/monitoring_repository/lib/src/models/evaporasi.dart @@ -7,20 +7,6 @@ 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, @@ -29,20 +15,6 @@ 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( @@ -69,30 +41,6 @@ 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'] ?? @@ -103,11 +51,13 @@ 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'] ?? + json['suhu_air_c'] ?? // ← ESP32 kirim field ini json['suhu_air'] ?? json['suhu'] ?? json['suhuAir'] ?? @@ -127,6 +77,7 @@ class Evaporasi { json['tinggi_air_m'] ?? json['tinggiAir_m'], ); + // Sudah dalam cm, tidak perlu konversi final tinggiVal = tinggiRaw; // ── Sanity check ───────────────────────────────────── @@ -141,79 +92,9 @@ class Evaporasi { // ── Status ─────────────────────────────────────────── final statusVal = json['status']?.toString() ?? "Normal"; - 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'], - ); - + // ── Parse Timestamp ────────────────────────────────── + // FIX: Tambahkan offset +07:00 (WIB) jika string tidak punya info timezone, + // agar tidak terjadi mismatch 7 jam antara Firebase dan Flutter. DateTime parseTimestamp(dynamic rawTimestamp) { try { if (rawTimestamp is int) { @@ -236,6 +117,7 @@ class Evaporasi { if (rawTimestamp is String) { String s = rawTimestamp.trim(); + // UNIX string final unixValue = int.tryParse(s); if (unixValue != null) { if (unixValue < 1000000000000) { @@ -246,10 +128,13 @@ 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'; } @@ -258,15 +143,18 @@ 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(':'); @@ -287,20 +175,6 @@ 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, ); } } diff --git a/test/evaporasi_bloc_test.dart b/test/evaporasi_bloc_test.dart new file mode 100644 index 0000000..65f843d --- /dev/null +++ b/test/evaporasi_bloc_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:klimatologiot/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart'; + +void main() { + group('EvaporasiBloc.computeStatus', () { + test('menggunakan threshold yang diatur untuk status normal', () { + final result = EvaporasiBloc.computeStatus( + 8.0, + thresholdRendah: 2.0, + thresholdTinggi: 10.0, + ); + + expect(result.$1, 'Normal'); + expect(result.$2, isFalse); + }); + + test('menggunakan threshold yang diatur untuk status tinggi', () { + final result = EvaporasiBloc.computeStatus( + 11.0, + thresholdRendah: 2.0, + thresholdTinggi: 10.0, + ); + + expect(result.$1, 'Tinggi'); + expect(result.$2, isTrue); + }); + }); +}