Feature
ein paar feature aber datenbank macht probleme wenn man aufträge speichern möchge
This commit is contained in:
79
appwrite_config.dart
Normal file
79
appwrite_config.dart
Normal file
@@ -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);
|
||||
15
appwrite_local.dart
Normal file
15
appwrite_local.dart
Normal file
@@ -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 = '';
|
||||
29
config/appwrite_rechnungen_setup.dart
Normal file
29
config/appwrite_rechnungen_setup.dart
Normal file
@@ -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.''';
|
||||
}
|
||||
15
config/branding.dart
Normal file
15
config/branding.dart
Normal file
@@ -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]';
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
91
legal/legal_content.dart
Normal file
91
legal/legal_content.dart
Normal file
@@ -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.
|
||||
''';
|
||||
}
|
||||
96
main.dart
96
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<void> 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<HandwerksApp> createState() => _HandwerksAppState();
|
||||
}
|
||||
|
||||
class _HandwerksAppState extends State<HandwerksApp> {
|
||||
bool _checking = true;
|
||||
bool _loggedIn = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_probeSession();
|
||||
}
|
||||
|
||||
Future<void> _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<User?>(
|
||||
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);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> 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<Map<String, dynamic>> 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<String> _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<String, dynamic>? _parseExtendedJson(String? raw) {
|
||||
if (raw == null || raw.trim().isEmpty) return null;
|
||||
try {
|
||||
final o = jsonDecode(raw);
|
||||
if (o is Map<String, dynamic>) return o;
|
||||
if (o is Map) return Map<String, dynamic>.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 <String>[],
|
||||
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<String, dynamic> 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<String, dynamic> toMap() {
|
||||
return {
|
||||
'extendedJson': _encodeAppwriteExtendedJson(),
|
||||
};
|
||||
}
|
||||
|
||||
/// JSON-fähige Darstellung für Datenauskunft/Export (ohne Binärdateien).
|
||||
Map<String, dynamic> 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<String>? 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
41
models/auftrag_list_sort.dart
Normal file
41
models/auftrag_list_sort.dart
Normal file
@@ -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<Auftrag> sortAuftraege(List<Auftrag> input, AuftragListenSort sort) {
|
||||
final copy = List<Auftrag>.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;
|
||||
}
|
||||
40
models/auftrag_status.dart
Normal file
40
models/auftrag_status.dart
Normal file
@@ -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;
|
||||
}
|
||||
54
models/dokument_typ.dart
Normal file
54
models/dokument_typ.dart
Normal file
@@ -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;
|
||||
}
|
||||
34
models/zahlungs_status.dart
Normal file
34
models/zahlungs_status.dart
Normal file
@@ -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;
|
||||
}
|
||||
55
screens/app_logged_in_gate.dart
Normal file
55
screens/app_logged_in_gate.dart
Normal file
@@ -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<AppLoggedInGate> createState() => _AppLoggedInGateState();
|
||||
}
|
||||
|
||||
class _AppLoggedInGateState extends State<AppLoggedInGate> {
|
||||
bool _prefsReady = false;
|
||||
bool _onboardingDone = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPrefs();
|
||||
}
|
||||
|
||||
Future<void> _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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<AuthScreen> createState() => _AuthScreenState();
|
||||
@@ -12,6 +17,7 @@ class AuthScreen extends StatefulWidget {
|
||||
class _AuthScreenState extends State<AuthScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabController;
|
||||
final _account = Account(appwriteClient);
|
||||
|
||||
final _loginEmail = TextEditingController();
|
||||
final _loginPassword = TextEditingController();
|
||||
@@ -44,23 +50,27 @@ class _AuthScreenState extends State<AuthScreen>
|
||||
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<void> _login() async {
|
||||
@@ -72,11 +82,12 @@ class _AuthScreenState extends State<AuthScreen>
|
||||
}
|
||||
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<AuthScreen>
|
||||
_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<AuthScreen>
|
||||
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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<List<Auftrag>>(
|
||||
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<void>(
|
||||
builder: (_) =>
|
||||
AuftragBearbeitenScreen(auftragId: a.id),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const AuftragBearbeitenScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Neue Rechnung'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
59
screens/legal/legal_document_screen.dart
Normal file
59
screens/legal/legal_document_screen.dart
Normal file
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
164
screens/onboarding/onboarding_screen.dart
Normal file
164
screens/onboarding/onboarding_screen.dart
Normal file
@@ -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<void> Function() onComplete;
|
||||
|
||||
@override
|
||||
State<OnboardingScreen> createState() => _OnboardingScreenState();
|
||||
}
|
||||
|
||||
class _OnboardingScreenState extends State<OnboardingScreen> {
|
||||
final _pageController = PageController();
|
||||
int _page = 0;
|
||||
static const _total = 4;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1153
screens/shell/main_shell_screen.dart
Normal file
1153
screens/shell/main_shell_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
16
services/app_preferences.dart
Normal file
16
services/app_preferences.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class AppPreferences {
|
||||
AppPreferences._();
|
||||
static const _onboardingKey = 'handwerkpro_onboarding_done_v1';
|
||||
|
||||
static Future<bool> isOnboardingDone() async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
return p.getBool(_onboardingKey) ?? false;
|
||||
}
|
||||
|
||||
static Future<void> setOnboardingDone() async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
await p.setBool(_onboardingKey, true);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,89 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
// Appwrite 1.8+ empfiehlt TablesDB; klassische Collections laufen weiter über Databases.
|
||||
// ignore_for_file: deprecated_member_use
|
||||
|
||||
import 'package:appwrite/appwrite.dart';
|
||||
|
||||
import '../appwrite_config.dart';
|
||||
import '../models/auftrag.dart';
|
||||
|
||||
class AuftragRepository {
|
||||
AuftragRepository({FirebaseFirestore? firestore, FirebaseAuth? auth})
|
||||
: _db = firestore ?? FirebaseFirestore.instance,
|
||||
_auth = auth ?? FirebaseAuth.instance;
|
||||
AuftragRepository({Client? client})
|
||||
: _db = Databases(client ?? appwriteClient),
|
||||
_account = Account(client ?? appwriteClient);
|
||||
|
||||
final FirebaseFirestore _db;
|
||||
final FirebaseAuth _auth;
|
||||
final Databases _db;
|
||||
final Account _account;
|
||||
|
||||
CollectionReference<Map<String, dynamic>> get _col {
|
||||
final uid = _auth.currentUser?.uid;
|
||||
if (uid == null) {
|
||||
throw StateError('Nicht angemeldet');
|
||||
}
|
||||
return _db.collection('users').doc(uid).collection('auftraege');
|
||||
Future<String> _uid() async {
|
||||
final u = await _account.get();
|
||||
return u.$id;
|
||||
}
|
||||
|
||||
Stream<List<Auftrag>> watchAuftraege() {
|
||||
return _col.orderBy('createdAt', descending: true).snapshots().map(
|
||||
(snap) => snap.docs.map(Auftrag.fromDoc).toList(),
|
||||
);
|
||||
List<String> _docPermissions(String uid) => [
|
||||
Permission.read(Role.user(uid)),
|
||||
Permission.update(Role.user(uid)),
|
||||
Permission.delete(Role.user(uid)),
|
||||
];
|
||||
|
||||
Future<List<Auftrag>> listAuftraege() async {
|
||||
final uid = await _uid();
|
||||
final res = await _db.listDocuments(
|
||||
databaseId: kAppwriteDatabaseId,
|
||||
collectionId: kAppwriteCollectionId,
|
||||
queries: [
|
||||
Query.equal('userId', uid),
|
||||
Query.orderDesc(r'$createdAt'),
|
||||
],
|
||||
);
|
||||
return res.documents.map(Auftrag.fromAppwriteDoc).toList();
|
||||
}
|
||||
|
||||
Future<Auftrag?> get(String id) async {
|
||||
final doc = await _col.doc(id).get();
|
||||
if (!doc.exists) return null;
|
||||
return Auftrag.fromDoc(doc);
|
||||
try {
|
||||
final doc = await _db.getDocument(
|
||||
databaseId: kAppwriteDatabaseId,
|
||||
collectionId: kAppwriteCollectionId,
|
||||
documentId: id,
|
||||
);
|
||||
return Auftrag.fromAppwriteDoc(doc);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Neue Dokument-ID ohne Schreibzugriff (für Storage-Pfade vor erstem Speichern).
|
||||
String neueId() => _col.doc().id;
|
||||
String neueId() => ID.unique();
|
||||
|
||||
Future<void> speichern({
|
||||
required String id,
|
||||
required Auftrag daten,
|
||||
bool isNeu = false,
|
||||
}) async {
|
||||
final payload = daten.toMap();
|
||||
final uid = await _uid();
|
||||
final payload = Map<String, dynamic>.from(daten.toMap());
|
||||
payload['userId'] = uid;
|
||||
if (isNeu) {
|
||||
payload['createdAt'] = FieldValue.serverTimestamp();
|
||||
await _db.createDocument(
|
||||
databaseId: kAppwriteDatabaseId,
|
||||
collectionId: kAppwriteCollectionId,
|
||||
documentId: id,
|
||||
data: payload,
|
||||
permissions: _docPermissions(uid),
|
||||
);
|
||||
} else {
|
||||
await _db.updateDocument(
|
||||
databaseId: kAppwriteDatabaseId,
|
||||
collectionId: kAppwriteCollectionId,
|
||||
documentId: id,
|
||||
data: payload,
|
||||
);
|
||||
}
|
||||
await _col.doc(id).set(payload, SetOptions(merge: true));
|
||||
}
|
||||
|
||||
Future<void> loeschen(String id) => _col.doc(id).delete();
|
||||
Future<void> loeschen(String id) async {
|
||||
await _db.deleteDocument(
|
||||
databaseId: kAppwriteDatabaseId,
|
||||
collectionId: kAppwriteCollectionId,
|
||||
documentId: id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,81 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:firebase_storage/firebase_storage.dart';
|
||||
import 'package:appwrite/appwrite.dart';
|
||||
|
||||
import '../appwrite_config.dart';
|
||||
|
||||
class AuftragStorageService {
|
||||
AuftragStorageService({FirebaseStorage? storage, FirebaseAuth? auth})
|
||||
: _storage = storage ?? FirebaseStorage.instance,
|
||||
_auth = auth ?? FirebaseAuth.instance;
|
||||
AuftragStorageService({Client? client})
|
||||
: _storage = Storage(client ?? appwriteClient),
|
||||
_account = Account(client ?? appwriteClient);
|
||||
|
||||
final FirebaseStorage _storage;
|
||||
final FirebaseAuth _auth;
|
||||
final Storage _storage;
|
||||
final Account _account;
|
||||
|
||||
Reference _basis(String auftragId) {
|
||||
final uid = _auth.currentUser?.uid;
|
||||
if (uid == null) throw StateError('Nicht angemeldet');
|
||||
return _storage.ref('users/$uid/auftraege/$auftragId');
|
||||
Future<String> _uid() async {
|
||||
final u = await _account.get();
|
||||
return u.$id;
|
||||
}
|
||||
|
||||
Future<String> hochladenFoto(String auftragId, Uint8List bytes, String dateiname) async {
|
||||
final ref = _basis(auftragId).child('fotos/$dateiname');
|
||||
await ref.putData(
|
||||
bytes,
|
||||
SettableMetadata(contentType: 'image/jpeg'),
|
||||
List<String> _filePermissions(String uid) => [
|
||||
Permission.read(Role.user(uid)),
|
||||
Permission.update(Role.user(uid)),
|
||||
Permission.delete(Role.user(uid)),
|
||||
];
|
||||
|
||||
Future<String> hochladenFoto(
|
||||
String auftragId,
|
||||
Uint8List bytes,
|
||||
String dateiname,
|
||||
) async {
|
||||
final uid = await _uid();
|
||||
final fileId = ID.unique();
|
||||
await _storage.createFile(
|
||||
bucketId: kAppwriteBucketId,
|
||||
fileId: fileId,
|
||||
file: InputFile.fromBytes(
|
||||
bytes: bytes,
|
||||
filename: dateiname,
|
||||
contentType: 'image/jpeg',
|
||||
),
|
||||
permissions: _filePermissions(uid),
|
||||
);
|
||||
return ref.getDownloadURL();
|
||||
return fileId;
|
||||
}
|
||||
|
||||
Future<String> hochladenUnterschrift(String auftragId, Uint8List pngBytes) async {
|
||||
final ref = _basis(auftragId).child('unterschrift.png');
|
||||
await ref.putData(
|
||||
pngBytes,
|
||||
SettableMetadata(contentType: 'image/png'),
|
||||
final uid = await _uid();
|
||||
final fileId = ID.unique();
|
||||
await _storage.createFile(
|
||||
bucketId: kAppwriteBucketId,
|
||||
fileId: fileId,
|
||||
file: InputFile.fromBytes(
|
||||
bytes: pngBytes,
|
||||
filename: 'unterschrift_$auftragId.png',
|
||||
contentType: 'image/png',
|
||||
),
|
||||
permissions: _filePermissions(uid),
|
||||
);
|
||||
return ref.getDownloadURL();
|
||||
return fileId;
|
||||
}
|
||||
|
||||
Future<Uint8List> getFileBytes(String fileId) async {
|
||||
return _storage.getFileView(
|
||||
bucketId: kAppwriteBucketId,
|
||||
fileId: fileId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Entfernt die Datei im Bucket (kein Fehler bei HTTP-URLs oder fehlenden Rechten).
|
||||
Future<void> loescheDatei(String fileId) async {
|
||||
if (fileId.startsWith('http://') || fileId.startsWith('https://')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await _storage.deleteFile(
|
||||
bucketId: kAppwriteBucketId,
|
||||
fileId: fileId,
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
96
services/data_export_service.dart
Normal file
96
services/data_export_service.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../models/auftrag.dart';
|
||||
import '../models/auftrag_status.dart';
|
||||
import '../models/dokument_typ.dart';
|
||||
import '../models/zahlungs_status.dart';
|
||||
|
||||
/// Export der Auftragsdaten als JSON (ohne Binärdaten der Fotos/Unterschrift).
|
||||
class DataExportService {
|
||||
DataExportService._();
|
||||
|
||||
static String _csvZelle(String? s) {
|
||||
final t = (s ?? '').replaceAll('"', '""');
|
||||
if (t.contains(';') || t.contains('\n') || t.contains('"')) {
|
||||
return '"$t"';
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
/// Semikolon-CSV (Excel DE) – grobe Vorlage für Steuerbüro / DATEV-Vorbereitung.
|
||||
static Future<File> exportAuftraegeCsvToTempFile(List<Auftrag> list) async {
|
||||
final header = [
|
||||
'Rechnungsnummer',
|
||||
'Dokumenttyp',
|
||||
'Titel',
|
||||
'Kunde',
|
||||
'Adresse',
|
||||
'E-Mail',
|
||||
'Betrag_Text',
|
||||
'Status',
|
||||
'Zahlungsstatus',
|
||||
'Faellig_am',
|
||||
'Leistungsdatum',
|
||||
'Kleinunternehmer',
|
||||
'Reverse_Charge',
|
||||
'Skonto',
|
||||
'USt_Id_Kunde',
|
||||
'IBAN',
|
||||
'BIC',
|
||||
'Kontoinhaber',
|
||||
'Erstellt',
|
||||
];
|
||||
final sb = StringBuffer()
|
||||
..writeln(header.map(_csvZelle).join(';'));
|
||||
for (final a in list) {
|
||||
sb.writeln(
|
||||
[
|
||||
a.rechnungsnummer,
|
||||
a.dokumentTyp.labelDe,
|
||||
a.titel,
|
||||
a.kundenName,
|
||||
a.kundenAdresse,
|
||||
a.kundenEmail,
|
||||
a.betragText,
|
||||
a.status.labelDe,
|
||||
a.zahlungsStatus.labelDe,
|
||||
a.faelligAm?.toIso8601String() ?? '',
|
||||
a.leistungsDatum?.toIso8601String() ?? '',
|
||||
a.kleinunternehmer ? 'ja' : 'nein',
|
||||
a.reverseCharge ? 'ja' : 'nein',
|
||||
a.skontoText,
|
||||
a.ustIdKunde,
|
||||
a.ibanVerkaeufer,
|
||||
a.bicVerkaeufer,
|
||||
a.kontoinhaberVerkaeufer,
|
||||
a.createdAt?.toIso8601String() ?? '',
|
||||
].map(_csvZelle).join(';'),
|
||||
);
|
||||
}
|
||||
final dir = await getTemporaryDirectory();
|
||||
final name =
|
||||
'handwerkpro_export_${DateTime.now().millisecondsSinceEpoch}.csv';
|
||||
final file = File('${dir.path}/$name');
|
||||
await file.writeAsString(sb.toString(), encoding: utf8);
|
||||
return file;
|
||||
}
|
||||
|
||||
static Future<File> exportAuftraegeToTempFile(List<Auftrag> list) async {
|
||||
final payload = {
|
||||
'exportVersion': 4,
|
||||
'exportedAt': DateTime.now().toIso8601String(),
|
||||
'count': list.length,
|
||||
'auftraege': list.map((a) => a.toExportMap()).toList(),
|
||||
};
|
||||
final json = const JsonEncoder.withIndent(' ').convert(payload);
|
||||
final dir = await getTemporaryDirectory();
|
||||
final name =
|
||||
'handwerkpro_export_${DateTime.now().millisecondsSinceEpoch}.json';
|
||||
final file = File('${dir.path}/$name');
|
||||
await file.writeAsString(json);
|
||||
return file;
|
||||
}
|
||||
}
|
||||
29
services/german_amount_parser.dart
Normal file
29
services/german_amount_parser.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
/// Parst typische deutsche Betragsangaben (z. B. `1.234,56`) für QR / Logik.
|
||||
class GermanAmountParser {
|
||||
GermanAmountParser._();
|
||||
|
||||
/// Liefert den Betrag in Euro als [double], oder `null` wenn nicht erkennbar.
|
||||
static double? parseEuro(String raw) {
|
||||
var s = raw.trim();
|
||||
if (s.isEmpty) return null;
|
||||
s = s.replaceAll(RegExp(r'\s'), '');
|
||||
s = s.replaceAll('€', '').replaceAll('EUR', '').trim();
|
||||
if (s.isEmpty) return null;
|
||||
if (s.contains(',')) {
|
||||
s = s.replaceAll('.', '').replaceAll(',', '.');
|
||||
} else {
|
||||
final dotCount = '.'.allMatches(s).length;
|
||||
if (dotCount > 1) {
|
||||
s = s.replaceAll('.', '');
|
||||
}
|
||||
}
|
||||
return double.tryParse(s);
|
||||
}
|
||||
|
||||
/// Betrag für SEPA-QR: `EUR12.34` (Punkt, zwei Nachkommastellen).
|
||||
static String? formatForSepaQr(String betragText) {
|
||||
final v = parseEuro(betragText);
|
||||
if (v == null || v <= 0) return null;
|
||||
return 'EUR${v.toStringAsFixed(2)}';
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,21 @@ import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus, XFile;
|
||||
|
||||
import '../models/auftrag.dart';
|
||||
import '../models/auftrag_status.dart';
|
||||
import '../models/dokument_typ.dart';
|
||||
import '../models/zahlungs_status.dart';
|
||||
import 'sepa_qr_data.dart';
|
||||
|
||||
class PdfExportService {
|
||||
static final _datDe = DateFormat('dd.MM.yyyy');
|
||||
|
||||
static Future<File> buildPdf({
|
||||
required Auftrag auftrag,
|
||||
required List<Uint8List?> fotoBytes,
|
||||
@@ -19,6 +26,8 @@ class PdfExportService {
|
||||
final nr = auftrag.rechnungsnummer.isEmpty
|
||||
? 'Entwurf'
|
||||
: auftrag.rechnungsnummer;
|
||||
final docTitel = auftrag.dokumentTyp.pdfTitel;
|
||||
final epc = SepaQrData.buildEpcString(auftrag);
|
||||
|
||||
doc.addPage(
|
||||
pw.MultiPage(
|
||||
@@ -28,7 +37,7 @@ class PdfExportService {
|
||||
pw.Header(
|
||||
level: 0,
|
||||
child: pw.Text(
|
||||
'Rechnung',
|
||||
docTitel,
|
||||
style: pw.TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
@@ -36,36 +45,91 @@ class PdfExportService {
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Text('Rechnungs-Nr.: $nr',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
)),
|
||||
pw.Text(
|
||||
'Dokument-Nr.: $nr',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 6),
|
||||
pw.Text(
|
||||
'Bearbeitungsstatus: ${auftrag.status.labelDe} · Zahlung: '
|
||||
'${auftrag.zahlungsStatus.labelDe}',
|
||||
style: const pw.TextStyle(fontSize: 10),
|
||||
),
|
||||
pw.SizedBox(height: 16),
|
||||
pw.Text('Leistung / Titel: ${auftrag.titel}',
|
||||
style: const pw.TextStyle(fontSize: 14)),
|
||||
pw.Text(
|
||||
'Leistung / Titel: ${auftrag.titel}',
|
||||
style: const pw.TextStyle(fontSize: 14),
|
||||
),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Text('Kunde: ${auftrag.kundenName}',
|
||||
style: const pw.TextStyle(fontSize: 14)),
|
||||
pw.Text(
|
||||
'Kunde: ${auftrag.kundenName}',
|
||||
style: const pw.TextStyle(fontSize: 14),
|
||||
),
|
||||
if (auftrag.kundenAdresse.isNotEmpty) ...[
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text(
|
||||
'Adresse: ${auftrag.kundenAdresse}',
|
||||
style: const pw.TextStyle(fontSize: 11),
|
||||
),
|
||||
],
|
||||
if (auftrag.kundenEmail.isNotEmpty) ...[
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text('E-Mail: ${auftrag.kundenEmail}',
|
||||
style: const pw.TextStyle(fontSize: 11)),
|
||||
pw.Text(
|
||||
'E-Mail: ${auftrag.kundenEmail}',
|
||||
style: const pw.TextStyle(fontSize: 11),
|
||||
),
|
||||
],
|
||||
if (auftrag.ustIdKunde.trim().isNotEmpty) ...[
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text(
|
||||
'USt-IdNr. Kunde: ${auftrag.ustIdKunde.trim()}',
|
||||
style: const pw.TextStyle(fontSize: 11),
|
||||
),
|
||||
],
|
||||
if (auftrag.leistungsDatum != null) ...[
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Text(
|
||||
'Leistungsdatum: ${_datDe.format(auftrag.leistungsDatum!)}',
|
||||
style: const pw.TextStyle(fontSize: 11),
|
||||
),
|
||||
],
|
||||
if (auftrag.faelligAm != null) ...[
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text(
|
||||
'Zahlungsziel / Fälligkeit: ${_datDe.format(auftrag.faelligAm!)}',
|
||||
style: const pw.TextStyle(fontSize: 11),
|
||||
),
|
||||
],
|
||||
if (auftrag.betragText.isNotEmpty) ...[
|
||||
pw.SizedBox(height: 12),
|
||||
pw.Text('Betrag (Brutto): ${auftrag.betragText} €',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
)),
|
||||
],
|
||||
pw.SizedBox(height: 16),
|
||||
pw.Text('Beschreibung / Positionen:',
|
||||
pw.Text(
|
||||
'Betrag (Brutto): ${auftrag.betragText} €',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 12,
|
||||
fontSize: 14,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
)),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (auftrag.skontoText.trim().isNotEmpty) ...[
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Text(
|
||||
'Skonto / Zahlungsbedingungen: ${auftrag.skontoText.trim()}',
|
||||
style: const pw.TextStyle(fontSize: 10),
|
||||
),
|
||||
],
|
||||
pw.SizedBox(height: 12),
|
||||
_steuerHinweiseBlock(auftrag),
|
||||
pw.SizedBox(height: 16),
|
||||
pw.Text(
|
||||
'Beschreibung / Positionen:',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 6),
|
||||
pw.Text(
|
||||
auftrag.beschreibung.isEmpty ? '—' : auftrag.beschreibung,
|
||||
@@ -73,11 +137,13 @@ class PdfExportService {
|
||||
),
|
||||
pw.SizedBox(height: 20),
|
||||
if (fotoBytes.any((b) => b != null && b.isNotEmpty)) ...[
|
||||
pw.Text('Fotos',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
)),
|
||||
pw.Text(
|
||||
'Fotos',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 8),
|
||||
for (final b in fotoBytes)
|
||||
if (b != null && b.isNotEmpty)
|
||||
@@ -92,11 +158,13 @@ class PdfExportService {
|
||||
],
|
||||
if (unterschriftBytes != null && unterschriftBytes.isNotEmpty) ...[
|
||||
pw.SizedBox(height: 16),
|
||||
pw.Text('Unterschrift Kunde',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
)),
|
||||
pw.Text(
|
||||
'Unterschrift Kunde',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Image(
|
||||
pw.MemoryImage(unterschriftBytes),
|
||||
@@ -104,6 +172,30 @@ class PdfExportService {
|
||||
fit: pw.BoxFit.contain,
|
||||
),
|
||||
],
|
||||
if (epc != null) ...[
|
||||
pw.SizedBox(height: 24),
|
||||
pw.Text(
|
||||
'SEPA-Zahlung (QR-Code für Banking-App)',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.BarcodeWidget(
|
||||
data: epc,
|
||||
barcode: pw.Barcode.qrCode(),
|
||||
drawText: false,
|
||||
width: 132,
|
||||
height: 132,
|
||||
),
|
||||
],
|
||||
pw.SizedBox(height: 28),
|
||||
pw.Text(
|
||||
'Hinweis: Dieses Dokument dient der Dokumentation und ersetzt keine '
|
||||
'steuerliche oder rechtliche Beratung.',
|
||||
style: pw.TextStyle(fontSize: 8, color: PdfColors.grey700),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -111,12 +203,48 @@ class PdfExportService {
|
||||
final bytes = await doc.save();
|
||||
final dir = await getTemporaryDirectory();
|
||||
final safeNr = nr.replaceAll(RegExp(r'[^\w\-]'), '_');
|
||||
final name = 'rechnung_${safeNr.isEmpty ? auftrag.id.substring(0, 8) : safeNr}.pdf';
|
||||
final name =
|
||||
'rechnung_${safeNr.isEmpty ? auftrag.id.substring(0, 8) : safeNr}.pdf';
|
||||
final file = File('${dir.path}/$name');
|
||||
await file.writeAsBytes(bytes);
|
||||
return file;
|
||||
}
|
||||
|
||||
static pw.Widget _steuerHinweiseBlock(Auftrag a) {
|
||||
final parts = <pw.Widget>[];
|
||||
if (a.kleinunternehmer) {
|
||||
parts.add(
|
||||
pw.Text(
|
||||
'§19 UStG: Als Kleinunternehmer wird keine Umsatzsteuer berechnet.',
|
||||
style: const pw.TextStyle(fontSize: 9),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (a.reverseCharge) {
|
||||
parts.add(
|
||||
pw.Text(
|
||||
'Steuerschuldnerschaft des Leistungsempfängers (Reverse Charge) – '
|
||||
'Umsatzsteuer liegt bei Ihnen.',
|
||||
style: const pw.TextStyle(fontSize: 9),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (parts.isEmpty) {
|
||||
return pw.SizedBox(height: 0);
|
||||
}
|
||||
return pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(
|
||||
'Steuerliche Hinweise',
|
||||
style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold),
|
||||
),
|
||||
pw.SizedBox(height: 4),
|
||||
...parts.expand((w) => [w, pw.SizedBox(height: 2)]),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static Future<Uint8List?> ladeUrl(String url) async {
|
||||
try {
|
||||
final r = await http.get(Uri.parse(url));
|
||||
@@ -125,6 +253,22 @@ class PdfExportService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// HTTP-URL oder Appwrite-Datei-ID (über [fileLoader]).
|
||||
static Future<Uint8List?> ladeReferenz(
|
||||
String ref, {
|
||||
Future<Uint8List?> Function(String fileId)? fileLoader,
|
||||
}) async {
|
||||
if (ref.startsWith('http://') || ref.startsWith('https://')) {
|
||||
return ladeUrl(ref);
|
||||
}
|
||||
if (fileLoader != null) {
|
||||
try {
|
||||
return await fileLoader(ref);
|
||||
} catch (_) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<void> teilen(File file, {String? rechnungsnummer}) async {
|
||||
await SharePlus.instance.share(
|
||||
ShareParams(
|
||||
|
||||
76
services/pdf_history_service.dart
Normal file
76
services/pdf_history_service.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class PdfHistoryEntry {
|
||||
PdfHistoryEntry({
|
||||
required this.at,
|
||||
required this.title,
|
||||
required this.rechnungsnummer,
|
||||
});
|
||||
|
||||
final DateTime at;
|
||||
final String title;
|
||||
final String rechnungsnummer;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'at': at.toIso8601String(),
|
||||
'title': title,
|
||||
'rechnungsnummer': rechnungsnummer,
|
||||
};
|
||||
|
||||
factory PdfHistoryEntry.fromJson(Map<String, dynamic> m) {
|
||||
return PdfHistoryEntry(
|
||||
at: DateTime.tryParse(m['at'] as String? ?? '') ?? DateTime.now(),
|
||||
title: m['title'] as String? ?? '',
|
||||
rechnungsnummer: m['rechnungsnummer'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Lokaler Verlauf geteilter/erzeugter PDFs (nur Metadaten, keine Dateiablage).
|
||||
class PdfHistoryService {
|
||||
PdfHistoryService._();
|
||||
static const _key = 'handwerkpro_pdf_history_v1';
|
||||
static const _max = 30;
|
||||
|
||||
static Future<List<PdfHistoryEntry>> load() async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
final raw = p.getString(_key);
|
||||
if (raw == null || raw.isEmpty) return [];
|
||||
try {
|
||||
final list = jsonDecode(raw) as List<dynamic>;
|
||||
return list
|
||||
.map((e) => PdfHistoryEntry.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||
.toList();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> add({
|
||||
required String title,
|
||||
required String rechnungsnummer,
|
||||
}) async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
final existing = await load();
|
||||
existing.insert(
|
||||
0,
|
||||
PdfHistoryEntry(
|
||||
at: DateTime.now(),
|
||||
title: title,
|
||||
rechnungsnummer: rechnungsnummer,
|
||||
),
|
||||
);
|
||||
final trimmed = existing.take(_max).toList();
|
||||
await p.setString(
|
||||
_key,
|
||||
jsonEncode(trimmed.map((e) => e.toJson()).toList()),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> clear() async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
await p.remove(_key);
|
||||
}
|
||||
}
|
||||
46
services/position_from_text_parser.dart
Normal file
46
services/position_from_text_parser.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
/// Heuristik: Freitext wie „2h Elektro Küche“ → Zeile für die Positionsbeschreibung.
|
||||
///
|
||||
/// Kein externes KI-Modell – nur Muster, die auf der Baustelle häufig vorkommen.
|
||||
class PositionFromTextParser {
|
||||
PositionFromTextParser._();
|
||||
|
||||
/// Liefert einen Vorschlag für eine neue Zeile (mehrzeilig anfügbar).
|
||||
static String? vorschlagZeile(String raw) {
|
||||
final s = raw.trim();
|
||||
if (s.isEmpty) return null;
|
||||
|
||||
final stunden = RegExp(
|
||||
r'^(\d+(?:[.,]\d+)?)\s*h(?:\s+|$)',
|
||||
caseSensitive: false,
|
||||
).firstMatch(s);
|
||||
if (stunden != null) {
|
||||
final h = stunden.group(1)!.replaceAll(',', '.');
|
||||
final rest = s.substring(stunden.end).trim();
|
||||
final leistung = rest.isEmpty ? 'Arbeitsleistung' : rest;
|
||||
return '${h}h – $leistung';
|
||||
}
|
||||
|
||||
final euro = RegExp(
|
||||
r'^(\d+(?:[.,]\d+)?)\s*€',
|
||||
caseSensitive: false,
|
||||
).firstMatch(s);
|
||||
if (euro != null) {
|
||||
final betrag = euro.group(1)!.replaceAll('.', '').replaceAll(',', '.');
|
||||
final rest = s.substring(euro.end).trim();
|
||||
final leistung = rest.isEmpty ? 'Material / Leistung' : rest;
|
||||
return '$leistung – $betrag€ (netto/brutto lt. Vereinbarung)';
|
||||
}
|
||||
|
||||
final mal = RegExp(
|
||||
r'^(\d+)\s*x\s*',
|
||||
caseSensitive: false,
|
||||
).firstMatch(s);
|
||||
if (mal != null) {
|
||||
final n = mal.group(1)!;
|
||||
final rest = s.substring(mal.end).trim();
|
||||
if (rest.isNotEmpty) return '$n× $rest';
|
||||
}
|
||||
|
||||
return '• $s';
|
||||
}
|
||||
}
|
||||
46
services/sepa_qr_data.dart
Normal file
46
services/sepa_qr_data.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
import '../models/auftrag.dart';
|
||||
import 'german_amount_parser.dart';
|
||||
|
||||
/// EPC / GiroCode-kompatibler QR-Payload (SEPA Credit Transfer, Version 002).
|
||||
///
|
||||
/// Siehe [EPC069-12](https://www.europeanpaymentscouncil.eu/document-library/).
|
||||
class SepaQrData {
|
||||
SepaQrData._();
|
||||
|
||||
static String? buildEpcString(Auftrag auftrag) {
|
||||
final iban = _normalizeIban(auftrag.ibanVerkaeufer);
|
||||
if (iban.isEmpty) return null;
|
||||
final name = auftrag.kontoinhaberVerkaeufer.trim().isEmpty
|
||||
? 'Empfänger'
|
||||
: _truncate(auftrag.kontoinhaberVerkaeufer.trim(), 70);
|
||||
final bic = auftrag.bicVerkaeufer.trim().replaceAll(' ', '');
|
||||
final amount = GermanAmountParser.formatForSepaQr(auftrag.betragText);
|
||||
if (amount == null) return null;
|
||||
final nr = auftrag.rechnungsnummer.trim();
|
||||
final purpose = _truncate(nr.isEmpty ? auftrag.titel : 'Rechnung $nr', 140);
|
||||
|
||||
final lines = <String>[
|
||||
'BCD',
|
||||
'002',
|
||||
'1',
|
||||
'SCT',
|
||||
bic.isEmpty ? '' : _truncate(bic, 11),
|
||||
name,
|
||||
iban,
|
||||
amount,
|
||||
'',
|
||||
'',
|
||||
purpose,
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
static String _normalizeIban(String raw) {
|
||||
return raw.replaceAll(' ', '').toUpperCase();
|
||||
}
|
||||
|
||||
static String _truncate(String s, int max) {
|
||||
if (s.length <= max) return s;
|
||||
return s.substring(0, max);
|
||||
}
|
||||
}
|
||||
82
services/workflow_metrics_service.dart
Normal file
82
services/workflow_metrics_service.dart
Normal file
@@ -0,0 +1,82 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/dokument_typ.dart';
|
||||
|
||||
/// Lokale Kennzahlen (keine Cloud) – Fokus: PDFs & Dokumenttypen.
|
||||
class WorkflowMetricsService {
|
||||
WorkflowMetricsService._();
|
||||
|
||||
static const _kPdfTotal = 'hp_metrics_pdf_total';
|
||||
static const _kPdfWeek = 'hp_metrics_pdf_week';
|
||||
static const _kPdfWeekId = 'hp_metrics_pdf_week_id';
|
||||
static const _kLastPdfAt = 'hp_metrics_last_pdf_at';
|
||||
static const _kPrefixDoc = 'hp_metrics_doc_';
|
||||
|
||||
static DateTime _montagNullUhr(DateTime d) {
|
||||
final t = DateTime(d.year, d.month, d.day);
|
||||
return t.subtract(Duration(days: t.weekday - DateTime.monday));
|
||||
}
|
||||
|
||||
static int _kalenderWocheSchluessel(DateTime d) {
|
||||
final m = _montagNullUhr(d);
|
||||
return m.year * 10000 + m.month * 100 + m.day;
|
||||
}
|
||||
|
||||
static Future<void> recordPdfExported() async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
final now = DateTime.now();
|
||||
final wid = _kalenderWocheSchluessel(now);
|
||||
final stored = p.getInt(_kPdfWeekId);
|
||||
var week = p.getInt(_kPdfWeek) ?? 0;
|
||||
if (stored != wid) {
|
||||
week = 0;
|
||||
await p.setInt(_kPdfWeekId, wid);
|
||||
}
|
||||
week += 1;
|
||||
final total = (p.getInt(_kPdfTotal) ?? 0) + 1;
|
||||
await p.setInt(_kPdfWeek, week);
|
||||
await p.setInt(_kPdfTotal, total);
|
||||
await p.setString(_kLastPdfAt, now.toIso8601String());
|
||||
}
|
||||
|
||||
static Future<void> recordDokumentGespeichert(DokumentTyp typ) async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
final k = '$_kPrefixDoc${typ.storageValue}';
|
||||
await p.setInt(k, (p.getInt(k) ?? 0) + 1);
|
||||
}
|
||||
|
||||
static Future<WorkflowMetricsSnapshot> load() async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
final wid = _kalenderWocheSchluessel(DateTime.now());
|
||||
final stored = p.getInt(_kPdfWeekId);
|
||||
final week = (stored == wid) ? (p.getInt(_kPdfWeek) ?? 0) : 0;
|
||||
final total = p.getInt(_kPdfTotal) ?? 0;
|
||||
final last = DateTime.tryParse(p.getString(_kLastPdfAt) ?? '');
|
||||
final byDoc = <String, int>{};
|
||||
for (final t in DokumentTyp.values) {
|
||||
final k = '$_kPrefixDoc${t.storageValue}';
|
||||
final n = p.getInt(k);
|
||||
if (n != null && n > 0) byDoc[t.storageValue] = n;
|
||||
}
|
||||
return WorkflowMetricsSnapshot(
|
||||
pdfThisCalendarWeek: week,
|
||||
pdfAllTime: total,
|
||||
lastPdfAt: last,
|
||||
savesByDokumentTyp: byDoc,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WorkflowMetricsSnapshot {
|
||||
WorkflowMetricsSnapshot({
|
||||
required this.pdfThisCalendarWeek,
|
||||
required this.pdfAllTime,
|
||||
required this.lastPdfAt,
|
||||
required this.savesByDokumentTyp,
|
||||
});
|
||||
|
||||
final int pdfThisCalendarWeek;
|
||||
final int pdfAllTime;
|
||||
final DateTime? lastPdfAt;
|
||||
final Map<String, int> savesByDokumentTyp;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
63
utils/appwrite_error_message.dart
Normal file
63
utils/appwrite_error_message.dart
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user