From 80016bd1efaf8d5354b1baf40329c92dfaa9e0e5 Mon Sep 17 00:00:00 2001 From: kleponijo Date: Mon, 13 Jul 2026 13:44:43 +0700 Subject: [PATCH] Restore evaporasi module --- .../blocs/evaporasi_settings_bloc.dart | 219 +++++ .../blocs/evaporasi_settings_event.dart | 80 ++ .../blocs/evaporasi_settings_screen.dart | 798 ++++++++++++++++++ .../blocs/evaporasi_settings_state.dart | 112 +++ .../evaporasi/blocs/evaporasi_bloc.dart | 388 +++++++++ .../evaporasi/blocs/evaporasi_event.dart | 38 + .../evaporasi/blocs/evaporasi_state.dart | 103 +++ .../evaporasi/views/evaporasi_screen.dart | 667 +++++++++++++++ .../views/widgets/evaporasi_chart_widget.dart | 405 +++++++++ .../widgets/evaporasi_control_panel.dart | 227 +++++ .../widgets/evaporasi_date_search_bar.dart | 59 ++ .../views/widgets/evaporasi_history_list.dart | 477 +++++++++++ .../widgets/evaporasi_range_selector.dart | 206 +++++ .../utils/excel/evaporasi_excel_service.dart | 405 +++++++++ 14 files changed, 4184 insertions(+) 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 create mode 100644 lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart create mode 100644 lib/screens/monitoring/evaporasi/blocs/evaporasi_event.dart create mode 100644 lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart create mode 100644 lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart create mode 100644 lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart create mode 100644 lib/screens/monitoring/evaporasi/views/widgets/evaporasi_control_panel.dart create mode 100644 lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_search_bar.dart create mode 100644 lib/screens/monitoring/evaporasi/views/widgets/evaporasi_history_list.dart create mode 100644 lib/screens/monitoring/evaporasi/views/widgets/evaporasi_range_selector.dart create mode 100644 lib/screens/monitoring/shared/utils/excel/evaporasi_excel_service.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..e1bb8a5 --- /dev/null +++ b/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_bloc.dart @@ -0,0 +1,219 @@ +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(_onPumpStartChanged); + on(_onPumpEndChanged); + on(_onD0Changed); + on(_onDmaxManualChanged); + 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), pumpStartTime: (data['pump_start_time'] as String?) ?? '06:00', + pumpEndTime: (data['pump_end_time'] as String?) ?? '18:00', + d0: _toInt(data['d0'], 0), + dmaxManual: _toInt(data['dmax_manual'], 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 rtRef = FirebaseDatabase.instance.ref('Monitoring/realtime'); + final rtSnap = await rtRef.get(); + if (rtSnap.exists && rtSnap.value != null) { + final rt = Map.from(rtSnap.value as Map); + final val = _toInt(rt['dmax_saat_ini'], 0); + final firmware = (rt['firmware_version'] as String?) ?? '--'; + final wifi = rt['wifi_connected'] is bool ? (rt['wifi_connected'] as bool) : (rt['wifi_connected'] == 1); + final firebase = rt['firebase_connected'] is bool ? (rt['firebase_connected'] as bool) : (rt['firebase_connected'] == 1); + final activeD0 = _toInt(rt['d0_active'], state.d0); + final activeDmax = _toInt(rt['dmax_active'], val); + DateTime? lastUpd; + try { + final lu = rt['last_update']; + if (lu != null) { + final ms = (lu is num) ? lu.toInt() : int.tryParse(lu.toString()) ?? 0; + if (ms > 0) lastUpd = DateTime.fromMillisecondsSinceEpoch(ms); + } + } catch (_) {} + + emit(state.copyWith( + dmax: val, + firmwareVersion: firmware, + wifiConnected: wifi ?? false, + firebaseConnected: firebase ?? false, + activeD0: activeD0, + activeDmax: activeDmax, + lastUpdate: lastUpd, + )); + } else { + // fallback: try single path + final snap = await FirebaseDatabase.instance.ref(_rtdbRealtimePath).get(); + final val = snap.exists ? (snap.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)); + + void _onPumpStartChanged( + EvaporasiPumpStartChanged event, + Emitter emit, + ) => emit(state.copyWith(pumpStartTime: event.value)); + + void _onPumpEndChanged( + EvaporasiPumpEndChanged event, + Emitter emit, + ) => emit(state.copyWith(pumpEndTime: event.value)); + + void _onD0Changed( + EvaporasiD0Changed event, + Emitter emit, + ) => emit(state.copyWith(d0: event.value)); + + void _onDmaxManualChanged( + EvaporasiDmaxManualChanged event, + Emitter emit, + ) => emit(state.copyWith(dmaxManual: 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, + 'pump_start_time': state.pumpStartTime, + 'pump_end_time': state.pumpEndTime, + 'd0': state.d0, + 'dmax_manual': state.dmaxManual, + '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..8ee87d4 --- /dev/null +++ b/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_event.dart @@ -0,0 +1,80 @@ +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 EvaporasiPumpStartChanged extends EvaporasiSettingsEvent { + final String value; // HH:mm + const EvaporasiPumpStartChanged(this.value); + @override List get props => [value]; +} + +class EvaporasiPumpEndChanged extends EvaporasiSettingsEvent { + final String value; // HH:mm + const EvaporasiPumpEndChanged(this.value); + @override List get props => [value]; +} + +class EvaporasiD0Changed extends EvaporasiSettingsEvent { + final int value; + const EvaporasiD0Changed(this.value); + @override List get props => [value]; +} + +class EvaporasiDmaxManualChanged extends EvaporasiSettingsEvent { + final int value; + const EvaporasiDmaxManualChanged(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..cd48b1e --- /dev/null +++ b/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_screen.dart @@ -0,0 +1,798 @@ +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(); + final _pumpStartController = TextEditingController(); + final _pumpEndController = TextEditingController(); + final _d0Controller = TextEditingController(); + final _dmaxManualController = TextEditingController(); + + void _syncControllers(EvaporasiSettingsState s) { + final rendah = s.thresholdRendah.toStringAsFixed(1); + final tinggi = s.thresholdTinggi.toStringAsFixed(1); + final offset = s.koreksiOffset.toStringAsFixed(2); + final pumpStart = s.pumpStartTime; + final pumpEnd = s.pumpEndTime; + final d0 = s.d0 == 0 ? '' : s.d0.toString(); + final dmaxManual = s.dmaxManual == 0 ? '' : s.dmaxManual.toString(); + + if (_rendahController.text != rendah) { + _rendahController.text = rendah; + } + if (_tinggiController.text != tinggi) { + _tinggiController.text = tinggi; + } + if (_offsetController.text != offset) { + _offsetController.text = offset; + } + if (_pumpStartController.text != pumpStart) { + _pumpStartController.text = pumpStart; + } + if (_pumpEndController.text != pumpEnd) { + _pumpEndController.text = pumpEnd; + } + if (_d0Controller.text != d0) { + _d0Controller.text = d0; + } + if (_dmaxManualController.text != dmaxManual) { + _dmaxManualController.text = dmaxManual; + } + } + + Future _pickPumpTime(BuildContext context, TextEditingController controller, ValueChanged onSelected, String initialValue) async { + final parts = initialValue.split(':'); + final initial = TimeOfDay( + hour: int.tryParse(parts[0]) ?? 6, + minute: int.tryParse(parts[1]) ?? 0, + ); + final picked = await showTimePicker( + context: context, + initialTime: initial, + helpText: 'Pilih Jam Pompa', + ); + if (picked != null) { + final value = '${picked.hour.toString().padLeft(2, '0')}:${picked.minute.toString().padLeft(2, '0')}'; + controller.text = value; + onSelected(value); + } + } + + @override + void dispose() { + _rendahController.dispose(); + _tinggiController.dispose(); + _offsetController.dispose(); + _pumpStartController.dispose(); + _pumpEndController.dispose(); + _d0Controller.dispose(); + _dmaxManualController.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('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: 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 _TimePickerField extends StatelessWidget { + final TextEditingController controller; + final String label; + final String helper; + final VoidCallback onTap; + + const _TimePickerField({ + required this.controller, + required this.label, + required this.helper, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + readOnly: true, + decoration: InputDecoration( + labelText: label, + hintText: 'HH:mm', + helperText: helper, + helperMaxLines: 2, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + suffixIcon: const Icon(Icons.schedule_rounded), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + isDense: true, + ), + onTap: onTap, + ); + } +} + +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 _RealtimeInfo extends StatelessWidget { + final EvaporasiSettingsState state; + const _RealtimeInfo({required this.state}); + + String _fmtDate(DateTime? d) { + if (d == null) return '--'; + final l = d.toLocal(); + return '${l.day.toString().padLeft(2, '0')}/${l.month.toString().padLeft(2, '0')}/${l.year} ${l.hour.toString().padLeft(2, '0')}:${l.minute.toString().padLeft(2, '0')}:${l.second.toString().padLeft(2, '0')}'; + } + + @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.devices_rounded, size: 15, color: Colors.blue.shade700), + const SizedBox(width: 8), + Text('Informasi Perangkat', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.blue.shade700)), + ]), + const SizedBox(height: 10), + Wrap(spacing: 12, runSpacing: 8, children: [ + _InfoTile(label: 'Firmware', value: state.firmwareVersion), + _InfoTile(label: 'WiFi', value: state.wifiConnected ? 'Terhubung' : 'Tidak'), + _InfoTile(label: 'Firebase', value: state.firebaseConnected ? 'Terhubung' : 'Tidak'), + _InfoTile(label: 'D0 aktif', value: state.activeD0 == 0 ? '--' : state.activeD0.toString()), + _InfoTile(label: 'DMAX aktif', value: state.activeDmax == 0 ? '--' : state.activeDmax.toString()), + _InfoTile(label: 'Terakhir', value: _fmtDate(state.lastUpdate)), + ]) + ]), + ); + } +} + +class _InfoTile extends StatelessWidget { + final String label; + final String value; + const _InfoTile({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade100), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(label, style: TextStyle(fontSize: 11, color: Colors.grey.shade600)), + const SizedBox(height: 6), + Text(value, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold)), + ]), + ); + } +} diff --git a/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_state.dart b/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_state.dart new file mode 100644 index 0000000..c8d735b --- /dev/null +++ b/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_state.dart @@ -0,0 +1,112 @@ +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 String pumpStartTime; + final String pumpEndTime; + final int d0; + final int dmaxManual; + final int dmax; + final bool isResettingDmax; + final String firmwareVersion; + final bool wifiConnected; + final bool firebaseConnected; + final int activeD0; + final int activeDmax; + final DateTime? lastUpdate; + + // 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.pumpStartTime = '06:00', + this.pumpEndTime = '18:00', + this.d0 = 0, + this.dmaxManual = 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, + this.firmwareVersion = '--', + this.wifiConnected = false, + this.firebaseConnected = false, + this.activeD0 = 0, + this.activeDmax = 0, + this.lastUpdate, + }); + + EvaporasiSettingsState copyWith({ + double? thresholdRendah, + double? thresholdTinggi, + String? rumusKalibrasi, + double? koreksiOffset, + String? pumpStartTime, + String? pumpEndTime, + int? d0, + int? dmaxManual, + int? intervalRealtime_ms, + int? intervalHistory_ms, + int? intervalBaca_ms, + EvaporasiSettingsStatus? status, + String? errorMessage, + int? dmax, + bool? isResettingDmax, + String? firmwareVersion, + bool? wifiConnected, + bool? firebaseConnected, + int? activeD0, + int? activeDmax, + DateTime? lastUpdate, + }) { + return EvaporasiSettingsState( + thresholdRendah: thresholdRendah ?? this.thresholdRendah, + thresholdTinggi: thresholdTinggi ?? this.thresholdTinggi, + rumusKalibrasi: rumusKalibrasi ?? this.rumusKalibrasi, + koreksiOffset: koreksiOffset ?? this.koreksiOffset, + pumpStartTime: pumpStartTime ?? this.pumpStartTime, + pumpEndTime: pumpEndTime ?? this.pumpEndTime, + d0: d0 ?? this.d0, + dmaxManual: dmaxManual ?? this.dmaxManual, + firmwareVersion: firmwareVersion ?? this.firmwareVersion, + wifiConnected: wifiConnected ?? this.wifiConnected, + firebaseConnected: firebaseConnected ?? this.firebaseConnected, + activeD0: activeD0 ?? this.activeD0, + activeDmax: activeDmax ?? this.activeDmax, + lastUpdate: lastUpdate ?? this.lastUpdate, + 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, + pumpStartTime, pumpEndTime, d0, dmaxManual, + firmwareVersion, wifiConnected, firebaseConnected, activeD0, activeDmax, lastUpdate, + 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 new file mode 100644 index 0000000..4e0d5da --- /dev/null +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart @@ -0,0 +1,388 @@ +// lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart + +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:monitoring_repository/monitoring_repository.dart'; + +import '../../../../blocs/notification_bloc/notification_bloc.dart'; +import '../../../../core/utils/time_series_mapper.dart'; + +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; + + EvaporasiBloc({ + required MonitoringRepository repository, + required NotificationBloc notificationBloc, + }) : _repository = repository, + _notificationBloc = notificationBloc, + super(EvaporasiState()) { + on(_onStarted); + on<_EvaporasiRealtimeUpdated>(_onRealtimeUpdated); + on(_onDateRangeChanged); + on(_onDateFilterChanged); + } + + // ════════════════════════════════════════════════════════════ + // START + // ════════════════════════════════════════════════════════════ + Future _onStarted( + WatchEvaporasiStarted event, + Emitter emit, + ) async { + emit(state.copyWith(isLoading: true)); + + final history = List.from( + await _repository.getSensorHistory( + 'Monitoring/History', + (json) => Evaporasi.fromJson(json), + ), + )..sort((a, b) => a.timestamp.compareTo(b.timestamp)); + final now = DateTime.now(); + + // Default: tampilkan hari ini (per jam) + final dailyEvap = TimeSeriesMapper.toDaily( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.evaporasi, + ); + final dailyTemp = TimeSeriesMapper.toDaily( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.suhu, + ); + final labels = List.generate( + 24, (i) => '${i.toString().padLeft(2, '0')}:00'); + + final lastValue = history.isNotEmpty ? history.last.evaporasi : 0.0; + final lastWater = history.isNotEmpty ? history.last.tinggiAir : 0.0; + final lastTemp = history.isNotEmpty ? history.last.suhu : 0.0; + + final (status, willRain) = _computeStatus(lastValue); + _emitAlert(status, willRain, lastValue); + + emit(state.copyWith( + history: history, + filteredHistory: history, + currentValue: lastValue, + waterLevel: lastWater, + temperature: lastTemp, + startDate: DateTime(now.year, now.month, now.day), + endDate: DateTime(now.year, now.month, now.day), + chartValues: dailyEvap, + chartTemperatures: dailyTemp, + chartLabels: labels, + weatherStatus: status, + willRain: willRain, + currentData: history.isNotEmpty ? history.last : null, + isLoading: false, + )); + + await _subscription?.cancel(); + _subscription = _repository + .getSensorStream( + 'Monitoring/realtime', + (json) { + return Evaporasi.fromJson(json); + }, + ) + .listen((data) => add(_EvaporasiRealtimeUpdated(data))); + } + + // ════════════════════════════════════════════════════════════ + // REALTIME UPDATE + // ════════════════════════════════════════════════════════════ + void _onRealtimeUpdated( + _EvaporasiRealtimeUpdated event, + Emitter emit, + ) { + final updatedHistory = List.from(state.history); + final dupIdx = updatedHistory.indexWhere( + (e) => e.timestamp.toUtc() == event.data.timestamp.toUtc(), + ); + if (dupIdx >= 0) { + updatedHistory[dupIdx] = event.data; + } else { + updatedHistory.add(event.data); + } + updatedHistory.sort((a, b) => a.timestamp.compareTo(b.timestamp)); + + // Regenerasikan data chart secara dinamis agar selalu sinkron dengan data real-time + List updatedChart; + List updatedTemp; + List updatedLabels = state.chartLabels; + + final isSingle = _isSameDay(state.startDate, state.endDate); + + if (isSingle) { + updatedChart = TimeSeriesMapper.toSpecificDate( + data: updatedHistory, + getTime: (e) => e.timestamp, + getValue: (e) => e.evaporasi, + targetDate: state.startDate, + ); + updatedTemp = TimeSeriesMapper.toSpecificDate( + data: updatedHistory, + getTime: (e) => e.timestamp, + getValue: (e) => e.suhu, + targetDate: state.startDate, + ); + updatedLabels = List.generate(24, (i) => '${i.toString().padLeft(2, '0')}:00'); + } else { + final evapResult = TimeSeriesMapper.toDateRange( + data: updatedHistory, + getTime: (e) => e.timestamp, + getValue: (e) => e.evaporasi, + startDate: state.startDate, + endDate: state.endDate, + ); + final tempResult = TimeSeriesMapper.toDateRange( + data: updatedHistory, + getTime: (e) => e.timestamp, + getValue: (e) => e.suhu, + startDate: state.startDate, + endDate: state.endDate, + ); + updatedChart = evapResult.values; + updatedTemp = tempResult.values; + updatedLabels = evapResult.labels; + } + + final (status, willRain) = _computeStatus(event.data.evaporasi); + _emitAlert(status, willRain, event.data.evaporasi); + + emit(state.copyWith( + history: updatedHistory, + filteredHistory: state.selectedDateFilter != null + ? updatedHistory.where((e) => + e.timestamp.year == state.selectedDateFilter!.year && + e.timestamp.month == state.selectedDateFilter!.month && + e.timestamp.day == state.selectedDateFilter!.day).toList() + : updatedHistory, + currentValue: event.data.evaporasi, + temperature: event.data.suhu, + waterLevel: event.data.tinggiAir, + chartValues: updatedChart, + chartTemperatures: updatedTemp, + chartLabels: updatedLabels, + weatherStatus: status, + willRain: willRain, + currentData: event.data, + )); + } + + // ════════════════════════════════════════════════════════════ + // DATE RANGE CHANGED + // ════════════════════════════════════════════════════════════ + Future _onDateRangeChanged( + EvaporasiDateRangeChanged event, + Emitter emit, + ) async { + emit(state.copyWith(isLoading: true)); + + final history = state.history; + final start = event.startDate; + final end = event.endDate; + + final isSingle = _isSameDay(start, end); + + List values; + List temps; + List labels; + + + if (isSingle) { + // 1 hari → per jam + values = TimeSeriesMapper.toSpecificDate( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.evaporasi, + targetDate: start, + ); + temps = TimeSeriesMapper.toSpecificDate( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.suhu, + targetDate: start, + ); + labels = List.generate(24, (i) => '${i.toString().padLeft(2, '0')}:00'); + } else { + // 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, + chartValues: values, + chartTemperatures: temps, + chartLabels: labels, + isLoading: false, + )); + } + + // ════════════════════════════════════════════════════════════ + // DATE FILTER (LIST) + // ════════════════════════════════════════════════════════════ + void _onDateFilterChanged( + EvaporasiDateFilterChanged event, + Emitter emit, + ) { + final date = event.date; + if (date == null) { + emit(state.copyWith( + filteredHistory: state.history, + clearSelectedDateFilter: true, + )); + return; + } + final filtered = state.history.where((item) => + item.timestamp.year == date.year && + item.timestamp.month == date.month && + item.timestamp.day == date.day).toList(); + + emit(state.copyWith( + filteredHistory: filtered, + selectedDateFilter: date, + )); + } + + // ════════════════════════════════════════════════════════════ + // HELPERS + // ════════════════════════════════════════════════════════════ + static bool _isSameDay(DateTime a, DateTime b) => + a.year == b.year && a.month == b.month && a.day == b.day; + + static (String, bool) _computeStatus(double v) { + // Kategori status sesuai permintaan: + // - v < 20 => Rendah ("10 mm masih rendah") + // - 20 <= v < 30 => Normal/Sedang + // - v >= 30 => Tinggi + if (v >= 30.0) return ('Tinggi', true); + if (v >= 20.0) return ('Normal', false); + return ('Rendah', false); + } + + + void _emitAlert(String status, bool willRain, double value) { + final AlertSeverity severity; + final String message; + if (status == 'Tinggi') { + severity = AlertSeverity.danger; + message = 'Evaporasi ${value.toStringAsFixed(1)} mm — TINGGI'; + } else if (status == 'Normal') { + severity = AlertSeverity.warning; + message = 'Evaporasi ${value.toStringAsFixed(1)} mm — Normal'; + } else { + severity = AlertSeverity.info; + message = ''; + } + _notificationBloc.add(SensorAlertAdded(SensorAlert( + sensorId: 'evaporasi', + sensorName: 'Evaporasi', + message: message, + severity: severity, + timestamp: DateTime.now(), + ))); + } + + @override + Future close() async { + await _subscription?.cancel(); + return super.close(); + } +} + +class _EvaporasiRealtimeUpdated extends EvaporasiEvent { + final Evaporasi data; + const _EvaporasiRealtimeUpdated(this.data); + + @override + List get props => [data]; +} \ No newline at end of file diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_event.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_event.dart new file mode 100644 index 0000000..ad26b3f --- /dev/null +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_event.dart @@ -0,0 +1,38 @@ +// lib/screens/monitoring/evaporasi/blocs/evaporasi_event.dart +part of 'evaporasi_bloc.dart'; + +abstract class EvaporasiEvent extends Equatable { + const EvaporasiEvent(); + + @override + List get props => []; +} + +/// Mulai monitoring +class WatchEvaporasiStarted extends EvaporasiEvent {} + +/// Pilih rentang tanggal untuk grafik +/// startDate == endDate → tampilkan per jam (1 hari) +/// startDate != endDate → tampilkan per hari (range) +class EvaporasiDateRangeChanged extends EvaporasiEvent { + final DateTime startDate; + final DateTime endDate; + + const EvaporasiDateRangeChanged({ + required this.startDate, + required this.endDate, + }); + + @override + List get props => [startDate, endDate]; +} + +/// Filter list history berdasarkan tanggal +/// Kirim date = null untuk reset +class EvaporasiDateFilterChanged extends EvaporasiEvent { + final DateTime? date; + const EvaporasiDateFilterChanged(this.date); + + @override + List get props => [date]; +} \ No newline at end of file diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart new file mode 100644 index 0000000..5a3486d --- /dev/null +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart @@ -0,0 +1,103 @@ +// lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart +part of 'evaporasi_bloc.dart'; + +class EvaporasiState extends Equatable { + final double currentValue; + final double temperature; + final double waterLevel; + + // Rentang tanggal aktif untuk grafik + final DateTime startDate; + final DateTime endDate; + + // Data grafik + final List chartValues; + final List chartTemperatures; + final List chartLabels; + + // Data list + final List history; + final List filteredHistory; + + // Filter list + final DateTime? selectedDateFilter; + + final String weatherStatus; + final bool willRain; + final Evaporasi? currentData; + final bool isLoading; + + EvaporasiState({ + this.currentValue = 0.0, + this.temperature = 0.0, + this.waterLevel = 0.0, + DateTime? startDate, + DateTime? endDate, + this.chartValues = const [], + this.chartTemperatures = const [], + this.chartLabels = const [], + this.history = const [], + this.filteredHistory = const [], + this.selectedDateFilter, + this.weatherStatus = 'Rendah', + this.willRain = false, + this.currentData, + this.isLoading = true, + }) : startDate = startDate ?? DateTime.now(), + endDate = endDate ?? DateTime.now(); + + EvaporasiState copyWith({ + double? currentValue, + double? temperature, + double? waterLevel, + DateTime? startDate, + DateTime? endDate, + List? chartValues, + List? chartTemperatures, + List? chartLabels, + List? history, + List? filteredHistory, + DateTime? selectedDateFilter, + bool clearSelectedDateFilter = false, + String? weatherStatus, + bool? willRain, + Evaporasi? currentData, + bool? isLoading, + }) { + return EvaporasiState( + currentValue: currentValue ?? this.currentValue, + temperature: temperature ?? this.temperature, + waterLevel: waterLevel ?? this.waterLevel, + startDate: startDate ?? this.startDate, + endDate: endDate ?? this.endDate, + chartValues: chartValues ?? this.chartValues, + chartTemperatures: chartTemperatures ?? this.chartTemperatures, + chartLabels: chartLabels ?? this.chartLabels, + history: history ?? this.history, + filteredHistory: filteredHistory ?? this.filteredHistory, + selectedDateFilter: clearSelectedDateFilter + ? null + : (selectedDateFilter ?? this.selectedDateFilter), + weatherStatus: weatherStatus ?? this.weatherStatus, + willRain: willRain ?? this.willRain, + currentData: currentData ?? this.currentData, + isLoading: isLoading ?? this.isLoading, + ); + } + + // 1 hari = tampil per jam, > 1 hari = tampil per hari + bool get isSingleDay { + final s = DateTime(startDate.year, startDate.month, startDate.day); + final e = DateTime(endDate.year, endDate.month, endDate.day); + return s == e; + } + + @override + List get props => [ + currentValue, temperature, waterLevel, + startDate, endDate, + chartValues, chartTemperatures, chartLabels, + history, filteredHistory, selectedDateFilter, + weatherStatus, willRain, currentData, isLoading, + ]; +} \ No newline at end of file diff --git a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart new file mode 100644 index 0000000..c405d5d --- /dev/null +++ b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart @@ -0,0 +1,667 @@ +// 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'; + +import '../blocs/evaporasi_bloc.dart'; +import '../../shared/utils/pdf/pdf_export_service.dart'; +import '../../shared/widgets/export_pdf_button.dart'; +import 'widgets/evaporasi_chart_widget.dart'; +import 'widgets/evaporasi_control_panel.dart'; +import 'widgets/evaporasi_range_selector.dart'; +import 'widgets/evaporasi_history_list.dart'; +import '../../shared/utils/excel/evaporasi_excel_service.dart'; + +class EvaporasiScreen extends StatefulWidget { + const EvaporasiScreen({super.key}); + + @override + State createState() => _EvaporasiScreenState(); +} + +class _EvaporasiScreenState extends State { +// ── Dialog export: nama file + date range ─────────────────── + Future _showExportDialog( + BuildContext context, EvaporasiState state) async { + DateTime firstDate = DateTime.now().subtract(const Duration(days: 365)); + DateTime lastDate = DateTime.now(); + if (state.history.isNotEmpty) { + final sorted = [...state.history] + ..sort((a, b) => a.timestamp.compareTo(b.timestamp)); + firstDate = DateTime(sorted.first.timestamp.year, + sorted.first.timestamp.month, sorted.first.timestamp.day); + lastDate = DateTime(sorted.last.timestamp.year, + sorted.last.timestamp.month, sorted.last.timestamp.day); + } + + final nameController = TextEditingController( + text: 'evaporasi_${DateFormat('ddMMyyyy').format(DateTime.now())}', + ); + DateTime? dateFrom; + DateTime? dateTo; + + await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setDialogState) { + final fmt = DateFormat('dd MMM yyyy', 'id_ID'); + + Future pickRange() async { + final range = await showDateRangePicker( + context: ctx, + firstDate: firstDate, + lastDate: lastDate, + initialDateRange: dateFrom != null && dateTo != null + ? DateTimeRange(start: dateFrom!, end: dateTo!) + : null, + locale: const Locale('id', 'ID'), + builder: (ctx, child) => Theme( + data: Theme.of(ctx).copyWith( + colorScheme: ColorScheme.light( + primary: Colors.blue.shade700, + onPrimary: Colors.white, + surface: Colors.white, + ), + ), + child: child!, + ), + ); + if (range != null) { + setDialogState(() { + dateFrom = range.start; + dateTo = range.end; + }); + } + } + + return AlertDialog( + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Row( + children: [ + Icon(Icons.file_download_outlined, color: Colors.blue.shade700), + const SizedBox(width: 10), + const Text('Export Excel', + style: + TextStyle(fontSize: 17, fontWeight: FontWeight.bold)), + ], + ), + content: SizedBox( + width: 340, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Nama file ────────────────────────────── + const Text('Nama file', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.black54)), + const SizedBox(height: 6), + TextField( + controller: nameController, + decoration: InputDecoration( + hintText: 'nama_file', + suffixText: '.xlsx', + filled: true, + fillColor: Colors.grey.shade100, + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: + BorderSide(color: Colors.blue.shade600, width: 1.5), + ), + ), + ), + const SizedBox(height: 20), + + // ── Range tanggal ────────────────────────── + const Text('Filter rentang tanggal', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.black54)), + const SizedBox(height: 6), + InkWell( + onTap: pickRange, + borderRadius: BorderRadius.circular(10), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.grey.shade300), + ), + child: Row( + children: [ + Icon(Icons.date_range_rounded, + size: 18, color: Colors.blue.shade600), + const SizedBox(width: 10), + Expanded( + child: Text( + dateFrom != null && dateTo != null + ? '${fmt.format(dateFrom!)} → ${fmt.format(dateTo!)}' + : 'Semua data (tanpa filter)', + style: TextStyle( + fontSize: 13, + color: dateFrom != null + ? Colors.black87 + : Colors.grey.shade500, + ), + ), + ), + if (dateFrom != null) + GestureDetector( + onTap: () => setDialogState(() { + dateFrom = null; + dateTo = null; + }), + child: Icon(Icons.close_rounded, + size: 16, color: Colors.grey.shade500), + ), + ], + ), + ), + ), + if (dateFrom != null) ...[ + const SizedBox(height: 6), + Text( + '${state.history.where((e) { + final d = DateTime(e.timestamp.year, e.timestamp.month, + e.timestamp.day); + final from = DateTime( + dateFrom!.year, dateFrom!.month, dateFrom!.day); + final to = + DateTime(dateTo!.year, dateTo!.month, dateTo!.day); + return !d.isBefore(from) && !d.isAfter(to); + }).length} data dalam rentang ini', + style: + TextStyle(fontSize: 11, color: Colors.blue.shade600), + ), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text('Batal', + style: TextStyle(color: Colors.grey.shade600)), + ), + ElevatedButton.icon( + onPressed: () { + Navigator.pop(ctx); + _doExport( + context, + state, + nameController.text.trim(), + dateFrom, + dateTo, + ); + }, + icon: const Icon(Icons.download_rounded, size: 16), + label: const Text('Export'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue.shade700, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + ), + ), + ], + ); + }, + ), + ); + + nameController.dispose(); + } + +// ── Proses export setelah dialog ditutup ──────────────────── + Future _doExport( + BuildContext context, + EvaporasiState state, + String customName, + DateTime? dateFrom, + DateTime? dateTo, + ) async { + final messenger = ScaffoldMessenger.of(context); + final fileName = customName.isEmpty + ? 'evaporasi_${DateFormat('ddMMyyyy').format(DateTime.now())}' + : customName; + + try { + messenger.showSnackBar( + const SnackBar( + content: Row(children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)), + SizedBox(width: 12), + Text('Membuat file Excel...'), + ]), + duration: Duration(seconds: 30), + behavior: SnackBarBehavior.floating, + ), + ); + + await EvaporasiExcelService.export( + currentValue: state.currentValue, + temperature: state.temperature, + waterLevel: state.waterLevel, + acuanPagi: state.currentData?.acuanPagi ?? 0.0, + weatherStatus: state.weatherStatus, + history: state.history, + fileName: fileName, + dateFrom: dateFrom, + dateTo: dateTo, + ); + + messenger.hideCurrentSnackBar(); + messenger.showSnackBar(SnackBar( + content: Row(children: [ + const Icon(Icons.check_circle, color: Colors.white), + const SizedBox(width: 8), + Expanded(child: Text('$fileName.xlsx berhasil dibuat!')), + ]), + backgroundColor: Colors.green.shade600, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + duration: const Duration(seconds: 3), + )); + } catch (e) { + messenger.hideCurrentSnackBar(); + messenger.showSnackBar(SnackBar( + content: Text('Gagal: $e'), + backgroundColor: Colors.red.shade600, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + )); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.grey.shade100, + appBar: AppBar( + title: const Text( + 'Evaporasi', + style: TextStyle(fontWeight: FontWeight.bold), + ), + centerTitle: true, + backgroundColor: Colors.transparent, + elevation: 0, + foregroundColor: Colors.black, + actions: [ + BlocBuilder( + builder: (context, state) => IconButton( + tooltip: 'Export Excel', + icon: const Icon(Icons.file_download_outlined), + onPressed: state.history.isEmpty + ? null + : () => _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) { + return const Center(child: CircularProgressIndicator()); + } + + final historyMaps = state.history + .map((e) => { + 'timestamp': e.timestamp, + 'evaporasi': e.evaporasi, + 'suhu': e.suhu, + 'tinggiAir': e.tinggiAir, + }) + .toList(); + + return SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Main Card ─────────────────────────────── + _mainCard(state), + const SizedBox(height: 20), + + // ── Info Row ──────────────────────────────── + _infoRow(state), + const SizedBox(height: 20), + + // ── Status Card ───────────────────────────── + _statusCard(state), + const SizedBox(height: 20), + + // ── Kontrol Database Evaporasi ─────────────── + const EvaporasiControlPanel(), + const SizedBox(height: 20), + + // ── Range Selector ────────────────────────── + const Text( + 'Tren Evaporasi & Suhu', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 10), + 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( + dailyValues: s.chartValues, + dailyTemperatures: s.chartTemperatures, + period: s.isSingleDay ? 'Hari Ini' : 'Range', + chartLabels: s.chartLabels, + ), + ), + const SizedBox(height: 20), + + // ── Riwayat Data ──────────────────────────── + BlocBuilder( + builder: (context, s) => EvaporasiHistoryList( + history: s.filteredHistory, + selectedDate: s.selectedDateFilter, + onPickDate: () async { + final picked = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2024), + lastDate: DateTime.now(), + locale: const Locale('id', 'ID'), + ); + if (picked != null && context.mounted) { + context.read().add( + EvaporasiDateFilterChanged(picked), + ); + } + }, + onClearDate: () { + context.read().add( + const EvaporasiDateFilterChanged(null), + ); + }, + ), + ), + const SizedBox(height: 10), + + // ── Export PDF ────────────────────────────── + ExportPdfButton( + onExport: () => PdfExportService.evaporasi( + evaporasi: state.currentValue, + suhu: state.temperature, + tinggiAir: state.waterLevel, + timestamp: DateTime.now(), + historyData: historyMaps.isNotEmpty ? historyMaps : null, + ), + ), + const SizedBox(height: 20), + ], + ), + ); + }, + ), + ); + } + + Widget _mainCard(EvaporasiState state) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 40), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [Colors.blue.shade400, Colors.blue.shade800], + ), + borderRadius: BorderRadius.circular(25), + ), + child: Column( + children: [ + const Icon(Icons.water_drop, color: Colors.white, size: 45), + const SizedBox(height: 10), + Text( + state.currentValue.toStringAsFixed(2), + style: const TextStyle( + fontSize: 70, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const Text( + 'mm', + style: TextStyle(color: Colors.white70, fontSize: 18), + ), + ], + ), + ); + } + + Widget _infoRow(EvaporasiState state) { + return LayoutBuilder( + builder: (context, constraints) { + final cardWidth = (constraints.maxWidth - 12) / 2; + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _miniCard( + cardWidth, + 'Suhu Air', + state.temperature < 0 + ? '- °C' + : '${state.temperature.toStringAsFixed(1)} °C', + Icons.thermostat, + Colors.orange, + ), + _miniCard( + cardWidth, + 'Tinggi Air', + '${state.waterLevel.toStringAsFixed(1)} cm', + Icons.water, + Colors.blue, + ), + ], + ); + }, + ); + } + + Widget _miniCard( + double width, String title, String value, IconData icon, Color color) { + return Container( + width: width, + padding: const EdgeInsets.all(15), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + children: [ + Icon(icon, color: color), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: const TextStyle(fontSize: 11, color: Colors.grey), + overflow: TextOverflow.ellipsis, + ), + Text( + value, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ); + } + + Widget _statusCard(EvaporasiState state) { + Color statusColor; + IconData statusIcon; + String warningText; + + switch (state.weatherStatus) { + case 'Normal': + statusColor = Colors.orange; + statusIcon = Icons.warning_amber_rounded; + warningText = 'Sedang — evaporasi dalam batas normal, pantau kondisi.'; + break; + case 'Tinggi': + statusColor = Colors.red; + statusIcon = Icons.error_outline; + warningText = + 'Tinggi — evaporasi signifikan, berpotensi memengaruhi kondisi lingkungan.'; + break; + case 'Rendah': + default: + statusColor = Colors.green; + statusIcon = Icons.check_circle_outline; + warningText = 'Rendah — evaporasi stabil, risiko dampak rendah.'; + break; + } + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + children: [ + Icon(statusIcon, color: statusColor, size: 40), + const SizedBox(width: 15), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Status Evaporasi', + style: TextStyle(fontSize: 12, color: Colors.grey)), + const SizedBox(height: 4), + Text( + state.weatherStatus, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: statusColor), + ), + const SizedBox(height: 4), + Text( + warningText, + style: TextStyle( + fontSize: 12, + color: statusColor.withValues(alpha: 0.8), + fontWeight: FontWeight.w500), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart new file mode 100644 index 0000000..7c06562 --- /dev/null +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart @@ -0,0 +1,405 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; + +class EvaporasiChartWidget extends StatelessWidget { + final List dailyValues; + final List dailyTemperatures; + final String period; + final List chartLabels; + + const EvaporasiChartWidget({ + super.key, + required this.dailyValues, + required this.dailyTemperatures, + required this.period, + required this.chartLabels, + }); + + double _safeValue(double value) { + if (value.isNaN || value.isInfinite) return 0.0; + + // anti spike + if (value > 1000 || value < -1000) return 0.0; + + return value < 0 ? 0.0 : value; + } + + double _tempToEvapScale({ + required double temp, + required double evapMin, + required double evapMax, + required double tempMin, + required double tempMax, + }) { + final tempRange = (tempMax - tempMin); + if (tempRange.abs() < 1e-9) return evapMin; + + final normalized = (temp - tempMin) / tempRange; + return evapMin + normalized * (evapMax - evapMin); + } + + double _evapScaleToTemp({ + required double yEvap, + required double evapMin, + required double evapMax, + required double tempMin, + required double tempMax, + }) { + final evapRange = (evapMax - evapMin); + if (evapRange.abs() < 1e-9) return tempMin; + + final normalized = (yEvap - evapMin) / evapRange; + return tempMin + normalized * (tempMax - tempMin); + } + + double _xLabelInterval() { + if (period == 'Minggu Ini') return 1; // 7 label + if (period == 'Bulan Ini') return 5; // ~31 label tiap 5 hari + return 3; // 24 jam tiap 3 jam + } + + String _getBottomLabel(int index) { + if (chartLabels.isEmpty || index < 0 || index >= chartLabels.length) { + return ''; + } + return chartLabels[index]; + } + + double _maxOf(List values) { + if (values.isEmpty) return 0.0; + double max = values.first; + for (final v in values) { + if (v > max) max = v; + } + return max; + } + + double _minOf(List values) { + if (values.isEmpty) return 0.0; + double min = values.first; + for (final v in values) { + if (v < min) min = v; + } + return min; + } + + List _buildEvapSpots(double evapMin, double evapMax) { + if (dailyValues.isEmpty) return const []; + + return dailyValues.asMap().entries + .where((e) => e.value >= 0) // Hanya tampilkan data yang ada / valid + .map((e) { + final x = e.key.toDouble(); + final y = _safeValue(e.value).clamp(evapMin, evapMax); + return FlSpot(x, y); + }).toList(); + } + + @override + Widget build(BuildContext context) { + if (dailyValues.isEmpty && dailyTemperatures.isEmpty) { + return Container( + height: 240, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(25), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(13), + blurRadius: 10, + offset: const Offset(0, 5), + ) + ], + ), + child: const Text('Tidak ada data chart'), + ); + } + + const evapMin = 0.0; + + final evapMaxRaw = _maxOf(dailyValues); + final tempMinRaw = _minOf(dailyTemperatures); + final tempMaxRaw = _maxOf(dailyTemperatures); + + // ── FIXED COORD SYSTEM SUHU MALFUNCTION ── + // Jika data suhu flat 0 atau sangat rendah, kita kunci range visualnya dari 0 sampai 40 derajat Celsius + final tempMin = tempMaxRaw < 2.0 ? 0.0 : tempMinRaw; + final tempMax = tempMaxRaw < 2.0 ? 40.0 : (tempMaxRaw == tempMinRaw ? tempMinRaw + 1 : tempMaxRaw); + + // Evaporasi: Berikan buffer / ruang kosong (+ 20%) di bagian atas grafik agar tidak terpotong rata + final double evapMaxBase = evapMaxRaw < 1e-9 ? 20.0 : (evapMaxRaw > 50 ? 50.0 : evapMaxRaw); + final evapMax = evapMaxBase * 1.2; // Tambahan padding atas 20% + + final evapSpots = _buildEvapSpots(evapMin, evapMax); + + // Suhu diproyeksikan ke skala evaporasi + final tempSpots = dailyTemperatures.asMap().entries + .where((entry) => entry.value >= 0) // Hanya tampilkan data yang ada / valid + .map((entry) { + final x = entry.key.toDouble(); + final temp = _safeValue(entry.value); + final y = _tempToEvapScale( + temp: temp, + evapMin: evapMin, + evapMax: evapMax, + tempMin: tempMin, + tempMax: tempMax, + ); + return FlSpot(x, y.clamp(evapMin, evapMax)); + }).toList(); + + double getRightTitle(double y) { + if (tempSpots.isEmpty) return tempMin; + return _evapScaleToTemp( + yEvap: y, + evapMin: evapMin, + evapMax: evapMax, + tempMin: tempMin, + tempMax: tempMax, + ); + } + + final xInterval = _xLabelInterval().toInt().clamp(1, 1000); + + final chart = LineChart( + LineChartData( + minY: evapMin, + maxY: evapMax, + minX: -0.5, + maxX: (chartLabels.isNotEmpty ? chartLabels.length - 1 : 23) + .toDouble() + + 0.5, + // Diubah ke clipData false agar lengkungan ujung titik teratas tidak ter-crop kaku + clipData: const FlClipData.none(), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: (evapMax - evapMin) / 4, + getDrawingHorizontalLine: (value) => FlLine( + color: Colors.grey.shade200, + strokeWidth: 1, + ), + ), + borderData: FlBorderData(show: false), + extraLinesData: const ExtraLinesData(horizontalLines: []), + titlesData: FlTitlesData( + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 36, + interval: xInterval.toDouble(), + getTitlesWidget: (value, meta) { + final index = value.toInt(); + if (value != value.roundToDouble()) return const SizedBox(); + if (index < 0 || index >= chartLabels.length) { + return const SizedBox(); + } + if (index % xInterval != 0) return const SizedBox(); + + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + _getBottomLabel(index), + style: const TextStyle(color: Colors.grey, fontSize: 9), + ), + ); + }, + ), + ), + leftTitles: AxisTitles( + axisNameWidget: const Text( + 'mm', + style: TextStyle(color: Colors.blueGrey, fontSize: 10), + ), + axisNameSize: 16, + sideTitles: SideTitles( + showTitles: true, + reservedSize: 36, + interval: (evapMax - evapMin) / 4, + getTitlesWidget: (value, meta) { + return Text( + value.toStringAsFixed(0), + style: const TextStyle( + color: Colors.blueGrey, + fontSize: 10, + ), + ); + }, + ), + ), + rightTitles: AxisTitles( + axisNameWidget: const Text( + '°C', + style: TextStyle(color: Colors.brown, fontSize: 10), + ), + axisNameSize: 16, + sideTitles: SideTitles( + showTitles: true, + reservedSize: 36, + interval: (evapMax - evapMin) / 4, + getTitlesWidget: (value, meta) { + final t = getRightTitle(value); + // Menampilkan label dengan 1 angka desimal jika nilainya kecil, + // atau bulat murni jika data menggunakan fallback sistem (0-40) + return Text( + t > 5 ? t.toStringAsFixed(0) : t.toStringAsFixed(1), + style: const TextStyle( + color: Colors.brown, + fontSize: 10, + ), + ); + }, + ), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + lineTouchData: LineTouchData( + handleBuiltInTouches: true, + touchTooltipData: LineTouchTooltipData( + tooltipPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + showOnTopOfTheChartBoxArea: true, + fitInsideHorizontally: true, + fitInsideVertically: true, + getTooltipItems: (touchedSpots) { + if (touchedSpots.isEmpty) return const []; + + // Ambil spot pertama yang disentuh untuk mencari index X + final firstSpot = touchedSpots.first; + final idx = firstSpot.x.round(); + + // Validasi batas index + if (idx < 0 || idx >= dailyValues.length) { + return const []; + } + + // Ambil data evaporasi dan suhu langsung dari array data berdasarkan index X + final evapVal = dailyValues[idx]; + final tempVal = idx < dailyTemperatures.length ? dailyTemperatures[idx] : 0.0; + + final tooltipText = 'Evap: ${evapVal.toStringAsFixed(1)} mm\n' + 'Suhu: ${tempVal.toStringAsFixed(1)} °C'; + + return touchedSpots.asMap().entries.map((entry) { + final index = entry.key; + if (index == 0) { + return LineTooltipItem( + tooltipText, + const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.bold, + height: 1.4, + ), + ); + } else { + return const LineTooltipItem( + '', + TextStyle(color: Colors.transparent, fontSize: 0), + ); + } + }).toList(); + }, + ), + ), + lineBarsData: [ + if (evapSpots.isNotEmpty) + LineChartBarData( + spots: evapSpots, + isCurved: true, + curveSmoothness: 0.3, + color: Colors.blue.shade700, + barWidth: 2.5, + dotData: FlDotData(show: false), + belowBarData: BarAreaData( + show: true, + color: Colors.blue.shade100.withOpacity(0.3), + ), + ), + if (tempSpots.isNotEmpty) + LineChartBarData( + spots: tempSpots, + isCurved: true, + curveSmoothness: 0.3, + color: Colors.orange.shade700, + barWidth: 2.5, + dotData: FlDotData(show: false), + belowBarData: BarAreaData(show: false), + ), + ], + ), + ); + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + SizedBox( + height: 220, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + // Mengubah ClipBehavior ke none agar garis di titik maksimum visual luar aman tidak ter-crop + child: Padding( + padding: const EdgeInsets.only(top: 10, right: 4), + child: chart, + ), + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Row( + children: [ + Container( + width: 10, + height: 10, + decoration: const BoxDecoration( + color: Colors.blue, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + const Text( + 'Evaporasi (mm)', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + ), + ], + ), + Row( + children: [ + Container( + width: 10, + height: 10, + decoration: const BoxDecoration( + color: Colors.orange, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + const Text( + 'Suhu (°C)', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + ), + ], + ), + ], + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_control_panel.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_control_panel.dart new file mode 100644 index 0000000..87e3c82 --- /dev/null +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_control_panel.dart @@ -0,0 +1,227 @@ +import 'dart:async'; + +import 'package:firebase_database/firebase_database.dart'; +import 'package:flutter/material.dart'; + +class EvaporasiControlPanel extends StatefulWidget { + const EvaporasiControlPanel({super.key}); + + @override + State createState() => _EvaporasiControlPanelState(); +} + +class _EvaporasiControlPanelState extends State { + bool _selenoid = false; + bool _isTogglingSelenoid = false; + bool _isResettingEvaporasi = false; + StreamSubscription? _subscription; + + @override + void initState() { + super.initState(); + _subscription = FirebaseDatabase.instance + .ref('Monitoring/realtime') + .onValue + .listen(_onRealtimeUpdated); + } + + void _onRealtimeUpdated(DatabaseEvent event) { + final data = event.snapshot.value; + if (data is Map) { + final rawSelenoid = data['selenoid']; + final selenoid = _toBool(rawSelenoid, _selenoid); + if (mounted) { + setState(() { + _selenoid = selenoid; + }); + } + } + } + + bool _toBool(dynamic raw, bool fallback) { + if (raw is bool) return raw; + if (raw is num) return raw != 0; + if (raw is String) { + final lower = raw.toLowerCase(); + return lower == 'true' || lower == '1'; + } + return fallback; + } + + Future _toggleSelenoid() async { + if (_isTogglingSelenoid) return; + final nextValue = !_selenoid; + setState(() { + _isTogglingSelenoid = true; + }); + + try { + await Future.wait([ + FirebaseDatabase.instance.ref('Monitoring/selenoid').set(nextValue), + FirebaseDatabase.instance + .ref('Monitoring/realtime/selenoid') + .set(nextValue), + ]); + if (mounted) { + setState(() { + _selenoid = nextValue; + }); + } + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: + Text('Perintah selenoid dikirim: ${nextValue ? 'ON' : 'OFF'}'), + behavior: SnackBarBehavior.floating, + )); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Gagal mengirim perintah selenoid: $e'), + backgroundColor: Colors.red.shade600, + behavior: SnackBarBehavior.floating, + )); + } + } finally { + if (mounted) { + setState(() { + _isTogglingSelenoid = false; + }); + } + } + } + + Future _resetEvaporasi() async { + if (_isResettingEvaporasi) return; + setState(() { + _isResettingEvaporasi = true; + }); + + try { + await Future.wait([ + FirebaseDatabase.instance.ref('Monitoring/reset_evaporasi').set(true), + FirebaseDatabase.instance + .ref('Monitoring/realtime/reset_evaporasi') + .set(true), + ]); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('Perintah reset evaporasi berhasil dikirim.'), + behavior: SnackBarBehavior.floating, + )); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Gagal mengirim perintah reset evaporasi: $e'), + backgroundColor: Colors.red.shade600, + behavior: SnackBarBehavior.floating, + )); + } + } finally { + if (mounted) { + setState(() { + _isResettingEvaporasi = false; + }); + } + } + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + 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(Icons.api_rounded, color: Colors.blue.shade700), + const SizedBox(width: 10), + const Expanded( + child: Text( + 'Kontrol Database Evaporasi', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + 'Tombol ini terhubung ke path Firebase yang belum ada di UI utama: reset evaporasi dan kontrol selenoid.', + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + const SizedBox(height: 16), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ElevatedButton.icon( + onPressed: _isResettingEvaporasi ? null : _resetEvaporasi, + icon: _isResettingEvaporasi + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.refresh_rounded), + label: Text( + _isResettingEvaporasi ? 'Mengirim...' : 'Reset Evaporasi'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.shade600, + foregroundColor: Colors.white, + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ), + ), + OutlinedButton.icon( + onPressed: _isTogglingSelenoid ? null : _toggleSelenoid, + icon: _isTogglingSelenoid + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon( + _selenoid + ? Icons.toggle_on_rounded + : Icons.toggle_off_rounded, + color: _selenoid ? Colors.green : Colors.grey), + label: + Text(_selenoid ? 'Matikan Selenoid' : 'Nyalakan Selenoid'), + style: OutlinedButton.styleFrom( + foregroundColor: + _selenoid ? Colors.green.shade700 : Colors.grey.shade800, + side: BorderSide( + color: _selenoid + ? Colors.green.shade300 + : Colors.grey.shade300), + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ), + ), + ], + ), + const SizedBox(height: 16), + Text( + 'Status selenoid saat ini: ${_selenoid ? 'AKTIF' : 'MATI'}', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + ), + ], + ), + ); + } +} diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_search_bar.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_search_bar.dart new file mode 100644 index 0000000..930b665 --- /dev/null +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_search_bar.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +class EvaporasiDateSearchBar extends StatefulWidget { + final String initialQuery; + final ValueChanged onQueryChanged; + + const EvaporasiDateSearchBar({ + super.key, + required this.initialQuery, + required this.onQueryChanged, + }); + + @override + State createState() => _EvaporasiDateSearchBarState(); +} + +class _EvaporasiDateSearchBarState extends State { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialQuery); + _controller.addListener(() { + setState(() {}); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextField( + controller: _controller, + decoration: InputDecoration( + hintText: 'Cari tanggal (dd/MM/yyyy)', + prefixIcon: const Icon(Icons.search), + suffixIcon: _controller.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _controller.clear(); + widget.onQueryChanged(''); + }, + ) + : null, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + isDense: true, + ), + onChanged: (v) { + widget.onQueryChanged(v); + }, + ); + } +} diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_history_list.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_history_list.dart new file mode 100644 index 0000000..47dc4ef --- /dev/null +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_history_list.dart @@ -0,0 +1,477 @@ +// =========================================================== +// evaporasi_history_list.dart +// Lokasi: lib/screens/monitoring/evaporasi/views/widgets/ +// =========================================================== + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:monitoring_repository/monitoring_repository.dart'; + +class EvaporasiHistoryList extends StatelessWidget { + final List history; + final DateTime? selectedDate; + final VoidCallback onPickDate; + final VoidCallback onClearDate; + + const EvaporasiHistoryList({ + super.key, + required this.history, + required this.selectedDate, + required this.onPickDate, + required this.onClearDate, + }); + + Map> _groupByDate(List list) { + final map = >{}; + for (final item in list) { + final key = DateFormat('yyyy-MM-dd').format(item.timestamp); + map.putIfAbsent(key, () => []).add(item); + } + return map; + } + + @override + Widget build(BuildContext context) { + final grouped = _groupByDate(history); + final sortedKeys = grouped.keys.toList() + ..sort((a, b) => b.compareTo(a)); // terbaru di atas + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _HeaderBar( + selectedDate: selectedDate, + totalCount: history.length, + onPickDate: onPickDate, + onClearDate: onClearDate, + ), + const SizedBox(height: 12), + if (history.isEmpty) + _EmptyState(hasFilter: selectedDate != null) + else + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: sortedKeys.length, + itemBuilder: (context, idx) { + final dateKey = sortedKeys[idx]; + final items = grouped[dateKey]! + ..sort((a, b) => b.timestamp.compareTo(a.timestamp)); + final label = _formatDateLabel(dateKey); + return _DateGroup(label: label, items: items); + }, + ), + ], + ); + } + + String _formatDateLabel(String key) { + final dt = DateTime.parse(key); + final today = DateTime.now(); + if (dt.year == today.year && + dt.month == today.month && + dt.day == today.day) { + return 'Hari Ini — ${DateFormat('dd MMMM yyyy', 'id_ID').format(dt)}'; + } + final yesterday = today.subtract(const Duration(days: 1)); + if (dt.year == yesterday.year && + dt.month == yesterday.month && + dt.day == yesterday.day) { + return 'Kemarin — ${DateFormat('dd MMMM yyyy', 'id_ID').format(dt)}'; + } + return DateFormat('EEEE, dd MMMM yyyy', 'id_ID').format(dt); + } +} + +// ════════════════════════════════════════════════════════════ +// Header bar +// ════════════════════════════════════════════════════════════ +class _HeaderBar extends StatelessWidget { + final DateTime? selectedDate; + final int totalCount; + final VoidCallback onPickDate; + final VoidCallback onClearDate; + + const _HeaderBar({ + required this.selectedDate, + required this.totalCount, + required this.onPickDate, + required this.onClearDate, + }); + + @override + Widget build(BuildContext context) { + final filtered = selectedDate != null; + return Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Riwayat Data', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.black87), + ), + Text( + filtered + ? '${DateFormat('dd MMM yyyy', 'id_ID').format(selectedDate!)} • $totalCount data' + : '$totalCount data tersimpan', + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + ], + ), + const Spacer(), + if (filtered) + _ChipButton( + label: DateFormat('dd MMM yyyy', 'id_ID').format(selectedDate!), + icon: Icons.close_rounded, + color: Colors.blue.shade700, + onTap: onClearDate, + ) + else + _ChipButton( + label: 'Filter Tanggal', + icon: Icons.calendar_month_rounded, + color: Colors.blue.shade700, + onTap: onPickDate, + ), + ], + ); + } +} + +class _ChipButton extends StatelessWidget { + final String label; + final IconData icon; + final Color color; + final VoidCallback onTap; + + const _ChipButton({ + required this.label, + required this.icon, + required this.color, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: color.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 6), + Text(label, + style: TextStyle( + fontSize: 12, + color: color, + fontWeight: FontWeight.w600)), + ], + ), + ), + ); + } +} + +// ════════════════════════════════════════════════════════════ +// Group per tanggal +// ════════════════════════════════════════════════════════════ +class _DateGroup extends StatefulWidget { + final String label; + final List items; + + const _DateGroup({required this.label, required this.items}); + + @override + State<_DateGroup> createState() => _DateGroupState(); +} + +class _DateGroupState extends State<_DateGroup> { + bool _expanded = false; + + + double get _avgEvap { + if (widget.items.isEmpty) return 0; + return widget.items.map((e) => e.evaporasi).reduce((a, b) => a + b) / + widget.items.length; + } + + double get _maxEvap { + if (widget.items.isEmpty) return 0; + return widget.items + .map((e) => e.evaporasi) + .reduce((a, b) => a > b ? a : b); + } + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // ── Group header ────────────────────────────────── + InkWell( + onTap: () => setState(() => _expanded = !_expanded), + borderRadius: + const BorderRadius.vertical(top: Radius.circular(16)), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Container( + width: 4, + height: 36, + decoration: BoxDecoration( + color: Colors.blue.shade600, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.label, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13)), + const SizedBox(height: 2), + Text( + '${widget.items.length} data • rata-rata ${_avgEvap.toStringAsFixed(2)} mm • maks ${_maxEvap.toStringAsFixed(2)} mm', + style: TextStyle( + fontSize: 11, color: Colors.grey.shade600), + ), + ], + ), + ), + Icon( + _expanded + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + color: Colors.grey.shade500, + ), + ], + ), + ), + ), + + // ── Item list ───────────────────────────────────── + if (_expanded) + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: widget.items.length, + separatorBuilder: (_, __) => + Divider(height: 1, color: Colors.grey.shade100), + itemBuilder: (context, i) => + _HistoryItemTile(item: widget.items[i]), + ), + ], + ), + ); + } +} + +// ════════════════════════════════════════════════════════════ +// Satu baris data history +// ════════════════════════════════════════════════════════════ +class _HistoryItemTile extends StatelessWidget { + final Evaporasi item; + + const _HistoryItemTile({required this.item}); + + String get _status { + if (item.evaporasi > 10.0) return 'Tinggi'; + if (item.evaporasi >= 2.0) return 'Normal'; + return 'Rendah'; + } + + Color get _statusColor { + switch (_status) { + case 'Tinggi': + return Colors.red.shade600; + case 'Normal': + return Colors.orange.shade700; + default: + return Colors.green.shade600; + } + } + + IconData get _statusIcon { + switch (_status) { + case 'Tinggi': + return Icons.warning_rounded; + case 'Normal': + return Icons.info_outline_rounded; + default: + return Icons.check_circle_outline_rounded; + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Jam ─────────────────────────────────────────── + SizedBox( + width: 56, + child: Text( + DateFormat('HH:mm:ss').format(item.timestamp), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + fontFamily: 'monospace'), + ), + ), + const SizedBox(width: 8), + + // ── Data evaporasi, tinggi air, suhu ───────────── + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Evaporasi + RichText( + text: TextSpan( + style: const TextStyle(color: Colors.black87), + children: [ + TextSpan( + text: item.evaporasi.toStringAsFixed(2), + style: const TextStyle( + fontSize: 15, fontWeight: FontWeight.bold), + ), + const TextSpan( + text: ' mm', + style: + TextStyle(fontSize: 11, color: Colors.black54), + ), + ], + ), + ), + const SizedBox(height: 3), + // Tinggi Air + Row( + children: [ + Icon(Icons.water, size: 11, + color: Colors.blue.shade400), + const SizedBox(width: 3), + Text( + 'Tinggi Air: ${item.tinggiAir.toStringAsFixed(1)} cm', + style: TextStyle( + fontSize: 11, color: Colors.blue.shade600), + ), + ], + ), + const SizedBox(height: 2), + // Suhu + Row( + children: [ + Icon(Icons.thermostat, size: 11, + color: Colors.orange.shade400), + const SizedBox(width: 3), + Text( + 'Suhu: ${item.suhu.toStringAsFixed(1)} °C', + style: TextStyle( + fontSize: 11, color: Colors.orange.shade700), + ), + ], + ), + ], + ), + ), + + // ── Badge status ────────────────────────────────── + Container( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: _statusColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + border: + Border.all(color: _statusColor.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(_statusIcon, size: 12, color: _statusColor), + const SizedBox(width: 4), + Text( + _status, + style: TextStyle( + fontSize: 11, + color: _statusColor, + fontWeight: FontWeight.w600), + ), + ], + ), + ), + ], + ), + ); + } +} + +// ════════════════════════════════════════════════════════════ +// Empty state +// ════════════════════════════════════════════════════════════ +class _EmptyState extends StatelessWidget { + final bool hasFilter; + const _EmptyState({required this.hasFilter}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 40), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + Icon( + hasFilter + ? Icons.search_off_rounded + : Icons.inbox_rounded, + size: 48, + color: Colors.grey.shade300, + ), + const SizedBox(height: 12), + Text( + hasFilter + ? 'Tidak ada data untuk tanggal ini' + : 'Belum ada data history', + style: + TextStyle(color: Colors.grey.shade500, fontSize: 14), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_range_selector.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_range_selector.dart new file mode 100644 index 0000000..095e934 --- /dev/null +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_range_selector.dart @@ -0,0 +1,206 @@ +// lib/screens/monitoring/evaporasi/views/widgets/evaporasi_range_selector.dart + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; +import '../../blocs/evaporasi_bloc.dart'; + +class EvaporasiRangeSelector extends StatelessWidget { + const EvaporasiRangeSelector({super.key}); + + @override + Widget build(BuildContext context) { + final state = context.watch().state; + final start = state.startDate; + final end = state.endDate; + final isSingle = state.isSingleDay; + + final label = isSingle + ? _formatSingle(start) + : '${_fmt(start)} → ${_fmt(end)}'; + + return GestureDetector( + onTap: () => _pickRange(context, start, end), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.06), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // ── Label rentang ────────────────────────────── + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isSingle ? 'Tampil per Jam' : 'Tampil per Hari', + style: TextStyle( + fontSize: 11, + color: Colors.grey.shade500, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ], + ), + + // ── Tombol pilih ─────────────────────────────── + Row( + children: [ + // Shortcut: Hari Ini + _ShortcutChip( + label: 'Hari Ini', + isActive: isSingle && _isToday(start), + onTap: () { + final today = DateTime.now(); + context.read().add( + EvaporasiDateRangeChanged( + startDate: DateTime(today.year, today.month, today.day), + endDate: DateTime(today.year, today.month, today.day), + ), + ); + }, + ), + const SizedBox(width: 6), + // Tombol pilih range bebas + GestureDetector( + onTap: () => _pickRange(context, start, end), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.blue.shade600, + borderRadius: BorderRadius.circular(20), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.date_range_rounded, + size: 14, color: Colors.white), + SizedBox(width: 5), + Text( + 'Pilih Tanggal', + style: TextStyle( + fontSize: 12, + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Future _pickRange( + BuildContext context, DateTime start, DateTime end) async { + final picked = await showDateRangePicker( + context: context, + firstDate: DateTime(2024), + lastDate: DateTime.now(), + initialDateRange: DateTimeRange(start: start, end: end), + locale: const Locale('id', 'ID'), + builder: (context, child) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: ColorScheme.light( + primary: Colors.blue.shade700, + onPrimary: Colors.white, + surface: Colors.white, + ), + ), + child: child!, + ); + }, + ); + + if (picked != null && context.mounted) { + context.read().add( + EvaporasiDateRangeChanged( + startDate: picked.start, + endDate: picked.end, + ), + ); + } + } + + bool _isToday(DateTime d) { + final now = DateTime.now(); + return d.year == now.year && d.month == now.month && d.day == now.day; + } + + String _fmt(DateTime d) => DateFormat('dd MMM yyyy', 'id_ID').format(d); + + String _formatSingle(DateTime d) { + if (_isToday(d)) return 'Hari Ini — ${_fmt(d)}'; + final yesterday = DateTime.now().subtract(const Duration(days: 1)); + if (d.year == yesterday.year && + d.month == yesterday.month && + d.day == yesterday.day) { + return 'Kemarin — ${_fmt(d)}'; + } + return DateFormat('EEEE, dd MMMM yyyy', 'id_ID').format(d); + } +} + +class _ShortcutChip extends StatelessWidget { + final String label; + final bool isActive; + final VoidCallback onTap; + + const _ShortcutChip({ + required this.label, + required this.isActive, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: isActive ? Colors.blue.shade50 : Colors.grey.shade100, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isActive + ? Colors.blue.shade400 + : Colors.grey.shade300, + ), + ), + child: Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: isActive ? Colors.blue.shade700 : Colors.grey.shade600, + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/monitoring/shared/utils/excel/evaporasi_excel_service.dart b/lib/screens/monitoring/shared/utils/excel/evaporasi_excel_service.dart new file mode 100644 index 0000000..7ccd62d --- /dev/null +++ b/lib/screens/monitoring/shared/utils/excel/evaporasi_excel_service.dart @@ -0,0 +1,405 @@ +// =========================================================== +// evaporasi_excel_service.dart +// Lokasi: lib/screens/monitoring/shared/utils/excel/ +// =========================================================== + +import 'dart:typed_data'; +import 'package:excel/excel.dart'; +import 'package:intl/intl.dart'; +import 'package:monitoring_repository/monitoring_repository.dart'; + +import 'excel_saver_stub.dart' + if (dart.library.html) 'excel_saver_web.dart' + if (dart.library.io) 'excel_saver_mobile.dart'; + +class EvaporasiExcelService { + // ── Format helper ────────────────────────────────────────── + // static final _dateFmt = DateFormat('dd/MM/yyyy HH:mm:ss', 'id_ID'); + static final _headerFmt = DateFormat('dd MMMM yyyy, HH:mm', 'id_ID'); + + // ── Warna tema ───────────────────────────────────────────── + static const _colorHeader = '1A4A8C'; // biru tua + static const _colorSubHead = '2E75B6'; // biru medium + static const _colorNormal = 'E8F4FD'; // biru sangat muda + static const _colorTinggi = 'F8D7DA'; // merah muda + static const _colorRendah = 'D1ECF1'; // biru muda + static const _colorWhite = 'FFFFFF'; + static const _colorWaspada = 'FFF3CD'; // kuning muda + + /// Export data evaporasi ke file Excel dan buka share sheet + static Future export({ + required double currentValue, + required double temperature, + required double waterLevel, + required double acuanPagi, + required String weatherStatus, + required List history, + required String fileName, + DateTime? dateFrom, + DateTime? dateTo, + }) async { + final excel = Excel.createExcel(); + excel.delete('Sheet1'); + + _buildSummarySheet( + excel, + currentValue, + temperature, + waterLevel, + acuanPagi, + weatherStatus, + history, + dateFrom, + dateTo, + ); + _buildHistorySheet(excel, history, dateFrom, dateTo); + + // ── Simpan file ───────────────────────────────────────── + final bytes = excel.save(); + if (bytes == null) throw Exception('Gagal membuat file Excel'); + + final safeName = fileName.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_'); + await saveAndShareExcel(Uint8List.fromList(bytes), '$safeName.xlsx'); + } + + // ════════════════════════════════════════════════════════════ + // SHEET 1: Ringkasan + // ════════════════════════════════════════════════════════════ + static void _buildSummarySheet( + Excel excel, + double currentValue, + double temperature, + double waterLevel, + double acuanPagi, + String weatherStatus, + List history, + DateTime? dateFrom, + DateTime? dateTo, + ) { + final sheet = excel['Ringkasan']; + + // -- Judul -- + _setCell(sheet, 0, 0, 'DATA EVAPORASI – WEATHER STATION', + bold: true, + fontSize: 14, + bgColor: _colorHeader, + fontColor: _colorWhite); + sheet.merge( + CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 0), + CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: 0), + ); + + // -- Metadata -- + _setCell(sheet, 1, 0, 'Diekspor pada', + bold: true, bgColor: _colorSubHead, fontColor: _colorWhite); + _setCell(sheet, 1, 1, _headerFmt.format(DateTime.now()), + bgColor: _colorNormal); + + if (dateFrom != null && dateTo != null) { + _setCell(sheet, 2, 0, 'Filter tanggal', + bold: true, bgColor: _colorSubHead, fontColor: _colorWhite); + _setCell( + sheet, + 2, + 1, + '${DateFormat('dd MMMM yyyy', 'id_ID').format(dateFrom)} → ${DateFormat('dd MMMM yyyy', 'id_ID').format(dateTo)}', + bgColor: _colorNormal, + ); + } + + // -- Nilai saat ini -- + _setCell(sheet, 4, 0, 'DATA SAAT INI', + bold: true, bgColor: _colorSubHead, fontColor: _colorWhite); + sheet.merge( + CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 4), + CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: 4), + ); + + _setCell(sheet, 5, 0, 'Evaporasi (mm)', bold: true); + _setCellDouble(sheet, 5, 1, currentValue); + + _setCell(sheet, 6, 0, 'Suhu Air (°C)', bold: true); + if (temperature < 0) { + _setCell(sheet, 6, 1, '-'); + } else { + _setCellDouble(sheet, 6, 1, temperature); + } + + _setCell(sheet, 7, 0, 'Tinggi Air (cm)', bold: true); + _setCellDouble(sheet, 7, 1, waterLevel); + + _setCell(sheet, 8, 0, 'Acuan Air Pagi (cm)', bold: true); + _setCellDouble(sheet, 8, 1, acuanPagi); + + _setCell(sheet, 9, 0, 'Status', bold: true); + _setCell(sheet, 9, 1, weatherStatus, + bgColor: _statusBgColor(weatherStatus)); + + // -- Statistik ringkasan -- + if (history.isNotEmpty) { + final evapValues = history.map((e) => e.evaporasi).toList(); + final tempValues = + history.map((e) => e.suhu).where((s) => s >= 0).toList(); + + final evapAvg = evapValues.reduce((a, b) => a + b) / evapValues.length; + final evapMax = evapValues.reduce((a, b) => a > b ? a : b); + final evapMin = evapValues.reduce((a, b) => a < b ? a : b); + + _setCell(sheet, 11, 0, 'STATISTIK HISTORY', + bold: true, bgColor: _colorSubHead, fontColor: _colorWhite); + sheet.merge( + CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 11), + CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: 11), + ); + + _setCell(sheet, 12, 0, 'Jumlah data', bold: true); + _setCellInt(sheet, 12, 1, history.length); + + _setCell(sheet, 13, 0, 'Rata-rata evaporasi (mm)', bold: true); + _setCellDouble(sheet, 13, 1, evapAvg); + + _setCell(sheet, 14, 0, 'Evaporasi maksimum (mm)', bold: true); + _setCellDouble(sheet, 14, 1, evapMax); + + _setCell(sheet, 15, 0, 'Evaporasi minimum (mm)', bold: true); + _setCellDouble(sheet, 15, 1, evapMin); + + if (tempValues.isNotEmpty) { + final tempAvg = tempValues.reduce((a, b) => a + b) / tempValues.length; + final tempMax = tempValues.reduce((a, b) => a > b ? a : b); + final tempMin = tempValues.reduce((a, b) => a < b ? a : b); + + _setCell(sheet, 16, 0, 'Rata-rata suhu (°C)', bold: true); + _setCellDouble(sheet, 16, 1, tempAvg); + + _setCell(sheet, 17, 0, 'Suhu maksimum (°C)', bold: true); + _setCellDouble(sheet, 17, 1, tempMax); + + _setCell(sheet, 18, 0, 'Suhu minimum (°C)', bold: true); + _setCellDouble(sheet, 18, 1, tempMin); + } + } + + // Set lebar kolom + sheet.setColumnWidth(0, 26); + sheet.setColumnWidth(1, 22); + sheet.setColumnWidth(2, 18); + sheet.setColumnWidth(3, 18); + } + + // ════════════════════════════════════════════════════════════ + // SHEET 2: Data History + // ════════════════════════════════════════════════════════════ + static void _buildHistorySheet( + Excel excel, + List history, + DateTime? dateFrom, + DateTime? dateTo, + ) { + final sheet = excel['Data History']; + + // -- Header kolom -- + final headers = [ + 'No', + 'Tanggal', + 'Waktu', + 'Evaporasi (mm)', + 'Tinggi Air (cm)', + 'Suhu (°C)', + 'Acuan Pagi (cm)', + 'Status', + ]; + for (var i = 0; i < headers.length; i++) { + _setCell(sheet, 0, i, headers[i], + bold: true, + bgColor: _colorHeader, + fontColor: _colorWhite, + centered: true); + } + + // Filter berdasarkan rentang tanggal (inklusif kedua ujung) + final data = (dateFrom != null && dateTo != null) + ? history.where((e) { + final d = + DateTime(e.timestamp.year, e.timestamp.month, e.timestamp.day); + final from = DateTime(dateFrom.year, dateFrom.month, dateFrom.day); + final to = DateTime(dateTo.year, dateTo.month, dateTo.day); + return !d.isBefore(from) && !d.isAfter(to); + }).toList() + : history; + + // Urutkan terbaru di atas + final sorted = [...data] + ..sort((a, b) => b.timestamp.compareTo(a.timestamp)); + + // -- Isi baris data -- + for (var i = 0; i < sorted.length; i++) { + final item = sorted[i]; + final rowIdx = i + 1; + final status = _getStatus(item.evaporasi); + final bgColor = i.isEven ? _colorNormal : _colorWhite; + final statusBg = _statusBgColor(status); + + _setCellInt(sheet, rowIdx, 0, i + 1, bgColor: bgColor, centered: true); + _setCell(sheet, rowIdx, 1, + DateFormat('dd/MM/yyyy', 'id_ID').format(item.timestamp), + bgColor: bgColor); + _setCell(sheet, rowIdx, 2, DateFormat('HH:mm:ss').format(item.timestamp), + bgColor: bgColor, centered: true); + _setCellDouble(sheet, rowIdx, 3, item.evaporasi, + bgColor: bgColor, centered: true); + _setCellDouble(sheet, rowIdx, 4, item.tinggiAir, + bgColor: bgColor, centered: true); + // Suhu: tampilkan '-' jika sensor error (< 0) + if (item.suhu < 0) { + _setCell(sheet, rowIdx, 5, '-', bgColor: bgColor, centered: true); + } else { + _setCellDouble(sheet, rowIdx, 5, item.suhu, + bgColor: bgColor, centered: true); + } + _setCellDouble(sheet, rowIdx, 6, item.acuanPagi, + bgColor: bgColor, centered: true); + _setCell(sheet, rowIdx, 7, status, + bgColor: statusBg, centered: true, bold: true); + } + + // -- Footer jika kosong -- + if (sorted.isEmpty) { + _setCell(sheet, 1, 0, 'Tidak ada data untuk tanggal yang dipilih', + bgColor: _colorWaspada, centered: true); + sheet.merge( + CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 1), + CellIndex.indexByColumnRow(columnIndex: 7, rowIndex: 1), + ); + } + + // Set lebar kolom + sheet.setColumnWidth(0, 6); + sheet.setColumnWidth(1, 14); + sheet.setColumnWidth(2, 12); + sheet.setColumnWidth(3, 18); + sheet.setColumnWidth(4, 18); + sheet.setColumnWidth(5, 14); + sheet.setColumnWidth(6, 18); + sheet.setColumnWidth(7, 12); + } + + // ════════════════════════════════════════════════════════════ + // HELPER CELLS + // ════════════════════════════════════════════════════════════ + static void _setCell( + Sheet sheet, + int row, + int col, + String value, { + bool bold = false, + double fontSize = 11, + String bgColor = _colorWhite, + String fontColor = '000000', + bool centered = false, + }) { + final cell = + sheet.cell(CellIndex.indexByColumnRow(columnIndex: col, rowIndex: row)); + cell.value = TextCellValue(value); + cell.cellStyle = CellStyle( + bold: bold, + fontSize: fontSize.toInt(), + backgroundColorHex: ExcelColor.fromHexString('#$bgColor'), + fontColorHex: ExcelColor.fromHexString('#$fontColor'), + horizontalAlign: centered ? HorizontalAlign.Center : HorizontalAlign.Left, + verticalAlign: VerticalAlign.Center, + textWrapping: TextWrapping.WrapText, + leftBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + rightBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + topBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + bottomBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + ); + } + + static void _setCellDouble( + Sheet sheet, + int row, + int col, + double value, { + String bgColor = _colorWhite, + bool centered = false, + }) { + final cell = + sheet.cell(CellIndex.indexByColumnRow(columnIndex: col, rowIndex: row)); + cell.value = DoubleCellValue(double.parse(value.toStringAsFixed(4))); + cell.cellStyle = CellStyle( + backgroundColorHex: ExcelColor.fromHexString('#$bgColor'), + horizontalAlign: + centered ? HorizontalAlign.Center : HorizontalAlign.Right, + verticalAlign: VerticalAlign.Center, + leftBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + rightBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + topBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + bottomBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + ); + } + + static void _setCellInt( + Sheet sheet, + int row, + int col, + int value, { + String bgColor = _colorWhite, + bool centered = false, + }) { + final cell = + sheet.cell(CellIndex.indexByColumnRow(columnIndex: col, rowIndex: row)); + cell.value = IntCellValue(value); + cell.cellStyle = CellStyle( + backgroundColorHex: ExcelColor.fromHexString('#$bgColor'), + horizontalAlign: + centered ? HorizontalAlign.Center : HorizontalAlign.Right, + verticalAlign: VerticalAlign.Center, + leftBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + rightBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + topBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + bottomBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + ); + } + + static String _statusBgColor(String status) { + switch (status) { + case 'Tinggi': + return _colorTinggi; + case 'Normal': + return _colorWaspada; + default: // Rendah + return _colorRendah; + } + } + + static String _getStatus(double evaporasi) { + if (evaporasi > 10.0) return 'Tinggi'; + if (evaporasi >= 2.0) return 'Normal'; + return 'Rendah'; + } +}