This commit is contained in:
kleponijo 2026-06-25 10:06:45 +07:00
parent e6c6eeba6a
commit d503f05c99
21 changed files with 11 additions and 3877 deletions

View File

@ -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<HomeScreen>
notificationBloc: context.read<NotificationBloc>(),
)..add(WatchWindSpeedStarted()),
),
BlocProvider<EvaporasiBloc>(
create: (context) => EvaporasiBloc(
repository: context.read<MonitoringRepository>(),
notificationBloc: context.read<NotificationBloc>(),
)..add(WatchEvaporasiStarted()),
),
BlocProvider<AtmosphericConditionsBloc>(
create: (context) => AtmosphericConditionsBloc(
repository: context.read<MonitoringRepository>(),

View File

@ -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<EvaporasiBloc>(
create: (context) => EvaporasiBloc(
repository: context.read<MonitoringRepository>(),
notificationBloc: context.read<NotificationBloc>(),
)..add(WatchEvaporasiStarted()),
child: const EvaporasiScreen(),
),
),
);
},
),
ListTile(
leading: Image.asset(
'images/atmosfer.png',

View File

@ -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<DashboardCharts> {
final now = DateTime.now();
context.read<WindSpeedBloc>().add(WindSpeedPeriodChanged(p));
context.read<EvaporasiBloc>().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<DashboardCharts> {
),
const SizedBox(width: 12),
// 2. Evaporasi
BlocBuilder<EvaporasiBloc, EvaporasiState>(
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<AtmosphericConditionsBloc,
AtmosphericConditionsState>(

View File

@ -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<EvaporasiBloc, EvaporasiState>(
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<AtmosphericConditionsBloc, AtmosphericConditionsState>(
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)),
),
),
),

View File

@ -1,4 +1,4 @@
part of 'device_setup_bloc.dart';
part of 'device_setup_wind_speed_bloc.dart';
abstract class DeviceSetupEvent {}

View File

@ -1,4 +1,4 @@
part of 'device_setup_bloc.dart';
part of 'device_setup_wind_speed_bloc.dart';
enum DeviceSetupStatus {
idle,

View File

@ -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<EvaporasiSettingsEvent, EvaporasiSettingsState> {
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<EvaporasiSettingsStarted>(_onStarted);
on<EvaporasiThresholdRendahChanged>(_onThresholdRendahChanged);
on<EvaporasiThresholdTinggiChanged>(_onThresholdTinggiChanged);
on<EvaporasiKoreksiOffsetChanged>(_onOffsetChanged);
on<EvaporasiPumpStartChanged>(_onPumpStartChanged);
on<EvaporasiPumpEndChanged>(_onPumpEndChanged);
on<EvaporasiStandarTinggiChanged>(_onStandarTinggiChanged);
on<EvaporasiBatasKritisChanged>(_onBatasKritisChanged);
on<EvaporasiD0Changed>(_onD0Changed);
on<EvaporasiDmaxManualChanged>(_onDmaxManualChanged);
on<EvaporasiIntervalRealtimeChanged>(_onIntervalRealtimeChanged);
on<EvaporasiIntervalHistoryChanged>(_onIntervalHistoryChanged);
on<EvaporasiIntervalBacaChanged>(_onIntervalBacaChanged);
on<EvaporasiDmaxResetRequested>(_onDmaxReset);
on<EvaporasiSettingsSaved>(_onSaved);
}
Future<void> _onStarted(
EvaporasiSettingsStarted event,
Emitter<EvaporasiSettingsState> emit,
) async {
emit(state.copyWith(status: EvaporasiSettingsStatus.loading));
try {
final snap = await _ref.get();
if (snap.exists && snap.value != null) {
final data = Map<String, dynamic>.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<String, dynamic>.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<EvaporasiSettingsState> emit,
) => emit(state.copyWith(thresholdRendah: event.value));
void _onThresholdTinggiChanged(
EvaporasiThresholdTinggiChanged event,
Emitter<EvaporasiSettingsState> emit,
) => emit(state.copyWith(thresholdTinggi: event.value));
void _onOffsetChanged(
EvaporasiKoreksiOffsetChanged event,
Emitter<EvaporasiSettingsState> emit,
) => emit(state.copyWith(koreksiOffset: event.value));
void _onIntervalRealtimeChanged(
EvaporasiIntervalRealtimeChanged event,
Emitter<EvaporasiSettingsState> emit,
) => emit(state.copyWith(intervalRealtime_ms: event.value));
void _onIntervalHistoryChanged(
EvaporasiIntervalHistoryChanged event,
Emitter<EvaporasiSettingsState> emit,
) => emit(state.copyWith(intervalHistory_ms: event.value));
void _onIntervalBacaChanged(
EvaporasiIntervalBacaChanged event,
Emitter<EvaporasiSettingsState> emit,
) => emit(state.copyWith(intervalBaca_ms: event.value));
void _onPumpStartChanged(
EvaporasiPumpStartChanged event,
Emitter<EvaporasiSettingsState> emit,
) => emit(state.copyWith(pumpStartTime: event.value));
void _onPumpEndChanged(
EvaporasiPumpEndChanged event,
Emitter<EvaporasiSettingsState> emit,
) => emit(state.copyWith(pumpEndTime: event.value));
void _onStandarTinggiChanged(
EvaporasiStandarTinggiChanged event,
Emitter<EvaporasiSettingsState> emit,
) => emit(state.copyWith(standarTinggiCm: event.value));
void _onBatasKritisChanged(
EvaporasiBatasKritisChanged event,
Emitter<EvaporasiSettingsState> emit,
) => emit(state.copyWith(batasKritisCm: event.value));
void _onD0Changed(
EvaporasiD0Changed event,
Emitter<EvaporasiSettingsState> emit,
) => emit(state.copyWith(d0: event.value));
void _onDmaxManualChanged(
EvaporasiDmaxManualChanged event,
Emitter<EvaporasiSettingsState> emit,
) => emit(state.copyWith(dmaxManual: event.value));
Future<void> _onDmaxReset(
EvaporasiDmaxResetRequested event,
Emitter<EvaporasiSettingsState> 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<void> _onSaved(
EvaporasiSettingsSaved event,
Emitter<EvaporasiSettingsState> 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;
}
}

View File

@ -1,87 +0,0 @@
part of 'evaporasi_settings_bloc.dart';
abstract class EvaporasiSettingsEvent extends Equatable {
const EvaporasiSettingsEvent();
@override
List<Object?> get props => [];
}
class EvaporasiSettingsStarted extends EvaporasiSettingsEvent {}
class EvaporasiThresholdRendahChanged extends EvaporasiSettingsEvent {
final double value;
const EvaporasiThresholdRendahChanged(this.value);
@override List<Object?> get props => [value];
}
class EvaporasiThresholdTinggiChanged extends EvaporasiSettingsEvent {
final double value;
const EvaporasiThresholdTinggiChanged(this.value);
@override List<Object?> get props => [value];
}
class EvaporasiKoreksiOffsetChanged extends EvaporasiSettingsEvent {
final double value;
const EvaporasiKoreksiOffsetChanged(this.value);
@override List<Object?> get props => [value];
}
// Interval baru
class EvaporasiIntervalRealtimeChanged extends EvaporasiSettingsEvent {
final int value; // ms
const EvaporasiIntervalRealtimeChanged(this.value);
@override List<Object?> get props => [value];
}
class EvaporasiIntervalHistoryChanged extends EvaporasiSettingsEvent {
final int value; // ms
const EvaporasiIntervalHistoryChanged(this.value);
@override List<Object?> get props => [value];
}
class EvaporasiIntervalBacaChanged extends EvaporasiSettingsEvent {
final int value; // ms
const EvaporasiIntervalBacaChanged(this.value);
@override List<Object?> get props => [value];
}
class EvaporasiPumpStartChanged extends EvaporasiSettingsEvent {
final String value; // HH:mm
const EvaporasiPumpStartChanged(this.value);
@override List<Object?> get props => [value];
}
class EvaporasiPumpEndChanged extends EvaporasiSettingsEvent {
final String value; // HH:mm
const EvaporasiPumpEndChanged(this.value);
@override List<Object?> get props => [value];
}
class EvaporasiStandarTinggiChanged extends EvaporasiSettingsEvent {
final double value;
const EvaporasiStandarTinggiChanged(this.value);
@override List<Object?> get props => [value];
}
class EvaporasiBatasKritisChanged extends EvaporasiSettingsEvent {
final double value;
const EvaporasiBatasKritisChanged(this.value);
@override List<Object?> get props => [value];
}
class EvaporasiD0Changed extends EvaporasiSettingsEvent {
final int value;
const EvaporasiD0Changed(this.value);
@override List<Object?> get props => [value];
}
class EvaporasiDmaxManualChanged extends EvaporasiSettingsEvent {
final int value;
const EvaporasiDmaxManualChanged(this.value);
@override List<Object?> get props => [value];
}
class EvaporasiSettingsSaved extends EvaporasiSettingsEvent {}
class EvaporasiDmaxResetRequested extends EvaporasiSettingsEvent {}

View File

@ -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<EvaporasiSettingsScreen> createState() =>
_EvaporasiSettingsScreenState();
}
class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
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<void> _pickPumpTime(
BuildContext context,
TextEditingController controller,
ValueChanged<String> 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<EvaporasiSettingsBloc, EvaporasiSettingsState>(
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<EvaporasiSettingsBloc>();
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<EvaporasiSettingsBloc>();
bloc.add(
EvaporasiIntervalHistoryChanged(
v));
},
),
]),
const SizedBox(height: 20),
],
),
),
SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_groupTitle('Aktuator & Pengaturan'),
const SizedBox(height: 12),
_SettingsCard(children: [
_TimePickerField(
controller: _pumpStartController,
label: 'Jam Mulai Pompa',
helper:
'Pilih jam mulai pompa bekerja.',
onTap: () => _pickPumpTime(
context,
_pumpStartController,
(value) => bloc.add(
EvaporasiPumpStartChanged(value)),
state.pumpStartTime,
),
),
const SizedBox(height: 12),
_TimePickerField(
controller: _pumpEndController,
label: 'Jam Selesai Pompa',
helper: 'Pilih jam berhenti pompa.',
onTap: () => _pickPumpTime(
context,
_pumpEndController,
(value) => bloc.add(
EvaporasiPumpEndChanged(value)),
state.pumpEndTime,
),
),
const Divider(height: 20),
_NumericField(
controller: _standarController,
label: 'Standar Tinggi Air (cm)',
hint: 'Contoh: 18.0',
helper:
'Nilai tinggi standar untuk selenoid (cm).',
onChanged: (v) {
final d = double.tryParse(v);
if (d != null && d >= 0) {
bloc.add(
EvaporasiStandarTinggiChanged(
d));
}
},
),
const SizedBox(height: 12),
_NumericField(
controller: _batasController,
label: 'Batas Kritis (cm)',
hint: 'Contoh: 15.0',
helper:
'Jika di bawah nilai ini → pompa ON paksa.',
onChanged: (v) {
final d = double.tryParse(v);
if (d != null && d >= 0) {
bloc.add(
EvaporasiBatasKritisChanged(d));
}
},
),
]),
const SizedBox(height: 16),
const SizedBox(height: 12),
EvaporasiControlPanel(),
const SizedBox(height: 20),
],
),
),
SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_groupTitle('OTA & Firmware'),
const SizedBox(height: 12),
_SettingsCard(children: [
_InfoTile(
label: 'Versi Firmware',
value: state.firmwareVersion),
const SizedBox(height: 8),
_InfoTile(
label: 'Status OTA',
value: state.otaStatus),
const SizedBox(height: 8),
_InfoTile(
label: 'Riwayat Data',
value: '${state.historyCount} entri'),
const SizedBox(height: 12),
_OtaCard(state: state),
]),
const SizedBox(height: 20),
],
),
),
],
),
),
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<Widget> 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<String> 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<int, String> options;
final int selected;
final ValueChanged<int> 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<void> _triggerOta(BuildContext context) async {
try {
await FirebaseDatabase.instance.ref('Monitoring/ota_trigger').set(true);
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Trigger OTA dikirim.')));
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Gagal trigger OTA: $e'),
backgroundColor: Colors.red.shade600));
}
}
@override
Widget build(BuildContext context) {
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('OTA kontrol',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.blue.shade800)),
const SizedBox(height: 8),
Row(children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () => _triggerOta(context),
icon: const Icon(Icons.system_update_alt_rounded),
label: const Text('Trigger OTA'),
style:
ElevatedButton.styleFrom(backgroundColor: Colors.blue.shade700),
),
),
const SizedBox(width: 12),
OutlinedButton.icon(
onPressed: () => Navigator.of(context)
.push(MaterialPageRoute(builder: (_) => const EvaporasiScreen())),
icon: const Icon(Icons.history_rounded),
label: const Text('Lihat Riwayat'),
),
])
]);
}
}
class _RealtimeInfo extends StatelessWidget {
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<int> onHistoryIntervalChanged;
const _CollectionIntervalCard({
required this.state,
required this.onHistoryIntervalChanged,
});
static final Map<int, String> historyOptions = {
600000: '10 menit',
1800000: '30 menit',
3600000: '1 jam',
7200000: '2 jam',
14400000: '4 jam',
};
static String _getHistoryLabel(int ms) {
return historyOptions[ms] ?? '${ms ~/ 1000}s';
}
@override
Widget build(BuildContext context) {
final currentInterval = state.intervalHistory_ms;
final dataPerDay = (24 * 60 * 60 * 1000) ~/ currentInterval;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.schedule_outlined,
size: 15, color: Colors.teal.shade700),
const SizedBox(width: 8),
Text(
'Interval Pengumpulan Data',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Colors.teal.shade700,
),
),
],
),
const SizedBox(height: 12),
Text(
'Pilih interval untuk menyimpan data ke Firebase:',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: historyOptions.entries.map((e) {
final isSelected = e.key == currentInterval;
return GestureDetector(
onTap: () => onHistoryIntervalChanged(e.key),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color:
isSelected ? Colors.teal.shade600 : Colors.grey.shade100,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected
? Colors.teal.shade600
: Colors.grey.shade300,
width: isSelected ? 2 : 1,
),
),
child: Text(
e.value,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isSelected ? Colors.white : Colors.black87,
),
),
),
);
}).toList(),
),
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.teal.shade50,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.teal.shade100),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.info_outline_rounded,
size: 14, color: Colors.teal.shade700),
const SizedBox(width: 8),
Text(
'Info',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.teal.shade700,
),
),
],
),
const SizedBox(height: 6),
Text(
'Interval saat ini: ${_getHistoryLabel(currentInterval)}',
style: TextStyle(
fontSize: 12,
color: Colors.teal.shade700,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
'Perkiraan data: ~$dataPerDay data per 24 jam',
style: TextStyle(fontSize: 11, color: Colors.teal.shade600),
),
const SizedBox(height: 4),
Text(
'Interval lebih singkat = data lebih detail tapi lebih banyak penyimpanan',
style: TextStyle(fontSize: 10, color: Colors.teal.shade500),
),
],
),
),
],
);
}
}

