Merge branch 'branch' of https://github.com/kleponijo/klimatologi into branch

This commit is contained in:
Mochamad ongki ramadani 2026-05-09 20:21:31 +07:00
commit 7a744d3997
21 changed files with 208 additions and 1164 deletions

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

View File

@ -1,5 +1,3 @@
import 'package:monitoring_repository/monitoring_repository.dart';
class TimeSeriesMapper { class TimeSeriesMapper {
/// ========================= /// =========================
/// 📅 DAILY (24 JAM) /// 📅 DAILY (24 JAM)
@ -144,30 +142,4 @@ 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

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:monitoring_repository/monitoring_repository.dart'; import 'package:monitoring_repository/monitoring_repository.dart';
import '../../../blocs/authentication_bloc/authentication_bloc.dart'; import '../../../blocs/authentication_bloc/authentication_bloc.dart';
<<<<<<< HEAD
import '../../../blocs/notification_bloc/notification_bloc.dart'; import '../../../blocs/notification_bloc/notification_bloc.dart';
import '../../monitoring/wind_speed/blocs/wind_speed_bloc.dart'; import '../../monitoring/wind_speed/blocs/wind_speed_bloc.dart';
import '../../monitoring/evaporasi/blocs/evaporasi_bloc.dart'; import '../../monitoring/evaporasi/blocs/evaporasi_bloc.dart';
@ -9,6 +10,8 @@ import '../../monitoring/atmospheric_conditions/blocs/atmospheric_conditions_blo
import '../widgets/notification_panel.dart'; import '../widgets/notification_panel.dart';
import '../widgets/sensor_grid.dart'; import '../widgets/sensor_grid.dart';
import '../widgets/dashboard_charts.dart'; import '../widgets/dashboard_charts.dart';
=======
>>>>>>> parent of 6f38d3b (update evaporsi)
import 'main_drawer.dart'; import 'main_drawer.dart';
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
@ -97,6 +100,7 @@ class _HomeScreenState extends State<HomeScreen>
height: 90, height: 90,
fit: BoxFit.contain, fit: BoxFit.contain,
), ),
<<<<<<< HEAD
actions: [ actions: [
// Icon lonceng dengan badge // Icon lonceng dengan badge
BlocBuilder<NotificationBloc, NotificationState>( BlocBuilder<NotificationBloc, NotificationState>(
@ -174,6 +178,17 @@ class _HomeScreenState extends State<HomeScreen>
), ),
), ),
], ],
=======
const SizedBox(width: 10),
],
),
actions: [
IconButton(
icon: const Icon(Icons.notifications_none, color: Colors.black),
onPressed: () {
// Aksi notifikasi
},
>>>>>>> parent of 6f38d3b (update evaporsi)
), ),
), ),
), ),

View File

