update dashboard evaporasi

This commit is contained in:
Mochamad ongki ramadani 2026-06-10 07:26:11 +07:00
parent 4f9182e4b4
commit a28b2224ab
7 changed files with 655 additions and 581 deletions

View File

@ -1,14 +1,19 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:firebase_database/firebase_database.dart';
import 'evaporasi_settings_bloc.dart'; import 'evaporasi_settings_bloc.dart';
import '../../evaporasi/views/widgets/evaporasi_control_panel.dart';
import '../../evaporasi/views/evaporasi_screen.dart';
class EvaporasiSettingsScreen extends StatefulWidget { 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> {
@ -62,7 +67,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,
@ -74,7 +83,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);
} }
@ -113,7 +123,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 &&
@ -144,253 +155,195 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
), ),
body: isLoading body: isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: SingleChildScrollView( : DefaultTabController(
padding: const EdgeInsets.all(20), length: 3,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_InfoCard( Material(
'Pengaturan disimpan ke Firebase Firestore dan ' color: Colors.transparent,
'langsung berlaku untuk semua perangkat.', 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), Expanded(
_groupTitle('Grup A — Sistem & Kalibrasi'), child: TabBarView(
const SizedBox(height: 12), children: [
_sectionTitle('Batas Status Evaporasi'), SingleChildScrollView(
const SizedBox(height: 6), padding: const EdgeInsets.all(20),
Text( child: Column(
'Tentukan nilai (mm) untuk klasifikasi status ' crossAxisAlignment: CrossAxisAlignment.start,
'Rendah, Normal, dan Tinggi.', children: [
style: TextStyle(fontSize: 12, color: Colors.grey.shade500), _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<EvaporasiSettingsBloc>();
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), Padding(
_SettingsCard(children: [ padding: const EdgeInsets.all(16.0),
_ThresholdPreview(state: state), child: SizedBox(
const Divider(height: 28), width: double.infinity,
_NumericField( child: ElevatedButton.icon(
controller: _rendahController, onPressed: isSaving
label: 'Batas RendahNormal (mm)', ? null
hint: 'Contoh: 20.0', : () => bloc.add(EvaporasiSettingsSaved()),
helper: 'Nilai < batas ini → Status Rendah. Default: 20.0', icon: isSaving
onChanged: (v) { ? const SizedBox(
final d = double.tryParse(v); width: 18,
if (d != null && d >= 0) { height: 18,
bloc.add(EvaporasiThresholdRendahChanged(d)); child: CircularProgressIndicator(
} strokeWidth: 2, color: Colors.white),
}, )
), : const Icon(Icons.save_rounded),
const SizedBox(height: 16), label: Text(isSaving
_NumericField( ? 'Menyimpan...'
controller: _tinggiController, : 'Simpan ke Firebase'),
label: 'Batas NormalTinggi (mm)', style: ElevatedButton.styleFrom(
hint: 'Contoh: 30.0', backgroundColor: Colors.blue.shade700,
helper: 'Nilai ≥ batas ini → Status Tinggi. Default: 30.0', foregroundColor: Colors.white,
onChanged: (v) { padding:
final d = double.tryParse(v); const EdgeInsets.symmetric(vertical: 16),
if (d != null && d >= 0) { shape: RoundedRectangleBorder(
bloc.add(EvaporasiThresholdTinggiChanged(d)); borderRadius: BorderRadius.circular(14)),
} textStyle: const TextStyle(
}, fontSize: 15, fontWeight: FontWeight.bold),
), ),
]),
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),
), ),
), ),
), ),
const SizedBox(height: 20),
], ],
), ),
), ),
@ -402,7 +355,8 @@ class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
Widget _groupTitle(String text) => Text( Widget _groupTitle(String text) => 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( Widget _sectionTitle(String text) => Text(
@ -425,15 +379,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) ...[
@ -469,9 +431,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),
], ],
), ),
), ),
@ -505,7 +470,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;
@ -519,13 +487,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(
@ -535,18 +508,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),
], ],
), ),
), ),
@ -580,14 +558,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(
@ -617,10 +602,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)),
), ),
], ],
), ),
@ -643,7 +630,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),
); );
} }
} }
@ -669,7 +657,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.]'),
@ -681,7 +670,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,
@ -714,7 +704,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,
@ -745,7 +736,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,
@ -756,12 +748,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(
@ -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<void> _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 { class _RealtimeInfo extends StatelessWidget {
final EvaporasiSettingsState state; final EvaporasiSettingsState state;
const _RealtimeInfo({required this.state}); const _RealtimeInfo({required this.state});
@ -805,15 +897,28 @@ class _RealtimeInfo extends StatelessWidget {
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(
_InfoTile(label: 'Firebase', value: state.firebaseConnected ? 'Terhubung' : 'Tidak'), label: 'WiFi',
_InfoTile(label: 'D0 aktif', value: state.activeD0 == 0 ? '--' : state.activeD0.toString()), value: state.wifiConnected ? 'Terhubung' : 'Tidak'),
_InfoTile(label: 'DMAX aktif', value: state.activeDmax == 0 ? '--' : state.activeDmax.toString()), _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)), _InfoTile(label: 'Terakhir', value: _fmtDate(state.lastUpdate)),
]) ])
]), ]),
@ -836,10 +941,154 @@ class _InfoTile extends StatelessWidget {
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)),
]), ]),
); );
} }
} }
//
// Data Collection Interval Card
//
class _CollectionIntervalCard extends StatelessWidget {
final EvaporasiSettingsState state;
final ValueChanged<int> onHistoryIntervalChanged;
const _CollectionIntervalCard({
required this.state,
required this.onHistoryIntervalChanged,
});
static final Map<int, String> 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),
),
],
),
),
],
);
}
}

