1156 lines
39 KiB
Dart
1156 lines
39 KiB
Dart
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()),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|