diff --git a/lib/main.dart b/lib/main.dart index eb74045..f0093ed 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,12 +6,14 @@ import 'package:klimatologiot/app.dart'; import 'package:user_repository/user_repository.dart'; import 'package:monitoring_repository/monitoring_repository.dart'; import 'firebase_options.dart'; +import 'package:intl/date_symbol_data_local.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, ); + await initializeDateFormatting('id_ID', null); Bloc.observer = SimpleBlocObserver(); runApp(MyApp( FirebaseUserRepo(), diff --git a/lib/screens/monitoring/shared/dialogs/export_dialog.dart b/lib/screens/monitoring/shared/dialogs/export_dialog.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/screens/monitoring/shared/utils/pdf/atmospheric_pdf_builder.dart b/lib/screens/monitoring/shared/utils/pdf/atmospheric_pdf_builder.dart new file mode 100644 index 0000000..17116ea --- /dev/null +++ b/lib/screens/monitoring/shared/utils/pdf/atmospheric_pdf_builder.dart @@ -0,0 +1,90 @@ +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:printing/printing.dart'; +import 'pdf_components.dart'; + +/// ============================================================ +/// Atmospheric PDF Builder +/// Letakkan di: lib/screens/monitoring/shared/utils/pdf/atmospheric_pdf_builder.dart +/// ============================================================ + +Future exportAtmosphericPdf({ + required double temperature, + required double humidity, + required double pressure, + required double altitude, + required DateTime timestamp, + List>? historyData, +}) async { + final pdf = pw.Document(); + + pdf.addPage( + pw.MultiPage( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.all(32), + header: (ctx) => buildPdfHeader('Laporan Kondisi Atmosfer', timestamp), + footer: (ctx) => buildPdfFooter(ctx), + build: (ctx) => [ + // — Summary card + buildSummaryCard( + title: 'Data Terkini', + subtitle: pdfFullFormat.format(timestamp), + items: [ + SummaryItem('Suhu', '${temperature.toStringAsFixed(1)} °C', '🌡️'), + SummaryItem('Kelembapan', '${humidity.toStringAsFixed(1)} %', '💧'), + SummaryItem('Tekanan', '${pressure.toStringAsFixed(1)} hPa', '🔵'), + SummaryItem('Ketinggian', '${altitude.toStringAsFixed(1)} m', '⛰️'), + ], + ), + pw.SizedBox(height: 24), + + // — Tabel key-value ringkasan + buildSectionTitle('Detail Parameter'), + pw.SizedBox(height: 8), + buildKeyValueTable([ + ['Parameter', 'Nilai', 'Satuan'], + ['Suhu', temperature.toStringAsFixed(1), '°C'], + ['Kelembapan', humidity.toStringAsFixed(1), '%'], + ['Tekanan Udara', pressure.toStringAsFixed(1), 'hPa'], + ['Ketinggian', altitude.toStringAsFixed(1), 'm'], + ]), + + // — Tabel history jika ada + if (historyData != null && historyData.isNotEmpty) ...[ + pw.SizedBox(height: 24), + buildSectionTitle('Riwayat Data'), + pw.SizedBox(height: 8), + _buildHistoryTable(historyData), + ], + ], + ), + ); + + await Printing.layoutPdf(onLayout: (_) => pdf.save()); +} + +pw.Widget _buildHistoryTable(List> data) { + return buildDataTable( + headers: ['No', 'Waktu', 'Suhu (°C)', 'Kelembapan (%)', 'Tekanan (hPa)', 'Ketinggian (m)'], + rows: data.asMap().entries.map((e) { + final d = e.value; + final ts = d['timestamp'] as DateTime?; + return [ + '${e.key + 1}', + ts != null ? pdfFullFormat.format(ts) : '-', + (d['temperature'] as double? ?? 0).toStringAsFixed(1), + (d['humidity'] as double? ?? 0).toStringAsFixed(1), + (d['pressure'] as double? ?? 0).toStringAsFixed(1), + (d['altitude'] as double? ?? 0).toStringAsFixed(1), + ]; + }).toList(), + colWidths: { + 0: const pw.FixedColumnWidth(25), + 1: const pw.FlexColumnWidth(2), + 2: const pw.FlexColumnWidth(1), + 3: const pw.FlexColumnWidth(1.2), + 4: const pw.FlexColumnWidth(1.2), + 5: const pw.FlexColumnWidth(1.2), + }, + ); +} diff --git a/lib/screens/monitoring/shared/utils/pdf/evaporasi_pdf_builder.dart b/lib/screens/monitoring/shared/utils/pdf/evaporasi_pdf_builder.dart new file mode 100644 index 0000000..0e7e460 --- /dev/null +++ b/lib/screens/monitoring/shared/utils/pdf/evaporasi_pdf_builder.dart @@ -0,0 +1,85 @@ +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:printing/printing.dart'; +import 'pdf_components.dart'; + +/// ============================================================ +/// Evaporasi PDF Builder +/// Letakkan di: lib/screens/monitoring/shared/utils/pdf/evaporasi_pdf_builder.dart +/// ============================================================ + +Future exportEvaporasiPdf({ + required double evaporasi, + required double suhu, + required double tinggiAir, + required DateTime timestamp, + List>? historyData, +}) async { + final pdf = pw.Document(); + + pdf.addPage( + pw.MultiPage( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.all(32), + header: (ctx) => buildPdfHeader('Laporan Evaporasi', timestamp), + footer: (ctx) => buildPdfFooter(ctx), + build: (ctx) => [ + // — Summary card + buildSummaryCard( + title: 'Data Terkini', + subtitle: pdfFullFormat.format(timestamp), + items: [ + SummaryItem('Evaporasi', '${evaporasi.toStringAsFixed(2)} mm', '💧'), + SummaryItem('Suhu', '${suhu.toStringAsFixed(1)} °C', '🌡️'), + SummaryItem('Tinggi Air', '${tinggiAir.toStringAsFixed(1)} cm', '📏'), + ], + ), + pw.SizedBox(height: 24), + + // — Tabel key-value ringkasan + buildSectionTitle('Detail Parameter'), + pw.SizedBox(height: 8), + buildKeyValueTable([ + ['Parameter', 'Nilai', 'Satuan'], + ['Evaporasi', evaporasi.toStringAsFixed(2), 'mm'], + ['Suhu', suhu.toStringAsFixed(1), '°C'], + ['Tinggi Air', tinggiAir.toStringAsFixed(1), 'cm'], + ]), + + // — Tabel history jika ada + if (historyData != null && historyData.isNotEmpty) ...[ + pw.SizedBox(height: 24), + buildSectionTitle('Riwayat Data'), + pw.SizedBox(height: 8), + _buildHistoryTable(historyData), + ], + ], + ), + ); + + await Printing.layoutPdf(onLayout: (_) => pdf.save()); +} + +pw.Widget _buildHistoryTable(List> data) { + return buildDataTable( + headers: ['No', 'Waktu', 'Evaporasi (mm)', 'Suhu (°C)', 'Tinggi Air (cm)'], + rows: data.asMap().entries.map((e) { + final d = e.value; + final ts = d['timestamp'] as DateTime?; + return [ + '${e.key + 1}', + ts != null ? pdfFullFormat.format(ts) : '-', + (d['evaporasi'] as double? ?? 0).toStringAsFixed(2), + (d['suhu'] as double? ?? 0).toStringAsFixed(1), + (d['tinggiAir'] as double? ?? 0).toStringAsFixed(1), + ]; + }).toList(), + colWidths: { + 0: const pw.FixedColumnWidth(25), + 1: const pw.FlexColumnWidth(2), + 2: const pw.FlexColumnWidth(1.3), + 3: const pw.FlexColumnWidth(1), + 4: const pw.FlexColumnWidth(1.3), + }, + ); +} diff --git a/lib/screens/monitoring/shared/utils/pdf/pdf_components.dart b/lib/screens/monitoring/shared/utils/pdf/pdf_components.dart new file mode 100644 index 0000000..97f719e --- /dev/null +++ b/lib/screens/monitoring/shared/utils/pdf/pdf_components.dart @@ -0,0 +1,284 @@ +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:intl/intl.dart'; + +/// ============================================================ +/// PDF Components — Reusable builders +/// Letakkan di: lib/screens/monitoring/shared/utils/pdf/pdf_components.dart +/// ============================================================ + +// ─── Warna tema ─────────────────────────────────────────────── +const kPdfPrimary = PdfColor.fromInt(0xFF1565C0); +const kPdfAccent = PdfColor.fromInt(0xFF42A5F5); +const kPdfRowEven = PdfColor.fromInt(0xFFF5F9FF); +const kPdfBorder = PdfColor.fromInt(0xFFBBDEFB); + +// ─── Format tanggal ─────────────────────────────────────────── +final pdfDateFormat = DateFormat('dd MMMM yyyy', 'id_ID'); +final pdfTimeFormat = DateFormat('HH:mm:ss'); +final pdfFullFormat = DateFormat('dd MMM yyyy, HH:mm', 'id_ID'); + +// ================================================================ +// HEADER — muncul di setiap halaman +// ================================================================ +pw.Widget buildPdfHeader(String title, DateTime date) { + return pw.Container( + margin: const pw.EdgeInsets.only(bottom: 16), + decoration: const pw.BoxDecoration( + border: pw.Border( + bottom: pw.BorderSide(color: kPdfBorder, width: 1.5), + ), + ), + child: pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + title, + style: pw.TextStyle( + fontSize: 18, + fontWeight: pw.FontWeight.bold, + color: kPdfPrimary, + ), + ), + pw.SizedBox(height: 2), + pw.Text( + 'Sistem Monitoring Klimatologi', + style: pw.TextStyle(fontSize: 10, color: PdfColors.grey600), + ), + ], + ), + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.end, + children: [ + pw.Text( + pdfDateFormat.format(date), + style: pw.TextStyle( + fontSize: 10, + fontWeight: pw.FontWeight.bold, + color: kPdfPrimary, + ), + ), + pw.Text( + pdfTimeFormat.format(date), + style: pw.TextStyle(fontSize: 9, color: PdfColors.grey600), + ), + ], + ), + ], + ), + ); +} + +// ================================================================ +// FOOTER — muncul di setiap halaman +// ================================================================ +pw.Widget buildPdfFooter(pw.Context context) { + return pw.Container( + margin: const pw.EdgeInsets.only(top: 12), + decoration: const pw.BoxDecoration( + border: pw.Border(top: pw.BorderSide(color: kPdfBorder, width: 1)), + ), + child: pw.Padding( + padding: const pw.EdgeInsets.only(top: 6), + child: pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text( + 'Digenerate otomatis oleh Aplikasi Klimatologi', + style: pw.TextStyle(fontSize: 8, color: PdfColors.grey500), + ), + pw.Text( + 'Halaman ${context.pageNumber} / ${context.pagesCount}', + style: pw.TextStyle(fontSize: 8, color: PdfColors.grey500), + ), + ], + ), + ), + ); +} + +// ================================================================ +// SECTION TITLE — judul bagian dengan background biru +// ================================================================ +pw.Widget buildSectionTitle(String title) { + return pw.Container( + padding: const pw.EdgeInsets.symmetric(vertical: 6, horizontal: 10), + decoration: const pw.BoxDecoration( + color: kPdfPrimary, + borderRadius: pw.BorderRadius.all(pw.Radius.circular(4)), + ), + child: pw.Text( + title, + style: pw.TextStyle( + fontSize: 12, + fontWeight: pw.FontWeight.bold, + color: PdfColors.white, + ), + ), + ); +} + +// ================================================================ +// SUMMARY CARD — grid kartu nilai terkini +// ================================================================ +pw.Widget buildSummaryCard({ + required String title, + required String subtitle, + required List items, +}) { + return pw.Container( + padding: const pw.EdgeInsets.all(16), + decoration: pw.BoxDecoration( + color: const PdfColor.fromInt(0xFFE3F2FD), + borderRadius: const pw.BorderRadius.all(pw.Radius.circular(8)), + border: pw.Border.all(color: kPdfBorder), + ), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + title, + style: pw.TextStyle( + fontSize: 13, + fontWeight: pw.FontWeight.bold, + color: kPdfPrimary, + ), + ), + pw.Text( + subtitle, + style: pw.TextStyle(fontSize: 9, color: PdfColors.grey600), + ), + pw.SizedBox(height: 12), + pw.Wrap( + spacing: 12, + runSpacing: 8, + children: items.map((item) => _summaryItemCard(item)).toList(), + ), + ], + ), + ); +} + +pw.Widget _summaryItemCard(SummaryItem item) { + return pw.Container( + width: 200, + padding: const pw.EdgeInsets.symmetric(vertical: 8, horizontal: 12), + decoration: pw.BoxDecoration( + color: PdfColors.white, + borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)), + border: pw.Border.all(color: kPdfBorder), + ), + child: pw.Row( + children: [ + pw.Text(item.emoji, style: const pw.TextStyle(fontSize: 18)), + pw.SizedBox(width: 8), + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + item.label, + style: pw.TextStyle(fontSize: 9, color: PdfColors.grey600), + ), + pw.Text( + item.value, + style: pw.TextStyle( + fontSize: 14, + fontWeight: pw.FontWeight.bold, + color: kPdfPrimary, + ), + ), + ], + ), + ], + ), + ); +} + +// ================================================================ +// BASE TABLE — semua tabel pakai ini +// ================================================================ +pw.Widget buildDataTable({ + required List headers, + required List> rows, + Map? colWidths, +}) { + return pw.Table( + border: pw.TableBorder.all(color: kPdfBorder, width: 0.5), + columnWidths: colWidths, + children: [ + // Header + pw.TableRow( + decoration: const pw.BoxDecoration(color: kPdfPrimary), + children: headers + .map((h) => pw.Padding( + padding: const pw.EdgeInsets.symmetric( + vertical: 7, horizontal: 8), + child: pw.Text( + h, + style: pw.TextStyle( + fontSize: 9, + fontWeight: pw.FontWeight.bold, + color: PdfColors.white, + ), + ), + )) + .toList(), + ), + // Data rows + ...rows.asMap().entries.map((entry) { + final isEven = entry.key % 2 == 0; + return pw.TableRow( + decoration: pw.BoxDecoration( + color: isEven ? kPdfRowEven : PdfColors.white, + ), + children: entry.value + .map((cell) => pw.Padding( + padding: const pw.EdgeInsets.symmetric( + vertical: 5, horizontal: 8), + child: pw.Text(cell, + style: const pw.TextStyle(fontSize: 9)), + )) + .toList(), + ); + }), + ], + ); +} + +// ================================================================ +// KEY-VALUE TABLE — tabel 3 kolom: Parameter | Nilai | Satuan +// ================================================================ +pw.Widget buildKeyValueTable(List> rows) { + return buildDataTable( + headers: rows.first, + rows: rows.skip(1).toList(), + colWidths: { + 0: const pw.FlexColumnWidth(2), + 1: const pw.FlexColumnWidth(1.5), + 2: const pw.FlexColumnWidth(1), + }, + ); +} + +// ================================================================ +// HELPER CLASS +// ================================================================ +class SummaryItem { + final String label; + final String value; + final String emoji; + const SummaryItem(this.label, this.value, this.emoji); +} + +// ================================================================ +// STATS HELPER +// ================================================================ +Map calcStats(List data) { + if (data.isEmpty) return {'avg': 0, 'max': 0, 'min': 0}; + final sorted = List.from(data)..sort(); + final avg = data.reduce((a, b) => a + b) / data.length; + return {'avg': avg, 'max': sorted.last, 'min': sorted.first}; +} diff --git a/lib/screens/monitoring/shared/utils/pdf/pdf_export_service.dart b/lib/screens/monitoring/shared/utils/pdf/pdf_export_service.dart new file mode 100644 index 0000000..9f22e35 --- /dev/null +++ b/lib/screens/monitoring/shared/utils/pdf/pdf_export_service.dart @@ -0,0 +1,62 @@ +import 'atmospheric_pdf_builder.dart'; +import 'evaporasi_pdf_builder.dart'; +import 'wind_speed_pdf_builder.dart'; + +/// ============================================================ +/// PDF Export Service — Entry Point +/// Letakkan di: lib/screens/monitoring/shared/utils/pdf/pdf_export_service.dart +/// +/// Ini cuma facade — semua logic ada di builder masing-masing. +/// Import file ini saja di screen kamu. +/// ============================================================ + +class PdfExportService { + PdfExportService._(); // tidak bisa di-instantiate + + static Future atmospheric({ + required double temperature, + required double humidity, + required double pressure, + required double altitude, + required DateTime timestamp, + List>? historyData, + }) => + exportAtmosphericPdf( + temperature: temperature, + humidity: humidity, + pressure: pressure, + altitude: altitude, + timestamp: timestamp, + historyData: historyData, + ); + + static Future evaporasi({ + required double evaporasi, + required double suhu, + required double tinggiAir, + required DateTime timestamp, + List>? historyData, + }) => + exportEvaporasiPdf( + evaporasi: evaporasi, + suhu: suhu, + tinggiAir: tinggiAir, + timestamp: timestamp, + historyData: historyData, + ); + + static Future windSpeed({ + required double currentSpeed, + required String period, + required List speeds, + required DateTime timestamp, + List>? historyData, + }) => + exportWindSpeedPdf( + currentSpeed: currentSpeed, + period: period, + speeds: speeds, + timestamp: timestamp, + historyData: historyData, + ); +} diff --git a/lib/screens/monitoring/shared/utils/pdf/wind_speed_pdf_builder.dart b/lib/screens/monitoring/shared/utils/pdf/wind_speed_pdf_builder.dart new file mode 100644 index 0000000..1abb06b --- /dev/null +++ b/lib/screens/monitoring/shared/utils/pdf/wind_speed_pdf_builder.dart @@ -0,0 +1,110 @@ +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:printing/printing.dart'; +import 'pdf_components.dart'; + +/// ============================================================ +/// Wind Speed PDF Builder +/// Letakkan di: lib/screens/monitoring/shared/utils/pdf/wind_speed_pdf_builder.dart +/// ============================================================ + +Future exportWindSpeedPdf({ + required double currentSpeed, + required String period, + required List speeds, + required DateTime timestamp, + List>? historyData, +}) async { + final pdf = pw.Document(); + final stats = calcStats(speeds); + + pdf.addPage( + pw.MultiPage( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.all(32), + header: (ctx) => buildPdfHeader('Laporan Kecepatan Angin', timestamp), + footer: (ctx) => buildPdfFooter(ctx), + build: (ctx) => [ + // — Summary card + buildSummaryCard( + title: 'Data Terkini', + subtitle: 'Periode: $period', + items: [ + SummaryItem('Kecepatan', '${currentSpeed.toStringAsFixed(1)} m/s', '💨'), + SummaryItem('Rata-rata', '${stats['avg']!.toStringAsFixed(1)} m/s', '📊'), + SummaryItem('Maksimum', '${stats['max']!.toStringAsFixed(1)} m/s', '⬆️'), + SummaryItem('Minimum', '${stats['min']!.toStringAsFixed(1)} m/s', '⬇️'), + ], + ), + pw.SizedBox(height: 24), + + // — Tabel data per periode + if (speeds.isNotEmpty) ...[ + buildSectionTitle('Data Periode ($period)'), + pw.SizedBox(height: 8), + _buildPeriodTable(speeds, period), + ], + + // — Tabel history jika ada + if (historyData != null && historyData.isNotEmpty) ...[ + pw.SizedBox(height: 24), + buildSectionTitle('Riwayat Detail'), + pw.SizedBox(height: 8), + _buildHistoryTable(historyData), + ], + ], + ), + ); + + await Printing.layoutPdf(onLayout: (_) => pdf.save()); +} + +pw.Widget _buildPeriodTable(List speeds, String period) { + final labels = _periodLabels(speeds.length, period); + return buildDataTable( + headers: ['No', 'Label', 'Kecepatan (m/s)'], + rows: speeds.asMap().entries.map((e) => [ + '${e.key + 1}', + labels[e.key], + e.value.toStringAsFixed(2), + ]).toList(), + colWidths: { + 0: const pw.FixedColumnWidth(30), + 1: const pw.FlexColumnWidth(2), + 2: const pw.FlexColumnWidth(1.5), + }, + ); +} + +pw.Widget _buildHistoryTable(List> data) { + return buildDataTable( + headers: ['No', 'Waktu', 'Kecepatan (m/s)', 'Pulse'], + rows: data.asMap().entries.map((e) { + final d = e.value; + final ts = d['timestamp'] as DateTime?; + return [ + '${e.key + 1}', + ts != null ? pdfFullFormat.format(ts) : '-', + (d['speed'] as double? ?? 0).toStringAsFixed(2), + '${d['pulse'] ?? 0}', + ]; + }).toList(), + colWidths: { + 0: const pw.FixedColumnWidth(25), + 1: const pw.FlexColumnWidth(2.5), + 2: const pw.FlexColumnWidth(1.5), + 3: const pw.FlexColumnWidth(1), + }, + ); +} + +List _periodLabels(int count, String period) { + if (period == 'Hari Ini') { + return List.generate(count, (i) => 'Jam ${i.toString().padLeft(2, '0')}:00'); + } else if (period == 'Minggu Ini') { + const days = ['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min']; + return List.generate(count, (i) => days[i % 7]); + } else { + return List.generate(count, (i) => 'Minggu ${i + 1}'); + } +} diff --git a/lib/screens/monitoring/shared/widgets/export_pdf_button.dart b/lib/screens/monitoring/shared/widgets/export_pdf_button.dart new file mode 100644 index 0000000..5e8f69a --- /dev/null +++ b/lib/screens/monitoring/shared/widgets/export_pdf_button.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; + +/// ============================================================ +/// Export PDF Button — Reusable Widget +/// Letakkan di: lib/screens/monitoring/shared/widgets/export_pdf_button.dart +/// ============================================================ +/// +/// Cara pakai (contoh di WindSpeedScreen): +/// +/// ExportPdfButton( +/// onExport: () async { +/// await PdfExportService.exportWindSpeedPdf( +/// currentSpeed: state.currentSpeed, +/// period: state.selectedPeriod, +/// speeds: data, +/// timestamp: DateTime.now(), +/// ); +/// }, +/// ) + +class ExportPdfButton extends StatefulWidget { + /// Callback async yang memanggil PdfExportService + final Future Function() onExport; + + /// Label tombol (default: "Export PDF") + final String label; + + /// Style: FAB (floating) atau ElevatedButton (inline) + final ExportButtonStyle style; + + const ExportPdfButton({ + super.key, + required this.onExport, + this.label = 'Export PDF', + this.style = ExportButtonStyle.elevated, + }); + + @override + State createState() => _ExportPdfButtonState(); +} + +class _ExportPdfButtonState extends State { + bool _isLoading = false; + + Future _handleExport() async { + if (_isLoading) return; + setState(() => _isLoading = true); + + try { + await widget.onExport(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Gagal export PDF: $e'), + backgroundColor: Colors.red.shade600, + ), + ); + } + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + return widget.style == ExportButtonStyle.fab + ? _buildFab() + : _buildElevated(); + } + + // — FAB style (floating action button) + Widget _buildFab() { + return FloatingActionButton.extended( + onPressed: _isLoading ? null : _handleExport, + backgroundColor: Colors.blue.shade700, + icon: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.picture_as_pdf, color: Colors.white), + label: Text( + _isLoading ? 'Menyiapkan...' : widget.label, + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + ), + ); + } + + // — Elevated button style (inline di dalam konten) + Widget _buildElevated() { + return SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _isLoading ? null : _handleExport, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue.shade700, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + icon: _isLoading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.picture_as_pdf, color: Colors.white), + label: Text( + _isLoading ? 'Menyiapkan PDF...' : widget.label, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 15, + ), + ), + ), + ); + } +} + +enum ExportButtonStyle { fab, elevated } 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 27f633c..4b01e41 100644 --- a/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart +++ b/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart @@ -48,9 +48,35 @@ class WindSpeedBloc extends Bloc { ); final dailyGraph = TimeSeriesMapper.smooth(raw); + final daily = TimeSeriesMapper.smooth( + TimeSeriesMapper.toDaily( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.speed, + ), + ); + + final weekly = TimeSeriesMapper.smooth( + TimeSeriesMapper.toWeekly( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.speed, + ), + ); + + final monthly = TimeSeriesMapper.smooth( + TimeSeriesMapper.toMonthly( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.speed, + ), + ); + emit(state.copyWith( history: history, dailySpeeds: dailyGraph, + weeklySpeeds: weekly, + monthlySpeeds: monthly, isLoading: false, )); @@ -91,7 +117,7 @@ class WindSpeedBloc extends Bloc { } } - updated[index] = newValue; + updated[index] = newValue.clamp(0, 100); } emit(state.copyWith( @@ -107,7 +133,7 @@ class WindSpeedBloc extends Bloc { WindSpeedPeriodChanged event, Emitter emit, ) async { - emit(state.copyWith(isLoading: true, selectedPeriod: event.period)); + emit(state.copyWith(selectedPeriod: event.period)); final history = state.history; 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 becd2e4..3276997 100644 --- a/lib/screens/monitoring/wind_speed/blocs/wind_speed_state.dart +++ b/lib/screens/monitoring/wind_speed/blocs/wind_speed_state.dart @@ -3,7 +3,9 @@ part of 'wind_speed_bloc.dart'; class WindSpeedState extends Equatable { final double currentSpeed; final String selectedPeriod; - final List dailySpeeds; // Untuk grafik + final List dailySpeeds; + final List weeklySpeeds; + final List monthlySpeeds; final bool isLoading; final List history; @@ -13,12 +15,16 @@ class WindSpeedState extends Equatable { this.dailySpeeds = const [], this.isLoading = true, this.history = const [], + this.monthlySpeeds = const [], + this.weeklySpeeds = const [], }); WindSpeedState copyWith({ double? currentSpeed, String? selectedPeriod, List? dailySpeeds, + List? weeklySpeeds, + List? monthlySpeeds, bool? isLoading, List? history, }) { @@ -26,6 +32,8 @@ class WindSpeedState extends Equatable { currentSpeed: currentSpeed ?? this.currentSpeed, selectedPeriod: selectedPeriod ?? this.selectedPeriod, dailySpeeds: dailySpeeds ?? this.dailySpeeds, + weeklySpeeds: weeklySpeeds ?? this.weeklySpeeds, + monthlySpeeds: monthlySpeeds ?? this.monthlySpeeds, isLoading: isLoading ?? this.isLoading, history: history ?? this.history, ); 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 c80a830..d24c2f4 100644 --- a/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart +++ b/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart @@ -5,6 +5,9 @@ import '../blocs/wind_speed_bloc.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'; + class WindSpeedScreen extends StatelessWidget { const WindSpeedScreen({super.key}); @@ -30,6 +33,32 @@ class WindSpeedScreen extends StatelessWidget { return const Center(child: CircularProgressIndicator()); } + // Pilih data sesuai periode + final List data = switch (state.selectedPeriod) { + "Minggu Ini" => state.weeklySpeeds, + "Bulan Ini" => state.monthlySpeeds, + _ => state.dailySpeeds, + }; + + // List data; + + // if (state.selectedPeriod == "Minggu Ini") { + // data = state.weeklySpeeds; + // } else if (state.selectedPeriod == "Bulan Ini") { + // data = state.monthlySpeeds; + // } else { + // data = state.dailySpeeds; + // } + + // Konversi history MyWindSpeed → Map (untuk tabel PDF) + final historyMaps = state.history + .map((e) => { + 'timestamp': e.timestamp, + 'speed': e.speed, + 'pulse': e.pulse, + }) + .toList(); + return SingleChildScrollView( padding: const EdgeInsets.all(20.0), child: Column( @@ -46,7 +75,7 @@ class WindSpeedScreen extends StatelessWidget { const SizedBox(height: 15), WindSpeedChartWidget( - dailySpeeds: state.dailySpeeds, + dailySpeeds: data, period: state.selectedPeriod, ), @@ -54,6 +83,18 @@ class WindSpeedScreen extends StatelessWidget { // 3. Info Tambahan (Status/Periode) _buildDetailRow(state.selectedPeriod), + 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, + ), + ), ], ), ); diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..ce0e550 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#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); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index be1ee3e..fd82dd7 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + printing ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 608950e..bf298b9 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,6 +10,7 @@ import firebase_auth import firebase_core import firebase_database import google_sign_in_ios +import printing func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin")) @@ -17,4 +18,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseDatabasePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseDatabasePlugin")) FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) + PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 1b69af3..2a56299 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.66" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" args: dependency: transitive description: @@ -25,6 +33,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + barcode: + dependency: transitive + description: + name: barcode + sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" + url: "https://pub.dev" + source: hosted + version: "2.2.9" + bidi: + dependency: transitive + description: + name: bidi + sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d" + url: "https://pub.dev" + source: hosted + version: "2.0.13" bloc: dependency: "direct main" description: @@ -352,6 +376,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" intl: dependency: "direct main" description: @@ -487,6 +519,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: transitive description: @@ -535,6 +575,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + pdf: + dependency: "direct main" + description: + name: pdf + sha256: e47a275b267873d5944ad5f5ff0dcc7ac2e36c02b3046a0ffac9b72fd362c44b + url: "https://pub.dev" + source: hosted + version: "3.12.0" + pdf_widget_wrapper: + dependency: transitive + description: + name: pdf_widget_wrapper + sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5 + url: "https://pub.dev" + source: hosted + version: "1.0.4" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" platform: dependency: transitive description: @@ -551,6 +615,22 @@ 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: + name: printing + sha256: "689170c9ddb1bda85826466ba80378aa8993486d3c959a71cd7d2d80cb606692" + url: "https://pub.dev" + source: hosted + version: "5.14.3" provider: dependency: transitive description: @@ -567,6 +647,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" rxdart: dependency: "direct main" description: @@ -683,6 +771,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" yaml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index fdfe240..8465d9f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,6 +25,8 @@ dependencies: rxdart: ^0.27.7 user_repository: path: packages/user_repository + pdf: ^3.11.1 + printing: ^5.13.1 dev_dependencies: flutter_lints: ^3.0.0 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index bf6d21a..bd3cfd7 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { CloudFirestorePluginCApiRegisterWithRegistrar( @@ -17,4 +18,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi")); FirebaseCorePluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + PrintingPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PrintingPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 37c2b78..879bac0 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 + printing ) list(APPEND FLUTTER_FFI_PLUGIN_LIST