update pembaruan time series mapper

This commit is contained in:
kleponijo 2026-05-06 17:47:40 +07:00
parent e37fd688fb
commit 41e707d209
13 changed files with 206 additions and 214 deletions

View File

@ -1,3 +1,5 @@
import 'package:monitoring_repository/monitoring_repository.dart';
class TimeSeriesMapper { class TimeSeriesMapper {
/// ========================= /// =========================
/// 📅 DAILY (24 JAM) /// 📅 DAILY (24 JAM)
@ -114,4 +116,30 @@ class TimeSeriesMapper {
return result; return result;
} }
// Tambahkan ini setelah method smooth()
/// Shortcut untuk model yang implement HasTimestamp.
/// Dari: TimeSeriesMapper.toDaily(data: h, getTime: (e) => e.timestamp, getValue: (e) => e.speed)
/// Ke: TimeSeriesMapper.dailyFrom(h, (e) => e.speed)
static List<double> dailyFrom<T extends HasTimestamp>(
List<T> data,
double Function(T) getValue,
) =>
smooth(
toDaily(data: data, getTime: (e) => e.timestamp, getValue: getValue));
static List<double> weeklyFrom<T extends HasTimestamp>(
List<T> data,
double Function(T) getValue,
) =>
smooth(toWeekly(
data: data, getTime: (e) => e.timestamp, getValue: getValue));
static List<double> monthlyFrom<T extends HasTimestamp>(
List<T> data,
double Function(T) getValue,
) =>
smooth(toMonthly(
data: data, getTime: (e) => e.timestamp, getValue: getValue));
} }

View File

@ -1,6 +0,0 @@
// Perlu handle semua kasus ini:
TimestampParser.parse(1777807041) // Unix detik DateTime
TimestampParser.parse(1777807041000) // Unix milidetik DateTime
TimestampParser.parse("14:26:01") // String jam saja DateTime hari ini
TimestampParser.parse("2025-03-05T14:26:01") // ISO string DateTime
TimestampParser.parse(null) // fallback DateTime.now()

View File

