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(); final BauteilPhotoService photoService; final formKey = GlobalKey(); 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(); bool _preisPausiert = false; BauteilModel? _bearbeitetesBauteil; final _verbrauchControllers = {}; final filamente = [].obs; final profile = [].obs; final bauteile = [].obs; final selectedFilamente = {}.obs; final editingId = RxnString(); final foto = Rxn(); 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 _loadBauteile() => storage.getModelList(storageKey).map(BauteilModel.fromJson).toList(); TextEditingController verbrauchController(String id) => _verbrauchControllers.putIfAbsent(id, () { final field = TextEditingController(); field.addListener(berechnePreis); return field; }); List 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 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 existing, ) { if (entry.filamente.isEmpty) { return 'Bitte mindestens ein Filament auswählen.'; } final sum = entry.filamente.fold( 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 _commit(List 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()) { Get.find().loadVerbrauch(); } if (Get.isRegistered()) { Get.find().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 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 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 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 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 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(); } }