From 9ddce354c0332d366ca0fffd96a8d6e202d31db6 Mon Sep 17 00:00:00 2001 From: JUSN Date: Sun, 5 Apr 2026 12:47:57 +0200 Subject: [PATCH] Feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ein paar feature aber datenbank macht probleme wenn man aufträge speichern möchge --- appwrite_config.dart | 79 ++ appwrite_local.dart | 15 + config/appwrite_rechnungen_setup.dart | 29 + config/branding.dart | 15 + firebase_options.dart | 49 - legal/legal_content.dart | 91 ++ main.dart | 96 +- models/auftrag.dart | 296 ++++- models/auftrag_list_sort.dart | 41 + models/auftrag_status.dart | 40 + models/dokument_typ.dart | 54 + models/zahlungs_status.dart | 34 + screens/app_logged_in_gate.dart | 55 + .../auftrag/auftrag_bearbeiten_screen.dart | 1032 ++++++++++++--- screens/auth/auth_screen.dart | 90 +- screens/firebase_setup_required_screen.dart | 47 - screens/home/auftraege_home_screen.dart | 161 --- screens/legal/legal_document_screen.dart | 59 + screens/onboarding/onboarding_screen.dart | 164 +++ screens/shell/main_shell_screen.dart | 1153 +++++++++++++++++ services/app_preferences.dart | 16 + services/auftrag_repository.dart | 91 +- services/auftrag_storage_service.dart | 87 +- services/data_export_service.dart | 96 ++ services/german_amount_parser.dart | 29 + services/pdf_export_service.dart | 210 ++- services/pdf_history_service.dart | 76 ++ services/position_from_text_parser.dart | 46 + services/sepa_qr_data.dart | 46 + services/workflow_metrics_service.dart | 82 ++ theme/app_theme.dart | 101 +- utils/appwrite_error_message.dart | 63 + 32 files changed, 3931 insertions(+), 612 deletions(-) create mode 100644 appwrite_config.dart create mode 100644 appwrite_local.dart create mode 100644 config/appwrite_rechnungen_setup.dart create mode 100644 config/branding.dart delete mode 100644 firebase_options.dart create mode 100644 legal/legal_content.dart create mode 100644 models/auftrag_list_sort.dart create mode 100644 models/auftrag_status.dart create mode 100644 models/dokument_typ.dart create mode 100644 models/zahlungs_status.dart create mode 100644 screens/app_logged_in_gate.dart delete mode 100644 screens/firebase_setup_required_screen.dart delete mode 100644 screens/home/auftraege_home_screen.dart create mode 100644 screens/legal/legal_document_screen.dart create mode 100644 screens/onboarding/onboarding_screen.dart create mode 100644 screens/shell/main_shell_screen.dart create mode 100644 services/app_preferences.dart create mode 100644 services/data_export_service.dart create mode 100644 services/german_amount_parser.dart create mode 100644 services/pdf_history_service.dart create mode 100644 services/position_from_text_parser.dart create mode 100644 services/sepa_qr_data.dart create mode 100644 services/workflow_metrics_service.dart create mode 100644 utils/appwrite_error_message.dart diff --git a/appwrite_config.dart b/appwrite_config.dart new file mode 100644 index 0000000..e8ac121 --- /dev/null +++ b/appwrite_config.dart @@ -0,0 +1,79 @@ +import 'package:appwrite/appwrite.dart'; + +import 'appwrite_local.dart'; + +/// Zentraler Appwrite-Client (Session-Cookies werden vom SDK verwaltet). +/// +/// **Wenn `database_not_found` trotz angelegter DB:** +/// 1. In der Console **dieselbe Project-ID** wie in der App (Profil → Appwrite). +/// 2. Unter Databases die **Database ID** kopieren (nicht nur den Namen) und in +/// [lib/appwrite_local.dart] bei `kAppwriteDatabaseIdOverride` eintragen. +/// 3. Oder per Build: `--dart-define=APPWRITE_DATABASE_ID=deine_id` +/// +/// **Console-Setup (einmalig):** +/// Optional: Collection/Bucket per Skript – im Projektroot `.appwrite_api_key` +/// (eine Zeile = Server-API-Key, siehe `.appwrite_api_key.example`), dann +/// `./tool/run_appwrite_setup.sh` ausführen. +/// 1. **Auth:** E-Mail/Passwort aktivieren. +/// 2. **Plattformen:** Bundle-ID `com.example.handwerksapp` (macOS/iOS/Android) +/// unter Settings → Platforms eintragen (sonst „Invalid Origin“). +/// 3. **Database** mit ID wie in [kAppwriteDatabaseId] (oder Override in +/// `appwrite_local.dart`). +/// 4. **Collection** mit ID wie [kAppwriteCollectionId] — wegen Appwrite-1.8-Limit +/// nur **zwei** String-Attribute: `userId` (required, Index) und `extendedJson` +/// (JSON mit `v: 2`, kompletter Datensatz inkl. Fotos/Unterschrift; siehe +/// [Auftrag.toMap] / [Auftrag.fromAppwriteDoc]). +/// 5. **Storage-Bucket** mit ID [kAppwriteBucketId]. +const String kAppwriteEndpoint = String.fromEnvironment( + 'APPWRITE_ENDPOINT', + defaultValue: 'https://appwrite.webklar.com/v1', +); + +const String _projectIdEnv = String.fromEnvironment( + 'APPWRITE_PROJECT_ID', + defaultValue: '', +); +const String _databaseIdEnv = String.fromEnvironment( + 'APPWRITE_DATABASE_ID', + defaultValue: '', +); +const String _collectionIdEnv = String.fromEnvironment( + 'APPWRITE_COLLECTION_ID', + defaultValue: '', +); +const String _bucketIdEnv = String.fromEnvironment( + 'APPWRITE_BUCKET_ID', + defaultValue: '', +); + +String get kAppwriteProjectId { + final o = kAppwriteProjectIdOverride.trim(); + if (o.isNotEmpty) return o; + if (_projectIdEnv.isNotEmpty) return _projectIdEnv; + return '69cd3e77002e40f90f00'; +} + +String get kAppwriteDatabaseId { + final o = kAppwriteDatabaseIdOverride.trim(); + if (o.isNotEmpty) return o; + if (_databaseIdEnv.isNotEmpty) return _databaseIdEnv; + return 'handwerksapp_db'; +} + +String get kAppwriteCollectionId { + final o = kAppwriteCollectionIdOverride.trim(); + if (o.isNotEmpty) return o; + if (_collectionIdEnv.isNotEmpty) return _collectionIdEnv; + return 'rechnungen'; +} + +String get kAppwriteBucketId { + final o = kAppwriteBucketIdOverride.trim(); + if (o.isNotEmpty) return o; + if (_bucketIdEnv.isNotEmpty) return _bucketIdEnv; + return 'rechnung_dateien'; +} + +final Client appwriteClient = Client() + ..setEndpoint(kAppwriteEndpoint) + ..setProject(kAppwriteProjectId); diff --git a/appwrite_local.dart b/appwrite_local.dart new file mode 100644 index 0000000..9db9d3e --- /dev/null +++ b/appwrite_local.dart @@ -0,0 +1,15 @@ +/// Lokale Appwrite-IDs – **hier die Werte aus deiner Console eintragen**, wenn die +/// App weiterhin `database_not_found` meldet. +/// +/// In Appwrite: **Databases** → deine Datenbank öffnen → die **ID** (nicht nur der +/// Name) kopieren. Gleiches Projekt wie unter Settings → **Project ID**. +library; + +// Leer lassen = Standard aus appwrite_config.dart bzw. --dart-define. +const String kAppwriteProjectIdOverride = ''; + +const String kAppwriteDatabaseIdOverride = '69d224d40023b30e2f3a'; + +const String kAppwriteCollectionIdOverride = ''; + +const String kAppwriteBucketIdOverride = ''; diff --git a/config/appwrite_rechnungen_setup.dart b/config/appwrite_rechnungen_setup.dart new file mode 100644 index 0000000..e58f639 --- /dev/null +++ b/config/appwrite_rechnungen_setup.dart @@ -0,0 +1,29 @@ +import '../appwrite_config.dart'; + +/// Anleitung: Collection [kAppwriteCollectionId] in DB [kAppwriteDatabaseId]. +String appwriteRechnungenCollectionCheckliste() { + final db = kAppwriteDatabaseId; + final col = kAppwriteCollectionId; + return '''In der Appwrite-Console (oder per Skript, siehe unten): + +1) Databases → Datenbank „$db“. + +2) Collection ID exakt: $col — Document security an. + +3) String-Attribute (Appwrite 1.8.x: nur zwei erlaubt): + + userId (required, Index) + extendedJson (JSON v2: kompletter Auftrag inkl. titel, Fotos, Unterschrift, …) + +4) Index auf userId. + +5) Storage-Bucket „$kAppwriteBucketId“. + +—— Automatisch —— +WARNUNG: Löscht die Collection „$col“ inkl. aller Dokumente und legt sie neu an. + + export APPWRITE_API_KEY='neuer_Schlüssel' + dart run tool/setup_appwrite_rechnungen.dart + +Schlüssel nie in Chat/App/Git. Alten Key nach Leaken in Appwrite widerrufen.'''; +} diff --git a/config/branding.dart b/config/branding.dart new file mode 100644 index 0000000..55a94b8 --- /dev/null +++ b/config/branding.dart @@ -0,0 +1,15 @@ +/// Anbieter- und Support-Daten **vor Veröffentlichung anpassen**. +/// Keine Rechtsberatung – Impressum/Datenschutz/AGB von Fachleuten prüfen lassen. +library; + +const String kAppDisplayName = 'HandwerkPro'; + +/// Wird für mailto: und Texte genutzt. +const String kSupportEmail = 'support@example.com'; + +const String kProviderLegalName = '[Firmenname / Anbieter eintragen]'; +const String kProviderStreet = '[Straße und Hausnummer]'; +const String kProviderCity = '[PLZ und Ort]'; +const String kProviderCountry = 'Deutschland'; +const String kProviderEmail = kSupportEmail; +const String kVatOrRegInfo = '[USt-IdNr. oder Handelsregister, falls zutreffend]'; diff --git a/firebase_options.dart b/firebase_options.dart deleted file mode 100644 index dd2ee54..0000000 --- a/firebase_options.dart +++ /dev/null @@ -1,49 +0,0 @@ -// Ersetze diese Datei durch die Ausgabe von: -// dart pub global activate flutterfire_cli -// flutterfire configure -// Bis dahin: Platzhalter-Werte (Auth schlägt ohne echtes Firebase-Projekt fehl). - -import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; -import 'package:flutter/foundation.dart' - show defaultTargetPlatform, kIsWeb, TargetPlatform; - -class DefaultFirebaseOptions { - static FirebaseOptions get currentPlatform { - if (kIsWeb) { - throw UnsupportedError( - 'Screen 1: Bitte zuerst iOS- oder Android-Gerät/Simulator nutzen ' - '(oder Web in Firebase anlegen + flutterfire configure).', - ); - } - switch (defaultTargetPlatform) { - case TargetPlatform.android: - return android; - case TargetPlatform.iOS: - return ios; - case TargetPlatform.macOS: - // Entwicklung: gleiche Firebase-App wie iOS (flutterfire configure mit macOS ergänzen). - return ios; - default: - throw UnsupportedError( - 'Nur iOS und Android sind für Screen 1 vorgesehen.', - ); - } - } - - static const FirebaseOptions android = FirebaseOptions( - apiKey: 'REPLACE_ME', - appId: '1:000000000000:android:0000000000000000000000', - messagingSenderId: '000000000000', - projectId: 'handwerksapp-placeholder', - storageBucket: 'handwerksapp-placeholder.appspot.com', - ); - - static const FirebaseOptions ios = FirebaseOptions( - apiKey: 'REPLACE_ME', - appId: '1:000000000000:ios:0000000000000000000000', - messagingSenderId: '000000000000', - projectId: 'handwerksapp-placeholder', - storageBucket: 'handwerksapp-placeholder.appspot.com', - iosBundleId: 'com.example.handwerksapp', - ); -} diff --git a/legal/legal_content.dart b/legal/legal_content.dart new file mode 100644 index 0000000..5b79b34 --- /dev/null +++ b/legal/legal_content.dart @@ -0,0 +1,91 @@ +import '../config/branding.dart'; + +/// **Muster / Vorlage** – vor Produktivnutzung durch eine Rechtsanwaltskanzlei prüfen lassen. +class LegalContent { + LegalContent._(); + + static const String disclaimer = + 'Die folgenden Texte sind eine unverbindliche Vorlage und ersetzen ' + 'keine Rechtsberatung. Bitte durch Fachpersonen prüfen und an dein ' + 'Unternehmen anpassen, bevor du die App verkaufst oder veröffentlichst.'; + + static String impressum = ''' +Impressum + +Angaben gemäß § 5 TMG / DDG + +Anbieter: +$kProviderLegalName +$kProviderStreet +$kProviderCity +$kProviderCountry + +Kontakt: +E-Mail: $kProviderEmail + +$kVatOrRegInfo + +Haftung für Inhalte: +Wir bemühen uns um aktuelle und richtige Inhalte. Für die Richtigkeit, Vollständigkeit und Aktualität übernehmen wir keine Gewähr. + +Haftung für Links: +Externe Links führen zu Inhalten Dritter. Auf diese Inhalte haben wir keinen Einfluss; für sie gilt das Recht des jeweiligen Anbieters. + +Urheberrecht: +Die durch uns erstellten Inhalte unterliegen dem deutschen Urheberrecht. + +Hinweis zur App: +$kAppDisplayName ist eine Software zur Dokumentation von Aufträgen und zur Erzeugung von PDF-Dateien. Sie stellt keine Steuer- oder Rechtsberatung dar. +'''; + + static String datenschutz = ''' +Datenschutzerklärung (Kurzfassung / Vorlage) + +Verantwortlicher: +$kProviderLegalName, $kProviderStreet, $kProviderCity – Kontakt: $kProviderEmail + +1. Zweck der App +$kAppDisplayName verarbeitet personenbezogene Daten, die du als Nutzer eingibst oder erzeugst (z. B. Kundendaten, Auftragsbeschreibungen, Fotos, Unterschriften), um die angebotenen Funktionen bereitzustellen. + +2. Hosting / Backend (Appwrite) +Daten werden über einen Dienst (z. B. Appwrite auf einem Server) gespeichert und verarbeitet. Es gilt der Vertrag mit deinem Hosting- bzw. Cloud-Anbieter sowie dessen Auftragsverarbeitung, sofern du Kunden gegenüber auftragsverarbeitungsvertraglich verpflichtet bist. + +3. Rechtsgrundlagen (Orientierung) +– Vertragserfüllung / vorvertragliche Maßnahmen (Nutzung der App) +– Berechtigte Interessen (Stabilität, Sicherheit), soweit nicht durch überwiegende Interessen der Nutzer überwogen +– Einwilligung, soweit du sie einholst (z. B. für optionale Analytics) + +4. Speicherdauer +Daten werden gespeichert, solange ein Kundenkonto besteht bzw. die Speicherung für den Zweck erforderlich ist. Danach Löschung oder Anonymisierung gemäß gesetzlicher Pflichten. + +5. Rechte der betroffenen Personen +Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung, Datenübertragbarkeit, Widerspruch – soweit gesetzlich vorgesehen. Anfragen an die oben genannte Kontaktadresse. + +6. Datenexport +In der App kann ein Export der gespeicherten Auftragsdaten angeboten werden (rein technische Kopie). Inhalt und Umfang richten sich nach der Implementierung. + +Bitte ergänze: konkreter Appwrite-Endpoint, Subprozessoren, Cookies (Web), Protokolle, und ob du Analytics oder Crash-Reporting nutzt. +'''; + + static String agb = ''' +Allgemeine Geschäftsbedingungen (Vorlage) + +Geltungsbereich: +Diese AGB regeln die Nutzung der Software $kAppDisplayName durch Verbraucher und Unternehmer, soweit anwendbar. + +Leistung: +Bereitstellung der App-Funktionen wie beschrieben (Aufträge, Medien, PDF). Änderungen der Funktionen bleiben vorbehalten, soweit zumutbar. + +Pflichten des Nutzers: +Der Nutzer ist für die Richtigkeit der eingegebenen Daten verantwortlich. Missbrauch ist untersagt. + +Haftung: +Wir haften unbeschränkt bei Vorsatz und grober Fahrlässigkeit sowie nach gesetzlichen Vorschriften. Im Übrigen Haftungsbeschränkung wie gesetzlich zulässig – bitte juristisch präzisieren. + +Zahlung / Abo (falls zutreffend): +[Hier Preis, Laufzeit, Kündigung, Zahlungsdienstleister einfügen] + +Schlussbestimmungen: +Es gilt das Recht der Bundesrepublik Deutschland unter Ausschluss der Kollisionsnormen, soweit zulässig. Gerichtsstand [einfügen], sofern Verbrauchern nicht ein zwingender Gerichtsstand entgegensteht. +'''; +} diff --git a/main.dart b/main.dart index cb50c6d..e75f235 100644 --- a/main.dart +++ b/main.dart @@ -1,57 +1,75 @@ -import 'package:firebase_auth/firebase_auth.dart'; -import 'package:firebase_core/firebase_core.dart'; +import 'package:appwrite/appwrite.dart'; import 'package:flutter/material.dart'; -import 'firebase_options.dart'; +import 'appwrite_config.dart'; import 'screens/auth/auth_screen.dart'; -import 'screens/firebase_setup_required_screen.dart'; -import 'screens/home/auftraege_home_screen.dart'; +import 'screens/app_logged_in_gate.dart'; import 'theme/app_theme.dart'; -Future main() async { +void main() { WidgetsFlutterBinding.ensureInitialized(); - try { - await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); - } catch (e, st) { - debugPrint('Firebase init failed: $e\n$st'); - runApp(FirebaseSetupRequiredScreen(error: e)); - return; - } runApp(const HandwerksApp()); } -class HandwerksApp extends StatelessWidget { +class HandwerksApp extends StatefulWidget { const HandwerksApp({super.key}); + @override + State createState() => _HandwerksAppState(); +} + +class _HandwerksAppState extends State { + bool _checking = true; + bool _loggedIn = false; + + @override + void initState() { + super.initState(); + _probeSession(); + } + + Future _probeSession() async { + setState(() => _checking = true); + try { + await Account(appwriteClient).get(); + if (mounted) { + setState(() { + _loggedIn = true; + _checking = false; + }); + } + } catch (_) { + if (mounted) { + setState(() { + _loggedIn = false; + _checking = false; + }); + } + } + } + @override Widget build(BuildContext context) { return MaterialApp( - title: 'Handwerksapp', + title: 'HandwerkPro', debugShowCheckedModeBanner: false, - theme: AppTheme.light(), - home: const _AuthGate(), - ); - } -} - -class _AuthGate extends StatelessWidget { - const _AuthGate(); - - @override - Widget build(BuildContext context) { - return StreamBuilder( - stream: FirebaseAuth.instance.authStateChanges(), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); - } - if (snapshot.hasData) { - return const AuftraegeHomeScreen(); - } - return const AuthScreen(); - }, + theme: AppTheme.dark(), + home: _checking + ? const Scaffold( + backgroundColor: AppTheme.background, + body: Center(child: CircularProgressIndicator()), + ) + : _loggedIn + ? AppLoggedInGate( + onLoggedOut: () { + setState(() => _loggedIn = false); + }, + ) + : AuthScreen( + onLoggedIn: () { + setState(() => _loggedIn = true); + }, + ), ); } } diff --git a/models/auftrag.dart b/models/auftrag.dart index 08fe49b..54c5ce4 100644 --- a/models/auftrag.dart +++ b/models/auftrag.dart @@ -1,64 +1,332 @@ -import 'package:cloud_firestore/cloud_firestore.dart'; +import 'dart:convert'; -/// Ein Datensatz = eine Rechnung / dokumentierter Auftrag (Fotos, Unterschrift, PDF). +import 'package:appwrite/models.dart'; + +import 'auftrag_status.dart'; +import 'dokument_typ.dart'; +import 'zahlungs_status.dart'; + +/// Ein Datensatz = dokumentierter Vorgang (Fotos, Unterschrift, PDF). +/// +/// `fotoUrls` und `unterschriftUrl` speichern Appwrite-**Datei-IDs** aus dem Bucket +/// (oder ältere http-URLs aus früheren Versionen). +/// +/// In Appwrite liegen **alle** Felder (inkl. Fotos/Unterschrift/Kernfelder) in +/// **einem** String `extendedJson` als JSON mit `v: 2`, plus `userId` — wegen +/// des niedrigen Attribut-Limits in Appwrite 1.8.x. Ältere flache Dokumente +/// und `v: 1`-JSON werden weiter gelesen. class Auftrag { Auftrag({ required this.id, required this.titel, required this.beschreibung, required this.kundenName, + required this.kundenAdresse, required this.kundenEmail, required this.rechnungsnummer, required this.betragText, required this.fotoUrls, required this.unterschriftUrl, required this.createdAt, + required this.status, + this.dokumentTyp = DokumentTyp.rechnung, + this.zahlungsStatus = ZahlungsStatus.offen, + this.faelligAm, + this.leistungsDatum, + this.kleinunternehmer = false, + this.reverseCharge = false, + this.skontoText = '', + this.ustIdKunde = '', + this.ibanVerkaeufer = '', + this.bicVerkaeufer = '', + this.kontoinhaberVerkaeufer = '', }); final String id; final String titel; final String beschreibung; final String kundenName; + final String kundenAdresse; final String kundenEmail; final String rechnungsnummer; final String betragText; final List fotoUrls; final String? unterschriftUrl; final DateTime? createdAt; + final AuftragStatus status; + + final DokumentTyp dokumentTyp; + final ZahlungsStatus zahlungsStatus; + final DateTime? faelligAm; + final DateTime? leistungsDatum; + final bool kleinunternehmer; + final bool reverseCharge; + final String skontoText; + final String ustIdKunde; + final String ibanVerkaeufer; + final String bicVerkaeufer; + final String kontoinhaberVerkaeufer; bool get hasUnterschrift => unterschriftUrl != null && unterschriftUrl!.isNotEmpty; - factory Auftrag.fromDoc(DocumentSnapshot> doc) { - final d = doc.data() ?? {}; - final fotos = d['fotoUrls']; + static bool _parseBool(dynamic v) { + if (v == true) return true; + if (v == false) return false; + final s = v?.toString().trim().toLowerCase(); + return s == '1' || s == 'true' || s == 'yes' || s == 'ja'; + } + + static List _parseFotoListe(dynamic v) { + if (v == null) return []; + if (v is List) { + return v.map((e) => e.toString()).where((s) => s.isNotEmpty).toList(); + } + final s = v.toString(); + if (s.isEmpty) return []; + return s.split(',').map((e) => e.trim()).where((e) => e.isNotEmpty).toList(); + } + + static DateTime? _parseDate(dynamic v) { + if (v == null) return null; + final s = v.toString().trim(); + if (s.isEmpty) return null; + return DateTime.tryParse(s); + } + + static Map? _parseExtendedJson(String? raw) { + if (raw == null || raw.trim().isEmpty) return null; + try { + final o = jsonDecode(raw); + if (o is Map) return o; + if (o is Map) return Map.from(o); + } catch (_) {} + return null; + } + + factory Auftrag.fromAppwriteDoc(Document doc) { + final d = doc.data; + final ext = _parseExtendedJson(d['extendedJson'] as String?); + + if (ext != null && ext['v'] == 2) { + final betrag = ext['betragText'] as String? ?? ''; + return Auftrag( + id: doc.$id, + titel: ext['titel'] as String? ?? '', + beschreibung: ext['beschreibung'] as String? ?? '', + kundenName: ext['kundenName'] as String? ?? '', + kundenAdresse: ext['kundenAdresse'] as String? ?? '', + kundenEmail: ext['kundenEmail'] as String? ?? '', + rechnungsnummer: ext['rechnungsnummer'] as String? ?? '', + betragText: betrag, + fotoUrls: _parseFotoListe(ext['fotoUrls']), + unterschriftUrl: ext['unterschriftUrl'] as String?, + createdAt: DateTime.tryParse(doc.$createdAt), + status: auftragStatusFromStorage( + ext['status'] as String?, + betragTextLegacy: betrag, + ), + dokumentTyp: dokumentTypFromStorage(ext['dokumentTyp'] as String?), + zahlungsStatus: + zahlungsStatusFromStorage(ext['zahlungsStatus'] as String?), + faelligAm: _parseDate(ext['faelligAm']), + leistungsDatum: _parseDate(ext['leistungsDatum']), + kleinunternehmer: _parseBool(ext['kleinunternehmer']), + reverseCharge: _parseBool(ext['reverseCharge']), + skontoText: ext['skontoText'] as String? ?? '', + ustIdKunde: ext['ustIdKunde'] as String? ?? '', + ibanVerkaeufer: ext['ibanVerkaeufer'] as String? ?? '', + bicVerkaeufer: ext['bicVerkaeufer'] as String? ?? '', + kontoinhaberVerkaeufer: + ext['kontoinhaberVerkaeufer'] as String? ?? '', + ); + } + + final betrag = d['betragText'] as String? ?? ''; + + DokumentTyp dokumentTyp; + ZahlungsStatus zahlungsStatus; + DateTime? faelligAm; + DateTime? leistungsDatum; + bool kleinunternehmer; + bool reverseCharge; + String skontoText; + String ustIdKunde; + String ibanVerkaeufer; + String bicVerkaeufer; + String kontoinhaberVerkaeufer; + + if (ext != null) { + dokumentTyp = dokumentTypFromStorage(ext['dokumentTyp'] as String?); + zahlungsStatus = + zahlungsStatusFromStorage(ext['zahlungsStatus'] as String?); + faelligAm = _parseDate(ext['faelligAm']); + leistungsDatum = _parseDate(ext['leistungsDatum']); + kleinunternehmer = _parseBool(ext['kleinunternehmer']); + reverseCharge = _parseBool(ext['reverseCharge']); + skontoText = ext['skontoText'] as String? ?? ''; + ustIdKunde = ext['ustIdKunde'] as String? ?? ''; + ibanVerkaeufer = ext['ibanVerkaeufer'] as String? ?? ''; + bicVerkaeufer = ext['bicVerkaeufer'] as String? ?? ''; + kontoinhaberVerkaeufer = ext['kontoinhaberVerkaeufer'] as String? ?? ''; + } else { + dokumentTyp = dokumentTypFromStorage(d['dokumentTyp'] as String?); + zahlungsStatus = + zahlungsStatusFromStorage(d['zahlungsStatus'] as String?); + faelligAm = _parseDate(d['faelligAm']); + leistungsDatum = _parseDate(d['leistungsDatum']); + kleinunternehmer = _parseBool(d['kleinunternehmer']); + reverseCharge = _parseBool(d['reverseCharge']); + skontoText = d['skontoText'] as String? ?? ''; + ustIdKunde = d['ustIdKunde'] as String? ?? ''; + ibanVerkaeufer = d['ibanVerkaeufer'] as String? ?? ''; + bicVerkaeufer = d['bicVerkaeufer'] as String? ?? ''; + kontoinhaberVerkaeufer = d['kontoinhaberVerkaeufer'] as String? ?? ''; + } + return Auftrag( - id: doc.id, + id: doc.$id, titel: d['titel'] as String? ?? '', beschreibung: d['beschreibung'] as String? ?? '', kundenName: d['kundenName'] as String? ?? '', + kundenAdresse: d['kundenAdresse'] as String? ?? '', kundenEmail: d['kundenEmail'] as String? ?? '', rechnungsnummer: d['rechnungsnummer'] as String? ?? '', - betragText: d['betragText'] as String? ?? '', - fotoUrls: fotos is List - ? fotos.map((e) => e.toString()).toList() - : const [], + betragText: betrag, + fotoUrls: _parseFotoListe(d['fotoUrls']), unterschriftUrl: d['unterschriftUrl'] as String?, - createdAt: (d['createdAt'] as Timestamp?)?.toDate(), + createdAt: DateTime.tryParse(doc.$createdAt), + status: auftragStatusFromStorage( + d['status'] as String?, + betragTextLegacy: betrag, + ), + dokumentTyp: dokumentTyp, + zahlungsStatus: zahlungsStatus, + faelligAm: faelligAm, + leistungsDatum: leistungsDatum, + kleinunternehmer: kleinunternehmer, + reverseCharge: reverseCharge, + skontoText: skontoText, + ustIdKunde: ustIdKunde, + ibanVerkaeufer: ibanVerkaeufer, + bicVerkaeufer: bicVerkaeufer, + kontoinhaberVerkaeufer: kontoinhaberVerkaeufer, ); } - Map toMap() { - return { + String _encodeAppwriteExtendedJson() { + return jsonEncode({ + 'v': 2, 'titel': titel, 'beschreibung': beschreibung, 'kundenName': kundenName, + 'kundenAdresse': kundenAdresse, 'kundenEmail': kundenEmail, 'rechnungsnummer': rechnungsnummer, 'betragText': betragText, 'fotoUrls': fotoUrls, 'unterschriftUrl': unterschriftUrl, - 'updatedAt': FieldValue.serverTimestamp(), + 'status': status.storageValue, + 'dokumentTyp': dokumentTyp.storageValue, + 'zahlungsStatus': zahlungsStatus.storageValue, + 'faelligAm': faelligAm?.toIso8601String() ?? '', + 'leistungsDatum': leistungsDatum?.toIso8601String() ?? '', + 'kleinunternehmer': kleinunternehmer, + 'reverseCharge': reverseCharge, + 'skontoText': skontoText, + 'ustIdKunde': ustIdKunde, + 'ibanVerkaeufer': ibanVerkaeufer, + 'bicVerkaeufer': bicVerkaeufer, + 'kontoinhaberVerkaeufer': kontoinhaberVerkaeufer, + }); + } + + /// Payload für Appwrite `data` — nur [extendedJson]; [userId] setzt das Repo. + Map toMap() { + return { + 'extendedJson': _encodeAppwriteExtendedJson(), }; } + + /// JSON-fähige Darstellung für Datenauskunft/Export (ohne Binärdateien). + Map toExportMap() { + return { + 'id': id, + 'titel': titel, + 'beschreibung': beschreibung, + 'kundenName': kundenName, + 'kundenAdresse': kundenAdresse, + 'kundenEmail': kundenEmail, + 'rechnungsnummer': rechnungsnummer, + 'betragText': betragText, + 'status': status.storageValue, + 'dokumentTyp': dokumentTyp.storageValue, + 'zahlungsStatus': zahlungsStatus.storageValue, + 'faelligAm': faelligAm?.toIso8601String(), + 'leistungsDatum': leistungsDatum?.toIso8601String(), + 'kleinunternehmer': kleinunternehmer, + 'reverseCharge': reverseCharge, + 'skontoText': skontoText, + 'ustIdKunde': ustIdKunde, + 'ibanVerkaeufer': ibanVerkaeufer, + 'bicVerkaeufer': bicVerkaeufer, + 'kontoinhaberVerkaeufer': kontoinhaberVerkaeufer, + 'fotoDateiIds': fotoUrls, + 'unterschriftDateiId': unterschriftUrl, + 'createdAt': createdAt?.toIso8601String(), + }; + } + + Auftrag copyWith({ + String? id, + String? titel, + String? beschreibung, + String? kundenName, + String? kundenAdresse, + String? kundenEmail, + String? rechnungsnummer, + String? betragText, + List? fotoUrls, + String? unterschriftUrl, + DateTime? createdAt, + AuftragStatus? status, + DokumentTyp? dokumentTyp, + ZahlungsStatus? zahlungsStatus, + DateTime? faelligAm, + DateTime? leistungsDatum, + bool? kleinunternehmer, + bool? reverseCharge, + String? skontoText, + String? ustIdKunde, + String? ibanVerkaeufer, + String? bicVerkaeufer, + String? kontoinhaberVerkaeufer, + }) { + return Auftrag( + id: id ?? this.id, + titel: titel ?? this.titel, + beschreibung: beschreibung ?? this.beschreibung, + kundenName: kundenName ?? this.kundenName, + kundenAdresse: kundenAdresse ?? this.kundenAdresse, + kundenEmail: kundenEmail ?? this.kundenEmail, + rechnungsnummer: rechnungsnummer ?? this.rechnungsnummer, + betragText: betragText ?? this.betragText, + fotoUrls: fotoUrls ?? List.from(this.fotoUrls), + unterschriftUrl: unterschriftUrl ?? this.unterschriftUrl, + createdAt: createdAt ?? this.createdAt, + status: status ?? this.status, + dokumentTyp: dokumentTyp ?? this.dokumentTyp, + zahlungsStatus: zahlungsStatus ?? this.zahlungsStatus, + faelligAm: faelligAm ?? this.faelligAm, + leistungsDatum: leistungsDatum ?? this.leistungsDatum, + kleinunternehmer: kleinunternehmer ?? this.kleinunternehmer, + reverseCharge: reverseCharge ?? this.reverseCharge, + skontoText: skontoText ?? this.skontoText, + ustIdKunde: ustIdKunde ?? this.ustIdKunde, + ibanVerkaeufer: ibanVerkaeufer ?? this.ibanVerkaeufer, + bicVerkaeufer: bicVerkaeufer ?? this.bicVerkaeufer, + kontoinhaberVerkaeufer: + kontoinhaberVerkaeufer ?? this.kontoinhaberVerkaeufer, + ); + } } diff --git a/models/auftrag_list_sort.dart b/models/auftrag_list_sort.dart new file mode 100644 index 0000000..d2484a5 --- /dev/null +++ b/models/auftrag_list_sort.dart @@ -0,0 +1,41 @@ +import 'auftrag.dart'; +import 'auftrag_status.dart'; + +enum AuftragListenSort { + datumNeu('Datum (neu zuerst)'), + status('Status'), + titelAz('Titel A–Z'); + + const AuftragListenSort(this.label); + final String label; +} + +List sortAuftraege(List input, AuftragListenSort sort) { + final copy = List.from(input); + int statusOrder(AuftragStatus s) => switch (s) { + AuftragStatus.offen => 0, + AuftragStatus.geplant => 1, + AuftragStatus.fertig => 2, + }; + switch (sort) { + case AuftragListenSort.datumNeu: + copy.sort((a, b) { + final da = a.createdAt ?? DateTime.fromMillisecondsSinceEpoch(0); + final db = b.createdAt ?? DateTime.fromMillisecondsSinceEpoch(0); + return db.compareTo(da); + }); + case AuftragListenSort.status: + copy.sort((a, b) { + final c = statusOrder(a.status).compareTo(statusOrder(b.status)); + if (c != 0) return c; + final da = a.createdAt ?? DateTime.fromMillisecondsSinceEpoch(0); + final db = b.createdAt ?? DateTime.fromMillisecondsSinceEpoch(0); + return db.compareTo(da); + }); + case AuftragListenSort.titelAz: + copy.sort( + (a, b) => a.titel.toLowerCase().compareTo(b.titel.toLowerCase()), + ); + } + return copy; +} diff --git a/models/auftrag_status.dart b/models/auftrag_status.dart new file mode 100644 index 0000000..e217d3a --- /dev/null +++ b/models/auftrag_status.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +import '../theme/app_theme.dart'; + +enum AuftragStatus { + offen, + geplant, + fertig, +} + +extension AuftragStatusX on AuftragStatus { + /// Wert in Appwrite (englisch, stabil). + String get storageValue => name; + + String get labelDe => switch (this) { + AuftragStatus.offen => 'Offen', + AuftragStatus.geplant => 'Geplant', + AuftragStatus.fertig => 'Fertig', + }; + + Color get badgeColor => switch (this) { + AuftragStatus.offen => AppTheme.statusOffen, + AuftragStatus.geplant => AppTheme.statusGeplant, + AuftragStatus.fertig => AppTheme.statusFertig, + }; +} + +AuftragStatus auftragStatusFromStorage( + String? raw, { + required String betragTextLegacy, +}) { + if (raw != null && raw.isNotEmpty) { + for (final s in AuftragStatus.values) { + if (s.name == raw) return s; + } + } + // Alte Datensätze ohne Feld: einmalig aus Betrag ableiten + if (betragTextLegacy.trim().isNotEmpty) return AuftragStatus.fertig; + return AuftragStatus.geplant; +} diff --git a/models/dokument_typ.dart b/models/dokument_typ.dart new file mode 100644 index 0000000..0c9deff --- /dev/null +++ b/models/dokument_typ.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; + +import '../theme/app_theme.dart'; + +/// Art des PDF / Vorgangs im Lebenszyklus (Angebot → Leistung → Rechnung …). +enum DokumentTyp { + angebot, + leistung, + abschlag, + rechnung, + schlussrechnung, + mahnung, +} + +extension DokumentTypX on DokumentTyp { + String get storageValue => name; + + String get labelDe => switch (this) { + DokumentTyp.angebot => 'Angebot', + DokumentTyp.leistung => 'Leistung / Baustelle', + DokumentTyp.abschlag => 'Abschlagsrechnung', + DokumentTyp.rechnung => 'Rechnung', + DokumentTyp.schlussrechnung => 'Schlussrechnung', + DokumentTyp.mahnung => 'Mahnung', + }; + + /// PDF-Titel / Dokumentkopf. + String get pdfTitel => switch (this) { + DokumentTyp.angebot => 'Angebot', + DokumentTyp.leistung => 'Leistungsnachweis', + DokumentTyp.abschlag => 'Abschlagsrechnung', + DokumentTyp.rechnung => 'Rechnung', + DokumentTyp.schlussrechnung => 'Schlussrechnung', + DokumentTyp.mahnung => 'Mahnung', + }; + + Color get badgeColor => switch (this) { + DokumentTyp.angebot => const Color(0xFF7E57C2), + DokumentTyp.leistung => AppTheme.statusGeplant, + DokumentTyp.abschlag => const Color(0xFF26A69A), + DokumentTyp.rechnung => AppTheme.accentCyan, + DokumentTyp.schlussrechnung => const Color(0xFF42A5F5), + DokumentTyp.mahnung => const Color(0xFFE53935), + }; +} + +DokumentTyp dokumentTypFromStorage(String? raw) { + if (raw != null && raw.isNotEmpty) { + for (final v in DokumentTyp.values) { + if (v.name == raw) return v; + } + } + return DokumentTyp.rechnung; +} diff --git a/models/zahlungs_status.dart b/models/zahlungs_status.dart new file mode 100644 index 0000000..01dc80b --- /dev/null +++ b/models/zahlungs_status.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +import '../theme/app_theme.dart'; + +enum ZahlungsStatus { + offen, + bezahlt, + ueberfaellig, +} + +extension ZahlungsStatusX on ZahlungsStatus { + String get storageValue => name; + + String get labelDe => switch (this) { + ZahlungsStatus.offen => 'Offen', + ZahlungsStatus.bezahlt => 'Bezahlt', + ZahlungsStatus.ueberfaellig => 'Überfällig', + }; + + Color get badgeColor => switch (this) { + ZahlungsStatus.offen => AppTheme.statusOffen, + ZahlungsStatus.bezahlt => AppTheme.statusFertig, + ZahlungsStatus.ueberfaellig => const Color(0xFFE53935), + }; +} + +ZahlungsStatus zahlungsStatusFromStorage(String? raw) { + if (raw != null && raw.isNotEmpty) { + for (final v in ZahlungsStatus.values) { + if (v.name == raw) return v; + } + } + return ZahlungsStatus.offen; +} diff --git a/screens/app_logged_in_gate.dart b/screens/app_logged_in_gate.dart new file mode 100644 index 0000000..dbd99f9 --- /dev/null +++ b/screens/app_logged_in_gate.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +import '../services/app_preferences.dart'; +import '../theme/app_theme.dart'; +import 'onboarding/onboarding_screen.dart'; +import 'shell/main_shell_screen.dart'; + +/// Zeigt einmalig das Onboarding, danach die Haupt-App. +class AppLoggedInGate extends StatefulWidget { + const AppLoggedInGate({super.key, required this.onLoggedOut}); + + final VoidCallback onLoggedOut; + + @override + State createState() => _AppLoggedInGateState(); +} + +class _AppLoggedInGateState extends State { + bool _prefsReady = false; + bool _onboardingDone = false; + + @override + void initState() { + super.initState(); + _loadPrefs(); + } + + Future _loadPrefs() async { + final done = await AppPreferences.isOnboardingDone(); + if (!mounted) return; + setState(() { + _onboardingDone = done; + _prefsReady = true; + }); + } + + @override + Widget build(BuildContext context) { + if (!_prefsReady) { + return const Scaffold( + backgroundColor: AppTheme.background, + body: Center(child: CircularProgressIndicator()), + ); + } + if (!_onboardingDone) { + return OnboardingScreen( + onComplete: () async { + await AppPreferences.setOnboardingDone(); + if (mounted) setState(() => _onboardingDone = true); + }, + ); + } + return MainShellScreen(onLoggedOut: widget.onLoggedOut); + } +} diff --git a/screens/auftrag/auftrag_bearbeiten_screen.dart b/screens/auftrag/auftrag_bearbeiten_screen.dart index ed5716b..c78d58e 100644 --- a/screens/auftrag/auftrag_bearbeiten_screen.dart +++ b/screens/auftrag/auftrag_bearbeiten_screen.dart @@ -1,15 +1,25 @@ import 'dart:io'; import 'dart:typed_data'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_email_sender/flutter_email_sender.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:intl/intl.dart'; import 'package:signature/signature.dart'; +import 'package:speech_to_text/speech_to_text.dart' as stt; import '../../models/auftrag.dart'; +import '../../models/auftrag_status.dart'; +import '../../models/dokument_typ.dart'; +import '../../models/zahlungs_status.dart'; import '../../services/auftrag_repository.dart'; import '../../services/auftrag_storage_service.dart'; import '../../services/pdf_export_service.dart'; +import '../../services/pdf_history_service.dart'; +import '../../services/position_from_text_parser.dart'; +import '../../services/workflow_metrics_service.dart'; +import '../../theme/app_theme.dart'; class AuftragBearbeitenScreen extends StatefulWidget { const AuftragBearbeitenScreen({super.key, this.auftragId}); @@ -28,6 +38,7 @@ class _AuftragBearbeitenScreenState extends State { final _titel = TextEditingController(); final _beschreibung = TextEditingController(); final _kunde = TextEditingController(); + final _kundenAdresse = TextEditingController(); final _kundenEmail = TextEditingController(); final _betrag = TextEditingController(); @@ -41,12 +52,29 @@ class _AuftragBearbeitenScreenState extends State { List _fotoUrls = []; String? _unterschriftUrl; bool _signaturVorhanden = false; + AuftragStatus _status = AuftragStatus.offen; + DokumentTyp _dokumentTyp = DokumentTyp.rechnung; + ZahlungsStatus _zahlungsStatus = ZahlungsStatus.offen; + DateTime? _faelligAm; + DateTime? _leistungsDatum; + bool _kleinunternehmer = false; + bool _reverseCharge = false; + final _skonto = TextEditingController(); + final _ustId = TextEditingController(); + final _iban = TextEditingController(); + final _bic = TextEditingController(); + final _kontoinhaber = TextEditingController(); + final stt.SpeechToText _speech = stt.SpeechToText(); + bool _speechReady = false; + bool _speechListening = false; + + static final _datDe = DateFormat('dd.MM.yyyy'); @override void initState() { super.initState(); _signatur = SignatureController( - penStrokeWidth: 2, + penStrokeWidth: 2.5, penColor: Colors.black, exportBackgroundColor: Colors.white, ); @@ -56,6 +84,22 @@ class _AuftragBearbeitenScreenState extends State { } else { _loading = false; } + if (!kIsWeb) { + _initSpeech(); + } + } + + void _initSpeech() { + _speech.initialize( + onError: (_) {}, + onStatus: (s) { + if (s == 'done' || s == 'notListening') { + if (mounted) setState(() => _speechListening = false); + } + }, + ).then((ok) { + if (mounted) setState(() => _speechReady = ok); + }); } Future _laden() async { @@ -63,7 +107,7 @@ class _AuftragBearbeitenScreenState extends State { if (!mounted) return; if (a == null) { setState(() => _loading = false); - _snack('Rechnung nicht gefunden.'); + _snack('Auftrag nicht gefunden.'); return; } _isNeu = false; @@ -71,11 +115,24 @@ class _AuftragBearbeitenScreenState extends State { _titel.text = a.titel; _beschreibung.text = a.beschreibung; _kunde.text = a.kundenName; + _kundenAdresse.text = a.kundenAdresse; _kundenEmail.text = a.kundenEmail; _betrag.text = a.betragText; _fotoUrls = List.from(a.fotoUrls); _unterschriftUrl = a.unterschriftUrl; _signaturVorhanden = a.hasUnterschrift; + _status = a.status; + _dokumentTyp = a.dokumentTyp; + _zahlungsStatus = a.zahlungsStatus; + _faelligAm = a.faelligAm; + _leistungsDatum = a.leistungsDatum; + _kleinunternehmer = a.kleinunternehmer; + _reverseCharge = a.reverseCharge; + _skonto.text = a.skontoText; + _ustId.text = a.ustIdKunde; + _iban.text = a.ibanVerkaeufer; + _bic.text = a.bicVerkaeufer; + _kontoinhaber.text = a.kontoinhaberVerkaeufer; setState(() => _loading = false); } @@ -85,9 +142,18 @@ class _AuftragBearbeitenScreenState extends State { _titel.dispose(); _beschreibung.dispose(); _kunde.dispose(); + _kundenAdresse.dispose(); _kundenEmail.dispose(); _betrag.dispose(); + _skonto.dispose(); + _ustId.dispose(); + _iban.dispose(); + _bic.dispose(); + _kontoinhaber.dispose(); _signatur.dispose(); + if (_speechListening) { + _speech.stop(); + } super.dispose(); } @@ -96,6 +162,64 @@ class _AuftragBearbeitenScreenState extends State { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t))); } + Future _bytesFuerReferenz(String ref) { + return PdfExportService.ladeReferenz( + ref, + fileLoader: (id) => _storage.getFileBytes(id), + ); + } + + Widget _fotoVorschau(String ref) { + if (ref.startsWith('http://') || ref.startsWith('https://')) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + ref, + width: 88, + height: 88, + fit: BoxFit.cover, + errorBuilder: (context, error, stack) => Container( + width: 88, + height: 88, + color: const Color(0xFF2C2C2C), + child: const Icon(Icons.broken_image, color: Colors.white54), + ), + ), + ); + } + return FutureBuilder( + future: _storage.getFileBytes(ref), + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return Container( + width: 88, + height: 88, + color: const Color(0xFF2C2C2C), + alignment: Alignment.center, + child: const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + final b = snap.data; + if (b == null || b.isEmpty) { + return Container( + width: 88, + height: 88, + color: const Color(0xFF2C2C2C), + child: const Icon(Icons.broken_image, color: Colors.white54), + ); + } + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.memory(b, width: 88, height: 88, fit: BoxFit.cover), + ); + }, + ); + } + Auftrag _aktuellerAuftrag(String? sigUrl) { var rn = _rechnungsnummer.text.trim(); if (rn.isEmpty) { @@ -110,47 +234,35 @@ class _AuftragBearbeitenScreenState extends State { titel: _titel.text.trim(), beschreibung: _beschreibung.text.trim(), kundenName: _kunde.text.trim(), + kundenAdresse: _kundenAdresse.text.trim(), kundenEmail: _kundenEmail.text.trim(), rechnungsnummer: rn, betragText: _betrag.text.trim(), fotoUrls: _fotoUrls, unterschriftUrl: sigUrl, createdAt: null, + status: _status, + dokumentTyp: _dokumentTyp, + zahlungsStatus: _zahlungsStatus, + faelligAm: _faelligAm, + leistungsDatum: _leistungsDatum, + kleinunternehmer: _kleinunternehmer, + reverseCharge: _reverseCharge, + skontoText: _skonto.text.trim(), + ustIdKunde: _ustId.text.trim(), + ibanVerkaeufer: _iban.text.trim(), + bicVerkaeufer: _bic.text.trim(), + kontoinhaberVerkaeufer: _kontoinhaber.text.trim(), ); } - Future _fotosHinzufuegen() async { - final files = await _picker.pickMultiImage(imageQuality: 85); - if (files.isEmpty) return; - setState(() => _saving = true); - try { - var i = _fotoUrls.length; - for (final x in files) { - final bytes = await x.readAsBytes(); - final name = '${DateTime.now().millisecondsSinceEpoch}_$i.jpg'; - final url = await _storage.hochladenFoto(_docId, bytes, name); - _fotoUrls.add(url); - i++; - } - setState(() {}); - } catch (e) { - _snack('Foto-Upload: $e'); - } finally { - if (mounted) setState(() => _saving = false); - } - } - - void _fotoEntfernen(int index) { - setState(() { - _fotoUrls.removeAt(index); - }); - } - - Future _speichern() async { + Future _speichernIntern({bool zeigeSnacks = true}) async { final titel = _titel.text.trim(); if (titel.isEmpty) { - _snack('Bitte einen Titel / Leistung eintragen.'); - return; + if (zeigeSnacks) { + _snack('Bitte Leistung / Kurztitel eintragen.'); + } + return false; } if (_rechnungsnummer.text.trim().isEmpty) { final compact = _docId.replaceAll('-', ''); @@ -160,7 +272,6 @@ class _AuftragBearbeitenScreenState extends State { _rechnungsnummer.text = 'RE-${DateTime.now().year}-$suffix'; } - setState(() => _saving = true); try { String? sigUrl = _unterschriftUrl; if (_signatur.isNotEmpty) { @@ -175,20 +286,226 @@ class _AuftragBearbeitenScreenState extends State { final auftrag = _aktuellerAuftrag(sigUrl); await _repo.speichern(id: _docId, daten: auftrag, isNeu: _isNeu); + await WorkflowMetricsService.recordDokumentGespeichert(auftrag.dokumentTyp); _isNeu = false; if (mounted) setState(() {}); - _snack('Rechnung gespeichert.'); + if (zeigeSnacks) _snack('Gespeichert.'); + return true; } catch (e) { - _snack('Speichern fehlgeschlagen: $e'); + if (zeigeSnacks) _snack('Speichern fehlgeschlagen: $e'); + return false; + } + } + + Future _speichernTapped() async { + setState(() => _saving = true); + try { + await _speichernIntern(); } finally { if (mounted) setState(() => _saving = false); } } + Future _fotoKamera() async { + if (kIsWeb) { + _snack('Kamera ist im Browser nicht verfügbar.'); + return; + } + final x = await _picker.pickImage( + source: ImageSource.camera, + imageQuality: 85, + ); + if (x == null) return; + setState(() => _saving = true); + try { + final bytes = await x.readAsBytes(); + final name = '${DateTime.now().millisecondsSinceEpoch}_cam.jpg'; + final id = await _storage.hochladenFoto(_docId, bytes, name); + _fotoUrls.add(id); + setState(() {}); + } catch (e) { + _snack('Kamera: $e'); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _fotosHinzufuegen() async { + final files = await _picker.pickMultiImage(imageQuality: 85); + if (files.isEmpty) return; + setState(() => _saving = true); + try { + var i = _fotoUrls.length; + for (final x in files) { + final bytes = await x.readAsBytes(); + final name = '${DateTime.now().millisecondsSinceEpoch}_$i.jpg'; + final id = await _storage.hochladenFoto(_docId, bytes, name); + _fotoUrls.add(id); + i++; + } + setState(() {}); + } catch (e) { + _snack('Foto-Upload: $e'); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _fotoEntfernen(int index) async { + final id = _fotoUrls[index]; + setState(() => _fotoUrls.removeAt(index)); + await _storage.loescheDatei(id); + } + + Future _kopieMitTyp(DokumentTyp typ, {required String numPrefix}) async { + final ok = await _speichernIntern(zeigeSnacks: true); + if (!ok || !mounted) return; + final base = _aktuellerAuftrag(_unterschriftUrl); + final newId = _repo.neueId(); + final compact = newId.replaceAll('-', ''); + final suffix = compact.length >= 6 + ? compact.substring(0, 6) + : compact.padRight(6, '0'); + final newRn = '$numPrefix-${DateTime.now().year}-$suffix'; + final body = typ == DokumentTyp.mahnung + ? 'Mahnung zu ${base.rechnungsnummer}.\n\n${base.beschreibung}' + : base.beschreibung; + final copy = Auftrag( + id: newId, + titel: base.titel, + beschreibung: body, + kundenName: base.kundenName, + kundenAdresse: base.kundenAdresse, + kundenEmail: base.kundenEmail, + rechnungsnummer: newRn, + betragText: base.betragText, + fotoUrls: const [], + unterschriftUrl: null, + createdAt: null, + status: base.status, + dokumentTyp: typ, + zahlungsStatus: ZahlungsStatus.offen, + faelligAm: base.faelligAm, + leistungsDatum: base.leistungsDatum, + kleinunternehmer: base.kleinunternehmer, + reverseCharge: base.reverseCharge, + skontoText: base.skontoText, + ustIdKunde: base.ustIdKunde, + ibanVerkaeufer: base.ibanVerkaeufer, + bicVerkaeufer: base.bicVerkaeufer, + kontoinhaberVerkaeufer: base.kontoinhaberVerkaeufer, + ); + try { + await _repo.speichern(id: newId, daten: copy, isNeu: true); + await WorkflowMetricsService.recordDokumentGespeichert(copy.dokumentTyp); + if (!mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => AuftragBearbeitenScreen(auftragId: newId), + ), + ); + } catch (e) { + _snack('Kopie fehlgeschlagen: $e'); + } + } + + Future _toggleSpeech() async { + if (!_speechReady) { + _snack('Spracheingabe nicht verfügbar (Rechte / Gerät).'); + return; + } + if (_speechListening) { + await _speech.stop(); + if (mounted) setState(() => _speechListening = false); + return; + } + setState(() => _speechListening = true); + await _speech.listen( + onResult: (r) { + if (!mounted) return; + if (r.finalResult) { + final t = r.recognizedWords.trim(); + if (t.isEmpty) return; + setState(() { + final cur = _beschreibung.text; + _beschreibung.text = cur.trim().isEmpty ? t : '$cur\n$t'; + _beschreibung.selection = TextSelection.collapsed( + offset: _beschreibung.text.length, + ); + }); + } + }, + localeId: 'de_DE', + listenOptions: stt.SpeechListenOptions( + listenMode: stt.ListenMode.dictation, + ), + ); + } + + Future _schnellpositionDialog() async { + final c = TextEditingController(); + final go = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Schnellposition'), + content: TextField( + controller: c, + autofocus: true, + decoration: const InputDecoration( + hintText: 'z. B. 2h Elektro oder 50€ Material Kabel', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Abbrechen'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Übernehmen'), + ), + ], + ), + ); + if (go != true || !mounted) return; + final line = PositionFromTextParser.vorschlagZeile(c.text); + c.dispose(); + if (line == null) { + _snack('Kein erkennbares Muster.'); + return; + } + setState(() { + final cur = _beschreibung.text.trim(); + _beschreibung.text = cur.isEmpty ? line : '$cur\n$line'; + }); + } + + Future _pickLeistungsdatum() async { + final now = DateTime.now(); + final d = await showDatePicker( + context: context, + initialDate: _leistungsDatum ?? now, + firstDate: DateTime(now.year - 5), + lastDate: DateTime(now.year + 2), + ); + if (d != null) setState(() => _leistungsDatum = d); + } + + Future _pickFaelligAm() async { + final now = DateTime.now(); + final d = await showDatePicker( + context: context, + initialDate: _faelligAm ?? now, + firstDate: DateTime(now.year - 2), + lastDate: DateTime(now.year + 5), + ); + if (d != null) setState(() => _faelligAm = d); + } + Future _pdfDateiErzeugen() async { final titel = _titel.text.trim(); if (titel.isEmpty) { - _snack('Titel eintragen (für die Rechnung).'); + _snack('Leistung / Kurztitel eintragen (für die PDF).'); return null; } @@ -200,12 +517,12 @@ class _AuftragBearbeitenScreenState extends State { if ((sigBytes == null || sigBytes.isEmpty) && sigUrl != null && sigUrl.isNotEmpty) { - sigBytes = await PdfExportService.ladeUrl(sigUrl); + sigBytes = await _bytesFuerReferenz(sigUrl); } final fotoBytes = []; - for (final url in _fotoUrls) { - fotoBytes.add(await PdfExportService.ladeUrl(url)); + for (final ref in _fotoUrls) { + fotoBytes.add(await _bytesFuerReferenz(ref)); } final auftrag = _aktuellerAuftrag(sigUrl); @@ -216,13 +533,21 @@ class _AuftragBearbeitenScreenState extends State { ); } - Future _pdfTeilen() async { + Future _pdfErstellenUndSenden() async { setState(() => _saving = true); try { + final ok = await _speichernIntern(zeigeSnacks: true); + if (!ok) return; final file = await _pdfDateiErzeugen(); if (file == null) return; final rn = _rechnungsnummer.text.trim(); await PdfExportService.teilen(file, rechnungsnummer: rn); + await PdfHistoryService.add( + title: _titel.text.trim().isEmpty ? 'Auftrag' : _titel.text.trim(), + rechnungsnummer: rn, + ); + await WorkflowMetricsService.recordPdfExported(); + if (mounted) _snack('PDF erstellt – Teilen geöffnet.'); } catch (e) { _snack('PDF / Teilen: $e'); } finally { @@ -233,6 +558,8 @@ class _AuftragBearbeitenScreenState extends State { Future _emailMitPdf() async { setState(() => _saving = true); try { + final ok = await _speichernIntern(zeigeSnacks: true); + if (!ok) return; final file = await _pdfDateiErzeugen(); if (file == null) return; @@ -250,6 +577,11 @@ class _AuftragBearbeitenScreenState extends State { attachmentPaths: [file.path], ), ); + await PdfHistoryService.add( + title: _titel.text.trim().isEmpty ? 'Auftrag' : _titel.text.trim(), + rechnungsnummer: rn, + ); + await WorkflowMetricsService.recordPdfExported(); } catch (e) { _snack('E-Mail: $e (Mail-App / Konto prüfen)'); } finally { @@ -257,202 +589,562 @@ class _AuftragBearbeitenScreenState extends State { } } + Widget _fotoSlot({ + required IconData icon, + required String label, + required VoidCallback? onTap, + }) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + width: 88, + height: 88, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade700, width: 1.5), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: Colors.grey.shade600, size: 28), + const SizedBox(height: 4), + Text( + label, + style: TextStyle(fontSize: 10, color: Colors.grey.shade600), + ), + ], + ), + ), + ), + ); + } + + Widget _fotoLeerHinzufuegen() { + return _fotoSlot( + icon: Icons.add_photo_alternate_outlined, + label: 'Galerie', + onTap: _saving ? null : _fotosHinzufuegen, + ); + } + + Widget _fotoKameraSlot() { + return _fotoSlot( + icon: Icons.photo_camera_outlined, + label: 'Kamera', + onTap: _saving ? null : _fotoKamera, + ); + } + @override Widget build(BuildContext context) { if (_loading) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), + return Scaffold( + backgroundColor: AppTheme.background, + body: const Center(child: CircularProgressIndicator()), ); } return Scaffold( + backgroundColor: AppTheme.background, appBar: AppBar( - title: Text(_isNeu ? 'Neue Rechnung' : 'Rechnung bearbeiten'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(context).pop(), + ), + title: Text(_isNeu ? 'Neuer Auftrag' : 'Auftrag bearbeiten'), + actions: [ + PopupMenuButton( + enabled: !_saving, + icon: const Icon(Icons.more_vert), + color: AppTheme.card, + onSelected: (v) { + if (v == 'mahnung') { + _kopieMitTyp(DokumentTyp.mahnung, numPrefix: 'MAH'); + } else if (v == 'angebot') { + _kopieMitTyp(DokumentTyp.angebot, numPrefix: 'AN'); + } else if (v == 'rechnung') { + _kopieMitTyp(DokumentTyp.rechnung, numPrefix: 'RE'); + } + }, + itemBuilder: (context) => const [ + PopupMenuItem( + value: 'mahnung', + child: Text('Neue Mahnung (Kopie)'), + ), + PopupMenuItem( + value: 'angebot', + child: Text('Kopie als Angebot'), + ), + PopupMenuItem( + value: 'rechnung', + child: Text('Kopie als Rechnung'), + ), + ], + ), + TextButton( + onPressed: _saving ? null : _speichernTapped, + child: const Text( + 'Speichern', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ), + ], ), body: Stack( children: [ ListView( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.fromLTRB(20, 8, 20, 120), children: [ TextField( - controller: _rechnungsnummer, + controller: _kunde, + textCapitalization: TextCapitalization.words, decoration: const InputDecoration( - labelText: 'Rechnungsnummer', - hintText: 'Wird beim Speichern automatisch gesetzt, wenn leer', + labelText: 'Kunde', + hintText: 'z. B. Müller GmbH', ), ), - const SizedBox(height: 12), + const SizedBox(height: 14), + TextField( + controller: _kundenAdresse, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration( + labelText: 'Adresse', + hintText: 'Hauptstr. 12, 67655 Kaiserslautern', + ), + ), + const SizedBox(height: 14), TextField( controller: _titel, + textCapitalization: TextCapitalization.sentences, decoration: const InputDecoration( - labelText: 'Leistung / Titel', + labelText: 'Leistung / Kurztitel', hintText: 'z. B. Elektroinstallation Küche', ), ), - const SizedBox(height: 12), - TextField( - controller: _kunde, - decoration: const InputDecoration( - labelText: 'Kunde (Name)', - ), + const SizedBox(height: 18), + Text( + 'Status', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Colors.grey.shade400, + fontWeight: FontWeight.w600, + ), ), - const SizedBox(height: 12), - TextField( - controller: _kundenEmail, - keyboardType: TextInputType.emailAddress, - autocorrect: false, - decoration: const InputDecoration( - labelText: 'Kunden-E-Mail', - hintText: 'Für „Per E-Mail senden“', - ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: AuftragStatus.values.map((s) { + final sel = _status == s; + return FilterChip( + label: Text(s.labelDe), + selected: sel, + onSelected: (_) { + if (_saving) return; + setState(() => _status = s); + }, + selectedColor: + AppTheme.accentCyan.withValues(alpha: 0.35), + checkmarkColor: Colors.black, + labelStyle: TextStyle( + color: sel ? Colors.black : Colors.white70, + fontWeight: sel ? FontWeight.w600 : FontWeight.w400, + ), + ); + }).toList(), ), - const SizedBox(height: 12), - TextField( - controller: _betrag, - keyboardType: TextInputType.text, - decoration: const InputDecoration( - labelText: 'Betrag brutto (€)', - hintText: 'z. B. 1.234,56', - ), + const SizedBox(height: 20), + Text( + 'Dokumenttyp', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Colors.grey.shade400, + fontWeight: FontWeight.w600, + ), ), - const SizedBox(height: 12), - TextField( - controller: _beschreibung, - minLines: 3, - maxLines: 8, - decoration: const InputDecoration( - labelText: 'Beschreibung / Positionen', - alignLabelWithHint: true, - ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: DokumentTyp.values.map((t) { + final sel = _dokumentTyp == t; + return FilterChip( + label: Text(t.labelDe, style: const TextStyle(fontSize: 12)), + selected: sel, + onSelected: (_) { + if (_saving) return; + setState(() => _dokumentTyp = t); + }, + selectedColor: + AppTheme.accentCyan.withValues(alpha: 0.35), + checkmarkColor: Colors.black, + labelStyle: TextStyle( + color: sel ? Colors.black : Colors.white70, + fontWeight: sel ? FontWeight.w600 : FontWeight.w400, + ), + ); + }).toList(), ), - const SizedBox(height: 24), + const SizedBox(height: 18), + Text( + 'Zahlung', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Colors.grey.shade400, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: ZahlungsStatus.values.map((z) { + final sel = _zahlungsStatus == z; + return FilterChip( + label: Text(z.labelDe), + selected: sel, + onSelected: (_) { + if (_saving) return; + setState(() => _zahlungsStatus = z); + }, + selectedColor: + AppTheme.accentCyan.withValues(alpha: 0.35), + checkmarkColor: Colors.black, + labelStyle: TextStyle( + color: sel ? Colors.black : Colors.white70, + fontWeight: sel ? FontWeight.w600 : FontWeight.w400, + ), + ); + }).toList(), + ), + const SizedBox(height: 10), Row( children: [ - Text( - 'Fotos', - style: Theme.of(context).textTheme.titleMedium, + Expanded( + child: OutlinedButton( + onPressed: _saving ? null : _pickLeistungsdatum, + child: Text( + _leistungsDatum == null + ? 'Leistungsdatum' + : 'Leistung: ${_datDe.format(_leistungsDatum!)}', + overflow: TextOverflow.ellipsis, + ), + ), ), - const Spacer(), - FilledButton.tonalIcon( - onPressed: _saving ? null : _fotosHinzufuegen, - icon: const Icon(Icons.add_photo_alternate_outlined), - label: const Text('Hinzufügen'), + const SizedBox(width: 10), + Expanded( + child: OutlinedButton( + onPressed: _saving ? null : _pickFaelligAm, + child: Text( + _faelligAm == null + ? 'Fällig am' + : 'Fällig: ${_datDe.format(_faelligAm!)}', + overflow: TextOverflow.ellipsis, + ), + ), ), ], ), + const SizedBox(height: 6), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: _saving + ? null + : () => setState(() { + _leistungsDatum = null; + _faelligAm = null; + }), + child: const Text('Daten löschen'), + ), + ), const SizedBox(height: 8), - if (_fotoUrls.isEmpty) - Text( - 'Noch keine Fotos.', - style: Theme.of(context).textTheme.bodySmall, - ) - else - SizedBox( - height: 100, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: _fotoUrls.length, - separatorBuilder: (context, i) => const SizedBox(width: 8), - itemBuilder: (context, i) { - return Stack( - clipBehavior: Clip.none, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.network( - _fotoUrls[i], - width: 100, - height: 100, - fit: BoxFit.cover, - errorBuilder: (context, error, stack) => Container( - width: 100, - height: 100, - color: Colors.grey.shade300, - child: const Icon(Icons.broken_image), - ), - ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + 'Beschreibung / Positionen', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Colors.grey.shade400, + fontWeight: FontWeight.w600, ), - Positioned( - top: -4, - right: -4, - child: IconButton.filled( - style: IconButton.styleFrom( - padding: const EdgeInsets.all(4), - minimumSize: const Size(28, 28), - ), - onPressed: - _saving ? null : () => _fotoEntfernen(i), - icon: const Icon(Icons.close, size: 16), - ), - ), - ], - ); - }, + ), + ), + IconButton( + tooltip: _speechListening ? 'Stopp' : 'Spracheingabe', + onPressed: _saving ? null : _toggleSpeech, + icon: Icon( + _speechListening ? Icons.mic : Icons.mic_none_outlined, + color: _speechListening + ? AppTheme.accentCyan + : Colors.grey, + ), + ), + TextButton( + onPressed: _saving ? null : _schnellpositionDialog, + child: const Text('Schnellposition'), + ), + ], + ), + const SizedBox(height: 6), + TextField( + controller: _beschreibung, + minLines: 4, + maxLines: 8, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration( + labelText: 'Text & Positionen', + hintText: 'Beschreibung eingeben…', + alignLabelWithHint: true, + ), + ), + Theme( + data: Theme.of(context).copyWith( + dividerColor: Colors.transparent, + listTileTheme: const ListTileThemeData( + iconColor: AppTheme.accentCyan, ), ), - const SizedBox(height: 24), + child: ExpansionTile( + tilePadding: EdgeInsets.zero, + title: Text( + 'Steuern, Skonto & SEPA-QR', + style: TextStyle( + color: Colors.grey.shade400, + fontWeight: FontWeight.w500, + ), + ), + children: [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Kleinunternehmer §19 UStG'), + value: _kleinunternehmer, + onChanged: _saving + ? null + : (v) => setState(() => _kleinunternehmer = v), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Reverse Charge'), + value: _reverseCharge, + onChanged: _saving + ? null + : (v) => setState(() => _reverseCharge = v), + ), + TextField( + controller: _skonto, + decoration: const InputDecoration( + labelText: 'Skonto / Zahlungsbedingungen', + hintText: 'z. B. 2% bei Zahlung innerhalb 14 Tagen', + ), + ), + const SizedBox(height: 12), + TextField( + controller: _ustId, + autocorrect: false, + decoration: const InputDecoration( + labelText: 'USt-IdNr. Kunde (optional)', + hintText: 'DE123456789', + ), + ), + const SizedBox(height: 12), + TextField( + controller: _iban, + autocorrect: false, + decoration: const InputDecoration( + labelText: 'Ihre IBAN (für QR-Zahlung im PDF)', + hintText: 'DE…', + ), + ), + const SizedBox(height: 12), + TextField( + controller: _bic, + autocorrect: false, + decoration: const InputDecoration( + labelText: 'BIC (optional, für QR)', + ), + ), + const SizedBox(height: 12), + TextField( + controller: _kontoinhaber, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration( + labelText: 'Kontoinhaber (Empfängername QR)', + ), + ), + const SizedBox(height: 8), + ], + ), + ), + const SizedBox(height: 22), + Text( + 'Fotos', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 10), + SizedBox( + height: 92, + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + for (var i = 0; i < _fotoUrls.length; i++) + Padding( + padding: const EdgeInsets.only(right: 10), + child: Stack( + clipBehavior: Clip.none, + children: [ + _fotoVorschau(_fotoUrls[i]), + Positioned( + top: -6, + right: -6, + child: IconButton.filled( + style: IconButton.styleFrom( + padding: const EdgeInsets.all(2), + minimumSize: const Size(26, 26), + backgroundColor: Colors.black87, + ), + onPressed: + _saving ? null : () => _fotoEntfernen(i), + icon: const Icon(Icons.close, size: 14), + ), + ), + ], + ), + ), + _fotoLeerHinzufuegen(), + Padding( + padding: const EdgeInsets.only(left: 10), + child: _fotoKameraSlot(), + ), + ], + ), + ), + const SizedBox(height: 22), Text( 'Unterschrift Kunde', - style: Theme.of(context).textTheme.titleMedium, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), ), - const SizedBox(height: 4), + const SizedBox(height: 6), if (_signaturVorhanden && _signatur.isEmpty) Padding( padding: const EdgeInsets.only(bottom: 8), child: Text( 'Es liegt bereits eine Unterschrift. Neu zeichnen und ' 'speichern ersetzt sie.', - style: Theme.of(context).textTheme.bodySmall, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade500, + ), ), ), ClipRRect( borderRadius: BorderRadius.circular(12), child: SizedBox( - height: 180, + height: 160, child: Signature( key: const Key('signatur'), controller: _signatur, - backgroundColor: Colors.grey.shade200, + backgroundColor: Colors.grey.shade300, ), ), ), - TextButton.icon( - onPressed: _saving ? null : () => _signatur.clear(), - icon: const Icon(Icons.clear), - label: const Text('Unterschrift leeren'), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: _saving ? null : () => _signatur.clear(), + icon: const Icon(Icons.clear, size: 18), + label: const Text('Unterschrift leeren'), + style: TextButton.styleFrom(foregroundColor: Colors.grey), + ), ), - const SizedBox(height: 24), - FilledButton.icon( - onPressed: _saving ? null : _speichern, - icon: const Icon(Icons.save_outlined), - label: const Text('Rechnung speichern'), + Theme( + data: Theme.of(context).copyWith( + dividerColor: Colors.transparent, + listTileTheme: const ListTileThemeData( + iconColor: AppTheme.accentCyan, + ), + ), + child: ExpansionTile( + tilePadding: EdgeInsets.zero, + title: Text( + 'Rechnung & Kontakt', + style: TextStyle( + color: Colors.grey.shade400, + fontWeight: FontWeight.w500, + ), + ), + children: [ + TextField( + controller: _rechnungsnummer, + decoration: const InputDecoration( + labelText: 'Rechnungsnummer', + hintText: 'Wird beim Speichern gesetzt, wenn leer', + ), + ), + const SizedBox(height: 12), + TextField( + controller: _kundenEmail, + keyboardType: TextInputType.emailAddress, + autocorrect: false, + decoration: const InputDecoration( + labelText: 'Kunden-E-Mail', + hintText: 'Für „Per E-Mail senden“', + ), + ), + const SizedBox(height: 12), + TextField( + controller: _betrag, + keyboardType: TextInputType.text, + decoration: const InputDecoration( + labelText: 'Betrag brutto (€)', + hintText: 'z. B. 1.234,56', + ), + ), + const SizedBox(height: 8), + ], + ), ), - const SizedBox(height: 8), - OutlinedButton.icon( - onPressed: _saving ? null : _pdfTeilen, - icon: const Icon(Icons.share_outlined), - label: const Text('PDF erzeugen & teilen'), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + height: 52, + child: FilledButton( + onPressed: _saving ? null : _pdfErstellenUndSenden, + child: const Text('PDF erstellen & senden'), + ), ), - const SizedBox(height: 8), - OutlinedButton.icon( - onPressed: _saving ? null : _emailMitPdf, - icon: const Icon(Icons.email_outlined), - label: const Text('PDF per E-Mail senden'), + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + height: 52, + child: OutlinedButton.icon( + onPressed: _saving ? null : _emailMitPdf, + icon: const Icon(Icons.email_outlined, size: 20), + label: const Text('PDF per E-Mail senden'), + style: OutlinedButton.styleFrom( + foregroundColor: AppTheme.accentCyan, + side: const BorderSide(color: Color(0xFF404040)), + padding: const EdgeInsets.symmetric(vertical: 14), + ), + ), ), - const SizedBox(height: 8), - Text( - 'E-Mail: Öffnet die Mail-App mit Anhang. Auf dem Simulator ' - 'oft nicht verfügbar – echtes Gerät nutzen. ' - 'Kunden-E-Mail oben ausfüllen oder Empfänger in der Mail-App wählen.', - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 32), ], ), if (_saving) const ModalBarrier( dismissible: false, - color: Color(0x33000000), + color: Color(0x66000000), ), if (_saving) const Center(child: CircularProgressIndicator()), diff --git a/screens/auth/auth_screen.dart b/screens/auth/auth_screen.dart index f53e230..c32de76 100644 --- a/screens/auth/auth_screen.dart +++ b/screens/auth/auth_screen.dart @@ -1,9 +1,14 @@ -import 'package:firebase_auth/firebase_auth.dart'; +import 'package:appwrite/appwrite.dart'; import 'package:flutter/material.dart'; -/// Screen 1: Login & Registrierung (E-Mail / Passwort) über Firebase Auth. +import '../../appwrite_config.dart'; +import '../../theme/app_theme.dart'; + +/// Screen 1: Login & Registrierung (E-Mail / Passwort) über Appwrite. class AuthScreen extends StatefulWidget { - const AuthScreen({super.key}); + const AuthScreen({super.key, required this.onLoggedIn}); + + final VoidCallback onLoggedIn; @override State createState() => _AuthScreenState(); @@ -12,6 +17,7 @@ class AuthScreen extends StatefulWidget { class _AuthScreenState extends State with SingleTickerProviderStateMixin { late final TabController _tabController; + final _account = Account(appwriteClient); final _loginEmail = TextEditingController(); final _loginPassword = TextEditingController(); @@ -44,23 +50,27 @@ class _AuthScreenState extends State ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); } - String _authMessage(FirebaseAuthException e) { - switch (e.code) { - case 'user-not-found': - case 'wrong-password': - case 'invalid-credential': - return 'E-Mail oder Passwort ist falsch.'; - case 'email-already-in-use': - return 'Diese E-Mail ist bereits registriert.'; - case 'weak-password': - return 'Passwort ist zu schwach (mindestens 6 Zeichen).'; - case 'invalid-email': - return 'Ungültige E-Mail-Adresse.'; - case 'network-request-failed': - return 'Netzwerkfehler. Internet prüfen.'; - default: - return e.message ?? 'Fehler: ${e.code}'; + String _authMessage(AppwriteException e) { + final t = e.type ?? ''; + final m = (e.message ?? '').toLowerCase(); + if (t == 'user_invalid_credentials' || + m.contains('invalid credentials') || + m.contains('wrong password')) { + return 'E-Mail oder Passwort ist falsch.'; } + if (t == 'user_already_exists' || m.contains('already exists')) { + return 'Diese E-Mail ist bereits registriert.'; + } + if (m.contains('password') && m.contains('short')) { + return 'Passwort ist zu schwach (mindestens 8 Zeichen in Appwrite).'; + } + if (t == 'general_argument_invalid' && m.contains('email')) { + return 'Ungültige E-Mail-Adresse.'; + } + if (e.code == 0 && m.contains('network')) { + return 'Netzwerkfehler. Internet prüfen.'; + } + return e.message?.isNotEmpty == true ? e.message! : 'Fehler: $t'; } Future _login() async { @@ -72,11 +82,12 @@ class _AuthScreenState extends State } setState(() => _loading = true); try { - await FirebaseAuth.instance.signInWithEmailAndPassword( + await _account.createEmailPasswordSession( email: email, password: password, ); - } on FirebaseAuthException catch (e) { + if (mounted) widget.onLoggedIn(); + } on AppwriteException catch (e) { _snack(_authMessage(e)); } catch (e) { _snack('Unerwarteter Fehler: $e'); @@ -93,20 +104,29 @@ class _AuthScreenState extends State _snack('E-Mail und Passwort eingeben.'); return; } - if (password.length < 6) { - _snack('Passwort mindestens 6 Zeichen (Firebase).'); + if (password.length < 8) { + _snack('Passwort mindestens 8 Zeichen (Appwrite-Standard).'); return; } setState(() => _loading = true); try { - final cred = await FirebaseAuth.instance.createUserWithEmailAndPassword( + await _account.create( + userId: ID.unique(), + email: email, + password: password, + name: name.isEmpty ? null : name, + ); + await _account.createEmailPasswordSession( email: email, password: password, ); - if (name.isNotEmpty && cred.user != null) { - await cred.user!.updateDisplayName(name); + if (name.isNotEmpty) { + try { + await _account.updateName(name: name); + } catch (_) {} } - } on FirebaseAuthException catch (e) { + if (mounted) widget.onLoggedIn(); + } on AppwriteException catch (e) { _snack(_authMessage(e)); } catch (e) { _snack('Unerwarteter Fehler: $e'); @@ -120,27 +140,29 @@ class _AuthScreenState extends State final scheme = Theme.of(context).colorScheme; return Scaffold( + backgroundColor: AppTheme.background, body: SafeArea( child: ListView( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), children: [ const SizedBox(height: 24), - Icon(Icons.construction, size: 48, color: scheme.primary), + Icon(Icons.handyman_rounded, size: 48, color: scheme.primary), const SizedBox(height: 16), Text( - 'Handwerksapp', + 'HandwerkPro', textAlign: TextAlign.center, style: Theme.of(context).textTheme.headlineMedium?.copyWith( fontWeight: FontWeight.bold, + color: Colors.white, ), ), const SizedBox(height: 8), Text( - 'Nach dem Login: Aufträge anlegen, Fotos, Kunden-Unterschrift, PDF teilen.\n' - 'Zuerst: Konto anlegen oder anmelden.', + 'Aufträge, Fotos, Unterschrift, PDF – alles in einer App. ' + 'Anmelden oder registrieren (Appwrite).', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: scheme.onSurfaceVariant, + color: const Color(0xFFB0B0B0), ), ), const SizedBox(height: 32), @@ -269,7 +291,9 @@ class _RegisterForm extends StatelessWidget { controller: password, obscureText: true, autofillHints: const [AutofillHints.newPassword], - decoration: const InputDecoration(labelText: 'Passwort (min. 6 Zeichen)'), + decoration: const InputDecoration( + labelText: 'Passwort (min. 8 Zeichen)', + ), ), const Spacer(), FilledButton( diff --git a/screens/firebase_setup_required_screen.dart b/screens/firebase_setup_required_screen.dart deleted file mode 100644 index ccbae0f..0000000 --- a/screens/firebase_setup_required_screen.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:flutter/material.dart'; - -/// Wird angezeigt, wenn Firebase.initializeApp fehlschlägt (z. B. Platzhalter-Keys). -class FirebaseSetupRequiredScreen extends StatelessWidget { - const FirebaseSetupRequiredScreen({super.key, required this.error}); - - final Object error; - - @override - Widget build(BuildContext context) { - return MaterialApp( - home: Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Firebase einrichten', - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 16), - const Text( - '1. In der Firebase Console ein Projekt anlegen.\n' - '2. Android-App (Package com.example.handwerksapp) und iOS-App ' - '(Bundle com.example.handwerksapp) registrieren.\n' - '3. Authentication → E-Mail/Passwort aktivieren.\n' - '4. Im Projektordner im Terminal:\n\n' - ' dart pub global activate flutterfire_cli\n' - ' flutterfire configure\n\n' - ' Das überschreibt lib/firebase_options.dart und die ' - 'google-services / GoogleService-Info Dateien.', - ), - const SizedBox(height: 16), - Text( - 'Technische Meldung:\n$error', - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/screens/home/auftraege_home_screen.dart b/screens/home/auftraege_home_screen.dart deleted file mode 100644 index 401ce30..0000000 --- a/screens/home/auftraege_home_screen.dart +++ /dev/null @@ -1,161 +0,0 @@ -import 'package:firebase_auth/firebase_auth.dart'; -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; - -import '../../models/auftrag.dart'; -import '../../services/auftrag_repository.dart'; -import '../auftrag/auftrag_bearbeiten_screen.dart'; - -class AuftraegeHomeScreen extends StatelessWidget { - const AuftraegeHomeScreen({super.key}); - - @override - Widget build(BuildContext context) { - final repo = AuftragRepository(); - final user = FirebaseAuth.instance.currentUser; - final name = user?.displayName?.trim(); - final scheme = Theme.of(context).colorScheme; - - return Scaffold( - appBar: AppBar( - title: const Text('Meine Rechnungen'), - actions: [ - IconButton( - icon: const Icon(Icons.logout), - tooltip: 'Abmelden', - onPressed: () => FirebaseAuth.instance.signOut(), - ), - ], - ), - body: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), - child: Card( - color: scheme.primaryContainer.withValues(alpha: 0.35), - child: Padding( - padding: const EdgeInsets.all(14), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.info_outline, color: scheme.primary), - const SizedBox(width: 12), - Expanded( - child: Text( - 'Nach dem Login bist du hier. Tippe unten auf ' - '„Neue Rechnung“: Daten eintragen, Fotos & Unterschrift, ' - 'dann PDF erzeugen oder per E-Mail senden.\n\n' - 'Wichtig: Nach großen Code-Änderungen App neu starten ' - '(Stop ▶), kein reines Hot Reload.', - style: Theme.of(context).textTheme.bodySmall, - ), - ), - ], - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), - child: Text( - name != null && name.isNotEmpty ? 'Hallo, $name' : 'Übersicht', - style: Theme.of(context).textTheme.titleLarge, - ), - ), - Expanded( - child: StreamBuilder>( - stream: repo.watchAuftraege(), - builder: (context, snapshot) { - if (snapshot.hasError) { - return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Text( - 'Rechnungen konnten nicht geladen werden.\n' - 'Firestore aktiviert und Sicherheitsregeln gesetzt?\n\n' - '${snapshot.error}', - textAlign: TextAlign.center, - ), - ), - ); - } - if (!snapshot.hasData) { - return const Center(child: CircularProgressIndicator()); - } - final list = snapshot.data!; - if (list.isEmpty) { - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Text( - 'Noch keine Rechnungen.\n' - 'Unten auf „Neue Rechnung“ tippen.', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - ), - ), - ); - } - return ListView.separated( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 88), - itemCount: list.length, - separatorBuilder: (context, i) => const SizedBox(height: 8), - itemBuilder: (context, i) { - final a = list[i]; - final datum = a.createdAt != null - ? DateFormat('dd.MM.yyyy').format(a.createdAt!) - : ''; - final nr = a.rechnungsnummer.isNotEmpty - ? a.rechnungsnummer - : 'ohne Nr.'; - return Card( - clipBehavior: Clip.antiAlias, - child: ListTile( - title: Text( - a.titel.isEmpty ? '(Ohne Titel)' : a.titel, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - '$nr · ${a.kundenName.isEmpty ? "Kunde —" : a.kundenName}' - '${datum.isNotEmpty ? " · $datum" : ""}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: const Icon(Icons.chevron_right), - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - AuftragBearbeitenScreen(auftragId: a.id), - ), - ); - }, - ), - ); - }, - ); - }, - ), - ), - ], - ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const AuftragBearbeitenScreen(), - ), - ); - }, - icon: const Icon(Icons.add), - label: const Text('Neue Rechnung'), - ), - ); - } -} diff --git a/screens/legal/legal_document_screen.dart b/screens/legal/legal_document_screen.dart new file mode 100644 index 0000000..e03c9e1 --- /dev/null +++ b/screens/legal/legal_document_screen.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import '../../theme/app_theme.dart'; + +class LegalDocumentScreen extends StatelessWidget { + const LegalDocumentScreen({ + super.key, + required this.title, + required this.body, + this.showDisclaimer = false, + }); + + final String title; + final String body; + final bool showDisclaimer; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.background, + appBar: AppBar( + title: Text(title), + ), + body: ListView( + padding: const EdgeInsets.all(20), + children: [ + if (showDisclaimer) ...[ + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppTheme.statusGeplant.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppTheme.statusGeplant.withValues(alpha: 0.4), + ), + ), + child: const Text( + 'Hinweis: Unverbindliche Vorlage – bitte rechtlich prüfen lassen.', + style: TextStyle( + color: Color(0xFFFFCC80), + height: 1.35, + ), + ), + ), + const SizedBox(height: 20), + ], + Text( + body.trim(), + style: TextStyle( + color: Colors.grey.shade300, + height: 1.45, + fontSize: 14, + ), + ), + ], + ), + ); + } +} diff --git a/screens/onboarding/onboarding_screen.dart b/screens/onboarding/onboarding_screen.dart new file mode 100644 index 0000000..1a9aadc --- /dev/null +++ b/screens/onboarding/onboarding_screen.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; + +import '../../theme/app_theme.dart'; + +class OnboardingScreen extends StatefulWidget { + const OnboardingScreen({super.key, required this.onComplete}); + + final Future Function() onComplete; + + @override + State createState() => _OnboardingScreenState(); +} + +class _OnboardingScreenState extends State { + final _pageController = PageController(); + int _page = 0; + static const _total = 4; + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + Future _next() async { + if (_page < _total - 1) { + await _pageController.nextPage( + duration: const Duration(milliseconds: 320), + curve: Curves.easeOutCubic, + ); + } else { + await widget.onComplete(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.background, + body: SafeArea( + child: Column( + children: [ + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () => widget.onComplete(), + child: const Text('Überspringen'), + ), + ), + Expanded( + child: PageView( + controller: _pageController, + onPageChanged: (i) => setState(() => _page = i), + children: const [ + _OnboardingPage( + icon: Icons.handyman_rounded, + title: 'Willkommen bei HandwerkPro', + text: + 'Erfasse Aufträge mit Fotos, Kundenadresse und ' + 'Unterschrift – alles strukturiert an einem Ort.', + ), + _OnboardingPage( + icon: Icons.cloud_done_outlined, + title: 'Deine Daten', + text: + 'Nach dem Login werden Aufträge sicher gespeichert. ' + 'Im Profil findest du Impressum, Datenschutz und einen ' + 'JSON-Export deiner Auftragsdaten.', + ), + _OnboardingPage( + icon: Icons.picture_as_pdf_outlined, + title: 'PDF & Teilen', + text: + 'Aus jedem Auftrag erzeugst du eine PDF-Rechnung und ' + 'teilst sie oder sendest sie per E-Mail – ohne ' + 'Steuerberatung, aber dokumentationsfest.', + ), + _OnboardingPage( + icon: Icons.account_balance_wallet_outlined, + title: 'Workflow fürs Handwerk', + text: + 'Angebot, Leistung, Rechnung, Mahnung – ein Vorgang, ' + 'ohne doppelte Kundendaten. Kleinunternehmer-Hinweis, ' + 'Skonto, SEPA-QR und CSV-Export für dein Steuerbüro.', + ), + ], + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + _total, + (i) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: CircleAvatar( + radius: 4, + backgroundColor: i == _page + ? AppTheme.accentCyan + : Colors.grey.shade700, + ), + ), + ), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), + child: SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _next, + child: Text(_page < _total - 1 ? 'Weiter' : 'Los geht’s'), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _OnboardingPage extends StatelessWidget { + const _OnboardingPage({ + required this.icon, + required this.title, + required this.text, + }); + + final IconData icon; + final String title; + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 72, color: AppTheme.accentCyan), + const SizedBox(height: 28), + Text( + title, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Text( + text, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.grey.shade400, + height: 1.45, + fontSize: 15, + ), + ), + ], + ), + ); + } +} diff --git a/screens/shell/main_shell_screen.dart b/screens/shell/main_shell_screen.dart new file mode 100644 index 0000000..e84c7ff --- /dev/null +++ b/screens/shell/main_shell_screen.dart @@ -0,0 +1,1153 @@ +import 'package:appwrite/appwrite.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../appwrite_config.dart'; +import '../../config/appwrite_rechnungen_setup.dart'; +import '../../config/branding.dart'; +import '../../utils/appwrite_error_message.dart'; +import '../../legal/legal_content.dart'; +import '../../models/auftrag.dart'; +import '../../models/auftrag_list_sort.dart'; +import '../../models/auftrag_status.dart'; +import '../../models/dokument_typ.dart'; +import '../../models/zahlungs_status.dart'; +import '../../services/auftrag_repository.dart'; +import '../../services/data_export_service.dart'; +import '../../services/pdf_history_service.dart'; +import '../../services/workflow_metrics_service.dart'; +import '../../theme/app_theme.dart'; +import '../auftrag/auftrag_bearbeiten_screen.dart'; +import '../legal/legal_document_screen.dart'; + +/// Haupt-Navigation wie im Mockup: Start, Aufträge, PDFs, Profil. +class MainShellScreen extends StatefulWidget { + const MainShellScreen({super.key, this.onLoggedOut}); + + final VoidCallback? onLoggedOut; + + @override + State createState() => _MainShellScreenState(); +} + +class _MainShellScreenState extends State { + final _repo = AuftragRepository(); + final _account = Account(appwriteClient); + final _searchCtrl = TextEditingController(); + + int _tab = 0; + String? _name; + String? _email; + List _list = []; + bool _loading = true; + String? _error; + AuftragListenSort _sort = AuftragListenSort.datumNeu; + List _pdfHistory = []; + String _appVersion = ''; + WorkflowMetricsSnapshot? _metrics; + + @override + void initState() { + super.initState(); + _searchCtrl.addListener(() => setState(() {})); + _refresh(); + _loadPdfHistory(); + _loadMetrics(); + PackageInfo.fromPlatform().then((p) { + if (mounted) { + setState(() => _appVersion = '${p.version} (${p.buildNumber})'); + } + }); + } + + Future _loadPdfHistory() async { + final h = await PdfHistoryService.load(); + if (mounted) setState(() => _pdfHistory = h); + } + + Future _loadMetrics() async { + final m = await WorkflowMetricsService.load(); + if (mounted) setState(() => _metrics = m); + } + + @override + void dispose() { + _searchCtrl.dispose(); + super.dispose(); + } + + List get _filteredList { + final q = _searchCtrl.text.trim().toLowerCase(); + if (q.isEmpty) return _list; + bool hit(String s) => s.toLowerCase().contains(q); + return _list.where((a) { + return hit(a.titel) || + hit(a.kundenName) || + hit(a.beschreibung) || + hit(a.kundenAdresse) || + hit(a.rechnungsnummer) || + hit(a.dokumentTyp.labelDe) || + hit(a.zahlungsStatus.labelDe); + }).toList(); + } + + Future _refresh() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final u = await _account.get(); + final list = await _repo.listAuftraege(); + if (!mounted) return; + setState(() { + _name = u.name; + _email = u.email; + _list = list; + _loading = false; + }); + await _loadPdfHistory(); + await _loadMetrics(); + } catch (e) { + if (!mounted) return; + setState(() { + _error = nachrichtFuerAppwriteDatenFehler(e); + _loading = false; + }); + } + } + + Future _openEditor({String? auftragId}) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => auftragId != null + ? AuftragBearbeitenScreen(auftragId: auftragId) + : const AuftragBearbeitenScreen(), + ), + ); + if (mounted) await _refresh(); + } + + List get _sortedAll => sortAuftraege(_list, _sort); + + List get _filteredSorted => + sortAuftraege(_filteredList, _sort); + + Widget _retryLoadSliver() { + return SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Daten konnten nicht geladen werden.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey.shade400), + ), + const SizedBox(height: 8), + SelectableText( + _error ?? '', + textAlign: TextAlign.center, + style: const TextStyle( + color: Color(0xFF888888), + fontSize: 12, + height: 1.35, + ), + ), + const SizedBox(height: 20), + FilledButton.icon( + onPressed: _loading ? null : _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Erneut versuchen'), + ), + ], + ), + ), + ), + ); + } + + bool _istForderungsDokument(DokumentTyp t) => + t == DokumentTyp.rechnung || + t == DokumentTyp.abschlag || + t == DokumentTyp.schlussrechnung || + t == DokumentTyp.mahnung; + + int get _offenePostenCount => _list.where((a) { + return _istForderungsDokument(a.dokumentTyp) && + a.zahlungsStatus == ZahlungsStatus.offen; + }).length; + + int get _ueberfaelligCount { + final heute = DateTime.now(); + final tagHeute = DateTime(heute.year, heute.month, heute.day); + return _list.where((a) { + if (a.zahlungsStatus == ZahlungsStatus.bezahlt) return false; + final f = a.faelligAm; + if (f == null) return false; + final tf = DateTime(f.year, f.month, f.day); + return !tf.isAfter(tagHeute); + }).length; + } + + Future _exportData() async { + try { + final list = await _repo.listAuftraege(); + final file = await DataExportService.exportAuftraegeToTempFile(list); + if (!mounted) return; + await SharePlus.instance.share( + ShareParams( + files: [XFile(file.path)], + subject: '$kAppDisplayName Datenexport', + ), + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Export vorbereitet.')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Export fehlgeschlagen: $e')), + ); + } + } + } + + Future _exportCsv() async { + try { + final list = await _repo.listAuftraege(); + final file = await DataExportService.exportAuftraegeCsvToTempFile(list); + if (!mounted) return; + await SharePlus.instance.share( + ShareParams( + files: [XFile(file.path)], + subject: '$kAppDisplayName CSV-Export', + ), + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('CSV-Export vorbereitet (Excel DE).')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('CSV-Export fehlgeschlagen: $e')), + ); + } + } + } + + Future _openSupportMail() async { + final uri = Uri.parse( + 'mailto:$kSupportEmail?subject=${Uri.encodeComponent('$kAppDisplayName Support')}', + ); + if (await canLaunchUrl(uri)) { + await launchUrl(uri); + } else if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Mail-App nicht erreichbar ($kSupportEmail)')), + ); + } + } + + String _greeting() { + final h = DateTime.now().hour; + if (h < 11) return 'Guten Morgen'; + if (h < 18) return 'Guten Tag'; + return 'Guten Abend'; + } + + ({String label, Color color}) _statusBadge(Auftrag a) { + return ( + label: a.status.labelDe, + color: a.status.badgeColor, + ); + } + + String _zeitZeile(Auftrag a) { + final c = a.createdAt ?? DateTime.now(); + final n = DateTime.now(); + final heute = DateTime(n.year, n.month, n.day); + final tag = DateTime(c.year, c.month, c.day); + final morgen = heute.add(const Duration(days: 1)); + final zeit = DateFormat('HH:mm').format(c); + if (tag == heute) return 'Heute, $zeit Uhr'; + if (tag == morgen) return 'Morgen, $zeit Uhr'; + return DateFormat('dd.MM.yyyy, HH:mm').format(c); + } + + int get _heuteCount { + final n = DateTime.now(); + return _list.where((a) { + final c = a.createdAt; + return c != null && + c.year == n.year && + c.month == n.month && + c.day == n.day; + }).length; + } + + int get _offenCount => + _list.where((a) => a.status == AuftragStatus.offen).length; + + int get _erledigtCount => + _list.where((a) => a.status == AuftragStatus.fertig).length; + + @override + Widget build(BuildContext context) { + final name = _name?.trim().isNotEmpty == true ? _name!.trim() : 'Handwerker'; + + return Scaffold( + body: IndexedStack( + index: _tab, + children: [ + _buildStartTab(context, name), + _buildAuftraegeTab(context), + _buildPdfsTab(context), + _buildProfilTab(context, name), + ], + ), + bottomNavigationBar: NavigationBar( + height: 72, + selectedIndex: _tab, + onDestinationSelected: (i) { + setState(() => _tab = i); + if (i == 2) _loadPdfHistory(); + }, + backgroundColor: AppTheme.card, + indicatorColor: AppTheme.accentCyan.withValues(alpha: 0.22), + labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, + destinations: [ + NavigationDestination( + icon: const Icon(Icons.home_outlined), + selectedIcon: const Icon(Icons.home, color: AppTheme.accentCyan), + label: 'Start', + ), + NavigationDestination( + icon: const Icon(Icons.assignment_outlined), + selectedIcon: + const Icon(Icons.assignment, color: AppTheme.accentCyan), + label: 'Aufträge', + ), + NavigationDestination( + icon: const Icon(Icons.picture_as_pdf_outlined), + selectedIcon: + const Icon(Icons.picture_as_pdf, color: AppTheme.accentCyan), + label: 'PDFs', + ), + NavigationDestination( + icon: const Icon(Icons.person_outline), + selectedIcon: const Icon(Icons.person, color: AppTheme.accentCyan), + label: 'Profil', + ), + ], + ), + floatingActionButton: (_tab == 0 || _tab == 1) + ? FloatingActionButton( + onPressed: _loading ? null : () => _openEditor(), + child: const Icon(Icons.add), + ) + : null, + floatingActionButtonLocation: FloatingActionButtonLocation.startFloat, + ); + } + + Widget _purpleAppBar({ + required String title, + List? actions, + }) { + return SliverAppBar( + pinned: true, + backgroundColor: AppTheme.headerPurple, + foregroundColor: Colors.white, + title: Text( + title, + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 20), + ), + actions: actions, + ); + } + + Widget _buildStartTab(BuildContext context, String displayName) { + return RefreshIndicator( + color: AppTheme.accentCyan, + onRefresh: _refresh, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + _purpleAppBar( + title: 'HandwerkPro', + actions: [ + IconButton( + icon: const Icon(Icons.notifications_none), + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Benachrichtigungen folgen.')), + ); + }, + ), + IconButton( + icon: const Icon(Icons.account_circle_outlined), + onPressed: () => setState(() => _tab = 3), + ), + ], + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 8), + child: Text( + '${_greeting()}, $displayName 👷', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Expanded( + child: _StatCard( + label: 'Offen', + value: _loading ? '…' : '$_offenCount', + ), + ), + const SizedBox(width: 10), + Expanded( + child: _StatCard( + label: 'Heute', + value: _loading ? '…' : '$_heuteCount', + ), + ), + const SizedBox(width: 10), + Expanded( + child: _StatCard( + label: 'Erledigt', + value: _loading ? '…' : '$_erledigtCount', + ), + ), + ], + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 0), + child: Row( + children: [ + Expanded( + child: _StatCard( + label: 'Offene Posten', + value: _loading ? '…' : '$_offenePostenCount', + ), + ), + const SizedBox(width: 10), + Expanded( + child: _StatCard( + label: 'Überfällig', + value: _loading ? '…' : '$_ueberfaelligCount', + ), + ), + const SizedBox(width: 10), + Expanded( + child: _StatCard( + label: 'PDFs (Woche)', + value: _metrics == null + ? '…' + : '${_metrics!.pdfThisCalendarWeek}', + ), + ), + ], + ), + ), + ), + if (_metrics != null && (_metrics!.pdfAllTime > 0 || _metrics!.lastPdfAt != null)) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), + child: Text( + _metrics!.lastPdfAt != null + ? 'Zuletzt PDF: ${DateFormat('dd.MM.yyyy HH:mm').format(_metrics!.lastPdfAt!)} · ' + 'gesamt ${_metrics!.pdfAllTime}' + : 'PDFs gesamt: ${_metrics!.pdfAllTime}', + style: TextStyle(color: Colors.grey.shade600, fontSize: 12), + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 24, 8, 8), + child: Row( + children: [ + Text( + 'Aufträge', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + const SizedBox(width: 4), + PopupMenuButton( + initialValue: _sort, + tooltip: 'Sortierung', + color: AppTheme.card, + onSelected: (v) => setState(() => _sort = v), + itemBuilder: (context) => AuftragListenSort.values + .map( + (s) => PopupMenuItem( + value: s, + child: Text(s.label), + ), + ) + .toList(), + child: Icon( + Icons.sort_rounded, + color: Colors.grey.shade500, + size: 22, + ), + ), + const Spacer(), + TextButton( + onPressed: () => setState(() => _tab = 1), + child: const Text( + 'Alle anzeigen →', + style: TextStyle(color: AppTheme.accentCyan), + ), + ), + ], + ), + ), + ), + if (_loading) + const SliverFillRemaining( + hasScrollBody: false, + child: Center(child: CircularProgressIndicator()), + ) + else if (_error != null) _retryLoadSliver() + else if (_list.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Text( + 'Noch keine Aufträge.\nTippe auf + für einen neuen Auftrag.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: const Color(0xFF9E9E9E), + ), + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 100), + sliver: SliverList.separated( + itemCount: _sortedAll.length > 5 ? 5 : _sortedAll.length, + separatorBuilder: (context, index) => + const SizedBox(height: 10), + itemBuilder: (context, i) { + final a = _sortedAll[i]; + final st = _statusBadge(a); + return _AuftragKarte( + titel: a.titel.isEmpty ? '(Ohne Titel)' : a.titel, + untertitel: _zeitZeile(a), + kunde: a.kundenName.isEmpty ? 'Kunde' : a.kundenName, + metaZeile: + '${a.dokumentTyp.labelDe} · ${a.zahlungsStatus.labelDe}', + statusLabel: st.label, + statusColor: st.color, + onTap: () => _openEditor(auftragId: a.id), + ); + }, + ), + ), + ], + ), + ); + } + + Widget _buildAuftraegeTab(BuildContext context) { + final filtered = _filteredSorted; + return RefreshIndicator( + color: AppTheme.accentCyan, + onRefresh: _refresh, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + _purpleAppBar(title: 'Aufträge'), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: TextField( + controller: _searchCtrl, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + hintText: 'Suche Titel, Kunde, Adresse, Nr. …', + hintStyle: TextStyle(color: Colors.grey.shade600), + prefixIcon: + Icon(Icons.search, color: Colors.grey.shade500), + suffixIcon: _searchCtrl.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + color: Colors.grey.shade500, + onPressed: () { + _searchCtrl.clear(); + setState(() {}); + }, + ) + : null, + filled: true, + fillColor: AppTheme.card, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFF404040)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide( + color: AppTheme.accentCyan, + width: 1.5, + ), + ), + ), + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Row( + children: [ + Text( + 'Sortierung', + style: TextStyle( + color: Colors.grey.shade500, + fontSize: 13, + ), + ), + const SizedBox(width: 12), + Expanded( + child: DropdownButtonFormField( + key: ValueKey(_sort), + initialValue: _sort, + dropdownColor: AppTheme.card, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + filled: true, + fillColor: AppTheme.card, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: + const BorderSide(color: Color(0xFF404040)), + ), + ), + items: AuftragListenSort.values + .map( + (s) => DropdownMenuItem( + value: s, + child: Text(s.label), + ), + ) + .toList(), + onChanged: (v) { + if (v != null) setState(() => _sort = v); + }, + ), + ), + ], + ), + ), + ), + if (_loading) + const SliverFillRemaining( + hasScrollBody: false, + child: Center(child: CircularProgressIndicator()), + ) + else if (_error != null) + _retryLoadSliver() + else if (_list.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Text( + 'Keine Aufträge', + style: TextStyle(color: Colors.grey.shade600), + ), + ), + ) + else if (filtered.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Text( + 'Keine Treffer für die Suche.', + style: TextStyle(color: Colors.grey.shade600), + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 100), + sliver: SliverList.separated( + itemCount: filtered.length, + separatorBuilder: (context, index) => + const SizedBox(height: 10), + itemBuilder: (context, i) { + final a = filtered[i]; + final st = _statusBadge(a); + return _AuftragKarte( + titel: a.titel.isEmpty ? '(Ohne Titel)' : a.titel, + untertitel: _zeitZeile(a), + kunde: a.kundenName.isEmpty ? 'Kunde' : a.kundenName, + metaZeile: + '${a.dokumentTyp.labelDe} · ${a.zahlungsStatus.labelDe}', + statusLabel: st.label, + statusColor: st.color, + onTap: () => _openEditor(auftragId: a.id), + ); + }, + ), + ), + ], + ), + ); + } + + Widget _buildPdfsTab(BuildContext context) { + return CustomScrollView( + slivers: [ + _purpleAppBar(title: 'PDFs'), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Text( + 'Zuletzt erzeugt oder geteilt – nur Metadaten auf diesem Gerät, ' + 'keine PDF-Ablage in der Cloud.', + style: TextStyle(color: Colors.grey.shade600, fontSize: 13), + ), + ), + ), + if (_pdfHistory.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Padding( + padding: const EdgeInsets.all(28), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.picture_as_pdf_outlined, + size: 56, color: Colors.grey.shade700), + const SizedBox(height: 16), + Text( + 'Noch kein PDF über die App geteilt oder per E-Mail gesendet.\n\n' + 'Im Auftrag: „PDF erstellen & senden“ oder „PDF per E-Mail senden“.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.grey.shade500, + height: 1.4, + ), + ), + ], + ), + ), + ) + else ...[ + SliverList( + delegate: SliverChildBuilderDelegate( + (context, i) { + final e = _pdfHistory[i]; + return ListTile( + leading: Icon(Icons.picture_as_pdf, + color: AppTheme.accentCyan), + title: Text( + e.title, + style: const TextStyle(color: Colors.white), + ), + subtitle: Text( + '${e.rechnungsnummer} · ${DateFormat('dd.MM.yyyy HH:mm').format(e.at)}', + style: TextStyle(color: Colors.grey.shade500), + ), + ); + }, + childCount: _pdfHistory.length, + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextButton( + onPressed: () async { + await PdfHistoryService.clear(); + await _loadPdfHistory(); + }, + child: const Text('Verlauf leeren'), + ), + ), + ), + ], + ], + ); + } + + Widget _buildProfilTab(BuildContext context, String displayName) { + void openLegal(String title, String body, {bool disclaimer = false}) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => LegalDocumentScreen( + title: title, + body: body, + showDisclaimer: disclaimer, + ), + ), + ); + } + + return CustomScrollView( + slivers: [ + _purpleAppBar(title: 'Profil'), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + CircleAvatar( + radius: 40, + backgroundColor: AppTheme.accentCyan.withValues(alpha: 0.2), + child: Text( + displayName.isNotEmpty + ? displayName[0].toUpperCase() + : '?', + style: const TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + color: AppTheme.accentCyan, + ), + ), + ), + const SizedBox(height: 16), + Text( + displayName, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + if (_email != null && _email!.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + _email!, + style: TextStyle(color: Colors.grey.shade500), + ), + ], + const SizedBox(height: 8), + Text( + _appVersion.isEmpty ? 'Version …' : 'Version $_appVersion', + style: TextStyle(color: Colors.grey.shade600, fontSize: 12), + ), + const SizedBox(height: 16), + Card( + child: Theme( + data: Theme.of(context).copyWith( + dividerColor: Colors.transparent, + ), + child: ExpansionTile( + tilePadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + title: const Text('Appwrite-Verbindung'), + subtitle: Text( + 'Endpoint, Project & Database-ID – bei Fehler 404 hier prüfen', + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 12, + ), + ), + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: SelectableText( + 'Endpoint:\n$kAppwriteEndpoint\n\n' + 'Project ID:\n$kAppwriteProjectId\n\n' + 'Database ID (in der Console unter Databases ' + 'exakt so, oft ≠ Anzeigename):\n' + '$kAppwriteDatabaseId\n\n' + 'Collection ID:\n$kAppwriteCollectionId\n\n' + 'Bucket ID:\n$kAppwriteBucketId\n\n' + 'IDs anpassen: lib/appwrite_local.dart ' + '(kAppwriteDatabaseIdOverride usw.) oder z. B.\n' + 'flutter run -d macos ' + '--dart-define=APPWRITE_DATABASE_ID=deine_id\n\n' + '── Collection „$kAppwriteCollectionId“ anlegen ──\n\n' + '${appwriteRechnungenCollectionCheckliste()}', + style: TextStyle( + color: Colors.grey.shade400, + fontSize: 12, + height: 1.4, + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + Card( + child: Column( + children: [ + ListTile( + leading: const Icon(Icons.article_outlined, + color: AppTheme.accentCyan), + title: const Text('Impressum'), + trailing: const Icon(Icons.chevron_right), + onTap: () => openLegal( + 'Impressum', + LegalContent.impressum, + disclaimer: true, + ), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.privacy_tip_outlined, + color: AppTheme.accentCyan), + title: const Text('Datenschutz'), + trailing: const Icon(Icons.chevron_right), + onTap: () => openLegal( + 'Datenschutz', + LegalContent.datenschutz, + disclaimer: true, + ), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.gavel_outlined, + color: AppTheme.accentCyan), + title: const Text('AGB'), + trailing: const Icon(Icons.chevron_right), + onTap: () => openLegal( + 'AGB', + LegalContent.agb, + disclaimer: true, + ), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.download_outlined, + color: AppTheme.accentCyan), + title: const Text('Aufträge exportieren (JSON)'), + subtitle: Text( + 'Datei-Fotos & Unterschrift nur als IDs', + style: TextStyle(color: Colors.grey.shade600), + ), + onTap: _exportData, + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.table_chart_outlined, + color: AppTheme.accentCyan), + title: const Text('CSV-Export (Excel / Steuerbüro)'), + subtitle: Text( + 'Semikolon, UTF-8 – grobe DATEV-Vorbereitung', + style: TextStyle(color: Colors.grey.shade600), + ), + onTap: _exportCsv, + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.mail_outline, + color: AppTheme.accentCyan), + title: const Text('Support'), + subtitle: Text(kSupportEmail), + onTap: _openSupportMail, + ), + ], + ), + ), + const SizedBox(height: 12), + Text( + LegalContent.disclaimer, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 11, + height: 1.35, + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () async { + try { + await _account.deleteSession(sessionId: 'current'); + } catch (_) {} + widget.onLoggedOut?.call(); + }, + icon: const Icon(Icons.logout), + label: const Text('Abmelden'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Color(0xFF505050)), + padding: const EdgeInsets.symmetric(vertical: 14), + ), + ), + ), + ], + ), + ), + ), + ], + ); + } +} + +class _StatCard extends StatelessWidget { + const _StatCard({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8), + child: Column( + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade500, + ), + ), + const SizedBox(height: 6), + Text( + value, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + ), + ), + ); + } +} + +class _AuftragKarte extends StatelessWidget { + const _AuftragKarte({ + required this.titel, + required this.untertitel, + required this.kunde, + required this.metaZeile, + required this.statusLabel, + required this.statusColor, + required this.onTap, + }); + + final String titel; + final String untertitel; + final String kunde; + final String metaZeile; + final String statusLabel; + final Color statusColor; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: AppTheme.card, + borderRadius: BorderRadius.circular(16), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + titel, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16, + color: Colors.white, + ), + ), + const SizedBox(height: 4), + Text( + untertitel, + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade500, + ), + ), + const SizedBox(height: 6), + Text( + kunde, + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade400, + ), + ), + const SizedBox(height: 4), + Text( + metaZeile, + style: TextStyle( + fontSize: 11, + color: Colors.grey.shade600, + ), + ), + ], + ), + ), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + statusLabel, + style: TextStyle( + color: statusColor, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/services/app_preferences.dart b/services/app_preferences.dart new file mode 100644 index 0000000..a059c2f --- /dev/null +++ b/services/app_preferences.dart @@ -0,0 +1,16 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class AppPreferences { + AppPreferences._(); + static const _onboardingKey = 'handwerkpro_onboarding_done_v1'; + + static Future isOnboardingDone() async { + final p = await SharedPreferences.getInstance(); + return p.getBool(_onboardingKey) ?? false; + } + + static Future setOnboardingDone() async { + final p = await SharedPreferences.getInstance(); + await p.setBool(_onboardingKey, true); + } +} diff --git a/services/auftrag_repository.dart b/services/auftrag_repository.dart index 03cb6e9..91041b0 100644 --- a/services/auftrag_repository.dart +++ b/services/auftrag_repository.dart @@ -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> get _col { - final uid = _auth.currentUser?.uid; - if (uid == null) { - throw StateError('Nicht angemeldet'); - } - return _db.collection('users').doc(uid).collection('auftraege'); + Future _uid() async { + final u = await _account.get(); + return u.$id; } - Stream> watchAuftraege() { - return _col.orderBy('createdAt', descending: true).snapshots().map( - (snap) => snap.docs.map(Auftrag.fromDoc).toList(), - ); + List _docPermissions(String uid) => [ + Permission.read(Role.user(uid)), + Permission.update(Role.user(uid)), + Permission.delete(Role.user(uid)), + ]; + + Future> 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 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 speichern({ required String id, required Auftrag daten, bool isNeu = false, }) async { - final payload = daten.toMap(); + final uid = await _uid(); + final payload = Map.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 loeschen(String id) => _col.doc(id).delete(); + Future loeschen(String id) async { + await _db.deleteDocument( + databaseId: kAppwriteDatabaseId, + collectionId: kAppwriteCollectionId, + documentId: id, + ); + } } diff --git a/services/auftrag_storage_service.dart b/services/auftrag_storage_service.dart index f29eb56..96f3484 100644 --- a/services/auftrag_storage_service.dart +++ b/services/auftrag_storage_service.dart @@ -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 _uid() async { + final u = await _account.get(); + return u.$id; } - Future hochladenFoto(String auftragId, Uint8List bytes, String dateiname) async { - final ref = _basis(auftragId).child('fotos/$dateiname'); - await ref.putData( - bytes, - SettableMetadata(contentType: 'image/jpeg'), + List _filePermissions(String uid) => [ + Permission.read(Role.user(uid)), + Permission.update(Role.user(uid)), + Permission.delete(Role.user(uid)), + ]; + + Future 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 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 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 loescheDatei(String fileId) async { + if (fileId.startsWith('http://') || fileId.startsWith('https://')) { + return; + } + try { + await _storage.deleteFile( + bucketId: kAppwriteBucketId, + fileId: fileId, + ); + } catch (_) {} + } } diff --git a/services/data_export_service.dart b/services/data_export_service.dart new file mode 100644 index 0000000..e9d6417 --- /dev/null +++ b/services/data_export_service.dart @@ -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 exportAuftraegeCsvToTempFile(List 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 exportAuftraegeToTempFile(List 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; + } +} diff --git a/services/german_amount_parser.dart b/services/german_amount_parser.dart new file mode 100644 index 0000000..8e482bd --- /dev/null +++ b/services/german_amount_parser.dart @@ -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)}'; + } +} diff --git a/services/pdf_export_service.dart b/services/pdf_export_service.dart index eab523c..4ee160a 100644 --- a/services/pdf_export_service.dart +++ b/services/pdf_export_service.dart @@ -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 buildPdf({ required Auftrag auftrag, required List 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 = []; + 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 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 ladeReferenz( + String ref, { + Future 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 teilen(File file, {String? rechnungsnummer}) async { await SharePlus.instance.share( ShareParams( diff --git a/services/pdf_history_service.dart b/services/pdf_history_service.dart new file mode 100644 index 0000000..f24434a --- /dev/null +++ b/services/pdf_history_service.dart @@ -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 toJson() => { + 'at': at.toIso8601String(), + 'title': title, + 'rechnungsnummer': rechnungsnummer, + }; + + factory PdfHistoryEntry.fromJson(Map 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> 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; + return list + .map((e) => PdfHistoryEntry.fromJson(Map.from(e as Map))) + .toList(); + } catch (_) { + return []; + } + } + + static Future 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 clear() async { + final p = await SharedPreferences.getInstance(); + await p.remove(_key); + } +} diff --git a/services/position_from_text_parser.dart b/services/position_from_text_parser.dart new file mode 100644 index 0000000..10d7aa1 --- /dev/null +++ b/services/position_from_text_parser.dart @@ -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'; + } +} diff --git a/services/sepa_qr_data.dart b/services/sepa_qr_data.dart new file mode 100644 index 0000000..218f51c --- /dev/null +++ b/services/sepa_qr_data.dart @@ -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 = [ + '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); + } +} diff --git a/services/workflow_metrics_service.dart b/services/workflow_metrics_service.dart new file mode 100644 index 0000000..1d6c424 --- /dev/null +++ b/services/workflow_metrics_service.dart @@ -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 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 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 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 = {}; + 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 savesByDokumentTyp; +} diff --git a/theme/app_theme.dart b/theme/app_theme.dart index 0664ba7..2dacedb 100644 --- a/theme/app_theme.dart +++ b/theme/app_theme.dart @@ -1,37 +1,102 @@ import 'package:flutter/material.dart'; -/// Ruhiges, seriöses Farbschema für Handwerks-Betriebe (Elektrik, Maler, SHK). +/// HandwerkPro: Dark UI mit Lila-Header und Türkis-Akzenten (Mockup). class AppTheme { AppTheme._(); - static const Color _seed = Color(0xFF0D47A1); + static const Color background = Color(0xFF121212); + static const Color card = Color(0xFF1E1E1E); + static const Color headerPurple = Color(0xFF4A148C); + static const Color headerPurpleLight = Color(0xFF6A1B9A); + static const Color accentCyan = Color(0xFF00E5FF); + static const Color statusOffen = Color(0xFF2196F3); + static const Color statusFertig = Color(0xFF4CAF50); + static const Color statusGeplant = Color(0xFFFF9800); - static ThemeData light() { - final scheme = ColorScheme.fromSeed( - seedColor: _seed, - brightness: Brightness.light, - surface: const Color(0xFFF5F7FA), - ); - return ThemeData( + static ThemeData dark() { + final base = ThemeData( useMaterial3: true, - colorScheme: scheme, - appBarTheme: AppBarTheme( - centerTitle: true, - backgroundColor: scheme.surface, - foregroundColor: scheme.onSurface, + brightness: Brightness.dark, + scaffoldBackgroundColor: background, + ); + return base.copyWith( + colorScheme: ColorScheme.dark( + surface: background, + primary: accentCyan, + onPrimary: Colors.black, + secondary: headerPurpleLight, + onSecondary: Colors.white, + tertiary: accentCyan, + surfaceContainerHighest: card, + onSurface: Colors.white, + onSurfaceVariant: Color(0xFFB0B0B0), + outline: Color(0xFF404040), + ), + appBarTheme: const AppBarTheme( + backgroundColor: headerPurple, + foregroundColor: Colors.white, elevation: 0, - scrolledUnderElevation: 1, + centerTitle: false, + scrolledUnderElevation: 0, + ), + cardTheme: CardThemeData( + color: card, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + margin: EdgeInsets.zero, ), inputDecorationTheme: InputDecorationTheme( filled: true, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + fillColor: card, + hintStyle: const TextStyle(color: Color(0xFF888888)), + labelStyle: const TextStyle(color: Color(0xFFAAAAAA)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFF404040)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFF404040)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: accentCyan, width: 1.5), + ), ), filledButtonTheme: FilledButtonThemeData( style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + backgroundColor: accentCyan, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), ), ), + floatingActionButtonTheme: const FloatingActionButtonThemeData( + backgroundColor: accentCyan, + foregroundColor: Colors.black, + elevation: 4, + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: card, + indicatorColor: accentCyan.withValues(alpha: 0.25), + labelTextStyle: WidgetStateProperty.resolveWith((s) { + final sel = s.contains(WidgetState.selected); + return TextStyle( + fontSize: 12, + fontWeight: sel ? FontWeight.w600 : FontWeight.w400, + color: sel ? accentCyan : const Color(0xFF9E9E9E), + ); + }), + iconTheme: WidgetStateProperty.resolveWith((s) { + final sel = s.contains(WidgetState.selected); + return IconThemeData(color: sel ? accentCyan : const Color(0xFF9E9E9E)); + }), + ), + tabBarTheme: TabBarThemeData( + labelColor: accentCyan, + unselectedLabelColor: const Color(0xFF9E9E9E), + indicatorColor: accentCyan, + ), ); } } diff --git a/utils/appwrite_error_message.dart b/utils/appwrite_error_message.dart new file mode 100644 index 0000000..8b2a0ca --- /dev/null +++ b/utils/appwrite_error_message.dart @@ -0,0 +1,63 @@ +import 'package:appwrite/appwrite.dart'; + +import '../appwrite_config.dart'; +import '../config/appwrite_rechnungen_setup.dart'; + +/// Verständliche Meldung beim Laden von Aufträgen / Account (Haupt-Shell). +String nachrichtFuerAppwriteDatenFehler(Object error) { + if (error is! AppwriteException) { + return error.toString(); + } + final e = error; + final typ = e.type ?? ''; + final msgLower = (e.message ?? '').toLowerCase(); + + // Appwrite-Texte sprechen manchmal von „API key“ — die App nutzt aber die + // **Nutzer-Session** (nach Login), nicht die Datei `.appwrite_api_key`. + if (msgLower.contains('api') && msgLower.contains('key')) { + return 'Die App verwendet beim normalen Betrieb **keinen** Server-API-Key ' + '(die Datei `.appwrite_api_key` ist nur fürs Einrichtungs-Skript).\n\n' + 'Wenn Appwrite trotzdem von einem Key spricht, liegt es meist an:\n' + '• Session abgelaufen oder ungültig → **Abmelden und neu anmelden**\n' + '• Falsche **Project-ID** oder **Endpoint** in der App\n' + '• Unter Appwrite **Settings → Platforms** die Plattform ' + '(Bundle-ID / Hostname) fehlt → „Unauthorized“ / merkwürdige Meldungen\n\n' + 'Technisch: ${e.toString()}'; + } + + switch (typ) { + case 'database_not_found': + return 'Auf dem Appwrite-Server gibt es keine Datenbank mit der ID ' + '„$kAppwriteDatabaseId“.\n\n' + 'So behebst du das:\n' + '• Appwrite Console öffnen → dein Projekt → Databases → ' + '„Create database“ → als Database ID exakt ' + '„$kAppwriteDatabaseId“ eintragen\n' + 'oder\n' + '• In lib/appwrite_local.dart bei kAppwriteDatabaseIdOverride die ' + 'ID eintragen (oder --dart-define=APPWRITE_DATABASE_ID=…).\n\n' + 'Technisch: ${e.toString()}'; + case 'collection_not_found': + return 'Die Collection „$kAppwriteCollectionId“ fehlt in der Datenbank ' + '„$kAppwriteDatabaseId“.\n\n' + '${appwriteRechnungenCollectionCheckliste()}\n\n' + 'Technisch: ${e.toString()}'; + case 'bucket_not_found': + return 'Der Storage-Bucket „$kAppwriteBucketId“ wurde nicht gefunden.\n\n' + 'Lege ihn in Storage an oder trage die Bucket-ID in ' + 'lib/appwrite_local.dart (kAppwriteBucketIdOverride) ein.\n\n' + 'Technisch: ${e.toString()}'; + case 'project_not_found': + return 'Das Appwrite-Projekt wurde nicht gefunden (falsche Project-ID ' + 'oder Endpoint). Prüfe setProject(...) in lib/appwrite_config.dart ' + 'und den Endpoint.\n\n' + 'Technisch: ${e.toString()}'; + case 'user_unauthorized': + case 'general_unauthorized_scope': + return 'Keine Berechtigung für diese Aktion. Bitte erneut anmelden ' + 'oder in der Appwrite Console die Collection-/DB-Rechte prüfen.\n\n' + 'Technisch: ${e.toString()}'; + default: + return e.toString(); + } +}