From 20012db61571de0303e3aee558a0d9bec21f6854 Mon Sep 17 00:00:00 2001 From: josiadmin Date: Thu, 29 Oct 2026 15:03:48 +0100 Subject: [PATCH] add some new features --- .vscode/settings.json | 3 + android/gradle.properties | 4 + lib/controller/abfall_controller.dart | 212 +++++++++++++ .../bauteil_auswertung_controller.dart | 75 +++++ lib/controller/bauteil_controller.dart | 176 ++++++++++- lib/controller/export_controller.dart | 4 + lib/controller/home_controller.dart | 20 +- lib/helper/abfall_berechnung.dart | 71 +++++ lib/helper/bauteil_preis_berechnung.dart | 73 +++++ lib/helper/sample_routes.dart | 21 ++ lib/helper/storage_keys.dart | 1 + lib/models/abfall_model.dart | 38 +++ lib/models/backup_model.dart | 31 +- lib/models/bauteil_auswertung_model.dart | 27 ++ lib/models/bauteil_model.dart | 21 +- lib/models/kauf_filament_model.dart | 9 +- lib/pages/abfall_view.dart | 48 +++ lib/pages/bauteil_auswertung_view.dart | 191 ++++++++++++ lib/pages/bauteil_view.dart | 17 +- lib/pages/export_view.dart | 5 +- lib/pages/home_view.dart | 6 + lib/widgets/abfall_form.dart | 139 +++++++++ lib/widgets/abfall_inventory.dart | 189 ++++++++++++ lib/widgets/bauteil_card.dart | 12 + lib/widgets/bauteil_filament_auswahl.dart | 51 +++- lib/widgets/bauteil_form.dart | 19 +- lib/widgets/bauteil_preis_uebersicht.dart | 59 ++++ lib/widgets/entry_panel.dart | 22 +- lib/widgets/filament_card.dart | 16 +- lib/widgets/filament_form.dart | 89 +++--- test/abfall_test.dart | 221 ++++++++++++++ test/bauteil_abfall_test.dart | 286 ++++++++++++++++++ test/bauteil_auswertung_test.dart | 191 ++++++++++++ test/bauteil_bestand_test.dart | 37 ++- test/bauteil_preis_test.dart | 250 +++++++++++++++ test/export_test.dart | 1 + test/filament_gewicht_test.dart | 206 +++++++++++++ test/filament_vorrat_test.dart | 160 ++++++++++ test/widget_test.dart | 4 +- 39 files changed, 2908 insertions(+), 97 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 lib/controller/abfall_controller.dart create mode 100644 lib/controller/bauteil_auswertung_controller.dart create mode 100644 lib/helper/abfall_berechnung.dart create mode 100644 lib/helper/bauteil_preis_berechnung.dart create mode 100644 lib/models/abfall_model.dart create mode 100644 lib/models/bauteil_auswertung_model.dart create mode 100644 lib/pages/abfall_view.dart create mode 100644 lib/pages/bauteil_auswertung_view.dart create mode 100644 lib/widgets/abfall_form.dart create mode 100644 lib/widgets/abfall_inventory.dart create mode 100644 lib/widgets/bauteil_preis_uebersicht.dart create mode 100644 test/abfall_test.dart create mode 100644 test/bauteil_abfall_test.dart create mode 100644 test/bauteil_auswertung_test.dart create mode 100644 test/bauteil_preis_test.dart create mode 100644 test/filament_gewicht_test.dart create mode 100644 test/filament_vorrat_test.dart diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c5f3f6b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.configuration.updateBuildConfiguration": "interactive" +} \ No newline at end of file diff --git a/android/gradle.properties b/android/gradle.properties index e96108c..3f7b937 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -4,3 +4,7 @@ android.useAndroidX=true android.newDsl=false # This builtInKotlin flag was added by the Flutter template android.builtInKotlin=false + +# Project and Pub cache are on different Windows drives (E: and C:). +# Kotlin incremental caches cannot relativize paths across these roots. +kotlin.incremental=false diff --git a/lib/controller/abfall_controller.dart b/lib/controller/abfall_controller.dart new file mode 100644 index 0000000..043f0c4 --- /dev/null +++ b/lib/controller/abfall_controller.dart @@ -0,0 +1,212 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:uuid/uuid.dart'; + +import '../helper/input_validators.dart'; +import '../helper/abfall_berechnung.dart'; +import '../models/bauteil_model.dart'; +import '../models/kauf_filament_model.dart'; +import '../helper/storage_keys.dart'; +import '../models/abfall_model.dart'; +import '../services/get_storage_services.dart'; + +class AbfallController extends GetxController { + final storage = Get.find(); + final abfaelle = [].obs; + final datumZeit = DateTime.now().obs; + final editingId = RxnString(); + final isSaving = false.obs; + final message = RxnString(); + final hasError = false.obs; + final formKey = GlobalKey(); + final scrollController = ScrollController(); + final kilopreisController = TextEditingController(); + final gewichtController = TextEditingController(); + final kostenController = TextEditingController(); + + double get summeAbfall => + abfaelle.fold(0, (sum, entry) => sum + entry.gewichtGramm); + double get summeKosten => + abfaelle.fold(0, (sum, entry) => sum + entry.kosten); + + @override + void onInit() { + super.onInit(); + loadAbfaelle(); + } + + void loadAbfaelle() { + final entries = storage + .getModelList(StorageKeys.abfaelle) + .map(AbfallModel.fromJson) + .toList(); + final filamente = storage + .getModelList(StorageKeys.filamente) + .map(KaufFilamentModel.fromJson) + .toList(); + for (final bauteil + in storage + .getModelList(StorageKeys.bauteile) + .map(BauteilModel.fromJson)) { + if (bauteil.abfallGramm <= 0) continue; + final kosten = + bauteil.abfallKosten ?? AbfallBerechnung.kosten(bauteil, filamente); + entries.add( + AbfallModel( + abfallId: 'bauteil:${bauteil.bauteilId}', + bauteilId: bauteil.bauteilId, + bauteilName: bauteil.name, + datumZeit: bauteil.erstelltAm, + gewichtGramm: bauteil.abfallGramm, + kosten: kosten ?? 0, + kostenUnbekannt: kosten == null, + kostenNachtraeglich: bauteil.abfallKosten == null && kosten != null, + ), + ); + } + entries.sort((a, b) => b.datumZeit.compareTo(a.datumZeit)); + abfaelle.assignAll(entries); + } + + void berechneKosten(String _) { + final kilopreis = InputValidators.number(kilopreisController.text); + final gewicht = InputValidators.number(gewichtController.text); + if (kilopreis == null || + !kilopreis.isFinite || + kilopreis < 0 || + gewicht == null || + !gewicht.isFinite || + gewicht <= 0) { + return; + } + final kosten = gewicht / 1000 * kilopreis; + if (!kosten.isFinite) return; + kostenController.text = kosten.toStringAsFixed(2).replaceAll('.', ','); + } + + void setDatum(DateTime date) { + final current = datumZeit.value; + datumZeit.value = DateTime( + date.year, + date.month, + date.day, + current.hour, + current.minute, + ); + } + + void setUhrzeit(TimeOfDay time) { + final current = datumZeit.value; + datumZeit.value = DateTime( + current.year, + current.month, + current.day, + time.hour, + time.minute, + ); + } + + Future _commit(List entries) async { + await storage.saveModelList( + StorageKeys.abfaelle, + entries.map((entry) => entry.toJson()).toList(), + ); + loadAbfaelle(); + } + + Future saveAbfall() async { + if (isSaving.value) return; + message.value = null; + if (!(formKey.currentState?.validate() ?? false)) return; + isSaving.value = true; + try { + final entries = storage + .getModelList(StorageKeys.abfaelle) + .map(AbfallModel.fromJson) + .toList(); + final id = editingId.value; + if (id != null && !entries.any((entry) => entry.abfallId == id)) { + throw StateError('Eintrag nicht gefunden'); + } + final entry = AbfallModel( + abfallId: id ?? const Uuid().v4(), + datumZeit: datumZeit.value, + gewichtGramm: InputValidators.number(gewichtController.text)!, + kosten: InputValidators.number(kostenController.text)!, + ); + await _commit([ + ...entries.where((item) => item.abfallId != entry.abfallId), + entry, + ]); + resetForm(); + hasError.value = false; + message.value = 'Abfall gespeichert.'; + } catch (_) { + hasError.value = true; + message.value = 'Speichern fehlgeschlagen. Bitte erneut versuchen.'; + } finally { + isSaving.value = false; + } + } + + Future editAbfall(AbfallModel entry) async { + if (isSaving.value || entry.ausBauteil) return; + message.value = null; + editingId.value = entry.abfallId; + datumZeit.value = entry.datumZeit; + gewichtController.text = entry.gewichtGramm.toString().replaceAll('.', ','); + kostenController.text = entry.kosten.toString().replaceAll('.', ','); + if (scrollController.hasClients) { + await scrollController.animateTo( + 0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + ); + } + } + + Future deleteAbfall(String id) async { + if (isSaving.value || + abfaelle.any((item) => item.abfallId == id && item.ausBauteil)) { + return; + } + isSaving.value = true; + try { + final entries = storage + .getModelList(StorageKeys.abfaelle) + .map(AbfallModel.fromJson) + .where((entry) => entry.abfallId != id) + .toList(); + await _commit(entries); + if (editingId.value == id) resetForm(); + hasError.value = false; + message.value = 'Abfall gelöscht.'; + } catch (_) { + hasError.value = true; + message.value = 'Löschen fehlgeschlagen. Bitte erneut versuchen.'; + } finally { + isSaving.value = false; + } + } + + void resetForm() { + final kilopreis = kilopreisController.text; + formKey.currentState?.reset(); + kilopreisController.text = kilopreis; + editingId.value = null; + datumZeit.value = DateTime.now(); + gewichtController.clear(); + kostenController.clear(); + message.value = null; + hasError.value = false; + } + + @override + void onClose() { + scrollController.dispose(); + kilopreisController.dispose(); + gewichtController.dispose(); + kostenController.dispose(); + super.onClose(); + } +} diff --git a/lib/controller/bauteil_auswertung_controller.dart b/lib/controller/bauteil_auswertung_controller.dart new file mode 100644 index 0000000..0fa38c9 --- /dev/null +++ b/lib/controller/bauteil_auswertung_controller.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; + +import '../helper/storage_keys.dart'; +import '../models/bauteil_auswertung_model.dart'; +import '../models/bauteil_model.dart'; +import '../services/get_storage_services.dart'; + +class BauteilAuswertungController extends GetxController { + final storage = Get.find(); + final bauteile = [].obs; + final zeitraum = BauteilZeitraum.monat.obs; + final scrollController = ScrollController(); + + @override + void onInit() { + super.onInit(); + final entries = storage + .getModelList(StorageKeys.bauteile) + .map(BauteilModel.fromJson) + .toList(); + entries.sort((a, b) { + final datum = b.erstelltAm.compareTo(a.erstelltAm); + return datum == 0 ? a.bauteilId.compareTo(b.bauteilId) : datum; + }); + bauteile.assignAll(entries); + } + + double get gesamtsumme => BauteilAuswertungsGruppe.summeFuer(bauteile); + int get ohnePreis => bauteile + .where((item) => !BauteilAuswertungsGruppe.preisBekannt(item)) + .length; + + List get gruppen { + final auswahl = zeitraum.value; + final gruppiert = >{}; + for (final bauteil in bauteile) { + final datum = bauteil.erstelltAm.toLocal(); + final beginn = switch (auswahl) { + BauteilZeitraum.jahr => DateTime(datum.year), + BauteilZeitraum.monat => DateTime(datum.year, datum.month), + BauteilZeitraum.tag => DateTime(datum.year, datum.month, datum.day), + }; + gruppiert.putIfAbsent(beginn, () => []).add(bauteil); + } + final zeitpunkte = gruppiert.keys.toList()..sort((a, b) => b.compareTo(a)); + return zeitpunkte + .map( + (beginn) => BauteilAuswertungsGruppe( + beginn: beginn, + bauteile: List.unmodifiable(gruppiert[beginn]!), + ), + ) + .toList(); + } + + String gruppenTitel(DateTime beginn) => switch (zeitraum.value) { + BauteilZeitraum.jahr => 'Jahr ${beginn.year}', + BauteilZeitraum.monat => 'Monat ${DateFormat('MM.yyyy').format(beginn)}', + BauteilZeitraum.tag => DateFormat('dd.MM.yyyy').format(beginn), + }; + + void waehleZeitraum(BauteilZeitraum auswahl) { + if (auswahl == zeitraum.value) return; + if (scrollController.hasClients) scrollController.jumpTo(0); + zeitraum.value = auswahl; + } + + @override + void onClose() { + scrollController.dispose(); + super.onClose(); + } +} diff --git a/lib/controller/bauteil_controller.dart b/lib/controller/bauteil_controller.dart index 335fcc2..2b31b91 100644 --- a/lib/controller/bauteil_controller.dart +++ b/lib/controller/bauteil_controller.dart @@ -6,6 +6,7 @@ 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'; @@ -15,12 +16,14 @@ 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(); @@ -32,6 +35,12 @@ class BauteilController extends GetxController { 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; @@ -60,13 +69,20 @@ class BauteilController extends GetxController { ); 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, () => TextEditingController()); + _verbrauchControllers.putIfAbsent(id, () { + final field = TextEditingController(); + field.addListener(berechnePreis); + return field; + }); List profilesFor(String filamentId) => profile.where((item) => item.filamentId == filamentId).toList() @@ -78,6 +94,15 @@ class BauteilController extends GetxController { 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) @@ -97,6 +122,42 @@ class BauteilController extends GetxController { } 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) { @@ -190,8 +251,60 @@ class BauteilController extends GetxController { 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; @@ -225,7 +338,7 @@ class BauteilController extends GetxController { druckdauerMinuten: duration, gewichtGramm: InputValidators.number(gewichtController.text)!, abfallGramm: InputValidators.number(abfallController.text)!, - gesamtpreis: InputValidators.number(gesamtpreisController.text), + aufschlagProzent: InputValidators.number(aufschlagController.text), fotoBase64: foto.value == null ? null : base64Encode(foto.value!), filamente: selectedFilamente.entries .map( @@ -246,13 +359,32 @@ class BauteilController extends GetxController { _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), - entry, + gespeichert, ]); resetForm(); hasError.value = false; - message.value = 'Bauteil und Filamentverbrauch gespeichert.'; + message.value = 'Bauteil, Filamentverbrauch und Abfall gespeichert.'; } catch (_) { _error('Speichern fehlgeschlagen. Bitte erneut versuchen.'); } finally { @@ -263,6 +395,8 @@ class BauteilController extends GetxController { 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; @@ -279,7 +413,13 @@ class BauteilController extends GetxController { : ''; selectedFilamente[item.filamentId] = item.anwendungId; } + aufschlagController.text = + (entry.aufschlagProzent ?? standardAufschlagProzent) + .toString() + .replaceAll('.', ','); editingId.value = entry.bauteilId; + _preisPausiert = false; + berechnePreis(); await scrollToForm(); } @@ -298,7 +438,27 @@ class BauteilController extends GetxController { await scrollToForm(); return; } - await _commit([...existing, copy]); + 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 (_) { @@ -338,6 +498,8 @@ class BauteilController extends GetxController { } void resetForm() { + _preisPausiert = true; + _bearbeitetesBauteil = null; formKey.currentState?.reset(); editingId.value = null; nameController.clear(); @@ -352,6 +514,9 @@ class BauteilController extends GetxController { controller.clear(); } foto.value = null; + aufschlagController.text = standardAufschlagProzent.toStringAsFixed(0); + preisDetails.value = null; + _preisPausiert = false; } @override @@ -365,6 +530,7 @@ class BauteilController extends GetxController { gewichtController.dispose(); abfallController.dispose(); gesamtpreisController.dispose(); + aufschlagController.dispose(); for (final controller in _verbrauchControllers.values) { controller.dispose(); } diff --git a/lib/controller/export_controller.dart b/lib/controller/export_controller.dart index 74da7ee..da344ba 100644 --- a/lib/controller/export_controller.dart +++ b/lib/controller/export_controller.dart @@ -5,6 +5,7 @@ import '../models/backup_model.dart'; import '../services/backup_file_service.dart'; import '../services/get_storage_services.dart'; import 'home_controller.dart'; +import 'abfall_controller.dart'; class ExportController extends GetxController { ExportController({BackupFileService? fileService}) @@ -76,6 +77,9 @@ class ExportController extends GetxController { home.loadMaterialien(); home.resetForm(); } + if (Get.isRegistered()) { + Get.find().loadAbfaelle(); + } pendingBackup.value = null; hasError.value = false; message.value = 'Alle Daten wurden importiert.'; diff --git a/lib/controller/home_controller.dart b/lib/controller/home_controller.dart index c27c1eb..e2b292a 100644 --- a/lib/controller/home_controller.dart +++ b/lib/controller/home_controller.dart @@ -54,6 +54,7 @@ class HomeController extends GetxController { final preisController = TextEditingController(); final markeController = TextEditingController(); final gewichtController = TextEditingController(); + final bruttoGewichtController = TextEditingController(); final materialController = TextEditingController(text: 'PLA'); final kaufdatum = DateTime.now().obs; @@ -163,6 +164,18 @@ class HomeController extends GetxController { ); } + String? validateBruttogewicht(String? value) { + if ((value ?? '').trim().isEmpty) return null; + final error = InputValidators.positive(value); + if (error != null) return error; + final brutto = InputValidators.number(value)!; + final netto = InputValidators.number(gewichtController.text); + if (netto != null && netto.isFinite && brutto < netto) { + return 'Bruttogewicht muss mindestens dem Nettogewicht entsprechen.'; + } + return null; + } + Future saveFilament() async { if (!formKey.currentState!.validate()) return; @@ -186,7 +199,7 @@ class HomeController extends GetxController { gewicht < bereitsVerbraucht) { Get.snackbar( 'Ungültiges Gewicht', - 'Das Startgewicht muss positiv sein und mindestens dem bereits gebuchten Verbrauch entsprechen.', + 'Das Nettostartgewicht muss positiv sein und mindestens dem bereits gebuchten Verbrauch entsprechen.', ); return; } @@ -202,6 +215,7 @@ class HomeController extends GetxController { preis: preis, farbe: farbeController.text.trim(), gewicht: gewicht.toString(), + bruttoGewichtGramm: InputValidators.number(bruttoGewichtController.text), ); if (editingId.value == null) { @@ -231,6 +245,8 @@ class HomeController extends GetxController { haendlerController.text = filament.haendler; selectMaterial(filament.material); preisController.text = filament.preis.toStringAsFixed(2); + bruttoGewichtController.text = + filament.bruttoGewichtGramm?.toString().replaceAll('.', ',') ?? ''; farbeController.text = filament.farbe; gewichtController.text = filament.gewichtGramm == null ? filament.gewicht @@ -272,6 +288,7 @@ class HomeController extends GetxController { selectMaterial(materialien.firstOrNull ?? ''); materialMessage.value = null; gewichtController.clear(); + bruttoGewichtController.clear(); } @override @@ -283,6 +300,7 @@ class HomeController extends GetxController { farbeController.dispose(); preisController.dispose(); gewichtController.dispose(); + bruttoGewichtController.dispose(); materialController.dispose(); super.onClose(); } diff --git a/lib/helper/abfall_berechnung.dart b/lib/helper/abfall_berechnung.dart new file mode 100644 index 0000000..28280ab --- /dev/null +++ b/lib/helper/abfall_berechnung.dart @@ -0,0 +1,71 @@ +import '../models/bauteil_model.dart'; +import '../models/kauf_filament_model.dart'; + +class AbfallBerechnung { + /// Roll price is divided by its original weight, never by remaining stock. + /// For multi-filament prints, waste follows each filament's usage share. + static double? kosten( + BauteilModel bauteil, + Iterable filamente, + ) { + if (bauteil.abfallGramm == 0) return 0; + if (!bauteil.abfallGramm.isFinite || + bauteil.abfallGramm < 0 || + bauteil.filamente.isEmpty) { + return null; + } + final preis = preisProGramm(bauteil, filamente); + if (preis == null) return null; + final kosten = bauteil.abfallGramm * preis; + return kosten.isFinite ? double.parse(kosten.toStringAsFixed(2)) : null; + } + + static double? preisProGramm( + BauteilModel bauteil, + Iterable filamente, + ) { + if (bauteil.filamente.isEmpty) return null; + final rollen = {for (final rolle in filamente) rolle.filamentId: rolle}; + final gesamtverbrauch = bauteil.filamente.fold( + 0, + (sum, item) => sum + item.verbrauchGramm, + ); + if (bauteil.filamente.length > 1 && + (!gesamtverbrauch.isFinite || gesamtverbrauch <= 0)) { + return null; + } + var preis = 0.0; + for (final verbrauch in bauteil.filamente) { + final rolle = rollen[verbrauch.filamentId]; + final startgewicht = rolle?.gewichtGramm; + if (rolle == null || + startgewicht == null || + startgewicht <= 0 || + !rolle.preis.isFinite || + rolle.preis < 0 || + !verbrauch.verbrauchGramm.isFinite || + verbrauch.verbrauchGramm < 0 || + (bauteil.filamente.length > 1 && verbrauch.verbrauchGramm == 0)) { + return null; + } + final anteil = bauteil.filamente.length == 1 + ? 1.0 + : verbrauch.verbrauchGramm / gesamtverbrauch; + preis += anteil * (rolle.preis / startgewicht); + } + return preis.isFinite ? preis : null; + } + + /// Changing metadata alone must not change an already recorded material cost. + static bool gleicheBasis(BauteilModel a, BauteilModel b) => + a.gewichtGramm == b.gewichtGramm && + a.abfallGramm == b.abfallGramm && + a.filamente.length == b.filamente.length && + a.filamente.every( + (item) => b.filamente.any( + (other) => + item.filamentId == other.filamentId && + item.verbrauchGramm == other.verbrauchGramm, + ), + ); +} diff --git a/lib/helper/bauteil_preis_berechnung.dart b/lib/helper/bauteil_preis_berechnung.dart new file mode 100644 index 0000000..fc84235 --- /dev/null +++ b/lib/helper/bauteil_preis_berechnung.dart @@ -0,0 +1,73 @@ +import '../models/bauteil_model.dart'; +import '../models/kauf_filament_model.dart'; +import 'abfall_berechnung.dart'; + +class BauteilPreis { + const BauteilPreis({ + required this.materialKosten, + required this.abfallKosten, + required this.aufschlagProzent, + required this.gesamtpreis, + }); + final double materialKosten; + final double abfallKosten; + final double aufschlagProzent; + final double gesamtpreis; + double get aufschlagKosten => double.parse( + (gesamtpreis - materialKosten - abfallKosten).toStringAsFixed(2), + ); +} + +class BauteilPreisBerechnung { + static double _cent(double value) => double.parse(value.toStringAsFixed(2)); + + static BauteilPreis? berechne( + BauteilModel entry, + Iterable rollen, + double aufschlag, { + BauteilModel? previous, + }) { + if (!entry.gewichtGramm.isFinite || + entry.gewichtGramm <= 0 || + !entry.abfallGramm.isFinite || + entry.abfallGramm < 0 || + !aufschlag.isFinite || + aufschlag < 0) { + return null; + } + double material; + double abfall; + if (previous?.materialKosten != null && + previous?.abfallKosten != null && + AbfallBerechnung.gleicheBasis(entry, previous!)) { + material = previous.materialKosten!; + abfall = previous.abfallKosten!; + } else { + if (entry.filamente.length > 1) { + final summe = entry.filamente.fold( + 0, + (sum, item) => sum + item.verbrauchGramm, + ); + if (!summe.isFinite || + (summe - entry.gesamtverbrauchGramm).abs() > .01) { + return null; + } + } + final preis = AbfallBerechnung.preisProGramm(entry, rollen); + if (preis == null) return null; + final materialRoh = entry.gewichtGramm * preis; + final abfallRoh = entry.abfallGramm * preis; + if (!materialRoh.isFinite || !abfallRoh.isFinite) return null; + material = _cent(materialRoh); + abfall = _cent(abfallRoh); + } + final gesamt = (material + abfall) * (1 + aufschlag / 100); + if (!gesamt.isFinite) return null; + return BauteilPreis( + materialKosten: material, + abfallKosten: abfall, + aufschlagProzent: aufschlag, + gesamtpreis: _cent(gesamt), + ); + } +} diff --git a/lib/helper/sample_routes.dart b/lib/helper/sample_routes.dart index a134452..b71db5d 100644 --- a/lib/helper/sample_routes.dart +++ b/lib/helper/sample_routes.dart @@ -1,17 +1,38 @@ +import '../controller/abfall_controller.dart'; +import '../pages/abfall_view.dart'; + import 'package:get/get.dart'; import '../controller/anwendung_controller.dart'; import '../controller/bauteil_controller.dart'; +import '../controller/bauteil_auswertung_controller.dart'; import '../controller/export_controller.dart'; import '../pages/export_view.dart'; import '../pages/anwendung_view.dart'; import '../pages/bauteil_view.dart'; +import '../pages/bauteil_auswertung_view.dart'; import '../pages/home_view.dart'; import 'sample_bindings.dart'; class SampleRouts { static final sampleBindings = SampleBindings(); static List> samplePages = [ + GetPage( + name: BauteilAuswertungView.routeName, + page: () => const BauteilAuswertungView(), + binding: BindingsBuilder( + () => Get.lazyPut( + () => BauteilAuswertungController(), + ), + ), + ), + GetPage( + name: AbfallView.routeName, + page: () => const AbfallView(), + binding: BindingsBuilder( + () => Get.lazyPut(() => AbfallController()), + ), + ), GetPage( name: ExportView.routeName, page: () => const ExportView(), diff --git a/lib/helper/storage_keys.dart b/lib/helper/storage_keys.dart index 82daa2a..89fd22e 100644 --- a/lib/helper/storage_keys.dart +++ b/lib/helper/storage_keys.dart @@ -3,4 +3,5 @@ class StorageKeys { static const materialien = 'filament_materialien'; static const anwendungen = 'filament_anwendungen'; static const bauteile = 'gedruckte_bauteile'; + static const abfaelle = 'abfall_eintraege'; } diff --git a/lib/models/abfall_model.dart b/lib/models/abfall_model.dart new file mode 100644 index 0000000..d5139a3 --- /dev/null +++ b/lib/models/abfall_model.dart @@ -0,0 +1,38 @@ +class AbfallModel { + const AbfallModel({ + required this.abfallId, + required this.datumZeit, + required this.gewichtGramm, + required this.kosten, + this.bauteilId, + this.bauteilName, + this.kostenUnbekannt = false, + this.kostenNachtraeglich = false, + }); + + final String abfallId; + final DateTime datumZeit; + final double gewichtGramm; + final double kosten; + + // Linked records are derived from parts, never stored a second time. + final String? bauteilId; + final String? bauteilName; + final bool kostenUnbekannt; + final bool kostenNachtraeglich; + bool get ausBauteil => bauteilId != null; + + factory AbfallModel.fromJson(Map json) => AbfallModel( + abfallId: json['abfallId'] as String, + datumZeit: DateTime.parse(json['datumZeit'] as String).toLocal(), + gewichtGramm: (json['gewichtGramm'] as num).toDouble(), + kosten: (json['kosten'] as num).toDouble(), + ); + + Map toJson() => { + 'abfallId': abfallId, + 'datumZeit': datumZeit.toIso8601String(), + 'gewichtGramm': gewichtGramm, + 'kosten': kosten, + }; +} diff --git a/lib/models/backup_model.dart b/lib/models/backup_model.dart index f1365d5..181b4a6 100644 --- a/lib/models/backup_model.dart +++ b/lib/models/backup_model.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import '../helper/storage_keys.dart'; import 'anwendung_model.dart'; +import 'abfall_model.dart'; import 'bauteil_model.dart'; import 'kauf_filament_model.dart'; @@ -11,6 +12,7 @@ class BackupModel { StorageKeys.anwendungen, StorageKeys.bauteile, StorageKeys.materialien, + StorageKeys.abfaelle, ]; final Map>> data; @@ -34,9 +36,12 @@ class BackupModel { final raw = json['daten'] as Map; final data = >>{ for (final key in keys) - key: (raw[key] as List) - .map((item) => Map.from(item as Map)) - .toList(), + key: + ((key == StorageKeys.abfaelle && !raw.containsKey(key)) + ? [] + : raw[key] as List) + .map((item) => Map.from(item as Map)) + .toList(), }; final filamentIds = {}; for (final json in data[StorageKeys.filamente]!) { @@ -45,7 +50,10 @@ class BackupModel { !filamentIds.add(item.filamentId) || item.material.trim().isEmpty || !_positive(item.gewichtGramm) || - !_nonNegative(item.preis)) { + !_nonNegative(item.preis) || + (item.bruttoGewichtGramm != null && + (!_positive(item.bruttoGewichtGramm) || + item.bruttoGewichtGramm! < item.gewichtGramm!))) { throw const FormatException(); } } @@ -71,6 +79,11 @@ class BackupModel { item.druckdauerMinuten <= 0 || !_positive(item.gewichtGramm) || !_nonNegative(item.abfallGramm) || + (item.abfallKosten != null && !_nonNegative(item.abfallKosten!)) || + (item.materialKosten != null && + !_nonNegative(item.materialKosten!)) || + (item.aufschlagProzent != null && + !_nonNegative(item.aufschlagProzent!)) || (item.gesamtpreis != null && !_nonNegative(item.gesamtpreis!)) || item.filamente.isEmpty || item.filamente.any( @@ -82,6 +95,16 @@ class BackupModel { } if (item.fotoBase64 != null) base64Decode(item.fotoBase64!); } + final wasteIds = {}; + for (final json in data[StorageKeys.abfaelle]!) { + final item = AbfallModel.fromJson(json); + if (item.abfallId.isEmpty || + !wasteIds.add(item.abfallId) || + !_positive(item.gewichtGramm) || + !_nonNegative(item.kosten)) { + throw const FormatException(); + } + } final materials = data[StorageKeys.materialien]!; if (materials.isNotEmpty) { if (materials.length != 1) throw const FormatException(); diff --git a/lib/models/bauteil_auswertung_model.dart b/lib/models/bauteil_auswertung_model.dart new file mode 100644 index 0000000..dd656a4 --- /dev/null +++ b/lib/models/bauteil_auswertung_model.dart @@ -0,0 +1,27 @@ +import 'bauteil_model.dart'; + +enum BauteilZeitraum { jahr, monat, tag } + +class BauteilAuswertungsGruppe { + const BauteilAuswertungsGruppe({ + required this.beginn, + required this.bauteile, + }); + final DateTime beginn; + final List bauteile; + + static bool preisBekannt(BauteilModel bauteil) => + bauteil.gesamtpreis != null && + bauteil.gesamtpreis!.isFinite && + bauteil.gesamtpreis! >= 0; + + static double summeFuer(Iterable bauteile) => double.parse( + bauteile + .where(preisBekannt) + .fold(0, (sum, item) => sum + item.gesamtpreis!) + .toStringAsFixed(2), + ); + + double get summe => summeFuer(bauteile); + int get ohnePreis => bauteile.where((item) => !preisBekannt(item)).length; +} diff --git a/lib/models/bauteil_model.dart b/lib/models/bauteil_model.dart index 10c741a..6cf6ee5 100644 --- a/lib/models/bauteil_model.dart +++ b/lib/models/bauteil_model.dart @@ -35,6 +35,9 @@ class BauteilModel { final double gewichtGramm; final double abfallGramm; final double? gesamtpreis; + final double? abfallKosten; + final double? materialKosten; + final double? aufschlagProzent; final List filamente; final String? fotoBase64; @@ -48,6 +51,9 @@ class BauteilModel { required this.abfallGramm, required this.filamente, this.gesamtpreis, + this.abfallKosten, + this.materialKosten, + this.aufschlagProzent, this.fotoBase64, }); @@ -65,6 +71,10 @@ class BauteilModel { BauteilModel copyWith({ required String bauteilId, required DateTime erstelltAm, + double? abfallKosten, + double? materialKosten, + double? aufschlagProzent, + double? gesamtpreis, }) => BauteilModel( bauteilId: bauteilId, erstelltAm: erstelltAm, @@ -73,7 +83,10 @@ class BauteilModel { druckdauerMinuten: druckdauerMinuten, gewichtGramm: gewichtGramm, abfallGramm: abfallGramm, - gesamtpreis: gesamtpreis, + gesamtpreis: gesamtpreis ?? this.gesamtpreis, + abfallKosten: abfallKosten ?? this.abfallKosten, + materialKosten: materialKosten ?? this.materialKosten, + aufschlagProzent: aufschlagProzent ?? this.aufschlagProzent, filamente: filamente, fotoBase64: fotoBase64, ); @@ -87,6 +100,9 @@ class BauteilModel { 'gewichtGramm': gewichtGramm, 'abfallGramm': abfallGramm, 'gesamtpreis': gesamtpreis, + 'abfallKosten': abfallKosten, + 'materialKosten': materialKosten, + 'aufschlagProzent': aufschlagProzent, 'filamente': filamente.map((item) => item.toJson()).toList(), 'fotoBase64': fotoBase64, }; @@ -100,6 +116,9 @@ class BauteilModel { gewichtGramm: (json['gewichtGramm'] as num).toDouble(), abfallGramm: (json['abfallGramm'] as num).toDouble(), gesamtpreis: (json['gesamtpreis'] as num?)?.toDouble(), + abfallKosten: (json['abfallKosten'] as num?)?.toDouble(), + materialKosten: (json['materialKosten'] as num?)?.toDouble(), + aufschlagProzent: (json['aufschlagProzent'] as num?)?.toDouble(), fotoBase64: json['fotoBase64'] as String?, filamente: (json['filamente'] as List) .map( diff --git a/lib/models/kauf_filament_model.dart b/lib/models/kauf_filament_model.dart index 381e0ae..26ae81a 100644 --- a/lib/models/kauf_filament_model.dart +++ b/lib/models/kauf_filament_model.dart @@ -6,7 +6,9 @@ class KaufFilamentModel { final String material; final double preis; final String farbe; + // Original net material weight, without the spool. final String gewicht; + final double? bruttoGewichtGramm; const KaufFilamentModel({ required this.filamentId, @@ -17,9 +19,10 @@ class KaufFilamentModel { required this.preis, required this.farbe, required this.gewicht, + this.bruttoGewichtGramm, }); - /// Supports old purchases saved as "1kg" as well as gram values. + /// Original net material weight. Supports legacy "1kg" and gram values. double? get gewichtGramm { final match = RegExp( r'^\s*(\d+(?:[.,]\d+)?)\s*(kg|g)?\s*$', @@ -41,6 +44,7 @@ class KaufFilamentModel { 'preis': preis, 'farbe': farbe, 'gewicht': gewicht, + 'bruttoGewichtGramm': bruttoGewichtGramm, }; factory KaufFilamentModel.fromJson(Map json) { @@ -53,6 +57,7 @@ class KaufFilamentModel { preis: (json['preis'] as num).toDouble(), farbe: json['farbe'] as String, gewicht: json['gewicht'] ?? '1000', + bruttoGewichtGramm: (json['bruttoGewichtGramm'] as num?)?.toDouble(), ); } @@ -65,6 +70,7 @@ class KaufFilamentModel { double? preis, String? farbe, String? gewicht, + double? bruttoGewichtGramm, }) { return KaufFilamentModel( filamentId: filamentId ?? this.filamentId, @@ -75,6 +81,7 @@ class KaufFilamentModel { preis: preis ?? this.preis, farbe: farbe ?? this.farbe, gewicht: gewicht ?? this.gewicht, + bruttoGewichtGramm: bruttoGewichtGramm ?? this.bruttoGewichtGramm, ); } } diff --git a/lib/pages/abfall_view.dart b/lib/pages/abfall_view.dart new file mode 100644 index 0000000..2c453f0 --- /dev/null +++ b/lib/pages/abfall_view.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../controller/abfall_controller.dart'; +import '../widgets/abfall_form.dart'; +import '../widgets/abfall_inventory.dart'; + +class AbfallView extends GetView { + static const routeName = '/abfall-view'; + const AbfallView({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + leading: IconButton( + tooltip: 'Zurück', + icon: const Icon(Icons.arrow_back), + onPressed: () => Get.back(), + ), + title: const Text('Abfallverwaltung'), + ), + body: SafeArea( + top: false, + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1000), + child: CustomScrollView( + controller: controller.scrollController, + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverToBoxAdapter( + child: AbfallForm(controller: controller), + ), + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 24), + sliver: AbfallInventory(controller: controller), + ), + ], + ), + ), + ), + ), + ); +} diff --git a/lib/pages/bauteil_auswertung_view.dart b/lib/pages/bauteil_auswertung_view.dart new file mode 100644 index 0000000..918f69f --- /dev/null +++ b/lib/pages/bauteil_auswertung_view.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../controller/bauteil_auswertung_controller.dart'; +import '../helper/display_formats.dart'; +import '../models/bauteil_auswertung_model.dart'; +import '../models/bauteil_model.dart'; +import '../theme/app_theme.dart'; +import '../widgets/entry_panel.dart'; + +class BauteilAuswertungView extends GetView { + static const routeName = '/bauteile-auswertung-view'; + const BauteilAuswertungView({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + leading: IconButton( + tooltip: 'Zurück zu den Bauteilen', + icon: const Icon(Icons.arrow_back), + onPressed: () => Get.back(), + ), + title: const Text('Bauteilauswertung'), + ), + body: SafeArea( + top: false, + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1000), + child: Obx(() { + final gruppen = controller.gruppen; + // Headers and entries share one lazy list, even with many periods. + final zeilen = [ + for (final gruppe in gruppen) ...[gruppe, ...gruppe.bauteile], + ]; + return CustomScrollView( + controller: controller.scrollController, + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverToBoxAdapter(child: _uebersicht()), + ), + if (zeilen.isEmpty) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(24), + child: Text( + 'Noch keine Bauteile gespeichert.', + textAlign: TextAlign.center, + style: TextStyle(color: AppTheme.muted), + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + sliver: SliverList.builder( + itemCount: zeilen.length, + itemBuilder: (context, index) { + final zeile = zeilen[index]; + if (zeile is BauteilAuswertungsGruppe) { + return _gruppenKopf(zeile); + } + final bauteil = zeile as BauteilModel; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: SavedEntryCard( + key: ValueKey('auswertung-${bauteil.bauteilId}'), + title: bauteil.name, + subtitle: DisplayFormats.date( + bauteil.erstelltAm.toLocal(), + ), + notes: bauteil.verwendung, + details: [ + BauteilAuswertungsGruppe.preisBekannt(bauteil) + ? 'Gesamtpreis: ${DisplayFormats.currency(bauteil.gesamtpreis!)}' + : 'Gesamtpreis nicht erfasst', + '${DisplayFormats.number(bauteil.gewichtGramm)} g Bauteil', + '${DisplayFormats.number(bauteil.abfallGramm)} g Abfall', + DisplayFormats.duration( + bauteil.druckdauerMinuten, + ), + ], + ), + ); + }, + ), + ), + ], + ); + }), + ), + ), + ), + ); + + Widget _uebersicht() => Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppTheme.inputSurface, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: const Color(0xFFCEE6DF)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Alle gedruckten Bauteile', + style: TextStyle(fontSize: 23, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + Text( + '${controller.bauteile.length} Bauteile · neueste zuerst', + style: const TextStyle(color: AppTheme.muted), + ), + const SizedBox(height: 16), + const Text('Gesamtsumme', style: TextStyle(color: AppTheme.muted)), + Text( + DisplayFormats.currency(controller.gesamtsumme), + style: const TextStyle( + color: AppTheme.teal, + fontSize: 28, + fontWeight: FontWeight.w700, + ), + ), + if (controller.ohnePreis > 0) ...[ + const SizedBox(height: 8), + Text( + '${controller.ohnePreis} Bauteile ohne Gesamtpreis sind in der Liste enthalten, aber nicht in der Preissumme.', + style: const TextStyle(color: AppTheme.muted, height: 1.5), + ), + ], + const SizedBox(height: 24), + const Text( + 'Gruppieren nach', + style: TextStyle(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + SegmentedButton( + showSelectedIcon: false, + segments: const [ + ButtonSegment(value: BauteilZeitraum.jahr, label: Text('Jahr')), + ButtonSegment(value: BauteilZeitraum.monat, label: Text('Monat')), + ButtonSegment(value: BauteilZeitraum.tag, label: Text('Tag')), + ], + selected: {controller.zeitraum.value}, + onSelectionChanged: (auswahl) => + controller.waehleZeitraum(auswahl.single), + ), + ], + ), + ); + + Widget _gruppenKopf(BauteilAuswertungsGruppe gruppe) => Container( + margin: const EdgeInsets.only(top: 8, bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.inventorySurface, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + controller.gruppenTitel(gruppe.beginn), + style: const TextStyle( + color: Colors.white, + fontSize: 21, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + '${gruppe.bauteile.length} Bauteile · Summe: ${DisplayFormats.currency(gruppe.summe)}', + style: const TextStyle( + color: Color(0xFF79D7C6), + fontWeight: FontWeight.w600, + ), + ), + if (gruppe.ohnePreis > 0) ...[ + const SizedBox(height: 6), + Text( + '${gruppe.ohnePreis} ohne Gesamtpreis', + style: const TextStyle(color: Color(0xFFB9CCD9)), + ), + ], + ], + ), + ); +} diff --git a/lib/pages/bauteil_view.dart b/lib/pages/bauteil_view.dart index 383727a..84c8f06 100644 --- a/lib/pages/bauteil_view.dart +++ b/lib/pages/bauteil_view.dart @@ -8,6 +8,7 @@ import '../widgets/bauteil_card.dart'; import '../widgets/bauteil_delete_dialog.dart'; import '../widgets/bauteil_form.dart'; import '../widgets/entry_panel.dart'; +import 'bauteil_auswertung_view.dart'; class BauteilView extends GetView { static const routeName = '/bauteile-view'; @@ -15,7 +16,21 @@ class BauteilView extends GetView { @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text('Gedruckte Bauteile')), + appBar: AppBar( + title: const Text( + 'Gedruckte Bauteile', + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + actions: [ + IconButton( + tooltip: 'Bauteile auswerten', + icon: const Icon(Icons.bar_chart_outlined), + onPressed: () => Get.toNamed(BauteilAuswertungView.routeName), + ), + const SizedBox(width: 8), + ], + ), body: SafeArea( top: false, child: Align( diff --git a/lib/pages/export_view.dart b/lib/pages/export_view.dart index 45616e8..67f2fb9 100644 --- a/lib/pages/export_view.dart +++ b/lib/pages/export_view.dart @@ -27,7 +27,7 @@ class ExportView extends GetView { children: [ EntryPanel( title: 'Daten exportieren', - subtitle: 'Sichere Filamente, Verarbeitungsprofile, Bauteile inklusive Fotos und Verbrauch sowie deine Materialarten in einer JSON-Datei.\n\nSpeichere die Datei außerhalb der App, zum Beispiel in Dokumente oder in deiner Cloud. So kannst du sie nach einer Neuinstallation wieder importieren.', + subtitle: 'Sichere Filamente, Verarbeitungsprofile, Bauteile inklusive Fotos und Verbrauch sowie deine Materialarten und Abfalleinträge in einer JSON-Datei.\n\nSpeichere die Datei außerhalb der App, zum Beispiel in Dokumente oder in deiner Cloud. So kannst du sie nach einer Neuinstallation wieder importieren.', child: FilledButton.icon( onPressed: busy ? null : controller.exportData, icon: const Icon(Icons.file_download_outlined), @@ -63,6 +63,9 @@ class ExportView extends GetView { ? 'Materialarten: Standardliste' : '${(backup.data[StorageKeys.materialien]!.single['werte'] as List).length} Materialarten', ), + Text( + '${backup.data[StorageKeys.abfaelle]!.length} Abfalleinträge', + ), const SizedBox(height: 16), FilledButton.icon( onPressed: busy diff --git a/lib/pages/home_view.dart b/lib/pages/home_view.dart index fb60941..5c5712c 100644 --- a/lib/pages/home_view.dart +++ b/lib/pages/home_view.dart @@ -9,6 +9,7 @@ import '../widgets/filament_inventory.dart'; import '../widgets/app_version.dart'; import 'anwendung_view.dart'; import 'bauteil_view.dart'; +import 'abfall_view.dart'; import 'export_view.dart'; class HomeView extends GetView { @@ -26,6 +27,11 @@ class HomeView extends GetView { icon: const Icon(Icons.view_in_ar_outlined), onPressed: () => Get.toNamed(BauteilView.routeName), ), + IconButton( + tooltip: 'Abfall verwalten', + icon: const Icon(Icons.delete_outline), + onPressed: () => Get.toNamed(AbfallView.routeName), + ), const SizedBox(width: 8), ], title: LayoutBuilder( diff --git a/lib/widgets/abfall_form.dart b/lib/widgets/abfall_form.dart new file mode 100644 index 0000000..b3f547b --- /dev/null +++ b/lib/widgets/abfall_form.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; + +import '../controller/abfall_controller.dart'; +import '../helper/input_validators.dart'; +import 'entry_panel.dart'; + +class AbfallForm extends StatelessWidget { + const AbfallForm({super.key, required this.controller}); + final AbfallController controller; + + @override + Widget build(BuildContext context) => Obx(() { + final date = controller.datumZeit.value; + final busy = controller.isSaving.value; + final editing = controller.editingId.value != null; + return EntryPanel( + title: editing ? 'Abfall bearbeiten' : 'Abfall erfassen', + subtitle: 'Erfasse zusätzlichen Abfall. Abfall aus gespeicherten Bauteilen erscheint automatisch in der Übersicht.', + child: Form( + key: controller.formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + EntryFieldGrid( + children: [ + OutlinedButton.icon( + onPressed: busy + ? null + : () async { + final picked = await showDatePicker( + context: context, + initialDate: date, + firstDate: DateTime(1900), + lastDate: DateTime(2100, 12, 31), + helpText: 'Datum auswählen', + cancelText: 'Abbrechen', + confirmText: 'Übernehmen', + ); + if (picked != null && context.mounted) { + controller.setDatum(picked); + } + }, + icon: const Icon(Icons.calendar_today_outlined), + label: Text( + 'Datum: ${DateFormat('dd.MM.yyyy').format(date)}', + ), + ), + OutlinedButton.icon( + onPressed: busy + ? null + : () async { + final picked = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(date), + helpText: 'Uhrzeit auswählen', + cancelText: 'Abbrechen', + confirmText: 'Übernehmen', + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(alwaysUse24HourFormat: true), + child: child!, + ), + ); + if (picked != null && context.mounted) { + controller.setUhrzeit(picked); + } + }, + icon: const Icon(Icons.schedule), + label: Text('Uhrzeit: ${DateFormat('HH:mm').format(date)}'), + ), + ], + ), + const SizedBox(height: 20), + const Text( + 'Durchschnittlicher Preis pro kg', + style: TextStyle(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + EntryNumberField( + controller: controller.kilopreisController, + label: 'Kilopreis (optional)', + unit: '€/kg', + validator: (value) => + InputValidators.nonNegative(value, optional: true), + onChanged: controller.berechneKosten, + ), + const SizedBox(height: 16), + EntryFieldGrid( + children: [ + EntryNumberField( + controller: controller.gewichtController, + onChanged: controller.berechneKosten, + label: 'Abfallgewicht', + unit: 'g', + validator: InputValidators.positive, + ), + EntryNumberField( + controller: controller.kostenController, + label: 'Abfallkosten', + unit: '€', + validator: InputValidators.nonNegative, + ), + ], + ), + const SizedBox(height: 8), + const Text( + 'Abfallkosten sind manuell änderbar. Bei Änderung von Gewicht oder Kilopreis werden sie neu berechnet.', + style: TextStyle(fontSize: 12), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: busy ? null : controller.saveAbfall, + icon: Icon(editing ? Icons.check : Icons.add), + label: Text( + busy + ? 'Wird gespeichert …' + : editing + ? 'Änderungen speichern' + : 'Abfall speichern', + ), + ), + if (editing) + TextButton.icon( + onPressed: busy ? null : controller.resetForm, + icon: const Icon(Icons.close), + label: const Text('Bearbeitung abbrechen'), + ), + EntryMessage( + message: controller.message.value, + hasError: controller.hasError.value, + ), + ], + ), + ), + ); + }); +} diff --git a/lib/widgets/abfall_inventory.dart b/lib/widgets/abfall_inventory.dart new file mode 100644 index 0000000..e6151b0 --- /dev/null +++ b/lib/widgets/abfall_inventory.dart @@ -0,0 +1,189 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../controller/abfall_controller.dart'; +import '../helper/display_formats.dart'; +import '../models/abfall_model.dart'; +import '../theme/app_theme.dart'; +import 'entry_panel.dart'; + +class AbfallInventory extends StatelessWidget { + const AbfallInventory({super.key, required this.controller}); + final AbfallController controller; + + @override + Widget build(BuildContext context) => DecoratedSliver( + decoration: BoxDecoration( + color: AppTheme.inventorySurface, + borderRadius: BorderRadius.circular(24), + ), + sliver: SliverPadding( + padding: const EdgeInsets.all(24), + sliver: Obx(() { + final entries = controller.abfaelle.toList(); + final busy = controller.isSaving.value; + return SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Abfallübersicht', + style: TextStyle( + color: Colors.white, + fontSize: 25, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + Text( + '${entries.length} Einträge · die neuesten zuerst.', + style: const TextStyle(color: Color(0xFFB9CCD9)), + ), + const SizedBox(height: 20), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _AbfallStat( + label: 'Summe Abfall', + value: + '${DisplayFormats.number(controller.summeAbfall)} g', + ), + _AbfallStat( + label: 'Summe Abfallkosten', + value: DisplayFormats.currency(controller.summeKosten), + ), + ], + ), + if (entries.any((entry) => entry.kostenUnbekannt)) ...[ + const SizedBox(height: 12), + const Text( + 'Bei einigen älteren Bauteilen fehlen Filamentdaten. Ihr Gewicht ist enthalten; ihre unbekannten Kosten fehlen in der Kostensumme.', + style: TextStyle(color: Color(0xFFB9CCD9), height: 1.5), + ), + ], + const SizedBox(height: 24), + ], + ), + ), + if (entries.isEmpty) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Text( + 'Noch kein Abfall gespeichert. Erfasse deinen ersten Eintrag.', + style: TextStyle(color: Color(0xFFB9CCD9), height: 1.5), + ), + ), + ) + else + SliverList.separated( + itemCount: entries.length, + separatorBuilder: (context, index) => + const SizedBox(height: 12), + itemBuilder: (context, index) { + final entry = entries[index]; + return SavedEntryCard( + key: ValueKey(entry.abfallId), + title: + '${DisplayFormats.number(entry.gewichtGramm)} g Abfall', + subtitle: DisplayFormats.date(entry.datumZeit), + notes: entry.ausBauteil + ? 'Aus Bauteil „${entry.bauteilName}“. Änderungen und Löschen über die Bauteilverwaltung.${entry.kostenNachtraeglich ? ' Kosten nachträglich aus den aktuellen Rollenpreisen und Startgewichten berechnet.' : ''}' + : '', + details: [ + entry.kostenUnbekannt + ? 'Abfallkosten: nicht ermittelbar' + : 'Abfallkosten: ${DisplayFormats.currency(entry.kosten)}', + ], + trailing: entry.ausBauteil + ? null + : PopupMenuButton( + tooltip: 'Abfalleintrag verwalten', + enabled: !busy, + iconColor: AppTheme.ink, + onSelected: (action) { + if (action == 'edit') { + controller.editAbfall(entry); + } else if (action == 'delete') { + _confirmDelete(context, entry); + } + }, + itemBuilder: (context) => const [ + PopupMenuItem( + value: 'edit', + child: Text('Bearbeiten'), + ), + PopupMenuItem( + value: 'delete', + child: Text('Löschen'), + ), + ], + ), + ); + }, + ), + ], + ); + }), + ), + ); + + Future _confirmDelete(BuildContext context, AbfallModel entry) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Abfall löschen?'), + content: Text( + '${DisplayFormats.number(entry.gewichtGramm)} g Abfall vom ${DisplayFormats.date(entry.datumZeit)} löschen?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Abbrechen'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Löschen'), + ), + ], + ), + ); + if (confirmed == true) await controller.deleteAbfall(entry.abfallId); + } +} + +class _AbfallStat extends StatelessWidget { + const _AbfallStat({required this.label, required this.value}); + final String label; + final String value; + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: const Color(0xFF243E50), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle(color: Color(0xFFB9CCD9), fontSize: 12), + ), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); +} diff --git a/lib/widgets/bauteil_card.dart b/lib/widgets/bauteil_card.dart index e1ba145..32d2b8f 100644 --- a/lib/widgets/bauteil_card.dart +++ b/lib/widgets/bauteil_card.dart @@ -110,6 +110,18 @@ class BauteilCard extends StatelessWidget { 'Bauteil ${DisplayFormats.number(bauteil.gewichtGramm)} g', ), _detail('Abfall ${DisplayFormats.number(bauteil.abfallGramm)} g'), + if (bauteil.materialKosten != null) + _detail( + 'Filamentkosten ${DisplayFormats.currency(bauteil.materialKosten!)}', + ), + if (bauteil.aufschlagProzent != null) + _detail( + 'Aufschlag ${DisplayFormats.number(bauteil.aufschlagProzent!)} %', + ), + if (bauteil.abfallGramm > 0 && bauteil.abfallKosten != null) + _detail( + 'Abfallkosten ${DisplayFormats.currency(bauteil.abfallKosten!)}', + ), _detail( 'Gesamt ${DisplayFormats.number(bauteil.gesamtverbrauchGramm)} g', ), diff --git a/lib/widgets/bauteil_filament_auswahl.dart b/lib/widgets/bauteil_filament_auswahl.dart index 9eb276b..a66ed6f 100644 --- a/lib/widgets/bauteil_filament_auswahl.dart +++ b/lib/widgets/bauteil_filament_auswahl.dart @@ -11,6 +11,29 @@ class BauteilFilamentAuswahl extends StatelessWidget { const BauteilFilamentAuswahl({super.key, required this.controller}); final BauteilController controller; + Future _waehleFilament(BuildContext context, String id) async { + controller.toggleFilament(id, true); + final warnung = controller.pruefeFilamentVorrat(); + if (warnung == null || !context.mounted) return; + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => PopScope( + canPop: false, + child: AlertDialog( + title: const Text('Achtung: zu wenig Filament'), + content: Text(warnung), + actions: [ + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('OK'), + ), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) => Obx(() { final ids = controller.selectedFilamente.keys.toList(); @@ -36,21 +59,41 @@ class BauteilFilamentAuswahl extends StatelessWidget { .toString(), ), isExpanded: true, + itemHeight: 64, decoration: const InputDecoration(labelText: 'Filament hinzufügen'), hint: const Text('Filament auswählen'), items: availableFilamente .map( (item) => DropdownMenuItem( value: item.filamentId, - child: Text( - controller.filamentLabel(item.filamentId), - overflow: TextOverflow.ellipsis, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + controller.filamentLabel(item.filamentId), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + controller.restgewichtGramm(item.filamentId) == null + ? 'Restgewicht unbekannt' + : 'Restgewicht: ${DisplayFormats.number(controller.restgewichtGramm(item.filamentId)!)} g', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppTheme.teal, + fontSize: 13, + ), + ), + ], ), ), ) .toList(), onChanged: (id) { - if (id != null) controller.toggleFilament(id, true); + if (id != null) _waehleFilament(context, id); }, ), const SizedBox(height: 12), diff --git a/lib/widgets/bauteil_form.dart b/lib/widgets/bauteil_form.dart index d7711f1..77b52fd 100644 --- a/lib/widgets/bauteil_form.dart +++ b/lib/widgets/bauteil_form.dart @@ -6,6 +6,7 @@ import '../helper/input_validators.dart'; import '../theme/app_theme.dart'; import 'bauteil_filament_auswahl.dart'; import 'bauteil_photo_picker.dart'; +import 'bauteil_preis_uebersicht.dart'; import 'entry_panel.dart'; class BauteilForm extends StatelessWidget { @@ -65,14 +66,18 @@ class BauteilForm extends StatelessWidget { validator: InputValidators.nonNegative, ), EntryNumberField( - controller: controller.gesamtpreisController, - label: 'Gesamtpreis (optional)', - unit: '€', - validator: (value) => - InputValidators.nonNegative(value, optional: true), + controller: controller.aufschlagController, + label: 'Aufschlag', + unit: '%', + validator: InputValidators.nonNegative, ), ], ), + const SizedBox(height: 12), + const Text( + 'Abfall wird automatisch in die Abfallverwaltung übernommen. Seine Kosten ergeben sich aus Einkaufspreis und Startgewicht der verwendeten Rollen. Bei mehreren Filamenten wird der Abfall nach ihrem Verbrauch aufgeteilt.', + style: TextStyle(color: AppTheme.muted, height: 1.5), + ), const SizedBox(height: 24), BauteilPhotoPicker(controller: controller), const SizedBox(height: 24), @@ -87,7 +92,9 @@ class BauteilForm extends StatelessWidget { ), const SizedBox(height: 12), BauteilFilamentAuswahl(controller: controller), - const SizedBox(height: 12), + const SizedBox(height: 16), + BauteilPreisUebersicht(controller: controller), + const SizedBox(height: 16), Obx( () => FilledButton.icon( onPressed: diff --git a/lib/widgets/bauteil_preis_uebersicht.dart b/lib/widgets/bauteil_preis_uebersicht.dart new file mode 100644 index 0000000..319a6e7 --- /dev/null +++ b/lib/widgets/bauteil_preis_uebersicht.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../controller/bauteil_controller.dart'; +import '../helper/display_formats.dart'; +import '../theme/app_theme.dart'; + +class BauteilPreisUebersicht extends StatelessWidget { + const BauteilPreisUebersicht({super.key, required this.controller}); + final BauteilController controller; + + @override + Widget build(BuildContext context) => Obx(() { + final preis = controller.preisDetails.value; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Preiskalkulation', + style: TextStyle(fontWeight: FontWeight.w700, fontSize: 18), + ), + const SizedBox(height: 12), + if (preis != null) ...[ + Text( + 'Filamentkosten (Bauteil): ${DisplayFormats.currency(preis.materialKosten)}', + ), + const SizedBox(height: 6), + Text( + 'Abfallkosten: ${DisplayFormats.currency(preis.abfallKosten)}', + ), + const SizedBox(height: 6), + Text( + 'Aufschlag (${DisplayFormats.number(preis.aufschlagProzent)} %): ${DisplayFormats.currency(preis.aufschlagKosten)}', + ), + ] else + const Text( + 'Für die Berechnung Gewicht, Abfallmenge und Filament auswählen. Bei mehreren Rollen zusätzlich den Verbrauch je Rolle passend zum Gesamtgewicht eingeben.', + style: TextStyle(color: AppTheme.muted, height: 1.5), + ), + const SizedBox(height: 16), + TextFormField( + controller: controller.gesamtpreisController, + readOnly: true, + decoration: const InputDecoration( + labelText: 'Gesamtpreis (automatisch)', + suffixText: '€', + ), + ), + ], + ), + ); + }); +} diff --git a/lib/widgets/entry_panel.dart b/lib/widgets/entry_panel.dart index c0421bb..6ef8cb8 100644 --- a/lib/widgets/entry_panel.dart +++ b/lib/widgets/entry_panel.dart @@ -75,11 +75,13 @@ class EntryNumberField extends StatelessWidget { required this.label, required this.unit, required this.validator, + this.onChanged, }); final TextEditingController controller; final String label; final String unit; final FormFieldValidator validator; + final ValueChanged? onChanged; @override Widget build(BuildContext context) => TextFormField( @@ -87,6 +89,7 @@ class EntryNumberField extends StatelessWidget { keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: InputDecoration(labelText: label, suffixText: unit), validator: validator, + onChanged: onChanged, ); } @@ -176,11 +179,13 @@ class SavedEntryCard extends StatelessWidget { required this.subtitle, required this.details, this.notes = '', + this.trailing, }); final String title; final String subtitle; final List details; final String notes; + final Widget? trailing; @override Widget build(BuildContext context) => Card( @@ -194,9 +199,20 @@ class SavedEntryCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - title, - style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 18), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + title, + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 18, + ), + ), + ), + if (trailing != null) ...[const SizedBox(width: 8), trailing!], + ], ), const SizedBox(height: 6), Text( diff --git a/lib/widgets/filament_card.dart b/lib/widgets/filament_card.dart index 068d93a..5db5975 100644 --- a/lib/widgets/filament_card.dart +++ b/lib/widgets/filament_card.dart @@ -137,6 +137,11 @@ class FilamentCard extends StatelessWidget { const SizedBox(height: 8), LinearProgressIndicator( value: (rest! / start).clamp(0.0, 1.0), + color: rest < 100 + ? Colors.red + : rest <= start / 2 + ? Colors.blue + : null, minHeight: 6, borderRadius: BorderRadius.circular(8), backgroundColor: AppTheme.inputSurface, @@ -156,7 +161,16 @@ class FilamentCard extends StatelessWidget { label: filament.material, icon: Icons.layers_outlined, ), - _DetailPill(label: weight, icon: Icons.scale_outlined), + _DetailPill( + label: 'Netto: $weight', + icon: Icons.scale_outlined, + ), + if (filament.bruttoGewichtGramm != null) + _DetailPill( + label: + 'Brutto mit Spule: ${DisplayFormats.number(filament.bruttoGewichtGramm!)} g', + icon: Icons.scale_outlined, + ), if (hatProfil) const _DetailPill( label: 'Profil erstellt', diff --git a/lib/widgets/filament_form.dart b/lib/widgets/filament_form.dart index 488f621..709a356 100644 --- a/lib/widgets/filament_form.dart +++ b/lib/widgets/filament_form.dart @@ -145,57 +145,44 @@ class FilamentForm extends StatelessWidget { : null, ), const SizedBox(height: 16), - LayoutBuilder( - builder: (context, constraints) { - final fields = [ - TextFormField( - controller: controller.preisController, - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - decoration: const InputDecoration( - labelText: 'Preis', - suffixText: '€', - ), - validator: (value) { - final price = double.tryParse( - (value ?? '').replaceAll(',', '.'), - ); - return price == null || !price.isFinite || price < 0 - ? 'Preis ungültig' - : null; - }, - ), - TextFormField( - controller: controller.gewichtController, - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - decoration: const InputDecoration( - labelText: 'Startgewicht', - suffixText: 'g', - ), - validator: InputValidators.positive, - ), - ]; - if (constraints.maxWidth < 280) { - return Column( - children: [ - fields[0], - const SizedBox(height: 16), - fields[1], - ], - ); - } - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(child: fields[0]), - const SizedBox(width: 12), - Expanded(child: fields[1]), - ], - ); - }, + TextFormField( + controller: controller.preisController, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: const InputDecoration( + labelText: 'Preis', + suffixText: '€', + ), + validator: InputValidators.nonNegative, + ), + const SizedBox(height: 16), + TextFormField( + controller: controller.gewichtController, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: const InputDecoration( + labelText: 'Nettogewicht', + suffixText: 'g', + helperText: 'Startgewicht ohne Spule, nur Material.', + helperMaxLines: 2, + ), + validator: InputValidators.positive, + ), + const SizedBox(height: 16), + TextFormField( + controller: controller.bruttoGewichtController, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: const InputDecoration( + labelText: 'Bruttogewicht (optional)', + suffixText: 'g', + helperText: 'Material und Spule zusammen.', + helperMaxLines: 2, + ), + validator: controller.validateBruttogewicht, ), const SizedBox(height: 24), Obx( diff --git a/test/abfall_test.dart b/test/abfall_test.dart new file mode 100644 index 0000000..5380dc2 --- /dev/null +++ b/test/abfall_test.dart @@ -0,0 +1,221 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; + +import 'package:ertl_filament_verwaltung/controller/abfall_controller.dart'; +import 'package:ertl_filament_verwaltung/helper/storage_keys.dart'; +import 'package:ertl_filament_verwaltung/main.dart'; +import 'package:ertl_filament_verwaltung/models/abfall_model.dart'; +import 'package:ertl_filament_verwaltung/models/backup_model.dart'; +import 'package:ertl_filament_verwaltung/pages/abfall_view.dart'; +import 'package:ertl_filament_verwaltung/pages/home_view.dart'; +import 'package:ertl_filament_verwaltung/services/get_storage_services.dart'; +import 'package:ertl_filament_verwaltung/theme/app_theme.dart'; + +import 'helpers/memory_storage_service.dart'; + +void main() { + late MemoryStorageService storage; + setUp(() { + Get.testMode = true; + storage = MemoryStorageService(); + Get.put(storage); + }); + tearDown(() => Get.reset()); + + Future show(WidgetTester tester) async { + tester.view.physicalSize = const Size(320, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final controller = Get.put(AbfallController()); + await tester.pumpWidget( + GetMaterialApp(theme: AppTheme.light, home: const AbfallView()), + ); + await tester.pumpAndSettle(); + return controller; + } + + testWidgets('Home opens waste management and back returns home at 320px', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(const MyApp()); + await tester.pumpAndSettle(); + expect(find.byTooltip('Bauteile verwalten'), findsOneWidget); + await tester.tap(find.byTooltip('Abfall verwalten')); + await tester.pumpAndSettle(); + expect(Get.currentRoute, AbfallView.routeName); + expect(find.text('Abfallverwaltung'), findsOneWidget); + await tester.tap(find.byTooltip('Zurück')); + await tester.pumpAndSettle(); + expect(Get.currentRoute, HomeView.routeName); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'Save, edit, reload and delete keep totals and timestamps consistent', + (tester) async { + final controller = await show(tester); + final date = DateTime(2026, 10, 28, 14, 35); + controller.setDatum(date); + controller.setUhrzeit(const TimeOfDay(hour: 14, minute: 35)); + controller.gewichtController.text = '12,5'; + controller.kostenController.text = '1,25'; + await controller.saveAbfall(); + await tester.pumpAndSettle(); + final first = controller.abfaelle.single; + expect(first.datumZeit, date); + expect(controller.summeAbfall, 12.5); + expect(controller.summeKosten, 1.25); + + controller.datumZeit.value = date.add(const Duration(days: 1)); + controller.gewichtController.text = '7,5'; + controller.kostenController.text = '0,75'; + await controller.saveAbfall(); + await tester.pumpAndSettle(); + expect(controller.abfaelle.first.datumZeit.isAfter(date), isTrue); + expect(controller.summeAbfall, 20); + expect(controller.summeKosten, 2); + controller.scrollController.jumpTo( + controller.scrollController.position.maxScrollExtent, + ); + await tester.pumpAndSettle(); + expect(find.text('Summe Abfall'), findsOneWidget); + expect(find.text('20 g'), findsOneWidget); + + final editing = controller.editAbfall(first); + await tester.pumpAndSettle(); + await editing; + controller.gewichtController.text = '15'; + controller.kostenController.text = '1,50'; + await controller.saveAbfall(); + await tester.pumpAndSettle(); + controller.loadAbfaelle(); + expect(controller.abfaelle.length, 2); + expect(controller.summeAbfall, 22.5); + expect(controller.summeKosten, 2.25); + expect(controller.abfaelle.last.abfallId, first.abfallId); + await controller.deleteAbfall(first.abfallId); + await tester.pumpAndSettle(); + final reloaded = AbfallController()..onInit(); + expect(reloaded.abfaelle.single.gewichtGramm, 7.5); + expect(reloaded.summeKosten, .75); + reloaded.onClose(); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('Kilopreis calculates from grams while costs remain editable', ( + tester, + ) async { + final controller = await show(tester); + Future enter(TextEditingController field, String value) async { + final finder = find.byWidgetPredicate( + (widget) => widget is TextFormField && widget.controller == field, + ); + await tester.ensureVisible(finder); + await tester.enterText(finder, value); + await tester.pumpAndSettle(); + } + + await enter(controller.kilopreisController, '20'); + await enter(controller.gewichtController, '125'); + expect(controller.kostenController.text, '2,50'); + await enter(controller.kostenController, '3,10'); + controller.setUhrzeit(const TimeOfDay(hour: 10, minute: 30)); + await tester.pumpAndSettle(); + expect(controller.kostenController.text, '3,10'); + await enter(controller.gewichtController, '200'); + expect(controller.kostenController.text, '4,00'); + await enter(controller.kilopreisController, '30,5'); + expect(controller.kostenController.text, '6,10'); + await enter(controller.kostenController, '5,55'); + await controller.saveAbfall(); + await tester.pumpAndSettle(); + expect(controller.abfaelle.single.kosten, 5.55); + expect(controller.kilopreisController.text, '30,5'); + final editing = controller.editAbfall(controller.abfaelle.single); + await tester.pumpAndSettle(); + await editing; + await tester.pumpAndSettle(); + expect(controller.kostenController.text, '5,55'); + await enter(controller.kilopreisController, '0'); + expect(controller.kostenController.text, '0,00'); + await enter(controller.kilopreisController, ''); + await enter(controller.gewichtController, '250'); + await enter(controller.kostenController, '2,75'); + await controller.saveAbfall(); + await tester.pumpAndSettle(); + expect(controller.abfaelle.single.kosten, 2.75); + expect(tester.takeException(), isNull); + }); + testWidgets('Invalid inputs and failed writes preserve entries and form', ( + tester, + ) async { + final controller = await show(tester); + for (final weight in ['', '-1', '0', 'NaN', 'Infinity']) { + controller.gewichtController.text = weight; + controller.kostenController.text = '1'; + await controller.saveAbfall(); + expect(controller.abfaelle, isEmpty); + } + controller.gewichtController.text = '10'; + for (final cost in ['', '-1', 'NaN', 'Infinity']) { + controller.kostenController.text = cost; + await controller.saveAbfall(); + expect(controller.abfaelle, isEmpty); + } + controller.kostenController.text = '0'; + storage.failWrites = true; + await controller.saveAbfall(); + await tester.pumpAndSettle(); + expect(controller.hasError.value, isTrue); + expect(controller.gewichtController.text, '10'); + expect(controller.abfaelle, isEmpty); + storage.failWrites = false; + await controller.saveAbfall(); + await tester.pumpAndSettle(); + final saved = controller.abfaelle.single; + storage.failWrites = true; + await controller.deleteAbfall(saved.abfallId); + expect(controller.abfaelle.single.abfallId, saved.abfallId); + expect(controller.summeAbfall, 10); + expect(storage.getModelList(StorageKeys.abfaelle), hasLength(1)); + expect(tester.takeException(), isNull); + }); + + test( + 'Backups round-trip waste, accept older backups and reject invalid waste', + () { + final entry = AbfallModel( + abfallId: 'w1', + datumZeit: DateTime(2026, 10, 29, 9, 5), + gewichtGramm: 25, + kosten: 1.5, + ); + final data = { + for (final key in BackupModel.keys) key: >[], + }; + data[StorageKeys.abfaelle] = [entry.toJson()]; + final restored = BackupModel.decode(BackupModel(data).encode()); + expect(restored.data[StorageKeys.abfaelle], [entry.toJson()]); + final legacy = {...data}..remove(StorageKeys.abfaelle); + expect( + BackupModel.decode(BackupModel(legacy).encode()) + .data[StorageKeys.abfaelle], + isEmpty, + ); + data[StorageKeys.abfaelle] = [ + {...entry.toJson(), 'kosten': -1}, + ]; + expect( + () => BackupModel.decode(BackupModel(data).encode()), + throwsFormatException, + ); + }, + ); +} diff --git a/test/bauteil_abfall_test.dart b/test/bauteil_abfall_test.dart new file mode 100644 index 0000000..e026940 --- /dev/null +++ b/test/bauteil_abfall_test.dart @@ -0,0 +1,286 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; + +import 'package:ertl_filament_verwaltung/controller/abfall_controller.dart'; +import 'package:ertl_filament_verwaltung/controller/bauteil_controller.dart'; +import 'package:ertl_filament_verwaltung/controller/home_controller.dart'; +import 'package:ertl_filament_verwaltung/helper/abfall_berechnung.dart'; +import 'package:ertl_filament_verwaltung/helper/storage_keys.dart'; +import 'package:ertl_filament_verwaltung/models/abfall_model.dart'; +import 'package:ertl_filament_verwaltung/models/bauteil_model.dart'; +import 'package:ertl_filament_verwaltung/models/backup_model.dart'; +import 'package:ertl_filament_verwaltung/models/kauf_filament_model.dart'; +import 'package:ertl_filament_verwaltung/pages/abfall_view.dart'; +import 'package:ertl_filament_verwaltung/pages/bauteil_view.dart'; +import 'package:ertl_filament_verwaltung/services/get_storage_services.dart'; +import 'package:ertl_filament_verwaltung/theme/app_theme.dart'; + +import 'helpers/memory_storage_service.dart'; + +void main() { + late MemoryStorageService storage; + KaufFilamentModel rolle(String id, double preis, String startgewicht) => + KaufFilamentModel( + filamentId: id, + kaufdatum: DateTime(2026), + haendler: 'Shop', + marke: id, + material: 'PLA', + preis: preis, + farbe: id, + gewicht: startgewicht, + ); + BauteilModel teil( + String id, + double abfall, + List filamente, { + double? kosten, + }) => BauteilModel( + bauteilId: id, + erstelltAm: DateTime(2026, 10, 29), + name: id, + verwendung: 'Test', + druckdauerMinuten: 30, + gewichtGramm: 20, + abfallGramm: abfall, + filamente: filamente, + abfallKosten: kosten, + ); + + setUp(() { + Get.testMode = true; + storage = MemoryStorageService(); + storage.tables[StorageKeys.filamente] = [rolle('a', 15, '750').toJson()]; + Get.put(storage); + }); + tearDown(() => Get.reset()); + + Future showParts(WidgetTester tester) async { + final controller = Get.put(BauteilController()); + await tester.pumpWidget( + GetMaterialApp(theme: AppTheme.light, home: const BauteilView()), + ); + await tester.pumpAndSettle(); + return controller; + } + + void fill(BauteilController controller, {double waste = 5}) { + controller.nameController.text = 'Halter'; + controller.verwendungController.text = 'Werkstatt'; + controller.minutenController.text = '30'; + controller.gewichtController.text = '20'; + controller.abfallController.text = '$waste'; + controller.toggleFilament('a', true); + controller.verbrauchController('a').text = '${20 + waste}'; + } + + Future edit( + WidgetTester tester, + BauteilController controller, + BauteilModel part, + ) async { + final pending = controller.editBauteil(part); + await tester.pumpAndSettle(); + await pending; + } + + testWidgets( + 'Part waste uses original 750g roll despite previous consumption; lifecycle reconciles once', + (tester) async { + // 600g already consumed: only 150g remain before the next print. + storage.tables[StorageKeys.bauteile] = [ + teil('vorher', 0, [ + const BauteilFilamentModel(filamentId: 'a', verbrauchGramm: 600), + ]).toJson(), + ]; + storage.tables[StorageKeys.abfaelle] = [ + AbfallModel( + abfallId: 'manual', + datumZeit: DateTime(2026), + gewichtGramm: 3, + kosten: 1, + ).toJson(), + ]; + final home = Get.put(HomeController()); + final waste = Get.put(AbfallController()); + waste.kilopreisController.text = + '999'; // Manual rate must never price a part. + final controller = await showParts(tester); + fill(controller); + await controller.saveBauteil(); + await tester.pumpAndSettle(); + final saved = controller.bauteile.firstWhere( + (entry) => entry.name == 'Halter', + ); + expect(saved.abfallKosten, .1); // 15 / 750 * 5, not 15 / 150 * 5. + expect(home.restgewicht(home.filamente.single), 125); + expect(waste.summeAbfall, 8); + expect(waste.summeKosten, closeTo(1.1, 1e-9)); + waste.loadAbfaelle(); + waste.loadAbfaelle(); + expect(waste.abfaelle, hasLength(2)); + expect(storage.getModelList(StorageKeys.abfaelle), hasLength(1)); + final linked = waste.abfaelle.firstWhere((entry) => entry.ausBauteil); + await waste.deleteAbfall(linked.abfallId); + await waste.editAbfall(linked); + expect(waste.abfaelle, hasLength(2)); + expect(waste.editingId.value, isNull); + + await edit(tester, controller, saved); + controller.abfallController.text = '10'; + controller.verbrauchController('a').text = '30'; + await controller.saveBauteil(); + await tester.pumpAndSettle(); + expect(waste.summeAbfall, 13); + expect(waste.summeKosten, closeTo(1.2, 1e-9)); + final changed = controller.bauteile.firstWhere( + (entry) => entry.bauteilId == saved.bauteilId, + ); + await controller.duplicateBauteil(changed); + await tester.pumpAndSettle(); + expect(waste.summeAbfall, 23); + expect(waste.summeKosten, closeTo(1.4, 1e-9)); + final copy = controller.bauteile.firstWhere( + (entry) => entry.bauteilId != saved.bauteilId && entry.name == 'Halter', + ); + await controller.deleteBauteil(copy.bauteilId); + await tester.pumpAndSettle(); + expect(waste.summeAbfall, 13); + await edit(tester, controller, changed); + controller.abfallController.text = '0'; + controller.verbrauchController('a').text = '20'; + await controller.saveBauteil(); + await tester.pumpAndSettle(); + expect(waste.abfaelle.single.abfallId, 'manual'); + expect(waste.summeAbfall, 3); + await controller.deleteBauteil(saved.bauteilId); + await tester.pumpAndSettle(); + expect(waste.abfaelle.single.abfallId, 'manual'); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'Frozen waste cost survives price changes, metadata edits, removed rolls and backups', + (tester) async { + final waste = Get.put(AbfallController()); + final controller = await showParts(tester); + fill(controller, waste: 25); + await controller.saveBauteil(); + await tester.pumpAndSettle(); + expect(waste.summeKosten, .5); + storage.tables[StorageKeys.filamente] = [rolle('a', 30, '750').toJson()]; + await edit(tester, controller, controller.bauteile.single); + controller.nameController.text = 'Neuer Name'; + await controller.saveBauteil(); + await tester.pumpAndSettle(); + expect(waste.summeKosten, .5); + storage.tables[StorageKeys.filamente] = []; + waste.loadAbfaelle(); + expect(waste.summeKosten, .5); + expect(waste.abfaelle.single.kostenUnbekannt, isFalse); + final backup = BackupModel({ + for (final key in BackupModel.keys) key: storage.getModelList(key), + }); + final restored = BackupModel.decode(backup.encode()); + expect( + BauteilModel.fromJson(restored.data[StorageKeys.bauteile]!.single) + .abfallKosten, + .5, + ); + restored.data[StorageKeys.bauteile]!.single['abfallKosten'] = -1; + expect( + () => BackupModel.decode(BackupModel(restored.data).encode()), + throwsFormatException, + ); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'Failed part writes create no waste and failed delete keeps linked waste', + (tester) async { + final waste = Get.put(AbfallController()); + final controller = await showParts(tester); + fill(controller); + storage.failWrites = true; + await controller.saveBauteil(); + await tester.pumpAndSettle(); + expect(waste.abfaelle, isEmpty); + expect(controller.nameController.text, 'Halter'); + storage.failWrites = false; + await controller.saveBauteil(); + await tester.pumpAndSettle(); + expect(waste.summeKosten, .1); + storage.failWrites = true; + final deleting = controller.deleteBauteil( + controller.bauteile.single.bauteilId, + ); + await tester.pumpAndSettle(); + await deleting; + expect(waste.summeKosten, .1); + expect(waste.summeAbfall, 5); + expect(tester.takeException(), isNull); + }, + ); + + test( + 'Multi-filament waste uses weighted roll prices and original weights', + () { + final part = teil('multi', 10, [ + const BauteilFilamentModel(filamentId: 'a', verbrauchGramm: 60), + const BauteilFilamentModel(filamentId: 'b', verbrauchGramm: 40), + ]); + // 6g at 20€/kg and 4g at 40€/kg = 0.28€. + expect( + AbfallBerechnung.kosten(part, [ + rolle('a', 15, '0,75 kg'), + rolle('b', 20, '500'), + ]), + .28, + ); + expect(AbfallBerechnung.kosten(part, [rolle('a', 15, '750')]), isNull); + }, + ); + + testWidgets( + 'Existing parts appear with origin and unknown historical prices are labelled', + (tester) async { + storage.tables[StorageKeys.bauteile] = [ + teil('Altbestand', 25, [ + const BauteilFilamentModel(filamentId: 'a'), + ]).toJson(), + teil('Fehlende Rolle', 5, [ + const BauteilFilamentModel(filamentId: 'deleted'), + ]).toJson(), + ]; + final waste = Get.put(AbfallController()); + expect(waste.summeAbfall, 30); + expect(waste.summeKosten, .5); + expect( + waste.abfaelle.where((entry) => entry.kostenUnbekannt), + hasLength(1), + ); + expect( + waste.abfaelle.where((entry) => entry.kostenNachtraeglich), + hasLength(1), + ); + tester.view.physicalSize = const Size(320, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + GetMaterialApp(theme: AppTheme.light, home: const AbfallView()), + ); + await tester.pumpAndSettle(); + waste.scrollController.jumpTo( + waste.scrollController.position.maxScrollExtent, + ); + await tester.pumpAndSettle(); + expect(find.text('Abfallkosten: nicht ermittelbar'), findsOneWidget); + expect(find.byTooltip('Abfalleintrag verwalten'), findsNothing); + expect(tester.takeException(), isNull); + }, + ); +} diff --git a/test/bauteil_auswertung_test.dart b/test/bauteil_auswertung_test.dart new file mode 100644 index 0000000..6df782d --- /dev/null +++ b/test/bauteil_auswertung_test.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; + +import 'package:ertl_filament_verwaltung/controller/bauteil_controller.dart'; +import 'package:ertl_filament_verwaltung/controller/bauteil_auswertung_controller.dart'; +import 'package:ertl_filament_verwaltung/helper/storage_keys.dart'; +import 'package:ertl_filament_verwaltung/main.dart'; +import 'package:ertl_filament_verwaltung/models/bauteil_auswertung_model.dart'; +import 'package:ertl_filament_verwaltung/models/bauteil_model.dart'; +import 'package:ertl_filament_verwaltung/pages/bauteil_auswertung_view.dart'; +import 'package:ertl_filament_verwaltung/pages/bauteil_view.dart'; +import 'package:ertl_filament_verwaltung/services/get_storage_services.dart'; +import 'package:ertl_filament_verwaltung/theme/app_theme.dart'; + +import 'helpers/memory_storage_service.dart'; + +void main() { + late MemoryStorageService storage; + BauteilModel part(String id, DateTime date, double? price) => BauteilModel( + bauteilId: id, + erstelltAm: date, + name: 'Bauteil $id', + verwendung: 'Werkstatt', + druckdauerMinuten: 30, + gewichtGramm: 100, + abfallGramm: 10, + gesamtpreis: price, + filamente: [ + const BauteilFilamentModel(filamentId: 'rolle', verbrauchGramm: 110), + ], + ); + setUp(() { + Get.testMode = true; + storage = MemoryStorageService(); + storage.tables[StorageKeys.bauteile] = [ + part('alt', DateTime(2025, 12, 31, 23), 10).toJson(), + part('vormittag', DateTime(2026, 1, 2, 8), 2.5).toJson(), + part('nachmittag', DateTime(2026, 1, 2, 16), 3.25).toJson(), + part('januar', DateTime(2026, 1, 3, 12), 0).toJson(), + part('ohne', DateTime(2026, 2, 1, 8), null).toJson(), + ]; + Get.put(storage); + }); + tearDown(() => Get.reset()); + + test('Groups and sums all stored parts by year, month and day with unknown prices labelled', () { + final controller = Get.put(BauteilAuswertungController()); + expect(controller.zeitraum.value, BauteilZeitraum.monat); + expect(controller.bauteile.map((item) => item.bauteilId), [ + 'ohne', + 'januar', + 'nachmittag', + 'vormittag', + 'alt', + ]); + expect(controller.gesamtsumme, 15.75); + expect(controller.ohnePreis, 1); + expect(controller.gruppen.map((group) => group.summe), [0, 5.75, 10]); + expect(controller.gruppen.first.ohnePreis, 1); + expect( + controller.gruppenTitel(controller.gruppen[1].beginn), + 'Monat 01.2026', + ); + controller.waehleZeitraum(BauteilZeitraum.jahr); + expect(controller.gruppen.map((group) => group.summe), [5.75, 10]); + expect(controller.gruppen.first.bauteile, hasLength(4)); + expect( + controller.gruppenTitel(controller.gruppen.first.beginn), + 'Jahr 2026', + ); + controller.waehleZeitraum(BauteilZeitraum.tag); + expect(controller.gruppen, hasLength(4)); + final sameDay = controller.gruppen.firstWhere( + (group) => group.beginn == DateTime(2026, 1, 2), + ); + expect(sameDay.summe, 5.75); + expect(sameDay.bauteile.map((item) => item.bauteilId), [ + 'nachmittag', + 'vormittag', + ]); + expect(controller.gruppenTitel(sameDay.beginn), '02.01.2026'); + expect( + controller.gruppen.fold( + 0, + (sum, group) => sum + group.bauteile.length, + ), + 5, + ); + expect(storage.getModelList(StorageKeys.bauteile), hasLength(5)); + }); + + testWidgets( + 'Appbar opens report, selection works at 320px and back retains part draft', + (tester) async { + tester.view.physicalSize = const Size(320, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(const MyApp()); + await tester.pumpAndSettle(); + Get.toNamed(BauteilView.routeName); + await tester.pumpAndSettle(); + final parts = Get.find(); + parts.nameController.text = 'Mein Entwurf'; + await tester.tap(find.byTooltip('Bauteile auswerten')); + await tester.pumpAndSettle(); + expect(Get.currentRoute, BauteilAuswertungView.routeName); + expect(find.text('Gesamtsumme'), findsOneWidget); + expect(find.text('Bauteilauswertung'), findsOneWidget); + final report = Get.find(); + expect(report.gruppen, hasLength(3)); + await tester.tap(find.text('Jahr')); + await tester.pumpAndSettle(); + expect(report.gruppen, hasLength(2)); + expect(find.text('Jahr 2026'), findsOneWidget); + await tester.tap(find.text('Tag')); + await tester.pumpAndSettle(); + expect(report.gruppen, hasLength(4)); + tester.view.physicalSize = const Size(1280, 900); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await tester.tap(find.byTooltip('Zurück zu den Bauteilen')); + await tester.pumpAndSettle(); + expect(Get.currentRoute, BauteilView.routeName); + expect(parts.nameController.text, 'Mein Entwurf'); + expect(Get.isRegistered(), isFalse); + storage.tables[StorageKeys.bauteile]!.add( + part('neu', DateTime(2027), 4.25).toJson(), + ); + await tester.tap(find.byTooltip('Bauteile auswerten')); + await tester.pumpAndSettle(); + expect(Get.find().gesamtsumme, 20); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'Every part is reachable by scrolling and grouping never changes total', + (tester) async { + tester.view.physicalSize = const Size(320, 640); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final controller = Get.put(BauteilAuswertungController()); + await tester.pumpWidget( + GetMaterialApp( + theme: AppTheme.light, + home: const BauteilAuswertungView(), + ), + ); + await tester.pumpAndSettle(); + for (final period in BauteilZeitraum.values) { + controller.waehleZeitraum(period); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.text('Bauteil alt'), + 350, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + expect(find.text('Bauteil alt'), findsOneWidget); + expect(controller.gesamtsumme, 15.75); + expect(tester.takeException(), isNull); + } + }, + ); + + testWidgets( + 'Empty report shows zero and an empty message for all groupings', + (tester) async { + storage.tables.clear(); + final controller = Get.put(BauteilAuswertungController()); + await tester.pumpWidget( + GetMaterialApp( + theme: AppTheme.light, + home: const BauteilAuswertungView(), + ), + ); + await tester.pumpAndSettle(); + for (final period in BauteilZeitraum.values) { + controller.waehleZeitraum(period); + await tester.pumpAndSettle(); + expect(controller.gesamtsumme, 0); + expect(controller.gruppen, isEmpty); + expect(find.text('Noch keine Bauteile gespeichert.'), findsOneWidget); + expect(tester.takeException(), isNull); + } + }, + ); +} diff --git a/test/bauteil_bestand_test.dart b/test/bauteil_bestand_test.dart index 9e13ea7..c700242 100644 --- a/test/bauteil_bestand_test.dart +++ b/test/bauteil_bestand_test.dart @@ -143,6 +143,12 @@ void main() { Future add(String id) async { await tester.tap(find.byType(DropdownButtonFormField)); await tester.pumpAndSettle(); + expect( + find.text( + 'Restgewicht: ${DisplayFormats.number(controller.restgewichtGramm(id)!)} g', + ), + findsWidgets, + ); await tester.tap(find.text(controller.filamentLabel(id)).last); await tester.pumpAndSettle(); } @@ -201,7 +207,6 @@ void main() { (tester) async { await show(tester); fill(); - controller.gesamtpreisController.text = '12,50'; photos.result = png; await controller.pickPhoto(); await controller.saveBauteil(); @@ -209,19 +214,19 @@ void main() { expect(remaining('b'), 960); final originalId = controller.bauteile.single.bauteilId; final originalDate = controller.bauteile.single.erstelltAm; - expect(controller.bauteile.single.gesamtpreis, 12.5); + expect(controller.bauteile.single.gesamtpreis, 3); expect( storage .getModelList(BauteilController.storageKey) .single['gesamtpreis'], - 12.5, + 3, ); expect(controller.gesamtpreisController.text, isEmpty); await edit(tester, controller.bauteile.single); expect(controller.foto.value, png); - expect(controller.gesamtpreisController.text, '12,5'); - controller.gesamtpreisController.text = '18,90'; + expect(controller.gesamtpreisController.text, '3,00'); + controller.aufschlagController.text = '20'; expect(controller.verfuegbarGramm('a'), 1000); controller.verbrauchController('a').text = '20'; controller.verbrauchController('b').text = '80'; @@ -242,7 +247,7 @@ void main() { isTrue, ); expect( - controller.bauteile.every((item) => item.gesamtpreis == 18.9), + controller.bauteile.every((item) => item.gesamtpreis == 2.4), isTrue, ); expect(remaining('a'), 960); @@ -255,7 +260,7 @@ void main() { expect(remaining('a'), 980); final reloaded = Get.put(BauteilController(), tag: 'reload'); expect(reloaded.bauteile.single.fotoBytes, png); - expect(reloaded.bauteile.single.gesamtpreis, 18.9); + expect(reloaded.bauteile.single.gesamtpreis, 2.4); expect(reloaded.bauteile.single.filamente.last.verbrauchGramm, 80); // Purchases retain the original weight; the persisted ledger is deducted. expect( @@ -285,27 +290,27 @@ void main() { ); testWidgets( - 'Total price rejects invalid amounts, accepts zero and can be cleared', + 'Markup rejects invalid amounts, accepts zero and price is always calculated', (tester) async { await show(tester); fill(); for (final value in ['-1', 'NaN', 'Infinity', 'abc']) { - controller.gesamtpreisController.text = value; + controller.aufschlagController.text = value; await controller.saveBauteil(); expect(storage.getModelList(BauteilController.storageKey), isEmpty); } - controller.gesamtpreisController.text = '0'; + controller.aufschlagController.text = '0'; await controller.saveBauteil(); - expect(controller.bauteile.single.gesamtpreis, 0); + expect(controller.bauteile.single.gesamtpreis, 2); await edit(tester, controller.bauteile.single); - controller.gesamtpreisController.clear(); + controller.aufschlagController.text = '25'; await controller.saveBauteil(); - expect(controller.bauteile.single.gesamtpreis, isNull); + expect(controller.bauteile.single.gesamtpreis, 2.5); expect( storage .getModelList(BauteilController.storageKey) .single['gesamtpreis'], - isNull, + 2.5, ); }, ); @@ -322,6 +327,7 @@ void main() { expect(remaining('a'), 40); await edit(tester, first); expect(controller.verfuegbarGramm('a'), 940); + expect(controller.restgewichtGramm('a'), 40); controller.gewichtController.text = '940'; controller.verbrauchController('a').text = '950'; await controller.saveBauteil(); @@ -402,7 +408,6 @@ void main() { (tester) async { await show(tester, narrow: true); fill(); - controller.gesamtpreisController.text = '12,50'; photos.result = png; await controller.pickPhoto(); await controller.saveBauteil(); @@ -414,7 +419,7 @@ void main() { ); await tester.pumpAndSettle(); expect( - find.text('Gesamtpreis ${DisplayFormats.currency(12.5)}'), + find.text('Gesamtpreis ${DisplayFormats.currency(3)}'), findsOneWidget, ); expect( diff --git a/test/bauteil_preis_test.dart b/test/bauteil_preis_test.dart new file mode 100644 index 0000000..94a1e10 --- /dev/null +++ b/test/bauteil_preis_test.dart @@ -0,0 +1,250 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; + +import 'package:ertl_filament_verwaltung/controller/bauteil_controller.dart'; +import 'package:ertl_filament_verwaltung/helper/bauteil_preis_berechnung.dart'; +import 'package:ertl_filament_verwaltung/helper/storage_keys.dart'; +import 'package:ertl_filament_verwaltung/models/bauteil_model.dart'; +import 'package:ertl_filament_verwaltung/models/backup_model.dart'; +import 'package:ertl_filament_verwaltung/models/kauf_filament_model.dart'; +import 'package:ertl_filament_verwaltung/pages/bauteil_view.dart'; +import 'package:ertl_filament_verwaltung/services/get_storage_services.dart'; +import 'package:ertl_filament_verwaltung/theme/app_theme.dart'; + +import 'helpers/memory_storage_service.dart'; + +void main() { + late MemoryStorageService storage; + late BauteilController controller; + KaufFilamentModel rolle(String id, double preis, String gewicht) => + KaufFilamentModel( + filamentId: id, + kaufdatum: DateTime(2026), + haendler: 'Shop', + marke: id, + material: 'PLA', + preis: preis, + farbe: id, + gewicht: gewicht, + ); + setUp(() { + Get.testMode = true; + storage = MemoryStorageService(); + storage.tables[StorageKeys.filamente] = [ + rolle('a', 15, '750').toJson(), + rolle('b', 20, '500').toJson(), + ]; + Get.put(storage); + controller = Get.put(BauteilController()); + }); + tearDown(() => Get.reset()); + + Future show(WidgetTester tester) async { + tester.view.physicalSize = const Size(320, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + GetMaterialApp(theme: AppTheme.light, home: const BauteilView()), + ); + await tester.pumpAndSettle(); + } + + void fill() { + controller.nameController.text = 'Halter'; + controller.verwendungController.text = 'Werkstatt'; + controller.minutenController.text = '30'; + controller.gewichtController.text = '100'; + controller.abfallController.text = '25'; + controller.toggleFilament('a', true); + controller.verbrauchController('a').text = '125'; + } + + Future edit(WidgetTester tester) async { + final pending = controller.editBauteil(controller.bauteile.single); + await tester.pumpAndSettle(); + await pending; + } + + testWidgets( + 'Single filament price updates live before usage entry; markup changes recalculate', + (tester) async { + await show(tester); + expect(controller.aufschlagController.text, '50'); + controller.gewichtController.text = '100'; + controller.abfallController.text = '25'; + controller.toggleFilament('a', true); + await tester.pumpAndSettle(); + expect(controller.preisDetails.value!.materialKosten, 2); + expect(controller.preisDetails.value!.abfallKosten, .5); + expect(controller.gesamtpreisController.text, '3,75'); + await tester.ensureVisible( + find.widgetWithText(TextFormField, 'Gesamtpreis (automatisch)'), + ); + await tester.pumpAndSettle(); + expect(find.textContaining('Filamentkosten (Bauteil):'), findsOneWidget); + final total = tester.widget( + find.descendant( + of: find.widgetWithText(TextFormField, 'Gesamtpreis (automatisch)'), + matching: find.byType(TextField), + ), + ); + expect(total.readOnly, isTrue); + controller.gewichtController.text = '200'; + await tester.pumpAndSettle(); + expect(controller.gesamtpreisController.text, '6,75'); + final markup = find.byWidgetPredicate( + (widget) => + widget is TextFormField && + widget.controller == controller.aufschlagController, + ); + await tester.ensureVisible(markup); + await tester.enterText(markup, '20'); + await tester.pumpAndSettle(); + expect(controller.gesamtpreisController.text, '5,40'); + controller.toggleFilament('a', false); + controller.toggleFilament('b', true); + expect(controller.gesamtpreisController.text, '10,80'); + controller.toggleFilament('b', false); + expect(controller.preisDetails.value, isNull); + expect(controller.gesamtpreisController.text, isEmpty); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'Save, edit, duplicate and backup preserve markup and calculated totals', + (tester) async { + await show(tester); + fill(); + await controller.saveBauteil(); + await tester.pumpAndSettle(); + var saved = controller.bauteile.single; + expect(saved.materialKosten, 2); + expect(saved.abfallKosten, .5); + expect(saved.aufschlagProzent, 50); + expect(saved.gesamtpreis, 3.75); + expect(controller.aufschlagController.text, '50'); + storage.tables[StorageKeys.filamente]![0] = rolle( + 'a', + 30, + '750', + ).toJson(); + await edit(tester); + expect( + controller.gesamtpreisController.text, + '3,75', + ); // Recorded material cost stays stable. + controller.aufschlagController.text = '25'; + expect(controller.gesamtpreisController.text, '3,13'); + await controller.saveBauteil(); + await tester.pumpAndSettle(); + saved = controller.bauteile.single; + expect(saved.aufschlagProzent, 25); + expect(saved.gesamtpreis, 3.13); + final backup = BackupModel({ + for (final key in BackupModel.keys) key: storage.getModelList(key), + }); + final roundtrip = BauteilModel.fromJson( + BackupModel.decode(backup.encode()).data[StorageKeys.bauteile]!.single, + ); + expect(roundtrip.gesamtpreis, 3.13); + expect(roundtrip.materialKosten, 2); + expect(roundtrip.aufschlagProzent, 25); + await controller.duplicateBauteil( + saved, + ); // New print uses current roll price, same markup. + await tester.pumpAndSettle(); + expect(controller.bauteile, hasLength(2)); + final copy = controller.bauteile.firstWhere( + (item) => item.bauteilId != saved.bauteilId, + ); + expect(copy.materialKosten, 4); + expect(copy.abfallKosten, 1); + expect(copy.aufschlagProzent, 25); + expect(copy.gesamtpreis, 6.25); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'Multiple filaments use usage shares and count waste exactly once', + (tester) async { + await show(tester); + fill(); + controller.gewichtController.text = '90'; + controller.abfallController.text = '10'; + controller.toggleFilament('b', true); + expect( + controller.preisDetails.value, + isNull, + ); // Need an allocation for both roles. + controller.verbrauchController('a').text = '60'; + controller.verbrauchController('b').text = '40'; + expect(controller.preisDetails.value!.materialKosten, 2.52); + expect(controller.preisDetails.value!.abfallKosten, .28); + expect(controller.gesamtpreisController.text, '4,20'); + await controller.saveBauteil(); + await tester.pumpAndSettle(); + expect(controller.bauteile.single.gesamtpreis, 4.2); + expect(controller.bauteile.single.abfallKosten, .28); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('Failed writes retain calculation and edited markup for retry', ( + tester, + ) async { + await show(tester); + fill(); + controller.aufschlagController.text = '12,5'; + storage.failWrites = true; + await controller.saveBauteil(); + await tester.pumpAndSettle(); + expect(controller.bauteile, isEmpty); + expect(controller.aufschlagController.text, '12,5'); + expect(controller.gesamtpreisController.text, '2,81'); + storage.failWrites = false; + await controller.saveBauteil(); + await tester.pumpAndSettle(); + expect(controller.bauteile.single.gesamtpreis, 2.81); + expect(tester.takeException(), isNull); + }); + + test('Unknown prices and invalid allocation do not produce a plausible total; old backups remain valid', () { + final entry = BauteilModel( + bauteilId: 'x', + erstelltAm: DateTime(2026), + name: 'Alt', + verwendung: 'Test', + druckdauerMinuten: 1, + gewichtGramm: 100, + abfallGramm: 10, + filamente: [ + const BauteilFilamentModel(filamentId: 'missing', verbrauchGramm: 110), + ], + ); + expect(BauteilPreisBerechnung.berechne(entry, [], 10), isNull); + final oldJson = entry.toJson() + ..remove('aufschlagProzent') + ..remove('materialKosten'); + final data = { + for (final key in BackupModel.keys) key: >[], + }; + data[StorageKeys.bauteile] = [oldJson]; + expect( + BackupModel.decode(BackupModel(data).encode()).data[StorageKeys.bauteile], + hasLength(1), + ); + for (final key in ['aufschlagProzent', 'materialKosten']) { + data[StorageKeys.bauteile] = [ + {...oldJson, key: -1}, + ]; + expect( + () => BackupModel.decode(BackupModel(data).encode()), + throwsFormatException, + ); + } + }); +} diff --git a/test/export_test.dart b/test/export_test.dart index a55a4fe..7ea94f3 100644 --- a/test/export_test.dart +++ b/test/export_test.dart @@ -57,6 +57,7 @@ void main() { late ExportController controller; Map>> fixture() => { + StorageKeys.abfaelle: [], StorageKeys.filamente: [ KaufFilamentModel( filamentId: 'f1', diff --git a/test/filament_gewicht_test.dart b/test/filament_gewicht_test.dart new file mode 100644 index 0000000..f48f320 --- /dev/null +++ b/test/filament_gewicht_test.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; + +import 'package:ertl_filament_verwaltung/controller/home_controller.dart'; +import 'package:ertl_filament_verwaltung/helper/bauteil_preis_berechnung.dart'; +import 'package:ertl_filament_verwaltung/helper/storage_keys.dart'; +import 'package:ertl_filament_verwaltung/models/bauteil_model.dart'; +import 'package:ertl_filament_verwaltung/models/backup_model.dart'; +import 'package:ertl_filament_verwaltung/models/kauf_filament_model.dart'; +import 'package:ertl_filament_verwaltung/pages/home_view.dart'; +import 'package:ertl_filament_verwaltung/services/get_storage_services.dart'; +import 'package:ertl_filament_verwaltung/theme/app_theme.dart'; + +import 'helpers/memory_storage_service.dart'; + +void main() { + late MemoryStorageService storage; + late HomeController controller; + setUp(() { + Get.testMode = true; + storage = MemoryStorageService(); + Get.put(storage); + controller = Get.put(HomeController()); + }); + tearDown(() => Get.reset()); + + Future show(WidgetTester tester) async { + tester.view.physicalSize = const Size(320, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + GetMaterialApp(theme: AppTheme.light, home: const HomeView()), + ); + await tester.pumpAndSettle(); + } + + void fill() { + controller.haendlerController.text = 'Shop'; + controller.markeController.text = 'Marke'; + controller.farbeController.text = 'Blau'; + controller.preisController.text = '20'; + controller.gewichtController.text = '1000'; + } + + Future edit(WidgetTester tester, KaufFilamentModel filament) async { + final pending = controller.editFilament(filament); + await tester.pumpAndSettle(); + await pending; + } + + testWidgets( + 'Gross weight saves, reloads, edits, duplicates and can be cleared independently', + (tester) async { + await show(tester); + fill(); + final field = find.byWidgetPredicate( + (widget) => + widget is TextFormField && + widget.controller == controller.bruttoGewichtController, + ); + await tester.ensureVisible(field); + await tester.enterText(field, '1250,5'); + await controller.saveFilament(); + await tester.pumpAndSettle(); + var saved = controller.filamente.single; + expect(saved.gewichtGramm, 1000); + expect(saved.bruttoGewichtGramm, 1250.5); + expect(controller.bruttoGewichtController.text, isEmpty); + controller.loadFilamente(); + expect(controller.filamente.single.bruttoGewichtGramm, 1250.5); + await edit(tester, saved); + expect(controller.bruttoGewichtController.text, '1250,5'); + controller.bruttoGewichtController.text = '1300'; + await controller.saveFilament(); + await tester.pumpAndSettle(); + saved = controller.filamente.single; + await controller.duplicateFilament(saved); + expect(controller.filamente, hasLength(2)); + expect( + controller.filamente.map((item) => item.filamentId).toSet(), + hasLength(2), + ); + expect( + controller.filamente.every((item) => item.bruttoGewichtGramm == 1300), + isTrue, + ); + await edit(tester, saved); + controller.bruttoGewichtController.clear(); + await controller.saveFilament(); + await tester.pumpAndSettle(); + final changed = controller.filamente.firstWhere( + (item) => item.filamentId == saved.filamentId, + ); + expect(changed.bruttoGewichtGramm, isNull); + expect(changed.gewichtGramm, 1000); + expect( + controller.filamente + .firstWhere((item) => item.filamentId != saved.filamentId) + .bruttoGewichtGramm, + 1300, + ); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'Gross weight must be finite, positive and at least net weight; leaving it empty is allowed', + (tester) async { + await show(tester); + fill(); + for (final invalid in ['999', '0', '-1', 'NaN', 'Infinity', 'abc']) { + controller.bruttoGewichtController.text = invalid; + await controller.saveFilament(); + await tester.pumpAndSettle(); + expect(controller.filamente, isEmpty); + expect(storage.getModelList(StorageKeys.filamente), isEmpty); + } + controller.bruttoGewichtController.clear(); + await controller.saveFilament(); + await tester.pumpAndSettle(); + expect(controller.filamente.single.bruttoGewichtGramm, isNull); + expect(tester.takeException(), isNull); + }, + ); + + test('Gross weight is backed up while old backups without gross weight still load', () { + final rolle = KaufFilamentModel( + filamentId: 'a', + kaufdatum: DateTime(2026), + haendler: 'Shop', + marke: 'Marke', + material: 'PLA', + preis: 20, + farbe: 'Blau', + gewicht: '1kg', + bruttoGewichtGramm: 1250, + ); + final data = { + for (final key in BackupModel.keys) key: >[], + }; + data[StorageKeys.filamente] = [rolle.toJson()]; + final restored = BackupModel.decode(BackupModel(data).encode()); + final model = KaufFilamentModel.fromJson( + restored.data[StorageKeys.filamente]!.single, + ); + expect(model.bruttoGewichtGramm, 1250); + expect(model.gewichtGramm, 1000); + data[StorageKeys.filamente]!.single.remove('bruttoGewichtGramm'); + expect( + KaufFilamentModel.fromJson( + BackupModel.decode(BackupModel(data).encode()) + .data[StorageKeys.filamente]! + .single, + ).bruttoGewichtGramm, + isNull, + ); + for (final invalid in [999, 0, -1, 'invalid']) { + data[StorageKeys.filamente]!.single['bruttoGewichtGramm'] = invalid; + expect( + () => BackupModel.decode(BackupModel(data).encode()), + throwsFormatException, + ); + } + }); + + test( + 'Stock and all price calculations use only original net material weight', + () { + final rolle = KaufFilamentModel( + filamentId: 'a', + kaufdatum: DateTime(2026), + haendler: 'Shop', + marke: 'Marke', + material: 'PLA', + preis: 20, + farbe: 'Blau', + gewicht: '1000', + bruttoGewichtGramm: 1500, + ); + controller.verbrauch['a'] = 200; + expect(controller.restgewicht(rolle), 800); + expect( + controller.restgewicht(rolle.copyWith(bruttoGewichtGramm: 2000)), + 800, + ); + final part = BauteilModel( + bauteilId: 'part', + erstelltAm: DateTime(2026), + name: 'Teil', + verwendung: 'Test', + druckdauerMinuten: 1, + gewichtGramm: 100, + abfallGramm: 10, + filamente: [ + const BauteilFilamentModel(filamentId: 'a', verbrauchGramm: 110), + ], + ); + final price = BauteilPreisBerechnung.berechne(part, [rolle], 50)!; + expect(price.materialKosten, 2); + expect(price.abfallKosten, .2); + expect(price.gesamtpreis, 3.3); + }, + ); +} diff --git a/test/filament_vorrat_test.dart b/test/filament_vorrat_test.dart new file mode 100644 index 0000000..71cc98a --- /dev/null +++ b/test/filament_vorrat_test.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; + +import 'package:ertl_filament_verwaltung/controller/bauteil_controller.dart'; +import 'package:ertl_filament_verwaltung/helper/storage_keys.dart'; +import 'package:ertl_filament_verwaltung/models/bauteil_model.dart'; +import 'package:ertl_filament_verwaltung/models/kauf_filament_model.dart'; +import 'package:ertl_filament_verwaltung/services/get_storage_services.dart'; +import 'package:ertl_filament_verwaltung/theme/app_theme.dart'; +import 'package:ertl_filament_verwaltung/widgets/bauteil_filament_auswahl.dart'; + +import 'helpers/memory_storage_service.dart'; + +void main() { + late MemoryStorageService storage; + late BauteilController controller; + setUp(() { + Get.testMode = true; + storage = MemoryStorageService(); + storage.tables[StorageKeys.filamente] = [ + for (final entry in {'a': '100', 'b': '150'}.entries) + KaufFilamentModel( + filamentId: entry.key, + kaufdatum: DateTime(2026), + haendler: 'Shop', + marke: 'Rolle ${entry.key}', + material: 'PLA', + preis: 10, + farbe: 'Blau', + gewicht: entry.value, + ).toJson(), + ]; + storage.tables[StorageKeys.bauteile] = [ + BauteilModel( + bauteilId: 'vorher', + erstelltAm: DateTime(2026), + name: 'Vorher', + verwendung: 'Test', + druckdauerMinuten: 1, + gewichtGramm: 40, + abfallGramm: 0, + filamente: [ + const BauteilFilamentModel(filamentId: 'a', verbrauchGramm: 40), + ], + ).toJson(), + ]; + Get.put(storage); + controller = Get.put(BauteilController()); + }); + tearDown(() => Get.reset()); + + Future show(WidgetTester tester) async { + tester.view.physicalSize = const Size(320, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + GetMaterialApp( + theme: AppTheme.light, + home: Scaffold( + body: SingleChildScrollView( + child: BauteilFilamentAuswahl(controller: controller), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + Future select(WidgetTester tester, String id) async { + await tester.ensureVisible(find.byType(DropdownButtonFormField)); + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text(controller.filamentLabel(id)).last); + await tester.pumpAndSettle(); + } + + testWidgets( + 'Insufficient roll warns with quantities and requires OK; second roll covers combined demand', + (tester) async { + controller.gewichtController.text = '150'; + controller.abfallController.text = '10'; + await show(tester); + await select(tester, 'a'); + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text('Achtung: zu wenig Filament'), findsOneWidget); + expect( + find.textContaining('Benötigt (inklusive Abfall): 160 g'), + findsOneWidget, + ); + expect(find.textContaining('Verfügbar: 60 g'), findsOneWidget); + expect(find.textContaining('Es fehlen: 100 g'), findsOneWidget); + await tester.tapAt(const Offset(2, 2)); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsNothing); + expect(controller.selectedFilamente.keys, ['a']); + await select(tester, 'b'); // 60g + 150g cover 160g; b alone would not. + expect(find.byType(AlertDialog), findsNothing); + expect(controller.selectedFilamente.keys, ['a', 'b']); + expect(storage.getModelList(StorageKeys.bauteile), hasLength(1)); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'Exact remaining amount produces no warning and selecting does not book consumption', + (tester) async { + controller.gewichtController.text = '50'; + controller.abfallController.text = '10'; + await show(tester); + await select(tester, 'a'); + expect(find.byType(AlertDialog), findsNothing); + expect(controller.restgewichtGramm('a'), 60); + expect(storage.getModelList(StorageKeys.bauteile), hasLength(1)); + expect(tester.takeException(), isNull); + }, + ); + + test('Combined stock, decimal commas and editing credit use the correct available amount', () { + controller.gewichtController.text = '200,5'; + controller.abfallController.text = '9,5'; + controller.toggleFilament('a', true); + controller.toggleFilament('b', true); + expect(controller.pruefeFilamentVorrat(), isNull); // Exactly 210g. + controller.abfallController.text = '10'; + expect( + controller.pruefeFilamentVorrat(), + contains('ausgewählten Rollen zusammen'), + ); + expect(controller.pruefeFilamentVorrat(), contains('Es fehlen: 0,5 g')); + controller.toggleFilament('b', false); + controller.gewichtController.text = '90'; + controller.abfallController.text = '0'; + expect(controller.pruefeFilamentVorrat(), isNotNull); + controller.editingId.value = + 'vorher'; // Replace existing 40g booking, not a new print. + expect(controller.pruefeFilamentVorrat(), isNull); + }); + + testWidgets( + 'Without a valid requirement selection shows no shortage warning', + (tester) async { + await show(tester); + await select(tester, 'a'); + expect(find.byType(AlertDialog), findsNothing); + for (final value in ['', 'NaN', 'Infinity', '-1', '0', 'abc']) { + controller.gewichtController.text = value; + expect(controller.pruefeFilamentVorrat(), isNull); + } + expect(tester.takeException(), isNull); + }, + ); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 4e9c750..b71966f 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -73,7 +73,7 @@ void main() { tester.getTopLeft(find.byType(FilamentCard)).dx, greaterThan(tester.getTopRight(find.byType(FilamentForm)).dx), ); - expect(find.text('1000 g'), findsOneWidget); + expect(find.text('Netto: 1000 g'), findsOneWidget); expect(find.text('Testmarke'), findsOneWidget); await tester.tap(find.byTooltip('Filament verwalten')); @@ -114,7 +114,7 @@ void main() { ); await tester.pumpAndSettle(); expect(find.text('Testmarke'), findsOneWidget); - expect(find.text('1000 g'), findsOneWidget); + expect(find.text('Netto: 1000 g'), findsOneWidget); expect(tester.takeException(), isNull); });