Merge branch 'branch' of https://github.com/kleponijo/klimatologi into branch
This commit is contained in:
commit
7a744d3997
BIN
analyze_log.txt
BIN
analyze_log.txt
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
build_log.txt
BIN
build_log.txt
Binary file not shown.
BIN
build_log2.txt
BIN
build_log2.txt
Binary file not shown.
|
|
@ -1,5 +1,3 @@
|
|||
import 'package:monitoring_repository/monitoring_repository.dart';
|
||||
|
||||
class TimeSeriesMapper {
|
||||
/// =========================
|
||||
/// 📅 DAILY (24 JAM)
|
||||
|
|
@ -144,30 +142,4 @@ class TimeSeriesMapper {
|
|||
|
||||
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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:monitoring_repository/monitoring_repository.dart';
|
||||
import '../../../blocs/authentication_bloc/authentication_bloc.dart';
|
||||
<<<<<<< HEAD
|
||||
import '../../../blocs/notification_bloc/notification_bloc.dart';
|
||||
import '../../monitoring/wind_speed/blocs/wind_speed_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/sensor_grid.dart';
|
||||
import '../widgets/dashboard_charts.dart';
|
||||
=======
|
||||
>>>>>>> parent of 6f38d3b (update evaporsi)
|
||||
import 'main_drawer.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
|
|
@ -97,6 +100,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||
height: 90,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
<<<<<<< HEAD
|
||||
actions: [
|
||||
// Icon lonceng dengan badge
|
||||
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)
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -37,8 +37,6 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
|||
final history = await _repository.getSensorHistory(
|
||||
'Monitoring/History',
|
||||
(json) => Evaporasi.fromJson(json),
|
||||
orderByChild: null,
|
||||
limit: 500,
|
||||
);
|
||||
|
||||
final listData = List<Evaporasi>.from(history)
|
||||
|
|
@ -47,7 +45,7 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
|||
final dailyGraph = TimeSeriesMapper.toDaily(
|
||||
data: history,
|
||||
getTime: (e) => e.timestamp,
|
||||
getValue: (e) => e.evaporasi,
|
||||
getValue: (e) => e.evaporasi, // ⚠️ sesuaikan nama field
|
||||
);
|
||||
|
||||
final dailyTempGraph = TimeSeriesMapper.toDaily(
|
||||
|
|
@ -86,7 +84,6 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
|||
|
||||
emit(state.copyWith(
|
||||
history: history,
|
||||
listData: listData,
|
||||
dailyValues: dailyGraph,
|
||||
dailyTemperatures: dailyTempGraph,
|
||||
weeklyValues: weeklyGraph,
|
||||
|
|
@ -96,6 +93,7 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
|||
chartLabels: _buildChartLabels(period: 'Hari Ini'),
|
||||
weatherStatus: status,
|
||||
willRain: rain,
|
||||
listData: listData,
|
||||
isLoading: false,
|
||||
));
|
||||
|
||||
|
|
@ -183,8 +181,8 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
|||
dailyValues: updated,
|
||||
dailyTemperatures: updatedTemp,
|
||||
chartLabels: _buildChartLabels(period: event.period),
|
||||
viewMode: EvaporasiViewMode.period,
|
||||
isLoading: false,
|
||||
// penting: jangan hilangkan list
|
||||
listData: state.listData,
|
||||
history: state.history,
|
||||
));
|
||||
|
|
@ -219,7 +217,6 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
|||
emit(state.copyWith(
|
||||
dailyValues: updated,
|
||||
dailyTemperatures: updatedTemp,
|
||||
chartLabels: _buildChartLabels(period: 'Tanggal Khusus'),
|
||||
isLoading: false,
|
||||
// jangan hilangkan list
|
||||
listData: state.listData,
|
||||
|
|
|
|||
|
|
@ -28,9 +28,8 @@ class EvaporasiState extends Equatable {
|
|||
final List<Evaporasi> listData;
|
||||
|
||||
final List<Evaporasi> history;
|
||||
|
||||
final String weatherStatus; // Baik / Sedang / Buruk
|
||||
final bool willRain; // true jika status Sedang/Buruk
|
||||
final String weatherStatus;
|
||||
final bool willRain;
|
||||
|
||||
final bool isLoading;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||
import 'package:intl/intl.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/widgets/export_pdf_button.dart';
|
||||
import 'widgets/evaporasi_period_selector.dart';
|
||||
import 'widgets/evaporasi_chart_widget.dart';
|
||||
|
||||
class EvaporasiScreen extends StatefulWidget {
|
||||
const EvaporasiScreen({super.key});
|
||||
|
|
@ -60,7 +60,6 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
|||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
|
||||
// Period selector with date picker
|
||||
Builder(
|
||||
builder: (context) {
|
||||
|
|
@ -86,9 +85,7 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
|||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 15),
|
||||
|
||||
// Chart
|
||||
Builder(
|
||||
builder: (context) {
|
||||
|
|
@ -103,15 +100,10 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
|||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// List data (mengikuti chart: period vs custom date)
|
||||
_evaporasiList(state),
|
||||
|
||||
|
||||
const SizedBox(height: 10),
|
||||
|
||||
ExportPdfButton(
|
||||
onExport: () => PdfExportService.evaporasi(
|
||||
evaporasi: state.currentValue,
|
||||
|
|
@ -212,7 +204,7 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
|||
}
|
||||
|
||||
/// =========================
|
||||
/// 🌤️ STATUS CARD
|
||||
/// 📈 STATUS CARD
|
||||
/// =========================
|
||||
Widget _statusCard(EvaporasiState state) {
|
||||
Color statusColor;
|
||||
|
|
@ -237,24 +229,20 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
|||
// Teks peringatan khusus evaporasi (normal/sedang/tinggi)
|
||||
final String warningText;
|
||||
if (state.weatherStatus == 'Baik') {
|
||||
warningText =
|
||||
'Normal — evaporasi stabil, risiko dampak rendah.';
|
||||
warningText = 'Normal — evaporasi stabil, risiko dampak rendah.';
|
||||
} else if (state.weatherStatus == 'Sedang') {
|
||||
warningText =
|
||||
'Sedang — evaporasi mulai tinggi, pantau kondisi cuaca.';
|
||||
warningText = 'Sedang — evaporasi mulai tinggi, pantau kondisi cuaca.';
|
||||
} else {
|
||||
warningText =
|
||||
'Tinggi — evaporasi signifikan, berpotensi memengaruhi kondisi lingkungan.';
|
||||
}
|
||||
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: statusColor.withOpacity(0.3), width: 1.5),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
|
|
@ -281,7 +269,6 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
|||
color: statusColor,
|
||||
),
|
||||
),
|
||||
|
||||
if (state.willRain)
|
||||
Text(
|
||||
warningText,
|
||||
|
|
@ -291,7 +278,6 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
|||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -304,7 +290,8 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
|||
/// 🧾 LIST DATA EVAPORASI
|
||||
/// =========================
|
||||
Widget _evaporasiList(EvaporasiState state) {
|
||||
final data = state.viewMode == EvaporasiViewMode.customDate && state.selectedDate != null
|
||||
final data = state.viewMode == EvaporasiViewMode.customDate &&
|
||||
state.selectedDate != null
|
||||
? state.history
|
||||
.where((e) =>
|
||||
e.timestamp.year == state.selectedDate!.year &&
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -68,20 +68,7 @@ Widget _buildDatePickerButton(BuildContext context, EvaporasiViewMode viewMode,
|
|||
context.read<EvaporasiBloc>().add(
|
||||
const EvaporasiViewModeChanged(EvaporasiViewMode.customDate),
|
||||
);
|
||||
|
||||
// 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(),
|
||||
);
|
||||
},
|
||||
);
|
||||
showEvaporasiDatePicker(context);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
|
|
|
|||
|
|
@ -48,16 +48,32 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
|
|||
final history = await _repository.getSensorHistory(
|
||||
'anemometer/history',
|
||||
(json) => MyWindSpeed.fromJson(json),
|
||||
orderByChild: 'timestamp',
|
||||
limit: 500,
|
||||
);
|
||||
|
||||
final dailyGraph =
|
||||
TimeSeriesMapper.dailyFrom<MyWindSpeed>(history, (e) => e.speed);
|
||||
final weekly =
|
||||
TimeSeriesMapper.weeklyFrom<MyWindSpeed>(history, (e) => e.speed);
|
||||
final monthly =
|
||||
TimeSeriesMapper.monthlyFrom<MyWindSpeed>(history, (e) => e.speed);
|
||||
// ✅ Ini saja yang benar
|
||||
final dailyGraph = TimeSeriesMapper.smooth(
|
||||
TimeSeriesMapper.toDaily(
|
||||
data: history,
|
||||
getTime: (e) => e.timestamp,
|
||||
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
|
||||
if (history.isNotEmpty) {
|
||||
|
|
@ -125,11 +141,48 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
|
|||
/// =========================
|
||||
/// 📊 CHANGE PERIOD
|
||||
/// =========================
|
||||
void _onPeriodChanged(
|
||||
Future<void> _onPeriodChanged(
|
||||
WindSpeedPeriodChanged event,
|
||||
Emitter<WindSpeedState> emit,
|
||||
) {
|
||||
) async {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -3,4 +3,3 @@ library monitoring_repository;
|
|||
export 'src/monitoring_repo.dart';
|
||||
export 'src/firebase_monitoring_repo.dart';
|
||||
export 'src/models/models.dart';
|
||||
export 'src/utils/timestamp_parser.dart';
|
||||
|
|
|
|||
|
|
@ -1,61 +1,72 @@
|
|||
// import 'dart:developer';
|
||||
import 'dart:async';
|
||||
// import 'package:rxdart/rxdart.dart';
|
||||
import 'package:firebase_database/firebase_database.dart';
|
||||
import 'monitoring_repo.dart';
|
||||
import 'models/has_timestamp.dart';
|
||||
import 'models/models.dart';
|
||||
import 'package:monitoring_repository/monitoring_repository.dart';
|
||||
|
||||
/// === ambil data dari realtime database firebase === ///
|
||||
class FirebaseMonitoringRepo implements MonitoringRepository {
|
||||
final FirebaseDatabase _db = FirebaseDatabase.instance;
|
||||
|
||||
// Satu fungsi untuk semua jenis sensor
|
||||
// Kamu cukup masukkan "path" database-nya saja
|
||||
@override
|
||||
Stream<T> getSensorStream<T>(
|
||||
String path,
|
||||
T Function(Map<dynamic, dynamic> json) mapper,
|
||||
) {
|
||||
/// === ambil data mentah dari firebase === ///
|
||||
return _db.ref(path).onValue.map((event) {
|
||||
final value = event.snapshot.value;
|
||||
return mapper(value is Map ? value : {});
|
||||
final Object? value = event.snapshot.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
|
||||
// Jika ingin mengambil data sekali saja (bukan stream)
|
||||
Future<T> getSensorSnapshot<T>(
|
||||
String path,
|
||||
T Function(Map<dynamic, dynamic> json) mapper,
|
||||
) async {
|
||||
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
|
||||
Future<List<T>> getSensorHistory<T extends HasTimestamp>(
|
||||
Future<List<T>> getSensorHistory<T>(
|
||||
String path,
|
||||
T Function(Map<dynamic, dynamic> json) mapper, {
|
||||
String? orderByChild,
|
||||
int limit = 500,
|
||||
}) async {
|
||||
T Function(Map<dynamic, dynamic> json) mapper,
|
||||
) async {
|
||||
try {
|
||||
Query query = _db.ref(path);
|
||||
// Ambil data dari path history
|
||||
final snapshot = await _db.ref(path).get();
|
||||
|
||||
if (orderByChild != null) {
|
||||
query = query.orderByChild(orderByChild).limitToLast(limit);
|
||||
} else {
|
||||
query = query.limitToLast(limit);
|
||||
if (snapshot.exists && snapshot.value is Map) {
|
||||
final Map<dynamic, dynamic> data = snapshot.value as Map;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
} catch (_) {
|
||||
return [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,7 @@
|
|||
import 'has_timestamp.dart';
|
||||
import '../utils/timestamp_parser.dart';
|
||||
|
||||
class Evaporasi implements HasTimestamp {
|
||||
class Evaporasi {
|
||||
final double evaporasi;
|
||||
final double suhu;
|
||||
final double tinggiAir;
|
||||
final String status;
|
||||
|
||||
@override
|
||||
final DateTime timestamp;
|
||||
|
||||
Evaporasi({
|
||||
|
|
@ -15,7 +9,6 @@ class Evaporasi implements HasTimestamp {
|
|||
required this.suhu,
|
||||
required this.tinggiAir,
|
||||
required this.timestamp,
|
||||
this.status = '',
|
||||
});
|
||||
|
||||
static final empty = Evaporasi(
|
||||
|
|
@ -26,25 +19,71 @@ class Evaporasi implements HasTimestamp {
|
|||
);
|
||||
|
||||
factory Evaporasi.fromJson(Map<dynamic, dynamic> json) {
|
||||
// ✅ Handle perbedaan field name antara history dan realtime:
|
||||
// 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 now = DateTime.now();
|
||||
DateTime timestamp = now;
|
||||
|
||||
// ✅ 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")
|
||||
// 3) waktu ("HH:MM:SS") fallback (akan memakai tanggal hari ini)
|
||||
final rawTime = json['timestamp'] ?? json['datetime'] ?? json['waktu'];
|
||||
// 3) waktu ("HH:MM:SS") fallback (pakai tanggal hari ini)
|
||||
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(
|
||||
evaporasi: (json['evaporasi'] ?? 0).toDouble(),
|
||||
suhu: suhu,
|
||||
tinggiAir: tinggi,
|
||||
status: json['status'] ?? '',
|
||||
timestamp: TimestampParser.parse(rawTime), // ✅ pakai TimestampParser
|
||||
evaporasi: toDoubleSafe(evaporasiVal),
|
||||
suhu: toDoubleSafe(suhuVal),
|
||||
tinggiAir: toDoubleSafe(tinggiVal),
|
||||
timestamp: timestamp,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
/// Semua model sensor wajib implement ini.
|
||||
/// Memungkinkan sorting & TimeSeriesMapper yang generik.
|
||||
abstract class HasTimestamp {
|
||||
DateTime get timestamp;
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
export 'wind_speed.dart';
|
||||
export 'evaporasi.dart';
|
||||
export 'atmospheric_conditions.dart';
|
||||
export 'has_timestamp.dart';
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
import 'has_timestamp.dart';
|
||||
import '../utils/timestamp_parser.dart';
|
||||
class MyWindSpeed {
|
||||
double speed;
|
||||
|
||||
class MyWindSpeed implements HasTimestamp {
|
||||
final double speed;
|
||||
|
||||
@override
|
||||
final DateTime timestamp;
|
||||
DateTime timestamp;
|
||||
|
||||
MyWindSpeed({required this.speed, required this.timestamp});
|
||||
|
||||
static final empty = MyWindSpeed(
|
||||
speed: 0.0,
|
||||
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
|
||||
factory MyWindSpeed.fromJson(Map<dynamic, dynamic> json) {
|
||||
return MyWindSpeed(
|
||||
speed: (json['speed'] ?? 0).toDouble(),
|
||||
timestamp: TimestampParser.parse(json['timestamp']),
|
||||
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||
(json['timestamp'] ?? 0) * 1000, // kalau dari Unix detik
|
||||
).toLocal(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,21 @@
|
|||
/// file monitoring_repo.dart di "C:\App_project\klimatologi\packages\monitoring_repository\lib\src\monitoring_repo.dart"
|
||||
|
||||
import 'models/has_timestamp.dart';
|
||||
// import 'models/models.dart';
|
||||
|
||||
abstract class MonitoringRepository {
|
||||
// fungsi untuk ambil data berkali/streaming
|
||||
Stream<T> getSensorStream<T>(
|
||||
String path,
|
||||
T Function(Map<dynamic, dynamic> json) mapper,
|
||||
);
|
||||
|
||||
// fungsi untuk ambil sensor sekali
|
||||
Future<T> getSensorSnapshot<T>(
|
||||
String path,
|
||||
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,
|
||||
T Function(Map<dynamic, dynamic> json) mapper, {
|
||||
String? orderByChild,
|
||||
int limit = 500,
|
||||
});
|
||||
T Function(Map<dynamic, dynamic> json) mapper,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
16
pubspec.lock
16
pubspec.lock
|
|
@ -695,14 +695,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
|
|
@ -748,14 +740,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
Loading…
Reference in New Issue