View File

@ -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<Object?> 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,
];
}

View File

@ -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'];

View File

@ -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<EvaporasiEvent, EvaporasiState> {
static List<DateTime> _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>[];
DateTime cur = s;
while (!cur.isAfter(e)) {
days.add(cur);
cur = cur.add(const Duration(days: 1));
}
return days;
}
static List<String> _buildLabels(List<DateTime> 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<Evaporasi>? _subscription;
double _thresholdRendah = 2.0;
double _thresholdTinggi = 10.0;
EvaporasiBloc({
required MonitoringRepository repository,
required NotificationBloc notificationBloc,
}) : _repository = repository,
_notificationBloc = notificationBloc,
super(EvaporasiState()) {
_initLocalNotifications();
on<WatchEvaporasiStarted>(_onStarted);
on<_EvaporasiRealtimeUpdated>(_onRealtimeUpdated);
on<EvaporasiDateRangeChanged>(_onDateRangeChanged);
on<EvaporasiDateFilterChanged>(_onDateFilterChanged);
}
//
// START
//
Future<void> _onStarted(
WatchEvaporasiStarted event,
Emitter<EvaporasiState> emit,
) async {
emit(state.copyWith(isLoading: true));
final history = List<Evaporasi>.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<void> _onRealtimeUpdated(
_EvaporasiRealtimeUpdated event,
Emitter<EvaporasiState> 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<void> _onDateRangeChanged(
EvaporasiDateRangeChanged event,
Emitter<EvaporasiState> 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<EvaporasiState> 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<void> _loadThresholdSettings() async {
try {
final snap = await FirebaseDatabase.instance
.ref('Monitoring/settings/evaporasi')
.get();
if (!snap.exists || snap.value == null) return;
final data = Map<String, dynamic>.from(snap.value as Map);
_thresholdRendah = _toDouble(data['threshold_rendah'], _thresholdRendah);
_thresholdTinggi = _toDouble(data['threshold_tinggi'], _thresholdTinggi);
} catch (_) {
// Tetap pakai nilai default jika pembacaan gagal.
}
}
Future<void> _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<void> _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<void> close() async {
await _subscription?.cancel();
return super.close();
}
}
class _EvaporasiRealtimeUpdated extends EvaporasiEvent {
final Evaporasi data;
const _EvaporasiRealtimeUpdated(this.data);
@override
List<Object?> get props => [data];
}

View File

@ -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<Object?> 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<Object?> 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<Object?> get props => [date];
}

View File

@ -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<double> chartValues;
final List<double> chartTemperatures;
final List<String> chartLabels;
// Data list
final List<Evaporasi> history;
final List<Evaporasi> 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<double>? chartValues,
List<double>? chartTemperatures,
List<String>? chartLabels,
List<Evaporasi>? history,
List<Evaporasi>? 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<Object?> get props => [
currentValue, temperature, waterLevel, batasKritisCm,
startDate, endDate,
chartValues, chartTemperatures, chartLabels,
history, filteredHistory, selectedDateFilter,
weatherStatus, willRain, currentData, isLoading,
];
}

View File

@ -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<EvaporasiScreen> createState() => _EvaporasiScreenState();
}
class _EvaporasiScreenState extends State<EvaporasiScreen> {
// Dialog export: nama file + date range
Future<void> _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<void> 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<void> _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<EvaporasiBloc, EvaporasiState>(
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<EvaporasiBloc, EvaporasiState>(
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<EvaporasiBloc, EvaporasiState>(
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<EvaporasiBloc, EvaporasiState>(
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<EvaporasiBloc>().add(
EvaporasiDateFilterChanged(picked),
);
}
},
onClearDate: () {
context.read<EvaporasiBloc>().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

View File

@ -1,405 +0,0 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
class EvaporasiChartWidget extends StatelessWidget {
final List<double> dailyValues;
final List<double> dailyTemperatures;
final String period;
final List<String> 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<double> 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<double> values) {
if (values.isEmpty) return 0.0;
double min = values.first;
for (final v in values) {
if (v < min) min = v;
}
return min;
}
List<FlSpot> _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 <LineTooltipItem>[];
// 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 <LineTooltipItem>[];
}
// 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),
),
],
),
],
),
],
),
);
}
}