@ -37,8 +37,6 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
final history = await _repository.getSensorHistory( final history = await _repository.getSensorHistory(
'Monitoring/History', 'Monitoring/History',
(json) => Evaporasi.fromJson(json), (json) => Evaporasi.fromJson(json),
orderByChild: null,
limit: 500,
); );
final listData = List<Evaporasi>.from(history) final listData = List<Evaporasi>.from(history)
@ -47,7 +45,7 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
final dailyGraph = TimeSeriesMapper.toDaily( final dailyGraph = TimeSeriesMapper.toDaily(
data: history, data: history,
getTime: (e) => e.timestamp, getTime: (e) => e.timestamp,
getValue: (e) => e.evaporasi, getValue: (e) => e.evaporasi, // sesuaikan nama field
); );
final dailyTempGraph = TimeSeriesMapper.toDaily( final dailyTempGraph = TimeSeriesMapper.toDaily(
@ -86,7 +84,6 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
emit(state.copyWith( emit(state.copyWith(
history: history, history: history,
listData: listData,
dailyValues: dailyGraph, dailyValues: dailyGraph,
dailyTemperatures: dailyTempGraph, dailyTemperatures: dailyTempGraph,
weeklyValues: weeklyGraph, weeklyValues: weeklyGraph,
@ -96,6 +93,7 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
chartLabels: _buildChartLabels(period: 'Hari Ini'), chartLabels: _buildChartLabels(period: 'Hari Ini'),
weatherStatus: status, weatherStatus: status,
willRain: rain, willRain: rain,
listData: listData,
isLoading: false, isLoading: false,
)); ));
@ -183,8 +181,8 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
dailyValues: updated, dailyValues: updated,
dailyTemperatures: updatedTemp, dailyTemperatures: updatedTemp,
chartLabels: _buildChartLabels(period: event.period), chartLabels: _buildChartLabels(period: event.period),
viewMode: EvaporasiViewMode.period,
isLoading: false, isLoading: false,
// penting: jangan hilangkan list
listData: state.listData, listData: state.listData,
history: state.history, history: state.history,
)); ));
@ -219,7 +217,6 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
emit(state.copyWith( emit(state.copyWith(
dailyValues: updated, dailyValues: updated,
dailyTemperatures: updatedTemp, dailyTemperatures: updatedTemp,
chartLabels: _buildChartLabels(period: 'Tanggal Khusus'),
isLoading: false, isLoading: false,
// jangan hilangkan list // jangan hilangkan list
listData: state.listData, listData: state.listData,

View File

@ -28,9 +28,8 @@ class EvaporasiState extends Equatable {
final List<Evaporasi> listData; final List<Evaporasi> listData;
final List<Evaporasi> history; final List<Evaporasi> history;
final String weatherStatus;
final String weatherStatus; // Baik / Sedang / Buruk final bool willRain;
final bool willRain; // true jika status Sedang/Buruk
final bool isLoading; final bool isLoading;

View File

@ -3,10 +3,10 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../blocs/evaporasi_bloc.dart'; import '../blocs/evaporasi_bloc.dart';
import 'widgets/evaporasi_chart_widget.dart';
import 'widgets/evaporasi_period_selector.dart';
import '../../shared/utils/pdf/pdf_export_service.dart'; import '../../shared/utils/pdf/pdf_export_service.dart';
import '../../shared/widgets/export_pdf_button.dart'; import '../../shared/widgets/export_pdf_button.dart';
import 'widgets/evaporasi_period_selector.dart';
import 'widgets/evaporasi_chart_widget.dart';
class EvaporasiScreen extends StatefulWidget { class EvaporasiScreen extends StatefulWidget {
const EvaporasiScreen({super.key}); const EvaporasiScreen({super.key});
@ -60,7 +60,6 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 15), const SizedBox(height: 15),
// Period selector with date picker // Period selector with date picker
Builder( Builder(
builder: (context) { builder: (context) {
@ -86,9 +85,7 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
); );
}, },
), ),
const SizedBox(height: 15), const SizedBox(height: 15),
// Chart // Chart
Builder( Builder(
builder: (context) { builder: (context) {
@ -103,15 +100,10 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
); );
}, },
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
// List data (mengikuti chart: period vs custom date) // List data (mengikuti chart: period vs custom date)
_evaporasiList(state), _evaporasiList(state),
const SizedBox(height: 10), const SizedBox(height: 10),
ExportPdfButton( ExportPdfButton(
onExport: () => PdfExportService.evaporasi( onExport: () => PdfExportService.evaporasi(
evaporasi: state.currentValue, evaporasi: state.currentValue,
@ -212,7 +204,7 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
} }
/// ========================= /// =========================
/// 🌤 STATUS CARD /// 📈 STATUS CARD
/// ========================= /// =========================
Widget _statusCard(EvaporasiState state) { Widget _statusCard(EvaporasiState state) {
Color statusColor; Color statusColor;
@ -237,24 +229,20 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
// Teks peringatan khusus evaporasi (normal/sedang/tinggi) // Teks peringatan khusus evaporasi (normal/sedang/tinggi)
final String warningText; final String warningText;
if (state.weatherStatus == 'Baik') { if (state.weatherStatus == 'Baik') {
warningText = warningText = 'Normal — evaporasi stabil, risiko dampak rendah.';
'Normal — evaporasi stabil, risiko dampak rendah.';
} else if (state.weatherStatus == 'Sedang') { } else if (state.weatherStatus == 'Sedang') {
warningText = warningText = 'Sedang — evaporasi mulai tinggi, pantau kondisi cuaca.';
'Sedang — evaporasi mulai tinggi, pantau kondisi cuaca.';
} else { } else {
warningText = warningText =
'Tinggi — evaporasi signifikan, berpotensi memengaruhi kondisi lingkungan.'; 'Tinggi — evaporasi signifikan, berpotensi memengaruhi kondisi lingkungan.';
} }
return Container( return Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all(color: statusColor.withOpacity(0.3), width: 1.5),
), ),
child: Row( child: Row(
children: [ children: [
@ -281,7 +269,6 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
color: statusColor, color: statusColor,
), ),
), ),
if (state.willRain) if (state.willRain)
Text( Text(
warningText, warningText,
@ -291,7 +278,6 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
], ],
), ),
), ),
@ -304,7 +290,8 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
/// 🧾 LIST DATA EVAPORASI /// 🧾 LIST DATA EVAPORASI
/// ========================= /// =========================
Widget _evaporasiList(EvaporasiState state) { Widget _evaporasiList(EvaporasiState state) {
final data = state.viewMode == EvaporasiViewMode.customDate && state.selectedDate != null final data = state.viewMode == EvaporasiViewMode.customDate &&
state.selectedDate != null
? state.history ? state.history
.where((e) => .where((e) =>
e.timestamp.year == state.selectedDate!.year && e.timestamp.year == state.selectedDate!.year &&

View File

@ -1,283 +0,0 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
class EvaporasiChartWidget extends StatelessWidget {
final List<double> dailyValues;
final List<double> dailyTemperatures;
final String period;
final List<String> chartLabels;
const EvaporasiChartWidget({
super.key,
required this.dailyValues,
required this.dailyTemperatures,
required this.period,
required this.chartLabels,
});
double _getMaxY(List<double> data) {
if (data.isEmpty) return 10;
final max = data.reduce((a, b) => a > b ? a : b);
return (max + 5).clamp(10, 100);
}
List<double> _safeData(List<double> data) {
return data.map((e) {
if (e.isNaN || e.isInfinite) return 0.0;
return e < 0 ? 0.0 : e;
}).toList();
}
@override
Widget build(BuildContext context) {
final safeValues = _safeData(dailyValues);
final safeTemps = _safeData(dailyTemperatures);
// Gunakan max dari kedua dataset agar skala Y sesuai
final maxY = _getMaxY([
...safeValues,
...safeTemps,
]);
return Container(
height: 280,
padding: const EdgeInsets.fromLTRB(10, 20, 20, 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 5),
)
],
),
child: Column(
children: [
// Legend
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_legendItem(Colors.blue.shade700, "Evaporasi (mm)"),
const SizedBox(width: 20),
_legendItem(Colors.orange.shade600, "Suhu (°C)"),
],
),
const SizedBox(height: 10),
Expanded(
child: LineChart(
duration: const Duration(milliseconds: 600),
curve: Curves.easeOutCubic,
LineChartData(
minY: 0,
maxY: maxY,
gridData: const FlGridData(show: false),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
show: true,
// dual axis: kiri untuk Evaporasi (mm), kanan untuk Suhu (°C)
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
interval: _getYAxisInterval(safeValues),
getTitlesWidget: (value, meta) {
return Text(
value.toStringAsFixed(0),
style: const TextStyle(color: Colors.blueGrey, fontSize: 10),
);
},
),
),
rightTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
interval: _getYAxisInterval(safeTemps),
getTitlesWidget: (value, meta) {
return Text(
value.toStringAsFixed(0),
style: const TextStyle(color: Colors.orangeAccent, fontSize: 10),
);
},
),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
interval: _getInterval(),
getTitlesWidget: (value, meta) {
return SideTitleWidget(
meta: meta,
space: 8,
child: Text(
_getBottomTitle(value),
style: const TextStyle(
color: Colors.grey, fontSize: 10),
),
);
},
),
),
),
lineBarsData: [
// === Garis Evaporasi (kiri axis) ===
LineChartBarData(
spots: safeValues.asMap().entries.map((e) {
return FlSpot(e.key.toDouble(), e.value);
}).toList(),
isCurved: true,
curveSmoothness: 0.2,
preventCurveOverShooting: true,
color: Colors.blue.shade700,
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, bar, index) {
if (index == safeValues.length - 1) {
return FlDotCirclePainter(
radius: 5,
color: Colors.blue.shade900,
strokeWidth: 2,
strokeColor: Colors.white,
);
}
return FlDotCirclePainter(radius: 0);
},
),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: [
Colors.blue.withOpacity(0.25),
Colors.blue.withOpacity(0.05),
Colors.transparent
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
// === Garis Suhu (kanan axis) ===
LineChartBarData(
spots: safeTemps.asMap().entries.map((e) {
return FlSpot(e.key.toDouble(), e.value);
}).toList(),
isCurved: true,
curveSmoothness: 0.2,
preventCurveOverShooting: true,
color: Colors.orange.shade600,
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, bar, index) {
if (index == safeTemps.length - 1) {
return FlDotCirclePainter(
radius: 5,
color: Colors.orange.shade800,
strokeWidth: 2,
strokeColor: Colors.white,
);
}
return FlDotCirclePainter(radius: 0);
},
),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: [
Colors.orange.withOpacity(0.25),
Colors.orange.withOpacity(0.05),
Colors.transparent
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
],
lineTouchData: LineTouchData(
handleBuiltInTouches: true,
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (touchedSpots) {
return touchedSpots.map((spot) {
final isEvaporasi = spot.barIndex == 0;
final label = isEvaporasi ? "Evaporasi" : "Suhu";
final unit = isEvaporasi ? "mm" : "°C";
return LineTooltipItem(
"$label: ${spot.y.toStringAsFixed(1)} $unit",
TextStyle(
color: isEvaporasi
? Colors.blue.shade100
: Colors.orange.shade100,
fontWeight: FontWeight.bold,
),
);
}).toList();
},
),
),
),
),
),
],
),
);
}
Widget _legendItem(Color color, String label) {
return Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(3),
),
),
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
);
}
String _getBottomTitle(double value) {
final index = value.toInt();
if (chartLabels.isEmpty) return '';
if (index < 0 || index >= chartLabels.length) return '';
return chartLabels[index];
}
double _getInterval() {
if (period == "Hari Ini") return 1;
if (period == "Minggu Ini") return 1;
return 1;
}
double _getYAxisInterval(List<double> data) {
if (data.isEmpty) return 1;
final max = data.reduce((a, b) => a > b ? a : b);
if (max <= 10) return 2;
if (max <= 20) return 5;
if (max <= 30) return 10;
return 20;
}
}

