283 lines
8.7 KiB
Dart
283 lines
8.7 KiB
Dart
import 'dart:io';
|
||
import 'dart:typed_data';
|
||
|
||
import 'package:http/http.dart' as http;
|
||
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:share_plus/share_plus.dart' show ShareParams, SharePlus, XFile;
|
||
|
||
import '../models/auftrag.dart';
|
||
import '../models/auftrag_status.dart';
|
||
import '../models/dokument_typ.dart';
|
||
import '../models/zahlungs_status.dart';
|
||
import 'sepa_qr_data.dart';
|
||
|
||
class PdfExportService {
|
||
static final _datDe = DateFormat('dd.MM.yyyy');
|
||
|
||
static Future<File> buildPdf({
|
||
required Auftrag auftrag,
|
||
required List<Uint8List?> fotoBytes,
|
||
Uint8List? unterschriftBytes,
|
||
}) async {
|
||
final doc = pw.Document();
|
||
final nr = auftrag.rechnungsnummer.isEmpty
|
||
? 'Entwurf'
|
||
: auftrag.rechnungsnummer;
|
||
final docTitel = auftrag.dokumentTyp.pdfTitel;
|
||
final epc = SepaQrData.buildEpcString(auftrag);
|
||
|
||
doc.addPage(
|
||
pw.MultiPage(
|
||
pageFormat: PdfPageFormat.a4,
|
||
margin: const pw.EdgeInsets.all(40),
|
||
build: (ctx) => [
|
||
pw.Header(
|
||
level: 0,
|
||
child: pw.Text(
|
||
docTitel,
|
||
style: pw.TextStyle(
|
||
fontSize: 22,
|
||
fontWeight: pw.FontWeight.bold,
|
||
),
|
||
),
|
||
),
|
||
pw.SizedBox(height: 8),
|
||
pw.Text(
|
||
'Dokument-Nr.: $nr',
|
||
style: pw.TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: pw.FontWeight.bold,
|
||
),
|
||
),
|
||
pw.SizedBox(height: 6),
|
||
pw.Text(
|
||
'Bearbeitungsstatus: ${auftrag.status.labelDe} · Zahlung: '
|
||
'${auftrag.zahlungsStatus.labelDe}',
|
||
style: const pw.TextStyle(fontSize: 10),
|
||
),
|
||
pw.SizedBox(height: 16),
|
||
pw.Text(
|
||
'Leistung / Titel: ${auftrag.titel}',
|
||
style: const pw.TextStyle(fontSize: 14),
|
||
),
|
||
pw.SizedBox(height: 8),
|
||
pw.Text(
|
||
'Kunde: ${auftrag.kundenName}',
|
||
style: const pw.TextStyle(fontSize: 14),
|
||
),
|
||
if (auftrag.kundenAdresse.isNotEmpty) ...[
|
||
pw.SizedBox(height: 4),
|
||
pw.Text(
|
||
'Adresse: ${auftrag.kundenAdresse}',
|
||
style: const pw.TextStyle(fontSize: 11),
|
||
),
|
||
],
|
||
if (auftrag.kundenEmail.isNotEmpty) ...[
|
||
pw.SizedBox(height: 4),
|
||
pw.Text(
|
||
'E-Mail: ${auftrag.kundenEmail}',
|
||
style: const pw.TextStyle(fontSize: 11),
|
||
),
|
||
],
|
||
if (auftrag.ustIdKunde.trim().isNotEmpty) ...[
|
||
pw.SizedBox(height: 4),
|
||
pw.Text(
|
||
'USt-IdNr. Kunde: ${auftrag.ustIdKunde.trim()}',
|
||
style: const pw.TextStyle(fontSize: 11),
|
||
),
|
||
],
|
||
if (auftrag.leistungsDatum != null) ...[
|
||
pw.SizedBox(height: 8),
|
||
pw.Text(
|
||
'Leistungsdatum: ${_datDe.format(auftrag.leistungsDatum!)}',
|
||
style: const pw.TextStyle(fontSize: 11),
|
||
),
|
||
],
|
||
if (auftrag.faelligAm != null) ...[
|
||
pw.SizedBox(height: 4),
|
||
pw.Text(
|
||
'Zahlungsziel / Fälligkeit: ${_datDe.format(auftrag.faelligAm!)}',
|
||
style: const pw.TextStyle(fontSize: 11),
|
||
),
|
||
],
|
||
if (auftrag.betragText.isNotEmpty) ...[
|
||
pw.SizedBox(height: 12),
|
||
pw.Text(
|
||
'Betrag (Brutto): ${auftrag.betragText} €',
|
||
style: pw.TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: pw.FontWeight.bold,
|
||
),
|
||
),
|
||
],
|
||
if (auftrag.skontoText.trim().isNotEmpty) ...[
|
||
pw.SizedBox(height: 8),
|
||
pw.Text(
|
||
'Skonto / Zahlungsbedingungen: ${auftrag.skontoText.trim()}',
|
||
style: const pw.TextStyle(fontSize: 10),
|
||
),
|
||
],
|
||
pw.SizedBox(height: 12),
|
||
_steuerHinweiseBlock(auftrag),
|
||
pw.SizedBox(height: 16),
|
||
pw.Text(
|
||
'Beschreibung / Positionen:',
|
||
style: pw.TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: pw.FontWeight.bold,
|
||
),
|
||
),
|
||
pw.SizedBox(height: 6),
|
||
pw.Text(
|
||
auftrag.beschreibung.isEmpty ? '—' : auftrag.beschreibung,
|
||
style: const pw.TextStyle(fontSize: 11),
|
||
),
|
||
pw.SizedBox(height: 20),
|
||
if (fotoBytes.any((b) => b != null && b.isNotEmpty)) ...[
|
||
pw.Text(
|
||
'Fotos',
|
||
style: pw.TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: pw.FontWeight.bold,
|
||
),
|
||
),
|
||
pw.SizedBox(height: 8),
|
||
for (final b in fotoBytes)
|
||
if (b != null && b.isNotEmpty)
|
||
pw.Padding(
|
||
padding: const pw.EdgeInsets.only(bottom: 12),
|
||
child: pw.Image(
|
||
pw.MemoryImage(b),
|
||
fit: pw.BoxFit.contain,
|
||
height: 200,
|
||
),
|
||
),
|
||
],
|
||
if (unterschriftBytes != null && unterschriftBytes.isNotEmpty) ...[
|
||
pw.SizedBox(height: 16),
|
||
pw.Text(
|
||
'Unterschrift Kunde',
|
||
style: pw.TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: pw.FontWeight.bold,
|
||
),
|
||
),
|
||
pw.SizedBox(height: 8),
|
||
pw.Image(
|
||
pw.MemoryImage(unterschriftBytes),
|
||
height: 100,
|
||
fit: pw.BoxFit.contain,
|
||
),
|
||
],
|
||
if (epc != null) ...[
|
||
pw.SizedBox(height: 24),
|
||
pw.Text(
|
||
'SEPA-Zahlung (QR-Code für Banking-App)',
|
||
style: pw.TextStyle(
|
||
fontSize: 11,
|
||
fontWeight: pw.FontWeight.bold,
|
||
),
|
||
),
|
||
pw.SizedBox(height: 8),
|
||
pw.BarcodeWidget(
|
||
data: epc,
|
||
barcode: pw.Barcode.qrCode(),
|
||
drawText: false,
|
||
width: 132,
|
||
height: 132,
|
||
),
|
||
],
|
||
pw.SizedBox(height: 28),
|
||
pw.Text(
|
||
'Hinweis: Dieses Dokument dient der Dokumentation und ersetzt keine '
|
||
'steuerliche oder rechtliche Beratung.',
|
||
style: pw.TextStyle(fontSize: 8, color: PdfColors.grey700),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
final bytes = await doc.save();
|
||
final dir = await getTemporaryDirectory();
|
||
final safeNr = nr.replaceAll(RegExp(r'[^\w\-]'), '_');
|
||
final name =
|
||
'rechnung_${safeNr.isEmpty ? auftrag.id.substring(0, 8) : safeNr}.pdf';
|
||
final file = File('${dir.path}/$name');
|
||
await file.writeAsBytes(bytes);
|
||
return file;
|
||
}
|
||
|
||
static pw.Widget _steuerHinweiseBlock(Auftrag a) {
|
||
final parts = <pw.Widget>[];
|
||
if (a.kleinunternehmer) {
|
||
parts.add(
|
||
pw.Text(
|
||
'§19 UStG: Als Kleinunternehmer wird keine Umsatzsteuer berechnet.',
|
||
style: const pw.TextStyle(fontSize: 9),
|
||
),
|
||
);
|
||
}
|
||
if (a.reverseCharge) {
|
||
parts.add(
|
||
pw.Text(
|
||
'Steuerschuldnerschaft des Leistungsempfängers (Reverse Charge) – '
|
||
'Umsatzsteuer liegt bei Ihnen.',
|
||
style: const pw.TextStyle(fontSize: 9),
|
||
),
|
||
);
|
||
}
|
||
if (parts.isEmpty) {
|
||
return pw.SizedBox(height: 0);
|
||
}
|
||
return pw.Column(
|
||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||
children: [
|
||
pw.Text(
|
||
'Steuerliche Hinweise',
|
||
style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold),
|
||
),
|
||
pw.SizedBox(height: 4),
|
||
...parts.expand((w) => [w, pw.SizedBox(height: 2)]),
|
||
],
|
||
);
|
||
}
|
||
|
||
static Future<Uint8List?> ladeUrl(String url) async {
|
||
try {
|
||
final r = await http.get(Uri.parse(url));
|
||
if (r.statusCode == 200) return r.bodyBytes;
|
||
} catch (_) {}
|
||
return null;
|
||
}
|
||
|
||
/// HTTP-URL oder Appwrite-Datei-ID (über [fileLoader]).
|
||
static Future<Uint8List?> ladeReferenz(
|
||
String ref, {
|
||
Future<Uint8List?> Function(String fileId)? fileLoader,
|
||
}) async {
|
||
if (ref.startsWith('http://') || ref.startsWith('https://')) {
|
||
return ladeUrl(ref);
|
||
}
|
||
if (fileLoader != null) {
|
||
try {
|
||
return await fileLoader(ref);
|
||
} catch (_) {}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
static Future<void> teilen(File file, {String? rechnungsnummer}) async {
|
||
await SharePlus.instance.share(
|
||
ShareParams(
|
||
files: [XFile(file.path)],
|
||
subject: rechnungsnummer != null && rechnungsnummer.isNotEmpty
|
||
? 'Rechnung $rechnungsnummer'
|
||
: 'Rechnung PDF',
|
||
),
|
||
);
|
||
}
|
||
}
|