This commit is contained in:
Mochamad ongki ramadani 2026-05-20 01:03:14 +07:00
parent dbb6a25733
commit 76d8760703
7 changed files with 258 additions and 121 deletions

View File

@ -51,21 +51,24 @@ class TimeSeriesMapper {
required double Function(T) getValue,
}) {
final now = DateTime.now();
final sums = List<double>.filled(24, 0.0);
final counts = List<int>.filled(24, 0);
final values = List<double>.filled(24, -1.0);
final latestTimes = List<DateTime?>.filled(24, null);
for (final item in data) {
final time = getTime(item);
if (_isSameDay(time, now)) {
final hour = time.toLocal().hour;
final localTime = time.toLocal();
final hour = localTime.hour;
if (hour >= 0 && hour < 24) {
sums[hour] += getValue(item);
counts[hour]++;
final existingTime = latestTimes[hour];
if (existingTime == null || localTime.isAfter(existingTime)) {
values[hour] = getValue(item);
latestTimes[hour] = localTime;
}
}
}
}
final raw = List<double>.generate(24, (i) =>
counts[i] == 0 ? -1.0 : sums[i] / counts[i]);
return _interpolate(_filterSpike(raw));
return _interpolate(_filterSpike(values));
}
// ============================================================
@ -132,21 +135,24 @@ class TimeSeriesMapper {
required double Function(T) getValue,
required DateTime targetDate,
}) {
final sums = List<double>.filled(24, 0.0);
final counts = List<int>.filled(24, 0);
final values = List<double>.filled(24, -1.0);
final latestTimes = List<DateTime?>.filled(24, null);
for (final item in data) {
final time = getTime(item);
if (_isSameDay(time, targetDate)) {
final hour = time.toLocal().hour;
final localTime = time.toLocal();
final hour = localTime.hour;
if (hour >= 0 && hour < 24) {
sums[hour] += getValue(item);
counts[hour]++;
final existingTime = latestTimes[hour];
if (existingTime == null || localTime.isAfter(existingTime)) {
values[hour] = getValue(item);
latestTimes[hour] = localTime;
}
}
}
}
final raw = List<double>.generate(24, (i) =>
counts[i] == 0 ? -1.0 : sums[i] / counts[i]);
return _interpolate(_filterSpike(raw));
return _interpolate(_filterSpike(values));
}
// ============================================================

View File

@ -72,7 +72,25 @@ class _DashboardChartsState extends State<DashboardCharts> {
void _onPeriodChanged(String p) {
setState(() => _period = p);
context.read<WindSpeedBloc>().add(WindSpeedPeriodChanged(p));
context.read<EvaporasiBloc>().add(EvaporasiPeriodChanged(p));
final now = DateTime.now();
DateTime startDate;
DateTime endDate = now;
switch (p) {
case "Minggu Ini":
startDate = now.subtract(Duration(days: now.weekday - 1));
break;
case "Bulan Ini":
startDate = DateTime(now.year, now.month, 1);
break;
default:
startDate = now;
break;
}
context.read<EvaporasiBloc>().add(
EvaporasiDateRangeChanged(startDate: startDate, endDate: endDate),
);
// AtmosphericBloc tidak punya period (hanya realtime)
}
@ -132,11 +150,6 @@ class _DashboardChartsState extends State<DashboardCharts> {
// 2. Evaporasi
BlocBuilder<EvaporasiBloc, EvaporasiState>(
builder: (context, state) {
final data = switch (_period) {
"Minggu Ini" => state.dailyValues, // weekly jika ada
"Bulan Ini" => state.dailyValues,
_ => state.dailyValues,
};
return _SensorChartCard(
title: 'Evaporasi',
unit: 'mm',
@ -144,7 +157,7 @@ class _DashboardChartsState extends State<DashboardCharts> {
bgColor: Colors.teal.shade50,
icon: Icons.water_drop_outlined,
currentValue: state.currentValue,
data: data,
data: state.chartValues,
period: _period,
isLoading: state.isLoading,
);

View File

@ -46,6 +46,7 @@ class SensorGrid extends StatelessWidget {
? '—'
: state.currentValue.toStringAsFixed(1),
unit: 'mm',
status: state.currentData?.status,
isLoading: state.isLoading,
),
),
@ -79,6 +80,7 @@ class SensorCard extends StatelessWidget {
final String label;
final String value;
final String unit;
final String? status;
final bool isLoading;
const SensorCard({
@ -90,6 +92,7 @@ class SensorCard extends StatelessWidget {
required this.label,
required this.value,
required this.unit,
this.status,
this.isLoading = false,
});
@ -162,6 +165,32 @@ class SensorCard extends StatelessWidget {
),
],
),
if (status != null && !isLoading) ...[
const SizedBox(height: 5),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: (status == 'Tinggi'
? Colors.red.shade50
: (status == 'Rendah' ? Colors.blue.shade50 : Colors.green.shade50)),
borderRadius: BorderRadius.circular(4),
),
child: Text(
status!,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.bold,
color: (status == 'Tinggi'
? Colors.red.shade600
: (status == 'Rendah' ? Colors.blue.shade600 : Colors.green.shade600)),
),
),
),
],
),
],
const SizedBox(height: 4),
// Label
Text(

View File

@ -44,7 +44,6 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
(json) => Evaporasi.fromJson(json),
),
)..sort((a, b) => a.timestamp.compareTo(b.timestamp));
final now = DateTime.now();
// Default: tampilkan hari ini (per jam)
@ -88,10 +87,9 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
await _subscription?.cancel();
_subscription = _repository
.getSensorStream(
'Monitoring',
'Monitoring/realtime',
(json) {
final f = Map<dynamic, dynamic>.from(json)..remove('History');
return Evaporasi.fromJson(f);
return Evaporasi.fromJson(json);
},
)
.listen((data) => add(_EvaporasiRealtimeUpdated(data)));
@ -104,8 +102,6 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
_EvaporasiRealtimeUpdated event,
Emitter<EvaporasiState> emit,
) {
if (event.data.timestamp.millisecondsSinceEpoch == 0) return;
final updatedHistory = List<Evaporasi>.from(state.history);
final dupIdx = updatedHistory.indexWhere(
(e) => e.timestamp.toUtc() == event.data.timestamp.toUtc(),
@ -117,26 +113,45 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
}
updatedHistory.sort((a, b) => a.timestamp.compareTo(b.timestamp));
// Update chart jika sedang tampil hari ini per jam
List<double> updatedChart = state.chartValues;
List<double> updatedTemp = state.chartTemperatures;
// Regenerasikan data chart secara dinamis agar selalu sinkron dengan data real-time
List<double> updatedChart;
List<double> updatedTemp;
List<String> updatedLabels = state.chartLabels;
if (state.isSingleDay) {
final eventTime = event.data.timestamp.toLocal();
final now = DateTime.now();
final isToday = eventTime.year == now.year &&
eventTime.month == now.month &&
eventTime.day == now.day;
final isSingle = _isSameDay(state.startDate, state.endDate);
if (isToday && dupIdx < 0) {
updatedChart = List<double>.from(state.chartValues);
updatedTemp = List<double>.from(state.chartTemperatures);
final hour = eventTime.hour;
if (hour >= 0 && hour < 24) {
updatedChart[hour] = event.data.evaporasi;
updatedTemp[hour] = event.data.suhu;
}
}
if (isSingle) {
updatedChart = TimeSeriesMapper.toSpecificDate(
data: updatedHistory,
getTime: (e) => e.timestamp,
getValue: (e) => e.evaporasi,
targetDate: state.startDate,
);
updatedTemp = TimeSeriesMapper.toSpecificDate(
data: updatedHistory,
getTime: (e) => e.timestamp,
getValue: (e) => e.suhu,
targetDate: state.startDate,
);
updatedLabels = List.generate(24, (i) => '${i.toString().padLeft(2, '0')}:00');
} else {
final evapResult = TimeSeriesMapper.toDateRange(
data: updatedHistory,
getTime: (e) => e.timestamp,
getValue: (e) => e.evaporasi,
startDate: state.startDate,
endDate: state.endDate,
);
final tempResult = TimeSeriesMapper.toDateRange(
data: updatedHistory,
getTime: (e) => e.timestamp,
getValue: (e) => e.suhu,
startDate: state.startDate,
endDate: state.endDate,
);
updatedChart = evapResult.values;
updatedTemp = tempResult.values;
updatedLabels = evapResult.labels;
}
final (status, willRain) = _computeStatus(event.data.evaporasi);
@ -155,6 +170,7 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
waterLevel: event.data.tinggiAir,
chartValues: updatedChart,
chartTemperatures: updatedTemp,
chartLabels: updatedLabels,
weatherStatus: status,
willRain: willRain,
currentData: event.data,

View File

@ -165,23 +165,46 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
}
Widget _infoRow(EvaporasiState state) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_miniCard('Suhu Air',
'${state.temperature.toStringAsFixed(1)} °C',
Icons.thermostat, Colors.orange),
_miniCard('Tinggi Air',
'${state.waterLevel.toStringAsFixed(1)} cm',
Icons.water, Colors.blue),
],
return LayoutBuilder(
builder: (context, constraints) {
final cardWidth = (constraints.maxWidth - 12) / 2;
return Wrap(
spacing: 12,
runSpacing: 12,
children: [
_miniCard(
cardWidth,
'Suhu Air',
state.temperature < 0
? '- °C'
: '${state.temperature.toStringAsFixed(1)} °C',
Icons.thermostat,
Colors.orange,
),
_miniCard(
cardWidth,
'Tinggi Air',
'${state.waterLevel.toStringAsFixed(1)} cm',
Icons.water,
Colors.blue,
),
_miniCard(
cardWidth,
'Acuan Air Pagi',
'${(state.currentData?.acuanPagi ?? 0.0).toStringAsFixed(1)} cm',
Icons.wb_sunny_outlined,
Colors.amber.shade700,
),
],
);
},
);
}
Widget _miniCard(
String title, String value, IconData icon, Color color) {
double width, String title, String value, IconData icon, Color color) {
return Container(
width: 160,
width: width,
padding: const EdgeInsets.all(15),
decoration: BoxDecoration(
color: Colors.white,
@ -191,15 +214,26 @@ class _EvaporasiScreenState extends State<EvaporasiScreen> {
children: [
Icon(icon, color: color),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: const TextStyle(fontSize: 11, color: Colors.grey),
overflow: TextOverflow.ellipsis,
),
Text(
value,
style: const TextStyle(
fontSize: 12, color: Colors.grey)),
Text(value,
style: const TextStyle(fontWeight: FontWeight.bold)),
],
fontWeight: FontWeight.bold,
fontSize: 13,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),

View File

@ -86,11 +86,13 @@ class EvaporasiChartWidget extends StatelessWidget {
List<FlSpot> _buildEvapSpots(double evapMin, double evapMax) {
if (dailyValues.isEmpty) return const [];
return dailyValues.asMap().entries.map((e) {
final x = e.key.toDouble();
final y = _safeValue(e.value).clamp(evapMin, evapMax);
return FlSpot(x, y);
}).toList();
return dailyValues.asMap().entries
.where((e) => e.value >= 0) // Hanya tampilkan data yang ada / valid
.map((e) {
final x = e.key.toDouble();
final y = _safeValue(e.value).clamp(evapMin, evapMax);
return FlSpot(x, y);
}).toList();
}
@override
@ -120,27 +122,32 @@ class EvaporasiChartWidget extends StatelessWidget {
final tempMinRaw = _minOf(dailyTemperatures);
final tempMaxRaw = _maxOf(dailyTemperatures);
final tempMin = tempMinRaw;
final tempMax = tempMaxRaw == tempMinRaw ? tempMinRaw + 1 : tempMaxRaw;
// ── FIXED COORD SYSTEM SUHU MALFUNCTION ──
// Jika data suhu flat 0 atau sangat rendah, kita kunci range visualnya dari 0 sampai 40 derajat Celsius
final tempMin = tempMaxRaw < 2.0 ? 0.0 : tempMinRaw;
final tempMax = tempMaxRaw < 2.0 ? 40.0 : (tempMaxRaw == tempMinRaw ? tempMinRaw + 1 : tempMaxRaw);
// Evaporasi: batasi maksimum supaya tetap masuk akal
final evapMax = evapMaxRaw < 1e-9 ? 20.0 : (evapMaxRaw > 50 ? 50.0 : evapMaxRaw);
// Evaporasi: Berikan buffer / ruang kosong (+ 20%) di bagian atas grafik agar tidak terpotong rata
final double evapMaxBase = evapMaxRaw < 1e-9 ? 20.0 : (evapMaxRaw > 50 ? 50.0 : evapMaxRaw);
final evapMax = evapMaxBase * 1.2; // Tambahan padding atas 20%
final evapSpots = _buildEvapSpots(evapMin, evapMax);
// Suhu diproyeksikan ke skala evaporasi
final tempSpots = dailyTemperatures.asMap().entries.map((entry) {
final x = entry.key.toDouble();
final temp = _safeValue(entry.value);
final y = _tempToEvapScale(
temp: temp,
evapMin: evapMin,
evapMax: evapMax,
tempMin: tempMin,
tempMax: tempMax,
);
return FlSpot(x, y.clamp(evapMin, evapMax));
}).toList();
final tempSpots = dailyTemperatures.asMap().entries
.where((entry) => entry.value >= 0) // Hanya tampilkan data yang ada / valid
.map((entry) {
final x = entry.key.toDouble();
final temp = _safeValue(entry.value);
final y = _tempToEvapScale(
temp: temp,
evapMin: evapMin,
evapMax: evapMax,
tempMin: tempMin,
tempMax: tempMax,
);
return FlSpot(x, y.clamp(evapMin, evapMax));
}).toList();
double getRightTitle(double y) {
if (tempSpots.isEmpty) return tempMin;
@ -163,7 +170,8 @@ class EvaporasiChartWidget extends StatelessWidget {
maxX: (chartLabels.isNotEmpty ? chartLabels.length - 1 : 23)
.toDouble() +
0.5,
clipData: const FlClipData.all(),
// Diubah ke clipData false agar lengkungan ujung titik teratas tidak ter-crop kaku
clipData: const FlClipData.none(),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
@ -232,8 +240,10 @@ class EvaporasiChartWidget extends StatelessWidget {
interval: (evapMax - evapMin) / 4,
getTitlesWidget: (value, meta) {
final t = getRightTitle(value);
// Menampilkan label dengan 1 angka desimal jika nilainya kecil,
// atau bulat murni jika data menggunakan fallback sistem (0-40)
return Text(
t.toStringAsFixed(0),
t > 5 ? t.toStringAsFixed(0) : t.toStringAsFixed(1),
style: const TextStyle(
color: Colors.brown,
fontSize: 10,
@ -249,36 +259,48 @@ class EvaporasiChartWidget extends StatelessWidget {
lineTouchData: LineTouchData(
handleBuiltInTouches: true,
touchTooltipData: LineTouchTooltipData(
tooltipPadding: const EdgeInsets.all(8),
tooltipPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
showOnTopOfTheChartBoxArea: true,
fitInsideHorizontally: true,
fitInsideVertically: true,
getTooltipItems: (touchedSpots) {
if (touchedSpots.isEmpty) return const <LineTooltipItem>[];
final items = <LineTooltipItem>[];
for (int i = 0; i < touchedSpots.length; i++) {
final spot = touchedSpots[i];
final y = _safeValue(spot.y).clamp(evapMin, evapMax);
// Ambil spot pertama yang disentuh untuk mencari index X
final firstSpot = touchedSpots.first;
final idx = firstSpot.x.round();
if (i == 0) {
items.add(LineTooltipItem(
'Evap: ${y.toStringAsFixed(1)} mm',
const TextStyle(color: Colors.white, fontSize: 12),
));
} else {
final tempOnly = _evapScaleToTemp(
yEvap: y,
evapMin: evapMin,
evapMax: evapMax,
tempMin: tempMin,
tempMax: tempMax,
);
items.add(LineTooltipItem(
'Suhu: ${tempOnly.toStringAsFixed(1)} °C',
const TextStyle(color: Colors.white, fontSize: 12),
));
}
// Validasi batas index
if (idx < 0 || idx >= dailyValues.length) {
return const <LineTooltipItem>[];
}
return items;
// Ambil data evaporasi dan suhu langsung dari array data berdasarkan index X
final evapVal = dailyValues[idx];
final tempVal = idx < dailyTemperatures.length ? dailyTemperatures[idx] : 0.0;
final tooltipText = 'Evap: ${evapVal.toStringAsFixed(1)} mm\n'
'Suhu: ${tempVal.toStringAsFixed(1)} °C';
return touchedSpots.asMap().entries.map((entry) {
final index = entry.key;
if (index == 0) {
return LineTooltipItem(
tooltipText,
const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
height: 1.4,
),
);
} else {
return const LineTooltipItem(
'',
TextStyle(color: Colors.transparent, fontSize: 0),
);
}
}).toList();
},
),
),
@ -322,8 +344,6 @@ class EvaporasiChartWidget extends StatelessWidget {
children: [
const Text(
'',
// NOTE: placeholder; judul seragam dengan AtmosphericScreen.
// Jika ingin judul Evaporasi, ganti sesuai kebutuhan.
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
@ -331,7 +351,11 @@ class EvaporasiChartWidget extends StatelessWidget {
height: 220,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: chart,
// Mengubah ClipBehavior ke none agar garis di titik maksimum visual luar aman tidak ter-crop
child: Padding(
padding: const EdgeInsets.only(top: 10, right: 4),
child: chart,
),
),
),
const SizedBox(height: 12),
@ -378,5 +402,4 @@ class EvaporasiChartWidget extends StatelessWidget {
),
);
}
}
}

View File

@ -4,12 +4,16 @@ class Evaporasi {
final double evaporasi;
final double suhu;
final double tinggiAir;
final double acuanPagi;
final String status;
final DateTime timestamp;
Evaporasi({
required this.evaporasi,
required this.suhu,
required this.tinggiAir,
required this.acuanPagi,
required this.status,
required this.timestamp,
});
@ -17,6 +21,8 @@ class Evaporasi {
evaporasi: 0.0,
suhu: 0.0,
tinggiAir: 0.0,
acuanPagi: 0.0,
status: "Normal",
timestamp: DateTime.fromMillisecondsSinceEpoch(0),
);
@ -56,7 +62,7 @@ class Evaporasi {
json['temp'] ??
json['temperature'],
);
final suhuVal = (suhuRaw < -50 || suhuRaw > 100) ? 0.0 : suhuRaw;
final suhuVal = (suhuRaw < -50 || suhuRaw > 100) ? -1.0 : suhuRaw;
// ── Tinggi Air (cm) ──────────────────────────────────
final tinggiVal = toDoubleSafe(
@ -76,6 +82,14 @@ class Evaporasi {
final tinggiFiltered =
(tinggiVal < 0 || tinggiVal > 100) ? 0.0 : tinggiVal;
// ── Acuan Pagi (cm) ──────────────────────────────────
final acuanPagiVal = toDoubleSafe(
json['acuan_pagi_cm'] ?? json['acuan_pagi'] ?? 0.0,
);
// ── Status ───────────────────────────────────────────
final statusVal = json['status']?.toString() ?? "Normal";
// ── Parse Timestamp ──────────────────────────────────
// FIX: Tambahkan offset +07:00 (WIB) jika string tidak punya info timezone,
// agar tidak terjadi mismatch 7 jam antara Firebase dan Flutter.
@ -157,6 +171,8 @@ class Evaporasi {
evaporasi: evaporasiFiltered,
suhu: suhuVal,
tinggiAir: tinggiFiltered,
acuanPagi: acuanPagiVal,
status: statusVal,
timestamp: timestamp,
);
}