Files
ertl_filament_verwaltung/lib/controller/bauteil_controller.dart
T
2026-10-29 15:03:48 +01:00

540 lines
18 KiB
Dart

import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:uuid/uuid.dart';
import '../helper/display_formats.dart';
import '../helper/bauteil_preis_berechnung.dart';
import '../helper/filament_bestand.dart';
import '../helper/input_validators.dart';
import '../helper/storage_keys.dart';
import '../models/anwendung_model.dart';
import '../models/bauteil_model.dart';
import '../models/kauf_filament_model.dart';
import '../services/bauteil_photo_service.dart';
import '../services/get_storage_services.dart';
import 'home_controller.dart';
import 'abfall_controller.dart';
class BauteilController extends GetxController {
BauteilController({BauteilPhotoService? photoService})
: photoService = photoService ?? BauteilPhotoService();
static const storageKey = StorageKeys.bauteile;
static const double standardAufschlagProzent = 50;
final storage = Get.find<GetStorageService>();
final BauteilPhotoService photoService;
final formKey = GlobalKey<FormState>();
final scrollController = ScrollController();
final nameController = TextEditingController();
final verwendungController = TextEditingController();
final stundenController = TextEditingController(text: '0');
final minutenController = TextEditingController(text: '0');
final gewichtController = TextEditingController();
final abfallController = TextEditingController(text: '0');
final gesamtpreisController = TextEditingController();
final aufschlagController = TextEditingController(
text: standardAufschlagProzent.toStringAsFixed(0),
);
final preisDetails = Rxn<BauteilPreis>();
bool _preisPausiert = false;
BauteilModel? _bearbeitetesBauteil;
final _verbrauchControllers = <String, TextEditingController>{};
final filamente = <KaufFilamentModel>[].obs;
final profile = <AnwendungModel>[].obs;
final bauteile = <BauteilModel>[].obs;
final selectedFilamente = <String, String?>{}.obs;
final editingId = RxnString();
final foto = Rxn<Uint8List>();
final isPickingPhoto = false.obs;
final isSaving = false.obs;
final message = RxnString();
final hasError = false.obs;
bool _closed = false;
@override
void onInit() {
super.onInit();
filamente.assignAll(
storage
.getModelList(StorageKeys.filamente)
.map(KaufFilamentModel.fromJson),
);
profile.assignAll(
storage
.getModelList(StorageKeys.anwendungen)
.map(AnwendungModel.fromJson),
);
bauteile.assignAll(_loadBauteile());
bauteile.sort((a, b) => b.erstelltAm.compareTo(a.erstelltAm));
gewichtController.addListener(berechnePreis);
abfallController.addListener(berechnePreis);
aufschlagController.addListener(berechnePreis);
}
List<BauteilModel> _loadBauteile() =>
storage.getModelList(storageKey).map(BauteilModel.fromJson).toList();
TextEditingController verbrauchController(String id) =>
_verbrauchControllers.putIfAbsent(id, () {
final field = TextEditingController();
field.addListener(berechnePreis);
return field;
});
List<AnwendungModel> profilesFor(String filamentId) =>
profile.where((item) => item.filamentId == filamentId).toList()
..sort((a, b) => b.erstelltAm.compareTo(a.erstelltAm));
String filamentLabel(String id) {
final item = filamente.firstWhereOrNull((item) => item.filamentId == id);
if (item == null) return 'Gelöschtes Filament ($id)';
return '${item.marke} · ${item.material} · ${item.farbe}';
}
double? restgewichtGramm(String id) {
final startgewicht = filamente
.firstWhereOrNull((item) => item.filamentId == id)
?.gewichtGramm;
if (startgewicht == null) return null;
final verbrauch = FilamentBestand.verbrauch(bauteile);
return startgewicht - (verbrauch[id] ?? 0);
}
double? verfuegbarGramm(String id) {
final start = filamente
.firstWhereOrNull((item) => item.filamentId == id)
?.gewichtGramm;
if (start == null) return null;
final used = FilamentBestand.verbrauch(
bauteile,
ohneBauteilId: editingId.value,
);
return start - (used[id] ?? 0);
}
void toggleFilament(String id, bool selected) {
if (selected) {
verbrauchController(id);
selectedFilamente[id] = null;
} else {
selectedFilamente.remove(id);
}
berechnePreis();
}
String? pruefeFilamentVorrat() {
if (selectedFilamente.isEmpty) return null;
final gewicht = InputValidators.number(gewichtController.text);
final abfall = abfallController.text.trim().isEmpty
? 0.0
: InputValidators.number(abfallController.text);
if (gewicht == null ||
!gewicht.isFinite ||
gewicht <= 0 ||
abfall == null ||
!abfall.isFinite ||
abfall < 0) {
return null;
}
final bedarf = gewicht + abfall;
if (!bedarf.isFinite) return null;
var verfuegbar = 0.0;
for (final id in selectedFilamente.keys) {
final rest = verfuegbarGramm(id);
if (rest == null || !rest.isFinite) {
return 'Die Restmenge einer ausgewählten Rolle ist unbekannt. Bitte das Startgewicht und den bisherigen Verbrauch prüfen.';
}
verfuegbar += rest > 0 ? rest : 0;
}
if (bedarf - verfuegbar <= 0.000001) return null;
final rollen = selectedFilamente.length == 1
? 'der ausgewählten Rolle'
: 'der ausgewählten Rollen zusammen';
return 'Die Restmenge $rollen reicht nicht aus.\n\n'
'Benötigt (inklusive Abfall): ${DisplayFormats.number(bedarf)} g\n'
'Verfügbar: ${DisplayFormats.number(verfuegbar)} g\n'
'Es fehlen: ${DisplayFormats.number(bedarf - verfuegbar)} g\n\n'
'Füge eine weitere Rolle hinzu oder wähle eine andere Rolle aus.';
}
void selectProfile(String filamentId, String? anwendungId) {
if (!selectedFilamente.containsKey(filamentId)) return;
if (anwendungId != null &&
!profilesFor(filamentId)
.any((item) => item.anwendungId == anwendungId)) {
return;
}
selectedFilamente[filamentId] = anwendungId;
}
Future<void> pickPhoto() async {
if (isPickingPhoto.value || isSaving.value) return;
isPickingPhoto.value = true;
try {
final bytes = await photoService.pickPhoto();
if (!_closed && bytes != null) foto.value = bytes;
} on FormatException catch (error) {
if (!_closed) _error(error.message);
} catch (_) {
if (!_closed) {
_error(
'Das Foto konnte nicht geladen werden. Bitte ein JPG-, PNG- oder WebP-Bild auswählen.',
);
}
} finally {
if (!_closed) isPickingPhoto.value = false;
}
}
void removePhoto() => foto.value = null;
void _error(String text) {
hasError.value = true;
message.value = text;
}
String? _validateConsumption(
BauteilModel entry,
List<BauteilModel> existing,
) {
if (entry.filamente.isEmpty) {
return 'Bitte mindestens ein Filament auswählen.';
}
final sum = entry.filamente.fold<double>(
0,
(sum, item) => sum + item.verbrauchGramm,
);
if (entry.filamente.any(
(item) => !item.verbrauchGramm.isFinite || item.verbrauchGramm <= 0,
)) {
return 'Bitte für jedes verwendete Filament einen Verbrauch größer 0 eingeben.';
}
if (!sum.isFinite || (sum - entry.gesamtverbrauchGramm).abs() > 0.01) {
return 'Der Filamentverbrauch muss zusammen ${DisplayFormats.number(entry.gesamtverbrauchGramm)} g ergeben (Bauteilgewicht + Abfall).';
}
final used = FilamentBestand.verbrauch(
existing,
ohneBauteilId: entry.bauteilId,
);
final currentFilamente = storage
.getModelList(StorageKeys.filamente)
.map(KaufFilamentModel.fromJson)
.toList();
for (final item in entry.filamente) {
final filament = currentFilamente.firstWhereOrNull(
(filament) => filament.filamentId == item.filamentId,
);
final start = filament?.gewichtGramm;
if (start == null) {
return 'Filament fehlt oder sein Startgewicht ist ungültig. Bitte die Auswahl bzw. das Filament prüfen.';
}
final available = start - (used[item.filamentId] ?? 0);
if (item.verbrauchGramm - available > 0.000001) {
return '${filamentLabel(item.filamentId)}: nur ${DisplayFormats.number(available)} g verfügbar.';
}
}
return null;
}
/// One persisted ledger determines all remaining weights. Updating or deleting
/// an entry therefore cannot leave a second stock table out of sync.
Future<void> _commit(List<BauteilModel> entries) async {
await storage.saveModelList(
storageKey,
entries.map((item) => item.toJson()).toList(),
);
entries.sort((a, b) => b.erstelltAm.compareTo(a.erstelltAm));
bauteile.assignAll(entries);
if (Get.isRegistered<HomeController>()) {
Get.find<HomeController>().loadVerbrauch();
}
if (Get.isRegistered<AbfallController>()) {
Get.find<AbfallController>().loadAbfaelle();
}
}
void berechnePreis() {
if (_preisPausiert || _closed) return;
final gewicht = InputValidators.number(gewichtController.text);
final abfall = InputValidators.number(abfallController.text);
final aufschlag = InputValidators.number(aufschlagController.text);
BauteilPreis? preis;
if (gewicht != null && abfall != null && aufschlag != null) {
final entry = BauteilModel(
bauteilId: editingId.value ?? '',
erstelltAm: DateTime.now(),
name: '',
verwendung: '',
druckdauerMinuten: 1,
gewichtGramm: gewicht,
abfallGramm: abfall,
filamente: selectedFilamente.keys
.map(
(id) => BauteilFilamentModel(
filamentId: id,
verbrauchGramm:
InputValidators.number(_verbrauchControllers[id]?.text) ??
0,
),
)
.toList(),
);
preis = BauteilPreisBerechnung.berechne(
entry,
filamente,
aufschlag,
previous: _bearbeitetesBauteil,
);
}
preisDetails.value = preis;
gesamtpreisController.text = preis == null
? ''
: preis.gesamtpreis.toStringAsFixed(2).replaceAll('.', ',');
}
BauteilPreis? _kalkulation(
BauteilModel entry,
double aufschlag, {
BauteilModel? previous,
}) => BauteilPreisBerechnung.berechne(
entry,
storage.getModelList(StorageKeys.filamente).map(KaufFilamentModel.fromJson),
aufschlag,
previous: previous,
);
Future<void> saveBauteil() async {
if (isSaving.value || isPickingPhoto.value) return;
message.value = null;
if (!(formKey.currentState?.validate() ?? false)) return;
if (selectedFilamente.isEmpty) {
_error('Bitte mindestens ein Filament auswählen.');
return;
}
final duration =
int.parse(stundenController.text.trim()) * 60 +
int.parse(minutenController.text.trim());
if (duration <= 0) {
_error('Die Druckdauer muss mindestens eine Minute betragen.');
return;
}
isSaving.value = true;
try {
final existing = _loadBauteile();
final previous = existing.firstWhereOrNull(
(item) => item.bauteilId == editingId.value,
);
if (editingId.value != null && previous == null) {
_error('Das bearbeitete Bauteil wurde nicht gefunden.');
return;
}
final entry = BauteilModel(
bauteilId: editingId.value ?? const Uuid().v4(),
erstelltAm: previous?.erstelltAm ?? DateTime.now(),
name: nameController.text.trim(),
verwendung: verwendungController.text.trim(),
druckdauerMinuten: duration,
gewichtGramm: InputValidators.number(gewichtController.text)!,
abfallGramm: InputValidators.number(abfallController.text)!,
aufschlagProzent: InputValidators.number(aufschlagController.text),
fotoBase64: foto.value == null ? null : base64Encode(foto.value!),
filamente: selectedFilamente.entries
.map(
(item) => BauteilFilamentModel(
filamentId: item.key,
anwendungId: item.value,
verbrauchGramm:
InputValidators.number(
verbrauchController(item.key).text,
) ??
double.nan,
),
)
.toList(),
);
final error = _validateConsumption(entry, existing);
if (error != null) {
_error(error);
return;
}
final preis = _kalkulation(
entry,
entry.aufschlagProzent!,
previous: previous,
);
if (preis == null) {
_error(
'Preis nicht berechenbar. Bitte Verbrauch, Einkaufspreis und Startgewicht der Rollen sowie den Aufschlag prüfen.',
);
return;
}
final gespeichert = entry.copyWith(
bauteilId: entry.bauteilId,
erstelltAm: entry.erstelltAm,
abfallKosten: preis.abfallKosten,
materialKosten: preis.materialKosten,
aufschlagProzent: preis.aufschlagProzent,
gesamtpreis: preis.gesamtpreis,
);
await _commit([
...existing.where((item) => item.bauteilId != entry.bauteilId),
gespeichert,
]);
resetForm();
hasError.value = false;
message.value = 'Bauteil, Filamentverbrauch und Abfall gespeichert.';
} catch (_) {
_error('Speichern fehlgeschlagen. Bitte erneut versuchen.');
} finally {
isSaving.value = false;
}
}
Future<void> editBauteil(BauteilModel entry) async {
if (isSaving.value || isPickingPhoto.value) return;
resetForm();
_preisPausiert = true;
_bearbeitetesBauteil = entry;
message.value = null;
nameController.text = entry.name;
verwendungController.text = entry.verwendung;
stundenController.text = '${entry.druckdauerMinuten ~/ 60}';
minutenController.text = '${entry.druckdauerMinuten % 60}';
gewichtController.text = DisplayFormats.number(entry.gewichtGramm);
abfallController.text = DisplayFormats.number(entry.abfallGramm);
gesamtpreisController.text =
entry.gesamtpreis?.toString().replaceAll('.', ',') ?? '';
foto.value = entry.fotoBytes;
for (final item in entry.filamente) {
verbrauchController(item.filamentId).text = item.verbrauchGramm > 0
? DisplayFormats.number(item.verbrauchGramm)
: '';
selectedFilamente[item.filamentId] = item.anwendungId;
}
aufschlagController.text =
(entry.aufschlagProzent ?? standardAufschlagProzent)
.toString()
.replaceAll('.', ',');
editingId.value = entry.bauteilId;
_preisPausiert = false;
berechnePreis();
await scrollToForm();
}
Future<void> duplicateBauteil(BauteilModel entry) async {
if (isSaving.value || isPickingPhoto.value) return;
isSaving.value = true;
try {
final existing = _loadBauteile();
final copy = entry.copyWith(
bauteilId: const Uuid().v4(),
erstelltAm: DateTime.now(),
);
final error = _validateConsumption(copy, existing);
if (error != null) {
_error(error);
await scrollToForm();
return;
}
final preis = _kalkulation(
copy,
copy.aufschlagProzent ?? standardAufschlagProzent,
);
if (preis == null) {
_error(
'Abfallkosten nicht berechenbar. Bitte Einkaufspreis und Startgewicht der verwendeten Rollen prüfen.',
);
return;
}
await _commit([
...existing,
copy.copyWith(
bauteilId: copy.bauteilId,
erstelltAm: copy.erstelltAm,
abfallKosten: preis.abfallKosten,
materialKosten: preis.materialKosten,
aufschlagProzent: preis.aufschlagProzent,
gesamtpreis: preis.gesamtpreis,
),
]);
hasError.value = false;
message.value = 'Bauteil dupliziert und Verbrauch erneut gebucht.';
} catch (_) {
_error('Duplizieren fehlgeschlagen. Bitte erneut versuchen.');
await scrollToForm();
} finally {
isSaving.value = false;
}
}
Future<void> deleteBauteil(String id) async {
if (isSaving.value || isPickingPhoto.value) return;
isSaving.value = true;
try {
await _commit(
_loadBauteile().where((item) => item.bauteilId != id).toList(),
);
if (editingId.value == id) resetForm();
hasError.value = false;
message.value = 'Bauteil gelöscht und Verbrauch zurückgebucht.';
} catch (_) {
_error('Löschen fehlgeschlagen. Bitte erneut versuchen.');
await scrollToForm();
} finally {
isSaving.value = false;
}
}
Future<void> scrollToForm() async {
if (scrollController.hasClients) {
await scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
);
}
}
void resetForm() {
_preisPausiert = true;
_bearbeitetesBauteil = null;
formKey.currentState?.reset();
editingId.value = null;
nameController.clear();
verwendungController.clear();
stundenController.text = '0';
minutenController.text = '0';
gewichtController.clear();
abfallController.text = '0';
gesamtpreisController.clear();
selectedFilamente.clear();
for (final controller in _verbrauchControllers.values) {
controller.clear();
}
foto.value = null;
aufschlagController.text = standardAufschlagProzent.toStringAsFixed(0);
preisDetails.value = null;
_preisPausiert = false;
}
@override
void onClose() {
_closed = true;
scrollController.dispose();
nameController.dispose();
verwendungController.dispose();
stundenController.dispose();
minutenController.dispose();
gewichtController.dispose();
abfallController.dispose();
gesamtpreisController.dispose();
aufschlagController.dispose();
for (final controller in _verbrauchControllers.values) {
controller.dispose();
}
super.onClose();
}
}