This commit is contained in:
Mochamad ongki ramadani 2026-05-19 14:59:43 +07:00
parent 3eb4f32b09
commit 62ab660aa1
14 changed files with 1213 additions and 1007 deletions

17
TODO.md
View File

@ -1,16 +1,5 @@
# TODO - Fix flutter analyze issues
- [x] Pindahkan komponen pemilihan tanggal (📅 + EvaporasiPeriodSelector) di Evaporasi agar tampil di bawah grafik.
- [x] Pastikan tidak ada perubahan logika state (hanya urutan widget/placement).
- [ ] Jalankan hot reload/build untuk verifikasi UI.
## Step 1: Fix evaporasi.dart parse conflict
- Remove leftover git conflict markers in packages/monitoring_repository/lib/src/models/evaporasi.dart
- Unify timestamp parsing logic into a single implementation
- Ensure `factory Evaporasi.fromJson` always returns a non-null `Evaporasi`
## Step 2: Fix Google Sign-In import errors
- Investigate why `package:google_sign_in/google_sign_in.dart` is reported missing
- Update user_repository dependency versions if needed
- Run `flutter pub get` (from c:/flutter/klimatologi) and `flutter analyze` again
## Step 3: Re-run analyze and address remaining warnings
- Run `flutter analyze` and fix any remaining compile errors
- Optionally clean up performance/deprecation warnings (const constructors, withOpacity deprecation, Share->SharePlus)

Binary file not shown.

View File

@ -1,55 +1,88 @@
// lib/core/utils/time_series_mapper.dart
class TimeSeriesMapper {
/// =========================
/// 📅 DAILY (24 JAM)
/// =========================
// ============================================================
// FILTER SPIKE
// ============================================================
static List<double> _filterSpike(List<double> raw) {
final valid = raw.where((v) => v > 0).toList()..sort();
if (valid.isEmpty) return raw;
final median = valid[valid.length ~/ 2];
if (median <= 0) return raw;
return raw.map((v) => (v > 0 && v > median * 3) ? -1.0 : v).toList();
}
// ============================================================
// INTERPOLASI GAP
// ============================================================
static List<double> _interpolate(List<double> raw) {
final result = List<double>.from(raw);
final n = result.length;
for (int i = 0; i < n; i++) {
if (result[i] >= 0) continue;
double? prev; int prevIdx = -1;
for (int j = i - 1; j >= 0; j--) {
if (result[j] >= 0) { prev = result[j]; prevIdx = j; break; }
}
double? next; int nextIdx = -1;
for (int j = i + 1; j < n; j++) {
if (result[j] >= 0) { next = result[j]; nextIdx = j; break; }
}
if (prev != null && next != null) {
final t = (i - prevIdx) / (nextIdx - prevIdx);
result[i] = prev + t * (next - prev);
} else if (prev != null) {
result[i] = prev;
} else if (next != null) {
result[i] = next;
} else {
result[i] = 0.0;
}
}
return result;
}
// ============================================================
// DAILY (24 JAM)
// ============================================================
static List<double> toDaily<T>({
required List<T> data,
required DateTime Function(T) getTime,
required double Function(T) getValue,
}) {
final now = DateTime.now();
final sums = List<double>.filled(24, 0.0);
final counts = List<int>.filled(24, 0);
for (final item in data) {
final time = getTime(item);
if (_isSameDay(time, now)) {
final hour = time.toLocal().hour; // FIX: pastikan pakai local hour
final hour = time.toLocal().hour;
if (hour >= 0 && hour < 24) {
sums[hour] += getValue(item);
counts[hour]++;
}
}
}
return List.generate(24, (i) {
if (counts[i] == 0) return 0;
return sums[i] / counts[i];
});
final raw = List<double>.generate(24, (i) =>
counts[i] == 0 ? -1.0 : sums[i] / counts[i]);
return _interpolate(_filterSpike(raw));
}
/// =========================
/// 📅 WEEKLY (7 HARI)
/// =========================
// ============================================================
// WEEKLY
// ============================================================
static List<double> toWeekly<T>({
required List<T> data,
required DateTime Function(T) getTime,
required double Function(T) getValue,
}) {
final now = DateTime.now();
final sums = List<double>.filled(7, 0.0);
final counts = List<int>.filled(7, 0);
DateTime startOfWeek = now.subtract(Duration(days: now.weekday - 1));
startOfWeek =
DateTime(startOfWeek.year, startOfWeek.month, startOfWeek.day);
startOfWeek = DateTime(startOfWeek.year, startOfWeek.month, startOfWeek.day);
for (final item in data) {
final time = getTime(item).toLocal(); // FIX: konversi ke local
final time = getTime(item).toLocal();
if (!time.isBefore(startOfWeek)) {
final index = time.weekday - 1;
if (index >= 0 && index < 7) {
@ -58,16 +91,14 @@ class TimeSeriesMapper {
}
}
}
return List.generate(7, (i) {
if (counts[i] == 0) return 0;
return sums[i] / counts[i];
});
final raw = List<double>.generate(7, (i) =>
counts[i] == 0 ? -1.0 : sums[i] / counts[i]);
return _interpolate(_filterSpike(raw));
}
/// =========================
/// 📅 MONTHLY
/// =========================
// ============================================================
// MONTHLY
// ============================================================
static List<double> toMonthly<T>({
required List<T> data,
required DateTime Function(T) getTime,
@ -75,13 +106,10 @@ class TimeSeriesMapper {
}) {
final now = DateTime.now();
final daysInMonth = DateTime(now.year, now.month + 1, 0).day;
final sums = List<double>.filled(daysInMonth, 0.0);
final counts = List<int>.filled(daysInMonth, 0);
for (final item in data) {
final time = getTime(item).toLocal(); // FIX: konversi ke local
final time = getTime(item).toLocal();
if (time.month == now.month && time.year == now.year) {
final index = time.day - 1;
if (index >= 0 && index < daysInMonth) {
@ -90,16 +118,14 @@ class TimeSeriesMapper {
}
}
}
return List.generate(daysInMonth, (i) {
if (counts[i] == 0) return 0;
return sums[i] / counts[i];
});
final raw = List<double>.generate(daysInMonth, (i) =>
counts[i] == 0 ? -1.0 : sums[i] / counts[i]);
return _interpolate(_filterSpike(raw));
}
/// =========================
/// 📅 SPECIFIC DATE (24 JAM - TANGGAL KHUSUS)
/// =========================
// ============================================================
// SPECIFIC DATE (24 JAM)
// ============================================================
static List<double> toSpecificDate<T>({
required List<T> data,
required DateTime Function(T) getTime,
@ -108,53 +134,101 @@ class TimeSeriesMapper {
}) {
final sums = List<double>.filled(24, 0.0);
final counts = List<int>.filled(24, 0);
for (final item in data) {
final time = getTime(item);
if (_isSameDay(time, targetDate)) {
final hour = time.toLocal().hour; // FIX: pastikan pakai local hour
final hour = time.toLocal().hour;
if (hour >= 0 && hour < 24) {
sums[hour] += getValue(item);
counts[hour]++;
}
}
}
return List.generate(24, (i) {
if (counts[i] == 0) return 0;
return sums[i] / counts[i];
});
final raw = List<double>.generate(24, (i) =>
counts[i] == 0 ? -1.0 : sums[i] / counts[i]);
return _interpolate(_filterSpike(raw));
}
/// =========================
/// 🧠 HELPER Bandingkan tanggal secara LOCAL (bukan UTC)
/// FIX: Firebase datetime "2026-05-14 01:25:25" di-parse sebagai local time,
/// jadi perbandingan harus pakai local time juga agar tidak mismatch timezone
/// =========================
// ============================================================
// DATE RANGE rentang tanggal bebas, agregasi per hari
// Return: values (satu titik per hari) + labels (dd/MM atau dd MMM)
// ============================================================
static ({List<double> values, List<String> labels}) toDateRange<T>({
required List<T> data,
required DateTime Function(T) getTime,
required double Function(T) getValue,
required DateTime startDate,
required DateTime endDate,
}) {
final start = DateTime(startDate.year, startDate.month, startDate.day);
final end = DateTime(endDate.year, endDate.month, endDate.day);
// Buat list semua hari dalam rentang
final days = <DateTime>[];
DateTime cur = start;
while (!cur.isAfter(end)) {
days.add(cur);
cur = cur.add(const Duration(days: 1));
}
if (days.isEmpty) return (values: [], labels: []);
final sums = List<double>.filled(days.length, 0.0);
final counts = List<int>.filled(days.length, 0);
for (final item in data) {
final time = getTime(item).toLocal();
final dayOnly = DateTime(time.year, time.month, time.day);
for (int i = 0; i < days.length; i++) {
if (dayOnly == days[i]) {
sums[i] += getValue(item);
counts[i]++;
break;
}
}
}
final raw = List<double>.generate(days.length, (i) =>
counts[i] == 0 ? -1.0 : sums[i] / counts[i]);
final values = _interpolate(_filterSpike(raw));
// Label: "dd MMM" jika <= 14 hari, "dd/MM" jika lebih
final labels = days.map((d) {
if (days.length <= 14) {
return '${d.day} ${_bulan(d.month)}';
}
return '${d.day}/${d.month}';
}).toList();
return (values: values, labels: labels);
}
static String _bulan(int m) {
const b = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun',
'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'];
return b[m];
}
// ============================================================
// HELPER
// ============================================================
static bool _isSameDay(DateTime a, DateTime b) {
final al = a.toLocal();
final bl = b.toLocal();
return al.year == bl.year && al.month == bl.month && al.day == bl.day;
}
/// =========================
/// 📈 SMOOTH (moving average 3 titik)
/// =========================
static List<double> smooth(List<double> data) {
if (data.length < 3) return data;
final List<double> result = [];
final result = <double>[];
for (int i = 0; i < data.length; i++) {
if (i == 0 || i == data.length - 1) {
result.add(data[i]);
} else {
final avg = (data[i - 1] + data[i] + data[i + 1]) / 3;
result.add(avg);
result.add((data[i - 1] + data[i] + data[i + 1]) / 3);
}
}
return result;
}
}

View File

@ -1,3 +1,5 @@
// lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart
import 'dart:async';
import 'package:bloc/bloc.dart';
@ -20,14 +22,16 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
required NotificationBloc notificationBloc,
}) : _repository = repository,
_notificationBloc = notificationBloc,
super(const EvaporasiState()) {
super(EvaporasiState()) {
on<WatchEvaporasiStarted>(_onStarted);
on<_EvaporasiRealtimeUpdated>(_onRealtimeUpdated);
on<EvaporasiPeriodChanged>(_onPeriodChanged);
on<EvaporasiDateSelected>(_onDateSelected);
on<EvaporasiViewModeChanged>(_onViewModeChanged);
on<EvaporasiDateRangeChanged>(_onDateRangeChanged);
on<EvaporasiDateFilterChanged>(_onDateFilterChanged);
}
//
// START
//
Future<void> _onStarted(
WatchEvaporasiStarted event,
Emitter<EvaporasiState> emit,
@ -39,249 +43,247 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
'Monitoring/History',
(json) => Evaporasi.fromJson(json),
),
)
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
)..sort((a, b) => a.timestamp.compareTo(b.timestamp));
final listData = List<Evaporasi>.from(history);
final now = DateTime.now();
final dailyGraph = TimeSeriesMapper.toDaily(
// Default: tampilkan hari ini (per jam)
final dailyEvap = TimeSeriesMapper.toDaily(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.evaporasi,
);
final dailyTempGraph = TimeSeriesMapper.toDaily(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.suhu,
);
final weeklyGraph = TimeSeriesMapper.toWeekly(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.evaporasi,
);
final monthlyGraph = TimeSeriesMapper.toMonthly(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.evaporasi,
);
final weeklyTemp = TimeSeriesMapper.toWeekly(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.suhu,
);
final monthlyTemp = TimeSeriesMapper.toMonthly(
final dailyTemp = TimeSeriesMapper.toDaily(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.suhu,
);
final labels = List.generate(
24, (i) => '${i.toString().padLeft(2, '0')}:00');
final lastValue = history.isNotEmpty ? history.last.evaporasi : 0.0;
final lastWaterLevel = history.isNotEmpty ? history.last.tinggiAir : 0.0;
final lastTemperature = history.isNotEmpty ? history.last.suhu : 0.0;
final lastWater = history.isNotEmpty ? history.last.tinggiAir : 0.0;
final lastTemp = history.isNotEmpty ? history.last.suhu : 0.0;
final (status, rain) = _computeWeatherStatus(lastValue);
_emitEvaporasiAlert(status, rain, lastValue);
final (status, willRain) = _computeStatus(lastValue);
_emitAlert(status, willRain, lastValue);
emit(state.copyWith(
history: history,
listData: listData,
filteredHistory: history,
currentValue: lastValue,
waterLevel: lastWaterLevel,
temperature: lastTemperature,
dailyValues: dailyGraph,
dailyTemperatures: dailyTempGraph,
weeklyValues: weeklyGraph,
monthlyValues: monthlyGraph,
weeklyTemperatures: weeklyTemp,
monthlyTemperatures: monthlyTemp,
chartLabels: _buildChartLabels(period: 'Hari Ini'),
waterLevel: lastWater,
temperature: lastTemp,
startDate: DateTime(now.year, now.month, now.day),
endDate: DateTime(now.year, now.month, now.day),
chartValues: dailyEvap,
chartTemperatures: dailyTemp,
chartLabels: labels,
weatherStatus: status,
willRain: rain,
willRain: willRain,
currentData: history.isNotEmpty ? history.last : null,
viewMode: EvaporasiViewMode.period,
selectedDate: null,
isLoading: false,
));
await _subscription?.cancel();
_subscription = _repository
.getSensorStream('Monitoring/History', _latestHistoryEntry)
.getSensorStream(
'Monitoring',
(json) {
final f = Map<dynamic, dynamic>.from(json)..remove('History');
return Evaporasi.fromJson(f);
},
)
.listen((data) => add(_EvaporasiRealtimeUpdated(data)));
}
Evaporasi _latestHistoryEntry(Map<dynamic, dynamic> json) {
if (json.isEmpty) return Evaporasi.empty;
final entries = json.values
.whereType<Map<dynamic, dynamic>>()
.map((item) => Evaporasi.fromJson(item))
.toList();
if (entries.isEmpty) return Evaporasi.empty;
entries.sort((a, b) => a.timestamp.compareTo(b.timestamp));
return entries.last;
}
//
// REALTIME UPDATE
//
void _onRealtimeUpdated(
_EvaporasiRealtimeUpdated event,
Emitter<EvaporasiState> emit,
) {
if (event.data.timestamp.millisecondsSinceEpoch == 0) return;
final updatedHistory = List<Evaporasi>.from(state.history);
final duplicateIndex = updatedHistory.indexWhere(
(item) => item.timestamp.toUtc() == event.data.timestamp.toUtc(),
final dupIdx = updatedHistory.indexWhere(
(e) => e.timestamp.toUtc() == event.data.timestamp.toUtc(),
);
if (duplicateIndex >= 0) {
updatedHistory[duplicateIndex] = event.data;
if (dupIdx >= 0) {
updatedHistory[dupIdx] = event.data;
} else {
updatedHistory.add(event.data);
}
updatedHistory.sort((a, b) => a.timestamp.compareTo(b.timestamp));
// Update bucket untuk tampilan chart harian (index hour)
final updated = List<double>.from(state.dailyValues);
final updatedTemp = List<double>.from(state.dailyTemperatures);
// Update chart jika sedang tampil hari ini per jam
List<double> updatedChart = state.chartValues;
List<double> updatedTemp = state.chartTemperatures;
final eventTime = event.data.timestamp;
final now = DateTime.now();
if (state.isSingleDay) {
final eventTime = event.data.timestamp.toLocal();
final now = DateTime.now();
final isToday = eventTime.year == now.year &&
eventTime.month == now.month &&
eventTime.day == now.day;
final isSameDayUtc = eventTime.toUtc().year == now.toUtc().year &&
eventTime.toUtc().month == now.toUtc().month &&
eventTime.toUtc().day == now.toUtc().day;
final isDuplicate = duplicateIndex >= 0;
if (isSameDayUtc && !isDuplicate) {
final index = eventTime.hour;
if (index >= 0 && index < updated.length) {
updated[index] = event.data.evaporasi;
updatedTemp[index] = event.data.suhu;
if (isToday && dupIdx < 0) {
updatedChart = List<double>.from(state.chartValues);
updatedTemp = List<double>.from(state.chartTemperatures);
final hour = eventTime.hour;
if (hour >= 0 && hour < 24) {
updatedChart[hour] = event.data.evaporasi;
updatedTemp[hour] = event.data.suhu;
}
}
}
final (status, rain) = _computeWeatherStatus(event.data.evaporasi);
_emitEvaporasiAlert(status, rain, event.data.evaporasi);
final (status, willRain) = _computeStatus(event.data.evaporasi);
_emitAlert(status, willRain, event.data.evaporasi);
emit(state.copyWith(
history: updatedHistory,
listData: updatedHistory,
filteredHistory: state.selectedDateFilter != null
? updatedHistory.where((e) =>
e.timestamp.year == state.selectedDateFilter!.year &&
e.timestamp.month == state.selectedDateFilter!.month &&
e.timestamp.day == state.selectedDateFilter!.day).toList()
: updatedHistory,
currentValue: event.data.evaporasi,
temperature: event.data.suhu,
waterLevel: event.data.tinggiAir,
dailyValues: updated,
dailyTemperatures: updatedTemp,
chartValues: updatedChart,
chartTemperatures: updatedTemp,
weatherStatus: status,
willRain: rain,
willRain: willRain,
currentData: event.data,
));
}
Future<void> _onPeriodChanged(
EvaporasiPeriodChanged event,
//
// DATE RANGE CHANGED
//
Future<void> _onDateRangeChanged(
EvaporasiDateRangeChanged event,
Emitter<EvaporasiState> emit,
) async {
emit(state.copyWith(isLoading: true, selectedPeriod: event.period));
emit(state.copyWith(isLoading: true));
final history = state.history;
final start = event.startDate;
final end = event.endDate;
List<double> updated;
List<double> updatedTemp;
final isSingle = _isSameDay(start, end);
if (event.period == 'Minggu Ini') {
updated = TimeSeriesMapper.toWeekly(
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,
);
updatedTemp = TimeSeriesMapper.toWeekly(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.suhu,
);
} else if (event.period == 'Bulan Ini') {
updated = TimeSeriesMapper.toMonthly(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.evaporasi,
);
updatedTemp = TimeSeriesMapper.toMonthly(
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 {
updated = TimeSeriesMapper.toDaily(
// Range per hari
final evapResult = TimeSeriesMapper.toDateRange(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.evaporasi,
startDate: start,
endDate: end,
);
updatedTemp = TimeSeriesMapper.toDaily(
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;
}
emit(state.copyWith(
dailyValues: updated,
dailyTemperatures: updatedTemp,
chartLabels: _buildChartLabels(period: event.period),
viewMode: EvaporasiViewMode.period,
clearSelectedDate: true,
startDate: start,
endDate: end,
chartValues: values,
chartTemperatures: temps,
chartLabels: labels,
isLoading: false,
));
}
Future<void> _onDateSelected(
EvaporasiDateSelected event,
//
// DATE FILTER (LIST)
//
void _onDateFilterChanged(
EvaporasiDateFilterChanged event,
Emitter<EvaporasiState> emit,
) async {
emit(state.copyWith(
isLoading: true,
selectedDate: event.date,
viewMode: EvaporasiViewMode.customDate,
));
final history = state.history;
final updated = TimeSeriesMapper.toSpecificDate(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.evaporasi,
targetDate: event.date,
);
final updatedTemp = TimeSeriesMapper.toSpecificDate(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.suhu,
targetDate: event.date,
);
) {
final date = event.date;
if (date == null) {
emit(state.copyWith(
filteredHistory: state.history,
clearSelectedDateFilter: true,
));
return;
}
final filtered = state.history.where((item) =>
item.timestamp.year == date.year &&
item.timestamp.month == date.month &&
item.timestamp.day == date.day).toList();
emit(state.copyWith(
dailyValues: updated,
dailyTemperatures: updatedTemp,
chartLabels: _buildChartLabels(period: 'Tanggal Khusus'),
isLoading: false,
filteredHistory: filtered,
selectedDateFilter: date,
));
}
Future<void> _onViewModeChanged(
EvaporasiViewModeChanged event,
Emitter<EvaporasiState> emit,
) async {
if (event.mode == EvaporasiViewMode.period) {
add(EvaporasiPeriodChanged(state.selectedPeriod));
//
// HELPERS
//
static bool _isSameDay(DateTime a, DateTime b) =>
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);
return ('Rendah', false);
}
void _emitAlert(String status, bool willRain, double value) {
final AlertSeverity severity;
final String message;
if (status == 'Tinggi') {
severity = AlertSeverity.danger;
message = 'Evaporasi ${value.toStringAsFixed(1)} mm — TINGGI';
} else if (status == 'Normal') {
severity = AlertSeverity.warning;
message = 'Evaporasi ${value.toStringAsFixed(1)} mm — Normal';
} else {
emit(state.copyWith(viewMode: event.mode));
severity = AlertSeverity.info;
message = '';
}
_notificationBloc.add(SensorAlertAdded(SensorAlert(
sensorId: 'evaporasi',
sensorName: 'Evaporasi',
message: message,
severity: severity,
timestamp: DateTime.now(),
)));
}
@override
@ -289,63 +291,12 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
await _subscription?.cancel();
return super.close();
}
static (String status, bool willRain) _computeWeatherStatus(double value) {
if (value <= 5.0) return ('Baik', false);
if (value <= 10.0) return ('Sedang', true);
return ('Buruk', true);
}
void _emitEvaporasiAlert(String status, bool willRain, double value) {
final AlertSeverity severity;
final String message;
if (status == 'Buruk') {
severity = AlertSeverity.danger;
message =
'Evaporasi ${value.toStringAsFixed(1)} mm — status BURUK, potensi hujan tinggi';
} else if (status == 'Sedang') {
severity = AlertSeverity.warning;
message =
'Evaporasi ${value.toStringAsFixed(1)} mm — status sedang, potensi hujan';
} else {
severity = AlertSeverity.info;
message = '';
}
_notificationBloc.add(SensorAlertAdded(
SensorAlert(
sensorId: 'evaporasi',
sensorName: 'Evaporasi',
message: message,
severity: severity,
timestamp: DateTime.now(),
),
));
}
List<String> _buildChartLabels({required String period}) {
if (period == 'Minggu Ini') {
return const ['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min'];
}
final now = DateTime.now();
if (period == 'Bulan Ini') {
final daysInMonth = DateTime(now.year, now.month + 1, 0).day;
return List.generate(daysInMonth, (i) => '${i + 1}');
}
// Hari Ini / Tanggal Khusus => 24 jam
return List.generate(24, (i) => '${i.toString().padLeft(2, '0')}:00');
}
}
class _EvaporasiRealtimeUpdated extends EvaporasiEvent {
final Evaporasi data;
const _EvaporasiRealtimeUpdated(this.data);
@override
List<Object> get props => [data];
}
List<Object?> get props => [data];
}

View File

@ -1,41 +1,38 @@
// lib/screens/monitoring/evaporasi/blocs/evaporasi_event.dart
part of 'evaporasi_bloc.dart';
abstract class EvaporasiEvent extends Equatable {
const EvaporasiEvent();
@override
List<Object> get props => [];
List<Object?> get props => [];
}
/// 🚀 START MONITORING
/// Mulai monitoring
class WatchEvaporasiStarted extends EvaporasiEvent {}
/// 📊 GANTI PERIODE (Harian / Mingguan / Bulanan)
class EvaporasiPeriodChanged extends EvaporasiEvent {
final String period;
/// Pilih rentang tanggal untuk grafik
/// startDate == endDate tampilkan per jam (1 hari)
/// startDate != endDate tampilkan per hari (range)
class EvaporasiDateRangeChanged extends EvaporasiEvent {
final DateTime startDate;
final DateTime endDate;
const EvaporasiPeriodChanged(this.period);
const EvaporasiDateRangeChanged({
required this.startDate,
required this.endDate,
});
@override
List<Object> get props => [period];
List<Object?> get props => [startDate, endDate];
}
/// 📅 PILIH TANGGAL KHUSUS (Custom Date Picker)
class EvaporasiDateSelected extends EvaporasiEvent {
final DateTime date;
const EvaporasiDateSelected(this.date);
/// Filter list history berdasarkan tanggal
/// Kirim date = null untuk reset
class EvaporasiDateFilterChanged extends EvaporasiEvent {
final DateTime? date;
const EvaporasiDateFilterChanged(this.date);
@override
List<Object> get props => [date];
}
/// 🔄 KEMBALI KE MODE PERIOD
class EvaporasiViewModeChanged extends EvaporasiEvent {
final EvaporasiViewMode mode;
const EvaporasiViewModeChanged(this.mode);
@override
List<Object> get props => [mode];
}
List<Object?> get props => [date];
}

View File

@ -1,71 +1,64 @@
// lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart
part of 'evaporasi_bloc.dart';
enum EvaporasiViewMode { period, customDate }
class EvaporasiState extends Equatable {
final double currentValue;
final double temperature;
final double waterLevel;
final String selectedPeriod;
final DateTime? selectedDate;
final EvaporasiViewMode viewMode;
final List<double> dailyValues;
final List<double> weeklyValues;
final List<double> monthlyValues;
final List<double> dailyTemperatures;
final List<double> weeklyTemperatures;
final List<double> monthlyTemperatures;
// Rentang tanggal aktif untuk grafik
final DateTime startDate;
final DateTime endDate;
// Data grafik
final List<double> chartValues;
final List<double> chartTemperatures;
final List<String> chartLabels;
final List<Evaporasi> listData;
// Data list
final List<Evaporasi> history;
final List<Evaporasi> filteredHistory;
// Filter list
final DateTime? selectedDateFilter;
final String weatherStatus;
final bool willRain;
final Evaporasi? currentData; // data realtime terbaru
final Evaporasi? currentData;
final bool isLoading;
const EvaporasiState({
EvaporasiState({
this.currentValue = 0.0,
this.temperature = 0.0,
this.waterLevel = 0.0,
this.selectedPeriod = 'Hari Ini',
this.selectedDate,
this.viewMode = EvaporasiViewMode.period,
this.dailyValues = const [],
this.weeklyValues = const [],
this.monthlyValues = const [],
this.dailyTemperatures = const [],
this.weeklyTemperatures = const [],
this.monthlyTemperatures = const [],
DateTime? startDate,
DateTime? endDate,
this.chartValues = const [],
this.chartTemperatures = const [],
this.chartLabels = const [],
this.listData = const [],
this.history = const [],
this.weatherStatus = 'Baik',
this.filteredHistory = const [],
this.selectedDateFilter,
this.weatherStatus = 'Rendah',
this.willRain = false,
this.currentData,
this.isLoading = true,
});
}) : startDate = startDate ?? DateTime.now(),
endDate = endDate ?? DateTime.now();
// FIX: Tambah clearSelectedDate flag agar selectedDate bisa di-null-kan
EvaporasiState copyWith({
double? currentValue,
double? temperature,
double? waterLevel,
String? selectedPeriod,
DateTime? selectedDate,
bool clearSelectedDate = false, // tambahan flag reset
EvaporasiViewMode? viewMode,
List<double>? dailyValues,
List<double>? weeklyValues,
List<double>? monthlyValues,
List<double>? dailyTemperatures,
List<double>? weeklyTemperatures,
List<double>? monthlyTemperatures,
DateTime? startDate,
DateTime? endDate,
List<double>? chartValues,
List<double>? chartTemperatures,
List<String>? chartLabels,
List<Evaporasi>? listData,
List<Evaporasi>? history,
List<Evaporasi>? filteredHistory,
DateTime? selectedDateFilter,
bool clearSelectedDateFilter = false,
String? weatherStatus,
bool? willRain,
Evaporasi? currentData,
@ -75,20 +68,16 @@ class EvaporasiState extends Equatable {
currentValue: currentValue ?? this.currentValue,
temperature: temperature ?? this.temperature,
waterLevel: waterLevel ?? this.waterLevel,
selectedPeriod: selectedPeriod ?? this.selectedPeriod,
// FIX: jika clearSelectedDate=true, set null; jika selectedDate diberikan, pakai itu;
// jika tidak, pertahankan yang lama
selectedDate: clearSelectedDate ? null : (selectedDate ?? this.selectedDate),
viewMode: viewMode ?? this.viewMode,
dailyValues: dailyValues ?? this.dailyValues,
weeklyValues: weeklyValues ?? this.weeklyValues,
monthlyValues: monthlyValues ?? this.monthlyValues,
dailyTemperatures: dailyTemperatures ?? this.dailyTemperatures,
weeklyTemperatures: weeklyTemperatures ?? this.weeklyTemperatures,
monthlyTemperatures: monthlyTemperatures ?? this.monthlyTemperatures,
startDate: startDate ?? this.startDate,
endDate: endDate ?? this.endDate,
chartValues: chartValues ?? this.chartValues,
chartTemperatures: chartTemperatures ?? this.chartTemperatures,
chartLabels: chartLabels ?? this.chartLabels,
listData: listData ?? this.listData,
history: history ?? this.history,
filteredHistory: filteredHistory ?? this.filteredHistory,
selectedDateFilter: clearSelectedDateFilter
? null
: (selectedDateFilter ?? this.selectedDateFilter),
weatherStatus: weatherStatus ?? this.weatherStatus,
willRain: willRain ?? this.willRain,
currentData: currentData ?? this.currentData,
@ -96,26 +85,19 @@ class EvaporasiState extends Equatable {
);
}
// 1 hari = tampil per jam, > 1 hari = tampil per hari
bool get isSingleDay {
final s = DateTime(startDate.year, startDate.month, startDate.day);
final e = DateTime(endDate.year, endDate.month, endDate.day);
return s == e;
}
@override
List<Object?> get props => [
currentValue,
temperature,
waterLevel,
selectedPeriod,
selectedDate,
viewMode,
dailyValues,
weeklyValues,
monthlyValues,
dailyTemperatures,
weeklyTemperatures,
monthlyTemperatures,
chartLabels,
listData,
history,
weatherStatus,
willRain,
currentData,
isLoading,
currentValue, temperature, waterLevel,
startDate, endDate,
chartValues, chartTemperatures, chartLabels,
history, filteredHistory, selectedDateFilter,
weatherStatus, willRain, currentData, isLoading,
];
}
}

View File

@ -1,3 +1,5 @@
// lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
@ -5,8 +7,9 @@ import 'package:intl/intl.dart';
import '../blocs/evaporasi_bloc.dart';
import '../../shared/utils/pdf/pdf_export_service.dart';
import '../../shared/widgets/export_pdf_button.dart';
import 'widgets/evaporasi_period_selector.dart';
import 'widgets/evaporasi_chart_widget.dart';
import 'widgets/evaporasi_range_selector.dart';
import 'widgets/evaporasi_history_list.dart';
class EvaporasiScreen extends StatefulWidget {
const EvaporasiScreen({super.key});
@ -22,7 +25,7 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
backgroundColor: Colors.grey.shade100,
appBar: AppBar(
title: const Text(
"Evaporasi",
'Evaporasi',
style: TextStyle(fontWeight: FontWeight.bold),
),
centerTitle: true,
@ -48,64 +51,70 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
return SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Main Card
_mainCard(state),
const SizedBox(height: 25),
_infoRow(state),
const SizedBox(height: 25),
_statusCard(state),
const SizedBox(height: 25),
const Text(
"Tren Evaporasi & Suhu",
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 15),
// Period selector with date picker
Builder(
builder: (context) {
final state = context.watch<EvaporasiBloc>().state;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (state.viewMode == EvaporasiViewMode.customDate &&
state.selectedDate != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
"📅 ${_formatDateInfo(state.selectedDate!)}",
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.blue.shade700,
),
),
),
const EvaporasiPeriodSelector(),
],
);
},
),
const SizedBox(height: 15),
// Chart
Builder(
builder: (context) {
final state = context.watch<EvaporasiBloc>().state;
return EvaporasiChartWidget(
dailyValues: state.dailyValues,
dailyTemperatures: state.dailyTemperatures,
period: state.viewMode == EvaporasiViewMode.customDate
? "Tanggal Khusus"
: state.selectedPeriod,
chartLabels: state.chartLabels,
);
},
),
const SizedBox(height: 20),
// FIX: Gunakan BlocBuilder agar list reaktif terhadap perubahan state
BlocBuilder<EvaporasiBloc, EvaporasiState>(
builder: (context, state) => _evaporasiList(state),
// Info Row
_infoRow(state),
const SizedBox(height: 20),
// Status Card
_statusCard(state),
const SizedBox(height: 20),
// Range Selector
const Text(
'Tren Evaporasi & Suhu',
style: TextStyle(
fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),
const EvaporasiRangeSelector(),
const SizedBox(height: 12),
// Chart
BlocBuilder<EvaporasiBloc, EvaporasiState>(
builder: (context, s) => EvaporasiChartWidget(
dailyValues: s.chartValues,
dailyTemperatures: s.chartTemperatures,
period: s.isSingleDay ? 'Hari Ini' : 'Range',
chartLabels: s.chartLabels,
),
),
const SizedBox(height: 20),
// Riwayat Data
BlocBuilder<EvaporasiBloc, EvaporasiState>(
builder: (context, s) => EvaporasiHistoryList(
history: s.filteredHistory,
selectedDate: s.selectedDateFilter,
onPickDate: () async {
final picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(2024),
lastDate: DateTime.now(),
locale: const Locale('id', 'ID'),
);
if (picked != null && context.mounted) {
context.read<EvaporasiBloc>().add(
EvaporasiDateFilterChanged(picked),
);
}
},
onClearDate: () {
context.read<EvaporasiBloc>().add(
const EvaporasiDateFilterChanged(null),
);
},
),
),
const SizedBox(height: 10),
// Export PDF
ExportPdfButton(
onExport: () => PdfExportService.evaporasi(
evaporasi: state.currentValue,
@ -115,6 +124,7 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
historyData: historyMaps.isNotEmpty ? historyMaps : null,
),
),
const SizedBox(height: 20),
],
),
);
@ -123,9 +133,6 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
);
}
// =========================
// 🔥 MAIN CARD (EVAPORASI)
// =========================
Widget _mainCard(EvaporasiState state) {
return Container(
width: double.infinity,
@ -141,7 +148,7 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
const Icon(Icons.water_drop, color: Colors.white, size: 45),
const SizedBox(height: 10),
Text(
state.currentValue.toStringAsFixed(1),
state.currentValue.toStringAsFixed(2),
style: const TextStyle(
fontSize: 70,
fontWeight: FontWeight.bold,
@ -149,7 +156,7 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
),
),
const Text(
"mm",
'mm',
style: TextStyle(color: Colors.white70, fontSize: 18),
),
],
@ -157,30 +164,22 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
);
}
// =========================
// 📊 INFO KECIL (SUHU & AIR)
// =========================
Widget _infoRow(EvaporasiState state) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_miniCard(
"Suhu",
"${state.temperature.toStringAsFixed(1)} °C",
Icons.thermostat,
Colors.orange,
),
_miniCard(
"Tinggi Air",
"${state.waterLevel.toStringAsFixed(1)} cm",
Icons.water,
Colors.blue,
),
_miniCard('Suhu Air',
'${state.temperature.toStringAsFixed(1)} °C',
Icons.thermostat, Colors.orange),
_miniCard('Tinggi Air',
'${state.waterLevel.toStringAsFixed(1)} cm',
Icons.water, Colors.blue),
],
);
}
Widget _miniCard(String title, String value, IconData icon, Color color) {
Widget _miniCard(
String title, String value, IconData icon, Color color) {
return Container(
width: 160,
padding: const EdgeInsets.all(15),
@ -196,48 +195,43 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: const TextStyle(fontSize: 12, color: Colors.grey)),
Text(value, style: const TextStyle(fontWeight: FontWeight.bold)),
style: const TextStyle(
fontSize: 12, color: Colors.grey)),
Text(value,
style: const TextStyle(fontWeight: FontWeight.bold)),
],
)
),
],
),
);
}
// =========================
// 📈 STATUS CARD
// =========================
Widget _statusCard(EvaporasiState state) {
Color statusColor;
IconData statusIcon;
String warningText;
switch (state.weatherStatus) {
case "Sedang":
case 'Normal':
statusColor = Colors.orange;
statusIcon = Icons.warning_amber_rounded;
warningText =
'Sedang — evaporasi dalam batas normal, pantau kondisi.';
break;
case "Buruk":
case 'Tinggi':
statusColor = Colors.red;
statusIcon = Icons.error_outline;
warningText =
'Tinggi — evaporasi signifikan, berpotensi memengaruhi kondisi lingkungan.';
break;
case "Baik":
case 'Rendah':
default:
statusColor = Colors.green;
statusIcon = Icons.check_circle_outline;
warningText = 'Rendah — evaporasi stabil, risiko dampak rendah.';
break;
}
final String warningText;
if (state.weatherStatus == 'Baik') {
warningText = 'Normal — evaporasi stabil, risiko dampak rendah.';
} else if (state.weatherStatus == 'Sedang') {
warningText = 'Sedang — evaporasi mulai tinggi, pantau kondisi cuaca.';
} else {
warningText =
'Tinggi — evaporasi signifikan, berpotensi memengaruhi kondisi lingkungan.';
}
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
@ -253,32 +247,25 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"Status Cuaca",
style: TextStyle(fontSize: 12, color: Colors.grey),
const Text('Status Evaporasi',
style:
TextStyle(fontSize: 12, color: Colors.grey)),
const SizedBox(height: 4),
Text(
state.weatherStatus,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: statusColor),
),
const SizedBox(height: 4),
Text(
state.weatherStatus == 'Baik'
? 'Normal'
: state.weatherStatus == 'Sedang'
? 'Sedang'
: 'Tinggi',
warningText,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: statusColor,
),
),
if (state.willRain)
Text(
warningText,
style: const TextStyle(
fontSize: 12,
color: Colors.red,
fontWeight: FontWeight.w500,
),
),
color: statusColor.withValues(alpha: 0.8),
fontWeight: FontWeight.w500),
),
],
),
),
@ -286,228 +273,4 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
),
);
}
// =========================
// 🧾 LIST DATA EVAPORASI FIXED FILTER
// =========================
Widget _evaporasiList(EvaporasiState state) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
// Filter list sesuai mode & periode yang aktif
List filteredData;
if (state.viewMode == EvaporasiViewMode.customDate &&
state.selectedDate != null) {
// Mode custom date: tampilkan hanya data di tanggal yang dipilih
final sel = DateTime(
state.selectedDate!.year,
state.selectedDate!.month,
state.selectedDate!.day,
);
filteredData = state.history
.where((e) {
final d =
DateTime(e.timestamp.year, e.timestamp.month, e.timestamp.day);
return d == sel;
})
.toList()
.reversed
.toList();
} else if (state.selectedPeriod == 'Hari Ini') {
// Mode Hari Ini: hanya data hari ini
filteredData = state.history
.where((e) {
final d =
DateTime(e.timestamp.year, e.timestamp.month, e.timestamp.day);
return d == today;
})
.toList()
.reversed
.toList();
} else if (state.selectedPeriod == 'Minggu Ini') {
// Mode Minggu Ini: 7 hari ke belakang dari hari ini
final weekStart = today.subtract(const Duration(days: 6));
filteredData = state.history
.where((e) {
final d =
DateTime(e.timestamp.year, e.timestamp.month, e.timestamp.day);
return !d.isBefore(weekStart) && !d.isAfter(today);
})
.toList()
.reversed
.toList();
} else if (state.selectedPeriod == 'Bulan Ini') {
// Mode Bulan Ini: hanya data bulan & tahun yang sama
filteredData = state.history
.where((e) =>
e.timestamp.year == now.year && e.timestamp.month == now.month)
.toList()
.reversed
.toList();
} else {
filteredData = state.history.reversed.toList();
}
// Header label sesuai mode
final String listTitle;
if (state.viewMode == EvaporasiViewMode.customDate &&
state.selectedDate != null) {
listTitle = 'Data ${_formatDateInfo(state.selectedDate!)}';
} else if (state.selectedPeriod == 'Hari Ini') {
listTitle = 'Data Hari Ini';
} else if (state.selectedPeriod == 'Minggu Ini') {
listTitle = 'Data Minggu Ini';
} else if (state.selectedPeriod == 'Bulan Ini') {
listTitle = 'Data Bulan Ini';
} else {
listTitle = 'List Data Evaporasi';
}
if (filteredData.isEmpty) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
listTitle,
style:
const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
const Text(
'Belum ada data untuk periode ini',
style: TextStyle(color: Colors.grey),
),
],
),
);
}
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
listTitle,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
SizedBox(
height: 280,
child: ListView.separated(
itemCount: filteredData.length,
itemBuilder: (context, index) {
final e = filteredData[index];
final dateLabel =
'${DateFormat('dd MMM yyyy', 'id_ID').format(e.timestamp)}${DateFormat('HH:mm:ss', 'id_ID').format(e.timestamp)}';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
dateLabel,
style: const TextStyle(
fontSize: 12,
color: Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${e.evaporasi.toStringAsFixed(1)} mm',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: Colors.blue,
),
),
const SizedBox(height: 4),
Text(
'Tinggi Air: ${e.tinggiAir.toStringAsFixed(1)} cm',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.blue.shade700,
),
),
const SizedBox(height: 4),
Text(
'Suhu: ${e.suhu.toStringAsFixed(1)} °C',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.orange.shade700,
),
),
const SizedBox(height: 4),
Text(
_statusTextForHistory(e.evaporasi),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: _statusColorForHistory(e.evaporasi),
),
),
],
),
],
),
);
},
separatorBuilder: (_, __) => const Divider(height: 1),
),
),
],
),
);
}
String _statusTextForHistory(double evaporasi) {
if (evaporasi <= 5.0) return 'Status: Normal';
if (evaporasi <= 10.0) return 'Status: Sedang';
return 'Status: Tinggi';
}
Color _statusColorForHistory(double evaporasi) {
if (evaporasi <= 5.0) return Colors.green;
if (evaporasi <= 10.0) return Colors.orange;
return Colors.red;
}
String _formatDateInfo(DateTime date) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final yesterday = today.subtract(const Duration(days: 1));
final selected = DateTime(date.year, date.month, date.day);
if (selected == today) {
return "Hari Ini";
} else if (selected == yesterday) {
return "Kemarin";
} else {
return DateFormat('EEEE, d MMMM yyyy', 'id_ID').format(date);
}
}
}
}

