ein paar feature aber datenbank macht probleme wenn man aufträge speichern möchge
This commit is contained in:
2026-04-05 12:47:57 +02:00
parent e1d4bb7edf
commit 9ddce354c0
32 changed files with 3931 additions and 612 deletions

View 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);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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 (_) {}
}
}

View 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;
}
}

View 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)}';
}
}

View File

@@ -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(

View 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);
}
}

View 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';
}
}

View 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);
}
}

View 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;
}