import 'package:flutter/services.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'report_export.dart';
class FlutterPdfReportRenderer implements PdfReportRenderer {
const FlutterPdfReportRenderer({this.regularFontData, this.boldFontData});
final ByteData? regularFontData;
final ByteData? boldFontData;
static const _green = PdfColor.fromInt(0xff176b4d);
static const _ink = PdfColor.fromInt(0xff17211d);
static const _muted = PdfColor.fromInt(0xff66736d);
static const _canvas = PdfColor.fromInt(0xfff1f5f2);
@override
String get format => 'PDF';
@override
String get mimeType => 'application/pdf';
@override
String fileName(ReportDocument document) {
final date = document.report.date.toIso8601String().split('T').first;
final site = document.report.site.replaceAll(
RegExp(r'[^a-zA-Z0-9äöüÄÖÜß]+'),
'_',
);
return 'MACHWEIS_${date}_$site.pdf';
}
@override
Future<List<int>> export(ReportDocument document) async {
final regular = pw.Font.ttf(
regularFontData ??
await rootBundle.load('assets/fonts/Roboto-Regular.ttf'),
);
final bold = pw.Font.ttf(
boldFontData ?? await rootBundle.load('assets/fonts/Roboto-Bold.ttf'),
);
final pdf = pw.Document(
title: 'MACHWEIS - ${document.report.site}',
author: document.companyName,
subject: 'Leistungsnachweis Version ${document.report.version}',
creator: 'MACHWEIS',
);
final report = document.report;
pdf.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.fromLTRB(42, 38, 42, 46),
theme: pw.ThemeData.withFont(base: regular, bold: bold),
header: (context) => pw.Padding(
padding: const pw.EdgeInsets.only(bottom: 18),
child: pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Container(width: 7, height: 42, color: _green),
pw.SizedBox(width: 12),
pw.Expanded(
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
document.companyName.isEmpty
? 'MACHWEIS'
: document.companyName,
style: pw.TextStyle(
fontSize: 16,
fontWeight: pw.FontWeight.bold,
color: _ink,
),
),
if (document.companyAddress.isNotEmpty)
pw.Text(
document.companyAddress,
style: const pw.TextStyle(fontSize: 8.5, color: _muted),
),
if (document.companyContact.isNotEmpty)
pw.Text(
document.companyContact,
style: const pw.TextStyle(fontSize: 8.5, color: _muted),
),
],
),
),
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Text(
'LEISTUNGSNACHWEIS',
style: pw.TextStyle(
fontSize: 15,
fontWeight: pw.FontWeight.bold,
color: _green,
),
),
pw.Text(
'Version ${report.version}',
style: const pw.TextStyle(fontSize: 9, color: _muted),
),
],
),
],
),
),
footer: (context) => pw.Container(
padding: const pw.EdgeInsets.only(top: 10),
decoration: const pw.BoxDecoration(
border: pw.Border(top: pw.BorderSide(color: PdfColors.grey300)),
),
child: pw.Row(
children: [
pw.Expanded(
child: pw.Text(
document.reportFooter.isEmpty
? 'Digital erstellt mit MACHWEIS'
: document.reportFooter,
style: const pw.TextStyle(fontSize: 8, color: _muted),
),
),
pw.Text(
'Seite ${context.pageNumber} von ${context.pagesCount}',
style: const pw.TextStyle(fontSize: 8, color: _muted),
),
],
),
),
build: (context) => [
_statusBanner(document),
pw.SizedBox(height: 18),
_metaGrid(document),
pw.SizedBox(height: 22),
_sectionTitle('Ausgeführte Leistungen'),
pw.SizedBox(height: 8),
_activityTable(document),
if (report.materialItems.isNotEmpty) ...[
pw.SizedBox(height: 20),
_sectionTitle('Material'),
pw.SizedBox(height: 8),
_materialTable(document),
],
if (report.additionalOrder.isNotEmpty) ...[
pw.SizedBox(height: 20),
_sectionTitle('Zusatzauftrag'),
pw.SizedBox(height: 8),
pw.Container(
width: double.infinity,
padding: const pw.EdgeInsets.all(12),
decoration: pw.BoxDecoration(
color: _canvas,
borderRadius: pw.BorderRadius.circular(6),
),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(report.additionalOrder),
pw.SizedBox(height: 5),
pw.Text(
report.additionalOrderApproved
? 'Vom Kunden bestätigt'
: 'Bestätigung ausstehend',
style: pw.TextStyle(
fontSize: 9,
fontWeight: pw.FontWeight.bold,
color: report.additionalOrderApproved
? _green
: PdfColors.orange800,
),
),
],
),
),
],
if (document.photos.isNotEmpty) ...[
pw.SizedBox(height: 20),
_sectionTitle('Fotodokumentation'),
pw.SizedBox(height: 8),
pw.Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final photo in document.photos)
pw.Container(
width: 150,
height: 106,
decoration: pw.BoxDecoration(
border: pw.Border.all(color: PdfColors.grey300),
borderRadius: pw.BorderRadius.circular(5),
),
child: pw.ClipRRect(
horizontalRadius: 5,
verticalRadius: 5,
child: pw.Image(
pw.MemoryImage(photo.bytes),
fit: pw.BoxFit.cover,
),
),
),
],
),
],
if (report.isFinalized) ...[
pw.SizedBox(height: 22),
_sectionTitle('Kundenbestätigung'),
pw.SizedBox(height: 8),
pw.Container(
width: double.infinity,
padding: const pw.EdgeInsets.all(12),
decoration: pw.BoxDecoration(
border: pw.Border.all(color: PdfColors.grey300),
borderRadius: pw.BorderRadius.circular(6),
),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
'${report.customer} bestätigt die aufgeführten Leistungen und Arbeitszeiten.',
style: const pw.TextStyle(fontSize: 9),
),
pw.SizedBox(height: 8),
if (report.signature.isNotEmpty)
pw.SizedBox(
height: 72,
child: pw.SvgImage(svg: _signatureSvg(report.signature)),
),
pw.Container(height: 1, color: PdfColors.grey500),
pw.SizedBox(height: 5),
pw.Text(
'Unterschrift ${report.customer} - ${_dateTime(report.finalizedAt)}',
style: const pw.TextStyle(fontSize: 8, color: _muted),
),
],
),
),
],
],
),
);
return await pdf.save();
}
pw.Widget _statusBanner(ReportDocument document) {
final report = document.report;
return pw.Container(
padding: const pw.EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: pw.BoxDecoration(
color: report.isFinalized
? _canvas
: const PdfColor.fromInt(0xfffff4e5),
borderRadius: pw.BorderRadius.circular(7),
),
child: pw.Row(
children: [
pw.Expanded(
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
report.site,
style: pw.TextStyle(
fontSize: 18,
fontWeight: pw.FontWeight.bold,
color: _ink,
),
),
pw.Text(
report.customer,
style: const pw.TextStyle(fontSize: 10, color: _muted),
),
],
),
),
pw.Text(
report.isFinalized ? 'ABGESCHLOSSEN' : 'ENTWURF',
style: pw.TextStyle(
fontSize: 9,
fontWeight: pw.FontWeight.bold,
color: report.isFinalized ? _green : PdfColors.orange800,
),
),
],
),
);
}
pw.Widget _metaGrid(ReportDocument document) {
final report = document.report;
return pw.Table(
columnWidths: const {0: pw.FlexColumnWidth(1), 1: pw.FlexColumnWidth(1)},
children: [
pw.TableRow(
children: [
_metaCell('Leistungsdatum', _date(report.date)),
_metaCell(
'Arbeitszeit',
'${_time(report.start.hour, report.start.minute)} - ${_time(report.end.hour, report.end.minute)}',
),
],
),
pw.TableRow(
children: [
_metaCell('Pause', '${report.breakMinutes} Minuten'),
_metaCell('Nettoarbeitszeit', _duration(report.netMinutes)),
],
),
pw.TableRow(
children: [
_metaCell(
'Beteiligte Mitarbeiter',
document.participantNames.join(', '),
),
_metaCell('Nachweis-ID', report.id),
],
),
],
);
}
pw.Widget _metaCell(String label, String value) => pw.Padding(
padding: const pw.EdgeInsets.fromLTRB(0, 0, 12, 10),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
label.toUpperCase(),
style: pw.TextStyle(
fontSize: 7.5,
fontWeight: pw.FontWeight.bold,
color: _green,
),
),
pw.SizedBox(height: 2),
pw.Text(
value.isEmpty ? '-' : value,
style: const pw.TextStyle(fontSize: 9.5),
),
],
),
);
pw.Widget _sectionTitle(String text) => pw.Text(
text,
style: pw.TextStyle(
fontSize: 12,
fontWeight: pw.FontWeight.bold,
color: _ink,
),
);
pw.Widget _activityTable(
ReportDocument document,
) => pw.TableHelper.fromTextArray(
headerStyle: pw.TextStyle(
fontWeight: pw.FontWeight.bold,
color: PdfColors.white,
),
headerDecoration: const pw.BoxDecoration(color: _green),
cellStyle: const pw.TextStyle(fontSize: 9),
cellPadding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 7),
border: pw.TableBorder.all(color: PdfColors.grey300, width: 0.5),
columnWidths: const {0: pw.FixedColumnWidth(34), 1: pw.FlexColumnWidth()},
headers: const ['Nr.', 'Beschreibung'],
data: [
for (var index = 0; index < document.report.activityItems.length; index++)
['${index + 1}', document.report.activityItems[index]],
],
);
pw.Widget _materialTable(ReportDocument document) =>
pw.TableHelper.fromTextArray(
headerStyle: pw.TextStyle(
fontWeight: pw.FontWeight.bold,
color: PdfColors.white,
),
headerDecoration: const pw.BoxDecoration(color: _green),
cellStyle: const pw.TextStyle(fontSize: 9),
cellPadding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 7),
border: pw.TableBorder.all(color: PdfColors.grey300, width: 0.5),
columnWidths: const {
0: pw.FixedColumnWidth(34),
1: pw.FlexColumnWidth(),
2: pw.FixedColumnWidth(58),
3: pw.FixedColumnWidth(58),
},
headers: const ['Nr.', 'Material', 'Menge', 'Einheit'],
data: [
for (
var index = 0;
index < document.report.materialItems.length;
index++
)
[
'${index + 1}',
document.report.materialItems[index].name,
document.report.materialItems[index].quantity,
document.report.materialItems[index].unit,
],
],
);
String _signatureSvg(List<List<double>> points) {
final path = StringBuffer();
var startsStroke = true;
for (final point in points) {
if (point.length < 2 || point[0] < 0) {
startsStroke = true;
continue;
}
final x = (point[0] * 500).clamp(0, 500).toStringAsFixed(1);
final y = (point[1] * 120).clamp(0, 120).toStringAsFixed(1);
path.write('${startsStroke ? 'M' : 'L'}$x $y ');
startsStroke = false;
}
return '<svg viewBox="0 0 500 120" xmlns="http://www.w3.org/2000/svg"><path d="$path" fill="none" stroke="#17211d" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>';
}
String _time(int hour, int minute) =>
'${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}';
String _duration(int minutes) =>
'${minutes ~/ 60}:${(minutes % 60).toString().padLeft(2, '0')} Stunden';
String _date(DateTime value) =>
'${value.day.toString().padLeft(2, '0')}.${value.month.toString().padLeft(2, '0')}.${value.year}';
String _dateTime(DateTime? value) => value == null
? '-'
: '${_date(value)} ${_time(value.hour, value.minute)} Uhr';
}
