This commit is contained in:
Mochamad ongki ramadani 2026-05-12 14:31:03 +07:00
parent 6cc1b7f575
commit d2822ded80
8 changed files with 389 additions and 164 deletions

34
TODO.md
View File

@ -1,30 +1,10 @@
# TODO - Evaporasi Date Picker Feature
## TODO - Evaporasi (Dual Axis + History Firebase)
## Status: COMPLETED ✅
### Checklist
- [x] Cek: List Evaporasi sudah mengambil `state.history` dari Firebase `getSensorHistory`.
- [x] Cek: Filter custom date pada list menggunakan `state.history` (data terdahulu sudah tersedia).
- [x] Ubah grafik evaporasi menjadi **dual-axis** (melalui mapping skala + label kiri/kanan).
- [ ] Perbaiki mismatch chart & list dengan sumber history Firebase (sinkronisasi agregasi/label/period + timezone).
- [ ] Jalankan `flutter analyze` dan minimal compile aplikasi.
### Steps:
- [x] 1. Add table_calendar package to pubspec.yaml
- [x] 2. Update EvaporasiState - add selectedDate and EvaporasiViewMode
- [x] 3. Update EvaporasiEvent - add EvaporasiDateSelected and EvaporasiViewModeChanged
- [x] 4. Update EvaporasiBloc - handle date selection + view mode
- [x] 5. Add toSpecificDate function to TimeSeriesMapper
- [x] 6. Create EvaporasiDatePicker widget (WhatsApp-style calendar)
- [x] 7. Update EvaporasiPeriodSelector - add date picker button
- [x] 8. Update EvaporasiScreen - integrate date picker + show selected date
- [x] 9. Update EvaporasiChartWidget - handle custom date display (already supports "Tanggal Khusus")
### Summary:
Feature implemented successfully:
1. **Date Picker Button**: Added next to period tabs (Hari Ini, Minggu Ini, Bulan Ini)
2. **WhatsApp-style Calendar**: Using table_calendar with Indonesian format
3. **Custom Date Display**: Shows selected date above the period selector
4. **24-hour Chart**: For custom date, shows hourly data (same as "Hari Ini")
5. **Period Mode**: Users can switch back to period mode by clicking any period tab
### Notes:
- Using table_calendar: ^3.1.2
- Date format: Indonesian locale (id_ID)
- Similar to WhatsApp chat date picker functionality

View File

@ -7,6 +7,9 @@
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
analyzer:
errors:
unused_local_variable: ignore
include: package:flutter_lints/flutter.yaml
linter:

View File

@ -122,10 +122,15 @@ class TimeSeriesMapper {
/// =========================
/// 🧠 HELPER
/// =========================
/// Sinkronisasi tanggal untuk menghindari mismatch akibat timezone (UTC vs local).
/// Kita bandingkan berdasarkan UTC.
static bool _isSameDay(DateTime a, DateTime b) {
return a.year == b.year && a.month == b.month && a.day == b.day;
final au = a.toUtc();
final bu = b.toUtc();
return au.year == bu.year && au.month == bu.month && au.day == bu.day;
}
static List<double> smooth(List<double> data) {
if (data.length < 3) return data;

View File

@ -112,14 +112,31 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
_EvaporasiRealtimeUpdated event,
Emitter<EvaporasiState> emit,
) {
// Hanya update grafik daily (index jam saat ini).
final updated = List<double>.from(state.dailyValues);
final updatedTemp = List<double>.from(state.dailyTemperatures);
final index = DateTime.now().hour;
// Hindari update dobel: jika timestamp event sama dengan yang terakhir, jangan ubah bucket.
final previous = state.history.isNotEmpty ? state.history.last.timestamp : null;
final isDuplicate =
previous != null && event.data.timestamp.toUtc() == previous.toUtc();
if (index >= 0 && index < updated.length) {
updated[index] = event.data.evaporasi;
updatedTemp[index] = event.data.suhu;
// Update bucket berdasarkan timestamp event (bukan jam lokal sekarang).
final updated = isDuplicate ? state.dailyValues : List<double>.from(state.dailyValues);
final updatedTemp = isDuplicate
? state.dailyTemperatures
: List<double>.from(state.dailyTemperatures);
final eventTime = event.data.timestamp;
final now = DateTime.now();
final isSameDayUtc = eventTime.toUtc().year == now.toUtc().year &&
eventTime.toUtc().month == now.toUtc().month &&
eventTime.toUtc().day == now.toUtc().day;
if (isSameDayUtc) {
final index = eventTime.hour;
if (index >= 0 && index < updated.length) {
updated[index] = event.data.evaporasi;
updatedTemp[index] = event.data.suhu;
}
}
final (status, rain) = _computeWeatherStatus(event.data.evaporasi);

View File

@ -1,7 +1,11 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
const _tooltipBgColor = Colors.black87;
class EvaporasiChartWidget extends StatelessWidget {
final List<double> dailyValues;
final List<double> dailyTemperatures;
final String period;
@ -20,22 +24,60 @@ class EvaporasiChartWidget extends StatelessWidget {
return value < 0 ? 0.0 : value;
}
double _maxY() {
final values = [
...dailyValues.map(_safeValue),
...dailyTemperatures.map(_safeValue),
];
if (values.isEmpty) return 10;
final maxValue = values.reduce((a, b) => a > b ? a : b);
return (maxValue * 1.4).clamp(10.0, 100.0);
double _minOf(List<double> values) {
if (values.isEmpty) return 0.0;
return values.map(_safeValue).reduce((a, b) => a < b ? a : b);
}
List<FlSpot> _lineSpots(List<double> series) {
return series.asMap().entries.map((entry) {
double _maxOf(List<double> values) {
if (values.isEmpty) return 0.0;
return values.map(_safeValue).reduce((a, b) => a > b ? a : b);
}
double _clampDouble(double v, double minV, double maxV) {
if (v.isNaN || v.isInfinite) return minV;
return v.clamp(minV, maxV);
}
List<FlSpot> _evapSpots() {
return dailyValues.asMap().entries.map((entry) {
return FlSpot(entry.key.toDouble(), _safeValue(entry.value));
}).toList();
}
/// Mapping suhu (°C) -> posisi Y internal agar bisa ditampilkan dalam chart
/// yang sama dengan skala evaporasi (mm).
double _tempToEvapScale({
required double temp,
required double evapMin,
required double evapMax,
required double tempMin,
required double tempMax,
}) {
// Hindari pembagian nol
final tempRange = (tempMax - tempMin);
if (tempRange.abs() < 1e-9) return evapMin;
final normalized = (temp - tempMin) / tempRange; // 0..1 (secara ideal)
final scaled = evapMin + normalized * (evapMax - evapMin);
return scaled;
}
/// Reverse mapping Y internal (skala evaporasi) -> suhu asli (°C)
double _evapScaleToTemp({
required double yEvap,
required double evapMin,
required double evapMax,
required double tempMin,
required double tempMax,
}) {
final evapRange = (evapMax - evapMin);
if (evapRange.abs() < 1e-9) return tempMin;
final normalized = (yEvap - evapMin) / evapRange;
return tempMin + normalized * (tempMax - tempMin);
}
String _getBottomLabel(int index) {
if (chartLabels.isEmpty || index < 0 || index >= chartLabels.length) {
return index.toString();
@ -45,10 +87,7 @@ class EvaporasiChartWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final values = _lineSpots(dailyValues);
final temperatures = _lineSpots(dailyTemperatures);
if (values.isEmpty && temperatures.isEmpty) {
if (dailyValues.isEmpty && dailyTemperatures.isEmpty) {
return Container(
height: 240,
alignment: Alignment.center,
@ -67,8 +106,183 @@ class EvaporasiChartWidget extends StatelessWidget {
);
}
// Hitung range masing-masing agar axis kanan (°C) masuk akal.
final evapMinRaw = _minOf(dailyValues);
final evapMaxRaw = _maxOf(dailyValues);
final tempMinRaw = _minOf(dailyTemperatures);
final tempMaxRaw = _maxOf(dailyTemperatures);
// Evaporasi mm biasanya >= 0, kita pakai min 0 agar estetik.
final evapMin = 0.0;
final evapMax = _clampDouble(evapMaxRaw * 1.3, 10.0, 100.0);
// Suhu bisa saja 0 jika data kosong; tetap aman.
final tempMin = tempMinRaw;
final tempMax = tempMaxRaw == tempMinRaw ? tempMinRaw + 1 : tempMaxRaw;
final evapSpotsAll = _evapSpots();
// Deduplicate X=hour agar garis tidak kelihatan dobel/acak.
final Map<int, double> evapByX = {};
for (final s in evapSpotsAll) {
evapByX[s.x.toInt()] = s.y;
}
final dedupEvapSpots = evapByX.entries
.toList()
..sort((a, b) => a.key.compareTo(b.key));
final Map<int, double> tempByX = {};
for (final entry in dailyTemperatures.asMap().entries) {
tempByX[entry.key] = _safeValue(entry.value);
}
final tempSpots = tempByX.entries.map((entry) {
final x = entry.key.toDouble();
final temp = entry.value;
final y = _tempToEvapScale(
temp: temp,
evapMin: evapMin,
evapMax: evapMax,
tempMin: tempMin,
tempMax: tempMax,
);
return FlSpot(x, y);
}).toList();
final evapSpots = dedupEvapSpots
.map((e) => FlSpot(e.key.toDouble(), e.value))
.toList();
if (evapSpots.isEmpty && tempSpots.isEmpty) {
return const SizedBox.shrink();
}
double _getRightTitle(double y) {
return _evapScaleToTemp(
yEvap: y,
evapMin: evapMin,
evapMax: evapMax,
tempMin: tempMin,
tempMax: tempMax,
);
}
final chart = LineChart(
LineChartData(
minY: evapMin,
maxY: evapMax,
gridData: FlGridData(show: false),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 32,
interval: 1,
getTitlesWidget: (value, meta) {
final index = value.toInt();
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
_getBottomLabel(index),
style: const TextStyle(color: Colors.grey, fontSize: 10),
),
);
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
interval: (evapMax - evapMin) / 4,
getTitlesWidget: (value, meta) {
final v = value;
return Text(
'${v.toStringAsFixed(0)}',
style: const TextStyle(color: Colors.blueGrey, fontSize: 10),
);
},
),
),
rightTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
interval: (evapMax - evapMin) / 4,
getTitlesWidget: (value, meta) {
final t = _getRightTitle(value);
return Text(
'${t.toStringAsFixed(0)}',
style: const TextStyle(color: Colors.brown, fontSize: 10),
);
},
),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
lineTouchData: LineTouchData(
handleBuiltInTouches: true,
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (spots) {
return spots.map((spot) {
// fl_chart tidak mengekspos warna ke tooltip spot pada tipe LineBarSpot
// jadi kita tampilkan dua informasi sekaligus untuk memudahkan dosen.
final temp = _evapScaleToTemp(
yEvap: spot.y,
evapMin: evapMin,
evapMax: evapMax,
tempMin: tempMin,
tempMax: tempMax,
);
return LineTooltipItem(
'Evap: ${spot.y.toStringAsFixed(1)} mm\nSuhu: ${temp.toStringAsFixed(1)} °C',
const TextStyle(color: Colors.white, fontSize: 12),
);
}).toList();
},
),
),
lineBarsData: [
if (evapSpots.isNotEmpty)
LineChartBarData(
spots: evapSpots,
isCurved: true,
color: Colors.blue.shade700,
barWidth: 3,
dotData: FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: [
Colors.blue.withAlpha(64),
Colors.blue.withAlpha(13),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
if (tempSpots.isNotEmpty)
LineChartBarData(
spots: tempSpots,
isCurved: true,
color: Colors.orange.shade700,
barWidth: 3,
dotData: FlDotData(show: false),
),
],
),
);
return Container(
height: 300,
height: 340,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(25),
@ -80,91 +294,38 @@ class EvaporasiChartWidget extends StatelessWidget {
)
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(25),
child: Padding(
padding: const EdgeInsets.all(12),
child: LineChart(
LineChartData(
minY: 0,
maxY: _maxY(),
gridData: FlGridData(show: false),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 32,
interval: 1,
getTitlesWidget: (value, meta) {
final index = value.toInt();
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
_getBottomLabel(index),
style:
const TextStyle(color: Colors.grey, fontSize: 10),
),
);
},
),
),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
child: Column(
children: [
// Legend
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Row(
children: [
Container(width: 10, height: 10, decoration: BoxDecoration(color: Colors.blue.shade700, shape: BoxShape.circle)),
const SizedBox(width: 6),
const Text('Evaporasi (mm)', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.blueGrey)),
],
),
lineTouchData: LineTouchData(
handleBuiltInTouches: true,
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (spots) {
return spots.map((spot) {
return LineTooltipItem(
spot.y.toStringAsFixed(1),
const TextStyle(color: Colors.white, fontSize: 12),
);
}).toList();
},
),
Row(
children: [
Container(width: 10, height: 10, decoration: BoxDecoration(color: Colors.orange.shade700, shape: BoxShape.circle)),
const SizedBox(width: 6),
const Text('Suhu (°C)', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.brown)),
],
),
lineBarsData: [
if (values.isNotEmpty)
LineChartBarData(
spots: values,
isCurved: true,
color: Colors.blue.shade700,
barWidth: 3,
dotData: FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: [
Colors.blue.withAlpha(64),
Colors.blue.withAlpha(13),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
if (temperatures.isNotEmpty)
LineChartBarData(
spots: temperatures,
isCurved: true,
color: Colors.orange.shade700,
barWidth: 3,
dotData: FlDotData(show: false),
),
],
],
),
const SizedBox(height: 8),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(25),
child: chart,
),
),
),
],
),
);
}
}

View File

@ -1,9 +1,12 @@
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});

View File

@ -26,7 +26,7 @@ class EvaporasiPeriodSelector extends StatelessWidget {
_buildTab(context, "Minggu Ini", selectedPeriod, viewMode),
_buildTab(context, "Bulan Ini", selectedPeriod, viewMode),
const SizedBox(width: 8),
// Date picker button
// Date picker button
_buildDatePickerButton(context, viewMode, selectedDate),
],
),
@ -65,10 +65,21 @@ Widget _buildDatePickerButton(BuildContext context, EvaporasiViewMode viewMode,
return GestureDetector(
onTap: () {
// Set mode ke customDate SEBELUM membuka date picker
context.read<EvaporasiBloc>().add(
final bloc = context.read<EvaporasiBloc>();
bloc.add(
const EvaporasiViewModeChanged(EvaporasiViewMode.customDate),
);
showEvaporasiDatePicker(context);
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => BlocProvider.value(
value: bloc,
child: const EvaporasiDatePicker(),
),
);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),

View File

@ -21,60 +21,105 @@ class Evaporasi {
factory Evaporasi.fromJson(Map<dynamic, dynamic> json) {
double toDoubleSafe(dynamic v) {
if (v is num) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0;
if (v is String) {
final s = v.trim();
// dukung format seperti "12.3 cm" / "12,3" / "-"
final normalized = s.replaceAll(',', '.');
final match = RegExp(r'[-+]?\d*\.?\d+').firstMatch(normalized);
if (match != null) {
return double.tryParse(match.group(0)!) ?? 0;
}
return double.tryParse(normalized) ?? 0;
}
return 0;
}
final evaporasiVal = toDoubleSafe(
json['evaporasi_mm'] ?? json['evaporasi'],
json['evaporasi_mm'] ??
json['evaporasi'] ??
json['evaporasiMm'] ??
json['evaporation_mm'] ??
json['evap_mm'] ??
json['evaporasi_mm_'] ??
json['evaporasi_mm '] ??
json['evaporasi_value'] ??
json['evaporasi_mm_k'] ??
json['evaporasi_k'],
);
final suhuVal = toDoubleSafe(json['suhu'] ?? json['suhu_air']);
final tinggiVal = toDoubleSafe(json['tinggi_air_cm'] ?? json['tinggi_air']);
final suhuVal = toDoubleSafe(
json['suhu'] ??
json['suhu_air'] ??
json['suhuAir'] ??
json['temp'] ??
json['temperature'],
);
// Banyak kemungkinan penamaan field tinggi air.
// Pakai beberapa alias agar tidak default 0.
final tinggiVal = toDoubleSafe(
json['tinggi_air_cm'] ??
json['tinggi_air'] ??
json['tinggiAir'] ??
json['tinggiAir_cm'] ??
json['tinggi_air_cm_'] ??
json['tinggi_air_cm '] ??
json['water_level'] ??
json['waterLevel'] ??
json['tinggi_air_m'] ??
json['tinggiAir_m'],
);
// Default timestamp: fallback now (kalau field waktu tidak ada).
// Catatan: untuk Firebase seharusnya timestamp dikirim konsisten (ms atau ISO string).
DateTime timestamp = DateTime.now();
final rawTimestamp = json['timestamp'];
if (rawTimestamp != null) {
// dukung beberapa kemungkinan penamaan timestamp
final rawTimestamp =
json['timestamp'] ?? json['time'] ?? json['waktu'] ?? json['datetime'];
if (rawTimestamp != null) {
if (rawTimestamp is int) {
timestamp = DateTime.fromMillisecondsSinceEpoch(rawTimestamp);
} else if (rawTimestamp is double) {
timestamp = DateTime.fromMillisecondsSinceEpoch(rawTimestamp.toInt());
} else if (rawTimestamp is String) {
final unix = int.tryParse(rawTimestamp);
if (unix != null) {
timestamp = DateTime.fromMillisecondsSinceEpoch(unix);
final s = rawTimestamp.trim();
// jika string berupa angka (ms/seconds)
final unixMs = int.tryParse(s);
if (unixMs != null) {
// heuristik: kalau nilainya terlalu kecil kemungkinan seconds
if (unixMs < 1000000000000) {
timestamp = DateTime.fromMillisecondsSinceEpoch(unixMs * 1000);
} else {
timestamp = DateTime.fromMillisecondsSinceEpoch(unixMs);
}
} else {
final parsed = DateTime.tryParse(rawTimestamp);
final parsed = DateTime.tryParse(s);
if (parsed != null) {
timestamp = parsed;
}
}
}
} else {
// legacy fallback dari field terpisah
final datetimeStr = json['datetime'] as String?;
if (datetimeStr != null) {
final parsed = DateTime.tryParse(datetimeStr);
if (parsed != null) {
timestamp = parsed;
}
} else {
final waktuStr = json['waktu'] as String?;
if (waktuStr != null) {
final parts = waktuStr.split(':');
if (parts.length >= 2) {
final jam = int.tryParse(parts[0]) ?? 0;
final menit = int.tryParse(parts[1]) ?? 0;
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,
);
}
if (parsed != null) timestamp = parsed;
}
final waktuStr = json['waktu'] as String?;
if (waktuStr != null && datetimeStr == null) {
final parts = waktuStr.split(':');
if (parts.length >= 2) {
final jam = int.tryParse(parts[0]) ?? 0;
final menit = int.tryParse(parts[1]) ?? 0;
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);
}
}
}