View File

@ -68,20 +68,7 @@ Widget _buildDatePickerButton(BuildContext context, EvaporasiViewMode viewMode,
context.read<EvaporasiBloc>().add( context.read<EvaporasiBloc>().add(
const EvaporasiViewModeChanged(EvaporasiViewMode.customDate), const EvaporasiViewModeChanged(EvaporasiViewMode.customDate),
); );
showEvaporasiDatePicker(context);
// Penting: pastikan bottom sheet masih punya context yang memiliki EvaporasiBloc
final bloc = context.read<EvaporasiBloc>();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) {
return BlocProvider.value(
value: bloc,
child: const EvaporasiDatePicker(),
);
},
);
}, },
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),

View File

@ -48,16 +48,32 @@ 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,
); );
final dailyGraph = // Ini saja yang benar
TimeSeriesMapper.dailyFrom<MyWindSpeed>(history, (e) => e.speed); final dailyGraph = TimeSeriesMapper.smooth(
final weekly = TimeSeriesMapper.toDaily(
TimeSeriesMapper.weeklyFrom<MyWindSpeed>(history, (e) => e.speed); data: history,
final monthly = getTime: (e) => e.timestamp,
TimeSeriesMapper.monthlyFrom<MyWindSpeed>(history, (e) => e.speed); getValue: (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) {
@ -125,11 +141,48 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
/// ========================= /// =========================
/// 📊 CHANGE PERIOD /// 📊 CHANGE PERIOD
/// ========================= /// =========================
void _onPeriodChanged( Future<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,4 +3,3 @@ 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,61 +1,72 @@
// 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 'monitoring_repo.dart'; import 'package:monitoring_repository/monitoring_repository.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 value = event.snapshot.value; final Object? 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();
return mapper(snapshot.value as Map<dynamic, dynamic>? ?? {}); final data = snapshot.value as Map<dynamic, dynamic>? ?? {};
return mapper(data);
} }
@override @override
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, ) async {
int limit = 500,
}) async {
try { try {
Query query = _db.ref(path); // Ambil data dari path history
final snapshot = await _db.ref(path).get();
if (orderByChild != null) { if (snapshot.exists && snapshot.value is Map) {
query = query.orderByChild(orderByChild).limitToLast(limit); final Map<dynamic, dynamic> data = snapshot.value as Map;
} else {
query = query.limitToLast(limit); final list = data.values.map((item) {
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;
});
return list;
} }
return [];
final snapshot = await query.get(); } catch (e) {
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;
} catch (_) {
return []; return [];
} }
} }

