eigentliche Handwerksapp

This commit is contained in:
2026-04-03 20:42:47 +02:00
parent 1f2e82b9f1
commit e1d4bb7edf
13 changed files with 1392 additions and 0 deletions

BIN
.DS_Store vendored

Binary file not shown.

49
firebase_options.dart Normal file
View File

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

57
main.dart Normal file
View File

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

64
models/auftrag.dart Normal file
View File

@@ -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<String> fotoUrls;
final String? unterschriftUrl;
final DateTime? createdAt;
bool get hasUnterschrift =>
unterschriftUrl != null && unterschriftUrl!.isNotEmpty;
factory Auftrag.fromDoc(DocumentSnapshot<Map<String, dynamic>> 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 <String>[],
unterschriftUrl: d['unterschriftUrl'] as String?,
createdAt: (d['createdAt'] as Timestamp?)?.toDate(),
);
}
Map<String, dynamic> toMap() {
return {
'titel': titel,
'beschreibung': beschreibung,
'kundenName': kundenName,
'kundenEmail': kundenEmail,
'rechnungsnummer': rechnungsnummer,
'betragText': betragText,
'fotoUrls': fotoUrls,
'unterschriftUrl': unterschriftUrl,
'updatedAt': FieldValue.serverTimestamp(),
};
}
}

BIN
screens/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -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<AuftragBearbeitenScreen> createState() =>
_AuftragBearbeitenScreenState();
}
class _AuftragBearbeitenScreenState extends State<AuftragBearbeitenScreen> {
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<String> _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<void> _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<void> _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<void> _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<File?> _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 = <Uint8List?>[];
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<void> _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<void> _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()),
],
),
);
}
}

View File

@@ -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<AuthScreen> createState() => _AuthScreenState();
}
class _AuthScreenState extends State<AuthScreen>
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<void> _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<void> _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'),
),
],
);
}
}

View File

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

View File

@@ -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<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,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<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');
}
Stream<List<Auftrag>> watchAuftraege() {
return _col.orderBy('createdAt', descending: true).snapshots().map(
(snap) => snap.docs.map(Auftrag.fromDoc).toList(),
);
}
Future<Auftrag?> 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<void> 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<void> loeschen(String id) => _col.doc(id).delete();
}

View File

@@ -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<String> 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<String> hochladenUnterschrift(String auftragId, Uint8List pngBytes) async {
final ref = _basis(auftragId).child('unterschrift.png');
await ref.putData(
pngBytes,
SettableMetadata(contentType: 'image/png'),
);
return ref.getDownloadURL();
}
}

View File

@@ -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<File> buildPdf({
required Auftrag auftrag,
required List<Uint8List?> 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<Uint8List?> 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<void> teilen(File file, {String? rechnungsnummer}) async {
await SharePlus.instance.share(
ShareParams(
files: [XFile(file.path)],
subject: rechnungsnummer != null && rechnungsnummer.isNotEmpty
? 'Rechnung $rechnungsnummer'
: 'Rechnung PDF',
),
);
}
}

37
theme/app_theme.dart Normal file
View File

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