Files
Handwerks_app/screens/auftrag/auftrag_bearbeiten_screen.dart
2026-04-03 20:42:47 +02:00

464 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()),
],
),
);
}
}