View File

@ -311,21 +311,30 @@ class EvaporasiChartWidget extends StatelessWidget {
);
return Container(
height: 400,
padding: const EdgeInsets.fromLTRB(8, 12, 8, 4),
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(13),
blurRadius: 10,
offset: const Offset(0, 5),
)
],
borderRadius: BorderRadius.circular(20),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'',
// NOTE: placeholder; judul seragam dengan AtmosphericScreen.
// Jika ingin judul Evaporasi, ganti sesuai kebutuhan.
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
SizedBox(
height: 220,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: chart,
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
@ -334,19 +343,15 @@ class EvaporasiChartWidget extends StatelessWidget {
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: Colors.blue.shade700,
decoration: const BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
const Text(
'Evaporasi (mm)',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.blueGrey,
),
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
],
),
@ -355,31 +360,20 @@ class EvaporasiChartWidget extends StatelessWidget {
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: Colors.orange.shade700,
decoration: const BoxDecoration(
color: Colors.orange,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
const Text(
'Suhu (°C)',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.brown,
),
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
],
),
],
),
const SizedBox(height: 8),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: chart,
),
),
],
),
);

View File

@ -1,237 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:table_calendar/table_calendar.dart';
import 'package:intl/intl.dart';
import '../../blocs/evaporasi_bloc.dart';
/// 📅 EVAPORASI DATE PICKER - WhatsApp Style
class EvaporasiDatePicker extends StatefulWidget {
const EvaporasiDatePicker({super.key});
@override
State<EvaporasiDatePicker> createState() => _EvaporasiDatePickerState();
}
class _EvaporasiDatePickerState extends State<EvaporasiDatePicker> {
CalendarFormat _calendarFormat = CalendarFormat.month;
DateTime _focusedDay = DateTime.now();
DateTime? _selectedDay;
@override
void initState() {
super.initState();
_selectedDay = _focusedDay;
}
@override
Widget build(BuildContext context) {
return Container(
height: 400,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
),
child: Column(
children: [
// Header with close button
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: () {
// Kembali ke mode period DAN TUTUP bottom sheet
context.read<EvaporasiBloc>().add(
const EvaporasiViewModeChanged(
EvaporasiViewMode.period),
);
Navigator.of(context).pop();
},
child: const Text(
"Kembali",
style: TextStyle(color: Colors.grey),
),
),
Text(
"Pilih Tanggal",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.grey.shade700,
),
),
TextButton(
onPressed: () {
if (_selectedDay != null) {
context.read<EvaporasiBloc>().add(
EvaporasiDateSelected(_selectedDay!),
);
Navigator.of(context).pop();
}
},
child: const Text(
"OK",
style: TextStyle(
color: Colors.blue,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
// Calendar
Expanded(
child: SingleChildScrollView(
child: TableCalendar(
firstDay: DateTime.utc(2020, 1, 1),
lastDay: DateTime.now(),
focusedDay: _focusedDay,
calendarFormat: _calendarFormat,
selectedDayPredicate: (day) {
return isSameDay(_selectedDay, day);
},
onDaySelected: (selectedDay, focusedDay) {
setState(() {
_selectedDay = selectedDay;
_focusedDay = focusedDay;
});
},
onFormatChanged: (format) {
if (_calendarFormat != format) {
setState(() {
_calendarFormat = format;
});
}
},
onPageChanged: (focusedDay) {
setState(() {
_focusedDay = focusedDay;
});
},
calendarStyle: CalendarStyle(
// Default
defaultDecoration: const BoxDecoration(
color: Colors.transparent,
shape: BoxShape.circle,
),
// Today
todayDecoration: BoxDecoration(
color: Colors.blue.shade100,
shape: BoxShape.circle,
),
todayTextStyle: TextStyle(
color: Colors.blue.shade700,
fontWeight: FontWeight.bold,
),
// Selected
selectedDecoration: const BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
),
selectedTextStyle: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
// Outside days
outsideDaysVisible: false,
weekendTextStyle: TextStyle(color: Colors.grey.shade600),
),
headerStyle: HeaderStyle(
formatButtonVisible: true,
titleCentered: true,
formatButtonShowsNext: false,
formatButtonDecoration: BoxDecoration(
border: Border.all(color: Colors.blue),
borderRadius: BorderRadius.circular(12),
),
formatButtonTextStyle: const TextStyle(
color: Colors.blue,
),
titleTextStyle: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.grey.shade700,
),
leftChevronIcon: Icon(
Icons.chevron_left,
color: Colors.grey.shade600,
),
rightChevronIcon: Icon(
Icons.chevron_right,
color: Colors.grey.shade600,
),
),
daysOfWeekStyle: DaysOfWeekStyle(
weekdayStyle: TextStyle(
color: Colors.grey.shade500,
fontWeight: FontWeight.w500,
fontSize: 12,
),
weekendStyle: TextStyle(
color: Colors.grey.shade500,
fontWeight: FontWeight.w500,
fontSize: 12,
),
),
),
),
),
// Selected date display
if (_selectedDay != null)
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(20),
bottomRight: Radius.circular(20),
),
),
child: Text(
_formatDate(_selectedDay!),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.grey.shade700,
),
),
),
],
),
);
}
String _formatDate(DateTime date) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final yesterday = today.subtract(const Duration(days: 1));
final selected = DateTime(date.year, date.month, date.day);
if (selected == today) {
return "Hari Ini";
} else if (selected == yesterday) {
return "Kemarin";
} else {
return DateFormat('EEEE, d MMMM yyyy', 'id_ID').format(date);
}
}
}
/// 🔹 Show Date Picker Dialog
void showEvaporasiDatePicker(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => Container(
margin: const EdgeInsets.all(16),
height: 450,
child: const EvaporasiDatePicker(),
),
);
}

