From d36d107daa5751a9e18697df670ea6f1c5f6ed0a Mon Sep 17 00:00:00 2001 From: Mochamad ongki ramadani Date: Thu, 28 May 2026 22:33:26 +0700 Subject: [PATCH] gas --- .../blocs/evaporasi_settings_bloc.dart | 158 ++++ .../blocs/evaporasi_settings_event.dart | 56 ++ .../blocs/evaporasi_settings_screen.dart | 752 ++++++++++++++++++ .../blocs/evaporasi_settings_state.dart | 70 ++ .../evaporasi/blocs/evaporasi_bloc.dart | 110 ++- .../evaporasi/views/evaporasi_screen.dart | 81 +- .../views/widgets/evaporasi_history_list.dart | 3 +- 7 files changed, 1208 insertions(+), 22 deletions(-) create mode 100644 lib/screens/monitoring/device_setup/blocs/evaporasi_settings_bloc.dart create mode 100644 lib/screens/monitoring/device_setup/blocs/evaporasi_settings_event.dart create mode 100644 lib/screens/monitoring/device_setup/blocs/evaporasi_settings_screen.dart create mode 100644 lib/screens/monitoring/device_setup/blocs/evaporasi_settings_state.dart diff --git a/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_bloc.dart b/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_bloc.dart new file mode 100644 index 0000000..2b5d172 --- /dev/null +++ b/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_bloc.dart @@ -0,0 +1,158 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:firebase_database/firebase_database.dart'; + +part 'evaporasi_settings_event.dart'; +part 'evaporasi_settings_state.dart'; + +class EvaporasiSettingsBloc extends Bloc { + final DatabaseReference _ref; + + static const _path = 'Monitoring/settings/evaporasi'; + static const _rtdbResetPath = 'Monitoring/reset_dmax'; + static const _rtdbRealtimePath = 'Monitoring/realtime/dmax_saat_ini'; + + EvaporasiSettingsBloc({DatabaseReference? ref}) + : _ref = ref ?? FirebaseDatabase.instance.ref(_path), + super(const EvaporasiSettingsState()) { + on(_onStarted); + on(_onThresholdRendahChanged); + on(_onThresholdTinggiChanged); + on(_onRumusChanged); + on(_onOffsetChanged); + on(_onIntervalRealtimeChanged); + on(_onIntervalHistoryChanged); + on(_onIntervalBacaChanged); + on(_onDmaxReset); + on(_onSaved); + } + + Future _onStarted( + EvaporasiSettingsStarted event, + Emitter emit, + ) async { + emit(state.copyWith(status: EvaporasiSettingsStatus.loading)); + try { + final snap = await _ref.get(); + if (snap.exists && snap.value != null) { + final data = Map.from(snap.value as Map); + emit(state.copyWith( + thresholdRendah: _toDouble(data['threshold_rendah'], 2.0), + thresholdTinggi: _toDouble(data['threshold_tinggi'], 10.0), + rumusKalibrasi: (data['rumus_kalibrasi'] as String?) ?? 'selisih_max', + koreksiOffset: _toDouble(data['koreksi_offset'], 0.0), + intervalRealtime_ms: _toInt(data['interval_realtime_ms'], 300000), + intervalHistory_ms: _toInt(data['interval_history_ms'], 600000), + intervalBaca_ms: _toInt(data['interval_baca_ms'], 10000), + status: EvaporasiSettingsStatus.loaded, + )); + } else { + emit(state.copyWith(status: EvaporasiSettingsStatus.loaded)); + } + } catch (e) { + emit(state.copyWith( + status: EvaporasiSettingsStatus.error, + errorMessage: e.toString(), + )); + return; + } + + try { + final rtSnap = await FirebaseDatabase.instance.ref(_rtdbRealtimePath).get(); + final val = rtSnap.exists ? (rtSnap.value as num?)?.toInt() ?? 0 : 0; + emit(state.copyWith(dmax: val)); + } catch (_) { + // ignore: avoid_catching_errors + } + } + + void _onThresholdRendahChanged( + EvaporasiThresholdRendahChanged event, + Emitter emit, + ) => emit(state.copyWith(thresholdRendah: event.value)); + + void _onThresholdTinggiChanged( + EvaporasiThresholdTinggiChanged event, + Emitter emit, + ) => emit(state.copyWith(thresholdTinggi: event.value)); + + void _onRumusChanged( + EvaporasiRumusKalibrasiChanged event, + Emitter emit, + ) => emit(state.copyWith(rumusKalibrasi: event.rumus)); + + void _onOffsetChanged( + EvaporasiKoreksiOffsetChanged event, + Emitter emit, + ) => emit(state.copyWith(koreksiOffset: event.value)); + + void _onIntervalRealtimeChanged( + EvaporasiIntervalRealtimeChanged event, + Emitter emit, + ) => emit(state.copyWith(intervalRealtime_ms: event.value)); + + void _onIntervalHistoryChanged( + EvaporasiIntervalHistoryChanged event, + Emitter emit, + ) => emit(state.copyWith(intervalHistory_ms: event.value)); + + void _onIntervalBacaChanged( + EvaporasiIntervalBacaChanged event, + Emitter emit, + ) => emit(state.copyWith(intervalBaca_ms: event.value)); + + Future _onDmaxReset( + EvaporasiDmaxResetRequested event, + Emitter emit, + ) async { + emit(state.copyWith(isResettingDmax: true)); + try { + await FirebaseDatabase.instance.ref(_rtdbResetPath).set(true); + await Future.delayed(const Duration(seconds: 6)); + final snap = await FirebaseDatabase.instance.ref(_rtdbRealtimePath).get(); + final newDmax = snap.exists ? (snap.value as num?)?.toInt() ?? 0 : 0; + emit(state.copyWith(dmax: newDmax, isResettingDmax: false)); + } catch (e) { + emit(state.copyWith(isResettingDmax: false, errorMessage: e.toString())); + } + } + + Future _onSaved( + EvaporasiSettingsSaved event, + Emitter emit, + ) async { + if (state.thresholdRendah >= state.thresholdTinggi) { + emit(state.copyWith( + status: EvaporasiSettingsStatus.error, + errorMessage: 'Batas Rendah harus lebih kecil dari batas Tinggi.', + )); + return; + } + + emit(state.copyWith(status: EvaporasiSettingsStatus.saving)); + try { + await _ref.set({ + 'threshold_rendah': state.thresholdRendah, + 'threshold_tinggi': state.thresholdTinggi, + 'rumus_kalibrasi': state.rumusKalibrasi, + 'koreksi_offset': state.koreksiOffset, + 'interval_realtime_ms': state.intervalRealtime_ms, + 'interval_history_ms': state.intervalHistory_ms, + 'interval_baca_ms': state.intervalBaca_ms, + 'updated_at': ServerValue.timestamp, + }); + emit(state.copyWith(status: EvaporasiSettingsStatus.saved)); + await Future.delayed(const Duration(seconds: 2)); + emit(state.copyWith(status: EvaporasiSettingsStatus.loaded)); + } catch (e) { + emit(state.copyWith( + status: EvaporasiSettingsStatus.error, + errorMessage: e.toString(), + )); + } + } + + double _toDouble(dynamic v, double def) => v == null ? def : (v as num).toDouble(); + + int _toInt(dynamic v, int def) => v == null ? def : (v as num).toInt(); +} diff --git a/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_event.dart b/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_event.dart new file mode 100644 index 0000000..9f2b3ab --- /dev/null +++ b/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_event.dart @@ -0,0 +1,56 @@ +part of 'evaporasi_settings_bloc.dart'; + +abstract class EvaporasiSettingsEvent extends Equatable { + const EvaporasiSettingsEvent(); + @override + List get props => []; +} + +class EvaporasiSettingsStarted extends EvaporasiSettingsEvent {} + +class EvaporasiThresholdRendahChanged extends EvaporasiSettingsEvent { + final double value; + const EvaporasiThresholdRendahChanged(this.value); + @override List get props => [value]; +} + +class EvaporasiThresholdTinggiChanged extends EvaporasiSettingsEvent { + final double value; + const EvaporasiThresholdTinggiChanged(this.value); + @override List get props => [value]; +} + +class EvaporasiRumusKalibrasiChanged extends EvaporasiSettingsEvent { + final String rumus; + const EvaporasiRumusKalibrasiChanged(this.rumus); + @override List get props => [rumus]; +} + +class EvaporasiKoreksiOffsetChanged extends EvaporasiSettingsEvent { + final double value; + const EvaporasiKoreksiOffsetChanged(this.value); + @override List get props => [value]; +} + +// ── Interval baru ────────────────────────────────────────── +class EvaporasiIntervalRealtimeChanged extends EvaporasiSettingsEvent { + final int value; // ms + const EvaporasiIntervalRealtimeChanged(this.value); + @override List get props => [value]; +} + +class EvaporasiIntervalHistoryChanged extends EvaporasiSettingsEvent { + final int value; // ms + const EvaporasiIntervalHistoryChanged(this.value); + @override List get props => [value]; +} + +class EvaporasiIntervalBacaChanged extends EvaporasiSettingsEvent { + final int value; // ms + const EvaporasiIntervalBacaChanged(this.value); + @override List get props => [value]; +} + +class EvaporasiSettingsSaved extends EvaporasiSettingsEvent {} + +class EvaporasiDmaxResetRequested extends EvaporasiSettingsEvent {} \ No newline at end of file diff --git a/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_screen.dart b/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_screen.dart new file mode 100644 index 0000000..cab370e --- /dev/null +++ b/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_screen.dart @@ -0,0 +1,752 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'evaporasi_settings_bloc.dart'; + +class EvaporasiSettingsScreen extends StatefulWidget { + const EvaporasiSettingsScreen({super.key}); + + @override + State createState() => _EvaporasiSettingsScreenState(); +} + +class _EvaporasiSettingsScreenState extends State { + final _rendahController = TextEditingController(); + final _tinggiController = TextEditingController(); + final _offsetController = TextEditingController(); + + void _syncControllers(EvaporasiSettingsState s) { + final rendah = s.thresholdRendah.toStringAsFixed(1); + final tinggi = s.thresholdTinggi.toStringAsFixed(1); + final offset = s.koreksiOffset.toStringAsFixed(2); + if (_rendahController.text != rendah) { + _rendahController.text = rendah; + } + if (_tinggiController.text != tinggi) { + _tinggiController.text = tinggi; + } + if (_offsetController.text != offset) { + _offsetController.text = offset; + } + } + + void _showResetDmaxDialog(BuildContext context) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Row(children: [ + Icon(Icons.restart_alt_rounded, color: Colors.orange), + SizedBox(width: 8), + Text('Reset DMAX'), + ]), + content: const Text( + 'DMAX akan direset ke nilai default.\n\n' + 'ESP32 akan mencari nilai DMAX baru secara otomatis ' + 'pada pembacaan berikutnya.\n\n' + 'Lakukan ini hanya jika sensor diganti atau ' + 'kalibrasi ulang diperlukan.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text('Batal', style: TextStyle(color: Colors.grey.shade600)), + ), + ElevatedButton.icon( + onPressed: () { + Navigator.pop(ctx); + context.read().add(EvaporasiDmaxResetRequested()); + }, + icon: const Icon(Icons.restart_alt_rounded, size: 16), + label: const Text('Reset DMAX'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange.shade700, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ), + ], + ), + ); + } + + @override + void dispose() { + _rendahController.dispose(); + _tinggiController.dispose(); + _offsetController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => EvaporasiSettingsBloc()..add(EvaporasiSettingsStarted()), + child: BlocConsumer( + listener: (context, state) { + if (state.status == EvaporasiSettingsStatus.loaded || + state.status == EvaporasiSettingsStatus.saved) { + _syncControllers(state); + } + if (state.status == EvaporasiSettingsStatus.saved) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: const Row(children: [ + Icon(Icons.check_circle, color: Colors.white), + SizedBox(width: 10), + Text('Pengaturan berhasil disimpan ke Firebase.'), + ]), + backgroundColor: Colors.green.shade600, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + )); + } + if (state.status == EvaporasiSettingsStatus.error && + state.errorMessage != null) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Error: ${state.errorMessage}'), + backgroundColor: Colors.red.shade600, + behavior: SnackBarBehavior.floating, + )); + } + }, + builder: (context, state) { + final bloc = context.read(); + final isLoading = state.status == EvaporasiSettingsStatus.loading; + final isSaving = state.status == EvaporasiSettingsStatus.saving; + + return Scaffold( + backgroundColor: Colors.grey.shade100, + appBar: AppBar( + title: const Text( + 'Pengaturan Evaporasi', + style: TextStyle(fontWeight: FontWeight.bold), + ), + centerTitle: true, + backgroundColor: Colors.transparent, + foregroundColor: Colors.black, + elevation: 0, + ), + body: isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _InfoCard( + 'Pengaturan disimpan ke Firebase Firestore dan ' + 'langsung berlaku untuk semua perangkat.', + ), + const SizedBox(height: 24), + _sectionTitle('Batas Status Evaporasi'), + const SizedBox(height: 6), + Text( + 'Tentukan nilai (mm) untuk klasifikasi status ' + 'Rendah, Normal, dan Tinggi.', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + const SizedBox(height: 12), + _SettingsCard(children: [ + _ThresholdPreview(state: state), + const Divider(height: 28), + _NumericField( + controller: _rendahController, + label: 'Batas Rendah–Normal (mm)', + hint: 'Contoh: 20.0', + helper: 'Nilai < batas ini → Status Rendah. Default: 20.0', + onChanged: (v) { + final d = double.tryParse(v); + if (d != null && d >= 0) { + bloc.add(EvaporasiThresholdRendahChanged(d)); + } + }, + ), + const SizedBox(height: 16), + _NumericField( + controller: _tinggiController, + label: 'Batas Normal–Tinggi (mm)', + hint: 'Contoh: 30.0', + helper: 'Nilai ≥ batas ini → Status Tinggi. Default: 30.0', + onChanged: (v) { + final d = double.tryParse(v); + if (d != null && d >= 0) { + bloc.add(EvaporasiThresholdTinggiChanged(d)); + } + }, + ), + ]), + const SizedBox(height: 24), + _sectionTitle('Rumus Kalibrasi E'), + const SizedBox(height: 6), + Text( + 'Pilih metode penghitungan nilai evaporasi ' + 'terkalibrasi (E) dari data sensor.', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + const SizedBox(height: 12), + _SettingsCard(children: [ + _RumusSelector( + selected: state.rumusKalibrasi, + onChanged: (v) => bloc.add(EvaporasiRumusKalibrasiChanged(v)), + ), + const Divider(height: 28), + _FormulaPreview(state: state), + const Divider(height: 28), + _NumericField( + controller: _offsetController, + label: 'Koreksi Offset (mm)', + hint: 'Contoh: 0.0 atau -1.5', + helper: 'Nilai ditambahkan ke hasil E. Gunakan negatif untuk koreksi ke bawah.', + allowNegative: true, + onChanged: (v) { + final d = double.tryParse(v); + if (d != null) { + bloc.add(EvaporasiKoreksiOffsetChanged(d)); + } + }, + ), + ]), + const SizedBox(height: 24), + _sectionTitle('Interval Pengiriman & Pembacaan'), + const SizedBox(height: 6), + Text( + 'Atur seberapa sering ESP32 membaca sensor dan mengirim data. ' + 'Interval lebih pendek = data lebih real-time, baterai lebih boros.', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + const SizedBox(height: 12), + _SettingsCard(children: [ + _IntervalSelector( + label: 'Interval Baca Sensor', + helper: 'Seberapa sering sensor dibaca. Min: 5 detik.', + options: const { + 5000: '5 detik', + 10000: '10 detik (default)', + 30000: '30 detik', + 60000: '1 menit', + }, + selected: state.intervalBaca_ms, + onChanged: (v) => bloc.add(EvaporasiIntervalBacaChanged(v)), + ), + const Divider(height: 24), + _IntervalSelector( + label: 'Interval Kirim Realtime', + helper: 'Frekuensi update data real-time ke Firebase.', + options: const { + 60000: '1 menit', + 300000: '5 menit (default)', + 600000: '10 menit', + }, + selected: state.intervalRealtime_ms, + onChanged: (v) => bloc.add(EvaporasiIntervalRealtimeChanged(v)), + ), + const Divider(height: 24), + _IntervalSelector( + label: 'Interval Simpan History', + helper: 'Frekuensi pencatatan ke riwayat Firebase.', + options: const { + 300000: '5 menit', + 600000: '10 menit (default)', + 1800000: '30 menit', + 3600000: '1 jam', + }, + selected: state.intervalHistory_ms, + onChanged: (v) => bloc.add(EvaporasiIntervalHistoryChanged(v)), + ), + ]), + const SizedBox(height: 24), + _sectionTitle('Kalibrasi DMAX'), + const SizedBox(height: 6), + Text( + 'DMAX adalah nilai raw sensor saat panci penuh. ' + 'Diperbarui otomatis oleh ESP32. Reset hanya ' + 'jika sensor diganti atau kalibrasi ulang diperlukan.', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + const SizedBox(height: 12), + _SettingsCard(children: [ + _DmaxPanel( + dmax: state.dmax, + isResetting: state.isResettingDmax, + onReset: () => _showResetDmaxDialog(context), + ), + ]), + 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), + ], + ), + ), + ); + }, + ), + ); + } + + Widget _sectionTitle(String text) => Text( + text, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ); +} + +class _ThresholdPreview extends StatelessWidget { + final EvaporasiSettingsState state; + const _ThresholdPreview({required this.state}); + + @override + Widget build(BuildContext context) { + final rendah = state.thresholdRendah; + final tinggi = state.thresholdTinggi; + final isValid = rendah < tinggi; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Preview Klasifikasi', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.black54)), + const SizedBox(height: 10), + Row( + children: [ + _StatusChip('Rendah', '< ${rendah.toStringAsFixed(0)} mm', Colors.green), + const SizedBox(width: 8), + _StatusChip('Normal', '${rendah.toStringAsFixed(0)}–${tinggi.toStringAsFixed(0)} mm', Colors.orange), + const SizedBox(width: 8), + _StatusChip('Tinggi', '≥ ${tinggi.toStringAsFixed(0)} mm', Colors.red), + ], + ), + if (!isValid) ...[ + const SizedBox(height: 8), + Row(children: [ + Icon(Icons.warning_rounded, size: 14, color: Colors.red.shade600), + const SizedBox(width: 6), + Text('Batas Rendah harus < batas Tinggi', + style: TextStyle(fontSize: 11, color: Colors.red.shade600)), + ]), + ], + ], + ); + } +} + +class _StatusChip extends StatelessWidget { + final String label; + final String range; + final Color color; + const _StatusChip(this.label, this.range, this.color); + + @override + Widget build(BuildContext context) { + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 6), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Column( + children: [ + Text(label, + style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: color)), + const SizedBox(height: 2), + Text(range, style: TextStyle(fontSize: 9, color: color), textAlign: TextAlign.center), + ], + ), + ), + ); + } +} + +class _RumusSelector extends StatelessWidget { + final String selected; + final ValueChanged onChanged; + const _RumusSelector({required this.selected, required this.onChanged}); + + @override + Widget build(BuildContext context) { + const options = [ + ( + value: 'selisih_max', + label: 'Selisih Maksimum', + subtitle: 'E = max(H kemarin) − max(H hari ini)', + icon: Icons.trending_down_rounded, + ), + ( + value: 'rata_harian', + label: 'Rata-rata Harian', + subtitle: 'E = rata(H kemarin) − rata(H hari ini)', + icon: Icons.bar_chart_rounded, + ), + ]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Metode Kalkulasi', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.black54)), + const SizedBox(height: 10), + ...options.map((opt) { + final isSelected = opt.value == selected; + return GestureDetector( + onTap: () => onChanged(opt.value), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: isSelected ? Colors.blue.shade50 : Colors.grey.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected ? Colors.blue.shade400 : Colors.grey.shade200, + width: isSelected ? 1.5 : 1, + ), + ), + child: Row( + children: [ + Icon(opt.icon, color: isSelected ? Colors.blue.shade700 : Colors.grey.shade500, size: 22), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(opt.label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: isSelected ? Colors.blue.shade800 : Colors.black87)), + const SizedBox(height: 2), + Text(opt.subtitle, + style: TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: isSelected ? Colors.blue.shade600 : Colors.grey.shade500)), + ], + ), + ), + if (isSelected) + Icon(Icons.check_circle_rounded, color: Colors.blue.shade600, size: 20), + ], + ), + ), + ); + }), + ], + ); + } +} + +class _FormulaPreview extends StatelessWidget { + final EvaporasiSettingsState state; + const _FormulaPreview({required this.state}); + + @override + Widget build(BuildContext context) { + final rumus = state.rumusKalibrasi == 'selisih_max' + ? 'E = max(H₁) − max(H₂) + offset' + : 'E = avg(H₁) − avg(H₂) + offset'; + final offsetStr = state.koreksiOffset >= 0 + ? '+${state.koreksiOffset.toStringAsFixed(2)}' + : state.koreksiOffset.toStringAsFixed(2); + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.grey.shade900, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Icon(Icons.functions_rounded, size: 15, color: Colors.amber.shade300), + const SizedBox(width: 8), + Text('Preview Rumus', + style: TextStyle(fontSize: 12, color: Colors.amber.shade300, fontWeight: FontWeight.bold)), + ]), + const SizedBox(height: 10), + Text(rumus, + style: TextStyle(fontSize: 12, color: Colors.grey.shade300, fontFamily: 'monospace')), + const SizedBox(height: 6), + Text('offset = $offsetStr mm', + style: TextStyle( + fontSize: 12, + color: Colors.green.shade300, + fontFamily: 'monospace', + fontWeight: FontWeight.bold)), + ], + ), + ); + } +} + +class _InfoCard extends StatelessWidget { + final String text; + const _InfoCard(this.text); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.blue.shade100), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline_rounded, size: 18, color: Colors.blue.shade600), + const SizedBox(width: 10), + Expanded( + child: Text(text, style: TextStyle(fontSize: 12, color: Colors.blue.shade800)), + ), + ], + ), + ); + } +} + +class _SettingsCard extends StatelessWidget { + final List children; + const _SettingsCard({required this.children}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 6), + ], + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: children), + ); + } +} + +class _NumericField extends StatelessWidget { + final TextEditingController controller; + final String label; + final String hint; + final String helper; + final bool allowNegative; + final ValueChanged onChanged; + + const _NumericField({ + required this.controller, + required this.label, + required this.hint, + required this.helper, + required this.onChanged, + this.allowNegative = false, + }); + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true), + inputFormatters: [ + FilteringTextInputFormatter.allow( + allowNegative ? RegExp(r'^-?[0-9]*\.?[0-9]*') : RegExp(r'[0-9.]'), + ), + ], + decoration: InputDecoration( + labelText: label, + hintText: hint, + helperText: helper, + helperMaxLines: 2, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + isDense: true, + ), + onChanged: onChanged, + ); + } +} + +class _IntervalSelector extends StatelessWidget { + final String label; + final String helper; + final Map options; + final int selected; + final ValueChanged onChanged; + + const _IntervalSelector({ + required this.label, + required this.helper, + required this.options, + required this.selected, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + Text(helper, style: TextStyle(fontSize: 11, color: Colors.grey.shade500)), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: options.entries.map((e) { + final isSelected = e.key == selected; + return GestureDetector( + onTap: () => onChanged(e.key), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: isSelected ? Colors.blue.shade700 : Colors.grey.shade100, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isSelected ? Colors.blue.shade700 : Colors.grey.shade300, + ), + ), + child: Text( + e.value, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: isSelected ? Colors.white : Colors.black87, + ), + ), + ), + ); + }).toList(), + ), + ], + ); + } +} + +class _DmaxPanel extends StatelessWidget { + final int dmax; + final bool isResetting; + final VoidCallback onReset; + + const _DmaxPanel({ + required this.dmax, + required this.isResetting, + required this.onReset, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Nilai DMAX Saat Ini', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.black54), + ), + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey.shade900, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Icon(Icons.memory_rounded, size: 15, color: Colors.amber.shade300), + const SizedBox(width: 8), + Text('Raw Sensor Value', + style: TextStyle(fontSize: 12, color: Colors.amber.shade300, fontWeight: FontWeight.bold)), + ]), + const SizedBox(height: 10), + Text( + dmax == 0 ? '-- (belum terbaca)' : dmax.toString(), + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: dmax == 0 ? Colors.grey.shade500 : Colors.green.shade300, + fontFamily: 'monospace', + ), + ), + const SizedBox(height: 4), + Text( + 'Diperbarui otomatis oleh ESP32', + style: TextStyle(fontSize: 11, color: Colors.grey.shade500), + ), + ], + ), + ), + const SizedBox(height: 14), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.orange.shade50, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.orange.shade100), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline_rounded, size: 16, color: Colors.orange.shade700), + const SizedBox(width: 8), + Expanded( + child: Text( + 'DMAX = nilai ADC saat panci penuh (tinggi air maksimum). ' + 'Semakin besar DMAX, semakin akurat pembacaan tinggi air.', + style: TextStyle(fontSize: 11, color: Colors.orange.shade800), + ), + ), + ], + ), + ), + const SizedBox(height: 14), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: isResetting ? null : onReset, + icon: isResetting + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.orange.shade700), + ) + : const Icon(Icons.restart_alt_rounded), + label: Text(isResetting ? 'Mereset...' : 'Reset DMAX ke Default'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.orange.shade700, + side: BorderSide(color: Colors.orange.shade300), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + ], + ); + } +} diff --git a/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_state.dart b/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_state.dart new file mode 100644 index 0000000..0db41bb --- /dev/null +++ b/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_state.dart @@ -0,0 +1,70 @@ +part of 'evaporasi_settings_bloc.dart'; + +enum EvaporasiSettingsStatus { loading, loaded, saving, saved, error } + +class EvaporasiSettingsState extends Equatable { + final double thresholdRendah; + final double thresholdTinggi; + final String rumusKalibrasi; + final double koreksiOffset; + final int dmax; + final bool isResettingDmax; + + // Interval (milidetik) — dikirim ke ESP32 via RTDB + final int intervalRealtime_ms; // default 300000 = 5 menit + final int intervalHistory_ms; // default 600000 = 10 menit + final int intervalBaca_ms; // default 10000 = 10 detik + + final EvaporasiSettingsStatus status; + final String? errorMessage; + + const EvaporasiSettingsState({ + this.thresholdRendah = 2.0, + this.thresholdTinggi = 10.0, + this.rumusKalibrasi = 'selisih_max', + this.koreksiOffset = 0.0, + this.intervalRealtime_ms = 300000, + this.intervalHistory_ms = 600000, + this.intervalBaca_ms = 10000, + this.status = EvaporasiSettingsStatus.loading, + this.errorMessage, + this.dmax = 0, + this.isResettingDmax = false, + }); + + EvaporasiSettingsState copyWith({ + double? thresholdRendah, + double? thresholdTinggi, + String? rumusKalibrasi, + double? koreksiOffset, + int? intervalRealtime_ms, + int? intervalHistory_ms, + int? intervalBaca_ms, + EvaporasiSettingsStatus? status, + String? errorMessage, + int? dmax, + bool? isResettingDmax, + }) { + return EvaporasiSettingsState( + thresholdRendah: thresholdRendah ?? this.thresholdRendah, + thresholdTinggi: thresholdTinggi ?? this.thresholdTinggi, + rumusKalibrasi: rumusKalibrasi ?? this.rumusKalibrasi, + koreksiOffset: koreksiOffset ?? this.koreksiOffset, + intervalRealtime_ms: intervalRealtime_ms ?? this.intervalRealtime_ms, + intervalHistory_ms: intervalHistory_ms ?? this.intervalHistory_ms, + intervalBaca_ms: intervalBaca_ms ?? this.intervalBaca_ms, + status: status ?? this.status, + errorMessage: errorMessage ?? this.errorMessage, + dmax: dmax ?? this.dmax, + isResettingDmax: isResettingDmax ?? this.isResettingDmax, + ); + } + + @override + List get props => [ + thresholdRendah, thresholdTinggi, rumusKalibrasi, koreksiOffset, + intervalRealtime_ms, intervalHistory_ms, intervalBaca_ms, + status, errorMessage, + dmax, isResettingDmax, + ]; +} \ No newline at end of file diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart index 04a5b9b..4e0d5da 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart @@ -13,6 +13,46 @@ part 'evaporasi_event.dart'; part 'evaporasi_state.dart'; class EvaporasiBloc extends Bloc { + static List _buildDayList(DateTime start, DateTime end) { + final s = DateTime(start.year, start.month, start.day); + final e = DateTime(end.year, end.month, end.day); + final days = []; + DateTime cur = s; + while (!cur.isAfter(e)) { + days.add(cur); + cur = cur.add(const Duration(days: 1)); + } + return days; + } + + static List _buildLabels(List days) { + return days.map((d) { + if (days.length <= 14) { + return '${d.day} ${_bulan(d.month)}'; + } + return '${d.day}/${d.month}'; + }).toList(); + } + + static String _bulan(int m) { + const b = [ + '', + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'Mei', + 'Jun', + 'Jul', + 'Agu', + 'Sep', + 'Okt', + 'Nov', + 'Des' + ]; + return b[m]; + } + final MonitoringRepository _repository; final NotificationBloc _notificationBloc; StreamSubscription? _subscription; @@ -196,6 +236,7 @@ class EvaporasiBloc extends Bloc { List temps; List labels; + if (isSingle) { // 1 hari → per jam values = TimeSeriesMapper.toSpecificDate( @@ -212,26 +253,50 @@ class EvaporasiBloc extends Bloc { ); labels = List.generate(24, (i) => '${i.toString().padLeft(2, '0')}:00'); } else { - // Range → per hari - final evapResult = TimeSeriesMapper.toDateRange( - data: history, - getTime: (e) => e.timestamp, - getValue: (e) => e.evaporasi, - startDate: start, - endDate: end, - ); - final tempResult = TimeSeriesMapper.toDateRange( - data: history, - getTime: (e) => e.timestamp, - getValue: (e) => e.suhu, - startDate: start, - endDate: end, - ); - values = evapResult.values; - temps = tempResult.values; - labels = evapResult.labels; + // Range → per hari (tapi evaporasi dikalibrasi dengan rumus: + // E(hari ke-2) = max(evap hari ke-1) - max(evap hari ke-2)) + final days = _buildDayList(start, end); + + + final dailyMaxEvap = List.filled(days.length, 0.0); + final dailyMaxTemp = List.filled(days.length, 0.0); + final dailyHasValue = List.filled(days.length, false); + + + for (final item in history) { + final d = DateTime(item.timestamp.year, item.timestamp.month, item.timestamp.day); + final idx = days.indexWhere((x) => x == d); + if (idx < 0) continue; + + final evap = item.evaporasi; + final temp = item.suhu; + + if (!dailyHasValue[idx]) { + dailyHasValue[idx] = true; + dailyMaxEvap[idx] = evap; + dailyMaxTemp[idx] = temp; + } else { + if (evap > dailyMaxEvap[idx]) dailyMaxEvap[idx] = evap; + if (temp > dailyMaxTemp[idx]) dailyMaxTemp[idx] = temp; + } + } + + // Hitung E berbasis pasangan H1 (maks hari sebelumnya) - H2 (maks hari ini). + // Definisi sesuai permintaan: E untuk hari i = maxEvap(hari i-1) - maxEvap(hari i) + // Mulai tanggal 21 Mei s/d sekarang: untuk hari pertama di rentang, nilainya 0 + final calibrated = List.filled(days.length, 0.0); + for (int i = 1; i < days.length; i++) { + final e = dailyMaxEvap[i - 1] - dailyMaxEvap[i]; + calibrated[i] = e < 0 ? 0.0 : e; + } + + values = calibrated; + temps = dailyMaxTemp; + labels = _buildLabels(days); + } + emit(state.copyWith( startDate: start, endDate: end, @@ -275,11 +340,16 @@ class EvaporasiBloc extends Bloc { a.year == b.year && a.month == b.month && a.day == b.day; static (String, bool) _computeStatus(double v) { - if (v > 10.0) return ('Tinggi', true); - if (v >= 2.0) return ('Normal', false); + // Kategori status sesuai permintaan: + // - v < 20 => Rendah ("10 mm masih rendah") + // - 20 <= v < 30 => Normal/Sedang + // - v >= 30 => Tinggi + if (v >= 30.0) return ('Tinggi', true); + if (v >= 20.0) return ('Normal', false); return ('Rendah', false); } + void _emitAlert(String status, bool willRain, double value) { final AlertSeverity severity; final String message; diff --git a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart index 086551f..814577a 100644 --- a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart +++ b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart @@ -1,5 +1,6 @@ // lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart +import '../../device_setup/blocs/evaporasi_settings_screen.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; @@ -318,8 +319,19 @@ class _EvaporasiScreenState extends State { : () => _showExportDialog(context, state), ), ), - ], + IconButton( + tooltip: 'Pengaturan', + icon: const Icon(Icons.settings_outlined), + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const EvaporasiSettingsScreen(), ), + ), + ), +], + ), + body: BlocBuilder( builder: (context, state) { if (state.isLoading) { @@ -361,6 +373,71 @@ class _EvaporasiScreenState extends State { const EvaporasiRangeSelector(), const SizedBox(height: 12), + // ── Block nilai E (kalibrasi H1 - H2) ────────────────── + BlocBuilder( + builder: (context, s) { + if (s.chartValues.isEmpty) { + return const SizedBox.shrink(); + } + + // Untuk range > 1 hari, chartValues berisi E per hari + final isRange = !s.isSingleDay; + if (!isRange) { + return const SizedBox.shrink(); + } + + // Tampilkan E pada hari terakhir rentang (hari ke-N) + final eLast = s.chartValues.last; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.blue.shade100), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: const [ + Icon(Icons.calculate_rounded, + color: Colors.blue, size: 20), + SizedBox(width: 10), + Text( + 'Nilai Evaporasi Terkalibrasi (E)', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'E terakhir (hari terakhir rentang): ${eLast.toStringAsFixed(2)} mm', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.blue.shade700, + ), + ), + const SizedBox(height: 6), + Text( + 'Rumus: E = max(H1) − max(H2)', + style: TextStyle( + fontSize: 12, + color: Colors.blueGrey.shade700, + ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 20), + // ── Chart ─────────────────────────────────── BlocBuilder( builder: (context, s) => EvaporasiChartWidget( @@ -372,6 +449,7 @@ class _EvaporasiScreenState extends State { ), const SizedBox(height: 20), + // ── Riwayat Data ──────────────────────────── BlocBuilder( builder: (context, s) => EvaporasiHistoryList( @@ -519,6 +597,7 @@ class _EvaporasiScreenState extends State { ); } + Widget _statusCard(EvaporasiState state) { Color statusColor; IconData statusIcon; diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_history_list.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_history_list.dart index ed10a76..47dc4ef 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_history_list.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_history_list.dart @@ -198,7 +198,8 @@ class _DateGroup extends StatefulWidget { } class _DateGroupState extends State<_DateGroup> { - bool _expanded = true; + bool _expanded = false; + double get _avgEvap { if (widget.items.isEmpty) return 0;