From d2822ded802a34d418c98368452efc37d09ed29e Mon Sep 17 00:00:00 2001 From: Mochamad ongki ramadani Date: Tue, 12 May 2026 14:31:03 +0700 Subject: [PATCH] ngeluuu --- TODO.md | 34 +- analysis_options.yaml | 3 + lib/core/utils/time_series_mapper.dart | 7 +- .../evaporasi/blocs/evaporasi_bloc.dart | 31 +- .../views/widgets/evaporasi_chart_widget.dart | 351 +++++++++++++----- .../views/widgets/evaporasi_date_picker.dart | 3 + .../widgets/evaporasi_period_selector.dart | 17 +- .../lib/src/models/evaporasi.dart | 107 ++++-- 8 files changed, 389 insertions(+), 164 deletions(-) diff --git a/TODO.md b/TODO.md index a5449e5..5d401e8 100644 --- a/TODO.md +++ b/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 diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d29021..0c570e0 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -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: diff --git a/lib/core/utils/time_series_mapper.dart b/lib/core/utils/time_series_mapper.dart index 0fa54cb..12b1a59 100644 --- a/lib/core/utils/time_series_mapper.dart +++ b/lib/core/utils/time_series_mapper.dart @@ -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 smooth(List data) { if (data.length < 3) return data; diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart index 0cee0c6..4e28b7d 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart @@ -112,14 +112,31 @@ class EvaporasiBloc extends Bloc { _EvaporasiRealtimeUpdated event, Emitter emit, ) { - // Hanya update grafik daily (index jam saat ini). - final updated = List.from(state.dailyValues); - final updatedTemp = List.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.from(state.dailyValues); + final updatedTemp = isDuplicate + ? state.dailyTemperatures + : List.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); diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart index 1595749..209b0a4 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart @@ -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 dailyValues; final List 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 values) { + if (values.isEmpty) return 0.0; + return values.map(_safeValue).reduce((a, b) => a < b ? a : b); } - List _lineSpots(List series) { - return series.asMap().entries.map((entry) { + double _maxOf(List 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 _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 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 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, ), ), - ), + ], ), ); } } + diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_picker.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_picker.dart index 62274bc..2fcf2b7 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_picker.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_picker.dart @@ -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}); diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart index ee5ea72..2d5e17c 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart @@ -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().add( + final bloc = context.read(); + 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), diff --git a/packages/monitoring_repository/lib/src/models/evaporasi.dart b/packages/monitoring_repository/lib/src/models/evaporasi.dart index ecf5a3a..f048180 100644 --- a/packages/monitoring_repository/lib/src/models/evaporasi.dart +++ b/packages/monitoring_repository/lib/src/models/evaporasi.dart @@ -21,60 +21,105 @@ class Evaporasi { factory Evaporasi.fromJson(Map 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); } } }