View File

@ -0,0 +1,476 @@
// ===========================================================
// evaporasi_history_list.dart
// Lokasi: lib/screens/monitoring/evaporasi/views/widgets/
// ===========================================================
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:monitoring_repository/monitoring_repository.dart';
class EvaporasiHistoryList extends StatelessWidget {
final List<Evaporasi> history;
final DateTime? selectedDate;
final VoidCallback onPickDate;
final VoidCallback onClearDate;
const EvaporasiHistoryList({
super.key,
required this.history,
required this.selectedDate,
required this.onPickDate,
required this.onClearDate,
});
Map<String, List<Evaporasi>> _groupByDate(List<Evaporasi> list) {
final map = <String, List<Evaporasi>>{};
for (final item in list) {
final key = DateFormat('yyyy-MM-dd').format(item.timestamp);
map.putIfAbsent(key, () => []).add(item);
}
return map;
}
@override
Widget build(BuildContext context) {
final grouped = _groupByDate(history);
final sortedKeys = grouped.keys.toList()
..sort((a, b) => b.compareTo(a)); // terbaru di atas
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_HeaderBar(
selectedDate: selectedDate,
totalCount: history.length,
onPickDate: onPickDate,
onClearDate: onClearDate,
),
const SizedBox(height: 12),
if (history.isEmpty)
_EmptyState(hasFilter: selectedDate != null)
else
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: sortedKeys.length,
itemBuilder: (context, idx) {
final dateKey = sortedKeys[idx];
final items = grouped[dateKey]!
..sort((a, b) => b.timestamp.compareTo(a.timestamp));
final label = _formatDateLabel(dateKey);
return _DateGroup(label: label, items: items);
},
),
],
);
}
String _formatDateLabel(String key) {
final dt = DateTime.parse(key);
final today = DateTime.now();
if (dt.year == today.year &&
dt.month == today.month &&
dt.day == today.day) {
return 'Hari Ini — ${DateFormat('dd MMMM yyyy', 'id_ID').format(dt)}';
}
final yesterday = today.subtract(const Duration(days: 1));
if (dt.year == yesterday.year &&
dt.month == yesterday.month &&
dt.day == yesterday.day) {
return 'Kemarin — ${DateFormat('dd MMMM yyyy', 'id_ID').format(dt)}';
}
return DateFormat('EEEE, dd MMMM yyyy', 'id_ID').format(dt);
}
}
//
// Header bar
//
class _HeaderBar extends StatelessWidget {
final DateTime? selectedDate;
final int totalCount;
final VoidCallback onPickDate;
final VoidCallback onClearDate;
const _HeaderBar({
required this.selectedDate,
required this.totalCount,
required this.onPickDate,
required this.onClearDate,
});
@override
Widget build(BuildContext context) {
final filtered = selectedDate != null;
return Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Riwayat Data',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87),
),
Text(
filtered
? '${DateFormat('dd MMM yyyy', 'id_ID').format(selectedDate!)}$totalCount data'
: '$totalCount data tersimpan',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
],
),
const Spacer(),
if (filtered)
_ChipButton(
label: DateFormat('dd MMM yyyy', 'id_ID').format(selectedDate!),
icon: Icons.close_rounded,
color: Colors.blue.shade700,
onTap: onClearDate,
)
else
_ChipButton(
label: 'Filter Tanggal',
icon: Icons.calendar_month_rounded,
color: Colors.blue.shade700,
onTap: onPickDate,
),
],
);
}
}
class _ChipButton extends StatelessWidget {
final String label;
final IconData icon;
final Color color;
final VoidCallback onTap;
const _ChipButton({
required this.label,
required this.icon,
required this.color,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: color.withValues(alpha: 0.4)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 6),
Text(label,
style: TextStyle(
fontSize: 12,
color: color,
fontWeight: FontWeight.w600)),
],
),
),
);
}
}
//
// Group per tanggal
//
class _DateGroup extends StatefulWidget {
final String label;
final List<Evaporasi> items;
const _DateGroup({required this.label, required this.items});
@override
State<_DateGroup> createState() => _DateGroupState();
}
class _DateGroupState extends State<_DateGroup> {
bool _expanded = true;
double get _avgEvap {
if (widget.items.isEmpty) return 0;
return widget.items.map((e) => e.evaporasi).reduce((a, b) => a + b) /
widget.items.length;
}
double get _maxEvap {
if (widget.items.isEmpty) return 0;
return widget.items
.map((e) => e.evaporasi)
.reduce((a, b) => a > b ? a : b);
}
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
children: [
// Group header
InkWell(
onTap: () => setState(() => _expanded = !_expanded),
borderRadius:
const BorderRadius.vertical(top: Radius.circular(16)),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
width: 4,
height: 36,
decoration: BoxDecoration(
color: Colors.blue.shade600,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.label,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13)),
const SizedBox(height: 2),
Text(
'${widget.items.length} data • rata-rata ${_avgEvap.toStringAsFixed(2)} mm • maks ${_maxEvap.toStringAsFixed(2)} mm',
style: TextStyle(
fontSize: 11, color: Colors.grey.shade600),
),
],
),
),
Icon(
_expanded
? Icons.keyboard_arrow_up_rounded
: Icons.keyboard_arrow_down_rounded,
color: Colors.grey.shade500,
),
],
),
),
),
// Item list
if (_expanded)
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: widget.items.length,
separatorBuilder: (_, __) =>
Divider(height: 1, color: Colors.grey.shade100),
itemBuilder: (context, i) =>
_HistoryItemTile(item: widget.items[i]),
),
],
),
);
}
}
//
// Satu baris data history
//
class _HistoryItemTile extends StatelessWidget {
final Evaporasi item;
const _HistoryItemTile({required this.item});
String get _status {
if (item.evaporasi > 10.0) return 'Tinggi';
if (item.evaporasi >= 2.0) return 'Normal';
return 'Rendah';
}
Color get _statusColor {
switch (_status) {
case 'Tinggi':
return Colors.red.shade600;
case 'Normal':
return Colors.orange.shade700;
default:
return Colors.green.shade600;
}
}
IconData get _statusIcon {
switch (_status) {
case 'Tinggi':
return Icons.warning_rounded;
case 'Normal':
return Icons.info_outline_rounded;
default:
return Icons.check_circle_outline_rounded;
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Jam
SizedBox(
width: 56,
child: Text(
DateFormat('HH:mm:ss').format(item.timestamp),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
fontFamily: 'monospace'),
),
),
const SizedBox(width: 8),
// Data evaporasi, tinggi air, suhu
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Evaporasi
RichText(
text: TextSpan(
style: const TextStyle(color: Colors.black87),
children: [
TextSpan(
text: item.evaporasi.toStringAsFixed(2),
style: const TextStyle(
fontSize: 15, fontWeight: FontWeight.bold),
),
const TextSpan(
text: ' mm',
style:
TextStyle(fontSize: 11, color: Colors.black54),
),
],
),
),
const SizedBox(height: 3),
// Tinggi Air
Row(
children: [
Icon(Icons.water, size: 11,
color: Colors.blue.shade400),
const SizedBox(width: 3),
Text(
'Tinggi Air: ${item.tinggiAir.toStringAsFixed(1)} cm',
style: TextStyle(
fontSize: 11, color: Colors.blue.shade600),
),
],
),
const SizedBox(height: 2),
// Suhu
Row(
children: [
Icon(Icons.thermostat, size: 11,
color: Colors.orange.shade400),
const SizedBox(width: 3),
Text(
'Suhu: ${item.suhu.toStringAsFixed(1)} °C',
style: TextStyle(
fontSize: 11, color: Colors.orange.shade700),
),
],
),
],
),
),
// Badge status
Container(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: _statusColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
border:
Border.all(color: _statusColor.withValues(alpha: 0.4)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(_statusIcon, size: 12, color: _statusColor),
const SizedBox(width: 4),
Text(
_status,
style: TextStyle(
fontSize: 11,
color: _statusColor,
fontWeight: FontWeight.w600),
),
],
),
),
],
),
);
}
}
//
// Empty state
//
class _EmptyState extends StatelessWidget {
final bool hasFilter;
const _EmptyState({required this.hasFilter});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 40),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Icon(
hasFilter
? Icons.search_off_rounded
: Icons.inbox_rounded,
size: 48,
color: Colors.grey.shade300,
),
const SizedBox(height: 12),
Text(
hasFilter
? 'Tidak ada data untuk tanggal ini'
: 'Belum ada data history',
style:
TextStyle(color: Colors.grey.shade500, fontSize: 14),
),
],
),
);
}
}

