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

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