View File

@ -1,13 +1,7 @@
import 'has_timestamp.dart'; class Evaporasi {
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({
@ -15,7 +9,6 @@ class Evaporasi implements HasTimestamp {
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(
@ -26,25 +19,71 @@ class Evaporasi implements HasTimestamp {
); );
factory Evaporasi.fromJson(Map<dynamic, dynamic> json) { factory Evaporasi.fromJson(Map<dynamic, dynamic> json) {
// Handle perbedaan field name antara history dan realtime: final now = DateTime.now();
// History path : suhu, tinggi, evaporasi, waktu DateTime timestamp = now;
// 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();
// Prioritas waktu sesuai firmware ESP32: // Prioritas waktu sesuai firmware ESP32:
// 1) timestamp (Unix, kalau pernah dikirim) // 1) timestamp (Unix atau ISO string, kalau pernah dikirim)
// 2) datetime ("YYYY-MM-DD HH:MM:SS") // 2) datetime ("YYYY-MM-DD HH:MM:SS")
// 3) waktu ("HH:MM:SS") fallback (akan memakai tanggal hari ini) // 3) waktu ("HH:MM:SS") fallback (pakai tanggal hari ini)
final rawTime = json['timestamp'] ?? json['datetime'] ?? json['waktu']; final rawTimestamp = json['timestamp'];
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);
} else {
final parsed = DateTime.tryParse(rawTimestamp);
if (parsed != null) {
timestamp = parsed;
}
}
}
} else {
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;
timestamp = DateTime(now.year, now.month, now.day, jam, menit, detik);
}
}
}
}
// Field di DB (dari info Anda):
// - evaporasi_mm
// - suhu_air
// - tinggi_air_cm
// Namun tetap toleran jika key berubah.
final evaporasiVal = json['evaporasi_mm'] ?? json['evaporasi'] ?? 0;
final suhuVal = json['suhu_air'] ?? json['suhu'] ?? 0;
final tinggiVal = json['tinggi_air_cm'] ?? json['tinggi_air'] ?? 0;
double toDoubleSafe(dynamic v) {
if (v is num) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0;
return 0;
}
return Evaporasi( return Evaporasi(
evaporasi: (json['evaporasi'] ?? 0).toDouble(), evaporasi: toDoubleSafe(evaporasiVal),
suhu: suhu, suhu: toDoubleSafe(suhuVal),
tinggiAir: tinggi, tinggiAir: toDoubleSafe(tinggiVal),
status: json['status'] ?? '', timestamp: timestamp,
timestamp: TimestampParser.parse(rawTime), // pakai TimestampParser
); );
} }
} }