View File

@ -0,0 +1,206 @@
// lib/screens/monitoring/evaporasi/views/widgets/evaporasi_range_selector.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../../blocs/evaporasi_bloc.dart';
class EvaporasiRangeSelector extends StatelessWidget {
const EvaporasiRangeSelector({super.key});
@override
Widget build(BuildContext context) {
final state = context.watch<EvaporasiBloc>().state;
final start = state.startDate;
final end = state.endDate;
final isSingle = state.isSingleDay;
final label = isSingle
? _formatSingle(start)
: '${_fmt(start)}${_fmt(end)}';
return GestureDetector(
onTap: () => _pickRange(context, start, end),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Label rentang
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isSingle ? 'Tampil per Jam' : 'Tampil per Hari',
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade500,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
],
),
// Tombol pilih
Row(
children: [
// Shortcut: Hari Ini
_ShortcutChip(
label: 'Hari Ini',
isActive: isSingle && _isToday(start),
onTap: () {
final today = DateTime.now();
context.read<EvaporasiBloc>().add(
EvaporasiDateRangeChanged(
startDate: DateTime(today.year, today.month, today.day),
endDate: DateTime(today.year, today.month, today.day),
),
);
},
),
const SizedBox(width: 6),
// Tombol pilih range bebas
GestureDetector(
onTap: () => _pickRange(context, start, end),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.blue.shade600,
borderRadius: BorderRadius.circular(20),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.date_range_rounded,
size: 14, color: Colors.white),
SizedBox(width: 5),
Text(
'Pilih Tanggal',
style: TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
],
),
],
),
),
);
}
Future<void> _pickRange(
BuildContext context, DateTime start, DateTime end) async {
final picked = await showDateRangePicker(
context: context,
firstDate: DateTime(2024),
lastDate: DateTime.now(),
initialDateRange: DateTimeRange(start: start, end: end),
locale: const Locale('id', 'ID'),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: ColorScheme.light(
primary: Colors.blue.shade700,
onPrimary: Colors.white,
surface: Colors.white,
),
),
child: child!,
);
},
);
if (picked != null && context.mounted) {
context.read<EvaporasiBloc>().add(
EvaporasiDateRangeChanged(
startDate: picked.start,
endDate: picked.end,
),
);
}
}
bool _isToday(DateTime d) {
final now = DateTime.now();
return d.year == now.year && d.month == now.month && d.day == now.day;
}
String _fmt(DateTime d) => DateFormat('dd MMM yyyy', 'id_ID').format(d);
String _formatSingle(DateTime d) {
if (_isToday(d)) return 'Hari Ini — ${_fmt(d)}';
final yesterday = DateTime.now().subtract(const Duration(days: 1));
if (d.year == yesterday.year &&
d.month == yesterday.month &&
d.day == yesterday.day) {
return 'Kemarin — ${_fmt(d)}';
}
return DateFormat('EEEE, dd MMMM yyyy', 'id_ID').format(d);
}
}
class _ShortcutChip extends StatelessWidget {
final String label;
final bool isActive;
final VoidCallback onTap;
const _ShortcutChip({
required this.label,
required this.isActive,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: isActive ? Colors.blue.shade50 : Colors.grey.shade100,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isActive
? Colors.blue.shade400
: Colors.grey.shade300,
),
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: isActive ? Colors.blue.shade700 : Colors.grey.shade600,
),
),
),
);
}
}

