Feature
ein paar feature aber datenbank macht probleme wenn man aufträge speichern möchge
This commit is contained in:
16
services/app_preferences.dart
Normal file
16
services/app_preferences.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class AppPreferences {
|
||||
AppPreferences._();
|
||||
static const _onboardingKey = 'handwerkpro_onboarding_done_v1';
|
||||
|
||||
static Future<bool> isOnboardingDone() async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
return p.getBool(_onboardingKey) ?? false;
|
||||
}
|
||||
|
||||
static Future<void> setOnboardingDone() async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
await p.setBool(_onboardingKey, true);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,89 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
// Appwrite 1.8+ empfiehlt TablesDB; klassische Collections laufen weiter über Databases.
|
||||
// ignore_for_file: deprecated_member_use
|
||||
|
||||
import 'package:appwrite/appwrite.dart';
|
||||
|
||||
import '../appwrite_config.dart';
|
||||
import '../models/auftrag.dart';
|
||||
|
||||
class AuftragRepository {
|
||||
AuftragRepository({FirebaseFirestore? firestore, FirebaseAuth? auth})
|
||||
: _db = firestore ?? FirebaseFirestore.instance,
|
||||
_auth = auth ?? FirebaseAuth.instance;
|
||||
AuftragRepository({Client? client})
|
||||
: _db = Databases(client ?? appwriteClient),
|
||||
_account = Account(client ?? appwriteClient);
|
||||
|
||||
final FirebaseFirestore _db;
|
||||
final FirebaseAuth _auth;
|
||||
final Databases _db;
|
||||
final Account _account;
|
||||
|
||||
CollectionReference<Map<String, dynamic>> get _col {
|
||||
final uid = _auth.currentUser?.uid;
|
||||
if (uid == null) {
|
||||
throw StateError('Nicht angemeldet');
|
||||
}
|
||||
return _db.collection('users').doc(uid).collection('auftraege');
|
||||
Future<String> _uid() async {
|
||||
final u = await _account.get();
|
||||
return u.$id;
|
||||
}
|
||||
|
||||
Stream<List<Auftrag>> watchAuftraege() {
|
||||
return _col.orderBy('createdAt', descending: true).snapshots().map(
|
||||
(snap) => snap.docs.map(Auftrag.fromDoc).toList(),
|
||||
);
|
||||
List<String> _docPermissions(String uid) => [
|
||||
Permission.read(Role.user(uid)),
|
||||
Permission.update(Role.user(uid)),
|
||||
Permission.delete(Role.user(uid)),
|
||||
];
|
||||
|
||||
Future<List<Auftrag>> listAuftraege() async {
|
||||
final uid = await _uid();
|
||||
final res = await _db.listDocuments(
|
||||
databaseId: kAppwriteDatabaseId,
|
||||
collectionId: kAppwriteCollectionId,
|
||||
queries: [
|
||||
Query.equal('userId', uid),
|
||||
Query.orderDesc(r'$createdAt'),
|
||||
],
|
||||
);
|
||||
return res.documents.map(Auftrag.fromAppwriteDoc).toList();
|
||||
}
|
||||
|
||||
Future<Auftrag?> get(String id) async {
|
||||
final doc = await _col.doc(id).get();
|
||||
if (!doc.exists) return null;
|
||||
return Auftrag.fromDoc(doc);
|
||||
try {
|
||||
final doc = await _db.getDocument(
|
||||
databaseId: kAppwriteDatabaseId,
|
||||
collectionId: kAppwriteCollectionId,
|
||||
documentId: id,
|
||||
);
|
||||
return Auftrag.fromAppwriteDoc(doc);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Neue Dokument-ID ohne Schreibzugriff (für Storage-Pfade vor erstem Speichern).
|
||||
String neueId() => _col.doc().id;
|
||||
String neueId() => ID.unique();
|
||||
|
||||
Future<void> speichern({
|
||||
required String id,
|
||||
required Auftrag daten,
|
||||
bool isNeu = false,
|
||||
}) async {
|
||||
final payload = daten.toMap();
|
||||
final uid = await _uid();
|
||||
final payload = Map<String, dynamic>.from(daten.toMap());
|
||||
payload['userId'] = uid;
|
||||
if (isNeu) {
|
||||
payload['createdAt'] = FieldValue.serverTimestamp();
|
||||
await _db.createDocument(
|
||||
databaseId: kAppwriteDatabaseId,
|
||||
collectionId: kAppwriteCollectionId,
|
||||
documentId: id,
|
||||
data: payload,
|
||||
permissions: _docPermissions(uid),
|
||||
);
|
||||
} else {
|
||||
await _db.updateDocument(
|
||||
databaseId: kAppwriteDatabaseId,
|
||||
collectionId: kAppwriteCollectionId,
|
||||
documentId: id,
|
||||
data: payload,
|
||||
);
|
||||
}
|
||||
await _col.doc(id).set(payload, SetOptions(merge: true));
|
||||
}
|
||||
|
||||
Future<void> loeschen(String id) => _col.doc(id).delete();
|
||||
Future<void> loeschen(String id) async {
|
||||
await _db.deleteDocument(
|
||||
databaseId: kAppwriteDatabaseId,
|
||||
collectionId: kAppwriteCollectionId,
|
||||
documentId: id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,81 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:firebase_storage/firebase_storage.dart';
|
||||
import 'package:appwrite/appwrite.dart';
|
||||
|
||||
import '../appwrite_config.dart';
|
||||
|
||||
class AuftragStorageService {
|
||||
AuftragStorageService({FirebaseStorage? storage, FirebaseAuth? auth})
|
||||
: _storage = storage ?? FirebaseStorage.instance,
|
||||
_auth = auth ?? FirebaseAuth.instance;
|
||||
AuftragStorageService({Client? client})
|
||||
: _storage = Storage(client ?? appwriteClient),
|
||||
_account = Account(client ?? appwriteClient);
|
||||
|
||||
final FirebaseStorage _storage;
|
||||
final FirebaseAuth _auth;
|
||||
final Storage _storage;
|
||||
final Account _account;
|
||||
|
||||
Reference _basis(String auftragId) {
|
||||
final uid = _auth.currentUser?.uid;
|
||||
if (uid == null) throw StateError('Nicht angemeldet');
|
||||
return _storage.ref('users/$uid/auftraege/$auftragId');
|
||||
Future<String> _uid() async {
|
||||
final u = await _account.get();
|
||||
return u.$id;
|
||||
}
|
||||
|
||||
Future<String> hochladenFoto(String auftragId, Uint8List bytes, String dateiname) async {
|
||||
final ref = _basis(auftragId).child('fotos/$dateiname');
|
||||
await ref.putData(
|
||||
bytes,
|
||||
SettableMetadata(contentType: 'image/jpeg'),
|
||||
List<String> _filePermissions(String uid) => [
|
||||
Permission.read(Role.user(uid)),
|
||||
Permission.update(Role.user(uid)),
|
||||
Permission.delete(Role.user(uid)),
|
||||
];
|
||||
|
||||
Future<String> hochladenFoto(
|
||||
String auftragId,
|
||||
Uint8List bytes,
|
||||
String dateiname,
|
||||
) async {
|
||||
final uid = await _uid();
|
||||
final fileId = ID.unique();
|
||||
await _storage.createFile(
|
||||
bucketId: kAppwriteBucketId,
|
||||
fileId: fileId,
|
||||
file: InputFile.fromBytes(
|
||||
bytes: bytes,
|
||||
filename: dateiname,
|
||||
contentType: 'image/jpeg',
|
||||
),
|
||||
permissions: _filePermissions(uid),
|
||||
);
|
||||
return ref.getDownloadURL();
|
||||
return fileId;
|
||||
}
|
||||
|
||||
Future<String> hochladenUnterschrift(String auftragId, Uint8List pngBytes) async {
|
||||
final ref = _basis(auftragId).child('unterschrift.png');
|
||||
await ref.putData(
|
||||
pngBytes,
|
||||
SettableMetadata(contentType: 'image/png'),
|
||||
final uid = await _uid();
|
||||
final fileId = ID.unique();
|
||||
await _storage.createFile(
|
||||
bucketId: kAppwriteBucketId,
|
||||
fileId: fileId,
|
||||
file: InputFile.fromBytes(
|
||||
bytes: pngBytes,
|
||||
filename: 'unterschrift_$auftragId.png',
|
||||
contentType: 'image/png',
|
||||
),
|
||||
permissions: _filePermissions(uid),
|
||||
);
|
||||
return ref.getDownloadURL();
|
||||
return fileId;
|
||||
}
|
||||
|
||||
Future<Uint8List> getFileBytes(String fileId) async {
|
||||
return _storage.getFileView(
|
||||
bucketId: kAppwriteBucketId,
|
||||
fileId: fileId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Entfernt die Datei im Bucket (kein Fehler bei HTTP-URLs oder fehlenden Rechten).
|
||||
Future<void> loescheDatei(String fileId) async {
|
||||
if (fileId.startsWith('http://') || fileId.startsWith('https://')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await _storage.deleteFile(
|
||||
bucketId: kAppwriteBucketId,
|
||||
fileId: fileId,
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
96
services/data_export_service.dart
Normal file
96
services/data_export_service.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../models/auftrag.dart';
|
||||
import '../models/auftrag_status.dart';
|
||||
import '../models/dokument_typ.dart';
|
||||
import '../models/zahlungs_status.dart';
|
||||
|
||||
/// Export der Auftragsdaten als JSON (ohne Binärdaten der Fotos/Unterschrift).
|
||||
class DataExportService {
|
||||
DataExportService._();
|
||||
|
||||
static String _csvZelle(String? s) {
|
||||
final t = (s ?? '').replaceAll('"', '""');
|
||||
if (t.contains(';') || t.contains('\n') || t.contains('"')) {
|
||||
return '"$t"';
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
/// Semikolon-CSV (Excel DE) – grobe Vorlage für Steuerbüro / DATEV-Vorbereitung.
|
||||
static Future<File> exportAuftraegeCsvToTempFile(List<Auftrag> list) async {
|
||||
final header = [
|
||||
'Rechnungsnummer',
|
||||
'Dokumenttyp',
|
||||
'Titel',
|
||||
'Kunde',
|
||||
'Adresse',
|
||||
'E-Mail',
|
||||
'Betrag_Text',
|
||||
'Status',
|
||||
'Zahlungsstatus',
|
||||
'Faellig_am',
|
||||
'Leistungsdatum',
|
||||
'Kleinunternehmer',
|
||||
'Reverse_Charge',
|
||||
'Skonto',
|
||||
'USt_Id_Kunde',
|
||||
'IBAN',
|
||||
'BIC',
|
||||
'Kontoinhaber',
|
||||
'Erstellt',
|
||||
];
|
||||
final sb = StringBuffer()
|
||||
..writeln(header.map(_csvZelle).join(';'));
|
||||
for (final a in list) {
|
||||
sb.writeln(
|
||||
[
|
||||
a.rechnungsnummer,
|
||||
a.dokumentTyp.labelDe,
|
||||
a.titel,
|
||||
a.kundenName,
|
||||
a.kundenAdresse,
|
||||
a.kundenEmail,
|
||||
a.betragText,
|
||||
a.status.labelDe,
|
||||
a.zahlungsStatus.labelDe,
|
||||
a.faelligAm?.toIso8601String() ?? '',
|
||||
a.leistungsDatum?.toIso8601String() ?? '',
|
||||
a.kleinunternehmer ? 'ja' : 'nein',
|
||||
a.reverseCharge ? 'ja' : 'nein',
|
||||
a.skontoText,
|
||||
a.ustIdKunde,
|
||||
a.ibanVerkaeufer,
|
||||
a.bicVerkaeufer,
|
||||
a.kontoinhaberVerkaeufer,
|
||||
a.createdAt?.toIso8601String() ?? '',
|
||||
].map(_csvZelle).join(';'),
|
||||
);
|
||||
}
|
||||
final dir = await getTemporaryDirectory();
|
||||
final name =
|
||||
'handwerkpro_export_${DateTime.now().millisecondsSinceEpoch}.csv';
|
||||
final file = File('${dir.path}/$name');
|
||||
await file.writeAsString(sb.toString(), encoding: utf8);
|
||||
return file;
|
||||
}
|
||||
|
||||
static Future<File> exportAuftraegeToTempFile(List<Auftrag> list) async {
|
||||
final payload = {
|
||||
'exportVersion': 4,
|
||||
'exportedAt': DateTime.now().toIso8601String(),
|
||||
'count': list.length,
|
||||
'auftraege': list.map((a) => a.toExportMap()).toList(),
|
||||
};
|
||||
final json = const JsonEncoder.withIndent(' ').convert(payload);
|
||||
final dir = await getTemporaryDirectory();
|
||||
final name =
|
||||
'handwerkpro_export_${DateTime.now().millisecondsSinceEpoch}.json';
|
||||
final file = File('${dir.path}/$name');
|
||||
await file.writeAsString(json);
|
||||
return file;
|
||||
}
|
||||
}
|
||||
29
services/german_amount_parser.dart
Normal file
29
services/german_amount_parser.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
/// Parst typische deutsche Betragsangaben (z. B. `1.234,56`) für QR / Logik.
|
||||
class GermanAmountParser {
|
||||
GermanAmountParser._();
|
||||
|
||||
/// Liefert den Betrag in Euro als [double], oder `null` wenn nicht erkennbar.
|
||||
static double? parseEuro(String raw) {
|
||||
var s = raw.trim();
|
||||
if (s.isEmpty) return null;
|
||||
s = s.replaceAll(RegExp(r'\s'), '');
|
||||
s = s.replaceAll('€', '').replaceAll('EUR', '').trim();
|
||||
if (s.isEmpty) return null;
|
||||
if (s.contains(',')) {
|
||||
s = s.replaceAll('.', '').replaceAll(',', '.');
|
||||
} else {
|
||||
final dotCount = '.'.allMatches(s).length;
|
||||
if (dotCount > 1) {
|
||||
s = s.replaceAll('.', '');
|
||||
}
|
||||
}
|
||||
return double.tryParse(s);
|
||||
}
|
||||
|
||||
/// Betrag für SEPA-QR: `EUR12.34` (Punkt, zwei Nachkommastellen).
|
||||
static String? formatForSepaQr(String betragText) {
|
||||
final v = parseEuro(betragText);
|
||||
if (v == null || v <= 0) return null;
|
||||
return 'EUR${v.toStringAsFixed(2)}';
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,21 @@ 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,
|
||||
@@ -19,6 +26,8 @@ class PdfExportService {
|
||||
final nr = auftrag.rechnungsnummer.isEmpty
|
||||
? 'Entwurf'
|
||||
: auftrag.rechnungsnummer;
|
||||
final docTitel = auftrag.dokumentTyp.pdfTitel;
|
||||
final epc = SepaQrData.buildEpcString(auftrag);
|
||||
|
||||
doc.addPage(
|
||||
pw.MultiPage(
|
||||
@@ -28,7 +37,7 @@ class PdfExportService {
|
||||
pw.Header(
|
||||
level: 0,
|
||||
child: pw.Text(
|
||||
'Rechnung',
|
||||
docTitel,
|
||||
style: pw.TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
@@ -36,36 +45,91 @@ class PdfExportService {
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Text('Rechnungs-Nr.: $nr',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
)),
|
||||
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.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)),
|
||||
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)),
|
||||
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,
|
||||
)),
|
||||
],
|
||||
pw.SizedBox(height: 16),
|
||||
pw.Text('Beschreibung / Positionen:',
|
||||
pw.Text(
|
||||
'Betrag (Brutto): ${auftrag.betragText} €',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 12,
|
||||
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,
|
||||
@@ -73,11 +137,13 @@ class PdfExportService {
|
||||
),
|
||||
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.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)
|
||||
@@ -92,11 +158,13 @@ class PdfExportService {
|
||||
],
|
||||
if (unterschriftBytes != null && unterschriftBytes.isNotEmpty) ...[
|
||||
pw.SizedBox(height: 16),
|
||||
pw.Text('Unterschrift Kunde',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
)),
|
||||
pw.Text(
|
||||
'Unterschrift Kunde',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Image(
|
||||
pw.MemoryImage(unterschriftBytes),
|
||||
@@ -104,6 +172,30 @@ class PdfExportService {
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -111,12 +203,48 @@ class PdfExportService {
|
||||
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 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));
|
||||
@@ -125,6 +253,22 @@ class PdfExportService {
|
||||
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(
|
||||
|
||||
76
services/pdf_history_service.dart
Normal file
76
services/pdf_history_service.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class PdfHistoryEntry {
|
||||
PdfHistoryEntry({
|
||||
required this.at,
|
||||
required this.title,
|
||||
required this.rechnungsnummer,
|
||||
});
|
||||
|
||||
final DateTime at;
|
||||
final String title;
|
||||
final String rechnungsnummer;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'at': at.toIso8601String(),
|
||||
'title': title,
|
||||
'rechnungsnummer': rechnungsnummer,
|
||||
};
|
||||
|
||||
factory PdfHistoryEntry.fromJson(Map<String, dynamic> m) {
|
||||
return PdfHistoryEntry(
|
||||
at: DateTime.tryParse(m['at'] as String? ?? '') ?? DateTime.now(),
|
||||
title: m['title'] as String? ?? '',
|
||||
rechnungsnummer: m['rechnungsnummer'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Lokaler Verlauf geteilter/erzeugter PDFs (nur Metadaten, keine Dateiablage).
|
||||
class PdfHistoryService {
|
||||
PdfHistoryService._();
|
||||
static const _key = 'handwerkpro_pdf_history_v1';
|
||||
static const _max = 30;
|
||||
|
||||
static Future<List<PdfHistoryEntry>> load() async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
final raw = p.getString(_key);
|
||||
if (raw == null || raw.isEmpty) return [];
|
||||
try {
|
||||
final list = jsonDecode(raw) as List<dynamic>;
|
||||
return list
|
||||
.map((e) => PdfHistoryEntry.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||
.toList();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> add({
|
||||
required String title,
|
||||
required String rechnungsnummer,
|
||||
}) async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
final existing = await load();
|
||||
existing.insert(
|
||||
0,
|
||||
PdfHistoryEntry(
|
||||
at: DateTime.now(),
|
||||
title: title,
|
||||
rechnungsnummer: rechnungsnummer,
|
||||
),
|
||||
);
|
||||
final trimmed = existing.take(_max).toList();
|
||||
await p.setString(
|
||||
_key,
|
||||
jsonEncode(trimmed.map((e) => e.toJson()).toList()),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> clear() async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
await p.remove(_key);
|
||||
}
|
||||
}
|
||||
46
services/position_from_text_parser.dart
Normal file
46
services/position_from_text_parser.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
/// Heuristik: Freitext wie „2h Elektro Küche“ → Zeile für die Positionsbeschreibung.
|
||||
///
|
||||
/// Kein externes KI-Modell – nur Muster, die auf der Baustelle häufig vorkommen.
|
||||
class PositionFromTextParser {
|
||||
PositionFromTextParser._();
|
||||
|
||||
/// Liefert einen Vorschlag für eine neue Zeile (mehrzeilig anfügbar).
|
||||
static String? vorschlagZeile(String raw) {
|
||||
final s = raw.trim();
|
||||
if (s.isEmpty) return null;
|
||||
|
||||
final stunden = RegExp(
|
||||
r'^(\d+(?:[.,]\d+)?)\s*h(?:\s+|$)',
|
||||
caseSensitive: false,
|
||||
).firstMatch(s);
|
||||
if (stunden != null) {
|
||||
final h = stunden.group(1)!.replaceAll(',', '.');
|
||||
final rest = s.substring(stunden.end).trim();
|
||||
final leistung = rest.isEmpty ? 'Arbeitsleistung' : rest;
|
||||
return '${h}h – $leistung';
|
||||
}
|
||||
|
||||
final euro = RegExp(
|
||||
r'^(\d+(?:[.,]\d+)?)\s*€',
|
||||
caseSensitive: false,
|
||||
).firstMatch(s);
|
||||
if (euro != null) {
|
||||
final betrag = euro.group(1)!.replaceAll('.', '').replaceAll(',', '.');
|
||||
final rest = s.substring(euro.end).trim();
|
||||
final leistung = rest.isEmpty ? 'Material / Leistung' : rest;
|
||||
return '$leistung – $betrag€ (netto/brutto lt. Vereinbarung)';
|
||||
}
|
||||
|
||||
final mal = RegExp(
|
||||
r'^(\d+)\s*x\s*',
|
||||
caseSensitive: false,
|
||||
).firstMatch(s);
|
||||
if (mal != null) {
|
||||
final n = mal.group(1)!;
|
||||
final rest = s.substring(mal.end).trim();
|
||||
if (rest.isNotEmpty) return '$n× $rest';
|
||||
}
|
||||
|
||||
return '• $s';
|
||||
}
|
||||
}
|
||||
46
services/sepa_qr_data.dart
Normal file
46
services/sepa_qr_data.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
import '../models/auftrag.dart';
|
||||
import 'german_amount_parser.dart';
|
||||
|
||||
/// EPC / GiroCode-kompatibler QR-Payload (SEPA Credit Transfer, Version 002).
|
||||
///
|
||||
/// Siehe [EPC069-12](https://www.europeanpaymentscouncil.eu/document-library/).
|
||||
class SepaQrData {
|
||||
SepaQrData._();
|
||||
|
||||
static String? buildEpcString(Auftrag auftrag) {
|
||||
final iban = _normalizeIban(auftrag.ibanVerkaeufer);
|
||||
if (iban.isEmpty) return null;
|
||||
final name = auftrag.kontoinhaberVerkaeufer.trim().isEmpty
|
||||
? 'Empfänger'
|
||||
: _truncate(auftrag.kontoinhaberVerkaeufer.trim(), 70);
|
||||
final bic = auftrag.bicVerkaeufer.trim().replaceAll(' ', '');
|
||||
final amount = GermanAmountParser.formatForSepaQr(auftrag.betragText);
|
||||
if (amount == null) return null;
|
||||
final nr = auftrag.rechnungsnummer.trim();
|
||||
final purpose = _truncate(nr.isEmpty ? auftrag.titel : 'Rechnung $nr', 140);
|
||||
|
||||
final lines = <String>[
|
||||
'BCD',
|
||||
'002',
|
||||
'1',
|
||||
'SCT',
|
||||
bic.isEmpty ? '' : _truncate(bic, 11),
|
||||
name,
|
||||
iban,
|
||||
amount,
|
||||
'',
|
||||
'',
|
||||
purpose,
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
static String _normalizeIban(String raw) {
|
||||
return raw.replaceAll(' ', '').toUpperCase();
|
||||
}
|
||||
|
||||
static String _truncate(String s, int max) {
|
||||
if (s.length <= max) return s;
|
||||
return s.substring(0, max);
|
||||
}
|
||||
}
|
||||
82
services/workflow_metrics_service.dart
Normal file
82
services/workflow_metrics_service.dart
Normal file
@@ -0,0 +1,82 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/dokument_typ.dart';
|
||||
|
||||
/// Lokale Kennzahlen (keine Cloud) – Fokus: PDFs & Dokumenttypen.
|
||||
class WorkflowMetricsService {
|
||||
WorkflowMetricsService._();
|
||||
|
||||
static const _kPdfTotal = 'hp_metrics_pdf_total';
|
||||
static const _kPdfWeek = 'hp_metrics_pdf_week';
|
||||
static const _kPdfWeekId = 'hp_metrics_pdf_week_id';
|
||||
static const _kLastPdfAt = 'hp_metrics_last_pdf_at';
|
||||
static const _kPrefixDoc = 'hp_metrics_doc_';
|
||||
|
||||
static DateTime _montagNullUhr(DateTime d) {
|
||||
final t = DateTime(d.year, d.month, d.day);
|
||||
return t.subtract(Duration(days: t.weekday - DateTime.monday));
|
||||
}
|
||||
|
||||
static int _kalenderWocheSchluessel(DateTime d) {
|
||||
final m = _montagNullUhr(d);
|
||||
return m.year * 10000 + m.month * 100 + m.day;
|
||||
}
|
||||
|
||||
static Future<void> recordPdfExported() async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
final now = DateTime.now();
|
||||
final wid = _kalenderWocheSchluessel(now);
|
||||
final stored = p.getInt(_kPdfWeekId);
|
||||
var week = p.getInt(_kPdfWeek) ?? 0;
|
||||
if (stored != wid) {
|
||||
week = 0;
|
||||
await p.setInt(_kPdfWeekId, wid);
|
||||
}
|
||||
week += 1;
|
||||
final total = (p.getInt(_kPdfTotal) ?? 0) + 1;
|
||||
await p.setInt(_kPdfWeek, week);
|
||||
await p.setInt(_kPdfTotal, total);
|
||||
await p.setString(_kLastPdfAt, now.toIso8601String());
|
||||
}
|
||||
|
||||
static Future<void> recordDokumentGespeichert(DokumentTyp typ) async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
final k = '$_kPrefixDoc${typ.storageValue}';
|
||||
await p.setInt(k, (p.getInt(k) ?? 0) + 1);
|
||||
}
|
||||
|
||||
static Future<WorkflowMetricsSnapshot> load() async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
final wid = _kalenderWocheSchluessel(DateTime.now());
|
||||
final stored = p.getInt(_kPdfWeekId);
|
||||
final week = (stored == wid) ? (p.getInt(_kPdfWeek) ?? 0) : 0;
|
||||
final total = p.getInt(_kPdfTotal) ?? 0;
|
||||
final last = DateTime.tryParse(p.getString(_kLastPdfAt) ?? '');
|
||||
final byDoc = <String, int>{};
|
||||
for (final t in DokumentTyp.values) {
|
||||
final k = '$_kPrefixDoc${t.storageValue}';
|
||||
final n = p.getInt(k);
|
||||
if (n != null && n > 0) byDoc[t.storageValue] = n;
|
||||
}
|
||||
return WorkflowMetricsSnapshot(
|
||||
pdfThisCalendarWeek: week,
|
||||
pdfAllTime: total,
|
||||
lastPdfAt: last,
|
||||
savesByDokumentTyp: byDoc,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WorkflowMetricsSnapshot {
|
||||
WorkflowMetricsSnapshot({
|
||||
required this.pdfThisCalendarWeek,
|
||||
required this.pdfAllTime,
|
||||
required this.lastPdfAt,
|
||||
required this.savesByDokumentTyp,
|
||||
});
|
||||
|
||||
final int pdfThisCalendarWeek;
|
||||
final int pdfAllTime;
|
||||
final DateTime? lastPdfAt;
|
||||
final Map<String, int> savesByDokumentTyp;
|
||||
}
|
||||
Reference in New Issue
Block a user