diff --git a/.DS_Store b/.DS_Store index 9e3d27d..05f7b80 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/firebase_options.dart b/firebase_options.dart new file mode 100644 index 0000000..dd2ee54 --- /dev/null +++ b/firebase_options.dart @@ -0,0 +1,49 @@ +// Ersetze diese Datei durch die Ausgabe von: +// dart pub global activate flutterfire_cli +// flutterfire configure +// Bis dahin: Platzhalter-Werte (Auth schlägt ohne echtes Firebase-Projekt fehl). + +import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, kIsWeb, TargetPlatform; + +class DefaultFirebaseOptions { + static FirebaseOptions get currentPlatform { + if (kIsWeb) { + throw UnsupportedError( + 'Screen 1: Bitte zuerst iOS- oder Android-Gerät/Simulator nutzen ' + '(oder Web in Firebase anlegen + flutterfire configure).', + ); + } + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return android; + case TargetPlatform.iOS: + return ios; + case TargetPlatform.macOS: + // Entwicklung: gleiche Firebase-App wie iOS (flutterfire configure mit macOS ergänzen). + return ios; + default: + throw UnsupportedError( + 'Nur iOS und Android sind für Screen 1 vorgesehen.', + ); + } + } + + static const FirebaseOptions android = FirebaseOptions( + apiKey: 'REPLACE_ME', + appId: '1:000000000000:android:0000000000000000000000', + messagingSenderId: '000000000000', + projectId: 'handwerksapp-placeholder', + storageBucket: 'handwerksapp-placeholder.appspot.com', + ); + + static const FirebaseOptions ios = FirebaseOptions( + apiKey: 'REPLACE_ME', + appId: '1:000000000000:ios:0000000000000000000000', + messagingSenderId: '000000000000', + projectId: 'handwerksapp-placeholder', + storageBucket: 'handwerksapp-placeholder.appspot.com', + iosBundleId: 'com.example.handwerksapp', + ); +} diff --git a/main.dart b/main.dart new file mode 100644 index 0000000..cb50c6d --- /dev/null +++ b/main.dart @@ -0,0 +1,57 @@ +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/material.dart'; + +import 'firebase_options.dart'; +import 'screens/auth/auth_screen.dart'; +import 'screens/firebase_setup_required_screen.dart'; +import 'screens/home/auftraege_home_screen.dart'; +import 'theme/app_theme.dart'; + +Future main() async { + 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 { + const HandwerksApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Handwerksapp', + debugShowCheckedModeBanner: false, + theme: AppTheme.light(), + home: const _AuthGate(), + ); + } +} + +class _AuthGate extends StatelessWidget { + const _AuthGate(); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: FirebaseAuth.instance.authStateChanges(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + if (snapshot.hasData) { + return const AuftraegeHomeScreen(); + } + return const AuthScreen(); + }, + ); + } +} diff --git a/models/auftrag.dart b/models/auftrag.dart new file mode 100644 index 0000000..08fe49b --- /dev/null +++ b/models/auftrag.dart @@ -0,0 +1,64 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; + +/// Ein Datensatz = eine Rechnung / dokumentierter Auftrag (Fotos, Unterschrift, PDF). +class Auftrag { + Auftrag({ + required this.id, + required this.titel, + required this.beschreibung, + required this.kundenName, + required this.kundenEmail, + required this.rechnungsnummer, + required this.betragText, + required this.fotoUrls, + required this.unterschriftUrl, + required this.createdAt, + }); + + final String id; + final String titel; + final String beschreibung; + final String kundenName; + final String kundenEmail; + final String rechnungsnummer; + final String betragText; + final List fotoUrls; + final String? unterschriftUrl; + final DateTime? createdAt; + + bool get hasUnterschrift => + unterschriftUrl != null && unterschriftUrl!.isNotEmpty; + + factory Auftrag.fromDoc(DocumentSnapshot> doc) { + final d = doc.data() ?? {}; + final fotos = d['fotoUrls']; + return Auftrag( + id: doc.id, + titel: d['titel'] as String? ?? '', + beschreibung: d['beschreibung'] as String? ?? '', + kundenName: d['kundenName'] 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 [], + unterschriftUrl: d['unterschriftUrl'] as String?, + createdAt: (d['createdAt'] as Timestamp?)?.toDate(), + ); + } + + Map toMap() { + return { + 'titel': titel, + 'beschreibung': beschreibung, + 'kundenName': kundenName, + 'kundenEmail': kundenEmail, + 'rechnungsnummer': rechnungsnummer, + 'betragText': betragText, + 'fotoUrls': fotoUrls, + 'unterschriftUrl': unterschriftUrl, + 'updatedAt': FieldValue.serverTimestamp(), + }; + } +} diff --git a/screens/.DS_Store b/screens/.DS_Store new file mode 100644 index 0000000..7e0090b Binary files /dev/null and b/screens/.DS_Store differ diff --git a/screens/auftrag/auftrag_bearbeiten_screen.dart b/screens/auftrag/auftrag_bearbeiten_screen.dart new file mode 100644 index 0000000..ed5716b --- /dev/null +++ b/screens/auftrag/auftrag_bearbeiten_screen.dart @@ -0,0 +1,463 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_email_sender/flutter_email_sender.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:signature/signature.dart'; + +import '../../models/auftrag.dart'; +import '../../services/auftrag_repository.dart'; +import '../../services/auftrag_storage_service.dart'; +import '../../services/pdf_export_service.dart'; + +class AuftragBearbeitenScreen extends StatefulWidget { + const AuftragBearbeitenScreen({super.key, this.auftragId}); + + final String? auftragId; + + @override + State createState() => + _AuftragBearbeitenScreenState(); +} + +class _AuftragBearbeitenScreenState extends State { + final _repo = AuftragRepository(); + final _storage = AuftragStorageService(); + final _rechnungsnummer = TextEditingController(); + final _titel = TextEditingController(); + final _beschreibung = TextEditingController(); + final _kunde = TextEditingController(); + final _kundenEmail = TextEditingController(); + final _betrag = TextEditingController(); + + late final String _docId; + late final SignatureController _signatur; + final _picker = ImagePicker(); + + bool _isNeu = true; + bool _loading = true; + bool _saving = false; + List _fotoUrls = []; + String? _unterschriftUrl; + bool _signaturVorhanden = false; + + @override + void initState() { + super.initState(); + _signatur = SignatureController( + penStrokeWidth: 2, + penColor: Colors.black, + exportBackgroundColor: Colors.white, + ); + _docId = widget.auftragId ?? _repo.neueId(); + if (widget.auftragId != null) { + _laden(); + } else { + _loading = false; + } + } + + Future _laden() async { + final a = await _repo.get(widget.auftragId!); + if (!mounted) return; + if (a == null) { + setState(() => _loading = false); + _snack('Rechnung nicht gefunden.'); + return; + } + _isNeu = false; + _rechnungsnummer.text = a.rechnungsnummer; + _titel.text = a.titel; + _beschreibung.text = a.beschreibung; + _kunde.text = a.kundenName; + _kundenEmail.text = a.kundenEmail; + _betrag.text = a.betragText; + _fotoUrls = List.from(a.fotoUrls); + _unterschriftUrl = a.unterschriftUrl; + _signaturVorhanden = a.hasUnterschrift; + setState(() => _loading = false); + } + + @override + void dispose() { + _rechnungsnummer.dispose(); + _titel.dispose(); + _beschreibung.dispose(); + _kunde.dispose(); + _kundenEmail.dispose(); + _betrag.dispose(); + _signatur.dispose(); + super.dispose(); + } + + void _snack(String t) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t))); + } + + Auftrag _aktuellerAuftrag(String? sigUrl) { + var rn = _rechnungsnummer.text.trim(); + if (rn.isEmpty) { + final compact = _docId.replaceAll('-', ''); + final suffix = compact.length >= 6 + ? compact.substring(0, 6) + : compact.padRight(6, '0'); + rn = 'RE-${DateTime.now().year}-$suffix'; + } + return Auftrag( + id: _docId, + titel: _titel.text.trim(), + beschreibung: _beschreibung.text.trim(), + kundenName: _kunde.text.trim(), + kundenEmail: _kundenEmail.text.trim(), + rechnungsnummer: rn, + betragText: _betrag.text.trim(), + fotoUrls: _fotoUrls, + unterschriftUrl: sigUrl, + createdAt: null, + ); + } + + Future _fotosHinzufuegen() async { + final files = await _picker.pickMultiImage(imageQuality: 85); + if (files.isEmpty) return; + setState(() => _saving = true); + try { + var i = _fotoUrls.length; + for (final x in files) { + final bytes = await x.readAsBytes(); + final name = '${DateTime.now().millisecondsSinceEpoch}_$i.jpg'; + final url = await _storage.hochladenFoto(_docId, bytes, name); + _fotoUrls.add(url); + i++; + } + setState(() {}); + } catch (e) { + _snack('Foto-Upload: $e'); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + void _fotoEntfernen(int index) { + setState(() { + _fotoUrls.removeAt(index); + }); + } + + Future _speichern() async { + final titel = _titel.text.trim(); + if (titel.isEmpty) { + _snack('Bitte einen Titel / Leistung eintragen.'); + return; + } + if (_rechnungsnummer.text.trim().isEmpty) { + final compact = _docId.replaceAll('-', ''); + final suffix = compact.length >= 6 + ? compact.substring(0, 6) + : compact.padRight(6, '0'); + _rechnungsnummer.text = 'RE-${DateTime.now().year}-$suffix'; + } + + setState(() => _saving = true); + try { + String? sigUrl = _unterschriftUrl; + if (_signatur.isNotEmpty) { + final png = await _signatur.toPngBytes(); + if (png != null && png.isNotEmpty) { + sigUrl = await _storage.hochladenUnterschrift(_docId, png); + _signatur.clear(); + _signaturVorhanden = true; + _unterschriftUrl = sigUrl; + } + } + + final auftrag = _aktuellerAuftrag(sigUrl); + await _repo.speichern(id: _docId, daten: auftrag, isNeu: _isNeu); + _isNeu = false; + if (mounted) setState(() {}); + _snack('Rechnung gespeichert.'); + } catch (e) { + _snack('Speichern fehlgeschlagen: $e'); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _pdfDateiErzeugen() async { + final titel = _titel.text.trim(); + if (titel.isEmpty) { + _snack('Titel eintragen (für die Rechnung).'); + return null; + } + + String? sigUrl = _unterschriftUrl; + Uint8List? sigBytes; + if (_signatur.isNotEmpty) { + sigBytes = await _signatur.toPngBytes(); + } + if ((sigBytes == null || sigBytes.isEmpty) && + sigUrl != null && + sigUrl.isNotEmpty) { + sigBytes = await PdfExportService.ladeUrl(sigUrl); + } + + final fotoBytes = []; + for (final url in _fotoUrls) { + fotoBytes.add(await PdfExportService.ladeUrl(url)); + } + + final auftrag = _aktuellerAuftrag(sigUrl); + return PdfExportService.buildPdf( + auftrag: auftrag, + fotoBytes: fotoBytes, + unterschriftBytes: sigBytes, + ); + } + + Future _pdfTeilen() async { + setState(() => _saving = true); + try { + final file = await _pdfDateiErzeugen(); + if (file == null) return; + final rn = _rechnungsnummer.text.trim(); + await PdfExportService.teilen(file, rechnungsnummer: rn); + } catch (e) { + _snack('PDF / Teilen: $e'); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _emailMitPdf() async { + setState(() => _saving = true); + try { + final file = await _pdfDateiErzeugen(); + if (file == null) return; + + final empf = _kundenEmail.text.trim(); + final rn = _rechnungsnummer.text.trim().isEmpty + ? 'Rechnung' + : _rechnungsnummer.text.trim(); + + await FlutterEmailSender.send( + Email( + subject: 'Rechnung $rn', + body: + 'Guten Tag,\n\nanbei die Rechnung als PDF.\n\nMit freundlichen Grüßen', + recipients: empf.contains('@') ? [empf] : [], + attachmentPaths: [file.path], + ), + ); + } catch (e) { + _snack('E-Mail: $e (Mail-App / Konto prüfen)'); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + if (_loading) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + return Scaffold( + appBar: AppBar( + title: Text(_isNeu ? 'Neue Rechnung' : 'Rechnung bearbeiten'), + ), + body: Stack( + children: [ + ListView( + padding: const EdgeInsets.all(20), + children: [ + TextField( + controller: _rechnungsnummer, + decoration: const InputDecoration( + labelText: 'Rechnungsnummer', + hintText: 'Wird beim Speichern automatisch gesetzt, wenn leer', + ), + ), + const SizedBox(height: 12), + TextField( + controller: _titel, + decoration: const InputDecoration( + labelText: 'Leistung / Titel', + hintText: 'z. B. Elektroinstallation Küche', + ), + ), + const SizedBox(height: 12), + TextField( + controller: _kunde, + decoration: const InputDecoration( + labelText: 'Kunde (Name)', + ), + ), + const SizedBox(height: 12), + TextField( + controller: _kundenEmail, + keyboardType: TextInputType.emailAddress, + autocorrect: false, + decoration: const InputDecoration( + labelText: 'Kunden-E-Mail', + hintText: 'Für „Per E-Mail senden“', + ), + ), + const SizedBox(height: 12), + TextField( + controller: _betrag, + keyboardType: TextInputType.text, + decoration: const InputDecoration( + labelText: 'Betrag brutto (€)', + hintText: 'z. B. 1.234,56', + ), + ), + const SizedBox(height: 12), + TextField( + controller: _beschreibung, + minLines: 3, + maxLines: 8, + decoration: const InputDecoration( + labelText: 'Beschreibung / Positionen', + alignLabelWithHint: true, + ), + ), + const SizedBox(height: 24), + Row( + children: [ + Text( + 'Fotos', + style: Theme.of(context).textTheme.titleMedium, + ), + const Spacer(), + FilledButton.tonalIcon( + onPressed: _saving ? null : _fotosHinzufuegen, + icon: const Icon(Icons.add_photo_alternate_outlined), + label: const Text('Hinzufügen'), + ), + ], + ), + const SizedBox(height: 8), + if (_fotoUrls.isEmpty) + Text( + 'Noch keine Fotos.', + style: Theme.of(context).textTheme.bodySmall, + ) + else + SizedBox( + height: 100, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: _fotoUrls.length, + separatorBuilder: (context, i) => const SizedBox(width: 8), + itemBuilder: (context, i) { + return Stack( + clipBehavior: Clip.none, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + _fotoUrls[i], + width: 100, + height: 100, + fit: BoxFit.cover, + errorBuilder: (context, error, stack) => Container( + width: 100, + height: 100, + color: Colors.grey.shade300, + child: const Icon(Icons.broken_image), + ), + ), + ), + Positioned( + top: -4, + right: -4, + child: IconButton.filled( + style: IconButton.styleFrom( + padding: const EdgeInsets.all(4), + minimumSize: const Size(28, 28), + ), + onPressed: + _saving ? null : () => _fotoEntfernen(i), + icon: const Icon(Icons.close, size: 16), + ), + ), + ], + ); + }, + ), + ), + const SizedBox(height: 24), + Text( + 'Unterschrift Kunde', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + if (_signaturVorhanden && _signatur.isEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + 'Es liegt bereits eine Unterschrift. Neu zeichnen und ' + 'speichern ersetzt sie.', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + height: 180, + child: Signature( + key: const Key('signatur'), + controller: _signatur, + backgroundColor: Colors.grey.shade200, + ), + ), + ), + TextButton.icon( + onPressed: _saving ? null : () => _signatur.clear(), + icon: const Icon(Icons.clear), + label: const Text('Unterschrift leeren'), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _saving ? null : _speichern, + icon: const Icon(Icons.save_outlined), + label: const Text('Rechnung speichern'), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: _saving ? null : _pdfTeilen, + icon: const Icon(Icons.share_outlined), + label: const Text('PDF erzeugen & teilen'), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: _saving ? null : _emailMitPdf, + icon: const Icon(Icons.email_outlined), + label: const Text('PDF per E-Mail senden'), + ), + const SizedBox(height: 8), + Text( + 'E-Mail: Öffnet die Mail-App mit Anhang. Auf dem Simulator ' + 'oft nicht verfügbar – echtes Gerät nutzen. ' + 'Kunden-E-Mail oben ausfüllen oder Empfänger in der Mail-App wählen.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 32), + ], + ), + if (_saving) + const ModalBarrier( + dismissible: false, + color: Color(0x33000000), + ), + if (_saving) + const Center(child: CircularProgressIndicator()), + ], + ), + ); + } +} diff --git a/screens/auth/auth_screen.dart b/screens/auth/auth_screen.dart new file mode 100644 index 0000000..f53e230 --- /dev/null +++ b/screens/auth/auth_screen.dart @@ -0,0 +1,288 @@ +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/material.dart'; + +/// Screen 1: Login & Registrierung (E-Mail / Passwort) über Firebase Auth. +class AuthScreen extends StatefulWidget { + const AuthScreen({super.key}); + + @override + State createState() => _AuthScreenState(); +} + +class _AuthScreenState extends State + with SingleTickerProviderStateMixin { + late final TabController _tabController; + + final _loginEmail = TextEditingController(); + final _loginPassword = TextEditingController(); + + final _regName = TextEditingController(); + final _regEmail = TextEditingController(); + final _regPassword = TextEditingController(); + + bool _loading = false; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + _loginEmail.dispose(); + _loginPassword.dispose(); + _regName.dispose(); + _regEmail.dispose(); + _regPassword.dispose(); + super.dispose(); + } + + void _snack(String text) { + if (!mounted) return; + 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}'; + } + } + + Future _login() async { + final email = _loginEmail.text.trim(); + final password = _loginPassword.text; + if (email.isEmpty || password.isEmpty) { + _snack('E-Mail und Passwort eingeben.'); + return; + } + setState(() => _loading = true); + try { + await FirebaseAuth.instance.signInWithEmailAndPassword( + email: email, + password: password, + ); + } on FirebaseAuthException catch (e) { + _snack(_authMessage(e)); + } catch (e) { + _snack('Unerwarteter Fehler: $e'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _register() async { + final email = _regEmail.text.trim(); + final password = _regPassword.text; + final name = _regName.text.trim(); + if (email.isEmpty || password.isEmpty) { + _snack('E-Mail und Passwort eingeben.'); + return; + } + if (password.length < 6) { + _snack('Passwort mindestens 6 Zeichen (Firebase).'); + return; + } + setState(() => _loading = true); + try { + final cred = await FirebaseAuth.instance.createUserWithEmailAndPassword( + email: email, + password: password, + ); + if (name.isNotEmpty && cred.user != null) { + await cred.user!.updateDisplayName(name); + } + } on FirebaseAuthException catch (e) { + _snack(_authMessage(e)); + } catch (e) { + _snack('Unerwarteter Fehler: $e'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + return Scaffold( + 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), + const SizedBox(height: 16), + Text( + 'Handwerksapp', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Nach dem Login: Aufträge anlegen, Fotos, Kunden-Unterschrift, PDF teilen.\n' + 'Zuerst: Konto anlegen oder anmelden.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 32), + TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'Anmelden'), + Tab(text: 'Registrieren'), + ], + ), + const SizedBox(height: 16), + SizedBox( + height: 320, + child: TabBarView( + controller: _tabController, + children: [ + _LoginForm( + email: _loginEmail, + password: _loginPassword, + loading: _loading, + onSubmit: _login, + ), + _RegisterForm( + name: _regName, + email: _regEmail, + password: _regPassword, + loading: _loading, + onSubmit: _register, + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _LoginForm extends StatelessWidget { + const _LoginForm({ + required this.email, + required this.password, + required this.loading, + required this.onSubmit, + }); + + final TextEditingController email; + final TextEditingController password; + final bool loading; + final VoidCallback onSubmit; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: email, + keyboardType: TextInputType.emailAddress, + autocorrect: false, + autofillHints: const [AutofillHints.email], + decoration: const InputDecoration(labelText: 'E-Mail'), + ), + const SizedBox(height: 12), + TextField( + controller: password, + obscureText: true, + autofillHints: const [AutofillHints.password], + decoration: const InputDecoration(labelText: 'Passwort'), + onSubmitted: (_) => onSubmit(), + ), + const Spacer(), + FilledButton( + onPressed: loading ? null : onSubmit, + child: loading + ? const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Anmelden'), + ), + ], + ); + } +} + +class _RegisterForm extends StatelessWidget { + const _RegisterForm({ + required this.name, + required this.email, + required this.password, + required this.loading, + required this.onSubmit, + }); + + final TextEditingController name; + final TextEditingController email; + final TextEditingController password; + final bool loading; + final VoidCallback onSubmit; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: name, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration( + labelText: 'Firmen- oder Anzeigename', + ), + ), + const SizedBox(height: 12), + TextField( + controller: email, + keyboardType: TextInputType.emailAddress, + autocorrect: false, + autofillHints: const [AutofillHints.email], + decoration: const InputDecoration(labelText: 'E-Mail'), + ), + const SizedBox(height: 12), + TextField( + controller: password, + obscureText: true, + autofillHints: const [AutofillHints.newPassword], + decoration: const InputDecoration(labelText: 'Passwort (min. 6 Zeichen)'), + ), + const Spacer(), + FilledButton( + onPressed: loading ? null : onSubmit, + child: loading + ? const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Konto erstellen'), + ), + ], + ); + } +} diff --git a/screens/firebase_setup_required_screen.dart b/screens/firebase_setup_required_screen.dart new file mode 100644 index 0000000..ccbae0f --- /dev/null +++ b/screens/firebase_setup_required_screen.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +/// Wird angezeigt, wenn Firebase.initializeApp fehlschlägt (z. B. Platzhalter-Keys). +class FirebaseSetupRequiredScreen extends StatelessWidget { + const FirebaseSetupRequiredScreen({super.key, required this.error}); + + final Object error; + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Firebase einrichten', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 16), + const Text( + '1. In der Firebase Console ein Projekt anlegen.\n' + '2. Android-App (Package com.example.handwerksapp) und iOS-App ' + '(Bundle com.example.handwerksapp) registrieren.\n' + '3. Authentication → E-Mail/Passwort aktivieren.\n' + '4. Im Projektordner im Terminal:\n\n' + ' dart pub global activate flutterfire_cli\n' + ' flutterfire configure\n\n' + ' Das überschreibt lib/firebase_options.dart und die ' + 'google-services / GoogleService-Info Dateien.', + ), + const SizedBox(height: 16), + Text( + 'Technische Meldung:\n$error', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/screens/home/auftraege_home_screen.dart b/screens/home/auftraege_home_screen.dart new file mode 100644 index 0000000..401ce30 --- /dev/null +++ b/screens/home/auftraege_home_screen.dart @@ -0,0 +1,161 @@ +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../../models/auftrag.dart'; +import '../../services/auftrag_repository.dart'; +import '../auftrag/auftrag_bearbeiten_screen.dart'; + +class AuftraegeHomeScreen extends StatelessWidget { + const AuftraegeHomeScreen({super.key}); + + @override + Widget build(BuildContext context) { + final repo = AuftragRepository(); + final user = FirebaseAuth.instance.currentUser; + final name = user?.displayName?.trim(); + final scheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text('Meine Rechnungen'), + actions: [ + IconButton( + icon: const Icon(Icons.logout), + tooltip: 'Abmelden', + onPressed: () => FirebaseAuth.instance.signOut(), + ), + ], + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Card( + color: scheme.primaryContainer.withValues(alpha: 0.35), + child: Padding( + padding: const EdgeInsets.all(14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, color: scheme.primary), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Nach dem Login bist du hier. Tippe unten auf ' + '„Neue Rechnung“: Daten eintragen, Fotos & Unterschrift, ' + 'dann PDF erzeugen oder per E-Mail senden.\n\n' + 'Wichtig: Nach großen Code-Änderungen App neu starten ' + '(Stop ▶), kein reines Hot Reload.', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), + child: Text( + name != null && name.isNotEmpty ? 'Hallo, $name' : 'Übersicht', + style: Theme.of(context).textTheme.titleLarge, + ), + ), + Expanded( + child: StreamBuilder>( + stream: repo.watchAuftraege(), + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + 'Rechnungen konnten nicht geladen werden.\n' + 'Firestore aktiviert und Sicherheitsregeln gesetzt?\n\n' + '${snapshot.error}', + textAlign: TextAlign.center, + ), + ), + ); + } + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator()); + } + final list = snapshot.data!; + if (list.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + 'Noch keine Rechnungen.\n' + 'Unten auf „Neue Rechnung“ tippen.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ), + ); + } + return ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 88), + itemCount: list.length, + separatorBuilder: (context, i) => const SizedBox(height: 8), + itemBuilder: (context, i) { + final a = list[i]; + final datum = a.createdAt != null + ? DateFormat('dd.MM.yyyy').format(a.createdAt!) + : ''; + final nr = a.rechnungsnummer.isNotEmpty + ? a.rechnungsnummer + : 'ohne Nr.'; + return Card( + clipBehavior: Clip.antiAlias, + child: ListTile( + title: Text( + a.titel.isEmpty ? '(Ohne Titel)' : a.titel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '$nr · ${a.kundenName.isEmpty ? "Kunde —" : a.kundenName}' + '${datum.isNotEmpty ? " · $datum" : ""}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + AuftragBearbeitenScreen(auftragId: a.id), + ), + ); + }, + ), + ); + }, + ); + }, + ), + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const AuftragBearbeitenScreen(), + ), + ); + }, + icon: const Icon(Icons.add), + label: const Text('Neue Rechnung'), + ), + ); + } +} diff --git a/services/auftrag_repository.dart b/services/auftrag_repository.dart new file mode 100644 index 0000000..03cb6e9 --- /dev/null +++ b/services/auftrag_repository.dart @@ -0,0 +1,50 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; + +import '../models/auftrag.dart'; + +class AuftragRepository { + AuftragRepository({FirebaseFirestore? firestore, FirebaseAuth? auth}) + : _db = firestore ?? FirebaseFirestore.instance, + _auth = auth ?? FirebaseAuth.instance; + + final FirebaseFirestore _db; + final FirebaseAuth _auth; + + CollectionReference> get _col { + final uid = _auth.currentUser?.uid; + if (uid == null) { + throw StateError('Nicht angemeldet'); + } + return _db.collection('users').doc(uid).collection('auftraege'); + } + + Stream> watchAuftraege() { + return _col.orderBy('createdAt', descending: true).snapshots().map( + (snap) => snap.docs.map(Auftrag.fromDoc).toList(), + ); + } + + Future get(String id) async { + final doc = await _col.doc(id).get(); + if (!doc.exists) return null; + return Auftrag.fromDoc(doc); + } + + /// Neue Dokument-ID ohne Schreibzugriff (für Storage-Pfade vor erstem Speichern). + String neueId() => _col.doc().id; + + Future speichern({ + required String id, + required Auftrag daten, + bool isNeu = false, + }) async { + final payload = daten.toMap(); + if (isNeu) { + payload['createdAt'] = FieldValue.serverTimestamp(); + } + await _col.doc(id).set(payload, SetOptions(merge: true)); + } + + Future loeschen(String id) => _col.doc(id).delete(); +} diff --git a/services/auftrag_storage_service.dart b/services/auftrag_storage_service.dart new file mode 100644 index 0000000..f29eb56 --- /dev/null +++ b/services/auftrag_storage_service.dart @@ -0,0 +1,38 @@ +import 'dart:typed_data'; + +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:firebase_storage/firebase_storage.dart'; + +class AuftragStorageService { + AuftragStorageService({FirebaseStorage? storage, FirebaseAuth? auth}) + : _storage = storage ?? FirebaseStorage.instance, + _auth = auth ?? FirebaseAuth.instance; + + final FirebaseStorage _storage; + final FirebaseAuth _auth; + + Reference _basis(String auftragId) { + final uid = _auth.currentUser?.uid; + if (uid == null) throw StateError('Nicht angemeldet'); + return _storage.ref('users/$uid/auftraege/$auftragId'); + } + + Future hochladenFoto(String auftragId, Uint8List bytes, String dateiname) async { + final ref = _basis(auftragId).child('fotos/$dateiname'); + await ref.putData( + bytes, + SettableMetadata(contentType: 'image/jpeg'), + ); + return ref.getDownloadURL(); + } + + Future hochladenUnterschrift(String auftragId, Uint8List pngBytes) async { + final ref = _basis(auftragId).child('unterschrift.png'); + await ref.putData( + pngBytes, + SettableMetadata(contentType: 'image/png'), + ); + return ref.getDownloadURL(); + } + +} diff --git a/services/pdf_export_service.dart b/services/pdf_export_service.dart new file mode 100644 index 0000000..eab523c --- /dev/null +++ b/services/pdf_export_service.dart @@ -0,0 +1,138 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; +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'; + +class PdfExportService { + static Future buildPdf({ + required Auftrag auftrag, + required List fotoBytes, + Uint8List? unterschriftBytes, + }) async { + final doc = pw.Document(); + final nr = auftrag.rechnungsnummer.isEmpty + ? 'Entwurf' + : auftrag.rechnungsnummer; + + doc.addPage( + pw.MultiPage( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.all(40), + build: (ctx) => [ + pw.Header( + level: 0, + child: pw.Text( + 'Rechnung', + style: pw.TextStyle( + fontSize: 22, + fontWeight: pw.FontWeight.bold, + ), + ), + ), + pw.SizedBox(height: 8), + pw.Text('Rechnungs-Nr.: $nr', + style: pw.TextStyle( + fontSize: 12, + fontWeight: pw.FontWeight.bold, + )), + pw.SizedBox(height: 16), + 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)), + if (auftrag.kundenEmail.isNotEmpty) ...[ + pw.SizedBox(height: 4), + pw.Text('E-Mail: ${auftrag.kundenEmail}', + 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:', + style: pw.TextStyle( + fontSize: 12, + fontWeight: pw.FontWeight.bold, + )), + pw.SizedBox(height: 6), + pw.Text( + auftrag.beschreibung.isEmpty ? '—' : auftrag.beschreibung, + style: const pw.TextStyle(fontSize: 11), + ), + 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.SizedBox(height: 8), + for (final b in fotoBytes) + if (b != null && b.isNotEmpty) + pw.Padding( + padding: const pw.EdgeInsets.only(bottom: 12), + child: pw.Image( + pw.MemoryImage(b), + fit: pw.BoxFit.contain, + height: 200, + ), + ), + ], + if (unterschriftBytes != null && unterschriftBytes.isNotEmpty) ...[ + pw.SizedBox(height: 16), + pw.Text('Unterschrift Kunde', + style: pw.TextStyle( + fontSize: 12, + fontWeight: pw.FontWeight.bold, + )), + pw.SizedBox(height: 8), + pw.Image( + pw.MemoryImage(unterschriftBytes), + height: 100, + fit: pw.BoxFit.contain, + ), + ], + ], + ), + ); + + 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 file = File('${dir.path}/$name'); + await file.writeAsBytes(bytes); + return file; + } + + static Future ladeUrl(String url) async { + try { + final r = await http.get(Uri.parse(url)); + if (r.statusCode == 200) return r.bodyBytes; + } catch (_) {} + return null; + } + + static Future teilen(File file, {String? rechnungsnummer}) async { + await SharePlus.instance.share( + ShareParams( + files: [XFile(file.path)], + subject: rechnungsnummer != null && rechnungsnummer.isNotEmpty + ? 'Rechnung $rechnungsnummer' + : 'Rechnung PDF', + ), + ); + } +} diff --git a/theme/app_theme.dart b/theme/app_theme.dart new file mode 100644 index 0000000..0664ba7 --- /dev/null +++ b/theme/app_theme.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +/// Ruhiges, seriöses Farbschema für Handwerks-Betriebe (Elektrik, Maler, SHK). +class AppTheme { + AppTheme._(); + + static const Color _seed = Color(0xFF0D47A1); + + static ThemeData light() { + final scheme = ColorScheme.fromSeed( + seedColor: _seed, + brightness: Brightness.light, + surface: const Color(0xFFF5F7FA), + ); + return ThemeData( + useMaterial3: true, + colorScheme: scheme, + appBarTheme: AppBarTheme( + centerTitle: true, + backgroundColor: scheme.surface, + foregroundColor: scheme.onSurface, + elevation: 0, + scrolledUnderElevation: 1, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ); + } +}