View File

@ -4,6 +4,7 @@ import 'dart:async';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:firebase_database/firebase_database.dart';
import 'package:monitoring_repository/monitoring_repository.dart'; import 'package:monitoring_repository/monitoring_repository.dart';
import '../../../../blocs/notification_bloc/notification_bloc.dart'; import '../../../../blocs/notification_bloc/notification_bloc.dart';
@ -56,6 +57,8 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
final MonitoringRepository _repository; final MonitoringRepository _repository;
final NotificationBloc _notificationBloc; final NotificationBloc _notificationBloc;
StreamSubscription<Evaporasi>? _subscription; StreamSubscription<Evaporasi>? _subscription;
double _thresholdRendah = 2.0;
double _thresholdTinggi = 10.0;
EvaporasiBloc({ EvaporasiBloc({
required MonitoringRepository repository, required MonitoringRepository repository,
@ -100,11 +103,17 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
final labels = List.generate( final labels = List.generate(
24, (i) => '${i.toString().padLeft(2, '0')}:00'); 24, (i) => '${i.toString().padLeft(2, '0')}:00');
await _loadThresholdSettings();
final lastValue = history.isNotEmpty ? history.last.evaporasi : 0.0; final lastValue = history.isNotEmpty ? history.last.evaporasi : 0.0;
final lastWater = history.isNotEmpty ? history.last.tinggiAir : 0.0; final lastWater = history.isNotEmpty ? history.last.tinggiAir : 0.0;
final lastTemp = history.isNotEmpty ? history.last.suhu : 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); _emitAlert(status, willRain, lastValue);
emit(state.copyWith( emit(state.copyWith(
@ -138,10 +147,10 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
// //
// REALTIME UPDATE // REALTIME UPDATE
// //
void _onRealtimeUpdated( Future<void> _onRealtimeUpdated(
_EvaporasiRealtimeUpdated event, _EvaporasiRealtimeUpdated event,
Emitter<EvaporasiState> emit, Emitter<EvaporasiState> emit,
) { ) async {
final updatedHistory = List<Evaporasi>.from(state.history); final updatedHistory = List<Evaporasi>.from(state.history);
final dupIdx = updatedHistory.indexWhere( final dupIdx = updatedHistory.indexWhere(
(e) => e.timestamp.toUtc() == event.data.timestamp.toUtc(), (e) => e.timestamp.toUtc() == event.data.timestamp.toUtc(),
@ -194,7 +203,13 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
updatedLabels = evapResult.labels; 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); _emitAlert(status, willRain, event.data.evaporasi);
emit(state.copyWith( emit(state.copyWith(
@ -339,16 +354,37 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
static bool _isSameDay(DateTime a, DateTime b) => static bool _isSameDay(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month && a.day == b.day; a.year == b.year && a.month == b.month && a.day == b.day;
static (String, bool) _computeStatus(double v) { static (String, bool) computeStatus(
// Kategori status sesuai permintaan: double value, {
// - v < 20 => Rendah ("10 mm masih rendah") double thresholdRendah = 2.0,
// - 20 <= v < 30 => Normal/Sedang double thresholdTinggi = 10.0,
// - v >= 30 => Tinggi }) {
if (v >= 30.0) return ('Tinggi', true); if (value >= thresholdTinggi) return ('Tinggi', true);
if (v >= 20.0) return ('Normal', false); if (value >= thresholdRendah) return ('Normal', false);
return ('Rendah', false); return ('Rendah', false);
} }
Future<void> _loadThresholdSettings() async {
try {
final snap = await FirebaseDatabase.instance
.ref('Monitoring/settings/evaporasi')
.get();
if (!snap.exists || snap.value == null) return;
final data = Map<String, dynamic>.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) { void _emitAlert(String status, bool willRain, double value) {
final AlertSeverity severity; final AlertSeverity severity;

View File

@ -599,66 +599,6 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
); );
} }
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) { Widget _statusCard(EvaporasiState state) {
Color statusColor; Color statusColor;
IconData statusIcon; IconData statusIcon;

View File

@ -14,7 +14,6 @@ class _EvaporasiControlPanelState extends State<EvaporasiControlPanel> {
bool _selenoid = false; bool _selenoid = false;
bool _isTogglingSelenoid = false; bool _isTogglingSelenoid = false;
bool _isResettingEvaporasi = false; bool _isResettingEvaporasi = false;
bool _isTriggeringOta = false;
StreamSubscription<DatabaseEvent>? _subscription; StreamSubscription<DatabaseEvent>? _subscription;
@override @override
@ -99,12 +98,9 @@ class _EvaporasiControlPanelState extends State<EvaporasiControlPanel> {
}); });
try { try {
await Future.wait([ await FirebaseDatabase.instance
FirebaseDatabase.instance.ref('Monitoring/reset_evaporasi').set(true), .ref('Monitoring/reset_evaporasi')
FirebaseDatabase.instance .set(true);
.ref('Monitoring/realtime/reset_evaporasi')
.set(true),
]);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar( ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Perintah reset evaporasi berhasil dikirim.'), content: Text('Perintah reset evaporasi berhasil dikirim.'),
@ -128,42 +124,6 @@ class _EvaporasiControlPanelState extends State<EvaporasiControlPanel> {
} }
} }
Future<void> _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 @override
void dispose() { void dispose() {
_subscription?.cancel(); _subscription?.cancel();
@ -197,7 +157,7 @@ class _EvaporasiControlPanelState extends State<EvaporasiControlPanel> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( 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), style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -250,24 +210,6 @@ class _EvaporasiControlPanelState extends State<EvaporasiControlPanel> {
const EdgeInsets.symmetric(horizontal: 16, vertical: 14), 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), const SizedBox(height: 16),

View File

@ -174,9 +174,7 @@ class _ChipButton extends StatelessWidget {
const SizedBox(width: 6), const SizedBox(width: 6),
Text(label, Text(label,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12, color: color, fontWeight: FontWeight.w600)),
color: color,
fontWeight: FontWeight.w600)),
], ],
), ),
), ),
@ -200,7 +198,6 @@ class _DateGroup extends StatefulWidget {
class _DateGroupState extends State<_DateGroup> { class _DateGroupState extends State<_DateGroup> {
bool _expanded = false; bool _expanded = false;
double get _avgEvap { double get _avgEvap {
if (widget.items.isEmpty) return 0; if (widget.items.isEmpty) return 0;
return widget.items.map((e) => e.evaporasi).reduce((a, b) => a + b) / return widget.items.map((e) => e.evaporasi).reduce((a, b) => a + b) /
@ -209,9 +206,21 @@ class _DateGroupState extends State<_DateGroup> {
double get _maxEvap { double get _maxEvap {
if (widget.items.isEmpty) return 0; if (widget.items.isEmpty) return 0;
return widget.items return widget.items.map((e) => e.evaporasi).reduce((a, b) => a > b ? a : b);
.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 @override
@ -234,11 +243,9 @@ class _DateGroupState extends State<_DateGroup> {
// Group header // Group header
InkWell( InkWell(
onTap: () => setState(() => _expanded = !_expanded), onTap: () => setState(() => _expanded = !_expanded),
borderRadius: borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
const BorderRadius.vertical(top: Radius.circular(16)),
child: Padding( child: Padding(
padding: padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row( child: Row(
children: [ children: [
Container( Container(
@ -256,14 +263,19 @@ class _DateGroupState extends State<_DateGroup> {
children: [ children: [
Text(widget.label, Text(widget.label,
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold, fontSize: 13)),
fontSize: 13)),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
'${widget.items.length} data • rata-rata ${_avgEvap.toStringAsFixed(2)} mm • maks ${_maxEvap.toStringAsFixed(2)} mm', '${widget.items.length} data • rata-rata ${_avgEvap.toStringAsFixed(2)} mm • maks ${_maxEvap.toStringAsFixed(2)} mm',
style: TextStyle( style: TextStyle(
fontSize: 11, color: Colors.grey.shade600), 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( const TextSpan(
text: ' mm', text: ' mm',
style: style: TextStyle(fontSize: 11, color: Colors.black54),
TextStyle(fontSize: 11, color: Colors.black54),
), ),
], ],
), ),
@ -378,13 +389,12 @@ class _HistoryItemTile extends StatelessWidget {
// Tinggi Air // Tinggi Air
Row( Row(
children: [ children: [
Icon(Icons.water, size: 11, Icon(Icons.water, size: 11, color: Colors.blue.shade400),
color: Colors.blue.shade400),
const SizedBox(width: 3), const SizedBox(width: 3),
Text( Text(
'Tinggi Air: ${item.tinggiAir.toStringAsFixed(1)} cm', 'Tinggi Air: ${item.tinggiAir.toStringAsFixed(1)} cm',
style: TextStyle( style:
fontSize: 11, color: Colors.blue.shade600), TextStyle(fontSize: 11, color: Colors.blue.shade600),
), ),
], ],
), ),
@ -392,8 +402,8 @@ class _HistoryItemTile extends StatelessWidget {
// Suhu // Suhu
Row( Row(
children: [ children: [
Icon(Icons.thermostat, size: 11, Icon(Icons.thermostat,
color: Colors.orange.shade400), size: 11, color: Colors.orange.shade400),
const SizedBox(width: 3), const SizedBox(width: 3),
Text( Text(
'Suhu: ${item.suhu.toStringAsFixed(1)} °C', 'Suhu: ${item.suhu.toStringAsFixed(1)} °C',
@ -408,13 +418,11 @@ class _HistoryItemTile extends StatelessWidget {
// Badge status // Badge status
Container( Container(
padding: padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _statusColor.withValues(alpha: 0.1), color: _statusColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: border: Border.all(color: _statusColor.withValues(alpha: 0.4)),
Border.all(color: _statusColor.withValues(alpha: 0.4)),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -456,9 +464,7 @@ class _EmptyState extends StatelessWidget {
child: Column( child: Column(
children: [ children: [
Icon( Icon(
hasFilter hasFilter ? Icons.search_off_rounded : Icons.inbox_rounded,
? Icons.search_off_rounded
: Icons.inbox_rounded,
size: 48, size: 48,
color: Colors.grey.shade300, color: Colors.grey.shade300,
), ),
@ -467,11 +473,10 @@ class _EmptyState extends StatelessWidget {
hasFilter hasFilter
? 'Tidak ada data untuk tanggal ini' ? 'Tidak ada data untuk tanggal ini'
: 'Belum ada data history', : 'Belum ada data history',
style: style: TextStyle(color: Colors.grey.shade500, fontSize: 14),
TextStyle(color: Colors.grey.shade500, fontSize: 14),
), ),
], ],
), ),
); );
} }
} }

View File

@ -7,20 +7,6 @@ class Evaporasi {
final double acuanPagi; final double acuanPagi;
final String status; final String status;
final DateTime timestamp; 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({ Evaporasi({
required this.evaporasi, required this.evaporasi,
@ -29,20 +15,6 @@ class Evaporasi {
required this.acuanPagi, required this.acuanPagi,
required this.status, required this.status,
required this.timestamp, 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( static final empty = Evaporasi(
@ -69,30 +41,6 @@ class Evaporasi {
return 0.0; 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) // Evaporasi (mm)
final evaporasiRaw = toDoubleSafe( final evaporasiRaw = toDoubleSafe(
json['evaporasi_mm'] ?? json['evaporasi_mm'] ??
@ -103,11 +51,13 @@ class Evaporasi {
json['evaporasi_value'] ?? json['evaporasi_value'] ??
json['evaporasi_k'], json['evaporasi_k'],
); );
// Sudah dalam mm, tidak perlu konversi
final evaporasiVal = evaporasiRaw; final evaporasiVal = evaporasiRaw;
// Suhu (°C) // Suhu (°C)
// FIX: tambah 'suhu_air_c' sesuai field yang dikirim ESP32
final suhuRaw = toDoubleSafe( final suhuRaw = toDoubleSafe(
json['suhu_air_c'] ?? json['suhu_air_c'] ?? // ESP32 kirim field ini
json['suhu_air'] ?? json['suhu_air'] ??
json['suhu'] ?? json['suhu'] ??
json['suhuAir'] ?? json['suhuAir'] ??
@ -127,6 +77,7 @@ class Evaporasi {
json['tinggi_air_m'] ?? json['tinggi_air_m'] ??
json['tinggiAir_m'], json['tinggiAir_m'],
); );
// Sudah dalam cm, tidak perlu konversi
final tinggiVal = tinggiRaw; final tinggiVal = tinggiRaw;
// Sanity check // Sanity check
@ -141,79 +92,9 @@ class Evaporasi {
// Status // Status
final statusVal = json['status']?.toString() ?? "Normal"; final statusVal = json['status']?.toString() ?? "Normal";
final sensorErrorVal = toBoolSafe( // Parse Timestamp
json['sensor_error'] ?? // FIX: Tambahkan offset +07:00 (WIB) jika string tidak punya info timezone,
json['sensorError'] ?? // agar tidak terjadi mismatch 7 jam antara Firebase dan Flutter.
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) { DateTime parseTimestamp(dynamic rawTimestamp) {
try { try {
if (rawTimestamp is int) { if (rawTimestamp is int) {
@ -236,6 +117,7 @@ class Evaporasi {
if (rawTimestamp is String) { if (rawTimestamp is String) {
String s = rawTimestamp.trim(); String s = rawTimestamp.trim();
// UNIX string
final unixValue = int.tryParse(s); final unixValue = int.tryParse(s);
if (unixValue != null) { if (unixValue != null) {
if (unixValue < 1000000000000) { if (unixValue < 1000000000000) {
@ -246,10 +128,13 @@ class Evaporasi {
return DateTime.fromMillisecondsSinceEpoch(unixValue).toLocal(); return DateTime.fromMillisecondsSinceEpoch(unixValue).toLocal();
} }
// Format "YYYY-MM-DD HH:mm:ss" tambah 'T' agar bisa diparsing
if (s.contains(' ') && !s.contains('T')) { if (s.contains(' ') && !s.contains('T')) {
s = s.replaceFirst(' ', '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)) { if (!s.contains('+') && !s.contains('Z') && !s.contains('-', 10)) {
s = '${s}+07:00'; s = '${s}+07:00';
} }
@ -258,15 +143,18 @@ class Evaporasi {
if (parsed != null) return parsed.toLocal(); if (parsed != null) return parsed.toLocal();
} }
} catch (_) {} } catch (_) {}
return DateTime.fromMillisecondsSinceEpoch(0); return DateTime.fromMillisecondsSinceEpoch(0);
} }
DateTime timestamp = DateTime.fromMillisecondsSinceEpoch(0); DateTime timestamp = DateTime.fromMillisecondsSinceEpoch(0);
final rawTimestamp = json['timestamp'] ?? json['time'] ?? json['datetime']; final rawTimestamp = json['timestamp'] ?? json['time'] ?? json['datetime'];
if (rawTimestamp != null) { if (rawTimestamp != null) {
timestamp = parseTimestamp(rawTimestamp); timestamp = parseTimestamp(rawTimestamp);
} else { } else {
// Legacy fallback: field "waktu" format "HH:mm:ss"
final waktuStr = json['waktu'] as String?; final waktuStr = json['waktu'] as String?;
if (waktuStr != null) { if (waktuStr != null) {
final parts = waktuStr.split(':'); final parts = waktuStr.split(':');
@ -287,20 +175,6 @@ class Evaporasi {
acuanPagi: acuanPagiVal, acuanPagi: acuanPagiVal,
status: statusVal, status: statusVal,
timestamp: timestamp, 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,
); );
} }
} }

View File

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