View File

@ -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<EvaporasiControlPanel> createState() => _EvaporasiControlPanelState();
}
class _EvaporasiControlPanelState extends State<EvaporasiControlPanel> {
bool _selenoid = false;
bool _isTogglingSelenoid = false;
bool _isResettingEvaporasi = false;
StreamSubscription<DatabaseEvent>? _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<void> _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<void> _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),
),
],
),
);
}
}

View File

@ -1,59 +0,0 @@
import 'package:flutter/material.dart';
class EvaporasiDateSearchBar extends StatefulWidget {
final String initialQuery;
final ValueChanged<String> onQueryChanged;
const EvaporasiDateSearchBar({
super.key,
required this.initialQuery,
required this.onQueryChanged,
});
@override
State<EvaporasiDateSearchBar> createState() => _EvaporasiDateSearchBarState();
}
class _EvaporasiDateSearchBarState extends State<EvaporasiDateSearchBar> {
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);
},
);
}
}

View File

@ -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<Evaporasi> 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<String, List<Evaporasi>> _groupByDate(List<Evaporasi> list) {
final map = <String, List<Evaporasi>>{};
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<Evaporasi> 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),
),
],
),
);
}
}

View File

@ -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<EvaporasiBloc>().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<EvaporasiBloc>().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<void> _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<EvaporasiBloc>().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,
),
),
),
);
}
}