BIN
output_flutter_run.txt Normal file

Binary file not shown.

View File

@ -1,3 +1,5 @@
// packages/monitoring_repository/lib/src/models/evaporasi.dart
class Evaporasi {
final double evaporasi;
final double suhu;
@ -33,7 +35,7 @@ class Evaporasi {
return 0.0;
}
// Evaporasi (mm)
// Evaporasi (mm)
final evaporasiVal = toDoubleSafe(
json['evaporasi_mm'] ??
json['evaporasi'] ??
@ -44,9 +46,11 @@ class Evaporasi {
json['evaporasi_k'],
);
// Suhu (°C)
// Suhu (°C)
// FIX: tambah 'suhu_air_c' sesuai field yang dikirim ESP32
final suhuRaw = toDoubleSafe(
json['suhu_air'] ??
json['suhu_air_c'] ?? // ESP32 kirim field ini
json['suhu_air'] ??
json['suhu'] ??
json['suhuAir'] ??
json['temp'] ??
@ -54,7 +58,7 @@ class Evaporasi {
);
final suhuVal = (suhuRaw < -50 || suhuRaw > 100) ? 0.0 : suhuRaw;
// Tinggi air
// Tinggi Air (cm)
final tinggiVal = toDoubleSafe(
json['tinggi_air_cm'] ??
json['tinggi_air'] ??
@ -66,16 +70,18 @@ class Evaporasi {
json['tinggiAir_m'],
);
// Filter data invalid
final evaporasiFiltered = (evaporasiVal < 0 || evaporasiVal > 50)
? 0.0
: evaporasiVal;
final tinggiFiltered = (tinggiVal < 0 || tinggiVal > 100) ? 0.0 : tinggiVal;
// Sanity check
final evaporasiFiltered =
(evaporasiVal < 0 || evaporasiVal > 50) ? 0.0 : evaporasiVal;
final tinggiFiltered =
(tinggiVal < 0 || tinggiVal > 100) ? 0.0 : tinggiVal;
// Parse Timestamp
// FIX: Tambahkan offset +07:00 (WIB) jika string tidak punya info timezone,
// agar tidak terjadi mismatch 7 jam antara Firebase dan Flutter.
DateTime parseTimestamp(dynamic rawTimestamp) {
try {
if (rawTimestamp is int) {
// If seconds, convert to ms.
if (rawTimestamp < 1000000000000) {
return DateTime.fromMillisecondsSinceEpoch(rawTimestamp * 1000)
.toLocal();
@ -86,8 +92,7 @@ class Evaporasi {
if (rawTimestamp is double) {
final value = rawTimestamp.toInt();
if (value < 1000000000000) {
return DateTime.fromMillisecondsSinceEpoch(value * 1000)
.toLocal();
return DateTime.fromMillisecondsSinceEpoch(value * 1000).toLocal();
}
return DateTime.fromMillisecondsSinceEpoch(value).toLocal();
}
@ -105,11 +110,17 @@ class Evaporasi {
return DateTime.fromMillisecondsSinceEpoch(unixValue).toLocal();
}
// Firebase sometimes uses "YYYY-MM-DD HH:mm:ss" (needs ISO 'T')
// Format "YYYY-MM-DD HH:mm:ss" tambah 'T' agar bisa diparsing
if (s.contains(' ') && !s.contains('T')) {
s = s.replaceFirst(' ', 'T');
}
// FIX: Jika tidak ada info timezone, anggap WIB (UTC+7)
// agar jam di chart tidak mismatch 7 jam
if (!s.contains('+') && !s.contains('Z') && !s.contains('-', 10)) {
s = '${s}+07:00';
}
final parsed = DateTime.tryParse(s);
if (parsed != null) return parsed.toLocal();
}
@ -126,7 +137,7 @@ class Evaporasi {
if (rawTimestamp != null) {
timestamp = parseTimestamp(rawTimestamp);
} else {
// legacy fallback: "waktu" format "HH:mm:ss"
// Legacy fallback: field "waktu" format "HH:mm:ss"
final waktuStr = json['waktu'] as String?;
if (waktuStr != null) {
final parts = waktuStr.split(':');
@ -136,7 +147,8 @@ class Evaporasi {
final detik =
parts.length >= 3 ? (int.tryParse(parts[2]) ?? 0) : 0;
final now = DateTime.now();
timestamp = DateTime(now.year, now.month, now.day, jam, menit, detik);
timestamp =
DateTime(now.year, now.month, now.day, jam, menit, detik);
}
}
}
@ -148,5 +160,4 @@ class Evaporasi {
timestamp: timestamp,
);
}
}
}

BIN
tool_check.txt Normal file

Binary file not shown.