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