This commit is contained in:
Mochamad ongki ramadani 2026-05-28 22:33:26 +07:00
parent 9b6362a61d
commit d36d107daa
7 changed files with 1208 additions and 22 deletions

View File

@ -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();
}

View File

@ -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 {}

View File

@ -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 RendahNormal (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 NormalTinggi (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)),
),
),
),
],
);
}
}

View File

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

View File

@ -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,25 +253,49 @@ 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,
@ -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;

View File

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

View File

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