MIF_E31222656/lib/utils/pdf_generator.dart

652 lines
27 KiB
Dart

import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart' show kIsWeb, debugPrint;
import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:open_file/open_file.dart';
import 'package:share_plus/share_plus.dart';
import 'package:cross_file/cross_file.dart';
import 'package:tugas_akhir_supabase/data/models/diagnosis_result_model.dart'; // Pastikan path ini benar
import 'package:tugas_akhir_supabase/utils/web_pdf_helper.dart' if (dart.library.io) 'package:tugas_akhir_supabase/utils/mobile_pdf_helper.dart';
// Conditionally import dart:html for web
// This is needed because dart:html is not available on mobile platforms
// The following line will be ignored when compiling for mobile
// @dart=2.9
class HarvestPdfGenerator {
final currency = NumberFormat.currency(locale: 'id_ID', symbol: 'Rp ');
/// Membuat PDF dari data analisis panen
Future<File> generatePdf({
required String title,
required Map<String, dynamic> harvestData,
Map<String, dynamic>? scheduleData,
List<Map<String, dynamic>>? dailyLogs,
Uint8List? chartImageBytes,
}) async {
// Buat dokumen PDF
final pdf = pw.Document();
// Tanggal laporan
final now = DateTime.now();
final formattedDate = DateFormat('dd MMMM yyyy, HH:mm').format(now);
final fileName = 'laporan_panen_${DateFormat('yyyyMMdd_HHmmss').format(now)}.pdf';
// Ekstrak data dengan penanganan tipe data yang aman
final cropName = _safeToString(scheduleData?['crop_name'] ?? harvestData['crop_name'] ?? 'Tidak diketahui');
final productivity = _safeToDouble(harvestData['productivity']);
final totalCost = _safeToDouble(harvestData['cost']);
final income = _safeToDouble(harvestData['income']);
final profit = _safeToDouble(harvestData['profit']);
final profitMargin = _safeToDouble(harvestData['profit_margin']);
final status = _safeToString(harvestData['status'] ?? 'Tidak diketahui');
// Tambahkan halaman ke PDF
pdf.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
header: (pw.Context context) {
return pw.Center(
child: pw.Text(
'LAPORAN ANALISIS PANEN',
style: pw.TextStyle(
fontSize: 18,
fontWeight: pw.FontWeight.bold,
),
),
);
},
footer: (pw.Context context) {
return pw.Center(
child: pw.Text(
'Halaman ${context.pageNumber} dari ${context.pagesCount}',
style: const pw.TextStyle(
fontSize: 10,
),
),
);
},
build: (pw.Context context) {
return [
pw.Center(
child: pw.Text(
formattedDate,
style: const pw.TextStyle(
fontSize: 12,
),
),
),
pw.SizedBox(height: 20),
// Informasi Tanaman
pw.Container(
padding: const pw.EdgeInsets.all(10),
decoration: pw.BoxDecoration(
border: pw.Border.all(),
borderRadius: pw.BorderRadius.circular(5),
),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
'INFORMASI TANAMAN',
style: pw.TextStyle(
fontSize: 14,
fontWeight: pw.FontWeight.bold,
),
),
pw.Divider(),
_buildInfoRowForHarvest('Jenis Tanaman', cropName),
if (scheduleData != null) ...[
_buildInfoRowForHarvest('Lahan', _safeToString(scheduleData['field_name'] ?? '-')),
_buildInfoRowForHarvest('Plot', _safeToString(scheduleData['plot'] ?? '-')),
_buildInfoRowForHarvest(
'Periode Tanam',
'${_formatDate(_safeToString(scheduleData['start_date']))} - ${_formatDate(_safeToString(scheduleData['end_date']))}'
),
_buildInfoRowForHarvest(
'Plot',
'${_safeToString(scheduleData['plot'])}'
),
],
],
),
),
pw.SizedBox(height: 15),
// Status Panen
pw.Container(
padding: const pw.EdgeInsets.all(10),
decoration: pw.BoxDecoration(
border: pw.Border.all(),
borderRadius: pw.BorderRadius.circular(5),
),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
'STATUS PANEN',
style: pw.TextStyle(
fontSize: 14,
fontWeight: pw.FontWeight.bold,
),
),
pw.Divider(),
_buildInfoRowForHarvest('Status', status),
_buildInfoRowForHarvest('Keterangan', _getStatusDescription(status)),
],
),
),
pw.SizedBox(height: 15),
// Ringkasan Keuangan
pw.Container(
padding: const pw.EdgeInsets.all(10),
decoration: pw.BoxDecoration(
border: pw.Border.all(),
borderRadius: pw.BorderRadius.circular(5),
),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
'RINGKASAN KEUANGAN',
style: pw.TextStyle(
fontSize: 14,
fontWeight: pw.FontWeight.bold,
),
),
pw.Divider(),
_buildInfoRowForHarvest('Total Biaya Produksi', currency.format(totalCost)),
_buildInfoRowForHarvest('Pendapatan Kotor', currency.format(income)),
_buildInfoRowForHarvest('Keuntungan Bersih', currency.format(profit)),
_buildInfoRowForHarvest('Rasio Keuntungan', '${profitMargin.toStringAsFixed(2)}%'),
_buildInfoRowForHarvest('Produktivitas', '${productivity.toStringAsFixed(2)} kilogram/ha'),
// Add RC Ratio & BC Ratio
if (totalCost > 0) ...[
_buildInfoRowForHarvest('R/C Ratio', (income / totalCost).toStringAsFixed(2)),
_buildInfoRowForHarvest('B/C Ratio', (profit / totalCost).toStringAsFixed(2)),
],
],
),
),
// If chart image is available, add it to the PDF
if (chartImageBytes != null) ...[
pw.SizedBox(height: 15),
pw.Container(
padding: const pw.EdgeInsets.all(10),
decoration: pw.BoxDecoration(
border: pw.Border.all(),
borderRadius: pw.BorderRadius.circular(5),
),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
'GRAFIK ANALISIS',
style: pw.TextStyle(
fontSize: 14,
fontWeight: pw.FontWeight.bold,
),
),
pw.Divider(),
pw.SizedBox(height: 5),
pw.Center(
child: pw.Image(
pw.MemoryImage(chartImageBytes),
width: 400,
height: 200,
fit: pw.BoxFit.contain,
),
),
],
),
),
],
pw.SizedBox(height: 15),
// Rincian Biaya
pw.Container(
padding: const pw.EdgeInsets.all(10),
decoration: pw.BoxDecoration(
border: pw.Border.all(),
borderRadius: pw.BorderRadius.circular(5),
),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
'RINCIAN BIAYA PRODUKSI',
style: pw.TextStyle(
fontSize: 14,
fontWeight: pw.FontWeight.bold,
),
),
pw.Divider(),
_buildInfoRowForHarvest('Bibit', currency.format(_safeToDouble(harvestData['seed_cost']))),
_buildInfoRowForHarvest('Pupuk', currency.format(_safeToDouble(harvestData['fertilizer_cost']))),
_buildInfoRowForHarvest('Pestisida', currency.format(_safeToDouble(harvestData['pesticide_cost']))),
_buildInfoRowForHarvest('Tenaga Kerja', currency.format(_safeToDouble(harvestData['labor_cost']))),
_buildInfoRowForHarvest('Irigasi', currency.format(_safeToDouble(harvestData['irrigation_cost']))),
pw.Divider(),
_buildInfoRowForHarvest('Total', currency.format(totalCost), isBold: true),
],
),
),
pw.SizedBox(height: 15),
// Analisis dan Rekomendasi
pw.Container(
padding: const pw.EdgeInsets.all(10),
decoration: pw.BoxDecoration(
border: pw.Border.all(),
borderRadius: pw.BorderRadius.circular(5),
),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
'ANALISIS & REKOMENDASI',
style: pw.TextStyle(
fontSize: 14,
fontWeight: pw.FontWeight.bold,
),
),
pw.Divider(),
pw.Text(_getAnalysisText(productivity, profitMargin)),
pw.SizedBox(height: 10),
pw.Text(
'Rekomendasi:',
style: pw.TextStyle(
fontWeight: pw.FontWeight.bold,
),
),
pw.SizedBox(height: 5),
pw.Text(_getRecommendation(status)),
],
),
),
// Jika ada catatan harian, tambahkan tabel
if (dailyLogs != null && dailyLogs.isNotEmpty) ...[
pw.SizedBox(height: 15),
pw.Text(
'CATATAN HARIAN',
style: pw.TextStyle(
fontSize: 14,
fontWeight: pw.FontWeight.bold,
),
),
pw.SizedBox(height: 5),
pw.Table(
border: pw.TableBorder.all(),
children: [
// Header
pw.TableRow(
children: [
_buildTableCell('Tanggal', isHeader: true),
_buildTableCell('Catatan', isHeader: true),
_buildTableCell('Biaya', isHeader: true),
],
),
// Data rows
...dailyLogs.map((log) {
String dateStr;
try {
final date = DateTime.parse(_safeToString(log['date']));
dateStr = DateFormat('dd/MM/yyyy').format(date);
} catch (e) {
dateStr = '-';
}
return pw.TableRow(
children: [
_buildTableCell(dateStr),
_buildTableCell(_safeToString(log['note'] ?? '-')),
_buildTableCell(currency.format(_safeToDouble(log['cost']))),
],
);
}).toList(),
],
),
],
];
},
),
);
// Simpan PDF ke direktori aplikasi (tidak memerlukan izin khusus)
final directory = await getApplicationDocumentsDirectory();
final filePath = '${directory.path}/$fileName';
final file = File(filePath);
await file.writeAsBytes(await pdf.save());
debugPrint('PDF saved to: $filePath');
return file;
}
pw.Widget _buildInfoRowForHarvest(String label, String value, {bool isBold = false}) {
return pw.Padding(
padding: const pw.EdgeInsets.symmetric(vertical: 3),
child: pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.SizedBox(
width: 150,
child: pw.Text(label),
),
pw.Text(': '),
pw.Expanded(
child: pw.Text(
value,
style: isBold ? pw.TextStyle(fontWeight: pw.FontWeight.bold) : null,
),
),
],
),
);
}
pw.Widget _buildTableCell(String text, {bool isHeader = false}) {
return pw.Padding(
padding: const pw.EdgeInsets.all(5),
child: pw.Text(
text,
style: isHeader ? pw.TextStyle(fontWeight: pw.FontWeight.bold) : null,
),
);
}
String _formatDate(String? dateStr) {
if (dateStr == null || dateStr.isEmpty) return '-';
try {
final date = DateTime.parse(dateStr);
return DateFormat('dd MMMM yyyy', 'id_ID').format(date);
} catch (e) {
return dateStr; // Kembalikan string asli jika parsing gagal
}
}
// Fungsi untuk mengamankan konversi nilai ke string
String _safeToString(dynamic value, {String defaultValue = '-'}) {
if (value == null) return defaultValue;
if (value is List && value.isEmpty) return defaultValue;
if (value is String && value.isEmpty) return defaultValue;
return value.toString();
}
// Fungsi untuk mengamankan konversi nilai ke double
double _safeToDouble(dynamic value, {double defaultValue = 0.0}) {
if (value == null) return defaultValue;
if (value is int) return value.toDouble();
if (value is double) return value;
if (value is String) {
try {
return double.parse(value);
} catch (e) {
return defaultValue;
}
}
return defaultValue;
}
String _getStatusDescription(String? status) {
switch (status) {
case 'Baik':
return 'Produktivitas dan profitabilitas optimal';
case 'Cukup':
return 'Performa yang cukup baik, masih dapat ditingkatkan';
case 'Kurang':
return 'Produktivitas dan profitabilitas perlu ditingkatkan';
default:
return '';
}
}
String _getAnalysisText(double productivity, double profitMargin) {
String productivityText;
if (productivity > 8000) {
productivityText = 'Produktivitas lahan sangat tinggi (${productivity.toStringAsFixed(2)} kilogram/ha), menunjukkan praktik budidaya yang sangat baik.';
} else if (productivity > 5000) {
productivityText = 'Produktivitas lahan baik (${productivity.toStringAsFixed(2)} kilogram/ha), menunjukkan praktik budidaya yang efektif.';
} else {
productivityText = 'Produktivitas lahan kurang optimal (${productivity.toStringAsFixed(2)} kilogram/ha), ada ruang untuk peningkatan praktik budidaya.';
}
String profitText;
if (profitMargin >= 30) {
profitText = 'Rasio keuntungan sangat baik (${profitMargin.toStringAsFixed(2)}%), menunjukkan efisiensi biaya produksi yang tinggi.';
} else if (profitMargin >= 15) {
profitText = 'Rasio keuntungan cukup baik (${profitMargin.toStringAsFixed(2)}%), namun masih ada ruang untuk peningkatan efisiensi.';
} else if (profitMargin > 0) {
profitText = 'Rasio keuntungan minimal (${profitMargin.toStringAsFixed(2)}%), perlu evaluasi struktur biaya produksi.';
} else {
profitText = 'Mengalami kerugian dengan rasio (${profitMargin.toStringAsFixed(2)}%), memerlukan perubahan signifikan pada struktur biaya dan teknik produksi.';
}
return '$productivityText\n\n$profitText';
}
String _getRecommendation(String? status) {
switch (status) {
case 'Baik':
return 'Pertahankan praktik pertanian yang sudah baik. Pertimbangkan untuk memperluas area tanam atau mencoba varietas unggulan untuk meningkatkan keuntungan lebih lanjut.';
case 'Cukup':
return 'Tingkatkan efisiensi biaya produksi, terutama pada komponen biaya terbesar. Pertimbangkan untuk mengoptimalkan penggunaan pupuk dan pestisida agar lebih tepat sasaran.';
case 'Kurang':
return 'Evaluasi ulang teknik budidaya yang diterapkan. Pastikan pemilihan varietas yang tepat, perbaiki teknik pemupukan, dan kendalikan hama penyakit secara terpadu untuk meningkatkan produktivitas.';
default:
return 'Belum dapat memberikan rekomendasi spesifik.';
}
}
/// Buka file PDF
Future<void> openPdf(File file) async {
try {
// Try using open_file package first
final result = await OpenFile.open(file.path);
if (result.type != ResultType.done) {
throw Exception('Tidak dapat membuka file: ${result.message}');
}
} catch (e) {
// If open_file fails, try an alternative approach
debugPrint('Error opening PDF with OpenFile: $e');
throw Exception('Gagal membuka PDF. Silakan coba bagikan file dan buka dengan aplikasi PDF lain.');
}
}
/// Bagikan file PDF
Future<void> sharePdf(File file) async {
try {
await Share.shareXFiles(
[XFile(file.path)],
text: 'Laporan Analisis Panen',
);
} catch (e) {
debugPrint('Error sharing PDF: $e');
throw Exception('Gagal membagikan PDF. Silakan coba lagi nanti.');
}
}
Future<File> generateDiagnosisReportPdf({
required DiagnosisResultModel diagnosisResult,
required Uint8List? imageBytes,
}) async {
final pdf = pw.Document();
final now = DateTime.now();
final formattedDate = DateFormat('dd MMMM yyyy, HH:mm', 'id_ID').format(now);
final fileName = 'laporan_diagnosis_${DateFormat('yyyyMMdd_HHmmss').format(now)}.pdf';
final pw.TextStyle headingStyle = pw.TextStyle(fontSize: 22, fontWeight: pw.FontWeight.bold, color: PdfColors.green800);
final pw.TextStyle subheadingStyle = pw.TextStyle(fontSize: 12, color: PdfColors.grey600);
final pw.TextStyle sectionTitleStyle = pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold, color: PdfColors.green700);
final pw.TextStyle boldStyle = pw.TextStyle(fontWeight: pw.FontWeight.bold);
final plantImage = imageBytes != null ? pw.Image(pw.MemoryImage(imageBytes), fit: pw.BoxFit.contain, height: 150) : pw.Container();
pdf.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4.copyWith(
marginLeft: 1.5 * PdfPageFormat.cm,
marginRight: 1.5 * PdfPageFormat.cm,
marginTop: 1.5 * PdfPageFormat.cm,
marginBottom: 1.5 * PdfPageFormat.cm,
),
header: (pw.Context context) {
return pw.Column(
children: [
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('Laporan Diagnosis Tanaman', style: headingStyle),
pw.Text(formattedDate, style: subheadingStyle),
]
),
pw.Divider(height: 20, thickness: 1.5, color: PdfColors.green800),
]
);
},
footer: (pw.Context context) {
return pw.Center(
child: pw.Text(
'Halaman ${context.pageNumber} dari ${context.pagesCount} - Dihasilkan oleh TaniSM4RT',
style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey500),
),
);
},
build: (pw.Context context) => [
// Plant Image
if (imageBytes != null)
pw.Center(
child: pw.Container(
margin: const pw.EdgeInsets.only(bottom: 20),
padding: const pw.EdgeInsets.all(5),
decoration: pw.BoxDecoration(
border: pw.Border.all(color: PdfColors.grey300, width: 1),
borderRadius: pw.BorderRadius.circular(5),
),
child: plantImage,
),
),
// Plant Identification
_buildDiagnosisSectionTitle('Identifikasi Tanaman'),
_buildDiagnosisInfoRow('Spesies Tanaman', _safeToString(diagnosisResult.plantSpecies)),
_buildDiagnosisInfoRow('Tahap Pertumbuhan', _safeToString(diagnosisResult.plantData['growthStage'])),
_buildDiagnosisInfoRow('Status Kesehatan', diagnosisResult.isHealthy ? 'Sehat' : 'Tidak Sehat / Terindikasi Penyakit',
valueStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: diagnosisResult.isHealthy ? PdfColors.green600 : PdfColors.orange600)
),
// Diagnosis Details (if not healthy)
if (!diagnosisResult.isHealthy) ...[
_buildDiagnosisSectionTitle('Detail Diagnosis Penyakit'),
_buildDiagnosisInfoRow('Nama Penyakit', _safeToString(diagnosisResult.diseaseName)),
_buildDiagnosisInfoRow('Nama Ilmiah', _safeToString(diagnosisResult.scientificName), valueStyle: pw.TextStyle(fontStyle: pw.FontStyle.italic)),
_buildDiagnosisInfoRow('Bagian Terdampak', _safeToString(diagnosisResult.additionalInfo.affectedParts.join(', '))),
_buildDiagnosisInfoRow('Kondisi Lingkungan Pemicu', _safeToString(diagnosisResult.additionalInfo.environmentalConditions)),
if (diagnosisResult.plantData['diseaseSeverity'] != null || diagnosisResult.plantData['infectedArea'] != null)
_buildDiagnosisSectionTitle('Tingkat Keparahan & Dampak', fontSize: 14),
if (diagnosisResult.plantData['diseaseSeverity'] != null)
_buildDiagnosisInfoRow('Tingkat Keparahan', '${(_safeToDouble(diagnosisResult.plantData['diseaseSeverity']) * 100).toStringAsFixed(0)}%'),
if (diagnosisResult.plantData['infectedArea'] != null)
_buildDiagnosisInfoRow('Estimasi Area Terinfeksi', '${(_safeToDouble(diagnosisResult.plantData['infectedArea']) * 100).toStringAsFixed(0)}%'),
if (diagnosisResult.economicImpact['estimatedLoss'] != null && _safeToString(diagnosisResult.economicImpact['estimatedLoss']).isNotEmpty)
_buildDiagnosisInfoRow('Potensi Kerugian Ekonomi', _safeToString(diagnosisResult.economicImpact['estimatedLoss'])),
_buildDiagnosisListSection('Gejala yang Teramati', _safeToString(diagnosisResult.symptoms).split('\n')),
_buildDiagnosisListSection('Kemungkinan Penyebab', _safeToString(diagnosisResult.causes).split('\n')),
],
// Treatment and Prevention (if not healthy)
if (!diagnosisResult.isHealthy) ...[
_buildDiagnosisSectionTitle('Rekomendasi Penanganan & Pencegahan'),
_buildDiagnosisListSection('Penanganan Organik', _safeToString(diagnosisResult.organicTreatment).split('\n')),
_buildDiagnosisListSection('Penanganan Kimiawi', _safeToString(diagnosisResult.chemicalTreatment).split('\n')),
_buildDiagnosisListSection('Langkah Pencegahan', _safeToString(diagnosisResult.preventionMeasures).split('\n')),
],
// Environmental Data
if (diagnosisResult.environmentalData.isNotEmpty &&
diagnosisResult.environmentalData.values.any((v) => _safeToDouble(v) != 0.0)) ...[
_buildDiagnosisSectionTitle('Data Lingkungan Saat Pengambilan Gambar'),
if (_safeToDouble(diagnosisResult.environmentalData['temperature']) != 0.0)
_buildDiagnosisInfoRow('Suhu Udara', '${_safeToDouble(diagnosisResult.environmentalData['temperature']).toStringAsFixed(1)} °C'),
if (_safeToDouble(diagnosisResult.environmentalData['humidity']) != 0.0)
_buildDiagnosisInfoRow('Kelembaban Udara', '${_safeToDouble(diagnosisResult.environmentalData['humidity']).toStringAsFixed(0)} %'),
if (_safeToDouble(diagnosisResult.environmentalData['lightIntensity']) != 0.0)
_buildDiagnosisInfoRow('Intensitas Cahaya', '${_safeToDouble(diagnosisResult.environmentalData['lightIntensity']).toStringAsFixed(0)} lux'),
],
pw.SizedBox(height: 30),
pw.Text(
'Catatan: Laporan ini dihasilkan berdasarkan analisis gambar dan data yang diberikan. Validasi lapangan oleh ahli pertanian mungkin diperlukan untuk diagnosis yang lebih akurat dan tindakan yang lebih tepat.',
style: pw.TextStyle(fontSize: 9, fontStyle: pw.FontStyle.italic, color: PdfColors.grey700),
textAlign: pw.TextAlign.justify,
),
],
),
);
// Save the PDF using the appropriate helper based on platform
final bytes = await pdf.save();
return savePdfFile(fileName, bytes);
}
pw.Widget _buildDiagnosisSectionTitle(String title, {PdfColor color = PdfColors.green800, double fontSize = 16}) {
return pw.Padding(
padding: const pw.EdgeInsets.only(top: 15, bottom: 8),
child: pw.Text(
title.toUpperCase(),
style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: fontSize, color: color),
),
);
}
pw.Widget _buildDiagnosisInfoRow(String label, String value, {bool isBoldValue = false, pw.TextStyle? valueStyle}) {
return pw.Padding(
padding: const pw.EdgeInsets.symmetric(vertical: 2.5),
child: pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.SizedBox(width: 130, child: pw.Text(label, style: pw.TextStyle(fontWeight: pw.FontWeight.bold))),
pw.Text(': ', style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
pw.Expanded(child: pw.Text(value, style: valueStyle ?? (isBoldValue ? pw.TextStyle(fontWeight: pw.FontWeight.bold) : null))),
],
),
);
}
pw.Widget _buildDiagnosisListSection(String title, List<String> items, {PdfColor iconColor = PdfColors.green700}) {
if (items.isEmpty || items.every((item) => item.trim().isEmpty || item == '-')) {
return pw.Container();
}
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
_buildDiagnosisSectionTitle(title, fontSize: 14),
pw.SizedBox(height: 4),
...items.map((item) => pw.Padding(
padding: const pw.EdgeInsets.only(left: 10, bottom: 4),
child: pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text('', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: iconColor)),
pw.Expanded(child: pw.Text(item)),
],
),
)).toList(),
],
);
}
}