gas
This commit is contained in:
parent
9b6362a61d
commit
d36d107daa
|
|
@ -0,0 +1,158 @@
|
|||
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_dmax';
|
||||
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<EvaporasiRumusKalibrasiChanged>(_onRumusChanged);
|
||||
on<EvaporasiKoreksiOffsetChanged>(_onOffsetChanged);
|
||||
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),
|
||||
rumusKalibrasi: (data['rumus_kalibrasi'] as String?) ?? 'selisih_max',
|
||||
koreksiOffset: _toDouble(data['koreksi_offset'], 0.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 rtSnap = await FirebaseDatabase.instance.ref(_rtdbRealtimePath).get();
|
||||
final val = rtSnap.exists ? (rtSnap.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 _onRumusChanged(
|
||||
EvaporasiRumusKalibrasiChanged event,
|
||||
Emitter<EvaporasiSettingsState> emit,
|
||||
) => emit(state.copyWith(rumusKalibrasi: event.rumus));
|
||||
|
||||
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));
|
||||
|
||||
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 {
|
||||
await _ref.set({
|
||||
'threshold_rendah': state.thresholdRendah,
|
||||
'threshold_tinggi': state.thresholdTinggi,
|
||||
'rumus_kalibrasi': state.rumusKalibrasi,
|
||||
'koreksi_offset': state.koreksiOffset,
|
||||
'interval_realtime_ms': state.intervalRealtime_ms,
|
||||
'interval_history_ms': state.intervalHistory_ms,
|
||||
'interval_baca_ms': state.intervalBaca_ms,
|
||||
'updated_at': ServerValue.timestamp,
|
||||
});
|
||||
emit(state.copyWith(status: EvaporasiSettingsStatus.saved));
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
emit(state.copyWith(status: EvaporasiSettingsStatus.loaded));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(
|
||||
status: EvaporasiSettingsStatus.error,
|
||||
errorMessage: e.toString(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
double _toDouble(dynamic v, double def) => v == null ? def : (v as num).toDouble();
|
||||
|
||||
int _toInt(dynamic v, int def) => v == null ? def : (v as num).toInt();
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
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 EvaporasiRumusKalibrasiChanged extends EvaporasiSettingsEvent {
|
||||
final String rumus;
|
||||
const EvaporasiRumusKalibrasiChanged(this.rumus);
|
||||
@override List<Object?> get props => [rumus];
|
||||
}
|
||||
|
||||
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 EvaporasiSettingsSaved extends EvaporasiSettingsEvent {}
|
||||
|
||||
class EvaporasiDmaxResetRequested extends EvaporasiSettingsEvent {}
|
||||
|
|
@ -0,0 +1,752 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'evaporasi_settings_bloc.dart';
|
||||
|
||||
class EvaporasiSettingsScreen extends StatefulWidget {
|
||||
const EvaporasiSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<EvaporasiSettingsScreen> createState() => _EvaporasiSettingsScreenState();
|
||||
}
|
||||
|
||||
class _EvaporasiSettingsScreenState extends State<EvaporasiSettingsScreen> {
|
||||
final _rendahController = TextEditingController();
|
||||
final _tinggiController = TextEditingController();
|
||||
final _offsetController = TextEditingController();
|
||||
|
||||
void _syncControllers(EvaporasiSettingsState s) {
|
||||
final rendah = s.thresholdRendah.toStringAsFixed(1);
|
||||
final tinggi = s.thresholdTinggi.toStringAsFixed(1);
|
||||
final offset = s.koreksiOffset.toStringAsFixed(2);
|
||||
if (_rendahController.text != rendah) {
|
||||
_rendahController.text = rendah;
|
||||
}
|
||||
if (_tinggiController.text != tinggi) {
|
||||
_tinggiController.text = tinggi;
|
||||
}
|
||||
if (_offsetController.text != offset) {
|
||||
_offsetController.text = offset;
|
||||
}
|
||||
}
|
||||
|
||||
void _showResetDmaxDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
title: const Row(children: [
|
||||
Icon(Icons.restart_alt_rounded, color: Colors.orange),
|
||||
SizedBox(width: 8),
|
||||
Text('Reset DMAX'),
|
||||
]),
|
||||
content: const Text(
|
||||
'DMAX akan direset ke nilai default.\n\n'
|
||||
'ESP32 akan mencari nilai DMAX baru secara otomatis '
|
||||
'pada pembacaan berikutnya.\n\n'
|
||||
'Lakukan ini hanya jika sensor diganti atau '
|
||||
'kalibrasi ulang diperlukan.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: Text('Batal', style: TextStyle(color: Colors.grey.shade600)),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
context.read<EvaporasiSettingsBloc>().add(EvaporasiDmaxResetRequested());
|
||||
},
|
||||
icon: const Icon(Icons.restart_alt_rounded, size: 16),
|
||||
label: const Text('Reset DMAX'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.orange.shade700,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_rendahController.dispose();
|
||||
_tinggiController.dispose();
|
||||
_offsetController.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())
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_InfoCard(
|
||||
'Pengaturan disimpan ke Firebase Firestore dan '
|
||||
'langsung berlaku untuk semua perangkat.',
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_sectionTitle('Batas Status Evaporasi'),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Tentukan nilai (mm) untuk klasifikasi status '
|
||||
'Rendah, Normal, dan Tinggi.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_SettingsCard(children: [
|
||||
_ThresholdPreview(state: state),
|
||||
const Divider(height: 28),
|
||||
_NumericField(
|
||||
controller: _rendahController,
|
||||
label: 'Batas Rendah–Normal (mm)',
|
||||
hint: 'Contoh: 20.0',
|
||||
helper: 'Nilai < batas ini → Status Rendah. Default: 20.0',
|
||||
onChanged: (v) {
|
||||
final d = double.tryParse(v);
|
||||
if (d != null && d >= 0) {
|
||||
bloc.add(EvaporasiThresholdRendahChanged(d));
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_NumericField(
|
||||
controller: _tinggiController,
|
||||
label: 'Batas Normal–Tinggi (mm)',
|
||||
hint: 'Contoh: 30.0',
|
||||
helper: 'Nilai ≥ batas ini → Status Tinggi. Default: 30.0',
|
||||
onChanged: (v) {
|
||||
final d = double.tryParse(v);
|
||||
if (d != null && d >= 0) {
|
||||
bloc.add(EvaporasiThresholdTinggiChanged(d));
|
||||
}
|
||||
},
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 24),
|
||||
_sectionTitle('Rumus Kalibrasi E'),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Pilih metode penghitungan nilai evaporasi '
|
||||
'terkalibrasi (E) dari data sensor.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_SettingsCard(children: [
|
||||
_RumusSelector(
|
||||
selected: state.rumusKalibrasi,
|
||||
onChanged: (v) => bloc.add(EvaporasiRumusKalibrasiChanged(v)),
|
||||
),
|
||||
const Divider(height: 28),
|
||||
_FormulaPreview(state: state),
|
||||
const Divider(height: 28),
|
||||
_NumericField(
|
||||
controller: _offsetController,
|
||||
label: 'Koreksi Offset (mm)',
|
||||
hint: 'Contoh: 0.0 atau -1.5',
|
||||
helper: 'Nilai ditambahkan ke hasil E. Gunakan negatif untuk koreksi ke bawah.',
|
||||
allowNegative: true,
|
||||
onChanged: (v) {
|
||||
final d = double.tryParse(v);
|
||||
if (d != null) {
|
||||
bloc.add(EvaporasiKoreksiOffsetChanged(d));
|
||||
}
|
||||
},
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 24),
|
||||
_sectionTitle('Interval Pengiriman & Pembacaan'),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Atur seberapa sering ESP32 membaca sensor dan mengirim data. '
|
||||
'Interval lebih pendek = data lebih real-time, baterai lebih boros.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_SettingsCard(children: [
|
||||
_IntervalSelector(
|
||||
label: 'Interval Baca Sensor',
|
||||
helper: 'Seberapa sering sensor dibaca. Min: 5 detik.',
|
||||
options: const {
|
||||
5000: '5 detik',
|
||||
10000: '10 detik (default)',
|
||||
30000: '30 detik',
|
||||
60000: '1 menit',
|
||||
},
|
||||
selected: state.intervalBaca_ms,
|
||||
onChanged: (v) => bloc.add(EvaporasiIntervalBacaChanged(v)),
|
||||
),
|
||||
const Divider(height: 24),
|
||||
_IntervalSelector(
|
||||
label: 'Interval Kirim Realtime',
|
||||
helper: 'Frekuensi update data real-time ke Firebase.',
|
||||
options: const {
|
||||
60000: '1 menit',
|
||||
300000: '5 menit (default)',
|
||||
600000: '10 menit',
|
||||
},
|
||||
selected: state.intervalRealtime_ms,
|
||||
onChanged: (v) => bloc.add(EvaporasiIntervalRealtimeChanged(v)),
|
||||
),
|
||||
const Divider(height: 24),
|
||||
_IntervalSelector(
|
||||
label: 'Interval Simpan History',
|
||||
helper: 'Frekuensi pencatatan ke riwayat Firebase.',
|
||||
options: const {
|
||||
300000: '5 menit',
|
||||
600000: '10 menit (default)',
|
||||
1800000: '30 menit',
|
||||
3600000: '1 jam',
|
||||
},
|
||||
selected: state.intervalHistory_ms,
|
||||
onChanged: (v) => bloc.add(EvaporasiIntervalHistoryChanged(v)),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 24),
|
||||
_sectionTitle('Kalibrasi DMAX'),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'DMAX adalah nilai raw sensor saat panci penuh. '
|
||||
'Diperbarui otomatis oleh ESP32. Reset hanya '
|
||||
'jika sensor diganti atau kalibrasi ulang diperlukan.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_SettingsCard(children: [
|
||||
_DmaxPanel(
|
||||
dmax: state.dmax,
|
||||
isResetting: state.isResettingDmax,
|
||||
onReset: () => _showResetDmaxDialog(context),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: isSaving ? null : () => bloc.add(EvaporasiSettingsSaved()),
|
||||
icon: isSaving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Icon(Icons.save_rounded),
|
||||
label: Text(isSaving ? 'Menyimpan...' : 'Simpan ke Firebase'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue.shade700,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionTitle(String text) => Text(
|
||||
text,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
);
|
||||
}
|
||||
|
||||
class _ThresholdPreview extends StatelessWidget {
|
||||
final EvaporasiSettingsState state;
|
||||
const _ThresholdPreview({required this.state});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rendah = state.thresholdRendah;
|
||||
final tinggi = state.thresholdTinggi;
|
||||
final isValid = rendah < tinggi;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Preview Klasifikasi',
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.black54)),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
_StatusChip('Rendah', '< ${rendah.toStringAsFixed(0)} mm', Colors.green),
|
||||
const SizedBox(width: 8),
|
||||
_StatusChip('Normal', '${rendah.toStringAsFixed(0)}–${tinggi.toStringAsFixed(0)} mm', Colors.orange),
|
||||
const SizedBox(width: 8),
|
||||
_StatusChip('Tinggi', '≥ ${tinggi.toStringAsFixed(0)} mm', Colors.red),
|
||||
],
|
||||
),
|
||||
if (!isValid) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(children: [
|
||||
Icon(Icons.warning_rounded, size: 14, color: Colors.red.shade600),
|
||||
const SizedBox(width: 6),
|
||||
Text('Batas Rendah harus < batas Tinggi',
|
||||
style: TextStyle(fontSize: 11, color: Colors.red.shade600)),
|
||||
]),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusChip extends StatelessWidget {
|
||||
final String label;
|
||||
final String range;
|
||||
final Color color;
|
||||
const _StatusChip(this.label, this.range, this.color);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: color.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(label,
|
||||
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: color)),
|
||||
const SizedBox(height: 2),
|
||||
Text(range, style: TextStyle(fontSize: 9, color: color), textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RumusSelector extends StatelessWidget {
|
||||
final String selected;
|
||||
final ValueChanged<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;
|
||||
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 _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 _DmaxPanel extends StatelessWidget {
|
||||
final int dmax;
|
||||
final bool isResetting;
|
||||
final VoidCallback onReset;
|
||||
|
||||
const _DmaxPanel({
|
||||
required this.dmax,
|
||||
required this.isResetting,
|
||||
required this.onReset,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Nilai DMAX Saat Ini',
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.black54),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade900,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Icon(Icons.memory_rounded, size: 15, color: Colors.amber.shade300),
|
||||
const SizedBox(width: 8),
|
||||
Text('Raw Sensor Value',
|
||||
style: TextStyle(fontSize: 12, color: Colors.amber.shade300, fontWeight: FontWeight.bold)),
|
||||
]),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
dmax == 0 ? '-- (belum terbaca)' : dmax.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: dmax == 0 ? Colors.grey.shade500 : Colors.green.shade300,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Diperbarui otomatis oleh ESP32',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey.shade500),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.shade50,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.orange.shade100),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.info_outline_rounded, size: 16, color: Colors.orange.shade700),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'DMAX = nilai ADC saat panci penuh (tinggi air maksimum). '
|
||||
'Semakin besar DMAX, semakin akurat pembacaan tinggi air.',
|
||||
style: TextStyle(fontSize: 11, color: Colors.orange.shade800),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: isResetting ? null : onReset,
|
||||
icon: isResetting
|
||||
? SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.orange.shade700),
|
||||
)
|
||||
: const Icon(Icons.restart_alt_rounded),
|
||||
label: Text(isResetting ? 'Mereset...' : 'Reset DMAX ke Default'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.orange.shade700,
|
||||
side: BorderSide(color: Colors.orange.shade300),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
part of 'evaporasi_settings_bloc.dart';
|
||||
|
||||
enum EvaporasiSettingsStatus { loading, loaded, saving, saved, error }
|
||||
|
||||
class EvaporasiSettingsState extends Equatable {
|
||||
final double thresholdRendah;
|
||||
final double thresholdTinggi;
|
||||
final String rumusKalibrasi;
|
||||
final double koreksiOffset;
|
||||
final int dmax;
|
||||
final bool isResettingDmax;
|
||||
|
||||
// Interval (milidetik) — dikirim ke ESP32 via RTDB
|
||||
final int intervalRealtime_ms; // default 300000 = 5 menit
|
||||
final int intervalHistory_ms; // default 600000 = 10 menit
|
||||
final int intervalBaca_ms; // default 10000 = 10 detik
|
||||
|
||||
final EvaporasiSettingsStatus status;
|
||||
final String? errorMessage;
|
||||
|
||||
const EvaporasiSettingsState({
|
||||
this.thresholdRendah = 2.0,
|
||||
this.thresholdTinggi = 10.0,
|
||||
this.rumusKalibrasi = 'selisih_max',
|
||||
this.koreksiOffset = 0.0,
|
||||
this.intervalRealtime_ms = 300000,
|
||||
this.intervalHistory_ms = 600000,
|
||||
this.intervalBaca_ms = 10000,
|
||||
this.status = EvaporasiSettingsStatus.loading,
|
||||
this.errorMessage,
|
||||
this.dmax = 0,
|
||||
this.isResettingDmax = false,
|
||||
});
|
||||
|
||||
EvaporasiSettingsState copyWith({
|
||||
double? thresholdRendah,
|
||||
double? thresholdTinggi,
|
||||
String? rumusKalibrasi,
|
||||
double? koreksiOffset,
|
||||
int? intervalRealtime_ms,
|
||||
int? intervalHistory_ms,
|
||||
int? intervalBaca_ms,
|
||||
EvaporasiSettingsStatus? status,
|
||||
String? errorMessage,
|
||||
int? dmax,
|
||||
bool? isResettingDmax,
|
||||
}) {
|
||||
return EvaporasiSettingsState(
|
||||
thresholdRendah: thresholdRendah ?? this.thresholdRendah,
|
||||
thresholdTinggi: thresholdTinggi ?? this.thresholdTinggi,
|
||||
rumusKalibrasi: rumusKalibrasi ?? this.rumusKalibrasi,
|
||||
koreksiOffset: koreksiOffset ?? this.koreksiOffset,
|
||||
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, rumusKalibrasi, koreksiOffset,
|
||||
intervalRealtime_ms, intervalHistory_ms, intervalBaca_ms,
|
||||
status, errorMessage,
|
||||
dmax, isResettingDmax,
|
||||
];
|
||||
}
|
||||
|
|
@ -13,6 +13,46 @@ 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;
|
||||
StreamSubscription<Evaporasi>? _subscription;
|
||||
|
|
@ -196,6 +236,7 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
|||
List<double> temps;
|
||||
List<String> labels;
|
||||
|
||||
|
||||
if (isSingle) {
|
||||
// 1 hari → per jam
|
||||
values = TimeSeriesMapper.toSpecificDate(
|
||||
|
|
@ -212,26 +253,50 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
|||
);
|
||||
labels = List.generate(24, (i) => '${i.toString().padLeft(2, '0')}:00');
|
||||
} else {
|
||||
// Range → per hari
|
||||
final evapResult = TimeSeriesMapper.toDateRange(
|
||||
data: history,
|
||||
getTime: (e) => e.timestamp,
|
||||
getValue: (e) => e.evaporasi,
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
);
|
||||
final tempResult = TimeSeriesMapper.toDateRange(
|
||||
data: history,
|
||||
getTime: (e) => e.timestamp,
|
||||
getValue: (e) => e.suhu,
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
);
|
||||
values = evapResult.values;
|
||||
temps = tempResult.values;
|
||||
labels = evapResult.labels;
|
||||
// Range → per hari (tapi evaporasi dikalibrasi dengan rumus:
|
||||
// E(hari ke-2) = max(evap hari ke-1) - max(evap hari ke-2))
|
||||
final days = _buildDayList(start, end);
|
||||
|
||||
|
||||
final dailyMaxEvap = List<double>.filled(days.length, 0.0);
|
||||
final dailyMaxTemp = List<double>.filled(days.length, 0.0);
|
||||
final dailyHasValue = List<bool>.filled(days.length, false);
|
||||
|
||||
|
||||
for (final item in history) {
|
||||
final d = DateTime(item.timestamp.year, item.timestamp.month, item.timestamp.day);
|
||||
final idx = days.indexWhere((x) => x == d);
|
||||
if (idx < 0) continue;
|
||||
|
||||
final evap = item.evaporasi;
|
||||
final temp = item.suhu;
|
||||
|
||||
if (!dailyHasValue[idx]) {
|
||||
dailyHasValue[idx] = true;
|
||||
dailyMaxEvap[idx] = evap;
|
||||
dailyMaxTemp[idx] = temp;
|
||||
} else {
|
||||
if (evap > dailyMaxEvap[idx]) dailyMaxEvap[idx] = evap;
|
||||
if (temp > dailyMaxTemp[idx]) dailyMaxTemp[idx] = temp;
|
||||
}
|
||||
}
|
||||
|
||||
// Hitung E berbasis pasangan H1 (maks hari sebelumnya) - H2 (maks hari ini).
|
||||
// Definisi sesuai permintaan: E untuk hari i = maxEvap(hari i-1) - maxEvap(hari i)
|
||||
// Mulai tanggal 21 Mei s/d sekarang: untuk hari pertama di rentang, nilainya 0
|
||||
final calibrated = List<double>.filled(days.length, 0.0);
|
||||
for (int i = 1; i < days.length; i++) {
|
||||
final e = dailyMaxEvap[i - 1] - dailyMaxEvap[i];
|
||||
calibrated[i] = e < 0 ? 0.0 : e;
|
||||
}
|
||||
|
||||
values = calibrated;
|
||||
temps = dailyMaxTemp;
|
||||
labels = _buildLabels(days);
|
||||
|
||||
}
|
||||
|
||||
|
||||
emit(state.copyWith(
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
|
|
@ -275,11 +340,16 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
|||
a.year == b.year && a.month == b.month && a.day == b.day;
|
||||
|
||||
static (String, bool) _computeStatus(double v) {
|
||||
if (v > 10.0) return ('Tinggi', true);
|
||||
if (v >= 2.0) return ('Normal', false);
|
||||
// Kategori status sesuai permintaan:
|
||||
// - v < 20 => Rendah ("10 mm masih rendah")
|
||||
// - 20 <= v < 30 => Normal/Sedang
|
||||
// - v >= 30 => Tinggi
|
||||
if (v >= 30.0) return ('Tinggi', true);
|
||||
if (v >= 20.0) return ('Normal', false);
|
||||
return ('Rendah', false);
|
||||
}
|
||||
|
||||
|
||||
void _emitAlert(String status, bool willRain, double value) {
|
||||
final AlertSeverity severity;
|
||||
final String message;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// 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';
|
||||
|
|
@ -318,8 +319,19 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
|||
: () => _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) {
|
||||
|
|
@ -361,6 +373,71 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
|||
const EvaporasiRangeSelector(),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ── Block nilai E (kalibrasi H1 - H2) ──────────────────
|
||||
BlocBuilder<EvaporasiBloc, EvaporasiState>(
|
||||
builder: (context, s) {
|
||||
if (s.chartValues.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// Untuk range > 1 hari, chartValues berisi E per hari
|
||||
final isRange = !s.isSingleDay;
|
||||
if (!isRange) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// Tampilkan E pada hari terakhir rentang (hari ke-N)
|
||||
final eLast = s.chartValues.last;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.blue.shade100),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: const [
|
||||
Icon(Icons.calculate_rounded,
|
||||
color: Colors.blue, size: 20),
|
||||
SizedBox(width: 10),
|
||||
Text(
|
||||
'Nilai Evaporasi Terkalibrasi (E)',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'E terakhir (hari terakhir rentang): ${eLast.toStringAsFixed(2)} mm',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue.shade700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Rumus: E = max(H1) − max(H2)',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.blueGrey.shade700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Chart ───────────────────────────────────
|
||||
BlocBuilder<EvaporasiBloc, EvaporasiState>(
|
||||
builder: (context, s) => EvaporasiChartWidget(
|
||||
|
|
@ -372,6 +449,7 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
|||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
|
||||
// ── Riwayat Data ────────────────────────────
|
||||
BlocBuilder<EvaporasiBloc, EvaporasiState>(
|
||||
builder: (context, s) => EvaporasiHistoryList(
|
||||
|
|
@ -519,6 +597,7 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _statusCard(EvaporasiState state) {
|
||||
Color statusColor;
|
||||
IconData statusIcon;
|
||||
|
|
|
|||
|
|
@ -198,7 +198,8 @@ class _DateGroup extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _DateGroupState extends State<_DateGroup> {
|
||||
bool _expanded = true;
|
||||
bool _expanded = false;
|
||||
|
||||
|
||||
double get _avgEvap {
|
||||
if (widget.items.isEmpty) return 0;
|
||||
|
|
|
|||
Loading…
Reference in New Issue