terbaru
This commit is contained in:
parent
3eb4f32b09
commit
62ab660aa1
17
TODO.md
17
TODO.md
|
|
@ -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.
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Binary file not shown.
Loading…
Reference in New Issue