@ -35,26 +35,29 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
emit(state.copyWith(isLoading: true)); emit(state.copyWith(isLoading: true));
final history = await _repository.getSensorHistory( final history = await _repository.getSensorHistory(
'evaporasi/history', 'Monitoring/History',
(json) => Evaporasi.fromJson(json), (json) => Evaporasi.fromJson(json),
orderByChild: null, // waktu = string, tidak bisa orderByChild
limit: 500,
); );
final dailyGraph = TimeSeriesMapper.toDaily( final dailyGraph =
data: history, TimeSeriesMapper.dailyFrom<Evaporasi>(history, (e) => e.evaporasi);
getTime: (e) => e.timestamp, final dailyTempGraph =
getValue: (e) => e.evaporasi, TimeSeriesMapper.dailyFrom<Evaporasi>(history, (e) => e.suhu);
); final weeklyGraph =
TimeSeriesMapper.weeklyFrom<Evaporasi>(history, (e) => e.evaporasi);
final dailyTempGraph = TimeSeriesMapper.toDaily( final monthlyGraph =
data: history, TimeSeriesMapper.monthlyFrom<Evaporasi>(history, (e) => e.evaporasi);
getTime: (e) => e.timestamp, final weeklyTemp =
getValue: (e) => e.suhu, TimeSeriesMapper.weeklyFrom<Evaporasi>(history, (e) => e.suhu);
); final monthlyTemp =
TimeSeriesMapper.monthlyFrom<Evaporasi>(history, (e) => e.suhu);
// Hitung status dari data terakhir history jika ada // Hitung status dari data terakhir history jika ada
final lastValue = history.isNotEmpty ? history.last.evaporasi : 0.0; final lastValue = history.isNotEmpty ? history.last.evaporasi : 0.0;
final (status, rain) = _computeWeatherStatus(lastValue); final (status, rain) = _computeWeatherStatus(lastValue);
_emitEvaporasiAlert(status, rain, lastValue); _emitEvaporasiAlert(status, lastValue);
emit(state.copyWith( emit(state.copyWith(
history: history, history: history,
@ -63,6 +66,10 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
weatherStatus: status, weatherStatus: status,
willRain: rain, willRain: rain,
isLoading: false, isLoading: false,
weeklyValues: weeklyGraph,
monthlyValues: monthlyGraph,
weeklyTemperatures: weeklyTemp,
monthlyTemperatures: monthlyTemp,
)); ));
await _subscription?.cancel(); await _subscription?.cancel();
@ -88,7 +95,7 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
} }
final (status, rain) = _computeWeatherStatus(event.data.evaporasi); final (status, rain) = _computeWeatherStatus(event.data.evaporasi);
_emitEvaporasiAlert(status, rain, event.data.evaporasi); _emitEvaporasiAlert(status, event.data.evaporasi);
emit(state.copyWith( emit(state.copyWith(
currentValue: event.data.evaporasi, currentValue: event.data.evaporasi,
@ -104,57 +111,11 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
/// ========================= /// =========================
/// 📊 PERIOD /// 📊 PERIOD
/// ========================= /// =========================
Future<void> _onPeriodChanged( void _onPeriodChanged(
EvaporasiPeriodChanged event, EvaporasiPeriodChanged event,
Emitter<EvaporasiState> emit, Emitter<EvaporasiState> emit,
) async { ) {
emit(state.copyWith(isLoading: true, selectedPeriod: event.period)); emit(state.copyWith(selectedPeriod: event.period));
final history = state.history;
List<double> updated;
List<double> updatedTemp;
if (event.period == "Minggu Ini") {
updated = TimeSeriesMapper.toWeekly(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.evaporasi,
);
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(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.suhu,
);
} else {
updated = TimeSeriesMapper.toDaily(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.evaporasi,
);
updatedTemp = TimeSeriesMapper.toDaily(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.suhu,
);
}
// Pertahankan status cuaca saat ini (tidak berubah karena hanya ganti periode)
emit(state.copyWith(
dailyValues: updated,
dailyTemperatures: updatedTemp,
isLoading: false,
));
} }
@override @override
@ -172,7 +133,7 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
return ('Buruk', true); return ('Buruk', true);
} }
void _emitEvaporasiAlert(String status, bool willRain, double value) { void _emitEvaporasiAlert(String status, double value) {
final AlertSeverity severity; final AlertSeverity severity;
final String message; final String message;

View File

@ -7,12 +7,17 @@ class EvaporasiState extends Equatable {
final String selectedPeriod; final String selectedPeriod;
final List<double> dailyValues; // untuk grafik evaporasi final List<double> dailyValues; // untuk grafik evaporasi
final List<double> weeklyValues;
final List<double> monthlyValues;
final List<double> dailyTemperatures; // untuk grafik suhu final List<double> dailyTemperatures; // untuk grafik suhu
final List<double> weeklyTemperatures;
final List<double> monthlyTemperatures;
final List<Evaporasi> history; final List<Evaporasi> history;
final String weatherStatus; // Baik / Sedang / Buruk final String weatherStatus;
final bool willRain; // true jika status Sedang/Buruk final bool willRain;
final bool isLoading; final bool isLoading;
const EvaporasiState({ const EvaporasiState({
@ -21,7 +26,11 @@ class EvaporasiState extends Equatable {
this.waterLevel = 0.0, this.waterLevel = 0.0,
this.selectedPeriod = "Hari Ini", this.selectedPeriod = "Hari Ini",
this.dailyValues = const [], this.dailyValues = const [],
this.weeklyValues = const [],
this.monthlyValues = const [],
this.dailyTemperatures = const [], this.dailyTemperatures = const [],
this.weeklyTemperatures = const [],
this.monthlyTemperatures = const [],
this.history = const [], this.history = const [],
this.weatherStatus = "Baik", this.weatherStatus = "Baik",
this.willRain = false, this.willRain = false,
@ -34,7 +43,11 @@ class EvaporasiState extends Equatable {
double? waterLevel, double? waterLevel,
String? selectedPeriod, String? selectedPeriod,
List<double>? dailyValues, List<double>? dailyValues,
List<double>? weeklyValues,
List<double>? monthlyValues,
List<double>? dailyTemperatures, List<double>? dailyTemperatures,
List<double>? weeklyTemperatures,
List<double>? monthlyTemperatures,
List<Evaporasi>? history, List<Evaporasi>? history,
String? weatherStatus, String? weatherStatus,
bool? willRain, bool? willRain,
@ -46,7 +59,11 @@ class EvaporasiState extends Equatable {
waterLevel: waterLevel ?? this.waterLevel, waterLevel: waterLevel ?? this.waterLevel,
selectedPeriod: selectedPeriod ?? this.selectedPeriod, selectedPeriod: selectedPeriod ?? this.selectedPeriod,
dailyValues: dailyValues ?? this.dailyValues, dailyValues: dailyValues ?? this.dailyValues,
weeklyValues: weeklyValues ?? this.weeklyValues,
monthlyValues: monthlyValues ?? this.monthlyValues,
dailyTemperatures: dailyTemperatures ?? this.dailyTemperatures, dailyTemperatures: dailyTemperatures ?? this.dailyTemperatures,
weeklyTemperatures: weeklyTemperatures ?? this.weeklyTemperatures,
monthlyTemperatures: monthlyTemperatures ?? this.monthlyTemperatures,
history: history ?? this.history, history: history ?? this.history,
weatherStatus: weatherStatus ?? this.weatherStatus, weatherStatus: weatherStatus ?? this.weatherStatus,
willRain: willRain ?? this.willRain, willRain: willRain ?? this.willRain,
@ -61,7 +78,11 @@ class EvaporasiState extends Equatable {
waterLevel, waterLevel,
selectedPeriod, selectedPeriod,
dailyValues, dailyValues,
weeklyValues,
monthlyValues,
dailyTemperatures, dailyTemperatures,
weeklyTemperatures,
monthlyTemperatures,
history, history,
weatherStatus, weatherStatus,
willRain, willRain,

View File

@ -48,32 +48,16 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
final history = await _repository.getSensorHistory( final history = await _repository.getSensorHistory(
'anemometer/history', 'anemometer/history',
(json) => MyWindSpeed.fromJson(json), (json) => MyWindSpeed.fromJson(json),
orderByChild: 'timestamp',
limit: 500,
); );
// Ini saja yang benar final dailyGraph =
final dailyGraph = TimeSeriesMapper.smooth( TimeSeriesMapper.dailyFrom<MyWindSpeed>(history, (e) => e.speed);
TimeSeriesMapper.toDaily( final weekly =
data: history, TimeSeriesMapper.weeklyFrom<MyWindSpeed>(history, (e) => e.speed);
getTime: (e) => e.timestamp, final monthly =
getValue: (e) => e.speed, TimeSeriesMapper.monthlyFrom<MyWindSpeed>(history, (e) => e.speed);
),
);
final weekly = TimeSeriesMapper.smooth(
TimeSeriesMapper.toWeekly(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.speed,
),
);
final monthly = TimeSeriesMapper.smooth(
TimeSeriesMapper.toMonthly(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.speed,
),
);
// Cek kondisi awal dari history // Cek kondisi awal dari history
if (history.isNotEmpty) { if (history.isNotEmpty) {
@ -141,48 +125,11 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
/// ========================= /// =========================
/// 📊 CHANGE PERIOD /// 📊 CHANGE PERIOD
/// ========================= /// =========================
Future<void> _onPeriodChanged( void _onPeriodChanged(
WindSpeedPeriodChanged event, WindSpeedPeriodChanged event,
Emitter<WindSpeedState> emit, Emitter<WindSpeedState> emit,
) async { ) {
emit(state.copyWith(selectedPeriod: event.period)); emit(state.copyWith(selectedPeriod: event.period));
final history = state.history;
List<double> raw;
if (event.period == "Minggu Ini") {
raw = TimeSeriesMapper.toWeekly(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.speed,
);
} else if (event.period == "Bulan Ini") {
raw = TimeSeriesMapper.toMonthly(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.speed,
);
} else {
raw = TimeSeriesMapper.toDaily(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.speed,
);
}
/// 🔥 baru di-smooth
final updatedGraph = TimeSeriesMapper.smooth(raw);
emit(state.copyWith(
dailySpeeds:
event.period == "Hari Ini" ? updatedGraph : state.dailySpeeds,
weeklySpeeds:
event.period == "Minggu Ini" ? updatedGraph : state.weeklySpeeds,
monthlySpeeds:
event.period == "Bulan Ini" ? updatedGraph : state.monthlySpeeds,
isLoading: false,
));
} }
@override @override

View File

@ -3,3 +3,4 @@ library monitoring_repository;
export 'src/monitoring_repo.dart'; export 'src/monitoring_repo.dart';
export 'src/firebase_monitoring_repo.dart'; export 'src/firebase_monitoring_repo.dart';
export 'src/models/models.dart'; export 'src/models/models.dart';
export 'src/utils/timestamp_parser.dart';

View File

@ -1,72 +1,61 @@
// import 'dart:developer';
import 'dart:async'; import 'dart:async';
// import 'package:rxdart/rxdart.dart';
import 'package:firebase_database/firebase_database.dart'; import 'package:firebase_database/firebase_database.dart';
import 'package:monitoring_repository/monitoring_repository.dart'; import 'monitoring_repo.dart';
import 'models/has_timestamp.dart';
import 'models/models.dart';
/// === ambil data dari realtime database firebase === ///
class FirebaseMonitoringRepo implements MonitoringRepository { class FirebaseMonitoringRepo implements MonitoringRepository {
final FirebaseDatabase _db = FirebaseDatabase.instance; final FirebaseDatabase _db = FirebaseDatabase.instance;
// Satu fungsi untuk semua jenis sensor
// Kamu cukup masukkan "path" database-nya saja
@override @override
Stream<T> getSensorStream<T>( Stream<T> getSensorStream<T>(
String path, String path,
T Function(Map<dynamic, dynamic> json) mapper, T Function(Map<dynamic, dynamic> json) mapper,
) { ) {
/// === ambil data mentah dari firebase === ///
return _db.ref(path).onValue.map((event) { return _db.ref(path).onValue.map((event) {
final Object? value = event.snapshot.value; final value = event.snapshot.value;
return mapper(value is Map ? value : {});
if (value is Map) {
return mapper(value);
} else {
// Jika data kosong atau bukan Map, berikan Map kosong agar mapper tidak crash
return mapper({});
}
}); });
} }
@override @override
// Jika ingin mengambil data sekali saja (bukan stream)
Future<T> getSensorSnapshot<T>( Future<T> getSensorSnapshot<T>(
String path, String path,
T Function(Map<dynamic, dynamic> json) mapper, T Function(Map<dynamic, dynamic> json) mapper,
) async { ) async {
final snapshot = await _db.ref(path).get(); final snapshot = await _db.ref(path).get();
final data = snapshot.value as Map<dynamic, dynamic>? ?? {}; return mapper(snapshot.value as Map<dynamic, dynamic>? ?? {});
return mapper(data);
} }
@override @override
Future<List<T>> getSensorHistory<T>( Future<List<T>> getSensorHistory<T extends HasTimestamp>(
String path, String path,
T Function(Map<dynamic, dynamic> json) mapper, T Function(Map<dynamic, dynamic> json) mapper, {
) async { String? orderByChild,
int limit = 500,
}) async {
try { try {
// Ambil data dari path history Query query = _db.ref(path);
final snapshot = await _db.ref(path).get();
if (snapshot.exists && snapshot.value is Map) { if (orderByChild != null) {
final Map<dynamic, dynamic> data = snapshot.value as Map; query = query.orderByChild(orderByChild).limitToLast(limit);
} else {
final list = data.values.map((item) { query = query.limitToLast(limit);
return mapper(item as Map<dynamic, dynamic>);
}).toList();
// Sort by timestamp Firebase push tidak jamin urutan
list.sort((a, b) {
if (a is MyWindSpeed && b is MyWindSpeed) {
return a.timestamp.compareTo(b.timestamp);
} }
return 0;
});
final snapshot = await query.get();
if (!snapshot.exists || snapshot.value is! Map) return [];
final data = snapshot.value as Map<dynamic, dynamic>;
final list = data.values
.whereType<Map>()
.map((item) => mapper(item))
.toList();
// Sort generik works untuk SEMUA sensor karena T extends HasTimestamp
list.sort((a, b) => a.timestamp.compareTo(b.timestamp));
return list; return list;
} } catch (_) {
return [];
} catch (e) {
return []; return [];
} }
} }

View File

@ -1,7 +1,13 @@
class Evaporasi { import 'has_timestamp.dart';
import '../utils/timestamp_parser.dart';
class Evaporasi implements HasTimestamp {
final double evaporasi; final double evaporasi;
final double suhu; final double suhu;
final double tinggiAir; final double tinggiAir;
final String status;
@override
final DateTime timestamp; final DateTime timestamp;
Evaporasi({ Evaporasi({
@ -9,6 +15,7 @@ class Evaporasi {
required this.suhu, required this.suhu,
required this.tinggiAir, required this.tinggiAir,
required this.timestamp, required this.timestamp,
this.status = '',
}); });
static final empty = Evaporasi( static final empty = Evaporasi(
@ -19,27 +26,22 @@ class Evaporasi {
); );
factory Evaporasi.fromJson(Map<dynamic, dynamic> json) { factory Evaporasi.fromJson(Map<dynamic, dynamic> json) {
final now = DateTime.now(); // Handle perbedaan field name antara history dan realtime:
DateTime timestamp = now; // History path : suhu, tinggi, evaporasi, waktu
// Realtime path : suhu_air, tinggi_air, evaporasi, waktu, status
final suhu = (json['suhu'] ?? json['suhu_air'] ?? 0).toDouble();
final tinggi = (json['tinggi'] ?? json['tinggi_air'] ?? 0).toDouble();
final waktuStr = json['waktu'] as String?; // Prioritas: timestamp (Unix, kalau firmware sudah diupdate)
if (waktuStr != null) { // fallback ke waktu ("HH:MM:SS")
final parts = waktuStr.split(':'); final rawTime = json['timestamp'] ?? json['waktu'];
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;
timestamp = DateTime(now.year, now.month, now.day, jam, menit, detik);
}
}
return Evaporasi( return Evaporasi(
evaporasi: (json['evaporasi'] ?? 0).toDouble(), evaporasi: (json['evaporasi'] ?? 0).toDouble(),
suhu: (json['suhu_air'] ?? 0).toDouble(), suhu: suhu,
tinggiAir: (json['tinggi_air'] ?? 0).toDouble(), tinggiAir: tinggi,
status: json['status'] ?? '',
/// 🔥 bikin timestamp dari jam & menit timestamp: TimestampParser.parse(rawTime), // pakai TimestampParser
timestamp: timestamp,
); );
} }
} }

View File

@ -0,0 +1,5 @@
/// Semua model sensor wajib implement ini.
/// Memungkinkan sorting & TimeSeriesMapper yang generik.
abstract class HasTimestamp {
DateTime get timestamp;
}

View File

@ -1,7 +1,4 @@
export 'wind_speed.dart'; export 'wind_speed.dart';
export 'evaporasi.dart'; export 'evaporasi.dart';
export 'atmospheric_conditions.dart'; export 'atmospheric_conditions.dart';
export 'has_timestamp.dart';
abstract class HasTimestamp {
DateTime get timestamp;
}

View File

@ -1,23 +1,23 @@
class MyWindSpeed { import 'has_timestamp.dart';
double speed; import '../utils/timestamp_parser.dart';
DateTime timestamp; class MyWindSpeed implements HasTimestamp {
final double speed;
@override
final DateTime timestamp;
MyWindSpeed({required this.speed, required this.timestamp}); MyWindSpeed({required this.speed, required this.timestamp});
static final empty = MyWindSpeed( static final empty = MyWindSpeed(
speed: 0.0, speed: 0.0,
timestamp: DateTime.fromMillisecondsSinceEpoch(0), timestamp: DateTime.fromMillisecondsSinceEpoch(0),
); );
factory MyWindSpeed.fromJson(Map<dynamic, dynamic> json) { factory MyWindSpeed.fromJson(Map<dynamic, dynamic> json) {
return MyWindSpeed( return MyWindSpeed(
speed: (json['speed'] ?? 0).toDouble(), speed: (json['speed'] ?? 0).toDouble(),
timestamp: TimestampParser.parse(json['timestamp']),
timestamp: DateTime.fromMillisecondsSinceEpoch(
(json['timestamp'] ?? 0) * 1000, // kalau dari Unix detik
).toLocal(),
); );
} }
} }

View File

@ -1,21 +1,22 @@
// import 'models/models.dart'; /// file monitoring_repo.dart di "C:\App_project\klimatologi\packages\monitoring_repository\lib\src\monitoring_repo.dart"
import 'models/has_timestamp.dart';
abstract class MonitoringRepository { abstract class MonitoringRepository {
// fungsi untuk ambil data berkali/streaming
Stream<T> getSensorStream<T>( Stream<T> getSensorStream<T>(
String path, String path,
T Function(Map<dynamic, dynamic> json) mapper, T Function(Map<dynamic, dynamic> json) mapper,
); );
// fungsi untuk ambil sensor sekali
Future<T> getSensorSnapshot<T>( Future<T> getSensorSnapshot<T>(
String path, String path,
T Function(Map<dynamic, dynamic> json) mapper, T Function(Map<dynamic, dynamic> json) mapper,
); );
// Fungsi untuk ambil riwayat (List) Future<List<T>> getSensorHistory<T extends HasTimestamp>(
Future<List<T>> getSensorHistory<T>(
String path, String path,
T Function(Map<dynamic, dynamic> json) mapper, T Function(Map<dynamic, dynamic> json) mapper, {
); String? orderByChild,
int limit = 500,
});
} }

View File

@ -0,0 +1,46 @@
/// Mengubah berbagai format waktu Firebase DateTime.
///
/// Format yang didukung:
/// int Unix detik 1777807041 (anemometer)
/// int Unix milidetik 1777950497446 (sensor/atmospheric)
/// String "HH:MM:SS" "11:09:00" (Monitoring/evaporasi, intraday only)
/// String ISO 8601 "2025-03-05T14:26" (untuk masa depan)
class TimestampParser {
// Angka > ini dianggap milidetik (threshold: ~Nov 2001 dalam detik)
static const int _msThreshold = 10000000000;
static DateTime parse(dynamic raw) {
if (raw == null) return DateTime.now();
if (raw is int) {
if (raw <= 0) return DateTime.now();
if (raw > _msThreshold) {
return DateTime.fromMillisecondsSinceEpoch(raw).toLocal();
}
return DateTime.fromMillisecondsSinceEpoch(raw * 1000).toLocal();
}
if (raw is double) return parse(raw.toInt());
if (raw is String) {
// "HH:MM:SS" pakai tanggal hari ini (intraday only)
final m = RegExp(r'^(\d{1,2}):(\d{2}):(\d{2})$').firstMatch(raw);
if (m != null) {
final now = DateTime.now();
return DateTime(
now.year,
now.month,
now.day,
int.parse(m.group(1)!),
int.parse(m.group(2)!),
int.parse(m.group(3)!),
);
}
try {
return DateTime.parse(raw).toLocal();
} catch (_) {}
}
return DateTime.now();
}
}