pepe
This commit is contained in:
parent
948184da6e
commit
096a0fd928
|
|
@ -9,7 +9,7 @@ class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettin
|
||||||
final DatabaseReference _ref;
|
final DatabaseReference _ref;
|
||||||
|
|
||||||
static const _path = 'Monitoring/settings/evaporasi';
|
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';
|
static const _rtdbRealtimePath = 'Monitoring/realtime/dmax_saat_ini';
|
||||||
|
|
||||||
EvaporasiSettingsBloc({DatabaseReference? ref})
|
EvaporasiSettingsBloc({DatabaseReference? ref})
|
||||||
|
|
@ -18,7 +18,7 @@ class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettin
|
||||||
on<EvaporasiSettingsStarted>(_onStarted);
|
on<EvaporasiSettingsStarted>(_onStarted);
|
||||||
on<EvaporasiThresholdRendahChanged>(_onThresholdRendahChanged);
|
on<EvaporasiThresholdRendahChanged>(_onThresholdRendahChanged);
|
||||||
on<EvaporasiThresholdTinggiChanged>(_onThresholdTinggiChanged);
|
on<EvaporasiThresholdTinggiChanged>(_onThresholdTinggiChanged);
|
||||||
on<EvaporasiRumusKalibrasiChanged>(_onRumusChanged);
|
// rumusKalibrasi removed: calculation is handled on ESP32 hardware
|
||||||
on<EvaporasiKoreksiOffsetChanged>(_onOffsetChanged);
|
on<EvaporasiKoreksiOffsetChanged>(_onOffsetChanged);
|
||||||
on<EvaporasiPumpStartChanged>(_onPumpStartChanged);
|
on<EvaporasiPumpStartChanged>(_onPumpStartChanged);
|
||||||
on<EvaporasiPumpEndChanged>(_onPumpEndChanged);
|
on<EvaporasiPumpEndChanged>(_onPumpEndChanged);
|
||||||
|
|
@ -40,12 +40,11 @@ class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettin
|
||||||
emit(state.copyWith(status: EvaporasiSettingsStatus.loading));
|
emit(state.copyWith(status: EvaporasiSettingsStatus.loading));
|
||||||
try {
|
try {
|
||||||
final snap = await _ref.get();
|
final snap = await _ref.get();
|
||||||
if (snap.exists && snap.value != null) {
|
if (snap.exists && snap.value != null) {
|
||||||
final data = Map<String, dynamic>.from(snap.value as Map);
|
final data = Map<String, dynamic>.from(snap.value as Map);
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
thresholdRendah: _toDouble(data['threshold_rendah'], 2.0),
|
thresholdRendah: _toDouble(data['threshold_rendah'], 2.0),
|
||||||
thresholdTinggi: _toDouble(data['threshold_tinggi'], 10.0),
|
thresholdTinggi: _toDouble(data['threshold_tinggi'], 10.0),
|
||||||
rumusKalibrasi: (data['rumus_kalibrasi'] as String?) ?? 'selisih_max',
|
|
||||||
koreksiOffset: _toDouble(data['koreksi_offset'], 0.0),
|
koreksiOffset: _toDouble(data['koreksi_offset'], 0.0),
|
||||||
pumpStartTime: (data['pump_start_time'] as String?) ??
|
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'),
|
(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,
|
Emitter<EvaporasiSettingsState> emit,
|
||||||
) => emit(state.copyWith(thresholdTinggi: event.value));
|
) => emit(state.copyWith(thresholdTinggi: event.value));
|
||||||
|
|
||||||
void _onRumusChanged(
|
// rumusKalibrasi handling removed — device firmware defines the formula
|
||||||
EvaporasiRumusKalibrasiChanged event,
|
|
||||||
Emitter<EvaporasiSettingsState> emit,
|
|
||||||
) => emit(state.copyWith(rumusKalibrasi: event.rumus));
|
|
||||||
|
|
||||||
void _onOffsetChanged(
|
void _onOffsetChanged(
|
||||||
EvaporasiKoreksiOffsetChanged event,
|
EvaporasiKoreksiOffsetChanged event,
|
||||||
|
|
@ -221,17 +217,20 @@ class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettin
|
||||||
try {
|
try {
|
||||||
final pumpStartHour = int.tryParse(state.pumpStartTime.split(':').first) ?? 0;
|
final pumpStartHour = int.tryParse(state.pumpStartTime.split(':').first) ?? 0;
|
||||||
final pumpEndHour = int.tryParse(state.pumpEndTime.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_rendah': state.thresholdRendah,
|
||||||
'threshold_tinggi': state.thresholdTinggi,
|
'threshold_tinggi': state.thresholdTinggi,
|
||||||
'rumus_kalibrasi': state.rumusKalibrasi,
|
|
||||||
'koreksi_offset': state.koreksiOffset,
|
'koreksi_offset': state.koreksiOffset,
|
||||||
'pump_start_time': state.pumpStartTime,
|
'pump_start_time': state.pumpStartTime,
|
||||||
'pump_end_time': state.pumpEndTime,
|
'pump_end_time': state.pumpEndTime,
|
||||||
'jam_pompa_mulai': pumpStartHour,
|
'jam_pompa_mulai': pumpStartHour,
|
||||||
'jam_pompa_selesai': pumpEndHour,
|
'jam_pompa_selesai': pumpEndHour,
|
||||||
'd0': state.d0,
|
|
||||||
'dmax_manual': state.dmaxManual,
|
|
||||||
'standar_tinggi_cm': state.standarTinggiCm,
|
'standar_tinggi_cm': state.standarTinggiCm,
|
||||||
'batas_kritis_cm': state.batasKritisCm,
|
'batas_kritis_cm': state.batasKritisCm,
|
||||||
'interval_realtime_ms': state.intervalRealtime_ms,
|
'interval_realtime_ms': state.intervalRealtime_ms,
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,6 @@ class EvaporasiThresholdTinggiChanged extends EvaporasiSettingsEvent {
|
||||||
@override List<Object?> get props => [value];
|
@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 {
|
class EvaporasiKoreksiOffsetChanged extends EvaporasiSettingsEvent {
|
||||||
final double value;
|
final double value;
|
||||||
|
|
|
||||||
|
|
@ -444,148 +444,7 @@ 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 {
|
class _InfoCard extends StatelessWidget {
|
||||||
final String text;
|
final String text;
|
||||||
const _InfoCard(this.text);
|
const _InfoCard(this.text);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ enum EvaporasiSettingsStatus { loading, loaded, saving, saved, error }
|
||||||
class EvaporasiSettingsState extends Equatable {
|
class EvaporasiSettingsState extends Equatable {
|
||||||
final double thresholdRendah;
|
final double thresholdRendah;
|
||||||
final double thresholdTinggi;
|
final double thresholdTinggi;
|
||||||
final String rumusKalibrasi;
|
|
||||||
final double koreksiOffset;
|
final double koreksiOffset;
|
||||||
final String pumpStartTime;
|
final String pumpStartTime;
|
||||||
final String pumpEndTime;
|
final String pumpEndTime;
|
||||||
|
|
@ -45,7 +44,6 @@ class EvaporasiSettingsState extends Equatable {
|
||||||
const EvaporasiSettingsState({
|
const EvaporasiSettingsState({
|
||||||
this.thresholdRendah = 2.0,
|
this.thresholdRendah = 2.0,
|
||||||
this.thresholdTinggi = 10.0,
|
this.thresholdTinggi = 10.0,
|
||||||
this.rumusKalibrasi = 'selisih_max',
|
|
||||||
this.koreksiOffset = 0.0,
|
this.koreksiOffset = 0.0,
|
||||||
this.pumpStartTime = '06:00',
|
this.pumpStartTime = '06:00',
|
||||||
this.pumpEndTime = '18:00',
|
this.pumpEndTime = '18:00',
|
||||||
|
|
@ -82,7 +80,6 @@ class EvaporasiSettingsState extends Equatable {
|
||||||
EvaporasiSettingsState copyWith({
|
EvaporasiSettingsState copyWith({
|
||||||
double? thresholdRendah,
|
double? thresholdRendah,
|
||||||
double? thresholdTinggi,
|
double? thresholdTinggi,
|
||||||
String? rumusKalibrasi,
|
|
||||||
double? koreksiOffset,
|
double? koreksiOffset,
|
||||||
String? pumpStartTime,
|
String? pumpStartTime,
|
||||||
String? pumpEndTime,
|
String? pumpEndTime,
|
||||||
|
|
@ -118,7 +115,6 @@ class EvaporasiSettingsState extends Equatable {
|
||||||
return EvaporasiSettingsState(
|
return EvaporasiSettingsState(
|
||||||
thresholdRendah: thresholdRendah ?? this.thresholdRendah,
|
thresholdRendah: thresholdRendah ?? this.thresholdRendah,
|
||||||
thresholdTinggi: thresholdTinggi ?? this.thresholdTinggi,
|
thresholdTinggi: thresholdTinggi ?? this.thresholdTinggi,
|
||||||
rumusKalibrasi: rumusKalibrasi ?? this.rumusKalibrasi,
|
|
||||||
koreksiOffset: koreksiOffset ?? this.koreksiOffset,
|
koreksiOffset: koreksiOffset ?? this.koreksiOffset,
|
||||||
pumpStartTime: pumpStartTime ?? this.pumpStartTime,
|
pumpStartTime: pumpStartTime ?? this.pumpStartTime,
|
||||||
pumpEndTime: pumpEndTime ?? this.pumpEndTime,
|
pumpEndTime: pumpEndTime ?? this.pumpEndTime,
|
||||||
|
|
@ -155,7 +151,7 @@ class EvaporasiSettingsState extends Equatable {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [
|
List<Object?> get props => [
|
||||||
thresholdRendah, thresholdTinggi, rumusKalibrasi, koreksiOffset,
|
thresholdRendah, thresholdTinggi, koreksiOffset,
|
||||||
pumpStartTime, pumpEndTime, d0, dmaxManual,
|
pumpStartTime, pumpEndTime, d0, dmaxManual,
|
||||||
intervalRealtime_ms, intervalHistory_ms, intervalBaca_ms,
|
intervalRealtime_ms, intervalHistory_ms, intervalBaca_ms,
|
||||||
standarTinggiCm, batasKritisCm, tempCompActive, tempCompCoef, tempRefC,
|
standarTinggiCm, batasKritisCm, tempCompActive, tempCompCoef, tempRefC,
|
||||||
|
|
|
||||||
|
|
@ -151,58 +151,6 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
||||||
_EvaporasiRealtimeUpdated event,
|
_EvaporasiRealtimeUpdated event,
|
||||||
Emitter<EvaporasiState> emit,
|
Emitter<EvaporasiState> emit,
|
||||||
) async {
|
) 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();
|
await _loadThresholdSettings();
|
||||||
|
|
||||||
final (status, willRain) = computeStatus(
|
final (status, willRain) = computeStatus(
|
||||||
|
|
@ -213,19 +161,9 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
||||||
_emitAlert(status, willRain, event.data.evaporasi);
|
_emitAlert(status, willRain, event.data.evaporasi);
|
||||||
|
|
||||||
emit(state.copyWith(
|
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,
|
currentValue: event.data.evaporasi,
|
||||||
temperature: event.data.suhu,
|
temperature: event.data.suhu,
|
||||||
waterLevel: event.data.tinggiAir,
|
waterLevel: event.data.tinggiAir,
|
||||||
chartValues: updatedChart,
|
|
||||||
chartTemperatures: updatedTemp,
|
|
||||||
chartLabels: updatedLabels,
|
|
||||||
weatherStatus: status,
|
weatherStatus: status,
|
||||||
willRain: willRain,
|
willRain: willRain,
|
||||||
currentData: event.data,
|
currentData: event.data,
|
||||||
|
|
|
||||||
|
|
@ -363,10 +363,6 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
||||||
_statusCard(state),
|
_statusCard(state),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// ── Kontrol Database Evaporasi ───────────────
|
|
||||||
const EvaporasiControlPanel(),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
|
|
||||||
// ── Range Selector ──────────────────────────
|
// ── Range Selector ──────────────────────────
|
||||||
const Text(
|
const Text(
|
||||||
'Tren Evaporasi & Suhu',
|
'Tren Evaporasi & Suhu',
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ class EvaporasiHistoryList extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final grouped = _groupByDate(history);
|
final grouped = _groupByDate(history);
|
||||||
final sortedKeys = grouped.keys.toList()
|
final sortedKeys = grouped.keys.toList()
|
||||||
..sort((a, b) => b.compareTo(a)); // terbaru di atas
|
..sort((a, b) => b.compareTo(a));
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
@ -68,15 +68,11 @@ class EvaporasiHistoryList extends StatelessWidget {
|
||||||
String _formatDateLabel(String key) {
|
String _formatDateLabel(String key) {
|
||||||
final dt = DateTime.parse(key);
|
final dt = DateTime.parse(key);
|
||||||
final today = DateTime.now();
|
final today = DateTime.now();
|
||||||
if (dt.year == today.year &&
|
if (dt.year == today.year && dt.month == today.month && dt.day == today.day) {
|
||||||
dt.month == today.month &&
|
|
||||||
dt.day == today.day) {
|
|
||||||
return 'Hari Ini — ${DateFormat('dd MMMM yyyy', 'id_ID').format(dt)}';
|
return 'Hari Ini — ${DateFormat('dd MMMM yyyy', 'id_ID').format(dt)}';
|
||||||
}
|
}
|
||||||
final yesterday = today.subtract(const Duration(days: 1));
|
final yesterday = today.subtract(const Duration(days: 1));
|
||||||
if (dt.year == yesterday.year &&
|
if (dt.year == yesterday.year && dt.month == yesterday.month && dt.day == yesterday.day) {
|
||||||
dt.month == yesterday.month &&
|
|
||||||
dt.day == yesterday.day) {
|
|
||||||
return 'Kemarin — ${DateFormat('dd MMMM yyyy', 'id_ID').format(dt)}';
|
return 'Kemarin — ${DateFormat('dd MMMM yyyy', 'id_ID').format(dt)}';
|
||||||
}
|
}
|
||||||
return DateFormat('EEEE, 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(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
const Text(
|
||||||
'History',
|
'Riwayat Data',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black87),
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (filtered)
|
if (filtered)
|
||||||
|
|
@ -171,37 +170,35 @@ class _ChipButton extends StatelessWidget {
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
// Group per tanggal
|
// Group per tanggal
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════
|
||||||
class _DateGroup extends StatelessWidget {
|
class _DateGroup extends StatefulWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final List<Evaporasi> items;
|
final List<Evaporasi> items;
|
||||||
|
|
||||||
const _DateGroup({required this.label, required this.items});
|
const _DateGroup({required this.label, required this.items});
|
||||||
|
|
||||||
double get _avgEvap {
|
@override
|
||||||
if (items.isEmpty) return 0;
|
State<_DateGroup> createState() => _DateGroupState();
|
||||||
return items.map((e) => e.evaporasi).reduce((a, b) => a + b) / items.length;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
double get _maxEvap {
|
class _DateGroupState extends State<_DateGroup> {
|
||||||
if (items.isEmpty) return 0;
|
bool _expanded = true;
|
||||||
return items.map((e) => e.evaporasi).reduce((a, b) => a > b ? a : b);
|
|
||||||
|
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 {
|
double get _avgTemp {
|
||||||
if (items.isEmpty) return 0;
|
if (widget.items.isEmpty) return 0;
|
||||||
final validTemps =
|
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;
|
if (validTemps.isEmpty) return 0;
|
||||||
return validTemps.map((e) => e.suhu).reduce((a, b) => a + b) /
|
return validTemps.map((e) => e.suhu).reduce((a, b) => a + b) /
|
||||||
validTemps.length;
|
validTemps.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
double get _maxTemp {
|
|
||||||
if (items.isEmpty) return 0;
|
|
||||||
return items.map((e) => e.suhu).reduce((a, b) => a > b ? a : b);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
|
|
@ -217,41 +214,103 @@ class _DateGroup extends StatelessWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Column(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
children: [
|
||||||
child: Row(
|
InkWell(
|
||||||
children: [
|
onTap: () => setState(() => _expanded = !_expanded),
|
||||||
Container(
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
width: 4,
|
child: Padding(
|
||||||
height: 36,
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
decoration: BoxDecoration(
|
child: Row(
|
||||||
color: Colors.blue.shade600,
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Text(label,
|
Container(
|
||||||
style: const TextStyle(
|
width: 4,
|
||||||
fontWeight: FontWeight.bold, fontSize: 13)),
|
height: 36,
|
||||||
const SizedBox(height: 2),
|
decoration: BoxDecoration(
|
||||||
Text(
|
color: Colors.blue.shade600,
|
||||||
'rata-rata ${_avgEvap.toStringAsFixed(2)} mm',
|
borderRadius: BorderRadius.circular(2),
|
||||||
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Expanded(
|
||||||
'rata-rata suhu ${_avgTemp.toStringAsFixed(1)} °C',
|
child: Column(
|
||||||
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(widget.label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold, fontSize: 13)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'${widget.items.length} data • rata-rata ${_avgEvap.toStringAsFixed(2)} mm • suhu rata-rata ${_avgTemp.toStringAsFixed(1)} °C',
|
||||||
|
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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(
|
||||||
|
'${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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue