ngeluuu
This commit is contained in:
parent
6cc1b7f575
commit
d2822ded80
34
TODO.md
34
TODO.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue