This commit is contained in:
Mochamad ongki ramadani 2026-06-21 12:54:32 +07:00
parent 948184da6e
commit 096a0fd928
7 changed files with 126 additions and 284 deletions

View File

@ -9,7 +9,7 @@ class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettin
final DatabaseReference _ref;
static const _path = 'Monitoring/settings/evaporasi';
static const _rtdbResetPath = 'Monitoring/reset_dmax';
static const _rtdbResetPath = 'Monitoring/reset_evaporasi';
static const _rtdbRealtimePath = 'Monitoring/realtime/dmax_saat_ini';
EvaporasiSettingsBloc({DatabaseReference? ref})
@ -18,7 +18,7 @@ class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettin
on<EvaporasiSettingsStarted>(_onStarted);
on<EvaporasiThresholdRendahChanged>(_onThresholdRendahChanged);
on<EvaporasiThresholdTinggiChanged>(_onThresholdTinggiChanged);
on<EvaporasiRumusKalibrasiChanged>(_onRumusChanged);
// rumusKalibrasi removed: calculation is handled on ESP32 hardware
on<EvaporasiKoreksiOffsetChanged>(_onOffsetChanged);
on<EvaporasiPumpStartChanged>(_onPumpStartChanged);
on<EvaporasiPumpEndChanged>(_onPumpEndChanged);
@ -45,7 +45,6 @@ class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettin
emit(state.copyWith(
thresholdRendah: _toDouble(data['threshold_rendah'], 2.0),
thresholdTinggi: _toDouble(data['threshold_tinggi'], 10.0),
rumusKalibrasi: (data['rumus_kalibrasi'] as String?) ?? 'selisih_max',
koreksiOffset: _toDouble(data['koreksi_offset'], 0.0),
pumpStartTime: (data['pump_start_time'] as String?) ??
(data['jam_pompa_mulai'] != null ? '${_toInt(data['jam_pompa_mulai'], 6).toString().padLeft(2, '0')}:00' : '06:00'),
@ -134,10 +133,7 @@ class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettin
Emitter<EvaporasiSettingsState> emit,
) => emit(state.copyWith(thresholdTinggi: event.value));
void _onRumusChanged(
EvaporasiRumusKalibrasiChanged event,
Emitter<EvaporasiSettingsState> emit,
) => emit(state.copyWith(rumusKalibrasi: event.rumus));
// rumusKalibrasi handling removed device firmware defines the formula
void _onOffsetChanged(
EvaporasiKoreksiOffsetChanged event,
@ -221,17 +217,20 @@ class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettin
try {
final pumpStartHour = int.tryParse(state.pumpStartTime.split(':').first) ?? 0;
final pumpEndHour = int.tryParse(state.pumpEndTime.split(':').first) ?? 0;
await _ref.set({
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,
'rumus_kalibrasi': state.rumusKalibrasi,
'koreksi_offset': state.koreksiOffset,
'pump_start_time': state.pumpStartTime,
'pump_end_time': state.pumpEndTime,
'jam_pompa_mulai': pumpStartHour,
'jam_pompa_selesai': pumpEndHour,
'd0': state.d0,
'dmax_manual': state.dmaxManual,
'standar_tinggi_cm': state.standarTinggiCm,
'batas_kritis_cm': state.batasKritisCm,
'interval_realtime_ms': state.intervalRealtime_ms,

View File

@ -20,11 +20,6 @@ class EvaporasiThresholdTinggiChanged extends EvaporasiSettingsEvent {
@override List<Object?> get props => [value];
}
class EvaporasiRumusKalibrasiChanged extends EvaporasiSettingsEvent {
final String rumus;
const EvaporasiRumusKalibrasiChanged(this.rumus);
@override List<Object?> get props => [rumus];
}
class EvaporasiKoreksiOffsetChanged extends EvaporasiSettingsEvent {
final double value;

View File

@ -444,147 +444,6 @@ class _StatusChip extends StatelessWidget {
}
}
class _RumusSelector extends StatelessWidget {
final String selected;
final ValueChanged<String> onChanged;
const _RumusSelector({required this.selected, required this.onChanged});
@override
Widget build(BuildContext context) {
const options = [
(
value: 'selisih_max',
label: 'Selisih Maksimum',
subtitle: 'E = max(H kemarin) max(H hari ini)',
icon: Icons.trending_down_rounded,
),
(
value: 'rata_harian',
label: 'Rata-rata Harian',
subtitle: 'E = rata(H kemarin) rata(H hari ini)',
icon: Icons.bar_chart_rounded,
),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Metode Kalkulasi',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.black54)),
const SizedBox(height: 10),
...options.map((opt) {
final isSelected = opt.value == selected;
return GestureDetector(
onTap: () => onChanged(opt.value),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isSelected ? Colors.blue.shade50 : Colors.grey.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color:
isSelected ? Colors.blue.shade400 : Colors.grey.shade200,
width: isSelected ? 1.5 : 1,
),
),
child: Row(
children: [
Icon(opt.icon,
color: isSelected
? Colors.blue.shade700
: Colors.grey.shade500,
size: 22),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(opt.label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: isSelected
? Colors.blue.shade800
: Colors.black87)),
const SizedBox(height: 2),
Text(opt.subtitle,
style: TextStyle(
fontSize: 11,
fontFamily: 'monospace',
color: isSelected
? Colors.blue.shade600
: Colors.grey.shade500)),
],
),
),
if (isSelected)
Icon(Icons.check_circle_rounded,
color: Colors.blue.shade600, size: 20),
],
),
),
);
}),
],
);
}
}
class _FormulaPreview extends StatelessWidget {
final EvaporasiSettingsState state;
const _FormulaPreview({required this.state});
@override
Widget build(BuildContext context) {
final rumus = state.rumusKalibrasi == 'selisih_max'
? 'E = max(H₁) max(H₂) + offset'
: 'E = avg(H₁) avg(H₂) + offset';
final offsetStr = state.koreksiOffset >= 0
? '+${state.koreksiOffset.toStringAsFixed(2)}'
: state.koreksiOffset.toStringAsFixed(2);
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.grey.shade900,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(Icons.functions_rounded,
size: 15, color: Colors.amber.shade300),
const SizedBox(width: 8),
Text('Preview Rumus',
style: TextStyle(
fontSize: 12,
color: Colors.amber.shade300,
fontWeight: FontWeight.bold)),
]),
const SizedBox(height: 10),
Text(rumus,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade300,
fontFamily: 'monospace')),
const SizedBox(height: 6),
Text('offset = $offsetStr mm',
style: TextStyle(
fontSize: 12,
color: Colors.green.shade300,
fontFamily: 'monospace',
fontWeight: FontWeight.bold)),
],
),
);
}
}
class _InfoCard extends StatelessWidget {
final String text;

View File

@ -5,7 +5,6 @@ enum EvaporasiSettingsStatus { loading, loaded, saving, saved, error }
class EvaporasiSettingsState extends Equatable {
final double thresholdRendah;
final double thresholdTinggi;
final String rumusKalibrasi;
final double koreksiOffset;
final String pumpStartTime;
final String pumpEndTime;
@ -45,7 +44,6 @@ class EvaporasiSettingsState extends Equatable {
const EvaporasiSettingsState({
this.thresholdRendah = 2.0,
this.thresholdTinggi = 10.0,
this.rumusKalibrasi = 'selisih_max',
this.koreksiOffset = 0.0,
this.pumpStartTime = '06:00',
this.pumpEndTime = '18:00',
@ -82,7 +80,6 @@ class EvaporasiSettingsState extends Equatable {
EvaporasiSettingsState copyWith({
double? thresholdRendah,
double? thresholdTinggi,
String? rumusKalibrasi,
double? koreksiOffset,
String? pumpStartTime,
String? pumpEndTime,
@ -118,7 +115,6 @@ class EvaporasiSettingsState extends Equatable {
return EvaporasiSettingsState(
thresholdRendah: thresholdRendah ?? this.thresholdRendah,
thresholdTinggi: thresholdTinggi ?? this.thresholdTinggi,
rumusKalibrasi: rumusKalibrasi ?? this.rumusKalibrasi,
koreksiOffset: koreksiOffset ?? this.koreksiOffset,
pumpStartTime: pumpStartTime ?? this.pumpStartTime,
pumpEndTime: pumpEndTime ?? this.pumpEndTime,
@ -155,7 +151,7 @@ class EvaporasiSettingsState extends Equatable {
@override
List<Object?> get props => [
thresholdRendah, thresholdTinggi, rumusKalibrasi, koreksiOffset,
thresholdRendah, thresholdTinggi, koreksiOffset,
pumpStartTime, pumpEndTime, d0, dmaxManual,
intervalRealtime_ms, intervalHistory_ms, intervalBaca_ms,
standarTinggiCm, batasKritisCm, tempCompActive, tempCompCoef, tempRefC,

View File

@ -151,58 +151,6 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
_EvaporasiRealtimeUpdated event,
Emitter<EvaporasiState> emit,
) async {
final updatedHistory = List<Evaporasi>.from(state.history);
final dupIdx = updatedHistory.indexWhere(
(e) => e.timestamp.toUtc() == event.data.timestamp.toUtc(),
);
if (dupIdx >= 0) {
updatedHistory[dupIdx] = event.data;
} else {
updatedHistory.add(event.data);
}
updatedHistory.sort((a, b) => a.timestamp.compareTo(b.timestamp));
// Regenerasikan data chart secara dinamis agar selalu sinkron dengan data real-time
List<double> updatedChart;
List<double> updatedTemp;
List<String> updatedLabels = state.chartLabels;
final isSingle = _isSameDay(state.startDate, state.endDate);
if (isSingle) {
updatedChart = TimeSeriesMapper.toSpecificDate(
data: updatedHistory,
getTime: (e) => e.timestamp,
getValue: (e) => e.evaporasi,
targetDate: state.startDate,
);
updatedTemp = TimeSeriesMapper.toSpecificDate(
data: updatedHistory,
getTime: (e) => e.timestamp,
getValue: (e) => e.suhu,
targetDate: state.startDate,
);
updatedLabels = List.generate(24, (i) => '${i.toString().padLeft(2, '0')}:00');
} else {
final evapResult = TimeSeriesMapper.toDateRange(
data: updatedHistory,
getTime: (e) => e.timestamp,
getValue: (e) => e.evaporasi,
startDate: state.startDate,
endDate: state.endDate,
);
final tempResult = TimeSeriesMapper.toDateRange(
data: updatedHistory,
getTime: (e) => e.timestamp,
getValue: (e) => e.suhu,
startDate: state.startDate,
endDate: state.endDate,
);
updatedChart = evapResult.values;
updatedTemp = tempResult.values;
updatedLabels = evapResult.labels;
}
await _loadThresholdSettings();
final (status, willRain) = computeStatus(
@ -213,19 +161,9 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
_emitAlert(status, willRain, event.data.evaporasi);
emit(state.copyWith(
history: updatedHistory,
filteredHistory: state.selectedDateFilter != null
? updatedHistory.where((e) =>
e.timestamp.year == state.selectedDateFilter!.year &&
e.timestamp.month == state.selectedDateFilter!.month &&
e.timestamp.day == state.selectedDateFilter!.day).toList()
: updatedHistory,
currentValue: event.data.evaporasi,
temperature: event.data.suhu,
waterLevel: event.data.tinggiAir,
chartValues: updatedChart,
chartTemperatures: updatedTemp,
chartLabels: updatedLabels,
weatherStatus: status,
willRain: willRain,
currentData: event.data,

View File

@ -363,10 +363,6 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
_statusCard(state),
const SizedBox(height: 20),
// Kontrol Database Evaporasi
const EvaporasiControlPanel(),
const SizedBox(height: 20),
// Range Selector
const Text(
'Tren Evaporasi & Suhu',

View File

@ -34,7 +34,7 @@ class EvaporasiHistoryList extends StatelessWidget {
Widget build(BuildContext context) {
final grouped = _groupByDate(history);
final sortedKeys = grouped.keys.toList()
..sort((a, b) => b.compareTo(a)); // terbaru di atas
..sort((a, b) => b.compareTo(a));
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -68,15 +68,11 @@ class EvaporasiHistoryList extends StatelessWidget {
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) {
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) {
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);
@ -105,9 +101,12 @@ class _HeaderBar extends StatelessWidget {
return Row(
children: [
const Text(
'History',
'Riwayat Data',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black87),
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const Spacer(),
if (filtered)
@ -171,37 +170,35 @@ class _ChipButton extends StatelessWidget {
//
// Group per tanggal
//
class _DateGroup extends StatelessWidget {
//
class _DateGroup extends StatefulWidget {
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;
@override
State<_DateGroup> createState() => _DateGroupState();
}
double get _maxEvap {
if (items.isEmpty) return 0;
return items.map((e) => e.evaporasi).reduce((a, b) => a > b ? a : b);
class _DateGroupState extends State<_DateGroup> {
bool _expanded = true;
double get _avgEvap {
if (widget.items.isEmpty) return 0;
return widget.items.map((e) => e.evaporasi).reduce((a, b) => a + b) /
widget.items.length;
}
double get _avgTemp {
if (items.isEmpty) return 0;
if (widget.items.isEmpty) return 0;
final validTemps =
items.where((e) => e.suhu >= -50 && e.suhu <= 100).toList();
widget.items.where((e) => e.suhu >= -50 && e.suhu <= 100).toList();
if (validTemps.isEmpty) return 0;
return validTemps.map((e) => e.suhu).reduce((a, b) => a + b) /
validTemps.length;
}
double get _maxTemp {
if (items.isEmpty) return 0;
return items.map((e) => e.suhu).reduce((a, b) => a > b ? a : b);
}
@override
Widget build(BuildContext context) {
return Container(
@ -217,6 +214,11 @@ class _DateGroup extends StatelessWidget {
),
],
),
child: Column(
children: [
InkWell(
onTap: () => setState(() => _expanded = !_expanded),
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
@ -234,17 +236,75 @@ class _DateGroup extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
Text(widget.label,
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 13)),
const SizedBox(height: 2),
Text(
'rata-rata ${_avgEvap.toStringAsFixed(2)} mm',
'${widget.items.length} data • rata-rata ${_avgEvap.toStringAsFixed(2)} mm • suhu rata-rata ${_avgTemp.toStringAsFixed(1)} °C',
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
),
const SizedBox(height: 2),
],
),
),
Icon(
_expanded
? Icons.keyboard_arrow_up_rounded
: Icons.keyboard_arrow_down_rounded,
color: Colors.grey.shade500,
),
],
),
),
),
if (_expanded)
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: widget.items.length,
separatorBuilder: (_, __) => Divider(height: 1, color: Colors.grey.shade100),
itemBuilder: (context, i) => _HistoryItemTile(item: widget.items[i]),
),
],
),
);
}
}
class _HistoryItemTile extends StatelessWidget {
final Evaporasi item;
const _HistoryItemTile({required this.item});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
SizedBox(
width: 52,
child: Text(
DateFormat('HH:mm:ss').format(item.timestamp),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
fontFamily: 'monospace',
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'rata-rata suhu ${_avgTemp.toStringAsFixed(1)} °C',
'${item.evaporasi.toStringAsFixed(2)} mm • ${item.suhu.toStringAsFixed(1)} °C • ${item.tinggiAir.toStringAsFixed(1)} cm',
style: const TextStyle(fontSize: 13, color: Colors.black87),
),
const SizedBox(height: 4),
Text(
item.status,
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
),
],
@ -252,7 +312,6 @@ class _DateGroup extends StatelessWidget {
),
],
),
),
);
}
}