From d503f05c9952a8434a79d3e781f1c52086bc9b5f Mon Sep 17 00:00:00 2001 From: kleponijo Date: Thu, 25 Jun 2026 10:06:45 +0700 Subject: [PATCH] oke --- lib/screens/home/views/home_screen.dart | 7 - lib/screens/home/views/main_drawer.dart | 21 - .../home/widgets/dashboard_charts.dart | 42 - lib/screens/home/widgets/sensor_grid.dart | 29 +- .../blocs/device_setup_event.dart | 2 +- .../blocs/device_setup_state.dart | 2 +- ...dart => device_setup_wind_speed_bloc.dart} | 0 .../blocs/evaporasi_settings_bloc.dart | 288 ------ .../blocs/evaporasi_settings_event.dart | 87 -- .../blocs/evaporasi_settings_screen.dart | 953 ------------------ .../blocs/evaporasi_settings_state.dart | 163 --- .../views/device_setup_wind_speed_screen.dart | 2 +- .../evaporasi/blocs/evaporasi_bloc.dart | 377 ------- .../evaporasi/blocs/evaporasi_event.dart | 38 - .../evaporasi/blocs/evaporasi_state.dart | 107 -- .../evaporasi/views/evaporasi_screen.dart | 599 ----------- .../views/widgets/evaporasi_chart_widget.dart | 405 -------- .../widgets/evaporasi_control_panel.dart | 224 ---- .../widgets/evaporasi_date_search_bar.dart | 59 -- .../views/widgets/evaporasi_history_list.dart | 276 ----- .../widgets/evaporasi_range_selector.dart | 207 ---- 21 files changed, 11 insertions(+), 3877 deletions(-) rename lib/screens/monitoring/device_setup/blocs/{device_setup_bloc.dart => device_setup_wind_speed_bloc.dart} (100%) delete mode 100644 lib/screens/monitoring/device_setup/blocs/evaporasi_settings_bloc.dart delete mode 100644 lib/screens/monitoring/device_setup/blocs/evaporasi_settings_event.dart delete mode 100644 lib/screens/monitoring/device_setup/blocs/evaporasi_settings_screen.dart delete mode 100644 lib/screens/monitoring/device_setup/blocs/evaporasi_settings_state.dart delete mode 100644 lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart delete mode 100644 lib/screens/monitoring/evaporasi/blocs/evaporasi_event.dart delete mode 100644 lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart delete mode 100644 lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart delete mode 100644 lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart delete mode 100644 lib/screens/monitoring/evaporasi/views/widgets/evaporasi_control_panel.dart delete mode 100644 lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_search_bar.dart delete mode 100644 lib/screens/monitoring/evaporasi/views/widgets/evaporasi_history_list.dart delete mode 100644 lib/screens/monitoring/evaporasi/views/widgets/evaporasi_range_selector.dart diff --git a/lib/screens/home/views/home_screen.dart b/lib/screens/home/views/home_screen.dart index 532749c..c287dae 100644 --- a/lib/screens/home/views/home_screen.dart +++ b/lib/screens/home/views/home_screen.dart @@ -4,7 +4,6 @@ import 'package:monitoring_repository/monitoring_repository.dart'; import '../../../blocs/authentication_bloc/authentication_bloc.dart'; import '../../../blocs/notification_bloc/notification_bloc.dart'; import '../../monitoring/wind_speed/blocs/wind_speed_bloc.dart'; -import '../../monitoring/evaporasi/blocs/evaporasi_bloc.dart'; import '../../monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart'; import '../../auth/views/welcome_screen.dart'; import '../widgets/notification_panel.dart'; @@ -65,12 +64,6 @@ class _HomeScreenState extends State notificationBloc: context.read(), )..add(WatchWindSpeedStarted()), ), - BlocProvider( - create: (context) => EvaporasiBloc( - repository: context.read(), - notificationBloc: context.read(), - )..add(WatchEvaporasiStarted()), - ), BlocProvider( create: (context) => AtmosphericConditionsBloc( repository: context.read(), diff --git a/lib/screens/home/views/main_drawer.dart b/lib/screens/home/views/main_drawer.dart index 871feda..d8fad5a 100644 --- a/lib/screens/home/views/main_drawer.dart +++ b/lib/screens/home/views/main_drawer.dart @@ -1,11 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:klimatologiot/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart'; import 'package:monitoring_repository/monitoring_repository.dart'; import '../../../blocs/authentication_bloc/authentication_bloc.dart'; import '../../monitoring/wind_speed/views/wind_speed_screen.dart'; import '../../monitoring/wind_speed/blocs/wind_speed_bloc.dart'; -import '../../monitoring/evaporasi/views/evaporasi_screen.dart'; import '../../monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart'; import '../../monitoring/atmospheric_conditions/views/atmospheric_screen.dart'; import '../../../blocs/notification_bloc/notification_bloc.dart'; @@ -69,25 +67,6 @@ class MainDrawer extends StatelessWidget { ); }, ), - ListTile( - leading: const Icon(Icons.water_drop), - title: const Text("Evaporasi"), - onTap: () { - Navigator.pop(context); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => BlocProvider( - create: (context) => EvaporasiBloc( - repository: context.read(), - notificationBloc: context.read(), - )..add(WatchEvaporasiStarted()), - child: const EvaporasiScreen(), - ), - ), - ); - }, - ), ListTile( leading: Image.asset( 'images/atmosfer.png', diff --git a/lib/screens/home/widgets/dashboard_charts.dart b/lib/screens/home/widgets/dashboard_charts.dart index f74eac3..099b2c4 100644 --- a/lib/screens/home/widgets/dashboard_charts.dart +++ b/lib/screens/home/widgets/dashboard_charts.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../monitoring/wind_speed/blocs/wind_speed_bloc.dart'; -import '../../monitoring/evaporasi/blocs/evaporasi_bloc.dart'; import '../../monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart'; /// Period selector khusus untuk dashboard @@ -74,29 +73,6 @@ class _DashboardChartsState extends State { final now = DateTime.now(); context.read().add(WindSpeedPeriodChanged(p)); - context.read().add( - EvaporasiDateRangeChanged( - startDate: _evaporasiStartOfPeriod(p, now), - endDate: _evaporasiEndOfPeriod(now), - ), - ); - // AtmosphericBloc tidak punya period (hanya realtime) - } - - DateTime _evaporasiStartOfPeriod(String period, DateTime now) { - final today = DateTime(now.year, now.month, now.day); - if (period == "Minggu Ini") { - final monday = today.subtract(Duration(days: today.weekday - 1)); - return monday; - } - if (period == "Bulan Ini") { - return DateTime(today.year, today.month, 1); - } - return today; - } - - DateTime _evaporasiEndOfPeriod(DateTime now) { - return DateTime(now.year, now.month, now.day); } @override @@ -152,24 +128,6 @@ class _DashboardChartsState extends State { ), const SizedBox(width: 12), - // 2. Evaporasi - BlocBuilder( - builder: (context, state) { - return _SensorChartCard( - title: 'Evaporasi', - unit: 'mm', - color: Colors.teal.shade600, - bgColor: Colors.teal.shade50, - icon: Icons.water_drop_outlined, - currentValue: state.currentValue, - data: state.chartValues, - period: _period, - isLoading: state.isLoading, - ); - }, - ), - const SizedBox(width: 12), - // 3. Tekanan Udara — hanya realtime (tidak ada history) BlocBuilder( diff --git a/lib/screens/home/widgets/sensor_grid.dart b/lib/screens/home/widgets/sensor_grid.dart index bae59e2..b66eaab 100644 --- a/lib/screens/home/widgets/sensor_grid.dart +++ b/lib/screens/home/widgets/sensor_grid.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../monitoring/wind_speed/blocs/wind_speed_bloc.dart'; -import '../../monitoring/evaporasi/blocs/evaporasi_bloc.dart'; import '../../monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart'; class SensorGrid extends StatelessWidget { @@ -34,23 +33,6 @@ class SensorGrid extends StatelessWidget { ), ), - // --- EVAPORASI --- - BlocBuilder( - builder: (context, state) => SensorCard( - width: cardWidth, - icon: Icons.water_drop_outlined, - iconColor: Colors.teal.shade600, - iconBgColor: Colors.teal.shade50, - label: 'Evaporasi', - value: state.isLoading - ? '—' - : state.currentValue.toStringAsFixed(1), - unit: 'mm', - status: state.currentData?.status, - isLoading: state.isLoading, - ), - ), - // --- TEKANAN UDARA --- BlocBuilder( builder: (context, state) => SensorCard( @@ -170,11 +152,14 @@ class SensorCard extends StatelessWidget { Row( children: [ Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: (status == 'Tinggi' ? Colors.red.shade50 - : (status == 'Rendah' ? Colors.blue.shade50 : Colors.green.shade50)), + : (status == 'Rendah' + ? Colors.blue.shade50 + : Colors.green.shade50)), borderRadius: BorderRadius.circular(4), ), child: Text( @@ -184,7 +169,9 @@ class SensorCard extends StatelessWidget { fontWeight: FontWeight.bold, color: (status == 'Tinggi' ? Colors.red.shade600 - : (status == 'Rendah' ? Colors.blue.shade600 : Colors.green.shade600)), + : (status == 'Rendah' + ? Colors.blue.shade600 + : Colors.green.shade600)), ), ), ), diff --git a/lib/screens/monitoring/device_setup/blocs/device_setup_event.dart b/lib/screens/monitoring/device_setup/blocs/device_setup_event.dart index 10d103d..6563fa5 100644 --- a/lib/screens/monitoring/device_setup/blocs/device_setup_event.dart +++ b/lib/screens/monitoring/device_setup/blocs/device_setup_event.dart @@ -1,4 +1,4 @@ -part of 'device_setup_bloc.dart'; +part of 'device_setup_wind_speed_bloc.dart'; abstract class DeviceSetupEvent {} diff --git a/lib/screens/monitoring/device_setup/blocs/device_setup_state.dart b/lib/screens/monitoring/device_setup/blocs/device_setup_state.dart index 55fa1cc..49ad026 100644 --- a/lib/screens/monitoring/device_setup/blocs/device_setup_state.dart +++ b/lib/screens/monitoring/device_setup/blocs/device_setup_state.dart @@ -1,4 +1,4 @@ -part of 'device_setup_bloc.dart'; +part of 'device_setup_wind_speed_bloc.dart'; enum DeviceSetupStatus { idle, diff --git a/lib/screens/monitoring/device_setup/blocs/device_setup_bloc.dart b/lib/screens/monitoring/device_setup/blocs/device_setup_wind_speed_bloc.dart similarity index 100% rename from lib/screens/monitoring/device_setup/blocs/device_setup_bloc.dart rename to lib/screens/monitoring/device_setup/blocs/device_setup_wind_speed_bloc.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 deleted file mode 100644 index e170ecc..0000000 --- a/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_bloc.dart +++ /dev/null @@ -1,288 +0,0 @@ -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_evaporasi'; - 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(_onOffsetChanged); - on(_onPumpStartChanged); - on(_onPumpEndChanged); - on(_onStandarTinggiChanged); - on(_onBatasKritisChanged); - 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), - koreksiOffset: _toDouble(data['koreksi_offset'], 0.0), - pumpStartTime: (data['pump_start_time'] as String?) ?? - (data['jam_pompa_mulai'] != null ? '${_toInt(data['jam_pompa_mulai'], 6).toString().padLeft(2, '0')}:00' : '06:00'), - pumpEndTime: (data['pump_end_time'] as String?) ?? - (data['jam_pompa_selesai'] != null ? '${_toInt(data['jam_pompa_selesai'], 18).toString().padLeft(2, '0')}:00' : '18:00'), - d0: _toInt(data['d0'], 0), - dmaxManual: _toInt(data['dmax_manual'], 0), - standarTinggiCm: _toDouble(data['standar_tinggi_cm'] ?? data['standar_tinggi'], 18.0), - batasKritisCm: _toDouble(data['batas_kritis_cm'] ?? data['batas_kritis'], 15.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, - sensorError: _toBool(rt['sensor_error'], state.sensorError), - ntpSync: _toBool(rt['ntp_sync'], state.ntpSync), - snapshotCm: _toDouble(rt['snapshot_cm'] ?? rt['snapshot'], state.snapshotCm), - standarTinggiCm: _toDouble(rt['standar_tinggi'] ?? rt['standar_tinggi_cm'], state.standarTinggiCm), - batasKritisCm: _toDouble(rt['batas_kritis'] ?? rt['batas_kritis_cm'], state.batasKritisCm), - tempCompActive: _toBool(rt['temp_comp_aktif'], state.tempCompActive), - tempCompCoef: _toDouble(rt['temp_comp_koef'] ?? rt['tempCompKoef'] ?? 0.0, state.tempCompCoef), - tempRefC: _toDouble(rt['temp_ref_c'] ?? rt['temp_ref'] ?? 0.0, state.tempRefC), - otaTrigger: _toBool(rt['ota_trigger'], state.otaTrigger), - relayAktif: _toBool(rt['selenoid'], state.relayAktif), - historyCount: _toInt(rt['history_count'], state.historyCount), - lastRealtime: _parseDateTime(rt['datetime']) ?? lastUpd, - otaStatus: (rt['ota_status'] as String?) ?? state.otaStatus, - )); - } 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 _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 _onStandarTinggiChanged( - EvaporasiStandarTinggiChanged event, - Emitter emit, - ) => emit(state.copyWith(standarTinggiCm: event.value)); - - void _onBatasKritisChanged( - EvaporasiBatasKritisChanged event, - Emitter emit, - ) => emit(state.copyWith(batasKritisCm: 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 { - final pumpStartHour = int.tryParse(state.pumpStartTime.split(':').first) ?? 0; - final pumpEndHour = int.tryParse(state.pumpEndTime.split(':').first) ?? 0; - await FirebaseDatabase.instance - .ref('Monitoring/settings/kalibrasi') - .update({ - 'd0': state.d0, - 'dmax': state.dmaxManual, - }); - await _ref.update({ - 'threshold_rendah': state.thresholdRendah, - 'threshold_tinggi': state.thresholdTinggi, - 'koreksi_offset': state.koreksiOffset, - 'pump_start_time': state.pumpStartTime, - 'pump_end_time': state.pumpEndTime, - 'jam_pompa_mulai': pumpStartHour, - 'jam_pompa_selesai': pumpEndHour, - 'standar_tinggi_cm': state.standarTinggiCm, - 'batas_kritis_cm': state.batasKritisCm, - '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) { - if (v == null) return def; - if (v is num) return v.toDouble(); - if (v is String) { - return double.tryParse(v) ?? def; - } - return def; - } - - int _toInt(dynamic v, int def) { - if (v == null) return def; - if (v is num) return v.toInt(); - if (v is String) { - return int.tryParse(v) ?? def; - } - return def; - } - - bool _toBool(dynamic v, bool def) { - if (v == null) return def; - if (v is bool) return v; - if (v is num) return v != 0; - if (v is String) { - final lower = v.toLowerCase().trim(); - return lower == 'true' || lower == '1' || lower == 'yes' || lower == 'aktif'; - } - return def; - } - - DateTime? _parseDateTime(dynamic raw) { - if (raw == null) return null; - if (raw is int) return DateTime.fromMillisecondsSinceEpoch(raw).toLocal(); - if (raw is num) return DateTime.fromMillisecondsSinceEpoch(raw.toInt()).toLocal(); - if (raw is String) { - final normalized = raw.replaceAll(' ', 'T'); - return DateTime.tryParse(normalized)?.toLocal(); - } - return null; - } -} diff --git a/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_event.dart b/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_event.dart deleted file mode 100644 index cf64e22..0000000 --- a/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_event.dart +++ /dev/null @@ -1,87 +0,0 @@ -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 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 EvaporasiStandarTinggiChanged extends EvaporasiSettingsEvent { - final double value; - const EvaporasiStandarTinggiChanged(this.value); - @override List get props => [value]; -} - -class EvaporasiBatasKritisChanged extends EvaporasiSettingsEvent { - final double value; - const EvaporasiBatasKritisChanged(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 deleted file mode 100644 index 1514a37..0000000 --- a/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_screen.dart +++ /dev/null @@ -1,953 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:firebase_database/firebase_database.dart'; - -import 'evaporasi_settings_bloc.dart'; - -import '../../evaporasi/views/widgets/evaporasi_control_panel.dart'; -import '../../evaporasi/views/evaporasi_screen.dart'; - -class EvaporasiSettingsScreen extends StatefulWidget { - const EvaporasiSettingsScreen({super.key}); - - @override - State createState() => - _EvaporasiSettingsScreenState(); -} - -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(); - final _standarController = TextEditingController(); - final _batasController = 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(); - final standar = s.standarTinggiCm.toStringAsFixed(1); - final batas = s.batasKritisCm.toStringAsFixed(1); - - 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; - } - if (_standarController.text != standar) { - _standarController.text = standar; - } - if (_batasController.text != batas) { - _batasController.text = batas; - } - } - - 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(); - _standarController.dispose(); - _batasController.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()) - : DefaultTabController( - length: 3, - child: Column( - children: [ - Material( - color: Colors.transparent, - child: TabBar( - labelColor: Colors.blue.shade700, - unselectedLabelColor: Colors.grey.shade600, - indicatorColor: Colors.blue.shade700, - tabs: const [ - Tab(text: 'Sistem & Kalibrasi'), - Tab(text: 'Aktuator & Kontrol'), - Tab(text: 'OTA & Firmware'), - ], - ), - ), - Expanded( - child: TabBarView( - children: [ - SingleChildScrollView( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _InfoCard( - 'Pengaturan disimpan ke Firebase Realtime DB dan langsung berlaku untuk perangkat.', - ), - const SizedBox(height: 16), - _groupTitle('Informasi Sistem & Sensor'), - const SizedBox(height: 12), - _SettingsCard(children: [ - _RealtimeInfo(state: state), - const SizedBox(height: 12), - _CompensationCard(state: state), - ]), - const SizedBox(height: 20), - _groupTitle('Interval Pengumpulan Data'), - const SizedBox(height: 12), - _SettingsCard(children: [ - _CollectionIntervalCard( - state: state, - onHistoryIntervalChanged: (v) { - final bloc = context - .read(); - bloc.add( - EvaporasiIntervalHistoryChanged( - v)); - }, - ), - ]), - const SizedBox(height: 20), - ], - ), - ), - SingleChildScrollView( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _groupTitle('Aktuator & Pengaturan'), - const SizedBox(height: 12), - _SettingsCard(children: [ - _TimePickerField( - controller: _pumpStartController, - label: 'Jam Mulai Pompa', - helper: - 'Pilih jam mulai pompa bekerja.', - onTap: () => _pickPumpTime( - context, - _pumpStartController, - (value) => bloc.add( - EvaporasiPumpStartChanged(value)), - state.pumpStartTime, - ), - ), - const SizedBox(height: 12), - _TimePickerField( - controller: _pumpEndController, - label: 'Jam Selesai Pompa', - helper: 'Pilih jam berhenti pompa.', - onTap: () => _pickPumpTime( - context, - _pumpEndController, - (value) => bloc.add( - EvaporasiPumpEndChanged(value)), - state.pumpEndTime, - ), - ), - const Divider(height: 20), - _NumericField( - controller: _standarController, - label: 'Standar Tinggi Air (cm)', - hint: 'Contoh: 18.0', - helper: - 'Nilai tinggi standar untuk selenoid (cm).', - onChanged: (v) { - final d = double.tryParse(v); - if (d != null && d >= 0) { - bloc.add( - EvaporasiStandarTinggiChanged( - d)); - } - }, - ), - const SizedBox(height: 12), - _NumericField( - controller: _batasController, - label: 'Batas Kritis (cm)', - hint: 'Contoh: 15.0', - helper: - 'Jika di bawah nilai ini → pompa ON paksa.', - onChanged: (v) { - final d = double.tryParse(v); - if (d != null && d >= 0) { - bloc.add( - EvaporasiBatasKritisChanged(d)); - } - }, - ), - ]), - const SizedBox(height: 16), - const SizedBox(height: 12), - EvaporasiControlPanel(), - const SizedBox(height: 20), - ], - ), - ), - SingleChildScrollView( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _groupTitle('OTA & Firmware'), - const SizedBox(height: 12), - _SettingsCard(children: [ - _InfoTile( - label: 'Versi Firmware', - value: state.firmwareVersion), - const SizedBox(height: 8), - _InfoTile( - label: 'Status OTA', - value: state.otaStatus), - const SizedBox(height: 8), - _InfoTile( - label: 'Riwayat Data', - value: '${state.historyCount} entri'), - const SizedBox(height: 12), - _OtaCard(state: state), - ]), - const SizedBox(height: 20), - ], - ), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.all(16.0), - child: SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: isSaving - ? null - : () => bloc.add(EvaporasiSettingsSaved()), - icon: isSaving - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.white), - ) - : const Icon(Icons.save_rounded), - label: Text(isSaving - ? 'Menyimpan...' - : 'Simpan ke Firebase'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue.shade700, - foregroundColor: Colors.white, - padding: - const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14)), - textStyle: const TextStyle( - fontSize: 15, fontWeight: FontWeight.bold), - ), - ), - ), - ), - ], - ), - ), - ); - }, - ), - ); - } - - Widget _groupTitle(String text) => Text( - text, - style: const TextStyle( - fontSize: 15, fontWeight: FontWeight.bold, color: Colors.blueGrey), - ); - - 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 _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 _CompensationCard extends StatelessWidget { - final EvaporasiSettingsState state; - const _CompensationCard({required this.state}); - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.grey.shade50, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: Colors.grey.shade200), - ), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Icon(Icons.thermostat_rounded, - size: 15, color: Colors.orange.shade700), - const SizedBox(width: 8), - Text('Kalibrasi & Kompensasi', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.bold, - color: Colors.orange.shade700)), - ]), - const SizedBox(height: 10), - Wrap(spacing: 12, runSpacing: 8, children: [ - _InfoTile( - label: 'Snapshot (cm)', - value: state.snapshotCm > 0 - ? state.snapshotCm.toStringAsFixed(2) - : '--'), - _InfoTile( - label: 'Koef. Kompensasi', - value: state.tempCompCoef.toStringAsFixed(2)), - _InfoTile( - label: 'Ref Suhu (°C)', value: state.tempRefC.toStringAsFixed(1)), - _InfoTile( - label: 'Komp. Aktif', - value: state.tempCompActive ? 'Ya' : 'Tidak'), - _InfoTile( - label: 'Sensor Error', value: state.sensorError ? 'YA' : 'TIDAK'), - _InfoTile(label: 'NTP Sync', value: state.ntpSync ? 'OK' : 'GAGAL'), - ]) - ]), - ); - } -} - -class _OtaCard extends StatelessWidget { - final EvaporasiSettingsState state; - const _OtaCard({required this.state}); - - Future _triggerOta(BuildContext context) async { - try { - await FirebaseDatabase.instance.ref('Monitoring/ota_trigger').set(true); - ScaffoldMessenger.of(context) - .showSnackBar(const SnackBar(content: Text('Trigger OTA dikirim.'))); - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Gagal trigger OTA: $e'), - backgroundColor: Colors.red.shade600)); - } - } - - @override - Widget build(BuildContext context) { - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('OTA kontrol', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Colors.blue.shade800)), - const SizedBox(height: 8), - Row(children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: () => _triggerOta(context), - icon: const Icon(Icons.system_update_alt_rounded), - label: const Text('Trigger OTA'), - style: - ElevatedButton.styleFrom(backgroundColor: Colors.blue.shade700), - ), - ), - const SizedBox(width: 12), - OutlinedButton.icon( - onPressed: () => Navigator.of(context) - .push(MaterialPageRoute(builder: (_) => const EvaporasiScreen())), - icon: const Icon(Icons.history_rounded), - label: const Text('Lihat Riwayat'), - ), - ]) - ]); - } -} - -class _RealtimeInfo extends StatelessWidget { - final EvaporasiSettingsState state; - const _RealtimeInfo({required this.state}); - - 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)), - ]), - ); - } -} - -// ════════════════════════════════════════════════════════════ -// Data Collection Interval Card -// ════════════════════════════════════════════════════════════ - -class _CollectionIntervalCard extends StatelessWidget { - final EvaporasiSettingsState state; - final ValueChanged onHistoryIntervalChanged; - - const _CollectionIntervalCard({ - required this.state, - required this.onHistoryIntervalChanged, - }); - - static final Map historyOptions = { - 600000: '10 menit', - 1800000: '30 menit', - 3600000: '1 jam', - 7200000: '2 jam', - 14400000: '4 jam', - }; - - static String _getHistoryLabel(int ms) { - return historyOptions[ms] ?? '${ms ~/ 1000}s'; - } - - @override - Widget build(BuildContext context) { - final currentInterval = state.intervalHistory_ms; - final dataPerDay = (24 * 60 * 60 * 1000) ~/ currentInterval; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.schedule_outlined, - size: 15, color: Colors.teal.shade700), - const SizedBox(width: 8), - Text( - 'Interval Pengumpulan Data', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.bold, - color: Colors.teal.shade700, - ), - ), - ], - ), - const SizedBox(height: 12), - Text( - 'Pilih interval untuk menyimpan data ke Firebase:', - style: TextStyle(fontSize: 12, color: Colors.grey.shade600), - ), - const SizedBox(height: 10), - Wrap( - spacing: 8, - runSpacing: 8, - children: historyOptions.entries.map((e) { - final isSelected = e.key == currentInterval; - return GestureDetector( - onTap: () => onHistoryIntervalChanged(e.key), - child: AnimatedContainer( - duration: const Duration(milliseconds: 180), - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: - isSelected ? Colors.teal.shade600 : Colors.grey.shade100, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: isSelected - ? Colors.teal.shade600 - : Colors.grey.shade300, - width: isSelected ? 2 : 1, - ), - ), - child: Text( - e.value, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: isSelected ? Colors.white : Colors.black87, - ), - ), - ), - ); - }).toList(), - ), - const SizedBox(height: 12), - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.teal.shade50, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: Colors.teal.shade100), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.info_outline_rounded, - size: 14, color: Colors.teal.shade700), - const SizedBox(width: 8), - Text( - 'Info', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Colors.teal.shade700, - ), - ), - ], - ), - const SizedBox(height: 6), - Text( - 'Interval saat ini: ${_getHistoryLabel(currentInterval)}', - style: TextStyle( - fontSize: 12, - color: Colors.teal.shade700, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 4), - Text( - 'Perkiraan data: ~$dataPerDay data per 24 jam', - style: TextStyle(fontSize: 11, color: Colors.teal.shade600), - ), - const SizedBox(height: 4), - Text( - 'Interval lebih singkat = data lebih detail tapi lebih banyak penyimpanan', - style: TextStyle(fontSize: 10, color: Colors.teal.shade500), - ), - ], - ), - ), - ], - ); - } -} diff --git a/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_state.dart b/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_state.dart deleted file mode 100644 index 0a1422b..0000000 --- a/lib/screens/monitoring/device_setup/blocs/evaporasi_settings_state.dart +++ /dev/null @@ -1,163 +0,0 @@ -part of 'evaporasi_settings_bloc.dart'; - -enum EvaporasiSettingsStatus { loading, loaded, saving, saved, error } - -class EvaporasiSettingsState extends Equatable { - final double thresholdRendah; - final double thresholdTinggi; - 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 double standarTinggiCm; - final double batasKritisCm; - final bool tempCompActive; - final double tempCompCoef; - final double tempRefC; - final bool sensorError; - final bool ntpSync; - final double snapshotCm; - final bool otaTrigger; - final bool relayAktif; - final String otaStatus; - final int historyCount; - final DateTime? lastRealtime; - - final EvaporasiSettingsStatus status; - final String? errorMessage; - - const EvaporasiSettingsState({ - this.thresholdRendah = 2.0, - this.thresholdTinggi = 10.0, - 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.standarTinggiCm = 18.0, - this.batasKritisCm = 15.0, - this.tempCompActive = true, - this.tempCompCoef = 500.0, - this.tempRefC = 25.0, - this.sensorError = false, - this.ntpSync = false, - this.snapshotCm = 0.0, - this.otaTrigger = false, - this.relayAktif = false, - this.otaStatus = '--', - this.historyCount = 0, - this.lastRealtime, - 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, - double? koreksiOffset, - String? pumpStartTime, - String? pumpEndTime, - int? d0, - int? dmaxManual, - int? intervalRealtime_ms, - int? intervalHistory_ms, - int? intervalBaca_ms, - double? standarTinggiCm, - double? batasKritisCm, - bool? tempCompActive, - double? tempCompCoef, - double? tempRefC, - bool? sensorError, - bool? ntpSync, - double? snapshotCm, - bool? otaTrigger, - bool? relayAktif, - String? otaStatus, - int? historyCount, - DateTime? lastRealtime, - 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, - koreksiOffset: koreksiOffset ?? this.koreksiOffset, - pumpStartTime: pumpStartTime ?? this.pumpStartTime, - pumpEndTime: pumpEndTime ?? this.pumpEndTime, - d0: d0 ?? this.d0, - dmaxManual: dmaxManual ?? this.dmaxManual, - standarTinggiCm: standarTinggiCm ?? this.standarTinggiCm, - batasKritisCm: batasKritisCm ?? this.batasKritisCm, - tempCompActive: tempCompActive ?? this.tempCompActive, - tempCompCoef: tempCompCoef ?? this.tempCompCoef, - tempRefC: tempRefC ?? this.tempRefC, - sensorError: sensorError ?? this.sensorError, - ntpSync: ntpSync ?? this.ntpSync, - snapshotCm: snapshotCm ?? this.snapshotCm, - otaTrigger: otaTrigger ?? this.otaTrigger, - relayAktif: relayAktif ?? this.relayAktif, - otaStatus: otaStatus ?? this.otaStatus, - historyCount: historyCount ?? this.historyCount, - lastRealtime: lastRealtime ?? this.lastRealtime, - 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, koreksiOffset, - pumpStartTime, pumpEndTime, d0, dmaxManual, - intervalRealtime_ms, intervalHistory_ms, intervalBaca_ms, - standarTinggiCm, batasKritisCm, tempCompActive, tempCompCoef, tempRefC, - sensorError, ntpSync, snapshotCm, otaTrigger, relayAktif, historyCount, lastRealtime, - firmwareVersion, wifiConnected, firebaseConnected, activeD0, activeDmax, lastUpdate, - status, errorMessage, - dmax, isResettingDmax, - ]; -} \ No newline at end of file diff --git a/lib/screens/monitoring/device_setup/views/device_setup_wind_speed_screen.dart b/lib/screens/monitoring/device_setup/views/device_setup_wind_speed_screen.dart index 4652c55..e0eaa23 100644 --- a/lib/screens/monitoring/device_setup/views/device_setup_wind_speed_screen.dart +++ b/lib/screens/monitoring/device_setup/views/device_setup_wind_speed_screen.dart @@ -4,7 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import 'package:monitoring_repository/monitoring_repository.dart'; import 'package:app_settings/app_settings.dart'; -import '../blocs/device_setup_bloc.dart'; +import '../blocs/device_setup_wind_speed_bloc.dart'; // ── Opsi device yang tersedia ───────────────────────────────── const _kDeviceOptions = ['esp_lapangan', 'esp_percobaan']; diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart deleted file mode 100644 index bd5e955..0000000 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart +++ /dev/null @@ -1,377 +0,0 @@ -// lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart - -import 'dart:async'; - -import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; -import 'package:firebase_database/firebase_database.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.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; - final FlutterLocalNotificationsPlugin _localNotificationsPlugin = - FlutterLocalNotificationsPlugin(); - StreamSubscription? _subscription; - double _thresholdRendah = 2.0; - double _thresholdTinggi = 10.0; - - EvaporasiBloc({ - required MonitoringRepository repository, - required NotificationBloc notificationBloc, - }) : _repository = repository, - _notificationBloc = notificationBloc, - super(EvaporasiState()) { - _initLocalNotifications(); - 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'); - - await _loadThresholdSettings(); - - final lastValue = history.isNotEmpty ? history.last.evaporasi : 0.0; - final lastWater = history.isNotEmpty ? history.last.tinggiAir : 0.0; - final lastTemp = history.isNotEmpty ? history.last.suhu : 0.0; - - final (status, willRain) = computeStatus( - lastValue, - thresholdRendah: _thresholdRendah, - thresholdTinggi: _thresholdTinggi, - ); - _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 - // ════════════════════════════════════════════════════════════ - Future _onRealtimeUpdated( - _EvaporasiRealtimeUpdated event, - Emitter emit, - ) async { - await _loadThresholdSettings(); - - final (status, willRain) = computeStatus( - event.data.evaporasi, - thresholdRendah: _thresholdRendah, - thresholdTinggi: _thresholdTinggi, - ); - - final bool hasFallenBelowCritical = - event.data.tinggiAir < state.batasKritisCm && - state.waterLevel >= state.batasKritisCm; - final bool statusBecameHigh = - state.weatherStatus != 'Tinggi' && status == 'Tinggi'; - - if (hasFallenBelowCritical || statusBecameHigh) { - await _showCriticalNotification(); - } - - _emitAlert(status, willRain, event.data.evaporasi); - - emit(state.copyWith( - currentValue: event.data.evaporasi, - temperature: event.data.suhu, - waterLevel: event.data.tinggiAir, - 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 selectedDate = DateTime( - event.startDate.year, - event.startDate.month, - event.startDate.day, - ); - - final values = TimeSeriesMapper.toSpecificDate( - data: history, - getTime: (e) => e.timestamp, - getValue: (e) => e.evaporasi, - targetDate: selectedDate, - ); - final temps = TimeSeriesMapper.toSpecificDate( - data: history, - getTime: (e) => e.timestamp, - getValue: (e) => e.suhu, - targetDate: selectedDate, - ); - final labels = List.generate(24, (i) => '${i.toString().padLeft(2, '0')}:00'); - - emit(state.copyWith( - startDate: selectedDate, - endDate: selectedDate, - 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 value, { - double thresholdRendah = 2.0, - double thresholdTinggi = 10.0, - }) { - if (value >= thresholdTinggi) return ('Tinggi', true); - if (value >= thresholdRendah) return ('Normal', false); - return ('Rendah', false); - } - - Future _loadThresholdSettings() async { - try { - final snap = await FirebaseDatabase.instance - .ref('Monitoring/settings/evaporasi') - .get(); - if (!snap.exists || snap.value == null) return; - - final data = Map.from(snap.value as Map); - _thresholdRendah = _toDouble(data['threshold_rendah'], _thresholdRendah); - _thresholdTinggi = _toDouble(data['threshold_tinggi'], _thresholdTinggi); - } catch (_) { - // Tetap pakai nilai default jika pembacaan gagal. - } - } - - Future _initLocalNotifications() async { - const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); - const iosSettings = DarwinInitializationSettings(); - const initSettings = InitializationSettings( - android: androidSettings, - iOS: iosSettings, - macOS: iosSettings, - ); - - try { - await _localNotificationsPlugin.initialize(settings: initSettings); - } catch (_) { - // Jika gagal, lanjutkan tanpa notifikasi lokal. - } - } - - Future _showCriticalNotification() async { - const androidDetails = AndroidNotificationDetails( - 'evaporasi_alerts', - 'Evaporasi Alerts', - channelDescription: 'Peringatan Evaporasi dan level air kritis', - importance: Importance.max, - priority: Priority.high, - ticker: 'Peringatan Darurat', - playSound: true, - enableVibration: true, - ); - const iosDetails = DarwinNotificationDetails( - presentAlert: true, - presentSound: true, - presentBadge: true, - ); - const notificationDetails = NotificationDetails( - android: androidDetails, - iOS: iosDetails, - macOS: iosDetails, - ); - - try { - await _localNotificationsPlugin.show( - id: 0, - title: 'Peringatan Darurat', - body: 'Peringatan Darurat: Batas Air Kritis tercapai, periksa pompa segera!', - notificationDetails: notificationDetails, - ); - } catch (_) { - // Ignore local notification failure. - } - } - - static double _toDouble(dynamic value, double fallback) { - if (value == null) return fallback; - if (value is num) return value.toDouble(); - if (value is String) return double.tryParse(value) ?? fallback; - return fallback; - } - - void _emitAlert(String status, bool willRain, double value) { - final AlertSeverity severity; - 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 deleted file mode 100644 index ad26b3f..0000000 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_event.dart +++ /dev/null @@ -1,38 +0,0 @@ -// 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 deleted file mode 100644 index 277416b..0000000 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart +++ /dev/null @@ -1,107 +0,0 @@ -// 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; - final double batasKritisCm; - - // 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, - this.batasKritisCm = 15.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, - double? batasKritisCm, - 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, - batasKritisCm: batasKritisCm ?? this.batasKritisCm, - 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, batasKritisCm, - 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 deleted file mode 100644 index 2cb0d24..0000000 --- a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart +++ /dev/null @@ -1,599 +0,0 @@ -// 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), - - // ── Range Selector ────────────────────────── - const Text( - 'Tren Evaporasi & Suhu', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 10), - const EvaporasiRangeSelector(), - const SizedBox(height: 12), - - // ── 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), - ), - ], - ), - ), - ], - ), - ); - } -} -// oioioioioiioioio \ No newline at end of file diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart deleted file mode 100644 index 7c06562..0000000 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart +++ /dev/null @@ -1,405 +0,0 @@ -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 deleted file mode 100644 index aeb353a..0000000 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_control_panel.dart +++ /dev/null @@ -1,224 +0,0 @@ -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 FirebaseDatabase.instance - .ref('Monitoring/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 deleted file mode 100644 index 930b665..0000000 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_search_bar.dart +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index ed624e2..0000000 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_history_list.dart +++ /dev/null @@ -1,276 +0,0 @@ -// =========================================================== -// 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)); - - 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: [ - const Text( - 'History Evaporasi Harian', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), - ), - 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 StatelessWidget { - final String label; - final List items; - - const _DateGroup({required this.label, required this.items}); - - double get _avgEvap { - if (items.isEmpty) return 0; - return items.map((e) => e.evaporasi).reduce((a, b) => a + b) / items.length; - } - - double get _avgTemp { - if (items.isEmpty) return 0; - final validTemps = items.where((e) => e.suhu >= -50 && e.suhu <= 100).toList(); - if (validTemps.isEmpty) return 0; - return validTemps.map((e) => e.suhu).reduce((a, b) => a + b) / validTemps.length; - } - - @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: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), - 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(label, - style: const TextStyle( - fontWeight: FontWeight.bold, fontSize: 13)), - const SizedBox(height: 6), - Text( - 'rata-rata evaporasi ${_avgEvap.toStringAsFixed(2)} mm • rata-rata suhu ${_avgTemp.toStringAsFixed(1)} °C', - style: TextStyle(fontSize: 12, color: Colors.grey.shade600), - ), - ], - ), - ), - ], - ), - ), - ); - } -} - -// ════════════════════════════════════════════════════════════ -// 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), - ), - ], - ), - ); - } -} diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_range_selector.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_range_selector.dart deleted file mode 100644 index 819248d..0000000 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_range_selector.dart +++ /dev/null @@ -1,207 +0,0 @@ -// 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: () => _pickDate(context, start), - 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 tanggal harian - GestureDetector( - onTap: () => _pickDate(context, start), - 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 _pickDate( - BuildContext context, DateTime date) async { - final picked = await showDatePicker( - context: context, - initialDate: date, - firstDate: DateTime(2024), - lastDate: DateTime.now(), - 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) { - final selected = DateTime(picked.year, picked.month, picked.day); - context.read().add( - EvaporasiDateRangeChanged( - startDate: selected, - endDate: selected, - ), - ); - } - } - - 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