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

79
appwrite_config.dart Normal file
View 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
View 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 = '';

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
import 'auftrag.dart';
import 'auftrag_status.dart';
enum AuftragListenSort {
datumNeu('Datum (neu zuerst)'),
status('Status'),
titelAz('Titel AZ');
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;
}

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

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

View 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

View File

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

View File

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

View File

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

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
import 'package:shared_preferences/shared_preferences.dart';
class AppPreferences {
AppPreferences._();
static const _onboardingKey = 'handwerkpro_onboarding_done_v1';
static Future<bool> isOnboardingDone() async {
final p = await SharedPreferences.getInstance();
return p.getBool(_onboardingKey) ?? false;
}
static Future<void> setOnboardingDone() async {
final p = await SharedPreferences.getInstance();
await p.setBool(_onboardingKey, true);
}
}

View File

@@ -1,50 +1,89 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
// Appwrite 1.8+ empfiehlt TablesDB; klassische Collections laufen weiter über Databases.
// ignore_for_file: deprecated_member_use
import 'package:appwrite/appwrite.dart';
import '../appwrite_config.dart';
import '../models/auftrag.dart';
class AuftragRepository {
AuftragRepository({FirebaseFirestore? firestore, FirebaseAuth? auth})
: _db = firestore ?? FirebaseFirestore.instance,
_auth = auth ?? FirebaseAuth.instance;
AuftragRepository({Client? client})
: _db = Databases(client ?? appwriteClient),
_account = Account(client ?? appwriteClient);
final FirebaseFirestore _db;
final FirebaseAuth _auth;
final Databases _db;
final Account _account;
CollectionReference<Map<String, dynamic>> get _col {
final uid = _auth.currentUser?.uid;
if (uid == null) {
throw StateError('Nicht angemeldet');
}
return _db.collection('users').doc(uid).collection('auftraege');
Future<String> _uid() async {
final u = await _account.get();
return u.$id;
}
Stream<List<Auftrag>> watchAuftraege() {
return _col.orderBy('createdAt', descending: true).snapshots().map(
(snap) => snap.docs.map(Auftrag.fromDoc).toList(),
);
List<String> _docPermissions(String uid) => [
Permission.read(Role.user(uid)),
Permission.update(Role.user(uid)),
Permission.delete(Role.user(uid)),
];
Future<List<Auftrag>> listAuftraege() async {
final uid = await _uid();
final res = await _db.listDocuments(
databaseId: kAppwriteDatabaseId,
collectionId: kAppwriteCollectionId,
queries: [
Query.equal('userId', uid),
Query.orderDesc(r'$createdAt'),
],
);
return res.documents.map(Auftrag.fromAppwriteDoc).toList();
}
Future<Auftrag?> get(String id) async {
final doc = await _col.doc(id).get();
if (!doc.exists) return null;
return Auftrag.fromDoc(doc);
try {
final doc = await _db.getDocument(
databaseId: kAppwriteDatabaseId,
collectionId: kAppwriteCollectionId,
documentId: id,
);
return Auftrag.fromAppwriteDoc(doc);
} catch (_) {
return null;
}
}
/// Neue Dokument-ID ohne Schreibzugriff (für Storage-Pfade vor erstem Speichern).
String neueId() => _col.doc().id;
String neueId() => ID.unique();
Future<void> speichern({
required String id,
required Auftrag daten,
bool isNeu = false,
}) async {
final payload = daten.toMap();
final uid = await _uid();
final payload = Map<String, dynamic>.from(daten.toMap());
payload['userId'] = uid;
if (isNeu) {
payload['createdAt'] = FieldValue.serverTimestamp();
await _db.createDocument(
databaseId: kAppwriteDatabaseId,
collectionId: kAppwriteCollectionId,
documentId: id,
data: payload,
permissions: _docPermissions(uid),
);
} else {
await _db.updateDocument(
databaseId: kAppwriteDatabaseId,
collectionId: kAppwriteCollectionId,
documentId: id,
data: payload,
);
}
await _col.doc(id).set(payload, SetOptions(merge: true));
}
Future<void> loeschen(String id) => _col.doc(id).delete();
Future<void> loeschen(String id) async {
await _db.deleteDocument(
databaseId: kAppwriteDatabaseId,
collectionId: kAppwriteCollectionId,
documentId: id,
);
}
}

View File

@@ -1,38 +1,81 @@
import 'dart:typed_data';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_storage/firebase_storage.dart';
import 'package:appwrite/appwrite.dart';
import '../appwrite_config.dart';
class AuftragStorageService {
AuftragStorageService({FirebaseStorage? storage, FirebaseAuth? auth})
: _storage = storage ?? FirebaseStorage.instance,
_auth = auth ?? FirebaseAuth.instance;
AuftragStorageService({Client? client})
: _storage = Storage(client ?? appwriteClient),
_account = Account(client ?? appwriteClient);
final FirebaseStorage _storage;
final FirebaseAuth _auth;
final Storage _storage;
final Account _account;
Reference _basis(String auftragId) {
final uid = _auth.currentUser?.uid;
if (uid == null) throw StateError('Nicht angemeldet');
return _storage.ref('users/$uid/auftraege/$auftragId');
Future<String> _uid() async {
final u = await _account.get();
return u.$id;
}
Future<String> hochladenFoto(String auftragId, Uint8List bytes, String dateiname) async {
final ref = _basis(auftragId).child('fotos/$dateiname');
await ref.putData(
bytes,
SettableMetadata(contentType: 'image/jpeg'),
List<String> _filePermissions(String uid) => [
Permission.read(Role.user(uid)),
Permission.update(Role.user(uid)),
Permission.delete(Role.user(uid)),
];
Future<String> hochladenFoto(
String auftragId,
Uint8List bytes,
String dateiname,
) async {
final uid = await _uid();
final fileId = ID.unique();
await _storage.createFile(
bucketId: kAppwriteBucketId,
fileId: fileId,
file: InputFile.fromBytes(
bytes: bytes,
filename: dateiname,
contentType: 'image/jpeg',
),
permissions: _filePermissions(uid),
);
return ref.getDownloadURL();
return fileId;
}
Future<String> hochladenUnterschrift(String auftragId, Uint8List pngBytes) async {
final ref = _basis(auftragId).child('unterschrift.png');
await ref.putData(
pngBytes,
SettableMetadata(contentType: 'image/png'),
final uid = await _uid();
final fileId = ID.unique();
await _storage.createFile(
bucketId: kAppwriteBucketId,
fileId: fileId,
file: InputFile.fromBytes(
bytes: pngBytes,
filename: 'unterschrift_$auftragId.png',
contentType: 'image/png',
),
permissions: _filePermissions(uid),
);
return ref.getDownloadURL();
return fileId;
}
Future<Uint8List> getFileBytes(String fileId) async {
return _storage.getFileView(
bucketId: kAppwriteBucketId,
fileId: fileId,
);
}
/// Entfernt die Datei im Bucket (kein Fehler bei HTTP-URLs oder fehlenden Rechten).
Future<void> loescheDatei(String fileId) async {
if (fileId.startsWith('http://') || fileId.startsWith('https://')) {
return;
}
try {
await _storage.deleteFile(
bucketId: kAppwriteBucketId,
fileId: fileId,
);
} catch (_) {}
}
}

View File

@@ -0,0 +1,96 @@
import 'dart:convert';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import '../models/auftrag.dart';
import '../models/auftrag_status.dart';
import '../models/dokument_typ.dart';
import '../models/zahlungs_status.dart';
/// Export der Auftragsdaten als JSON (ohne Binärdaten der Fotos/Unterschrift).
class DataExportService {
DataExportService._();
static String _csvZelle(String? s) {
final t = (s ?? '').replaceAll('"', '""');
if (t.contains(';') || t.contains('\n') || t.contains('"')) {
return '"$t"';
}
return t;
}
/// Semikolon-CSV (Excel DE) grobe Vorlage für Steuerbüro / DATEV-Vorbereitung.
static Future<File> exportAuftraegeCsvToTempFile(List<Auftrag> list) async {
final header = [
'Rechnungsnummer',
'Dokumenttyp',
'Titel',
'Kunde',
'Adresse',
'E-Mail',
'Betrag_Text',
'Status',
'Zahlungsstatus',
'Faellig_am',
'Leistungsdatum',
'Kleinunternehmer',
'Reverse_Charge',
'Skonto',
'USt_Id_Kunde',
'IBAN',
'BIC',
'Kontoinhaber',
'Erstellt',
];
final sb = StringBuffer()
..writeln(header.map(_csvZelle).join(';'));
for (final a in list) {
sb.writeln(
[
a.rechnungsnummer,
a.dokumentTyp.labelDe,
a.titel,
a.kundenName,
a.kundenAdresse,
a.kundenEmail,
a.betragText,
a.status.labelDe,
a.zahlungsStatus.labelDe,
a.faelligAm?.toIso8601String() ?? '',
a.leistungsDatum?.toIso8601String() ?? '',
a.kleinunternehmer ? 'ja' : 'nein',
a.reverseCharge ? 'ja' : 'nein',
a.skontoText,
a.ustIdKunde,
a.ibanVerkaeufer,
a.bicVerkaeufer,
a.kontoinhaberVerkaeufer,
a.createdAt?.toIso8601String() ?? '',
].map(_csvZelle).join(';'),
);
}
final dir = await getTemporaryDirectory();
final name =
'handwerkpro_export_${DateTime.now().millisecondsSinceEpoch}.csv';
final file = File('${dir.path}/$name');
await file.writeAsString(sb.toString(), encoding: utf8);
return file;
}
static Future<File> exportAuftraegeToTempFile(List<Auftrag> list) async {
final payload = {
'exportVersion': 4,
'exportedAt': DateTime.now().toIso8601String(),
'count': list.length,
'auftraege': list.map((a) => a.toExportMap()).toList(),
};
final json = const JsonEncoder.withIndent(' ').convert(payload);
final dir = await getTemporaryDirectory();
final name =
'handwerkpro_export_${DateTime.now().millisecondsSinceEpoch}.json';
final file = File('${dir.path}/$name');
await file.writeAsString(json);
return file;
}
}

View File

@@ -0,0 +1,29 @@
/// Parst typische deutsche Betragsangaben (z.B. `1.234,56`) für QR / Logik.
class GermanAmountParser {
GermanAmountParser._();
/// Liefert den Betrag in Euro als [double], oder `null` wenn nicht erkennbar.
static double? parseEuro(String raw) {
var s = raw.trim();
if (s.isEmpty) return null;
s = s.replaceAll(RegExp(r'\s'), '');
s = s.replaceAll('', '').replaceAll('EUR', '').trim();
if (s.isEmpty) return null;
if (s.contains(',')) {
s = s.replaceAll('.', '').replaceAll(',', '.');
} else {
final dotCount = '.'.allMatches(s).length;
if (dotCount > 1) {
s = s.replaceAll('.', '');
}
}
return double.tryParse(s);
}
/// Betrag für SEPA-QR: `EUR12.34` (Punkt, zwei Nachkommastellen).
static String? formatForSepaQr(String betragText) {
final v = parseEuro(betragText);
if (v == null || v <= 0) return null;
return 'EUR${v.toStringAsFixed(2)}';
}
}

View File

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

View File

@@ -0,0 +1,76 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
class PdfHistoryEntry {
PdfHistoryEntry({
required this.at,
required this.title,
required this.rechnungsnummer,
});
final DateTime at;
final String title;
final String rechnungsnummer;
Map<String, dynamic> toJson() => {
'at': at.toIso8601String(),
'title': title,
'rechnungsnummer': rechnungsnummer,
};
factory PdfHistoryEntry.fromJson(Map<String, dynamic> m) {
return PdfHistoryEntry(
at: DateTime.tryParse(m['at'] as String? ?? '') ?? DateTime.now(),
title: m['title'] as String? ?? '',
rechnungsnummer: m['rechnungsnummer'] as String? ?? '',
);
}
}
/// Lokaler Verlauf geteilter/erzeugter PDFs (nur Metadaten, keine Dateiablage).
class PdfHistoryService {
PdfHistoryService._();
static const _key = 'handwerkpro_pdf_history_v1';
static const _max = 30;
static Future<List<PdfHistoryEntry>> load() async {
final p = await SharedPreferences.getInstance();
final raw = p.getString(_key);
if (raw == null || raw.isEmpty) return [];
try {
final list = jsonDecode(raw) as List<dynamic>;
return list
.map((e) => PdfHistoryEntry.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
} catch (_) {
return [];
}
}
static Future<void> add({
required String title,
required String rechnungsnummer,
}) async {
final p = await SharedPreferences.getInstance();
final existing = await load();
existing.insert(
0,
PdfHistoryEntry(
at: DateTime.now(),
title: title,
rechnungsnummer: rechnungsnummer,
),
);
final trimmed = existing.take(_max).toList();
await p.setString(
_key,
jsonEncode(trimmed.map((e) => e.toJson()).toList()),
);
}
static Future<void> clear() async {
final p = await SharedPreferences.getInstance();
await p.remove(_key);
}
}

View File

@@ -0,0 +1,46 @@
/// Heuristik: Freitext wie „2h Elektro Küche“ → Zeile für die Positionsbeschreibung.
///
/// Kein externes KI-Modell nur Muster, die auf der Baustelle häufig vorkommen.
class PositionFromTextParser {
PositionFromTextParser._();
/// Liefert einen Vorschlag für eine neue Zeile (mehrzeilig anfügbar).
static String? vorschlagZeile(String raw) {
final s = raw.trim();
if (s.isEmpty) return null;
final stunden = RegExp(
r'^(\d+(?:[.,]\d+)?)\s*h(?:\s+|$)',
caseSensitive: false,
).firstMatch(s);
if (stunden != null) {
final h = stunden.group(1)!.replaceAll(',', '.');
final rest = s.substring(stunden.end).trim();
final leistung = rest.isEmpty ? 'Arbeitsleistung' : rest;
return '${h}h $leistung';
}
final euro = RegExp(
r'^(\d+(?:[.,]\d+)?)\s*€',
caseSensitive: false,
).firstMatch(s);
if (euro != null) {
final betrag = euro.group(1)!.replaceAll('.', '').replaceAll(',', '.');
final rest = s.substring(euro.end).trim();
final leistung = rest.isEmpty ? 'Material / Leistung' : rest;
return '$leistung $betrag€ (netto/brutto lt. Vereinbarung)';
}
final mal = RegExp(
r'^(\d+)\s*x\s*',
caseSensitive: false,
).firstMatch(s);
if (mal != null) {
final n = mal.group(1)!;
final rest = s.substring(mal.end).trim();
if (rest.isNotEmpty) return '$n× $rest';
}
return '$s';
}
}

View File

@@ -0,0 +1,46 @@
import '../models/auftrag.dart';
import 'german_amount_parser.dart';
/// EPC / GiroCode-kompatibler QR-Payload (SEPA Credit Transfer, Version 002).
///
/// Siehe [EPC069-12](https://www.europeanpaymentscouncil.eu/document-library/).
class SepaQrData {
SepaQrData._();
static String? buildEpcString(Auftrag auftrag) {
final iban = _normalizeIban(auftrag.ibanVerkaeufer);
if (iban.isEmpty) return null;
final name = auftrag.kontoinhaberVerkaeufer.trim().isEmpty
? 'Empfänger'
: _truncate(auftrag.kontoinhaberVerkaeufer.trim(), 70);
final bic = auftrag.bicVerkaeufer.trim().replaceAll(' ', '');
final amount = GermanAmountParser.formatForSepaQr(auftrag.betragText);
if (amount == null) return null;
final nr = auftrag.rechnungsnummer.trim();
final purpose = _truncate(nr.isEmpty ? auftrag.titel : 'Rechnung $nr', 140);
final lines = <String>[
'BCD',
'002',
'1',
'SCT',
bic.isEmpty ? '' : _truncate(bic, 11),
name,
iban,
amount,
'',
'',
purpose,
];
return lines.join('\n');
}
static String _normalizeIban(String raw) {
return raw.replaceAll(' ', '').toUpperCase();
}
static String _truncate(String s, int max) {
if (s.length <= max) return s;
return s.substring(0, max);
}
}

View File

@@ -0,0 +1,82 @@
import 'package:shared_preferences/shared_preferences.dart';
import '../models/dokument_typ.dart';
/// Lokale Kennzahlen (keine Cloud) Fokus: PDFs & Dokumenttypen.
class WorkflowMetricsService {
WorkflowMetricsService._();
static const _kPdfTotal = 'hp_metrics_pdf_total';
static const _kPdfWeek = 'hp_metrics_pdf_week';
static const _kPdfWeekId = 'hp_metrics_pdf_week_id';
static const _kLastPdfAt = 'hp_metrics_last_pdf_at';
static const _kPrefixDoc = 'hp_metrics_doc_';
static DateTime _montagNullUhr(DateTime d) {
final t = DateTime(d.year, d.month, d.day);
return t.subtract(Duration(days: t.weekday - DateTime.monday));
}
static int _kalenderWocheSchluessel(DateTime d) {
final m = _montagNullUhr(d);
return m.year * 10000 + m.month * 100 + m.day;
}
static Future<void> recordPdfExported() async {
final p = await SharedPreferences.getInstance();
final now = DateTime.now();
final wid = _kalenderWocheSchluessel(now);
final stored = p.getInt(_kPdfWeekId);
var week = p.getInt(_kPdfWeek) ?? 0;
if (stored != wid) {
week = 0;
await p.setInt(_kPdfWeekId, wid);
}
week += 1;
final total = (p.getInt(_kPdfTotal) ?? 0) + 1;
await p.setInt(_kPdfWeek, week);
await p.setInt(_kPdfTotal, total);
await p.setString(_kLastPdfAt, now.toIso8601String());
}
static Future<void> recordDokumentGespeichert(DokumentTyp typ) async {
final p = await SharedPreferences.getInstance();
final k = '$_kPrefixDoc${typ.storageValue}';
await p.setInt(k, (p.getInt(k) ?? 0) + 1);
}
static Future<WorkflowMetricsSnapshot> load() async {
final p = await SharedPreferences.getInstance();
final wid = _kalenderWocheSchluessel(DateTime.now());
final stored = p.getInt(_kPdfWeekId);
final week = (stored == wid) ? (p.getInt(_kPdfWeek) ?? 0) : 0;
final total = p.getInt(_kPdfTotal) ?? 0;
final last = DateTime.tryParse(p.getString(_kLastPdfAt) ?? '');
final byDoc = <String, int>{};
for (final t in DokumentTyp.values) {
final k = '$_kPrefixDoc${t.storageValue}';
final n = p.getInt(k);
if (n != null && n > 0) byDoc[t.storageValue] = n;
}
return WorkflowMetricsSnapshot(
pdfThisCalendarWeek: week,
pdfAllTime: total,
lastPdfAt: last,
savesByDokumentTyp: byDoc,
);
}
}
class WorkflowMetricsSnapshot {
WorkflowMetricsSnapshot({
required this.pdfThisCalendarWeek,
required this.pdfAllTime,
required this.lastPdfAt,
required this.savesByDokumentTyp,
});
final int pdfThisCalendarWeek;
final int pdfAllTime;
final DateTime? lastPdfAt;
final Map<String, int> savesByDokumentTyp;
}

View File

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

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