View File

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

View File

@ -1,4 +1,3 @@
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';

View File

@ -1,23 +1,23 @@
import 'has_timestamp.dart'; class MyWindSpeed {
import '../utils/timestamp_parser.dart'; double speed;
class MyWindSpeed implements HasTimestamp { DateTime timestamp;
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,22 +1,21 @@
/// file monitoring_repo.dart di "C:\App_project\klimatologi\packages\monitoring_repository\lib\src\monitoring_repo.dart" // import 'models/models.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,
); );
Future<List<T>> getSensorHistory<T extends HasTimestamp>( // Fungsi untuk ambil riwayat (List)
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

@ -1,46 +0,0 @@
/// 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();
}
}

View File

@ -695,14 +695,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.27.7" version: "0.27.7"
simple_gesture_detector:
dependency: transitive
description:
name: simple_gesture_detector
sha256: ba2cd5af24ff20a0b8d609cec3f40e5b0744d2a71804a2616ae086b9c19d19a3
url: "https://pub.dev"
source: hosted
version: "0.2.1"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@ -748,14 +740,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
table_calendar:
dependency: "direct main"
description:
name: table_calendar
sha256: "0c0c6219878b363a2d5f40c7afb159d845f253d061dc3c822aa0d5fe0f721982"
url: "https://pub.dev"
source: hosted
version: "3.2.0"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description: