From 6f2f7b91a59966535f80eaa11d48326fe7ade7f0 Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Tue, 12 May 2026 19:43:15 +0700 Subject: [PATCH 01/13] Update evaporasi.dart --- packages/monitoring_repository/lib/src/models/evaporasi.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/monitoring_repository/lib/src/models/evaporasi.dart b/packages/monitoring_repository/lib/src/models/evaporasi.dart index a3500e9..2f8b91a 100644 --- a/packages/monitoring_repository/lib/src/models/evaporasi.dart +++ b/packages/monitoring_repository/lib/src/models/evaporasi.dart @@ -71,7 +71,7 @@ class Evaporasi { // Banyak kemungkinan penamaan field tinggi air. // Pakai beberapa alias agar tidak default 0. final tinggiVal = toDoubleSafe( - json['tinggi'] ?? + json['tinggi_air_cm'] ?? json['tinggi_air'] ?? json['tinggiAir'] ?? json['tinggiAir_cm'] ?? From 0315ec1d31ddd044212203ab5b67da63778e34de Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Wed, 13 May 2026 10:57:54 +0700 Subject: [PATCH 02/13] pembaruan evaporasi grafik dan overflowed tanggal --- lib/main.dart | 3 - .../views/widgets/evaporasi_chart_widget.dart | 151 +++++++++++++----- .../widgets/evaporasi_period_selector.dart | 36 +++-- .../lib/src/firebase_monitoring_repo.dart | 1 - 4 files changed, 135 insertions(+), 56 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index f0093ed..bd3e7d5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,5 @@ import 'package:flutter/material.dart'; import 'package:firebase_core/firebase_core.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:klimatologiot/simple_bloc_observer.dart'; import 'package:klimatologiot/app.dart'; import 'package:user_repository/user_repository.dart'; import 'package:monitoring_repository/monitoring_repository.dart'; @@ -14,7 +12,6 @@ void main() async { options: DefaultFirebaseOptions.currentPlatform, ); await initializeDateFormatting('id_ID', null); - Bloc.observer = SimpleBlocObserver(); runApp(MyApp( FirebaseUserRepo(), FirebaseMonitoringRepo(), diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart index 209b0a4..d1e1a93 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart @@ -1,11 +1,7 @@ import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; -const _tooltipBgColor = Colors.black87; - - class EvaporasiChartWidget extends StatelessWidget { - final List dailyValues; final List dailyTemperatures; final String period; @@ -114,7 +110,7 @@ class EvaporasiChartWidget extends StatelessWidget { // Evaporasi mm biasanya >= 0, kita pakai min 0 agar estetik. final evapMin = 0.0; - final evapMax = _clampDouble(evapMaxRaw * 1.3, 10.0, 100.0); + final evapMax = _clampDouble(evapMaxRaw * 1.15, 8.0, 50.0); // Suhu bisa saja 0 jika data kosong; tetap aman. final tempMin = tempMinRaw; @@ -128,9 +124,8 @@ class EvaporasiChartWidget extends StatelessWidget { evapByX[s.x.toInt()] = s.y; } - final dedupEvapSpots = evapByX.entries - .toList() - ..sort((a, b) => a.key.compareTo(b.key)); + final dedupEvapSpots = evapByX.entries.toList() + ..sort((a, b) => a.key.compareTo(b.key)); final Map tempByX = {}; for (final entry in dailyTemperatures.asMap().entries) { @@ -149,9 +144,8 @@ class EvaporasiChartWidget extends StatelessWidget { return FlSpot(x, y); }).toList(); - final evapSpots = dedupEvapSpots - .map((e) => FlSpot(e.key.toDouble(), e.value)) - .toList(); + final evapSpots = + dedupEvapSpots.map((e) => FlSpot(e.key.toDouble(), e.value)).toList(); if (evapSpots.isEmpty && tempSpots.isEmpty) { return const SizedBox.shrink(); @@ -167,19 +161,32 @@ class EvaporasiChartWidget extends StatelessWidget { ); } - final chart = LineChart( LineChartData( minY: evapMin, maxY: evapMax, - gridData: FlGridData(show: false), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: (evapMax - evapMin) / 4, + getDrawingHorizontalLine: (value) { + return FlLine( + color: Colors.grey.withOpacity(0.15), + strokeWidth: 1, + ); + }, + ), borderData: FlBorderData(show: false), titlesData: FlTitlesData( bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 32, - interval: 1, + interval: period == 'Hari Ini' + ? 3 + : period == 'Minggu Ini' + ? 1 + : 5, getTitlesWidget: (value, meta) { final index = value.toInt(); return Padding( @@ -228,21 +235,35 @@ class EvaporasiChartWidget extends StatelessWidget { handleBuiltInTouches: true, touchTooltipData: LineTouchTooltipData( getTooltipItems: (spots) { - - return spots.map((spot) { - // fl_chart tidak mengekspos warna ke tooltip spot pada tipe LineBarSpot - // jadi kita tampilkan dua informasi sekaligus untuk memudahkan dosen. - final temp = _evapScaleToTemp( - yEvap: spot.y, - evapMin: evapMin, - evapMax: evapMax, - tempMin: tempMin, - tempMax: tempMax, - ); + final isTemp = spot.bar.color == Colors.orange.shade700; + + if (isTemp) { + final temp = _evapScaleToTemp( + yEvap: spot.y, + evapMin: evapMin, + evapMax: evapMax, + tempMin: tempMin, + tempMax: tempMax, + ); + + return LineTooltipItem( + '๐ŸŒก Suhu\n${temp.toStringAsFixed(1)} ยฐC', + const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ); + } + return LineTooltipItem( - 'Evap: ${spot.y.toStringAsFixed(1)} mm\nSuhu: ${temp.toStringAsFixed(1)} ยฐC', - const TextStyle(color: Colors.white, fontSize: 12), + '๐Ÿ’ง Evaporasi\n${spot.y.toStringAsFixed(1)} mm', + const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), ); }).toList(); }, @@ -253,9 +274,15 @@ class EvaporasiChartWidget extends StatelessWidget { LineChartBarData( spots: evapSpots, isCurved: true, + curveSmoothness: 0.35, + isStrokeCapRound: true, color: Colors.blue.shade700, barWidth: 3, - dotData: FlDotData(show: false), + showingIndicators: [0], + dotData: FlDotData( + show: false, + checkToShowDot: (spot, barData) => false, + ), belowBarData: BarAreaData( show: true, gradient: LinearGradient( @@ -272,12 +299,20 @@ class EvaporasiChartWidget extends StatelessWidget { LineChartBarData( spots: tempSpots, isCurved: true, + curveSmoothness: 0.35, + isStrokeCapRound: true, color: Colors.orange.shade700, barWidth: 3, - dotData: FlDotData(show: false), + showingIndicators: [0], + dotData: FlDotData( + show: false, + checkToShowDot: (spot, barData) => false, + ), ), ], ), + duration: const Duration(milliseconds: 350), + curve: Curves.easeOutCubic, ); return Container( @@ -297,25 +332,70 @@ class EvaporasiChartWidget extends StatelessWidget { child: Column( children: [ // Legend - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + Wrap( + spacing: 16, + runSpacing: 8, + alignment: WrapAlignment.center, children: [ Row( + mainAxisSize: MainAxisSize.min, children: [ - Container(width: 10, height: 10, decoration: BoxDecoration(color: Colors.blue.shade700, shape: BoxShape.circle)), + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: Colors.blue.shade700, + shape: BoxShape.circle, + ), + ), const SizedBox(width: 6), - const Text('Evaporasi (mm)', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.blueGrey)), + const Text( + 'Evaporasi (mm)', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.blueGrey, + ), + ), ], ), Row( + mainAxisSize: MainAxisSize.min, children: [ - Container(width: 10, height: 10, decoration: BoxDecoration(color: Colors.orange.shade700, shape: BoxShape.circle)), + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: Colors.orange.shade700, + shape: BoxShape.circle, + ), + ), const SizedBox(width: 6), - const Text('Suhu (ยฐC)', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.brown)), + const Text( + 'Suhu (ยฐC)', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.brown, + ), + ), ], ), ], ), + + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Text( + 'Monitoring Evaporasi & Suhu', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.blueGrey.shade700, + ), + ), + ), + const SizedBox(height: 8), Expanded( child: ClipRRect( @@ -328,4 +408,3 @@ class EvaporasiChartWidget extends StatelessWidget { ); } } - diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart index 2d5e17c..0b3d742 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart @@ -19,21 +19,26 @@ class EvaporasiPeriodSelector extends StatelessWidget { color: Colors.white, borderRadius: BorderRadius.circular(15), ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _buildTab(context, "Hari Ini", selectedPeriod, viewMode), - _buildTab(context, "Minggu Ini", selectedPeriod, viewMode), - _buildTab(context, "Bulan Ini", selectedPeriod, viewMode), - const SizedBox(width: 8), - // Date picker button - _buildDatePickerButton(context, viewMode, selectedDate), - ], + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildTab(context, "Hari Ini", selectedPeriod, viewMode), + _buildTab(context, "Minggu Ini", selectedPeriod, viewMode), + _buildTab(context, "Bulan Ini", selectedPeriod, viewMode), + const SizedBox(width: 8), + // Date picker button + _buildDatePickerButton(context, viewMode, selectedDate), + ], + ), ), ); } - Widget _buildTab(BuildContext context, String label, String current, EvaporasiViewMode viewMode) { + Widget _buildTab(BuildContext context, String label, String current, + EvaporasiViewMode viewMode) { // If in customDate mode, show period tabs as inactive bool isActive = viewMode == EvaporasiViewMode.period && label == current; @@ -59,7 +64,8 @@ class EvaporasiPeriodSelector extends StatelessWidget { ); } -Widget _buildDatePickerButton(BuildContext context, EvaporasiViewMode viewMode, DateTime? selectedDate) { + Widget _buildDatePickerButton(BuildContext context, + EvaporasiViewMode viewMode, DateTime? selectedDate) { final isActive = viewMode == EvaporasiViewMode.customDate; return GestureDetector( @@ -67,8 +73,8 @@ Widget _buildDatePickerButton(BuildContext context, EvaporasiViewMode viewMode, // Set mode ke customDate SEBELUM membuka date picker final bloc = context.read(); bloc.add( - const EvaporasiViewModeChanged(EvaporasiViewMode.customDate), - ); + const EvaporasiViewModeChanged(EvaporasiViewMode.customDate), + ); showModalBottomSheet( context: context, @@ -79,7 +85,6 @@ Widget _buildDatePickerButton(BuildContext context, EvaporasiViewMode viewMode, child: const EvaporasiDatePicker(), ), ); - }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -116,4 +121,3 @@ Widget _buildDatePickerButton(BuildContext context, EvaporasiViewMode viewMode, return "${date.day}/${date.month}"; } } - diff --git a/packages/monitoring_repository/lib/src/firebase_monitoring_repo.dart b/packages/monitoring_repository/lib/src/firebase_monitoring_repo.dart index 060d254..f7cf78c 100644 --- a/packages/monitoring_repository/lib/src/firebase_monitoring_repo.dart +++ b/packages/monitoring_repository/lib/src/firebase_monitoring_repo.dart @@ -52,7 +52,6 @@ class FirebaseMonitoringRepo implements MonitoringRepository { final Map data = snapshot.value as Map; final list = data.values.map((item) { - print(item); return mapper(item as Map); }).toList(); From 5a297ff456c07ec61ee14a2c3cef95dfa2659498 Mon Sep 17 00:00:00 2001 From: Mochamad ongki ramadani Date: Wed, 13 May 2026 18:48:44 +0700 Subject: [PATCH 03/13] ppew --- TODO.md | 14 +- .../views/widgets/evaporasi_chart_widget.dart | 173 ++++++++---------- 2 files changed, 88 insertions(+), 99 deletions(-) diff --git a/TODO.md b/TODO.md index 5d401e8..0ea3373 100644 --- a/TODO.md +++ b/TODO.md @@ -1,10 +1,10 @@ -## TODO - Evaporasi (Dual Axis + History Firebase) +# TODO -### Checklist -- [x] Cek: List Evaporasi sudah mengambil `state.history` dari Firebase `getSensorHistory`. -- [x] Cek: Filter custom date pada list menggunakan `state.history` (data terdahulu sudah tersedia). -- [x] Ubah grafik evaporasi menjadi **dual-axis** (melalui mapping skala + label kiri/kanan). -- [ ] Perbaiki mismatch chart & list dengan sumber history Firebase (sinkronisasi agregasi/label/period + timezone). -- [ ] Jalankan `flutter analyze` dan minimal compile aplikasi. +- [ ] Plan & identifikasi penyebab garis merah pada `evaporasi_chart_widget.dart`. +- [ ] Periksa isi file `evaporasi_chart_widget.dart` untuk potensi error sintaks/typing. +- [ ] Lakukan perbaikan kode sesuai penyebabnya. +- [x] Analisis file `evaporasi_chart_widget.dart` untuk potensi error sintaks/typing yang memicu garis merah. +- [x] Perbaiki penyebab garis merah pada `evaporasi_chart_widget.dart` (syntax/indentation/typing). +- [ ] Jalankan `flutter format` dan `flutter analyze` (manual di terminal) untuk memastikan error hilang. diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart index 209b0a4..2e829f5 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart @@ -1,11 +1,7 @@ import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; -const _tooltipBgColor = Colors.black87; - - class EvaporasiChartWidget extends StatelessWidget { - final List dailyValues; final List dailyTemperatures; final String period; @@ -24,29 +20,6 @@ class EvaporasiChartWidget extends StatelessWidget { return value < 0 ? 0.0 : value; } - double _minOf(List values) { - if (values.isEmpty) return 0.0; - return values.map(_safeValue).reduce((a, b) => a < b ? a : b); - } - - double _maxOf(List values) { - if (values.isEmpty) return 0.0; - return values.map(_safeValue).reduce((a, b) => a > b ? a : b); - } - - double _clampDouble(double v, double minV, double maxV) { - if (v.isNaN || v.isInfinite) return minV; - return v.clamp(minV, maxV); - } - - List _evapSpots() { - return dailyValues.asMap().entries.map((entry) { - return FlSpot(entry.key.toDouble(), _safeValue(entry.value)); - }).toList(); - } - - /// Mapping suhu (ยฐC) -> posisi Y internal agar bisa ditampilkan dalam chart - /// yang sama dengan skala evaporasi (mm). double _tempToEvapScale({ required double temp, required double evapMin, @@ -54,16 +27,13 @@ class EvaporasiChartWidget extends StatelessWidget { required double tempMin, required double tempMax, }) { - // Hindari pembagian nol final tempRange = (tempMax - tempMin); if (tempRange.abs() < 1e-9) return evapMin; - final normalized = (temp - tempMin) / tempRange; // 0..1 (secara ideal) - final scaled = evapMin + normalized * (evapMax - evapMin); - return scaled; + final normalized = (temp - tempMin) / tempRange; // 0..1 ideal + return evapMin + normalized * (evapMax - evapMin); } - /// Reverse mapping Y internal (skala evaporasi) -> suhu asli (ยฐC) double _evapScaleToTemp({ required double yEvap, required double evapMin, @@ -99,30 +69,24 @@ class EvaporasiChartWidget extends StatelessWidget { color: Colors.black.withAlpha(13), blurRadius: 10, offset: const Offset(0, 5), - ) + ), ], ), child: const Text('Tidak ada data chart'), ); } - // Hitung range masing-masing agar axis kanan (ยฐC) masuk akal. - final evapMinRaw = _minOf(dailyValues); - final evapMaxRaw = _maxOf(dailyValues); - final tempMinRaw = _minOf(dailyTemperatures); - final tempMaxRaw = _maxOf(dailyTemperatures); + // Sesuai permintaan: evaporasi 0..20 dan suhu 0..30 + const evapMin = 0.0; + const evapMax = 20.0; + const tempMin = 0.0; + const tempMax = 30.0; - // Evaporasi mm biasanya >= 0, kita pakai min 0 agar estetik. - final evapMin = 0.0; - final evapMax = _clampDouble(evapMaxRaw * 1.3, 10.0, 100.0); + // Deduplicate X by index for evaporasi + final evapSpotsAll = dailyValues.asMap().entries.map((e) { + return FlSpot(e.key.toDouble(), _safeValue(e.value)); + }).toList(); - // Suhu bisa saja 0 jika data kosong; tetap aman. - final tempMin = tempMinRaw; - final tempMax = tempMaxRaw == tempMinRaw ? tempMinRaw + 1 : tempMaxRaw; - - final evapSpotsAll = _evapSpots(); - - // Deduplicate X=hour agar garis tidak kelihatan dobel/acak. final Map evapByX = {}; for (final s in evapSpotsAll) { evapByX[s.x.toInt()] = s.y; @@ -130,12 +94,13 @@ class EvaporasiChartWidget extends StatelessWidget { final dedupEvapSpots = evapByX.entries .toList() - ..sort((a, b) => a.key.compareTo(b.key)); + ..sort((a, b) => a.key.compareTo(b.key)); - final Map tempByX = {}; + final tempByX = {}; for (final entry in dailyTemperatures.asMap().entries) { tempByX[entry.key] = _safeValue(entry.value); } + final tempSpots = tempByX.entries.map((entry) { final x = entry.key.toDouble(); final temp = entry.value; @@ -150,14 +115,13 @@ class EvaporasiChartWidget extends StatelessWidget { }).toList(); final evapSpots = dedupEvapSpots - .map((e) => FlSpot(e.key.toDouble(), e.value)) + .map((e) => FlSpot( + e.key.toDouble(), + e.value.clamp(evapMin, evapMax), + )) .toList(); - if (evapSpots.isEmpty && tempSpots.isEmpty) { - return const SizedBox.shrink(); - } - - double _getRightTitle(double y) { + double getRightTitle(double y) { return _evapScaleToTemp( yEvap: y, evapMin: evapMin, @@ -167,13 +131,14 @@ class EvaporasiChartWidget extends StatelessWidget { ); } - final chart = LineChart( LineChartData( minY: evapMin, maxY: evapMax, gridData: FlGridData(show: false), borderData: FlBorderData(show: false), + + titlesData: FlTitlesData( bottomTitles: AxisTitles( sideTitles: SideTitles( @@ -198,10 +163,12 @@ class EvaporasiChartWidget extends StatelessWidget { reservedSize: 40, interval: (evapMax - evapMin) / 4, getTitlesWidget: (value, meta) { - final v = value; return Text( - '${v.toStringAsFixed(0)}', - style: const TextStyle(color: Colors.blueGrey, fontSize: 10), + value.toStringAsFixed(0), + style: const TextStyle( + color: Colors.blueGrey, + fontSize: 10, + ), ); }, ), @@ -212,27 +179,27 @@ class EvaporasiChartWidget extends StatelessWidget { reservedSize: 40, interval: (evapMax - evapMin) / 4, getTitlesWidget: (value, meta) { - final t = _getRightTitle(value); + final t = getRightTitle(value); return Text( - '${t.toStringAsFixed(0)}', - style: const TextStyle(color: Colors.brown, fontSize: 10), + t.toStringAsFixed(0), + style: const TextStyle( + color: Colors.brown, + fontSize: 10, + ), ); }, ), ), - topTitles: const AxisTitles( + topTitles: AxisTitles( sideTitles: SideTitles(showTitles: false), ), ), lineTouchData: LineTouchData( handleBuiltInTouches: true, touchTooltipData: LineTouchTooltipData( + getTooltipItems: (spots) { - - return spots.map((spot) { - // fl_chart tidak mengekspos warna ke tooltip spot pada tipe LineBarSpot - // jadi kita tampilkan dua informasi sekaligus untuk memudahkan dosen. final temp = _evapScaleToTemp( yEvap: spot.y, evapMin: evapMin, @@ -252,36 +219,31 @@ class EvaporasiChartWidget extends StatelessWidget { if (evapSpots.isNotEmpty) LineChartBarData( spots: evapSpots, - isCurved: true, + isCurved: false, color: Colors.blue.shade700, - barWidth: 3, - dotData: FlDotData(show: false), - belowBarData: BarAreaData( - show: true, - gradient: LinearGradient( - colors: [ - Colors.blue.withAlpha(64), - Colors.blue.withAlpha(13), - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ), - ), + barWidth: 2, + dotData: FlDotData(show: false), + belowBarData: BarAreaData(show: false), + + ), + if (tempSpots.isNotEmpty) LineChartBarData( spots: tempSpots, - isCurved: true, + isCurved: false, color: Colors.orange.shade700, - barWidth: 3, - dotData: FlDotData(show: false), + barWidth: 2, + dotData: FlDotData(show: false), + belowBarData: BarAreaData(show: false), + ), ], ), ); return Container( - height: 340, + height: 380, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.white, @@ -291,27 +253,54 @@ class EvaporasiChartWidget extends StatelessWidget { color: Colors.black.withAlpha(13), blurRadius: 10, offset: const Offset(0, 5), - ) + ), ], ), child: Column( children: [ - // Legend Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ Row( children: [ - Container(width: 10, height: 10, decoration: BoxDecoration(color: Colors.blue.shade700, shape: BoxShape.circle)), + Container( + width: 10, + height: 10, + decoration: const BoxDecoration( + color: Colors.blue, + shape: BoxShape.circle, + ), + ), const SizedBox(width: 6), - const Text('Evaporasi (mm)', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.blueGrey)), + const Text( + 'Evaporasi (mm)', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.blueGrey, + ), + ), ], ), Row( children: [ - Container(width: 10, height: 10, decoration: BoxDecoration(color: Colors.orange.shade700, shape: BoxShape.circle)), + Container( + width: 10, + height: 10, + decoration: const BoxDecoration( + color: Colors.orange, + shape: BoxShape.circle, + ), + ), const SizedBox(width: 6), - const Text('Suhu (ยฐC)', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.brown)), + const Text( + 'Suhu (ยฐC)', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.brown, + ), + ), ], ), ], From 7dc33526364fd36df43a6f63bd27adba1d9c8b2f Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Wed, 13 May 2026 20:45:07 +0700 Subject: [PATCH 04/13] Update evaporasi.dart --- .../lib/src/models/evaporasi.dart | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/monitoring_repository/lib/src/models/evaporasi.dart b/packages/monitoring_repository/lib/src/models/evaporasi.dart index 2f8b91a..4da0049 100644 --- a/packages/monitoring_repository/lib/src/models/evaporasi.dart +++ b/packages/monitoring_repository/lib/src/models/evaporasi.dart @@ -134,10 +134,16 @@ class Evaporasi { } } + final evaporasiFiltered = (evaporasiVal < 0 || evaporasiVal > 50) + ? 0.0 + : evaporasiVal; + + final tinggiFiltered = (tinggiVal < 0 || tinggiVal > 100) ? 0.0 : tinggiVal; + return Evaporasi( - evaporasi: evaporasiVal, + evaporasi: evaporasiFiltered, suhu: suhuVal, - tinggiAir: tinggiVal, + tinggiAir: tinggiFiltered, timestamp: timestamp, ); } From e00d47179620d1aeff67f6a197b211838f1e42f6 Mon Sep 17 00:00:00 2001 From: Mochamad ongki ramadani Date: Wed, 13 May 2026 21:25:01 +0700 Subject: [PATCH 05/13] brow --- .../views/widgets/evaporasi_chart_widget.dart | 146 +++++------------- 1 file changed, 37 insertions(+), 109 deletions(-) diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart index a561758..6f1ad4c 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart @@ -17,7 +17,8 @@ class EvaporasiChartWidget extends StatelessWidget { double _safeValue(double value) { if (value.isNaN || value.isInfinite) return 0.0; - return value < 0 ? 0.0 : value; + if (value < 0) return 0.0; + return value; } double _tempToEvapScale({ @@ -30,7 +31,7 @@ class EvaporasiChartWidget extends StatelessWidget { final tempRange = (tempMax - tempMin); if (tempRange.abs() < 1e-9) return evapMin; - final normalized = (temp - tempMin) / tempRange; // 0..1 ideal + final normalized = (temp - tempMin) / tempRange; // 0..1 return evapMin + normalized * (evapMax - evapMin); } @@ -69,7 +70,7 @@ class EvaporasiChartWidget extends StatelessWidget { color: Colors.black.withAlpha(13), blurRadius: 10, offset: const Offset(0, 5), - ), + ) ], ), child: const Text('Tidak ada data chart'), @@ -82,18 +83,23 @@ class EvaporasiChartWidget extends StatelessWidget { const tempMin = 0.0; const tempMax = 30.0; - // Evaporasi mm biasanya >= 0, kita pakai min 0 agar estetik. - final evapMin = 0.0; - final evapMax = _clampDouble(evapMaxRaw * 1.15, 8.0, 50.0); + // Evaporasi: deduplicate X by index + final evapSpotsAll = dailyValues.asMap().entries.map((e) { + return FlSpot(e.key.toDouble(), _safeValue(e.value)); + }).toList(); final Map evapByX = {}; for (final s in evapSpotsAll) { evapByX[s.x.toInt()] = s.y; } - final dedupEvapSpots = evapByX.entries.toList() ..sort((a, b) => a.key.compareTo(b.key)); + final evapSpots = dedupEvapSpots + .map((e) => FlSpot(e.key.toDouble(), e.value.clamp(evapMin, evapMax))) + .toList(); + + // Suhu: skala-kan agar bisa ditampilkan di axis evaporasi final tempByX = {}; for (final entry in dailyTemperatures.asMap().entries) { tempByX[entry.key] = _safeValue(entry.value); @@ -109,12 +115,9 @@ class EvaporasiChartWidget extends StatelessWidget { tempMin: tempMin, tempMax: tempMax, ); - return FlSpot(x, y); + return FlSpot(x, y.clamp(evapMin, evapMax)); }).toList(); - final evapSpots = - dedupEvapSpots.map((e) => FlSpot(e.key.toDouble(), e.value)).toList(); - double getRightTitle(double y) { return _evapScaleToTemp( yEvap: y, @@ -129,30 +132,14 @@ class EvaporasiChartWidget extends StatelessWidget { LineChartData( minY: evapMin, maxY: evapMax, - gridData: FlGridData( - show: true, - drawVerticalLine: false, - horizontalInterval: (evapMax - evapMin) / 4, - getDrawingHorizontalLine: (value) { - return FlLine( - color: Colors.grey.withOpacity(0.15), - strokeWidth: 1, - ); - }, - ), + gridData: FlGridData(show: false), borderData: FlBorderData(show: false), - - titlesData: FlTitlesData( bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 32, - interval: period == 'Hari Ini' - ? 3 - : period == 'Minggu Ini' - ? 1 - : 5, + interval: 1, getTitlesWidget: (value, meta) { final index = value.toInt(); return Padding( @@ -205,37 +192,19 @@ class EvaporasiChartWidget extends StatelessWidget { lineTouchData: LineTouchData( handleBuiltInTouches: true, touchTooltipData: LineTouchTooltipData( - getTooltipItems: (spots) { return spots.map((spot) { - final isTemp = spot.bar.color == Colors.orange.shade700; - - if (isTemp) { - final temp = _evapScaleToTemp( - yEvap: spot.y, - evapMin: evapMin, - evapMax: evapMax, - tempMin: tempMin, - tempMax: tempMax, - ); - - return LineTooltipItem( - '๐ŸŒก Suhu\n${temp.toStringAsFixed(1)} ยฐC', - const TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ); - } + final temp = _evapScaleToTemp( + yEvap: spot.y, + evapMin: evapMin, + evapMax: evapMax, + tempMin: tempMin, + tempMax: tempMax, + ); return LineTooltipItem( - '๐Ÿ’ง Evaporasi\n${spot.y.toStringAsFixed(1)} mm', - const TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w600, - ), + 'Evap: ${spot.y.toStringAsFixed(1)} mm\nSuhu: ${temp.toStringAsFixed(1)} ยฐC', + const TextStyle(color: Colors.white, fontSize: 12), ); }).toList(); }, @@ -245,47 +214,23 @@ class EvaporasiChartWidget extends StatelessWidget { if (evapSpots.isNotEmpty) LineChartBarData( spots: evapSpots, - isCurved: true, - curveSmoothness: 0.35, - isStrokeCapRound: true, + isCurved: false, color: Colors.blue.shade700, - barWidth: 3, - showingIndicators: [0], - dotData: FlDotData( - show: false, - checkToShowDot: (spot, barData) => false, - ), - belowBarData: BarAreaData( - show: true, - gradient: LinearGradient( - colors: [ - Colors.blue.withAlpha(64), - Colors.blue.withAlpha(13), - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ), - ), + barWidth: 2, + dotData: FlDotData(show: false), + belowBarData: BarAreaData(show: false), ), - if (tempSpots.isNotEmpty) LineChartBarData( spots: tempSpots, - isCurved: true, - curveSmoothness: 0.35, - isStrokeCapRound: true, + isCurved: false, color: Colors.orange.shade700, - barWidth: 3, - showingIndicators: [0], - dotData: FlDotData( - show: false, - checkToShowDot: (spot, barData) => false, - ), + barWidth: 2, + dotData: FlDotData(show: false), + belowBarData: BarAreaData(show: false), ), ], ), - duration: const Duration(milliseconds: 350), - curve: Curves.easeOutCubic, ); return Container( @@ -299,19 +244,15 @@ class EvaporasiChartWidget extends StatelessWidget { color: Colors.black.withAlpha(13), blurRadius: 10, offset: const Offset(0, 5), - ), + ) ], ), child: Column( children: [ - // Legend - Wrap( - spacing: 16, - runSpacing: 8, - alignment: WrapAlignment.center, + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ Row( - mainAxisSize: MainAxisSize.min, children: [ Container( width: 10, @@ -333,7 +274,6 @@ class EvaporasiChartWidget extends StatelessWidget { ], ), Row( - mainAxisSize: MainAxisSize.min, children: [ Container( width: 10, @@ -356,19 +296,6 @@ class EvaporasiChartWidget extends StatelessWidget { ), ], ), - - Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Text( - 'Monitoring Evaporasi & Suhu', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Colors.blueGrey.shade700, - ), - ), - ), - const SizedBox(height: 8), Expanded( child: ClipRRect( @@ -381,3 +308,4 @@ class EvaporasiChartWidget extends StatelessWidget { ); } } + From 2d2c8c0cab1e725f16df36bba7c31b85416ec671 Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Wed, 13 May 2026 21:31:41 +0700 Subject: [PATCH 06/13] kece abis --- .../views/widgets/evaporasi_chart_widget.dart | 1 - .../widgets/evaporasi_date_search_bar.dart | 9 +++++++-- .../lib/src/models/evaporasi.dart | 17 +++++++++++------ 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart index d1e1a93..da87dc8 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart @@ -103,7 +103,6 @@ class EvaporasiChartWidget extends StatelessWidget { } // Hitung range masing-masing agar axis kanan (ยฐC) masuk akal. - final evapMinRaw = _minOf(dailyValues); final evapMaxRaw = _maxOf(dailyValues); final tempMinRaw = _minOf(dailyTemperatures); final tempMaxRaw = _maxOf(dailyTemperatures); diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_search_bar.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_search_bar.dart index ea17f0e..930b665 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_search_bar.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_search_bar.dart @@ -21,6 +21,9 @@ class _EvaporasiDateSearchBarState extends State { void initState() { super.initState(); _controller = TextEditingController(text: widget.initialQuery); + _controller.addListener(() { + setState(() {}); + }); } @override @@ -39,7 +42,10 @@ class _EvaporasiDateSearchBarState extends State { suffixIcon: _controller.text.isNotEmpty ? IconButton( icon: const Icon(Icons.clear), - onPressed: () => setState(() => _controller.clear()), + onPressed: () { + _controller.clear(); + widget.onQueryChanged(''); + }, ) : null, border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), @@ -47,7 +53,6 @@ class _EvaporasiDateSearchBarState extends State { ), onChanged: (v) { widget.onQueryChanged(v); - setState(() {}); }, ); } diff --git a/packages/monitoring_repository/lib/src/models/evaporasi.dart b/packages/monitoring_repository/lib/src/models/evaporasi.dart index 4da0049..00c46d8 100644 --- a/packages/monitoring_repository/lib/src/models/evaporasi.dart +++ b/packages/monitoring_repository/lib/src/models/evaporasi.dart @@ -83,6 +83,17 @@ class Evaporasi { json['tinggiAir_m'], ); + // ========================= + // FILTER DATA INVALID + // ========================= + + // evaporasi normal 0-50 mm + final evaporasiFiltered = (evaporasiVal < 0 || evaporasiVal > 50) + ? 0.0 + : evaporasiVal; + + final tinggiFiltered = (tinggiVal < 0 || tinggiVal > 100) ? 0.0 : tinggiVal; + // Default timestamp: fallback now (kalau field waktu tidak ada). // Catatan: untuk Firebase seharusnya timestamp dikirim konsisten (ms atau ISO string). DateTime timestamp = DateTime.now(); @@ -134,12 +145,6 @@ class Evaporasi { } } - final evaporasiFiltered = (evaporasiVal < 0 || evaporasiVal > 50) - ? 0.0 - : evaporasiVal; - - final tinggiFiltered = (tinggiVal < 0 || tinggiVal > 100) ? 0.0 : tinggiVal; - return Evaporasi( evaporasi: evaporasiFiltered, suhu: suhuVal, From 2ff3bd3306ac05b5771c0df225b318f1135adc23 Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Wed, 13 May 2026 22:40:30 +0700 Subject: [PATCH 07/13] oke --- .../evaporasi/blocs/evaporasi_bloc.dart | 16 ++++------------ .../evaporasi/blocs/evaporasi_state.dart | 7 ++++++- .../evaporasi/views/evaporasi_screen.dart | 14 +++++++++----- .../lib/src/models/evaporasi.dart | 6 +++--- 4 files changed, 22 insertions(+), 21 deletions(-) diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart index a3f1e56..9d5ca6d 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart @@ -99,6 +99,7 @@ class EvaporasiBloc extends Bloc { weatherStatus: status, willRain: rain, listData: listData, + currentData: history.isNotEmpty ? history.last : null, isLoading: false, )); @@ -116,17 +117,9 @@ class EvaporasiBloc extends Bloc { final previous = state.history.isNotEmpty ? state.history.last.timestamp : null; - final updatedHistory = List.from(state.history)..add(event.data); - - final isDuplicate = - previous != null && event.data.timestamp.toUtc() == previous.toUtc(); - // Update bucket berdasarkan timestamp event (bukan jam lokal sekarang). - final updated = - isDuplicate ? state.dailyValues : List.from(state.dailyValues); - final updatedTemp = isDuplicate - ? state.dailyTemperatures - : List.from(state.dailyTemperatures); + final updated = List.from(state.dailyValues); + final updatedTemp = List.from(state.dailyTemperatures); final eventTime = event.data.timestamp; final now = DateTime.now(); @@ -147,8 +140,6 @@ class EvaporasiBloc extends Bloc { _emitEvaporasiAlert(status, rain, event.data.evaporasi); emit(state.copyWith( - history: updatedHistory, - listData: updatedHistory, currentValue: event.data.evaporasi, temperature: event.data.suhu, waterLevel: event.data.tinggiAir, @@ -156,6 +147,7 @@ class EvaporasiBloc extends Bloc { dailyTemperatures: updatedTemp, weatherStatus: status, willRain: rain, + currentData: event.data, )); } diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart index d84efea..c1f8793 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart @@ -31,6 +31,8 @@ class EvaporasiState extends Equatable { final String weatherStatus; final bool willRain; + final Evaporasi? currentData; // data realtime terbaru + final bool isLoading; const EvaporasiState({ @@ -51,6 +53,7 @@ class EvaporasiState extends Equatable { this.history = const [], this.weatherStatus = 'Baik', this.willRain = false, + this.currentData, this.isLoading = true, }); @@ -72,6 +75,7 @@ class EvaporasiState extends Equatable { List? history, String? weatherStatus, bool? willRain, + Evaporasi? currentData, bool? isLoading, }) { return EvaporasiState( @@ -92,6 +96,7 @@ class EvaporasiState extends Equatable { history: history ?? this.history, weatherStatus: weatherStatus ?? this.weatherStatus, willRain: willRain ?? this.willRain, + currentData: currentData ?? this.currentData, isLoading: isLoading ?? this.isLoading, ); } @@ -115,7 +120,7 @@ class EvaporasiState extends Equatable { history, weatherStatus, willRain, + currentData, isLoading, ]; } - diff --git a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart index 3cdfdca..be79b65 100644 --- a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart +++ b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart @@ -290,15 +290,21 @@ class _EvaporasiScreenState extends State { /// ๐Ÿงพ LIST DATA EVAPORASI /// ========================= Widget _evaporasiList(EvaporasiState state) { - final data = state.viewMode == EvaporasiViewMode.customDate && + final allData = [...state.history]; + if (state.currentData != null) { + allData.add(state.currentData!); + } + + final data = (state.viewMode == EvaporasiViewMode.customDate && state.selectedDate != null - ? state.history + ? allData .where((e) => e.timestamp.year == state.selectedDate!.year && e.timestamp.month == state.selectedDate!.month && e.timestamp.day == state.selectedDate!.day) .toList() - : state.history; + : List.of(allData)) + ..sort((a, b) => b.timestamp.compareTo(a.timestamp)); if (data.isEmpty) { return Container( @@ -423,7 +429,6 @@ class _EvaporasiScreenState extends State { } String _formatDateInfo(DateTime date) { - final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); @@ -439,4 +444,3 @@ class _EvaporasiScreenState extends State { } } } - diff --git a/packages/monitoring_repository/lib/src/models/evaporasi.dart b/packages/monitoring_repository/lib/src/models/evaporasi.dart index 00c46d8..258c8c2 100644 --- a/packages/monitoring_repository/lib/src/models/evaporasi.dart +++ b/packages/monitoring_repository/lib/src/models/evaporasi.dart @@ -96,7 +96,7 @@ class Evaporasi { // Default timestamp: fallback now (kalau field waktu tidak ada). // Catatan: untuk Firebase seharusnya timestamp dikirim konsisten (ms atau ISO string). - DateTime timestamp = DateTime.now(); + DateTime timestamp = DateTime.fromMillisecondsSinceEpoch(0); // dukung beberapa kemungkinan penamaan timestamp final rawTimestamp = json['timestamp'] ?? json['time'] ?? json['datetime']; @@ -120,7 +120,7 @@ class Evaporasi { } else { final parsed = DateTime.tryParse(s); if (parsed != null) { - timestamp = parsed; + timestamp = parsed.toLocal(); } } } @@ -129,7 +129,7 @@ class Evaporasi { final datetimeStr = json['datetime'] as String?; if (datetimeStr != null) { final parsed = DateTime.tryParse(datetimeStr); - if (parsed != null) timestamp = parsed; + if (parsed != null) timestamp = parsed.toLocal(); } final waktuStr = json['waktu'] as String?; From 546f1287f88ce218c7cb7a89706010b1017d285a Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Wed, 13 May 2026 23:00:25 +0700 Subject: [PATCH 08/13] ada --- .../evaporasi/blocs/evaporasi_bloc.dart | 32 ++++++++++++++++--- .../evaporasi/views/evaporasi_screen.dart | 9 ++---- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart index 9d5ca6d..660489e 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart @@ -105,17 +105,39 @@ class EvaporasiBloc extends Bloc { await _subscription?.cancel(); _subscription = _repository - .getSensorStream('Monitoring', (json) => Evaporasi.fromJson(json)) + .getSensorStream('Monitoring/History', _latestHistoryEntry) .listen((data) => add(_EvaporasiRealtimeUpdated(data))); } + Evaporasi _latestHistoryEntry(Map json) { + if (json.isEmpty) return Evaporasi.empty; + + final entries = json.values + .whereType>() + .map((item) => Evaporasi.fromJson(item)) + .toList(); + + if (entries.isEmpty) return Evaporasi.empty; + + entries.sort((a, b) => a.timestamp.compareTo(b.timestamp)); + return entries.last; + } + void _onRealtimeUpdated( _EvaporasiRealtimeUpdated event, Emitter emit, ) { - // Hindari update dobel: jika timestamp event sama dengan yang terakhir, jangan ubah bucket. - final previous = - state.history.isNotEmpty ? state.history.last.timestamp : null; + final updatedHistory = List.from(state.history); + final duplicateIndex = updatedHistory.indexWhere( + (item) => item.timestamp.toUtc() == event.data.timestamp.toUtc(), + ); + + if (duplicateIndex >= 0) { + updatedHistory[duplicateIndex] = event.data; + } else { + updatedHistory.add(event.data); + } + updatedHistory.sort((a, b) => a.timestamp.compareTo(b.timestamp)); // Update bucket berdasarkan timestamp event (bukan jam lokal sekarang). final updated = List.from(state.dailyValues); @@ -140,6 +162,8 @@ class EvaporasiBloc extends Bloc { _emitEvaporasiAlert(status, rain, event.data.evaporasi); emit(state.copyWith( + history: updatedHistory, + listData: updatedHistory, currentValue: event.data.evaporasi, temperature: event.data.suhu, waterLevel: event.data.tinggiAir, diff --git a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart index be79b65..fec813a 100644 --- a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart +++ b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart @@ -290,20 +290,15 @@ class _EvaporasiScreenState extends State { /// ๐Ÿงพ LIST DATA EVAPORASI /// ========================= Widget _evaporasiList(EvaporasiState state) { - final allData = [...state.history]; - if (state.currentData != null) { - allData.add(state.currentData!); - } - final data = (state.viewMode == EvaporasiViewMode.customDate && state.selectedDate != null - ? allData + ? state.history .where((e) => e.timestamp.year == state.selectedDate!.year && e.timestamp.month == state.selectedDate!.month && e.timestamp.day == state.selectedDate!.day) .toList() - : List.of(allData)) + : List.of(state.history)) ..sort((a, b) => b.timestamp.compareTo(a.timestamp)); if (data.isEmpty) { From 6379502e281bc5979dc1d924fcef4e1d34ce39e8 Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Thu, 14 May 2026 17:15:45 +0700 Subject: [PATCH 09/13] penambahan dan perbaikan wind_speed/anemometer ditambahkan history dan list data dan tanggal --- .../evaporasi/blocs/evaporasi_bloc.dart | 13 +- .../views/widgets/evaporasi_chart_widget.dart | 18 +- .../utils/excel/wind_speed_excel_service.dart | 348 ++++++++++++++ .../widgets/wind_speed_history_list.dart | 448 ++++++++++++++++++ linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + .../lib/src/firebase_monitoring_repo.dart | 11 +- .../lib/src/models/evaporasi.dart | 86 +++- pubspec.lock | 114 ++++- pubspec.yaml | 5 +- .../flutter/generated_plugin_registrant.cc | 6 + windows/flutter/generated_plugins.cmake | 2 + 13 files changed, 998 insertions(+), 60 deletions(-) create mode 100644 lib/screens/monitoring/shared/utils/excel/wind_speed_excel_service.dart create mode 100644 lib/screens/monitoring/wind_speed/views/widgets/wind_speed_history_list.dart diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart index 660489e..e2f06ca 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart @@ -34,13 +34,14 @@ class EvaporasiBloc extends Bloc { emit(state.copyWith(isLoading: true)); // Ambil history yang akan dipakai untuk list + agregasi grafik. - final history = await _repository.getSensorHistory( - 'Monitoring/History', - (json) => Evaporasi.fromJson(json), - ); + final history = List.from( + await _repository.getSensorHistory( + 'Monitoring/History', + (json) => Evaporasi.fromJson(json), + ), + )..sort((a, b) => a.timestamp.compareTo(b.timestamp)); - final listData = List.from(history) - ..sort((a, b) => a.timestamp.compareTo(b.timestamp)); + final listData = List.from(history); final dailyGraph = TimeSeriesMapper.toDaily( data: history, diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart index da87dc8..a891d33 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart @@ -17,6 +17,12 @@ class EvaporasiChartWidget extends StatelessWidget { double _safeValue(double value) { if (value.isNaN || value.isInfinite) return 0.0; + + // anti spike + if (value > 1000 || value < -1000) { + return 0.0; + } + return value < 0 ? 0.0 : value; } @@ -117,14 +123,7 @@ class EvaporasiChartWidget extends StatelessWidget { final evapSpotsAll = _evapSpots(); - // Deduplicate X=hour agar garis tidak kelihatan dobel/acak. - final Map evapByX = {}; - for (final s in evapSpotsAll) { - evapByX[s.x.toInt()] = s.y; - } - - final dedupEvapSpots = evapByX.entries.toList() - ..sort((a, b) => a.key.compareTo(b.key)); + final dedupEvapSpots = evapSpotsAll; final Map tempByX = {}; for (final entry in dailyTemperatures.asMap().entries) { @@ -143,8 +142,7 @@ class EvaporasiChartWidget extends StatelessWidget { return FlSpot(x, y); }).toList(); - final evapSpots = - dedupEvapSpots.map((e) => FlSpot(e.key.toDouble(), e.value)).toList(); + final evapSpots = dedupEvapSpots; if (evapSpots.isEmpty && tempSpots.isEmpty) { return const SizedBox.shrink(); diff --git a/lib/screens/monitoring/shared/utils/excel/wind_speed_excel_service.dart b/lib/screens/monitoring/shared/utils/excel/wind_speed_excel_service.dart new file mode 100644 index 0000000..1ec30f0 --- /dev/null +++ b/lib/screens/monitoring/shared/utils/excel/wind_speed_excel_service.dart @@ -0,0 +1,348 @@ +// =========================================================== +// wind_speed_excel_service.dart +// Lokasi: lib/screens/monitoring/shared/utils/excel/ +// +// Dependensi (tambahkan ke pubspec.yaml): +// excel: ^4.0.6 +// path_provider: ^2.1.4 +// share_plus: ^10.0.2 +// =========================================================== + +import 'dart:io'; +import 'package:excel/excel.dart'; +import 'package:intl/intl.dart'; +import 'package:monitoring_repository/monitoring_repository.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +class WindSpeedExcelService { + // โ”€โ”€ Format helper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + static final _dateFmt = DateFormat('dd/MM/yyyy HH:mm:ss', 'id_ID'); + static final _fileFmt = DateFormat('yyyyMMdd_HHmmss'); + static final _headerFmt = DateFormat('dd MMMM yyyy, HH:mm', 'id_ID'); + + // โ”€โ”€ Warna tema โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + static const _colorHeader = '1A4A8C'; // biru tua + static const _colorSubHead = '2E75B6'; // biru medium + static const _colorNormal = 'E8F4FD'; // biru sangat muda + static const _colorWaspada = 'FFF3CD'; // kuning muda + static const _colorBahaya = 'F8D7DA'; // merah muda + static const _colorWhite = 'FFFFFF'; + + /// Export data kecepatan angin ke file Excel dan buka share sheet + static Future export({ + required double currentSpeed, + required String alertLevel, + required String period, + required List history, + DateTime? filterDate, + }) async { + final excel = Excel.createExcel(); + + // Hapus sheet default 'Sheet1' + excel.delete('Sheet1'); + + // โ”€โ”€ Buat dua sheet โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + _buildSummarySheet( + excel, currentSpeed, alertLevel, period, history, filterDate); + _buildHistorySheet(excel, history, filterDate); + + // โ”€โ”€ Simpan file โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + final bytes = excel.save(); + if (bytes == null) throw Exception('Gagal membuat file Excel'); + + final dir = await getTemporaryDirectory(); + final name = 'wind_speed_${_fileFmt.format(DateTime.now())}.xlsx'; + final file = File('${dir.path}/$name'); + await file.writeAsBytes(bytes, flush: true); + + // โ”€โ”€ Bagikan via share sheet โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + await Share.shareXFiles( + [ + XFile(file.path, + mimeType: + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + ], + subject: 'Data Kecepatan Angin โ€“ ${_headerFmt.format(DateTime.now())}', + ); + } + + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // SHEET 1: Ringkasan + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + static void _buildSummarySheet( + Excel excel, + double currentSpeed, + String alertLevel, + String period, + List history, + DateTime? filterDate, + ) { + final sheet = excel['Ringkasan']; + + // -- Judul -- + _setCell(sheet, 0, 0, 'DATA KECEPATAN ANGIN โ€“ ANEMOMETER', + bold: true, + fontSize: 14, + bgColor: _colorHeader, + fontColor: _colorWhite); + sheet.merge(CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 0), + CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: 0)); + + // -- Metadata -- + _setCell(sheet, 1, 0, 'Diekspor pada', + bold: true, bgColor: _colorSubHead, fontColor: _colorWhite); + _setCell(sheet, 1, 1, _headerFmt.format(DateTime.now()), + bgColor: _colorNormal); + _setCell(sheet, 2, 0, 'Periode grafik', + bold: true, bgColor: _colorSubHead, fontColor: _colorWhite); + _setCell(sheet, 2, 1, period, bgColor: _colorNormal); + + if (filterDate != null) { + _setCell(sheet, 3, 0, 'Filter tanggal', + bold: true, bgColor: _colorSubHead, fontColor: _colorWhite); + _setCell( + sheet, 3, 1, DateFormat('dd MMMM yyyy', 'id_ID').format(filterDate), + bgColor: _colorNormal); + } + + // -- Kecepatan saat ini -- + _setCell(sheet, 5, 0, 'KECEPATAN SAAT INI', + bold: true, bgColor: _colorSubHead, fontColor: _colorWhite); + sheet.merge(CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 5), + CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: 5)); + + _setCell(sheet, 6, 0, 'Kecepatan (m/s)', bold: true); + _setCellDouble(sheet, 6, 1, currentSpeed); + _setCell(sheet, 7, 0, 'Kecepatan (km/h)', bold: true); + _setCellDouble(sheet, 7, 1, currentSpeed * 3.6); + _setCell(sheet, 8, 0, 'Status', bold: true); + final statusColor = _alertBgColor(alertLevel); + _setCell(sheet, 8, 1, alertLevel, bgColor: statusColor); + + // -- Statistik ringkasan -- + if (history.isNotEmpty) { + final speeds = history.map((e) => e.speed).toList(); + final avg = speeds.reduce((a, b) => a + b) / speeds.length; + final max = speeds.reduce((a, b) => a > b ? a : b); + final min = speeds.reduce((a, b) => a < b ? a : b); + + _setCell(sheet, 10, 0, 'STATISTIK HISTORY', + bold: true, bgColor: _colorSubHead, fontColor: _colorWhite); + sheet.merge(CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 10), + CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: 10)); + + _setCell(sheet, 11, 0, 'Jumlah data', bold: true); + _setCellInt(sheet, 11, 1, history.length); + _setCell(sheet, 12, 0, 'Rata-rata (m/s)', bold: true); + _setCellDouble(sheet, 12, 1, avg); + _setCell(sheet, 13, 0, 'Maksimum (m/s)', bold: true); + _setCellDouble(sheet, 13, 1, max); + _setCell(sheet, 14, 0, 'Minimum (m/s)', bold: true); + _setCellDouble(sheet, 14, 1, min); + _setCell(sheet, 15, 0, 'Rata-rata (km/h)', bold: true); + _setCellDouble(sheet, 15, 1, avg * 3.6); + } + + // Set lebar kolom + sheet.setColumnWidth(0, 22); + sheet.setColumnWidth(1, 22); + sheet.setColumnWidth(2, 18); + sheet.setColumnWidth(3, 18); + } + + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // SHEET 2: Data History + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + static void _buildHistorySheet( + Excel excel, + List history, + DateTime? filterDate, + ) { + final sheet = excel['Data History']; + + // -- Header kolom -- + final headers = [ + 'No', + 'Tanggal', + 'Waktu', + 'Kecepatan (m/s)', + 'Kecepatan (km/h)', + 'Status' + ]; + for (var i = 0; i < headers.length; i++) { + _setCell(sheet, 0, i, headers[i], + bold: true, + bgColor: _colorHeader, + fontColor: _colorWhite, + centered: true); + } + + // -- Filter jika ada tanggal dipilih -- + final data = filterDate != null + ? history + .where((e) => + e.timestamp.year == filterDate.year && + e.timestamp.month == filterDate.month && + e.timestamp.day == filterDate.day) + .toList() + : history; + + // -- Isi baris data -- + for (var i = 0; i < data.length; i++) { + final item = data[i]; + final rowIdx = i + 1; + final kmh = item.speed * 3.6; + final status = _getAlertLevel(item.speed); + final bgColor = i.isEven ? _colorNormal : _colorWhite; + final statusBg = _alertBgColor(status); + + _setCellInt(sheet, rowIdx, 0, i + 1, bgColor: bgColor, centered: true); + _setCell(sheet, rowIdx, 1, + DateFormat('dd/MM/yyyy', 'id_ID').format(item.timestamp), + bgColor: bgColor); + _setCell(sheet, rowIdx, 2, DateFormat('HH:mm:ss').format(item.timestamp), + bgColor: bgColor, centered: true); + _setCellDouble(sheet, rowIdx, 3, item.speed, + bgColor: bgColor, centered: true); + _setCellDouble(sheet, rowIdx, 4, kmh, bgColor: bgColor, centered: true); + _setCell(sheet, rowIdx, 5, status, + bgColor: statusBg, centered: true, bold: true); + } + + // -- Footer jika kosong -- + if (data.isEmpty) { + _setCell(sheet, 1, 0, 'Tidak ada data untuk tanggal yang dipilih', + bgColor: _colorWaspada, centered: true); + sheet.merge(CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 1), + CellIndex.indexByColumnRow(columnIndex: 5, rowIndex: 1)); + } + + // Set lebar kolom + sheet.setColumnWidth(0, 6); + sheet.setColumnWidth(1, 14); + sheet.setColumnWidth(2, 12); + sheet.setColumnWidth(3, 18); + sheet.setColumnWidth(4, 18); + sheet.setColumnWidth(5, 12); + } + + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // HELPER CELLS + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + static void _setCell( + Sheet sheet, + int row, + int col, + String value, { + bool bold = false, + double fontSize = 11, + String bgColor = _colorWhite, + String fontColor = '000000', + bool centered = false, + }) { + final cell = + sheet.cell(CellIndex.indexByColumnRow(columnIndex: col, rowIndex: row)); + cell.value = TextCellValue(value); + cell.cellStyle = CellStyle( + bold: bold, + fontSize: fontSize.toInt(), + backgroundColorHex: ExcelColor.fromHexString('#$bgColor'), + fontColorHex: ExcelColor.fromHexString('#$fontColor'), + horizontalAlign: centered ? HorizontalAlign.Center : HorizontalAlign.Left, + verticalAlign: VerticalAlign.Center, + textWrapping: TextWrapping.WrapText, + leftBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + rightBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + topBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + bottomBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + ); + } + + static void _setCellDouble( + Sheet sheet, + int row, + int col, + double value, { + String bgColor = _colorWhite, + bool centered = false, + }) { + final cell = + sheet.cell(CellIndex.indexByColumnRow(columnIndex: col, rowIndex: row)); + cell.value = DoubleCellValue(double.parse(value.toStringAsFixed(4))); + cell.cellStyle = CellStyle( + backgroundColorHex: ExcelColor.fromHexString('#$bgColor'), + horizontalAlign: + centered ? HorizontalAlign.Center : HorizontalAlign.Right, + verticalAlign: VerticalAlign.Center, + leftBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + rightBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + topBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + bottomBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + ); + } + + static void _setCellInt( + Sheet sheet, + int row, + int col, + int value, { + String bgColor = _colorWhite, + bool centered = false, + }) { + final cell = + sheet.cell(CellIndex.indexByColumnRow(columnIndex: col, rowIndex: row)); + cell.value = IntCellValue(value); + cell.cellStyle = CellStyle( + backgroundColorHex: ExcelColor.fromHexString('#$bgColor'), + horizontalAlign: + centered ? HorizontalAlign.Center : HorizontalAlign.Right, + verticalAlign: VerticalAlign.Center, + leftBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + rightBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + topBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + bottomBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + ); + } + + static String _alertBgColor(String level) { + switch (level) { + case 'Bahaya': + return _colorBahaya; + case 'Waspada': + return _colorWaspada; + default: + return _colorNormal; + } + } + + static String _getAlertLevel(double speed) { + if (speed >= 12.5) return 'Bahaya'; + if (speed >= 8.0) return 'Waspada'; + return 'Normal'; + } +} diff --git a/lib/screens/monitoring/wind_speed/views/widgets/wind_speed_history_list.dart b/lib/screens/monitoring/wind_speed/views/widgets/wind_speed_history_list.dart new file mode 100644 index 0000000..911c3ed --- /dev/null +++ b/lib/screens/monitoring/wind_speed/views/widgets/wind_speed_history_list.dart @@ -0,0 +1,448 @@ +// =========================================================== +// wind_speed_history_list.dart +// Lokasi: lib/screens/monitoring/wind_speed/views/widgets/ +// =========================================================== + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:monitoring_repository/monitoring_repository.dart'; + +class WindSpeedHistoryList extends StatelessWidget { + final List history; + final DateTime? selectedDate; + final VoidCallback onPickDate; + final VoidCallback onClearDate; + + const WindSpeedHistoryList({ + super.key, + required this.history, + required this.selectedDate, + required this.onPickDate, + required this.onClearDate, + }); + + // โ”€โ”€ Grouping per tanggal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Map> _groupByDate(List list) { + final map = >{}; + for (final item in list) { + final key = DateFormat('yyyy-MM-dd').format(item.timestamp); + map.putIfAbsent(key, () => []).add(item); + } + return map; + } + + @override + Widget build(BuildContext context) { + final grouped = _groupByDate(history); + final sortedKeys = grouped.keys.toList() + ..sort((a, b) => b.compareTo(a)); // terbaru di atas + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // โ”€โ”€ Header bar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + _HeaderBar( + selectedDate: selectedDate, + totalCount: history.length, + onPickDate: onPickDate, + onClearDate: onClearDate, + ), + const SizedBox(height: 12), + + if (history.isEmpty) + _EmptyState(hasFilter: selectedDate != null) + else + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: sortedKeys.length, + itemBuilder: (context, idx) { + final dateKey = sortedKeys[idx]; + final items = grouped[dateKey]! + ..sort((a, b) => b.timestamp.compareTo(a.timestamp)); + final label = _formatDateLabel(dateKey); + + return _DateGroup( + label: label, + items: items, + ); + }, + ), + ], + ); + } + + String _formatDateLabel(String key) { + final dt = DateTime.parse(key); + final today = DateTime.now(); + if (dt.year == today.year && + dt.month == today.month && + dt.day == today.day) { + return 'Hari Ini โ€” ${DateFormat('dd MMMM yyyy', 'id_ID').format(dt)}'; + } + final yesterday = today.subtract(const Duration(days: 1)); + if (dt.year == yesterday.year && + dt.month == yesterday.month && + dt.day == yesterday.day) { + return 'Kemarin โ€” ${DateFormat('dd MMMM yyyy', 'id_ID').format(dt)}'; + } + return DateFormat('EEEE, dd MMMM yyyy', 'id_ID').format(dt); + } +} + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// Header dengan date picker & counter +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +class _HeaderBar extends StatelessWidget { + final DateTime? selectedDate; + final int totalCount; + final VoidCallback onPickDate; + final VoidCallback onClearDate; + + const _HeaderBar({ + required this.selectedDate, + required this.totalCount, + required this.onPickDate, + required this.onClearDate, + }); + + @override + Widget build(BuildContext context) { + final filtered = selectedDate != null; + + return Row( + children: [ + // Judul + badge jumlah + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Riwayat Data', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.black87), + ), + Text( + filtered + ? '${DateFormat('dd MMM yyyy', 'id_ID').format(selectedDate!)} โ€ข $totalCount data' + : '$totalCount data tersimpan', + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + ], + ), + const Spacer(), + + // Tombol filter tanggal + if (filtered) + _ChipButton( + label: DateFormat('dd MMM yyyy', 'id_ID').format(selectedDate!), + icon: Icons.close_rounded, + color: Colors.blue.shade700, + onTap: onClearDate, + ) + else + _ChipButton( + label: 'Filter Tanggal', + icon: Icons.calendar_month_rounded, + color: Colors.blue.shade700, + onTap: onPickDate, + ), + ], + ); + } +} + +class _ChipButton extends StatelessWidget { + final String label; + final IconData icon; + final Color color; + final VoidCallback onTap; + + const _ChipButton({ + required this.label, + required this.icon, + required this.color, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: color.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 6), + Text(label, + style: TextStyle( + fontSize: 12, color: color, fontWeight: FontWeight.w600)), + ], + ), + ), + ); + } +} + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// Group per tanggal +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +class _DateGroup extends StatefulWidget { + final String label; + final List items; + + const _DateGroup({required this.label, required this.items}); + + @override + State<_DateGroup> createState() => _DateGroupState(); +} + +class _DateGroupState extends State<_DateGroup> { + bool _expanded = true; // default terbuka + + double get _avgSpeed { + if (widget.items.isEmpty) return 0; + return widget.items.map((e) => e.speed).reduce((a, b) => a + b) / + widget.items.length; + } + + double get _maxSpeed { + if (widget.items.isEmpty) return 0; + return widget.items.map((e) => e.speed).reduce((a, b) => a > b ? a : b); + } + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 8, + offset: const Offset(0, 2)), + ], + ), + child: Column( + children: [ + // โ”€โ”€ Group header โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + InkWell( + onTap: () => setState(() => _expanded = !_expanded), + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Container( + width: 4, + height: 36, + decoration: BoxDecoration( + color: Colors.blue.shade600, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.label, + style: const TextStyle( + fontWeight: FontWeight.bold, fontSize: 13)), + const SizedBox(height: 2), + Text( + '${widget.items.length} data โ€ข rata-rata ${_avgSpeed.toStringAsFixed(2)} m/s โ€ข maks ${_maxSpeed.toStringAsFixed(2)} m/s', + style: TextStyle( + fontSize: 11, color: Colors.grey.shade600), + ), + ], + ), + ), + Icon( + _expanded + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + color: Colors.grey.shade500, + ), + ], + ), + ), + ), + + // โ”€โ”€ Item list โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (_expanded) + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: widget.items.length, + separatorBuilder: (_, __) => + Divider(height: 1, color: Colors.grey.shade100), + itemBuilder: (context, i) => + _HistoryItemTile(item: widget.items[i]), + ), + ], + ), + ); + } +} + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// Satu baris data history +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +class _HistoryItemTile extends StatelessWidget { + final MyWindSpeed item; + + const _HistoryItemTile({required this.item}); + + String get _alertLevel { + if (item.speed >= 12.5) return 'Bahaya'; + if (item.speed >= 8.0) return 'Waspada'; + return 'Normal'; + } + + Color get _alertColor { + switch (_alertLevel) { + case 'Bahaya': + return Colors.red.shade600; + case 'Waspada': + return Colors.orange.shade700; + default: + return Colors.green.shade600; + } + } + + IconData get _alertIcon { + switch (_alertLevel) { + case 'Bahaya': + return Icons.warning_rounded; + case 'Waspada': + return Icons.info_outline_rounded; + default: + return Icons.check_circle_outline_rounded; + } + } + + @override + Widget build(BuildContext context) { + final kmh = item.speed * 3.6; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + // Jam + SizedBox( + width: 52, + child: Text( + DateFormat('HH:mm:ss').format(item.timestamp), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + fontFamily: 'monospace'), + ), + ), + + // Speed m/s + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RichText( + text: TextSpan( + style: const TextStyle(color: Colors.black87), + children: [ + TextSpan( + text: item.speed.toStringAsFixed(3), + style: const TextStyle( + fontSize: 15, fontWeight: FontWeight.bold), + ), + const TextSpan( + text: ' m/s', + style: TextStyle(fontSize: 11, color: Colors.black54), + ), + ], + ), + ), + Text( + '${kmh.toStringAsFixed(2)} km/h', + style: TextStyle(fontSize: 11, color: Colors.grey.shade500), + ), + ], + ), + ), + + // Badge status + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: _alertColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: _alertColor.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(_alertIcon, size: 12, color: _alertColor), + const SizedBox(width: 4), + Text( + _alertLevel, + style: TextStyle( + fontSize: 11, + color: _alertColor, + fontWeight: FontWeight.w600), + ), + ], + ), + ), + ], + ), + ); + } +} + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// Empty state +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +class _EmptyState extends StatelessWidget { + final bool hasFilter; + const _EmptyState({required this.hasFilter}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 40), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + Icon( + hasFilter ? Icons.search_off_rounded : Icons.inbox_rounded, + size: 48, + color: Colors.grey.shade300, + ), + const SizedBox(height: 12), + Text( + hasFilter + ? 'Tidak ada data untuk tanggal ini' + : 'Belum ada data history', + style: TextStyle(color: Colors.grey.shade500, fontSize: 14), + ), + ], + ), + ); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index ce0e550..2dccc22 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,9 +7,13 @@ #include "generated_plugin_registrant.h" #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) printing_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin"); printing_plugin_register_with_registrar(printing_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index fd82dd7..50a5a3a 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST printing + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 9b58f8a..e527c3b 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -12,6 +12,7 @@ import firebase_core import firebase_database import google_sign_in_ios import printing +import share_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppSettingsPlugin.register(with: registry.registrar(forPlugin: "AppSettingsPlugin")) @@ -21,4 +22,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseDatabasePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseDatabasePlugin")) FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) } diff --git a/packages/monitoring_repository/lib/src/firebase_monitoring_repo.dart b/packages/monitoring_repository/lib/src/firebase_monitoring_repo.dart index f7cf78c..6155249 100644 --- a/packages/monitoring_repository/lib/src/firebase_monitoring_repo.dart +++ b/packages/monitoring_repository/lib/src/firebase_monitoring_repo.dart @@ -55,12 +55,15 @@ class FirebaseMonitoringRepo implements MonitoringRepository { return mapper(item as Map); }).toList(); - // โœ… Sort by timestamp โ€” Firebase push tidak jamin urutan + // โœ… Sort by timestamp โ€” Firebase push tidak selalu menghasilkan urutan kronologis. list.sort((a, b) { - if (a is MyWindSpeed && b is MyWindSpeed) { - return a.timestamp.compareTo(b.timestamp); + try { + final aTimestamp = (a as dynamic).timestamp as DateTime; + final bTimestamp = (b as dynamic).timestamp as DateTime; + return aTimestamp.compareTo(bTimestamp); + } catch (_) { + return 0; } - return 0; }); return list; diff --git a/packages/monitoring_repository/lib/src/models/evaporasi.dart b/packages/monitoring_repository/lib/src/models/evaporasi.dart index 258c8c2..44489b0 100644 --- a/packages/monitoring_repository/lib/src/models/evaporasi.dart +++ b/packages/monitoring_repository/lib/src/models/evaporasi.dart @@ -94,36 +94,70 @@ class Evaporasi { final tinggiFiltered = (tinggiVal < 0 || tinggiVal > 100) ? 0.0 : tinggiVal; - // Default timestamp: fallback now (kalau field waktu tidak ada). - // Catatan: untuk Firebase seharusnya timestamp dikirim konsisten (ms atau ISO string). - DateTime timestamp = DateTime.fromMillisecondsSinceEpoch(0); + DateTime parseTimestamp(dynamic rawTimestamp) { + try { + // UNIX INT + if (rawTimestamp is int) { + if (rawTimestamp < 1000000000000) { + return DateTime.fromMillisecondsSinceEpoch( + rawTimestamp * 1000, + ).toLocal(); + } - // dukung beberapa kemungkinan penamaan timestamp + return DateTime.fromMillisecondsSinceEpoch(rawTimestamp).toLocal(); + } + + // DOUBLE + if (rawTimestamp is double) { + final value = rawTimestamp.toInt(); + + if (value < 1000000000000) { + return DateTime.fromMillisecondsSinceEpoch(value * 1000).toLocal(); + } + + return DateTime.fromMillisecondsSinceEpoch(value).toLocal(); + } + + // STRING + if (rawTimestamp is String) { + String s = rawTimestamp.trim(); + + // UNIX STRING + final unixValue = int.tryParse(s); + + if (unixValue != null) { + if (unixValue < 1000000000000) { + return DateTime.fromMillisecondsSinceEpoch( + unixValue * 1000, + ).toLocal(); + } + + return DateTime.fromMillisecondsSinceEpoch(unixValue).toLocal(); + } + + // FIX FORMAT FIREBASE + // 2026-05-14 16:07:23 + if (s.contains(' ') && !s.contains('T')) { + s = s.replaceFirst(' ', 'T'); + } + + final parsed = DateTime.tryParse(s); + + if (parsed != null) { + return parsed.toLocal(); + } + } + } catch (_) {} + + return DateTime.fromMillisecondsSinceEpoch(0); + } + + // Default timestamp: fallback nilai kosong agar tidak pakai waktu lokal sekarang. + DateTime timestamp = DateTime.fromMillisecondsSinceEpoch(0); final rawTimestamp = json['timestamp'] ?? json['time'] ?? json['datetime']; if (rawTimestamp != null) { - if (rawTimestamp is int) { - timestamp = DateTime.fromMillisecondsSinceEpoch(rawTimestamp); - } else if (rawTimestamp is double) { - timestamp = DateTime.fromMillisecondsSinceEpoch(rawTimestamp.toInt()); - } else if (rawTimestamp is String) { - final s = rawTimestamp.trim(); - // jika string berupa angka (ms/seconds) - final unixMs = int.tryParse(s); - if (unixMs != null) { - // heuristik: kalau nilainya terlalu kecil kemungkinan seconds - if (unixMs < 1000000000000) { - timestamp = DateTime.fromMillisecondsSinceEpoch(unixMs * 1000); - } else { - timestamp = DateTime.fromMillisecondsSinceEpoch(unixMs); - } - } else { - final parsed = DateTime.tryParse(s); - if (parsed != null) { - timestamp = parsed.toLocal(); - } - } - } + timestamp = parseTimestamp(rawTimestamp); } else { // legacy fallback dari field terpisah final datetimeStr = json['datetime'] as String?; diff --git a/pubspec.lock b/pubspec.lock index ae5f51c..66251e9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: archive - sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d url: "https://pub.dev" source: hosted - version: "4.0.9" + version: "3.6.1" args: dependency: transitive description: @@ -137,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -177,6 +185,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.8" + excel: + dependency: "direct main" + description: + name: excel + sha256: "1a15327dcad260d5db21d1f6e04f04838109b39a2f6a84ea486ceda36e468780" + url: "https://pub.dev" + source: hosted + version: "4.0.6" fake_async: dependency: transitive description: @@ -193,6 +209,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" file: dependency: transitive description: @@ -273,6 +297,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.7+3" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" fl_chart: dependency: "direct main" description: @@ -404,10 +436,10 @@ packages: dependency: transitive description: name: image - sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d url: "https://pub.dev" source: hosted - version: "4.8.0" + version: "4.3.0" intl: dependency: "direct main" description: @@ -560,7 +592,7 @@ packages: source: hosted version: "1.1.0" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" @@ -647,14 +679,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" - posix: - dependency: transitive - description: - name: posix - sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" - url: "https://pub.dev" - source: hosted - version: "6.5.0" printing: dependency: "direct main" description: @@ -695,6 +719,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.27.7" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: a857d8b1479250aff6b57a51b2c02d31ca05848d441817c43f1640c885c286c0 + url: "https://pub.dev" + source: hosted + version: "13.1.0" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "7f7ae28cf400d13f811e297ff37742dba83b79e0a6f5dce14eec0248274e6ce9" + url: "https://pub.dev" + source: hosted + version: "7.1.0" simple_gesture_detector: dependency: transitive description: @@ -780,6 +820,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" user_repository: dependency: "direct main" description: @@ -787,6 +859,14 @@ packages: relative: true source: path version: "0.0.1" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" vector_math: dependency: transitive description: @@ -811,6 +891,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: a1fc9eb9248baa05dfc12ed5b66e377b3e23f095eec078e0371622b9033810d9 + url: "https://pub.dev" + source: hosted + version: "6.2.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 91178c7..e2e2237 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,11 +13,11 @@ dependencies: cupertino_icons: ^1.0.8 dio: ^5.9.2 equatable: ^2.0.5 + excel: ^4.0.6 firebase_auth: ^6.1.3 firebase_core: ^4.3.0 firebase_database: ^12.1.3 fl_chart: ^1.1.1 - table_calendar: ^3.1.2 flutter: sdk: flutter flutter_bloc: ^8.1.0 @@ -26,9 +26,12 @@ dependencies: intl: ^0.20.2 monitoring_repository: path: packages/monitoring_repository + path_provider: ^2.1.5 pdf: ^3.11.1 printing: ^5.13.1 rxdart: ^0.27.7 + share_plus: ^13.1.0 + table_calendar: ^3.1.2 user_repository: path: packages/user_repository diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index bd3cfd7..41b50d5 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -10,6 +10,8 @@ #include #include #include +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { CloudFirestorePluginCApiRegisterWithRegistrar( @@ -20,4 +22,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); PrintingPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PrintingPlugin")); + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 879bac0..fbda3a5 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -7,6 +7,8 @@ list(APPEND FLUTTER_PLUGIN_LIST firebase_auth firebase_core printing + share_plus + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST From 3df7ee1fbe56331dc5b82e6f9b1a4b17abf901e7 Mon Sep 17 00:00:00 2001 From: Mochamad ongki ramadani Date: Thu, 14 May 2026 19:58:43 +0700 Subject: [PATCH 10/13] pepe --- lib/core/utils/time_series_mapper.dart | 58 ++++--- .../evaporasi/blocs/evaporasi_bloc.dart | 14 +- .../evaporasi/blocs/evaporasi_state.dart | 31 ++-- .../evaporasi/views/evaporasi_screen.dart | 149 +++++++++++++----- .../views/widgets/evaporasi_chart_widget.dart | 133 +++++++++++----- .../lib/src/models/evaporasi.dart | 116 +++++++------- 6 files changed, 312 insertions(+), 189 deletions(-) diff --git a/lib/core/utils/time_series_mapper.dart b/lib/core/utils/time_series_mapper.dart index 12b1a59..3d61bf4 100644 --- a/lib/core/utils/time_series_mapper.dart +++ b/lib/core/utils/time_series_mapper.dart @@ -16,15 +16,17 @@ class TimeSeriesMapper { final time = getTime(item); if (_isSameDay(time, now)) { - final hour = time.hour; - sums[hour] += getValue(item); - counts[hour]++; + final hour = time.toLocal().hour; // โœ… FIX: pastikan pakai local hour + if (hour >= 0 && hour < 24) { + sums[hour] += getValue(item); + counts[hour]++; + } } } return List.generate(24, (i) { if (counts[i] == 0) return 0; - return sums[i] / counts[i]; // ๐Ÿ”ฅ average, bukan overwrite + return sums[i] / counts[i]; }); } @@ -46,12 +48,14 @@ class TimeSeriesMapper { DateTime(startOfWeek.year, startOfWeek.month, startOfWeek.day); for (final item in data) { - final time = getTime(item); + final time = getTime(item).toLocal(); // โœ… FIX: konversi ke local - if (time.isAfter(startOfWeek)) { + if (!time.isBefore(startOfWeek)) { final index = time.weekday - 1; - sums[index] += getValue(item); - counts[index]++; + if (index >= 0 && index < 7) { + sums[index] += getValue(item); + counts[index]++; + } } } @@ -76,12 +80,14 @@ class TimeSeriesMapper { final counts = List.filled(daysInMonth, 0); for (final item in data) { - final time = getTime(item); + final time = getTime(item).toLocal(); // โœ… FIX: konversi ke local if (time.month == now.month && time.year == now.year) { final index = time.day - 1; - sums[index] += getValue(item); - counts[index]++; + if (index >= 0 && index < daysInMonth) { + sums[index] += getValue(item); + counts[index]++; + } } } @@ -91,7 +97,7 @@ class TimeSeriesMapper { }); } -/// ========================= + /// ========================= /// ๐Ÿ“… SPECIFIC DATE (24 JAM - TANGGAL KHUSUS) /// ========================= static List toSpecificDate({ @@ -107,9 +113,11 @@ class TimeSeriesMapper { final time = getTime(item); if (_isSameDay(time, targetDate)) { - final hour = time.hour; - sums[hour] += getValue(item); - counts[hour]++; + final hour = time.toLocal().hour; // โœ… FIX: pastikan pakai local hour + if (hour >= 0 && hour < 24) { + sums[hour] += getValue(item); + counts[hour]++; + } } } @@ -120,21 +128,23 @@ class TimeSeriesMapper { } /// ========================= - /// ๐Ÿง  HELPER + /// ๐Ÿง  HELPER โ€” Bandingkan tanggal secara LOCAL (bukan UTC) + /// โœ… FIX: Firebase datetime "2026-05-14 01:25:25" di-parse sebagai local time, + /// jadi perbandingan harus pakai local time juga agar tidak mismatch timezone /// ========================= - /// Sinkronisasi tanggal untuk menghindari mismatch akibat timezone (UTC vs local). - /// Kita bandingkan berdasarkan UTC. static bool _isSameDay(DateTime a, DateTime b) { - final au = a.toUtc(); - final bu = b.toUtc(); - return au.year == bu.year && au.month == bu.month && au.day == bu.day; + final al = a.toLocal(); + final bl = b.toLocal(); + return al.year == bl.year && al.month == bl.month && al.day == bl.day; } - + /// ========================= + /// ๐Ÿ“ˆ SMOOTH (moving average 3 titik) + /// ========================= static List smooth(List data) { if (data.length < 3) return data; - List result = []; + final List result = []; for (int i = 0; i < data.length; i++) { if (i == 0 || i == data.length - 1) { @@ -147,4 +157,4 @@ class TimeSeriesMapper { return result; } -} +} \ No newline at end of file diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart index a3f1e56..97ef996 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart @@ -33,7 +33,6 @@ class EvaporasiBloc extends Bloc { ) async { emit(state.copyWith(isLoading: true)); - // Ambil history yang akan dipakai untuk list + agregasi grafik. final history = await _repository.getSensorHistory( 'Monitoring/History', (json) => Evaporasi.fromJson(json), @@ -45,7 +44,7 @@ class EvaporasiBloc extends Bloc { final dailyGraph = TimeSeriesMapper.toDaily( data: history, getTime: (e) => e.timestamp, - getValue: (e) => e.evaporasi, // โš ๏ธ sesuaikan nama field + getValue: (e) => e.evaporasi, ); final dailyTempGraph = TimeSeriesMapper.toDaily( @@ -112,7 +111,6 @@ class EvaporasiBloc extends Bloc { _EvaporasiRealtimeUpdated event, Emitter emit, ) { - // Hindari update dobel: jika timestamp event sama dengan yang terakhir, jangan ubah bucket. final previous = state.history.isNotEmpty ? state.history.last.timestamp : null; @@ -121,7 +119,6 @@ class EvaporasiBloc extends Bloc { final isDuplicate = previous != null && event.data.timestamp.toUtc() == previous.toUtc(); - // Update bucket berdasarkan timestamp event (bukan jam lokal sekarang). final updated = isDuplicate ? state.dailyValues : List.from(state.dailyValues); final updatedTemp = isDuplicate @@ -135,7 +132,7 @@ class EvaporasiBloc extends Bloc { eventTime.toUtc().month == now.toUtc().month && eventTime.toUtc().day == now.toUtc().day; - if (isSameDayUtc) { + if (isSameDayUtc && !isDuplicate) { final index = eventTime.hour; if (index >= 0 && index < updated.length) { updated[index] = event.data.evaporasi; @@ -210,6 +207,8 @@ class EvaporasiBloc extends Bloc { dailyTemperatures: updatedTemp, chartLabels: _buildChartLabels(period: event.period), viewMode: EvaporasiViewMode.period, + // โœ… FIX: reset selectedDate saat kembali ke mode period + clearSelectedDate: true, isLoading: false, listData: state.listData, history: state.history, @@ -242,11 +241,12 @@ class EvaporasiBloc extends Bloc { targetDate: event.date, ); + // โœ… FIX: update chartLabels untuk custom date (24 jam label) emit(state.copyWith( dailyValues: updated, dailyTemperatures: updatedTemp, + chartLabels: _buildChartLabels(period: 'Tanggal Khusus'), isLoading: false, - // jangan hilangkan list listData: state.listData, history: state.history, )); @@ -326,4 +326,4 @@ class _EvaporasiRealtimeUpdated extends EvaporasiEvent { @override List get props => [data]; -} +} \ No newline at end of file diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart index d84efea..cc60eb7 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart @@ -3,34 +3,26 @@ part of 'evaporasi_bloc.dart'; enum EvaporasiViewMode { period, customDate } class EvaporasiState extends Equatable { - final double currentValue; // nilai evaporasi realtime - final double temperature; // suhu (opsional dari firebase) - final double waterLevel; // tinggi air + final double currentValue; + final double temperature; + final double waterLevel; final String selectedPeriod; - final DateTime? selectedDate; // tanggal spesifik untuk custom date - final EvaporasiViewMode viewMode; // period vs custom date + final DateTime? selectedDate; + final EvaporasiViewMode viewMode; - final List dailyValues; // untuk grafik evaporasi (default) + final List dailyValues; final List weeklyValues; final List monthlyValues; - final List dailyTemperatures; // untuk grafik suhu + final List dailyTemperatures; final List weeklyTemperatures; final List monthlyTemperatures; - /// Label X-axis sesuai agregasi period - /// Contoh: Hari Ini => ["00:00","01:00",...] - /// Minggu Ini => ["Sen","Sel",...] - /// Bulan Ini => ["1","2",...] final List chartLabels; - - /// Data untuk list (timestamp asli dari firebase) final List listData; - final List history; final String weatherStatus; final bool willRain; - final bool isLoading; const EvaporasiState({ @@ -54,12 +46,14 @@ class EvaporasiState extends Equatable { this.isLoading = true, }); + // โœ… FIX: Tambah clearSelectedDate flag agar selectedDate bisa di-null-kan EvaporasiState copyWith({ double? currentValue, double? temperature, double? waterLevel, String? selectedPeriod, DateTime? selectedDate, + bool clearSelectedDate = false, // โœ… tambahan flag reset EvaporasiViewMode? viewMode, List? dailyValues, List? weeklyValues, @@ -79,7 +73,9 @@ class EvaporasiState extends Equatable { temperature: temperature ?? this.temperature, waterLevel: waterLevel ?? this.waterLevel, selectedPeriod: selectedPeriod ?? this.selectedPeriod, - selectedDate: selectedDate ?? this.selectedDate, + // โœ… FIX: jika clearSelectedDate=true, set null; jika selectedDate diberikan, pakai itu; + // jika tidak, pertahankan yang lama + selectedDate: clearSelectedDate ? null : (selectedDate ?? this.selectedDate), viewMode: viewMode ?? this.viewMode, dailyValues: dailyValues ?? this.dailyValues, weeklyValues: weeklyValues ?? this.weeklyValues, @@ -117,5 +113,4 @@ class EvaporasiState extends Equatable { willRain, isLoading, ]; -} - +} \ No newline at end of file diff --git a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart index 3cdfdca..9b2abbe 100644 --- a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart +++ b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart @@ -101,8 +101,10 @@ class _EvaporasiScreenState extends State { }, ), const SizedBox(height: 20), - // List data (mengikuti chart: period vs custom date) - _evaporasiList(state), + // โœ… FIX: Gunakan BlocBuilder agar list reaktif terhadap perubahan state + BlocBuilder( + builder: (context, state) => _evaporasiList(state), + ), const SizedBox(height: 10), ExportPdfButton( onExport: () => PdfExportService.evaporasi( @@ -121,9 +123,9 @@ class _EvaporasiScreenState extends State { ); } - /// ========================= - /// ๐Ÿ”ฅ MAIN CARD (EVAPORASI) - /// ========================= + // ========================= + // ๐Ÿ”ฅ MAIN CARD (EVAPORASI) + // ========================= Widget _mainCard(EvaporasiState state) { return Container( width: double.infinity, @@ -155,9 +157,9 @@ class _EvaporasiScreenState extends State { ); } - /// ========================= - /// ๐Ÿ“Š INFO KECIL (SUHU & AIR) - /// ========================= + // ========================= + // ๐Ÿ“Š INFO KECIL (SUHU & AIR) + // ========================= Widget _infoRow(EvaporasiState state) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -203,9 +205,9 @@ class _EvaporasiScreenState extends State { ); } - /// ========================= - /// ๐Ÿ“ˆ STATUS CARD - /// ========================= + // ========================= + // ๐Ÿ“ˆ STATUS CARD + // ========================= Widget _statusCard(EvaporasiState state) { Color statusColor; IconData statusIcon; @@ -226,7 +228,6 @@ class _EvaporasiScreenState extends State { break; } - // Teks peringatan khusus evaporasi (normal/sedang/tinggi) final String warningText; if (state.weatherStatus == 'Baik') { warningText = 'Normal โ€” evaporasi stabil, risiko dampak rendah.'; @@ -286,21 +287,84 @@ class _EvaporasiScreenState extends State { ); } - /// ========================= - /// ๐Ÿงพ LIST DATA EVAPORASI - /// ========================= + // ========================= + // ๐Ÿงพ LIST DATA EVAPORASI โ€” โœ… FIXED FILTER + // ========================= Widget _evaporasiList(EvaporasiState state) { - final data = state.viewMode == EvaporasiViewMode.customDate && - state.selectedDate != null - ? state.history - .where((e) => - e.timestamp.year == state.selectedDate!.year && - e.timestamp.month == state.selectedDate!.month && - e.timestamp.day == state.selectedDate!.day) - .toList() - : state.history; + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); - if (data.isEmpty) { + // โœ… Filter list sesuai mode & periode yang aktif + List filteredData; + + if (state.viewMode == EvaporasiViewMode.customDate && + state.selectedDate != null) { + // Mode custom date: tampilkan hanya data di tanggal yang dipilih + final sel = DateTime( + state.selectedDate!.year, + state.selectedDate!.month, + state.selectedDate!.day, + ); + filteredData = state.history + .where((e) { + final d = + DateTime(e.timestamp.year, e.timestamp.month, e.timestamp.day); + return d == sel; + }) + .toList() + .reversed + .toList(); + } else if (state.selectedPeriod == 'Hari Ini') { + // Mode Hari Ini: hanya data hari ini + filteredData = state.history + .where((e) { + final d = + DateTime(e.timestamp.year, e.timestamp.month, e.timestamp.day); + return d == today; + }) + .toList() + .reversed + .toList(); + } else if (state.selectedPeriod == 'Minggu Ini') { + // Mode Minggu Ini: 7 hari ke belakang dari hari ini + final weekStart = today.subtract(const Duration(days: 6)); + filteredData = state.history + .where((e) { + final d = + DateTime(e.timestamp.year, e.timestamp.month, e.timestamp.day); + return !d.isBefore(weekStart) && !d.isAfter(today); + }) + .toList() + .reversed + .toList(); + } else if (state.selectedPeriod == 'Bulan Ini') { + // Mode Bulan Ini: hanya data bulan & tahun yang sama + filteredData = state.history + .where((e) => + e.timestamp.year == now.year && e.timestamp.month == now.month) + .toList() + .reversed + .toList(); + } else { + filteredData = state.history.reversed.toList(); + } + + // โœ… Header label sesuai mode + final String listTitle; + if (state.viewMode == EvaporasiViewMode.customDate && + state.selectedDate != null) { + listTitle = 'Data ${_formatDateInfo(state.selectedDate!)}'; + } else if (state.selectedPeriod == 'Hari Ini') { + listTitle = 'Data Hari Ini'; + } else if (state.selectedPeriod == 'Minggu Ini') { + listTitle = 'Data Minggu Ini'; + } else if (state.selectedPeriod == 'Bulan Ini') { + listTitle = 'Data Bulan Ini'; + } else { + listTitle = 'List Data Evaporasi'; + } + + if (filteredData.isEmpty) { return Container( width: double.infinity, padding: const EdgeInsets.all(16), @@ -308,9 +372,20 @@ class _EvaporasiScreenState extends State { color: Colors.white, borderRadius: BorderRadius.circular(20), ), - child: const Text( - 'Belum ada data evaporasi', - style: TextStyle(color: Colors.grey), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + listTitle, + style: + const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + const Text( + 'Belum ada data untuk periode ini', + style: TextStyle(color: Colors.grey), + ), + ], ), ); } @@ -325,17 +400,17 @@ class _EvaporasiScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'List Data Evaporasi', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + Text( + listTitle, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), ), const SizedBox(height: 12), SizedBox( height: 280, child: ListView.separated( - itemCount: data.length, + itemCount: filteredData.length, itemBuilder: (context, index) { - final e = data[index]; + final e = filteredData[index]; final dateLabel = '${DateFormat('dd MMM yyyy', 'id_ID').format(e.timestamp)} โ€ข ${DateFormat('HH:mm:ss', 'id_ID').format(e.timestamp)}'; @@ -407,9 +482,6 @@ class _EvaporasiScreenState extends State { ); } - /// ========================= - /// ๐Ÿ“… FORMAT DATE INFO (for custom date display) - /// ========================= String _statusTextForHistory(double evaporasi) { if (evaporasi <= 5.0) return 'Status: Normal'; if (evaporasi <= 10.0) return 'Status: Sedang'; @@ -423,9 +495,7 @@ class _EvaporasiScreenState extends State { } String _formatDateInfo(DateTime date) { - final now = DateTime.now(); - final today = DateTime(now.year, now.month, now.day); final yesterday = today.subtract(const Duration(days: 1)); final selected = DateTime(date.year, date.month, date.day); @@ -438,5 +508,4 @@ class _EvaporasiScreenState extends State { return DateFormat('EEEE, d MMMM yyyy', 'id_ID').format(date); } } -} - +} \ No newline at end of file diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart index 6f1ad4c..6976d8f 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart @@ -30,8 +30,7 @@ class EvaporasiChartWidget extends StatelessWidget { }) { final tempRange = (tempMax - tempMin); if (tempRange.abs() < 1e-9) return evapMin; - - final normalized = (temp - tempMin) / tempRange; // 0..1 + final normalized = (temp - tempMin) / tempRange; return evapMin + normalized * (evapMax - evapMin); } @@ -44,14 +43,21 @@ class EvaporasiChartWidget extends StatelessWidget { }) { final evapRange = (evapMax - evapMin); if (evapRange.abs() < 1e-9) return tempMin; - final normalized = (yEvap - evapMin) / evapRange; return tempMin + normalized * (tempMax - tempMin); } + /// โœ… FIX: Interval label X-axis disesuaikan per period agar tidak tumpang tindih + double _xLabelInterval() { + if (period == 'Minggu Ini') return 1; // 7 label โ†’ semua tampil + if (period == 'Bulan Ini') return 5; // ~31 label โ†’ tiap 5 hari + // Hari Ini / Tanggal Khusus โ†’ 24 jam, tampilkan tiap 3 jam + return 3; + } + String _getBottomLabel(int index) { if (chartLabels.isEmpty || index < 0 || index >= chartLabels.length) { - return index.toString(); + return ''; } return chartLabels[index]; } @@ -77,13 +83,12 @@ class EvaporasiChartWidget extends StatelessWidget { ); } - // Sesuai permintaan: evaporasi 0..20 dan suhu 0..30 const evapMin = 0.0; const evapMax = 20.0; const tempMin = 0.0; - const tempMax = 30.0; + const tempMax = 40.0; // โœ… FIX: naikkan batas suhu ke 40ยฐC agar lebih realistis - // Evaporasi: deduplicate X by index + // Evaporasi spots final evapSpotsAll = dailyValues.asMap().entries.map((e) { return FlSpot(e.key.toDouble(), _safeValue(e.value)); }).toList(); @@ -92,6 +97,7 @@ class EvaporasiChartWidget extends StatelessWidget { for (final s in evapSpotsAll) { evapByX[s.x.toInt()] = s.y; } + final dedupEvapSpots = evapByX.entries.toList() ..sort((a, b) => a.key.compareTo(b.key)); @@ -99,7 +105,7 @@ class EvaporasiChartWidget extends StatelessWidget { .map((e) => FlSpot(e.key.toDouble(), e.value.clamp(evapMin, evapMax))) .toList(); - // Suhu: skala-kan agar bisa ditampilkan di axis evaporasi + // Suhu spots โ€” diproyeksikan ke skala evaporasi final tempByX = {}; for (final entry in dailyTemperatures.asMap().entries) { tempByX[entry.key] = _safeValue(entry.value); @@ -128,34 +134,58 @@ class EvaporasiChartWidget extends StatelessWidget { ); } + final xInterval = _xLabelInterval(); + final chart = LineChart( LineChartData( minY: evapMin, maxY: evapMax, - gridData: FlGridData(show: false), + // โœ… FIX: padding kiri/kanan agar garis tidak terpotong di tepi + minX: -0.5, + maxX: (chartLabels.isNotEmpty ? chartLabels.length - 1 : 23).toDouble() + 0.5, + clipData: const FlClipData.all(), // โœ… FIX: clip agar garis tidak keluar area chart + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: (evapMax - evapMin) / 4, + getDrawingHorizontalLine: (value) => FlLine( + color: Colors.grey.shade200, + strokeWidth: 1, + ), + ), borderData: FlBorderData(show: false), + extraLinesData: const ExtraLinesData(horizontalLines: []), titlesData: FlTitlesData( bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, - reservedSize: 32, - interval: 1, + reservedSize: 36, // โœ… FIX: tambah ruang bawah agar label tidak nutup chart + interval: xInterval, // โœ… FIX: interval dinamis per periode getTitlesWidget: (value, meta) { final index = value.toInt(); + // โœ… FIX: hanya render label di indeks yang valid & tepat interval + if (value != value.roundToDouble()) return const SizedBox(); + if (index < 0 || index >= chartLabels.length) return const SizedBox(); + if (index % xInterval.toInt() != 0) return const SizedBox(); return Padding( padding: const EdgeInsets.only(top: 8), child: Text( _getBottomLabel(index), - style: const TextStyle(color: Colors.grey, fontSize: 10), + style: const TextStyle(color: Colors.grey, fontSize: 9), ), ); }, ), ), leftTitles: AxisTitles( + axisNameWidget: const Text( + 'mm', + style: TextStyle(color: Colors.blueGrey, fontSize: 10), + ), + axisNameSize: 16, sideTitles: SideTitles( showTitles: true, - reservedSize: 40, + reservedSize: 36, interval: (evapMax - evapMin) / 4, getTitlesWidget: (value, meta) { return Text( @@ -169,9 +199,14 @@ class EvaporasiChartWidget extends StatelessWidget { ), ), rightTitles: AxisTitles( + axisNameWidget: const Text( + 'ยฐC', + style: TextStyle(color: Colors.brown, fontSize: 10), + ), + axisNameSize: 16, sideTitles: SideTitles( showTitles: true, - reservedSize: 40, + reservedSize: 36, interval: (evapMax - evapMin) / 4, getTitlesWidget: (value, meta) { final t = getRightTitle(value); @@ -185,28 +220,42 @@ class EvaporasiChartWidget extends StatelessWidget { }, ), ), - topTitles: AxisTitles( + topTitles: const AxisTitles( sideTitles: SideTitles(showTitles: false), ), ), lineTouchData: LineTouchData( handleBuiltInTouches: true, touchTooltipData: LineTouchTooltipData( - getTooltipItems: (spots) { - return spots.map((spot) { - final temp = _evapScaleToTemp( - yEvap: spot.y, - evapMin: evapMin, - evapMax: evapMax, - tempMin: tempMin, - tempMax: tempMax, - ); + tooltipPadding: const EdgeInsets.all(8), + getTooltipItems: (touchedSpots) { + if (touchedSpots.isEmpty) return const []; - return LineTooltipItem( - 'Evap: ${spot.y.toStringAsFixed(1)} mm\nSuhu: ${temp.toStringAsFixed(1)} ยฐC', - const TextStyle(color: Colors.white, fontSize: 12), - ); - }).toList(); + final List items = []; + for (int i = 0; i < touchedSpots.length; i++) { + final spot = touchedSpots[i]; + final y = _safeValue(spot.y).clamp(evapMin, evapMax); + + 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), + )); + } + } + return items; }, ), ), @@ -214,18 +263,23 @@ class EvaporasiChartWidget extends StatelessWidget { if (evapSpots.isNotEmpty) LineChartBarData( spots: evapSpots, - isCurved: false, + isCurved: true, + curveSmoothness: 0.3, color: Colors.blue.shade700, - barWidth: 2, + barWidth: 2.5, dotData: FlDotData(show: false), - belowBarData: BarAreaData(show: false), + belowBarData: BarAreaData( + show: true, + color: Colors.blue.shade100.withOpacity(0.3), + ), ), if (tempSpots.isNotEmpty) LineChartBarData( spots: tempSpots, - isCurved: false, + isCurved: true, + curveSmoothness: 0.3, color: Colors.orange.shade700, - barWidth: 2, + barWidth: 2.5, dotData: FlDotData(show: false), belowBarData: BarAreaData(show: false), ), @@ -234,8 +288,8 @@ class EvaporasiChartWidget extends StatelessWidget { ); return Container( - height: 380, - padding: const EdgeInsets.all(12), + height: 400, // โœ… FIX: tambah tinggi container agar label bawah tidak terpotong + padding: const EdgeInsets.fromLTRB(8, 12, 8, 4), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(25), @@ -249,6 +303,7 @@ class EvaporasiChartWidget extends StatelessWidget { ), child: Column( children: [ + // Legend Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ @@ -297,9 +352,10 @@ class EvaporasiChartWidget extends StatelessWidget { ], ), const SizedBox(height: 8), + // โœ… FIX: Expanded + ClipRRect memastikan chart mengisi sisa ruang tanpa overflow Expanded( child: ClipRRect( - borderRadius: BorderRadius.circular(25), + borderRadius: BorderRadius.circular(16), child: chart, ), ), @@ -307,5 +363,4 @@ class EvaporasiChartWidget extends StatelessWidget { ), ); } -} - +} \ No newline at end of file diff --git a/packages/monitoring_repository/lib/src/models/evaporasi.dart b/packages/monitoring_repository/lib/src/models/evaporasi.dart index 2f8b91a..5ea122e 100644 --- a/packages/monitoring_repository/lib/src/models/evaporasi.dart +++ b/packages/monitoring_repository/lib/src/models/evaporasi.dart @@ -19,117 +19,111 @@ class Evaporasi { ); factory Evaporasi.fromJson(Map json) { + // =========================== + // ๐Ÿ”ง HELPER: parse angka aman + // =========================== double toDoubleSafe(dynamic v) { if (v is num) return v.toDouble(); if (v is String) { final s = v.trim(); - // dukung format seperti "12.3 cm" / "12,3" / "-" final normalized = s.replaceAll(',', '.'); final match = RegExp(r'[-+]?\d*\.?\d+').firstMatch(normalized); if (match != null) { - return double.tryParse(match.group(0)!) ?? 0; + return double.tryParse(match.group(0)!) ?? 0.0; } - return double.tryParse(normalized) ?? 0; + return double.tryParse(normalized) ?? 0.0; } - return 0; + return 0.0; } + // =========================== + // ๐Ÿ’ง EVAPORASI + // โœ… Firebase: evaporasi_mm โ†’ prioritas pertama + // =========================== final evaporasiVal = toDoubleSafe( json['evaporasi_mm'] ?? json['evaporasi'] ?? json['evaporasiMm'] ?? json['evaporation_mm'] ?? json['evap_mm'] ?? - json['evaporasi_mm_'] ?? - json['evaporasi_mm '] ?? json['evaporasi_value'] ?? - json['evaporasi_mm_k'] ?? json['evaporasi_k'], ); - /// ========================= - /// SUHU - /// ========================= - final suhuParsed = toDoubleSafe( - json['suhu'] ?? - json['suhu_air'] ?? + // =========================== + // ๐ŸŒก๏ธ SUHU + // โœ… Firebase: suhu_air โ†’ prioritas pertama + // =========================== + final suhuRaw = toDoubleSafe( + json['suhu_air'] ?? // โœ… field utama di Firebase + json['suhu'] ?? json['suhuAir'] ?? json['temp'] ?? json['temperature'], ); + // Filter nilai tidak masuk akal (sensor error) + final suhuVal = (suhuRaw < -50 || suhuRaw > 100) ? 0.0 : suhuRaw; - // Filter nilai rusak - final suhuVal = (suhuParsed < -50 || suhuParsed > 100) ? 0.0 : suhuParsed; - toDoubleSafe( - json['suhu'] ?? - json['suhu_air'] ?? - json['suhuAir'] ?? - json['temp'] ?? - json['temperature'], - ); - - // Banyak kemungkinan penamaan field tinggi air. - // Pakai beberapa alias agar tidak default 0. + // =========================== + // ๐Ÿ’ฆ TINGGI AIR + // โœ… Firebase: tinggi_air_cm โ†’ prioritas pertama + // =========================== final tinggiVal = toDoubleSafe( - json['tinggi_air_cm'] ?? + json['tinggi_air_cm'] ?? // โœ… field utama di Firebase json['tinggi_air'] ?? json['tinggiAir'] ?? json['tinggiAir_cm'] ?? - json['tinggi_air_cm_'] ?? - json['tinggi_air_cm '] ?? json['water_level'] ?? json['waterLevel'] ?? json['tinggi_air_m'] ?? json['tinggiAir_m'], ); - // Default timestamp: fallback now (kalau field waktu tidak ada). - // Catatan: untuk Firebase seharusnya timestamp dikirim konsisten (ms atau ISO string). + // =========================== + // ๐Ÿ• TIMESTAMP + // โœ… Firebase: datetime "2026-05-14 01:25:25" โ†’ prioritas pertama + // =========================== DateTime timestamp = DateTime.now(); - // dukung beberapa kemungkinan penamaan timestamp - final rawTimestamp = json['timestamp'] ?? json['time'] ?? json['datetime']; + // Urutan prioritas sesuai field yang ada di Firebase + final rawTimestamp = + json['datetime'] ?? // โœ… field utama di Firebase + json['timestamp'] ?? + json['time']; if (rawTimestamp != null) { - if (rawTimestamp is int) { + if (rawTimestamp is String) { + final s = rawTimestamp.trim(); + // Coba parse sebagai integer unix ms/s + final unixMs = int.tryParse(s); + if (unixMs != null) { + timestamp = unixMs < 1000000000000 + ? DateTime.fromMillisecondsSinceEpoch(unixMs * 1000) + : DateTime.fromMillisecondsSinceEpoch(unixMs); + } else { + // โœ… Format Firebase: "2026-05-14 01:25:25" + // DateTime.tryParse butuh ISO format โ†’ ganti spasi dengan T + final iso = s.contains('T') ? s : s.replaceFirst(' ', 'T'); + timestamp = DateTime.tryParse(iso) ?? DateTime.now(); + } + } else if (rawTimestamp is int) { timestamp = DateTime.fromMillisecondsSinceEpoch(rawTimestamp); } else if (rawTimestamp is double) { timestamp = DateTime.fromMillisecondsSinceEpoch(rawTimestamp.toInt()); - } else if (rawTimestamp is String) { - final s = rawTimestamp.trim(); - // jika string berupa angka (ms/seconds) - final unixMs = int.tryParse(s); - if (unixMs != null) { - // heuristik: kalau nilainya terlalu kecil kemungkinan seconds - if (unixMs < 1000000000000) { - timestamp = DateTime.fromMillisecondsSinceEpoch(unixMs * 1000); - } else { - timestamp = DateTime.fromMillisecondsSinceEpoch(unixMs); - } - } else { - final parsed = DateTime.tryParse(s); - if (parsed != null) { - timestamp = parsed; - } - } } } else { - // legacy fallback dari field terpisah - final datetimeStr = json['datetime'] as String?; - if (datetimeStr != null) { - final parsed = DateTime.tryParse(datetimeStr); - if (parsed != null) timestamp = parsed; - } - + // Legacy fallback: field 'waktu' format "HH:mm:ss" final waktuStr = json['waktu'] as String?; - if (waktuStr != null && datetimeStr == null) { + if (waktuStr != null) { final parts = waktuStr.split(':'); if (parts.length >= 2) { final jam = int.tryParse(parts[0]) ?? 0; final menit = int.tryParse(parts[1]) ?? 0; - final detik = parts.length >= 3 ? (int.tryParse(parts[2]) ?? 0) : 0; + final detik = + parts.length >= 3 ? (int.tryParse(parts[2]) ?? 0) : 0; final now = DateTime.now(); - timestamp = DateTime(now.year, now.month, now.day, jam, menit, detik); + timestamp = + DateTime(now.year, now.month, now.day, jam, menit, detik); } } } @@ -141,4 +135,4 @@ class Evaporasi { timestamp: timestamp, ); } -} +} \ No newline at end of file From 1a3423809c467f9fa2e0fb20dfd00ae5b2a2048d Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Fri, 15 May 2026 09:30:09 +0700 Subject: [PATCH 11/13] Untuk Younglex --- .../reports/problems/problems-report.html | 663 ++++++++++++++++++ lib/app_view.dart | 11 +- .../utils/excel/excel_saver_mobile.dart | 19 + .../shared/utils/excel/excel_saver_stub.dart | 6 + .../shared/utils/excel/excel_saver_web.dart | 16 + .../utils/excel/wind_speed_excel_service.dart | 4 +- .../wind_speed/blocs/wind_speed_bloc.dart | 125 ++-- .../wind_speed/blocs/wind_speed_event.dart | 26 +- .../wind_speed/blocs/wind_speed_state.dart | 46 +- .../wind_speed/views/wind_speed_screen.dart | 614 +++++++++++++--- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 4 + pubspec.lock | 77 ++ pubspec.yaml | 1 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 17 files changed, 1463 insertions(+), 158 deletions(-) create mode 100644 android/build/reports/problems/problems-report.html create mode 100644 lib/screens/monitoring/shared/utils/excel/excel_saver_mobile.dart create mode 100644 lib/screens/monitoring/shared/utils/excel/excel_saver_stub.dart create mode 100644 lib/screens/monitoring/shared/utils/excel/excel_saver_web.dart diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html new file mode 100644 index 0000000..6bb5e2c --- /dev/null +++ b/android/build/reports/problems/problems-report.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/lib/app_view.dart b/lib/app_view.dart index 0320ddb..f8b4763 100644 --- a/lib/app_view.dart +++ b/lib/app_view.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:klimatologiot/blocs/authentication_bloc/authentication_bloc.dart'; - +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:klimatologiot/screens/auth/views/welcome_screen.dart'; import 'package:klimatologiot/screens/home/views/home_screen.dart'; @@ -13,6 +13,15 @@ class MyAppView extends StatelessWidget { return MaterialApp( title: 'Klimatologi', debugShowCheckedModeBanner: false, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [ + Locale('id', 'ID'), + Locale('en'), + ], theme: ThemeData( colorScheme: ColorScheme.light( surface: Colors.grey.shade100, diff --git a/lib/screens/monitoring/shared/utils/excel/excel_saver_mobile.dart b/lib/screens/monitoring/shared/utils/excel/excel_saver_mobile.dart new file mode 100644 index 0000000..e0e9b16 --- /dev/null +++ b/lib/screens/monitoring/shared/utils/excel/excel_saver_mobile.dart @@ -0,0 +1,19 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +/// Mobile (Android/iOS): simpan ke temp dir lalu buka share sheet +Future saveAndShareExcel(Uint8List bytes, String fileName) async { + final dir = await getTemporaryDirectory(); + final file = File('${dir.path}/$fileName'); + await file.writeAsBytes(bytes, flush: true); + + await Share.shareXFiles( + [XFile( + file.path, + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + )], + subject: fileName, + ); +} diff --git a/lib/screens/monitoring/shared/utils/excel/excel_saver_stub.dart b/lib/screens/monitoring/shared/utils/excel/excel_saver_stub.dart new file mode 100644 index 0000000..8c86ee5 --- /dev/null +++ b/lib/screens/monitoring/shared/utils/excel/excel_saver_stub.dart @@ -0,0 +1,6 @@ +import 'dart:typed_data'; + +// Stub โ€” tidak pernah dipanggil langsung, digantikan web atau mobile +Future saveAndShareExcel(Uint8List bytes, String fileName) async { + throw UnsupportedError('Platform tidak didukung'); +} diff --git a/lib/screens/monitoring/shared/utils/excel/excel_saver_web.dart b/lib/screens/monitoring/shared/utils/excel/excel_saver_web.dart new file mode 100644 index 0000000..eb0bc20 --- /dev/null +++ b/lib/screens/monitoring/shared/utils/excel/excel_saver_web.dart @@ -0,0 +1,16 @@ +// ignore: avoid_web_libraries_in_flutter +import 'dart:html' as html; +import 'dart:typed_data'; + +/// Web: langsung trigger download lewat browser +Future saveAndShareExcel(Uint8List bytes, String fileName) async { + final blob = html.Blob( + [bytes], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); + final url = html.Url.createObjectUrlFromBlob(blob); + final anchor = html.AnchorElement(href: url) + ..setAttribute('download', fileName) + ..click(); + html.Url.revokeObjectUrl(url); +} diff --git a/lib/screens/monitoring/shared/utils/excel/wind_speed_excel_service.dart b/lib/screens/monitoring/shared/utils/excel/wind_speed_excel_service.dart index 1ec30f0..445f846 100644 --- a/lib/screens/monitoring/shared/utils/excel/wind_speed_excel_service.dart +++ b/lib/screens/monitoring/shared/utils/excel/wind_speed_excel_service.dart @@ -8,10 +8,12 @@ // share_plus: ^10.0.2 // =========================================================== -import 'dart:io'; +import 'dart:typed_data'; import 'package:excel/excel.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:intl/intl.dart'; import 'package:monitoring_repository/monitoring_repository.dart'; +import 'dart:io'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; diff --git a/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart b/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart index 9a995a7..e618a2c 100644 --- a/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart +++ b/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart @@ -19,38 +19,32 @@ class WindSpeedBloc extends Bloc { final NotificationBloc _notificationBloc; StreamSubscription? _subscription; - WindSpeedBloc( - {required MonitoringRepository repository, - required NotificationBloc notificationBloc}) - : _repository = repository, + WindSpeedBloc({ + required MonitoringRepository repository, + required NotificationBloc notificationBloc, + }) : _repository = repository, _notificationBloc = notificationBloc, super(const WindSpeedState()) { - /// ๐Ÿ”ฅ START MONITORING on(_onStarted, transformer: restartable()); - - /// ๐Ÿ”ฅ REALTIME UPDATE on<_WindSpeedRealtimeUpdated>(_onRealtimeUpdated); - - /// ๐Ÿ”ฅ CHANGE PERIOD on(_onPeriodChanged); + on(_onDateFilterChanged); // โ† baru } - /// ========================= - /// ๐Ÿš€ START - /// ========================= + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // START + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• Future _onStarted( WatchWindSpeedStarted event, Emitter emit, ) async { emit(state.copyWith(isLoading: true)); - /// 1. Ambil history SEKALI final history = await _repository.getSensorHistory( 'anemometer/history', (json) => MyWindSpeed.fromJson(json), ); - // โœ… Ini saja yang benar final dailyGraph = TimeSeriesMapper.smooth( TimeSeriesMapper.toDaily( data: history, @@ -58,7 +52,6 @@ class WindSpeedBloc extends Bloc { getValue: (e) => e.speed, ), ); - final weekly = TimeSeriesMapper.smooth( TimeSeriesMapper.toWeekly( data: history, @@ -66,7 +59,6 @@ class WindSpeedBloc extends Bloc { getValue: (e) => e.speed, ), ); - final monthly = TimeSeriesMapper.smooth( TimeSeriesMapper.toMonthly( data: history, @@ -75,25 +67,22 @@ class WindSpeedBloc extends Bloc { ), ); - // Cek kondisi awal dari history if (history.isNotEmpty) { _emitWindAlert(history.last.speed); } emit(state.copyWith( history: history, + filteredHistory: history, // awal = semua data dailySpeeds: dailyGraph, weeklySpeeds: weekly, monthlySpeeds: monthly, isLoading: false, - alertLevel: history.isNotEmpty // โ† tambah ini - ? _getAlertLevel(history.last.speed) - : "Normal", + alertLevel: + history.isNotEmpty ? _getAlertLevel(history.last.speed) : 'Normal', )); - /// 3. Start realtime stream (manual subscription) await _subscription?.cancel(); - _subscription = _repository .getSensorStream( 'anemometer/realtime', @@ -104,9 +93,9 @@ class WindSpeedBloc extends Bloc { }); } - /// ========================= - /// โšก REALTIME UPDATE - /// ========================= + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // REALTIME UPDATE + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• void _onRealtimeUpdated( _WindSpeedRealtimeUpdated event, Emitter emit, @@ -117,13 +106,11 @@ class WindSpeedBloc extends Bloc { if (index < updated.length) { final lastValue = updated[index]; - - /// ๐Ÿ”ฅ ANTI SPIKE + SMOOTHING if (lastValue != 0) { if ((newValue - lastValue).abs() > 15) { - newValue = lastValue; // buang spike + newValue = lastValue; } else { - newValue = (lastValue + newValue) / 2; // smoothing + newValue = (lastValue + newValue) / 2; } } updated[index] = newValue.clamp(0, 100); @@ -138,26 +125,24 @@ class WindSpeedBloc extends Bloc { )); } - /// ========================= - /// ๐Ÿ“Š CHANGE PERIOD - /// ========================= + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // PERIOD CHANGED + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• Future _onPeriodChanged( WindSpeedPeriodChanged event, Emitter emit, ) async { emit(state.copyWith(selectedPeriod: event.period)); - final history = state.history; List raw; - - if (event.period == "Minggu Ini") { + if (event.period == 'Minggu Ini') { raw = TimeSeriesMapper.toWeekly( data: history, getTime: (e) => e.timestamp, getValue: (e) => e.speed, ); - } else if (event.period == "Bulan Ini") { + } else if (event.period == 'Bulan Ini') { raw = TimeSeriesMapper.toMonthly( data: history, getTime: (e) => e.timestamp, @@ -171,35 +156,60 @@ class WindSpeedBloc extends Bloc { ); } - /// ๐Ÿ”ฅ baru di-smooth final updatedGraph = TimeSeriesMapper.smooth(raw); emit(state.copyWith( dailySpeeds: - event.period == "Hari Ini" ? updatedGraph : state.dailySpeeds, + event.period == 'Hari Ini' ? updatedGraph : state.dailySpeeds, weeklySpeeds: - event.period == "Minggu Ini" ? updatedGraph : state.weeklySpeeds, + event.period == 'Minggu Ini' ? updatedGraph : state.weeklySpeeds, monthlySpeeds: - event.period == "Bulan Ini" ? updatedGraph : state.monthlySpeeds, + event.period == 'Bulan Ini' ? updatedGraph : state.monthlySpeeds, isLoading: false, )); } - @override - Future close() async { - await _subscription?.cancel(); - return super.close(); + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // DATE FILTER CHANGED โ† baru + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + void _onDateFilterChanged( + WindSpeedDateFilterChanged event, + Emitter emit, + ) { + final date = event.date; + final allHistory = state.history; + + if (date == null) { + // Reset: tampilkan semua + emit(state.copyWith( + filteredHistory: allHistory, + clearSelectedDate: true, + )); + return; + } + + // Filter data yang tanggalnya sama dengan [date] + final filtered = allHistory.where((item) { + return item.timestamp.year == date.year && + item.timestamp.month == date.month && + item.timestamp.day == date.day; + }).toList(); + + emit(state.copyWith( + filteredHistory: filtered, + selectedDate: date, + )); } + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // HELPERS + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• String _getAlertLevel(double speed) { - if (speed >= _kWindDanger) return "Bahaya"; - if (speed >= _kWindWarning) return "Waspada"; - return "Normal"; + if (speed >= _kWindDanger) return 'Bahaya'; + if (speed >= _kWindWarning) return 'Waspada'; + return 'Normal'; } -// ========================= - // HELPER: kirim alert ke NotificationBloc - // ========================= void _emitWindAlert(double speed) { final AlertSeverity severity; final String message; @@ -211,7 +221,7 @@ class WindSpeedBloc extends Bloc { severity = AlertSeverity.warning; message = 'Kecepatan angin ${speed.toStringAsFixed(1)} m/s โ€” waspada'; } else { - severity = AlertSeverity.info; // normal โ†’ bersihkan alert + severity = AlertSeverity.info; message = ''; } @@ -225,16 +235,19 @@ class WindSpeedBloc extends Bloc { ), )); } + + @override + Future close() async { + await _subscription?.cancel(); + return super.close(); + } } -/// ========================= -/// ๐Ÿ”’ INTERNAL EVENT (PRIVATE) -/// ========================= +/// Internal event โ€” tidak diekspos ke luar class _WindSpeedRealtimeUpdated extends WindSpeedEvent { final MyWindSpeed data; - const _WindSpeedRealtimeUpdated(this.data); @override - List get props => [data]; + List get props => [data]; } diff --git a/lib/screens/monitoring/wind_speed/blocs/wind_speed_event.dart b/lib/screens/monitoring/wind_speed/blocs/wind_speed_event.dart index e966119..38430af 100644 --- a/lib/screens/monitoring/wind_speed/blocs/wind_speed_event.dart +++ b/lib/screens/monitoring/wind_speed/blocs/wind_speed_event.dart @@ -1,16 +1,32 @@ part of 'wind_speed_bloc.dart'; -sealed class WindSpeedEvent extends Equatable { +abstract class WindSpeedEvent extends Equatable { const WindSpeedEvent(); + @override - List get props => []; + List get props => []; } -// Perintah untuk mulai dengerin data Firebase -class WatchWindSpeedStarted extends WindSpeedEvent {} +/// Mulai watch stream realtime + ambil history +class WatchWindSpeedStarted extends WindSpeedEvent { + const WatchWindSpeedStarted(); +} -// Perintah kalau user ganti filter (Hari Ini, Minggu Ini, dll) +/// Ganti periode grafik: "Hari Ini" | "Minggu Ini" | "Bulan Ini" class WindSpeedPeriodChanged extends WindSpeedEvent { final String period; const WindSpeedPeriodChanged(this.period); + + @override + List get props => [period]; +} + +/// Filter history berdasarkan tanggal. +/// Kirim [date] = null untuk reset (tampilkan semua) +class WindSpeedDateFilterChanged extends WindSpeedEvent { + final DateTime? date; + const WindSpeedDateFilterChanged(this.date); + + @override + List get props => [date]; } diff --git a/lib/screens/monitoring/wind_speed/blocs/wind_speed_state.dart b/lib/screens/monitoring/wind_speed/blocs/wind_speed_state.dart index 7ff5572..218aa97 100644 --- a/lib/screens/monitoring/wind_speed/blocs/wind_speed_state.dart +++ b/lib/screens/monitoring/wind_speed/blocs/wind_speed_state.dart @@ -1,57 +1,73 @@ part of 'wind_speed_bloc.dart'; class WindSpeedState extends Equatable { + final bool isLoading; final double currentSpeed; + final String alertLevel; final String selectedPeriod; + + // โ”€โ”€ Graf โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ final List dailySpeeds; final List weeklySpeeds; final List monthlySpeeds; - final bool isLoading; - final List history; - final String alertLevel; // "Normal" | "Waspada" | "Bahaya" + + // โ”€โ”€ History โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + final List history; // semua data mentah + final List filteredHistory; // setelah difilter tanggal + final DateTime? selectedDate; // null = tampilkan semua const WindSpeedState({ + this.isLoading = false, this.currentSpeed = 0.0, - this.selectedPeriod = "Hari Ini", + this.alertLevel = 'Normal', + this.selectedPeriod = 'Hari Ini', this.dailySpeeds = const [], - this.isLoading = true, - this.history = const [], - this.monthlySpeeds = const [], this.weeklySpeeds = const [], - this.alertLevel = "Normal", + this.monthlySpeeds = const [], + this.history = const [], + this.filteredHistory = const [], + this.selectedDate, }); WindSpeedState copyWith({ + bool? isLoading, double? currentSpeed, + String? alertLevel, String? selectedPeriod, List? dailySpeeds, List? weeklySpeeds, List? monthlySpeeds, - bool? isLoading, List? history, - String? alertLevel, + List? filteredHistory, + DateTime? selectedDate, + bool clearSelectedDate = false, }) { return WindSpeedState( + isLoading: isLoading ?? this.isLoading, currentSpeed: currentSpeed ?? this.currentSpeed, + alertLevel: alertLevel ?? this.alertLevel, selectedPeriod: selectedPeriod ?? this.selectedPeriod, dailySpeeds: dailySpeeds ?? this.dailySpeeds, weeklySpeeds: weeklySpeeds ?? this.weeklySpeeds, monthlySpeeds: monthlySpeeds ?? this.monthlySpeeds, - isLoading: isLoading ?? this.isLoading, history: history ?? this.history, - alertLevel: alertLevel ?? this.alertLevel, + filteredHistory: filteredHistory ?? this.filteredHistory, + selectedDate: + clearSelectedDate ? null : (selectedDate ?? this.selectedDate), ); } @override - List get props => [ + List get props => [ + isLoading, currentSpeed, + alertLevel, selectedPeriod, dailySpeeds, weeklySpeeds, monthlySpeeds, - isLoading, history, - alertLevel, + filteredHistory, + selectedDate, ]; } diff --git a/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart b/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart index f741f4c..bdd1518 100644 --- a/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart +++ b/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart @@ -1,23 +1,121 @@ +// =========================================================== +// wind_speed_screen.dart +// Lokasi: lib/screens/monitoring/wind_speed/views/ +// =========================================================== + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; +import 'package:monitoring_repository/monitoring_repository.dart'; import '../blocs/wind_speed_bloc.dart'; -import './widgets/wind_speed_chart_widget.dart'; +import 'widgets/wind_speed_chart_widget.dart'; import 'widgets/period_selector.dart'; - -import '../../shared/utils/pdf/pdf_export_service.dart'; -import '../../shared/widgets/export_pdf_button.dart'; import '../../device_setup/views/device_setup_screen.dart'; +import '../../shared/utils/excel/wind_speed_excel_service.dart'; -class WindSpeedScreen extends StatelessWidget { +class WindSpeedScreen extends StatefulWidget { const WindSpeedScreen({super.key}); + @override + State createState() => _WindSpeedScreenState(); +} + +class _WindSpeedScreenState extends State { + // โ”€โ”€ Date picker โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Future _pickDate(BuildContext context, WindSpeedState state) async { + DateTime firstDate = DateTime.now().subtract(const Duration(days: 365)); + DateTime lastDate = DateTime.now(); + + if (state.history.isNotEmpty) { + final sorted = [...state.history] + ..sort((a, b) => a.timestamp.compareTo(b.timestamp)); + firstDate = DateTime(sorted.first.timestamp.year, + sorted.first.timestamp.month, sorted.first.timestamp.day); + lastDate = DateTime(sorted.last.timestamp.year, + sorted.last.timestamp.month, sorted.last.timestamp.day); + } + + final picked = await showDatePicker( + context: context, + initialDate: state.selectedDate ?? lastDate, + firstDate: firstDate, + lastDate: lastDate, + locale: const Locale('id', 'ID'), + builder: (context, child) => Theme( + data: Theme.of(context).copyWith( + colorScheme: ColorScheme.light( + primary: Colors.blue.shade700, + onPrimary: Colors.white, + surface: Colors.white, + ), + ), + child: child!, + ), + ); + + if (picked != null && context.mounted) { + context.read().add(WindSpeedDateFilterChanged(picked)); + } + } + + // โ”€โ”€ Export Excel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Future _exportExcel(BuildContext context, WindSpeedState state) async { + final messenger = ScaffoldMessenger.of(context); + try { + messenger.showSnackBar( + const SnackBar( + content: Row(children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)), + SizedBox(width: 12), + Text('Membuat file Excel...'), + ]), + duration: Duration(seconds: 30), + behavior: SnackBarBehavior.floating, + ), + ); + + await WindSpeedExcelService.export( + currentSpeed: state.currentSpeed, + alertLevel: state.alertLevel, + period: state.selectedPeriod, + history: state.history, + filterDate: state.selectedDate, + ); + + messenger.hideCurrentSnackBar(); + messenger.showSnackBar(SnackBar( + content: const Row(children: [ + Icon(Icons.check_circle, color: Colors.white), + SizedBox(width: 8), + Text('File Excel berhasil dibuat!'), + ]), + backgroundColor: Colors.green.shade600, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + duration: const Duration(seconds: 3), + )); + } catch (e) { + messenger.hideCurrentSnackBar(); + messenger.showSnackBar(SnackBar( + content: Text('Gagal: $e'), + backgroundColor: Colors.red.shade600, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + )); + } + } + @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.grey.shade100, appBar: AppBar( title: const Text( - "Kecepatan Angin", + 'Kecepatan Angin', style: TextStyle(fontWeight: FontWeight.bold), ), centerTitle: true, @@ -25,79 +123,70 @@ class WindSpeedScreen extends StatelessWidget { backgroundColor: Colors.transparent, foregroundColor: Colors.black, actions: [ + BlocBuilder( + builder: (context, state) => IconButton( + tooltip: 'Export Excel', + icon: const Icon(Icons.file_download_outlined), + onPressed: state.history.isEmpty + ? null + : () => _exportExcel(context, state), + ), + ), IconButton( tooltip: 'Pengaturan Perangkat', icon: const Icon(Icons.settings_rounded), - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const DeviceSetupScreen(), - ), - ); - }, + onPressed: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const DeviceSetupScreen()), + ), ), ], ), - - /// === Body area, lokasi dan tata letak Widgets === /// body: BlocBuilder( builder: (context, state) { if (state.isLoading) { return const Center(child: CircularProgressIndicator()); } - // Pilih data sesuai periode final List data = switch (state.selectedPeriod) { - "Minggu Ini" => state.weeklySpeeds, - "Bulan Ini" => state.monthlySpeeds, + 'Minggu Ini' => state.weeklySpeeds, + 'Bulan Ini' => state.monthlySpeeds, _ => state.dailySpeeds, }; - // Konversi history MyWindSpeed โ†’ Map (untuk tabel PDF) - final historyMaps = state.history - .map((e) => { - 'timestamp': e.timestamp, - 'speed': e.speed, - }) - .toList(); - return SingleChildScrollView( padding: const EdgeInsets.all(20.0), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - /// ==== 1. Tampilan Angka Utama kecepatan angin (Hero Widget look) ==== /// - _buildMainSpeedDisplay(state.currentSpeed), - const SizedBox(height: 30), - - const Text("Tren Kecepatan", - style: - TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), - const SizedBox(height: 15), - const PeriodSelector(), - const SizedBox(height: 15), - - WindSpeedChartWidget( - dailySpeeds: data, - period: state.selectedPeriod, - ), - - const SizedBox(height: 30), - - // 3. Info Tambahan (Status/Periode) - _buildDetailRow(state.selectedPeriod, state.alertLevel), + // โ”€โ”€ 1. Hero kecepatan โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + _buildMainSpeedDisplay(state.currentSpeed, state.alertLevel), const SizedBox(height: 24), - // โ† Tombol Export PDF - ExportPdfButton( - onExport: () => PdfExportService.windSpeed( - currentSpeed: state.currentSpeed, - period: state.selectedPeriod, - speeds: data, - timestamp: DateTime.now(), - historyData: historyMaps.isNotEmpty ? historyMaps : null, - ), + // โ”€โ”€ 2. Tren grafik โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const Text( + 'Tren Kecepatan', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), + const SizedBox(height: 12), + const PeriodSelector(), + const SizedBox(height: 12), + if (data.isEmpty) + _buildEmptyChart() + else + WindSpeedChartWidget( + dailySpeeds: data, + period: state.selectedPeriod, + ), + const SizedBox(height: 20), + + // โ”€โ”€ 3. Info status + periode โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + _buildDetailRow(state.selectedPeriod, state.alertLevel), + const SizedBox(height: 28), + + // โ”€โ”€ 4. Riwayat data โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + _buildHistorySection(context, state), + const SizedBox(height: 20), ], ), ); @@ -106,10 +195,19 @@ class WindSpeedScreen extends StatelessWidget { ); } - Widget _buildMainSpeedDisplay(double speed) { + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // Hero kecepatan + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + Widget _buildMainSpeedDisplay(double speed, String alertLevel) { + final (badgeColor, badgeIcon) = switch (alertLevel) { + 'Bahaya' => (Colors.red.shade400, Icons.warning_rounded), + 'Waspada' => (Colors.orange.shade400, Icons.info_outline_rounded), + _ => (Colors.green.shade400, Icons.check_circle_outline_rounded), + }; + return Container( width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 40), + padding: const EdgeInsets.symmetric(vertical: 36), decoration: BoxDecoration( gradient: LinearGradient( colors: [Colors.blue.shade400, Colors.blue.shade800], @@ -122,38 +220,73 @@ class WindSpeedScreen extends StatelessWidget { color: Colors.blue.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10), - ) + ), ], ), child: Column( children: [ - const Icon(Icons.air, color: Colors.white, size: 50), - const SizedBox( - height: 10, - ), + const Icon(Icons.air, color: Colors.white, size: 46), + const SizedBox(height: 8), Text( - "${speed.toStringAsFixed(1)}", + speed.toStringAsFixed(1), style: const TextStyle( - fontSize: 80, fontWeight: FontWeight.bold, color: Colors.white), + fontSize: 80, + fontWeight: FontWeight.bold, + color: Colors.white, + height: 1, + ), + ), + const Text('m/s', + style: TextStyle(fontSize: 20, color: Colors.white70)), + const SizedBox(height: 4), + Text( + '${(speed * 3.6).toStringAsFixed(1)} km/h', + style: const TextStyle(fontSize: 14, color: Colors.white54), + ), + const SizedBox(height: 14), + Container( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 7), + decoration: BoxDecoration( + color: badgeColor.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: badgeColor.withValues(alpha: 0.7)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(badgeIcon, color: badgeColor, size: 15), + const SizedBox(width: 6), + Text( + alertLevel, + style: TextStyle( + color: badgeColor, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ], + ), ), - const Text("m/s", - style: TextStyle(fontSize: 20, color: Colors.white70)) ], ), ); } + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // Info baris: status + periode + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• Widget _buildDetailRow(String period, String alertLevel) { final (icon, color) = switch (alertLevel) { - "Bahaya" => (Icons.warning_rounded, Colors.red), - "Waspada" => (Icons.info_outline, Colors.orange), + 'Bahaya' => (Icons.warning_rounded, Colors.red), + 'Waspada' => (Icons.info_outline, Colors.orange), _ => (Icons.check_circle, Colors.green), }; + return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - _miniInfoCard("Status", alertLevel, icon, color), - _miniInfoCard("Periode", period, Icons.timer, Colors.orange), + _miniInfoCard('Status', alertLevel, icon, color), + _miniInfoCard('Periode', period, Icons.timer, Colors.orange), ], ); } @@ -165,19 +298,340 @@ class WindSpeedScreen extends StatelessWidget { decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 6), + ], ), child: Row( children: [ - Icon(icon, color: color, size: 30), + Icon(icon, color: color, size: 28), const SizedBox(width: 10), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, - style: const TextStyle(color: Colors.grey, fontSize: 12)), - Text(value, style: const TextStyle(fontWeight: FontWeight.bold)), - ], - ) + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: const TextStyle(color: Colors.grey, fontSize: 11)), + Text( + value, + style: const TextStyle( + fontWeight: FontWeight.bold, fontSize: 13), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ); + } + + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // Placeholder chart kosong โ€” cegah crash saat data belum ada + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + Widget _buildEmptyChart() { + return Container( + height: 160, + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.bar_chart_rounded, size: 42, color: Colors.grey.shade300), + const SizedBox(height: 8), + Text( + 'Data grafik belum tersedia', + style: TextStyle(color: Colors.grey.shade400, fontSize: 13), + ), + ], + ), + ); + } + + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // Section riwayat + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + Widget _buildHistorySection(BuildContext context, WindSpeedState state) { + // Urutkan terbaru di atas + final sorted = [...state.filteredHistory] + ..sort((a, b) => b.timestamp.compareTo(a.timestamp)); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header judul + tombol filter + Row( + children: [ + const Text( + 'Riwayat Data', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const Spacer(), + state.selectedDate != null + ? _FilterChip( + label: DateFormat('dd MMM yyyy', 'id_ID') + .format(state.selectedDate!), + icon: Icons.close_rounded, + onTap: () => context + .read() + .add(const WindSpeedDateFilterChanged(null)), + ) + : _FilterChip( + label: 'Filter Tanggal', + icon: Icons.calendar_month_rounded, + onTap: () => _pickDate(context, state), + ), + ], + ), + + if (state.selectedDate != null) ...[ + const SizedBox(height: 4), + Text( + '${sorted.length} data pada ${DateFormat('dd MMMM yyyy', 'id_ID').format(state.selectedDate!)}', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + ] else ...[ + const SizedBox(height: 4), + Text( + '${sorted.length} data tersimpan', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + ], + + const SizedBox(height: 12), + + // List dengan batas tinggi 420px โ€” di dalamnya bisa di-scroll + sorted.isEmpty + ? _buildEmptyHistory(state.selectedDate != null) + : ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 420), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: ListView.separated( + shrinkWrap: false, + padding: EdgeInsets.zero, + itemCount: sorted.length, + separatorBuilder: (_, __) => + Divider(height: 1, color: Colors.grey.shade100), + itemBuilder: (context, i) => + _HistoryTile(item: sorted[i], index: i), + ), + ), + ), + ), + ], + ); + } + + Widget _buildEmptyHistory(bool hasFilter) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 36), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + Icon( + hasFilter ? Icons.search_off_rounded : Icons.inbox_rounded, + size: 44, + color: Colors.grey.shade300, + ), + const SizedBox(height: 10), + Text( + hasFilter + ? 'Tidak ada data untuk tanggal ini' + : 'Belum ada data history', + style: TextStyle(color: Colors.grey.shade400, fontSize: 13), + ), + ], + ), + ); + } +} + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// Chip filter tanggal +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +class _FilterChip extends StatelessWidget { + final String label; + final IconData icon; + final VoidCallback onTap; + + const _FilterChip( + {required this.label, required this.icon, required this.onTap}); + + @override + Widget build(BuildContext context) { + final color = Colors.blue.shade700; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: color.withValues(alpha: 0.35)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: 12, color: color, fontWeight: FontWeight.w600), + ), + ], + ), + ), + ); + } +} + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// Satu baris data riwayat +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +class _HistoryTile extends StatelessWidget { + final MyWindSpeed item; + final int index; + + const _HistoryTile({required this.item, required this.index}); + + String get _alertLevel { + if (item.speed >= 12.5) return 'Bahaya'; + if (item.speed >= 8.0) return 'Waspada'; + return 'Normal'; + } + + Color get _alertColor { + switch (_alertLevel) { + case 'Bahaya': + return Colors.red.shade600; + case 'Waspada': + return Colors.orange.shade700; + default: + return Colors.green.shade600; + } + } + + IconData get _alertIcon { + switch (_alertLevel) { + case 'Bahaya': + return Icons.warning_rounded; + case 'Waspada': + return Icons.info_outline_rounded; + default: + return Icons.check_circle_outline_rounded; + } + } + + @override + Widget build(BuildContext context) { + final kmh = item.speed * 3.6; + final bg = index.isEven ? Colors.grey.shade50 : Colors.white; + + return Container( + color: bg, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + // Nomor + SizedBox( + width: 28, + child: Text( + '${index + 1}', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade400, + fontWeight: FontWeight.w600), + ), + ), + + // Tanggal + jam + Expanded( + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + DateFormat('dd MMM yyyy', 'id_ID').format(item.timestamp), + style: const TextStyle( + fontSize: 12, fontWeight: FontWeight.w600), + ), + Text( + DateFormat('HH:mm:ss').format(item.timestamp), + style: TextStyle( + fontSize: 11, + color: Colors.grey.shade500, + fontFamily: 'monospace'), + ), + ], + ), + ), + + // Kecepatan + Expanded( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${item.speed.toStringAsFixed(3)} m/s', + style: const TextStyle( + fontSize: 13, fontWeight: FontWeight.bold), + ), + Text( + '${kmh.toStringAsFixed(2)} km/h', + style: TextStyle(fontSize: 11, color: Colors.grey.shade400), + ), + ], + ), + ), + + const SizedBox(width: 10), + + // Badge status + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: _alertColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: _alertColor.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(_alertIcon, size: 11, color: _alertColor), + const SizedBox(width: 3), + Text( + _alertLevel, + style: TextStyle( + fontSize: 10, + color: _alertColor, + fontWeight: FontWeight.w700), + ), + ], + ), + ), ], ), ); diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 2dccc22..190186a 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,10 +6,14 @@ #include "generated_plugin_registrant.h" +#include #include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) flutter_localization_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterLocalizationPlugin"); + flutter_localization_plugin_register_with_registrar(flutter_localization_registrar); g_autoptr(FlPluginRegistrar) printing_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin"); printing_plugin_register_with_registrar(printing_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 50a5a3a..35578a2 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_localization printing url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index e527c3b..acc9d4a 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,9 +10,11 @@ import cloud_firestore import firebase_auth import firebase_core import firebase_database +import flutter_localization import google_sign_in_ios import printing import share_plus +import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppSettingsPlugin.register(with: registry.registrar(forPlugin: "AppSettingsPlugin")) @@ -20,7 +22,9 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseDatabasePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseDatabasePlugin")) + FlutterLocalizationPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalizationPlugin")) FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 66251e9..d428630 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -334,6 +334,19 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + flutter_localization: + dependency: "direct main" + description: + name: flutter_localization + sha256: a670baf52f4c99859c07293092b9fbc6508235fc88232f5cbb772f2687610a32 + url: "https://pub.dev" + source: hosted + version: "0.4.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_test: dependency: "direct dev" description: flutter @@ -735,6 +748,62 @@ packages: url: "https://pub.dev" source: hosted version: "7.1.0" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.dev" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" simple_gesture_detector: dependency: transitive description: @@ -820,6 +889,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 + url: "https://pub.dev" + source: hosted + version: "2.3.1" url_launcher_linux: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e2e2237..d71431c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: flutter: sdk: flutter flutter_bloc: ^8.1.0 + flutter_localization: ^0.4.0 google_fonts: ^8.0.2 google_sign_in: ^6.3.0 intl: ^0.20.2 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 41b50d5..bdf7389 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -20,6 +21,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi")); FirebaseCorePluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + FlutterLocalizationPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterLocalizationPluginCApi")); PrintingPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PrintingPlugin")); SharePlusWindowsPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index fbda3a5..32a0f2c 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST cloud_firestore firebase_auth firebase_core + flutter_localization printing share_plus url_launcher_windows From 460fb517707db45ce77f017f9c9c91c199c70839 Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Sat, 16 May 2026 14:02:11 +0700 Subject: [PATCH 12/13] sdads --- lib/main.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/main.dart b/lib/main.dart index bd3e7d5..3d17ef1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,3 +17,4 @@ void main() async { FirebaseMonitoringRepo(), )); } +// sadads From 3eb4f32b0960d694dac8ba9cd80b9abf3a473c79 Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Sat, 16 May 2026 14:03:57 +0700 Subject: [PATCH 13/13] Update main.dart --- lib/main.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/main.dart b/lib/main.dart index 3d17ef1..f7a8f1f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,4 +17,4 @@ void main() async { FirebaseMonitoringRepo(), )); } -// sadads +// sadadsadsadsadasda