Files
Handwerks_app/screens/auftrag/auftrag_bearbeiten_screen.dart
JUSN 9ddce354c0 Feature
ein paar feature aber datenbank macht probleme wenn man aufträge speichern möchge
2026-04-05 12:47:57 +02:00

1156 lines
39 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/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_email_sender/flutter_email_sender.dart';
import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart';
import 'package:signature/signature.dart';
import 'package:speech_to_text/speech_to_text.dart' as stt;
import '../../models/auftrag.dart';
import '../../models/auftrag_status.dart';
import '../../models/dokument_typ.dart';
import '../../models/zahlungs_status.dart';
import '../../services/auftrag_repository.dart';
import '../../services/auftrag_storage_service.dart';
import '../../services/pdf_export_service.dart';
import '../../services/pdf_history_service.dart';
import '../../services/position_from_text_parser.dart';
import '../../services/workflow_metrics_service.dart';
import '../../theme/app_theme.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 _kundenAdresse = 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;
AuftragStatus _status = AuftragStatus.offen;
DokumentTyp _dokumentTyp = DokumentTyp.rechnung;
ZahlungsStatus _zahlungsStatus = ZahlungsStatus.offen;
DateTime? _faelligAm;
DateTime? _leistungsDatum;
bool _kleinunternehmer = false;
bool _reverseCharge = false;
final _skonto = TextEditingController();
final _ustId = TextEditingController();
final _iban = TextEditingController();
final _bic = TextEditingController();
final _kontoinhaber = TextEditingController();
final stt.SpeechToText _speech = stt.SpeechToText();
bool _speechReady = false;
bool _speechListening = false;
static final _datDe = DateFormat('dd.MM.yyyy');
@override
void initState() {
super.initState();
_signatur = SignatureController(
penStrokeWidth: 2.5,
penColor: Colors.black,
exportBackgroundColor: Colors.white,
);
_docId = widget.auftragId ?? _repo.neueId();
if (widget.auftragId != null) {
_laden();
} else {
_loading = false;
}
if (!kIsWeb) {
_initSpeech();
}
}
void _initSpeech() {
_speech.initialize(
onError: (_) {},
onStatus: (s) {
if (s == 'done' || s == 'notListening') {
if (mounted) setState(() => _speechListening = false);
}
},
).then((ok) {
if (mounted) setState(() => _speechReady = ok);
});
}
Future<void> _laden() async {
final a = await _repo.get(widget.auftragId!);
if (!mounted) return;
if (a == null) {
setState(() => _loading = false);
_snack('Auftrag nicht gefunden.');
return;
}
_isNeu = false;
_rechnungsnummer.text = a.rechnungsnummer;
_titel.text = a.titel;
_beschreibung.text = a.beschreibung;
_kunde.text = a.kundenName;
_kundenAdresse.text = a.kundenAdresse;
_kundenEmail.text = a.kundenEmail;
_betrag.text = a.betragText;
_fotoUrls = List.from(a.fotoUrls);
_unterschriftUrl = a.unterschriftUrl;
_signaturVorhanden = a.hasUnterschrift;
_status = a.status;
_dokumentTyp = a.dokumentTyp;
_zahlungsStatus = a.zahlungsStatus;
_faelligAm = a.faelligAm;
_leistungsDatum = a.leistungsDatum;
_kleinunternehmer = a.kleinunternehmer;
_reverseCharge = a.reverseCharge;
_skonto.text = a.skontoText;
_ustId.text = a.ustIdKunde;
_iban.text = a.ibanVerkaeufer;
_bic.text = a.bicVerkaeufer;
_kontoinhaber.text = a.kontoinhaberVerkaeufer;
setState(() => _loading = false);
}
@override
void dispose() {
_rechnungsnummer.dispose();
_titel.dispose();
_beschreibung.dispose();
_kunde.dispose();
_kundenAdresse.dispose();
_kundenEmail.dispose();
_betrag.dispose();
_skonto.dispose();
_ustId.dispose();
_iban.dispose();
_bic.dispose();
_kontoinhaber.dispose();
_signatur.dispose();
if (_speechListening) {
_speech.stop();
}
super.dispose();
}
void _snack(String t) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t)));
}
Future<Uint8List?> _bytesFuerReferenz(String ref) {
return PdfExportService.ladeReferenz(
ref,
fileLoader: (id) => _storage.getFileBytes(id),
);
}
Widget _fotoVorschau(String ref) {
if (ref.startsWith('http://') || ref.startsWith('https://')) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
ref,
width: 88,
height: 88,
fit: BoxFit.cover,
errorBuilder: (context, error, stack) => Container(
width: 88,
height: 88,
color: const Color(0xFF2C2C2C),
child: const Icon(Icons.broken_image, color: Colors.white54),
),
),
);
}
return FutureBuilder<Uint8List>(
future: _storage.getFileBytes(ref),
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return Container(
width: 88,
height: 88,
color: const Color(0xFF2C2C2C),
alignment: Alignment.center,
child: const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
final b = snap.data;
if (b == null || b.isEmpty) {
return Container(
width: 88,
height: 88,
color: const Color(0xFF2C2C2C),
child: const Icon(Icons.broken_image, color: Colors.white54),
);
}
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.memory(b, width: 88, height: 88, fit: BoxFit.cover),
);
},
);
}
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(),
kundenAdresse: _kundenAdresse.text.trim(),
kundenEmail: _kundenEmail.text.trim(),
rechnungsnummer: rn,
betragText: _betrag.text.trim(),
fotoUrls: _fotoUrls,
unterschriftUrl: sigUrl,
createdAt: null,
status: _status,
dokumentTyp: _dokumentTyp,
zahlungsStatus: _zahlungsStatus,
faelligAm: _faelligAm,
leistungsDatum: _leistungsDatum,
kleinunternehmer: _kleinunternehmer,
reverseCharge: _reverseCharge,
skontoText: _skonto.text.trim(),
ustIdKunde: _ustId.text.trim(),
ibanVerkaeufer: _iban.text.trim(),
bicVerkaeufer: _bic.text.trim(),
kontoinhaberVerkaeufer: _kontoinhaber.text.trim(),
);
}
Future<bool> _speichernIntern({bool zeigeSnacks = true}) async {
final titel = _titel.text.trim();
if (titel.isEmpty) {
if (zeigeSnacks) {
_snack('Bitte Leistung / Kurztitel eintragen.');
}
return false;
}
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';
}
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);
await WorkflowMetricsService.recordDokumentGespeichert(auftrag.dokumentTyp);
_isNeu = false;
if (mounted) setState(() {});
if (zeigeSnacks) _snack('Gespeichert.');
return true;
} catch (e) {
if (zeigeSnacks) _snack('Speichern fehlgeschlagen: $e');
return false;
}
}
Future<void> _speichernTapped() async {
setState(() => _saving = true);
try {
await _speichernIntern();
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _fotoKamera() async {
if (kIsWeb) {
_snack('Kamera ist im Browser nicht verfügbar.');
return;
}
final x = await _picker.pickImage(
source: ImageSource.camera,
imageQuality: 85,
);
if (x == null) return;
setState(() => _saving = true);
try {
final bytes = await x.readAsBytes();
final name = '${DateTime.now().millisecondsSinceEpoch}_cam.jpg';
final id = await _storage.hochladenFoto(_docId, bytes, name);
_fotoUrls.add(id);
setState(() {});
} catch (e) {
_snack('Kamera: $e');
} finally {
if (mounted) setState(() => _saving = false);
}
}
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 id = await _storage.hochladenFoto(_docId, bytes, name);
_fotoUrls.add(id);
i++;
}
setState(() {});
} catch (e) {
_snack('Foto-Upload: $e');
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _fotoEntfernen(int index) async {
final id = _fotoUrls[index];
setState(() => _fotoUrls.removeAt(index));
await _storage.loescheDatei(id);
}
Future<void> _kopieMitTyp(DokumentTyp typ, {required String numPrefix}) async {
final ok = await _speichernIntern(zeigeSnacks: true);
if (!ok || !mounted) return;
final base = _aktuellerAuftrag(_unterschriftUrl);
final newId = _repo.neueId();
final compact = newId.replaceAll('-', '');
final suffix = compact.length >= 6
? compact.substring(0, 6)
: compact.padRight(6, '0');
final newRn = '$numPrefix-${DateTime.now().year}-$suffix';
final body = typ == DokumentTyp.mahnung
? 'Mahnung zu ${base.rechnungsnummer}.\n\n${base.beschreibung}'
: base.beschreibung;
final copy = Auftrag(
id: newId,
titel: base.titel,
beschreibung: body,
kundenName: base.kundenName,
kundenAdresse: base.kundenAdresse,
kundenEmail: base.kundenEmail,
rechnungsnummer: newRn,
betragText: base.betragText,
fotoUrls: const [],
unterschriftUrl: null,
createdAt: null,
status: base.status,
dokumentTyp: typ,
zahlungsStatus: ZahlungsStatus.offen,
faelligAm: base.faelligAm,
leistungsDatum: base.leistungsDatum,
kleinunternehmer: base.kleinunternehmer,
reverseCharge: base.reverseCharge,
skontoText: base.skontoText,
ustIdKunde: base.ustIdKunde,
ibanVerkaeufer: base.ibanVerkaeufer,
bicVerkaeufer: base.bicVerkaeufer,
kontoinhaberVerkaeufer: base.kontoinhaberVerkaeufer,
);
try {
await _repo.speichern(id: newId, daten: copy, isNeu: true);
await WorkflowMetricsService.recordDokumentGespeichert(copy.dokumentTyp);
if (!mounted) return;
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => AuftragBearbeitenScreen(auftragId: newId),
),
);
} catch (e) {
_snack('Kopie fehlgeschlagen: $e');
}
}
Future<void> _toggleSpeech() async {
if (!_speechReady) {
_snack('Spracheingabe nicht verfügbar (Rechte / Gerät).');
return;
}
if (_speechListening) {
await _speech.stop();
if (mounted) setState(() => _speechListening = false);
return;
}
setState(() => _speechListening = true);
await _speech.listen(
onResult: (r) {
if (!mounted) return;
if (r.finalResult) {
final t = r.recognizedWords.trim();
if (t.isEmpty) return;
setState(() {
final cur = _beschreibung.text;
_beschreibung.text = cur.trim().isEmpty ? t : '$cur\n$t';
_beschreibung.selection = TextSelection.collapsed(
offset: _beschreibung.text.length,
);
});
}
},
localeId: 'de_DE',
listenOptions: stt.SpeechListenOptions(
listenMode: stt.ListenMode.dictation,
),
);
}
Future<void> _schnellpositionDialog() async {
final c = TextEditingController();
final go = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Schnellposition'),
content: TextField(
controller: c,
autofocus: true,
decoration: const InputDecoration(
hintText: 'z. B. 2h Elektro oder 50€ Material Kabel',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Übernehmen'),
),
],
),
);
if (go != true || !mounted) return;
final line = PositionFromTextParser.vorschlagZeile(c.text);
c.dispose();
if (line == null) {
_snack('Kein erkennbares Muster.');
return;
}
setState(() {
final cur = _beschreibung.text.trim();
_beschreibung.text = cur.isEmpty ? line : '$cur\n$line';
});
}
Future<void> _pickLeistungsdatum() async {
final now = DateTime.now();
final d = await showDatePicker(
context: context,
initialDate: _leistungsDatum ?? now,
firstDate: DateTime(now.year - 5),
lastDate: DateTime(now.year + 2),
);
if (d != null) setState(() => _leistungsDatum = d);
}
Future<void> _pickFaelligAm() async {
final now = DateTime.now();
final d = await showDatePicker(
context: context,
initialDate: _faelligAm ?? now,
firstDate: DateTime(now.year - 2),
lastDate: DateTime(now.year + 5),
);
if (d != null) setState(() => _faelligAm = d);
}
Future<File?> _pdfDateiErzeugen() async {
final titel = _titel.text.trim();
if (titel.isEmpty) {
_snack('Leistung / Kurztitel eintragen (für die PDF).');
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 _bytesFuerReferenz(sigUrl);
}
final fotoBytes = <Uint8List?>[];
for (final ref in _fotoUrls) {
fotoBytes.add(await _bytesFuerReferenz(ref));
}
final auftrag = _aktuellerAuftrag(sigUrl);
return PdfExportService.buildPdf(
auftrag: auftrag,
fotoBytes: fotoBytes,
unterschriftBytes: sigBytes,
);
}
Future<void> _pdfErstellenUndSenden() async {
setState(() => _saving = true);
try {
final ok = await _speichernIntern(zeigeSnacks: true);
if (!ok) return;
final file = await _pdfDateiErzeugen();
if (file == null) return;
final rn = _rechnungsnummer.text.trim();
await PdfExportService.teilen(file, rechnungsnummer: rn);
await PdfHistoryService.add(
title: _titel.text.trim().isEmpty ? 'Auftrag' : _titel.text.trim(),
rechnungsnummer: rn,
);
await WorkflowMetricsService.recordPdfExported();
if (mounted) _snack('PDF erstellt Teilen geöffnet.');
} catch (e) {
_snack('PDF / Teilen: $e');
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _emailMitPdf() async {
setState(() => _saving = true);
try {
final ok = await _speichernIntern(zeigeSnacks: true);
if (!ok) return;
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],
),
);
await PdfHistoryService.add(
title: _titel.text.trim().isEmpty ? 'Auftrag' : _titel.text.trim(),
rechnungsnummer: rn,
);
await WorkflowMetricsService.recordPdfExported();
} catch (e) {
_snack('E-Mail: $e (Mail-App / Konto prüfen)');
} finally {
if (mounted) setState(() => _saving = false);
}
}
Widget _fotoSlot({
required IconData icon,
required String label,
required VoidCallback? onTap,
}) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
width: 88,
height: 88,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade700, width: 1.5),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: Colors.grey.shade600, size: 28),
const SizedBox(height: 4),
Text(
label,
style: TextStyle(fontSize: 10, color: Colors.grey.shade600),
),
],
),
),
),
);
}
Widget _fotoLeerHinzufuegen() {
return _fotoSlot(
icon: Icons.add_photo_alternate_outlined,
label: 'Galerie',
onTap: _saving ? null : _fotosHinzufuegen,
);
}
Widget _fotoKameraSlot() {
return _fotoSlot(
icon: Icons.photo_camera_outlined,
label: 'Kamera',
onTap: _saving ? null : _fotoKamera,
);
}
@override
Widget build(BuildContext context) {
if (_loading) {
return Scaffold(
backgroundColor: AppTheme.background,
body: const Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
backgroundColor: AppTheme.background,
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(),
),
title: Text(_isNeu ? 'Neuer Auftrag' : 'Auftrag bearbeiten'),
actions: [
PopupMenuButton<String>(
enabled: !_saving,
icon: const Icon(Icons.more_vert),
color: AppTheme.card,
onSelected: (v) {
if (v == 'mahnung') {
_kopieMitTyp(DokumentTyp.mahnung, numPrefix: 'MAH');
} else if (v == 'angebot') {
_kopieMitTyp(DokumentTyp.angebot, numPrefix: 'AN');
} else if (v == 'rechnung') {
_kopieMitTyp(DokumentTyp.rechnung, numPrefix: 'RE');
}
},
itemBuilder: (context) => const [
PopupMenuItem(
value: 'mahnung',
child: Text('Neue Mahnung (Kopie)'),
),
PopupMenuItem(
value: 'angebot',
child: Text('Kopie als Angebot'),
),
PopupMenuItem(
value: 'rechnung',
child: Text('Kopie als Rechnung'),
),
],
),
TextButton(
onPressed: _saving ? null : _speichernTapped,
child: const Text(
'Speichern',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
],
),
body: Stack(
children: [
ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 120),
children: [
TextField(
controller: _kunde,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Kunde',
hintText: 'z. B. Müller GmbH',
),
),
const SizedBox(height: 14),
TextField(
controller: _kundenAdresse,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Adresse',
hintText: 'Hauptstr. 12, 67655 Kaiserslautern',
),
),
const SizedBox(height: 14),
TextField(
controller: _titel,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Leistung / Kurztitel',
hintText: 'z. B. Elektroinstallation Küche',
),
),
const SizedBox(height: 18),
Text(
'Status',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Colors.grey.shade400,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: AuftragStatus.values.map((s) {
final sel = _status == s;
return FilterChip(
label: Text(s.labelDe),
selected: sel,
onSelected: (_) {
if (_saving) return;
setState(() => _status = s);
},
selectedColor:
AppTheme.accentCyan.withValues(alpha: 0.35),
checkmarkColor: Colors.black,
labelStyle: TextStyle(
color: sel ? Colors.black : Colors.white70,
fontWeight: sel ? FontWeight.w600 : FontWeight.w400,
),
);
}).toList(),
),
const SizedBox(height: 20),
Text(
'Dokumenttyp',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Colors.grey.shade400,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: DokumentTyp.values.map((t) {
final sel = _dokumentTyp == t;
return FilterChip(
label: Text(t.labelDe, style: const TextStyle(fontSize: 12)),
selected: sel,
onSelected: (_) {
if (_saving) return;
setState(() => _dokumentTyp = t);
},
selectedColor:
AppTheme.accentCyan.withValues(alpha: 0.35),
checkmarkColor: Colors.black,
labelStyle: TextStyle(
color: sel ? Colors.black : Colors.white70,
fontWeight: sel ? FontWeight.w600 : FontWeight.w400,
),
);
}).toList(),
),
const SizedBox(height: 18),
Text(
'Zahlung',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Colors.grey.shade400,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: ZahlungsStatus.values.map((z) {
final sel = _zahlungsStatus == z;
return FilterChip(
label: Text(z.labelDe),
selected: sel,
onSelected: (_) {
if (_saving) return;
setState(() => _zahlungsStatus = z);
},
selectedColor:
AppTheme.accentCyan.withValues(alpha: 0.35),
checkmarkColor: Colors.black,
labelStyle: TextStyle(
color: sel ? Colors.black : Colors.white70,
fontWeight: sel ? FontWeight.w600 : FontWeight.w400,
),
);
}).toList(),
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _saving ? null : _pickLeistungsdatum,
child: Text(
_leistungsDatum == null
? 'Leistungsdatum'
: 'Leistung: ${_datDe.format(_leistungsDatum!)}',
overflow: TextOverflow.ellipsis,
),
),
),
const SizedBox(width: 10),
Expanded(
child: OutlinedButton(
onPressed: _saving ? null : _pickFaelligAm,
child: Text(
_faelligAm == null
? 'Fällig am'
: 'Fällig: ${_datDe.format(_faelligAm!)}',
overflow: TextOverflow.ellipsis,
),
),
),
],
),
const SizedBox(height: 6),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: _saving
? null
: () => setState(() {
_leistungsDatum = null;
_faelligAm = null;
}),
child: const Text('Daten löschen'),
),
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
'Beschreibung / Positionen',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Colors.grey.shade400,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
tooltip: _speechListening ? 'Stopp' : 'Spracheingabe',
onPressed: _saving ? null : _toggleSpeech,
icon: Icon(
_speechListening ? Icons.mic : Icons.mic_none_outlined,
color: _speechListening
? AppTheme.accentCyan
: Colors.grey,
),
),
TextButton(
onPressed: _saving ? null : _schnellpositionDialog,
child: const Text('Schnellposition'),
),
],
),
const SizedBox(height: 6),
TextField(
controller: _beschreibung,
minLines: 4,
maxLines: 8,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Text & Positionen',
hintText: 'Beschreibung eingeben…',
alignLabelWithHint: true,
),
),
Theme(
data: Theme.of(context).copyWith(
dividerColor: Colors.transparent,
listTileTheme: const ListTileThemeData(
iconColor: AppTheme.accentCyan,
),
),
child: ExpansionTile(
tilePadding: EdgeInsets.zero,
title: Text(
'Steuern, Skonto & SEPA-QR',
style: TextStyle(
color: Colors.grey.shade400,
fontWeight: FontWeight.w500,
),
),
children: [
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Kleinunternehmer §19 UStG'),
value: _kleinunternehmer,
onChanged: _saving
? null
: (v) => setState(() => _kleinunternehmer = v),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Reverse Charge'),
value: _reverseCharge,
onChanged: _saving
? null
: (v) => setState(() => _reverseCharge = v),
),
TextField(
controller: _skonto,
decoration: const InputDecoration(
labelText: 'Skonto / Zahlungsbedingungen',
hintText: 'z. B. 2% bei Zahlung innerhalb 14 Tagen',
),
),
const SizedBox(height: 12),
TextField(
controller: _ustId,
autocorrect: false,
decoration: const InputDecoration(
labelText: 'USt-IdNr. Kunde (optional)',
hintText: 'DE123456789',
),
),
const SizedBox(height: 12),
TextField(
controller: _iban,
autocorrect: false,
decoration: const InputDecoration(
labelText: 'Ihre IBAN (für QR-Zahlung im PDF)',
hintText: 'DE…',
),
),
const SizedBox(height: 12),
TextField(
controller: _bic,
autocorrect: false,
decoration: const InputDecoration(
labelText: 'BIC (optional, für QR)',
),
),
const SizedBox(height: 12),
TextField(
controller: _kontoinhaber,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Kontoinhaber (Empfängername QR)',
),
),
const SizedBox(height: 8),
],
),
),
const SizedBox(height: 22),
Text(
'Fotos',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 10),
SizedBox(
height: 92,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
for (var i = 0; i < _fotoUrls.length; i++)
Padding(
padding: const EdgeInsets.only(right: 10),
child: Stack(
clipBehavior: Clip.none,
children: [
_fotoVorschau(_fotoUrls[i]),
Positioned(
top: -6,
right: -6,
child: IconButton.filled(
style: IconButton.styleFrom(
padding: const EdgeInsets.all(2),
minimumSize: const Size(26, 26),
backgroundColor: Colors.black87,
),
onPressed:
_saving ? null : () => _fotoEntfernen(i),
icon: const Icon(Icons.close, size: 14),
),
),
],
),
),
_fotoLeerHinzufuegen(),
Padding(
padding: const EdgeInsets.only(left: 10),
child: _fotoKameraSlot(),
),
],
),
),
const SizedBox(height: 22),
Text(
'Unterschrift Kunde',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
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: TextStyle(
fontSize: 12,
color: Colors.grey.shade500,
),
),
),
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: 160,
child: Signature(
key: const Key('signatur'),
controller: _signatur,
backgroundColor: Colors.grey.shade300,
),
),
),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: _saving ? null : () => _signatur.clear(),
icon: const Icon(Icons.clear, size: 18),
label: const Text('Unterschrift leeren'),
style: TextButton.styleFrom(foregroundColor: Colors.grey),
),
),
Theme(
data: Theme.of(context).copyWith(
dividerColor: Colors.transparent,
listTileTheme: const ListTileThemeData(
iconColor: AppTheme.accentCyan,
),
),
child: ExpansionTile(
tilePadding: EdgeInsets.zero,
title: Text(
'Rechnung & Kontakt',
style: TextStyle(
color: Colors.grey.shade400,
fontWeight: FontWeight.w500,
),
),
children: [
TextField(
controller: _rechnungsnummer,
decoration: const InputDecoration(
labelText: 'Rechnungsnummer',
hintText: 'Wird beim Speichern gesetzt, wenn leer',
),
),
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: 8),
],
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
height: 52,
child: FilledButton(
onPressed: _saving ? null : _pdfErstellenUndSenden,
child: const Text('PDF erstellen & senden'),
),
),
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
height: 52,
child: OutlinedButton.icon(
onPressed: _saving ? null : _emailMitPdf,
icon: const Icon(Icons.email_outlined, size: 20),
label: const Text('PDF per E-Mail senden'),
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.accentCyan,
side: const BorderSide(color: Color(0xFF404040)),
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
],
),
if (_saving)
const ModalBarrier(
dismissible: false,
color: Color(0x66000000),
),
if (_saving)
const Center(child: CircularProgressIndicator()),
],
),
);
}
}