This commit is contained in:
Mochamad ongki ramadani 2026-06-21 22:36:32 +07:00
parent f713294638
commit 4b9ea8598c
10 changed files with 162 additions and 227 deletions

View File

@ -18,7 +18,6 @@ class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettin
on<EvaporasiSettingsStarted>(_onStarted);
on<EvaporasiThresholdRendahChanged>(_onThresholdRendahChanged);
on<EvaporasiThresholdTinggiChanged>(_onThresholdTinggiChanged);
// rumusKalibrasi removed: calculation is handled on ESP32 hardware
on<EvaporasiKoreksiOffsetChanged>(_onOffsetChanged);
on<EvaporasiPumpStartChanged>(_onPumpStartChanged);
on<EvaporasiPumpEndChanged>(_onPumpEndChanged);
@ -133,8 +132,6 @@ class EvaporasiSettingsBloc extends Bloc<EvaporasiSettingsEvent, EvaporasiSettin
Emitter<EvaporasiSettingsState> emit,
) => emit(state.copyWith(thresholdTinggi: event.value));
// rumusKalibrasi handling removed device firmware defines the formula
void _onOffsetChanged(
EvaporasiKoreksiOffsetChanged event,
Emitter<EvaporasiSettingsState> emit,

View File

@ -336,14 +336,6 @@ class _SensorSettingsTab extends StatelessWidget {
),
const SizedBox(height: 32),
// Rumus preview
_FormulaPreview(
kFaktor: state.kFaktor,
radiusM: state.radiusM,
magnetCount: state.magnetCount,
),
const SizedBox(height: 32),
// Tombol Simpan
SizedBox(
width: double.infinity,
@ -1037,75 +1029,6 @@ class _SettingsCard extends StatelessWidget {
}
}
class _FormulaPreview extends StatelessWidget {
final double kFaktor;
final double radiusM;
final int magnetCount;
const _FormulaPreview({
required this.kFaktor,
required this.radiusM,
required this.magnetCount,
});
@override
Widget build(BuildContext context) {
// Contoh: 1 pulsa per detik
final exampleRps = 1.0 / magnetCount;
final exampleSpeed = 2 * 3.14159 * radiusM * exampleRps * kFaktor;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade900,
borderRadius: BorderRadius.circular(14),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(Icons.functions_rounded,
size: 16, 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(
'speed = 2π × radius × (pulsa / (dt × magnet)) × k',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade400,
fontFamily: 'monospace'),
),
const SizedBox(height: 6),
Text(
' = 2π × ${radiusM.toStringAsFixed(3)}m × (p / (dt × $magnetCount)) × ${kFaktor.toStringAsFixed(1)}',
style: const TextStyle(
fontSize: 12, color: Colors.white70, fontFamily: 'monospace'),
),
const SizedBox(height: 10),
Text(
'@ 1 pulsa/detik → ${exampleSpeed.toStringAsFixed(3)} m/s',
style: TextStyle(
fontSize: 13,
color: Colors.green.shade300,
fontWeight: FontWeight.bold,
fontFamily: 'monospace'),
),
],
),
);
}
}
// WIDGET BARU: _MagnetCountSelector
// Tambahkan class baru ini di bagian bawah file,
// setelah class _FormulaPreview
class _MagnetCountSelector extends StatelessWidget {
final int selected;
final ValueChanged<int> onSelected;

View File

@ -5,6 +5,7 @@ import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:firebase_database/firebase_database.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:monitoring_repository/monitoring_repository.dart';
import '../../../../blocs/notification_bloc/notification_bloc.dart';
@ -56,6 +57,8 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
final MonitoringRepository _repository;
final NotificationBloc _notificationBloc;
final FlutterLocalNotificationsPlugin _localNotificationsPlugin =
FlutterLocalNotificationsPlugin();
StreamSubscription<Evaporasi>? _subscription;
double _thresholdRendah = 2.0;
double _thresholdTinggi = 10.0;
@ -66,6 +69,7 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
}) : _repository = repository,
_notificationBloc = notificationBloc,
super(EvaporasiState()) {
_initLocalNotifications();
on<WatchEvaporasiStarted>(_onStarted);
on<_EvaporasiRealtimeUpdated>(_onRealtimeUpdated);
on<EvaporasiDateRangeChanged>(_onDateRangeChanged);
@ -158,6 +162,17 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
thresholdRendah: _thresholdRendah,
thresholdTinggi: _thresholdTinggi,
);
final bool hasFallenBelowCritical =
event.data.tinggiAir < state.batasKritisCm &&
state.waterLevel >= state.batasKritisCm;
final bool statusBecameHigh =
state.weatherStatus != 'Tinggi' && status == 'Tinggi';
if (hasFallenBelowCritical || statusBecameHigh) {
await _showCriticalNotification();
}
_emitAlert(status, willRain, event.data.evaporasi);
emit(state.copyWith(
@ -180,79 +195,29 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
emit(state.copyWith(isLoading: true));
final history = state.history;
final start = event.startDate;
final end = event.endDate;
final isSingle = _isSameDay(start, end);
List<double> values;
List<double> temps;
List<String> labels;
if (isSingle) {
// 1 hari per jam
values = TimeSeriesMapper.toSpecificDate(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.evaporasi,
targetDate: start,
);
temps = TimeSeriesMapper.toSpecificDate(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.suhu,
targetDate: start,
);
labels = List.generate(24, (i) => '${i.toString().padLeft(2, '0')}:00');
} else {
// 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);
}
final selectedDate = DateTime(
event.startDate.year,
event.startDate.month,
event.startDate.day,
);
final values = TimeSeriesMapper.toSpecificDate(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.evaporasi,
targetDate: selectedDate,
);
final temps = TimeSeriesMapper.toSpecificDate(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.suhu,
targetDate: selectedDate,
);
final labels = List.generate(24, (i) => '${i.toString().padLeft(2, '0')}:00');
emit(state.copyWith(
startDate: start,
endDate: end,
startDate: selectedDate,
endDate: selectedDate,
chartValues: values,
chartTemperatures: temps,
chartLabels: labels,
@ -317,6 +282,56 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
}
}
Future<void> _initLocalNotifications() async {
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const iosSettings = DarwinInitializationSettings();
const initSettings = InitializationSettings(
android: androidSettings,
iOS: iosSettings,
macOS: iosSettings,
);
try {
await _localNotificationsPlugin.initialize(settings: initSettings);
} catch (_) {
// Jika gagal, lanjutkan tanpa notifikasi lokal.
}
}
Future<void> _showCriticalNotification() async {
const androidDetails = AndroidNotificationDetails(
'evaporasi_alerts',
'Evaporasi Alerts',
channelDescription: 'Peringatan Evaporasi dan level air kritis',
importance: Importance.max,
priority: Priority.high,
ticker: 'Peringatan Darurat',
playSound: true,
enableVibration: true,
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentSound: true,
presentBadge: true,
);
const notificationDetails = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
macOS: iosDetails,
);
try {
await _localNotificationsPlugin.show(
id: 0,
title: 'Peringatan Darurat',
body: 'Peringatan Darurat: Batas Air Kritis tercapai, periksa pompa segera!',
notificationDetails: notificationDetails,
);
} catch (_) {
// Ignore local notification failure.
}
}
static double _toDouble(dynamic value, double fallback) {
if (value == null) return fallback;
if (value is num) return value.toDouble();

View File

@ -5,6 +5,7 @@ class EvaporasiState extends Equatable {
final double currentValue;
final double temperature;
final double waterLevel;
final double batasKritisCm;
// Rentang tanggal aktif untuk grafik
final DateTime startDate;
@ -31,6 +32,7 @@ class EvaporasiState extends Equatable {
this.currentValue = 0.0,
this.temperature = 0.0,
this.waterLevel = 0.0,
this.batasKritisCm = 15.0,
DateTime? startDate,
DateTime? endDate,
this.chartValues = const [],
@ -50,6 +52,7 @@ class EvaporasiState extends Equatable {
double? currentValue,
double? temperature,
double? waterLevel,
double? batasKritisCm,
DateTime? startDate,
DateTime? endDate,
List<double>? chartValues,
@ -68,6 +71,7 @@ class EvaporasiState extends Equatable {
currentValue: currentValue ?? this.currentValue,
temperature: temperature ?? this.temperature,
waterLevel: waterLevel ?? this.waterLevel,
batasKritisCm: batasKritisCm ?? this.batasKritisCm,
startDate: startDate ?? this.startDate,
endDate: endDate ?? this.endDate,
chartValues: chartValues ?? this.chartValues,
@ -94,7 +98,7 @@ class EvaporasiState extends Equatable {
@override
List<Object?> get props => [
currentValue, temperature, waterLevel,
currentValue, temperature, waterLevel, batasKritisCm,
startDate, endDate,
chartValues, chartTemperatures, chartLabels,
history, filteredHistory, selectedDateFilter,

View File

@ -372,71 +372,6 @@ 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(

View File

@ -20,7 +20,7 @@ class EvaporasiRangeSelector extends StatelessWidget {
: '${_fmt(start)}${_fmt(end)}';
return GestureDetector(
onTap: () => _pickRange(context, start, end),
onTap: () => _pickDate(context, start),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
@ -79,9 +79,9 @@ class EvaporasiRangeSelector extends StatelessWidget {
},
),
const SizedBox(width: 6),
// Tombol pilih range bebas
// Tombol pilih tanggal harian
GestureDetector(
onTap: () => _pickRange(context, start, end),
onTap: () => _pickDate(context, start),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
@ -115,13 +115,13 @@ class EvaporasiRangeSelector extends StatelessWidget {
);
}
Future<void> _pickRange(
BuildContext context, DateTime start, DateTime end) async {
final picked = await showDateRangePicker(
Future<void> _pickDate(
BuildContext context, DateTime date) async {
final picked = await showDatePicker(
context: context,
initialDate: date,
firstDate: DateTime(2024),
lastDate: DateTime.now(),
initialDateRange: DateTimeRange(start: start, end: end),
locale: const Locale('id', 'ID'),
builder: (context, child) {
return Theme(
@ -138,10 +138,11 @@ class EvaporasiRangeSelector extends StatelessWidget {
);
if (picked != null && context.mounted) {
final selected = DateTime(picked.year, picked.month, picked.day);
context.read<EvaporasiBloc>().add(
EvaporasiDateRangeChanged(
startDate: picked.start,
endDate: picked.end,
startDate: selected,
endDate: selected,
),
);
}

View File

@ -10,6 +10,7 @@ import cloud_firestore
import firebase_auth
import firebase_core
import firebase_database
import flutter_local_notifications
import flutter_localization
import google_sign_in_ios
import printing
@ -22,6 +23,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseDatabasePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseDatabasePlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterLocalizationPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalizationPlugin"))
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))

View File

@ -193,6 +193,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.8"
dbus:
dependency: transitive
description:
name: dbus
sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645"
url: "https://pub.dev"
source: hosted
version: "0.7.14"
dio:
dependency: "direct main"
description:
@ -374,6 +382,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.2"
flutter_local_notifications:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: a0d7141f14cabcee42967470a858dfc99dd6cfb70d3cab404bacfcafa9e84e70
url: "https://pub.dev"
source: hosted
version: "22.0.1"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b"
url: "https://pub.dev"
source: hosted
version: "8.0.1"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: ff0013eae795e8dc8fad4a8992a209e64d3ba2fbd8bf5e43c36bf448f95bd814
url: "https://pub.dev"
source: hosted
version: "12.0.0"
flutter_local_notifications_web:
dependency: transitive
description:
name: flutter_local_notifications_web
sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
flutter_local_notifications_windows:
dependency: transitive
description:
name: flutter_local_notifications_windows
sha256: "6f43bdd03b171b7a90f22647506fea33e2bb12294b7c7c7a3d690e960a382945"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
flutter_localization:
dependency: "direct main"
description:
@ -945,6 +993,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.7"
timezone:
dependency: transitive
description:
name: timezone
sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
url: "https://pub.dev"
source: hosted
version: "0.11.0"
typed_data:
dependency: transitive
description:
@ -1036,10 +1092,10 @@ packages:
dependency: transitive
description:
name: win32
sha256: a1fc9eb9248baa05dfc12ed5b66e377b3e23f095eec078e0371622b9033810d9
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
url: "https://pub.dev"
source: hosted
version: "6.2.0"
version: "6.3.0"
xdg_directories:
dependency: transitive
description:

View File

@ -29,6 +29,7 @@ dependencies:
path: packages/monitoring_repository
path_provider: ^2.1.5
pdf: ^3.11.1
flutter_local_notifications: ^22.0.1
printing: ^5.13.1
rxdart: ^0.27.7
share_plus: ^13.1.0

View File

@ -13,6 +13,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
flutter_local_notifications_windows
jni
)