update dashboard evaporasi
This commit is contained in:
parent
4f9182e4b4
commit
a28b2224ab
|
|
@ -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 Rendah–Normal (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 Normal–Tinggi (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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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,8 +473,7 @@ 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),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue