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] 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