add some new features

This commit is contained in:
2026-10-29 15:03:48 +01:00
parent c876405a8f
commit 20012db615
39 changed files with 2908 additions and 97 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"java.configuration.updateBuildConfiguration": "interactive"
}
+4
View File
@@ -4,3 +4,7 @@ android.useAndroidX=true
android.newDsl=false android.newDsl=false
# This builtInKotlin flag was added by the Flutter template # This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false 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
+212
View File
@@ -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<GetStorageService>();
final abfaelle = <AbfallModel>[].obs;
final datumZeit = DateTime.now().obs;
final editingId = RxnString();
final isSaving = false.obs;
final message = RxnString();
final hasError = false.obs;
final formKey = GlobalKey<FormState>();
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<void> _commit(List<AbfallModel> entries) async {
await storage.saveModelList(
StorageKeys.abfaelle,
entries.map((entry) => entry.toJson()).toList(),
);
loadAbfaelle();
}
Future<void> 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<void> 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<void> 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();
}
}
@@ -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<GetStorageService>();
final bauteile = <BauteilModel>[].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<BauteilAuswertungsGruppe> get gruppen {
final auswahl = zeitraum.value;
final gruppiert = <DateTime, List<BauteilModel>>{};
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();
}
}
+171 -5
View File
@@ -6,6 +6,7 @@ import 'package:get/get.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../helper/display_formats.dart'; import '../helper/display_formats.dart';
import '../helper/bauteil_preis_berechnung.dart';
import '../helper/filament_bestand.dart'; import '../helper/filament_bestand.dart';
import '../helper/input_validators.dart'; import '../helper/input_validators.dart';
import '../helper/storage_keys.dart'; import '../helper/storage_keys.dart';
@@ -15,12 +16,14 @@ import '../models/kauf_filament_model.dart';
import '../services/bauteil_photo_service.dart'; import '../services/bauteil_photo_service.dart';
import '../services/get_storage_services.dart'; import '../services/get_storage_services.dart';
import 'home_controller.dart'; import 'home_controller.dart';
import 'abfall_controller.dart';
class BauteilController extends GetxController { class BauteilController extends GetxController {
BauteilController({BauteilPhotoService? photoService}) BauteilController({BauteilPhotoService? photoService})
: photoService = photoService ?? BauteilPhotoService(); : photoService = photoService ?? BauteilPhotoService();
static const storageKey = StorageKeys.bauteile; static const storageKey = StorageKeys.bauteile;
static const double standardAufschlagProzent = 50;
final storage = Get.find<GetStorageService>(); final storage = Get.find<GetStorageService>();
final BauteilPhotoService photoService; final BauteilPhotoService photoService;
final formKey = GlobalKey<FormState>(); final formKey = GlobalKey<FormState>();
@@ -32,6 +35,12 @@ class BauteilController extends GetxController {
final gewichtController = TextEditingController(); final gewichtController = TextEditingController();
final abfallController = TextEditingController(text: '0'); final abfallController = TextEditingController(text: '0');
final gesamtpreisController = TextEditingController(); final gesamtpreisController = TextEditingController();
final aufschlagController = TextEditingController(
text: standardAufschlagProzent.toStringAsFixed(0),
);
final preisDetails = Rxn<BauteilPreis>();
bool _preisPausiert = false;
BauteilModel? _bearbeitetesBauteil;
final _verbrauchControllers = <String, TextEditingController>{}; final _verbrauchControllers = <String, TextEditingController>{};
final filamente = <KaufFilamentModel>[].obs; final filamente = <KaufFilamentModel>[].obs;
final profile = <AnwendungModel>[].obs; final profile = <AnwendungModel>[].obs;
@@ -60,13 +69,20 @@ class BauteilController extends GetxController {
); );
bauteile.assignAll(_loadBauteile()); bauteile.assignAll(_loadBauteile());
bauteile.sort((a, b) => b.erstelltAm.compareTo(a.erstelltAm)); bauteile.sort((a, b) => b.erstelltAm.compareTo(a.erstelltAm));
gewichtController.addListener(berechnePreis);
abfallController.addListener(berechnePreis);
aufschlagController.addListener(berechnePreis);
} }
List<BauteilModel> _loadBauteile() => List<BauteilModel> _loadBauteile() =>
storage.getModelList(storageKey).map(BauteilModel.fromJson).toList(); storage.getModelList(storageKey).map(BauteilModel.fromJson).toList();
TextEditingController verbrauchController(String id) => TextEditingController verbrauchController(String id) =>
_verbrauchControllers.putIfAbsent(id, () => TextEditingController()); _verbrauchControllers.putIfAbsent(id, () {
final field = TextEditingController();
field.addListener(berechnePreis);
return field;
});
List<AnwendungModel> profilesFor(String filamentId) => List<AnwendungModel> profilesFor(String filamentId) =>
profile.where((item) => item.filamentId == filamentId).toList() profile.where((item) => item.filamentId == filamentId).toList()
@@ -78,6 +94,15 @@ class BauteilController extends GetxController {
return '${item.marke} · ${item.material} · ${item.farbe}'; 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) { double? verfuegbarGramm(String id) {
final start = filamente final start = filamente
.firstWhereOrNull((item) => item.filamentId == id) .firstWhereOrNull((item) => item.filamentId == id)
@@ -97,6 +122,42 @@ class BauteilController extends GetxController {
} else { } else {
selectedFilamente.remove(id); 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) { void selectProfile(String filamentId, String? anwendungId) {
@@ -190,8 +251,60 @@ class BauteilController extends GetxController {
if (Get.isRegistered<HomeController>()) { if (Get.isRegistered<HomeController>()) {
Get.find<HomeController>().loadVerbrauch(); Get.find<HomeController>().loadVerbrauch();
} }
if (Get.isRegistered<AbfallController>()) {
Get.find<AbfallController>().loadAbfaelle();
}
} }
void berechnePreis() {
if (_preisPausiert || _closed) return;
final gewicht = InputValidators.number(gewichtController.text);
final abfall = InputValidators.number(abfallController.text);
final aufschlag = InputValidators.number(aufschlagController.text);
BauteilPreis? preis;
if (gewicht != null && abfall != null && aufschlag != null) {
final entry = BauteilModel(
bauteilId: editingId.value ?? '',
erstelltAm: DateTime.now(),
name: '',
verwendung: '',
druckdauerMinuten: 1,
gewichtGramm: gewicht,
abfallGramm: abfall,
filamente: selectedFilamente.keys
.map(
(id) => BauteilFilamentModel(
filamentId: id,
verbrauchGramm:
InputValidators.number(_verbrauchControllers[id]?.text) ??
0,
),
)
.toList(),
);
preis = BauteilPreisBerechnung.berechne(
entry,
filamente,
aufschlag,
previous: _bearbeitetesBauteil,
);
}
preisDetails.value = preis;
gesamtpreisController.text = preis == null
? ''
: preis.gesamtpreis.toStringAsFixed(2).replaceAll('.', ',');
}
BauteilPreis? _kalkulation(
BauteilModel entry,
double aufschlag, {
BauteilModel? previous,
}) => BauteilPreisBerechnung.berechne(
entry,
storage.getModelList(StorageKeys.filamente).map(KaufFilamentModel.fromJson),
aufschlag,
previous: previous,
);
Future<void> saveBauteil() async { Future<void> saveBauteil() async {
if (isSaving.value || isPickingPhoto.value) return; if (isSaving.value || isPickingPhoto.value) return;
message.value = null; message.value = null;
@@ -225,7 +338,7 @@ class BauteilController extends GetxController {
druckdauerMinuten: duration, druckdauerMinuten: duration,
gewichtGramm: InputValidators.number(gewichtController.text)!, gewichtGramm: InputValidators.number(gewichtController.text)!,
abfallGramm: InputValidators.number(abfallController.text)!, abfallGramm: InputValidators.number(abfallController.text)!,
gesamtpreis: InputValidators.number(gesamtpreisController.text), aufschlagProzent: InputValidators.number(aufschlagController.text),
fotoBase64: foto.value == null ? null : base64Encode(foto.value!), fotoBase64: foto.value == null ? null : base64Encode(foto.value!),
filamente: selectedFilamente.entries filamente: selectedFilamente.entries
.map( .map(
@@ -246,13 +359,32 @@ class BauteilController extends GetxController {
_error(error); _error(error);
return; 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([ await _commit([
...existing.where((item) => item.bauteilId != entry.bauteilId), ...existing.where((item) => item.bauteilId != entry.bauteilId),
entry, gespeichert,
]); ]);
resetForm(); resetForm();
hasError.value = false; hasError.value = false;
message.value = 'Bauteil und Filamentverbrauch gespeichert.'; message.value = 'Bauteil, Filamentverbrauch und Abfall gespeichert.';
} catch (_) { } catch (_) {
_error('Speichern fehlgeschlagen. Bitte erneut versuchen.'); _error('Speichern fehlgeschlagen. Bitte erneut versuchen.');
} finally { } finally {
@@ -263,6 +395,8 @@ class BauteilController extends GetxController {
Future<void> editBauteil(BauteilModel entry) async { Future<void> editBauteil(BauteilModel entry) async {
if (isSaving.value || isPickingPhoto.value) return; if (isSaving.value || isPickingPhoto.value) return;
resetForm(); resetForm();
_preisPausiert = true;
_bearbeitetesBauteil = entry;
message.value = null; message.value = null;
nameController.text = entry.name; nameController.text = entry.name;
verwendungController.text = entry.verwendung; verwendungController.text = entry.verwendung;
@@ -279,7 +413,13 @@ class BauteilController extends GetxController {
: ''; : '';
selectedFilamente[item.filamentId] = item.anwendungId; selectedFilamente[item.filamentId] = item.anwendungId;
} }
aufschlagController.text =
(entry.aufschlagProzent ?? standardAufschlagProzent)
.toString()
.replaceAll('.', ',');
editingId.value = entry.bauteilId; editingId.value = entry.bauteilId;
_preisPausiert = false;
berechnePreis();
await scrollToForm(); await scrollToForm();
} }
@@ -298,7 +438,27 @@ class BauteilController extends GetxController {
await scrollToForm(); await scrollToForm();
return; 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; hasError.value = false;
message.value = 'Bauteil dupliziert und Verbrauch erneut gebucht.'; message.value = 'Bauteil dupliziert und Verbrauch erneut gebucht.';
} catch (_) { } catch (_) {
@@ -338,6 +498,8 @@ class BauteilController extends GetxController {
} }
void resetForm() { void resetForm() {
_preisPausiert = true;
_bearbeitetesBauteil = null;
formKey.currentState?.reset(); formKey.currentState?.reset();
editingId.value = null; editingId.value = null;
nameController.clear(); nameController.clear();
@@ -352,6 +514,9 @@ class BauteilController extends GetxController {
controller.clear(); controller.clear();
} }
foto.value = null; foto.value = null;
aufschlagController.text = standardAufschlagProzent.toStringAsFixed(0);
preisDetails.value = null;
_preisPausiert = false;
} }
@override @override
@@ -365,6 +530,7 @@ class BauteilController extends GetxController {
gewichtController.dispose(); gewichtController.dispose();
abfallController.dispose(); abfallController.dispose();
gesamtpreisController.dispose(); gesamtpreisController.dispose();
aufschlagController.dispose();
for (final controller in _verbrauchControllers.values) { for (final controller in _verbrauchControllers.values) {
controller.dispose(); controller.dispose();
} }
+4
View File
@@ -5,6 +5,7 @@ import '../models/backup_model.dart';
import '../services/backup_file_service.dart'; import '../services/backup_file_service.dart';
import '../services/get_storage_services.dart'; import '../services/get_storage_services.dart';
import 'home_controller.dart'; import 'home_controller.dart';
import 'abfall_controller.dart';
class ExportController extends GetxController { class ExportController extends GetxController {
ExportController({BackupFileService? fileService}) ExportController({BackupFileService? fileService})
@@ -76,6 +77,9 @@ class ExportController extends GetxController {
home.loadMaterialien(); home.loadMaterialien();
home.resetForm(); home.resetForm();
} }
if (Get.isRegistered<AbfallController>()) {
Get.find<AbfallController>().loadAbfaelle();
}
pendingBackup.value = null; pendingBackup.value = null;
hasError.value = false; hasError.value = false;
message.value = 'Alle Daten wurden importiert.'; message.value = 'Alle Daten wurden importiert.';
+19 -1
View File
@@ -54,6 +54,7 @@ class HomeController extends GetxController {
final preisController = TextEditingController(); final preisController = TextEditingController();
final markeController = TextEditingController(); final markeController = TextEditingController();
final gewichtController = TextEditingController(); final gewichtController = TextEditingController();
final bruttoGewichtController = TextEditingController();
final materialController = TextEditingController(text: 'PLA'); final materialController = TextEditingController(text: 'PLA');
final kaufdatum = DateTime.now().obs; 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<void> saveFilament() async { Future<void> saveFilament() async {
if (!formKey.currentState!.validate()) return; if (!formKey.currentState!.validate()) return;
@@ -186,7 +199,7 @@ class HomeController extends GetxController {
gewicht < bereitsVerbraucht) { gewicht < bereitsVerbraucht) {
Get.snackbar( Get.snackbar(
'Ungültiges Gewicht', '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; return;
} }
@@ -202,6 +215,7 @@ class HomeController extends GetxController {
preis: preis, preis: preis,
farbe: farbeController.text.trim(), farbe: farbeController.text.trim(),
gewicht: gewicht.toString(), gewicht: gewicht.toString(),
bruttoGewichtGramm: InputValidators.number(bruttoGewichtController.text),
); );
if (editingId.value == null) { if (editingId.value == null) {
@@ -231,6 +245,8 @@ class HomeController extends GetxController {
haendlerController.text = filament.haendler; haendlerController.text = filament.haendler;
selectMaterial(filament.material); selectMaterial(filament.material);
preisController.text = filament.preis.toStringAsFixed(2); preisController.text = filament.preis.toStringAsFixed(2);
bruttoGewichtController.text =
filament.bruttoGewichtGramm?.toString().replaceAll('.', ',') ?? '';
farbeController.text = filament.farbe; farbeController.text = filament.farbe;
gewichtController.text = filament.gewichtGramm == null gewichtController.text = filament.gewichtGramm == null
? filament.gewicht ? filament.gewicht
@@ -272,6 +288,7 @@ class HomeController extends GetxController {
selectMaterial(materialien.firstOrNull ?? ''); selectMaterial(materialien.firstOrNull ?? '');
materialMessage.value = null; materialMessage.value = null;
gewichtController.clear(); gewichtController.clear();
bruttoGewichtController.clear();
} }
@override @override
@@ -283,6 +300,7 @@ class HomeController extends GetxController {
farbeController.dispose(); farbeController.dispose();
preisController.dispose(); preisController.dispose();
gewichtController.dispose(); gewichtController.dispose();
bruttoGewichtController.dispose();
materialController.dispose(); materialController.dispose();
super.onClose(); super.onClose();
} }
+71
View File
@@ -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<KaufFilamentModel> 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<KaufFilamentModel> filamente,
) {
if (bauteil.filamente.isEmpty) return null;
final rollen = {for (final rolle in filamente) rolle.filamentId: rolle};
final gesamtverbrauch = bauteil.filamente.fold<double>(
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,
),
);
}
+73
View File
@@ -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<KaufFilamentModel> 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<double>(
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),
);
}
}
+21
View File
@@ -1,17 +1,38 @@
import '../controller/abfall_controller.dart';
import '../pages/abfall_view.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../controller/anwendung_controller.dart'; import '../controller/anwendung_controller.dart';
import '../controller/bauteil_controller.dart'; import '../controller/bauteil_controller.dart';
import '../controller/bauteil_auswertung_controller.dart';
import '../controller/export_controller.dart'; import '../controller/export_controller.dart';
import '../pages/export_view.dart'; import '../pages/export_view.dart';
import '../pages/anwendung_view.dart'; import '../pages/anwendung_view.dart';
import '../pages/bauteil_view.dart'; import '../pages/bauteil_view.dart';
import '../pages/bauteil_auswertung_view.dart';
import '../pages/home_view.dart'; import '../pages/home_view.dart';
import 'sample_bindings.dart'; import 'sample_bindings.dart';
class SampleRouts { class SampleRouts {
static final sampleBindings = SampleBindings(); static final sampleBindings = SampleBindings();
static List<GetPage<dynamic>> samplePages = [ static List<GetPage<dynamic>> samplePages = [
GetPage(
name: BauteilAuswertungView.routeName,
page: () => const BauteilAuswertungView(),
binding: BindingsBuilder(
() => Get.lazyPut<BauteilAuswertungController>(
() => BauteilAuswertungController(),
),
),
),
GetPage(
name: AbfallView.routeName,
page: () => const AbfallView(),
binding: BindingsBuilder(
() => Get.lazyPut<AbfallController>(() => AbfallController()),
),
),
GetPage( GetPage(
name: ExportView.routeName, name: ExportView.routeName,
page: () => const ExportView(), page: () => const ExportView(),
+1
View File
@@ -3,4 +3,5 @@ class StorageKeys {
static const materialien = 'filament_materialien'; static const materialien = 'filament_materialien';
static const anwendungen = 'filament_anwendungen'; static const anwendungen = 'filament_anwendungen';
static const bauteile = 'gedruckte_bauteile'; static const bauteile = 'gedruckte_bauteile';
static const abfaelle = 'abfall_eintraege';
} }
+38
View File
@@ -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<String, dynamic> 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<String, dynamic> toJson() => {
'abfallId': abfallId,
'datumZeit': datumZeit.toIso8601String(),
'gewichtGramm': gewichtGramm,
'kosten': kosten,
};
}
+27 -4
View File
@@ -2,6 +2,7 @@ import 'dart:convert';
import '../helper/storage_keys.dart'; import '../helper/storage_keys.dart';
import 'anwendung_model.dart'; import 'anwendung_model.dart';
import 'abfall_model.dart';
import 'bauteil_model.dart'; import 'bauteil_model.dart';
import 'kauf_filament_model.dart'; import 'kauf_filament_model.dart';
@@ -11,6 +12,7 @@ class BackupModel {
StorageKeys.anwendungen, StorageKeys.anwendungen,
StorageKeys.bauteile, StorageKeys.bauteile,
StorageKeys.materialien, StorageKeys.materialien,
StorageKeys.abfaelle,
]; ];
final Map<String, List<Map<String, dynamic>>> data; final Map<String, List<Map<String, dynamic>>> data;
@@ -34,9 +36,12 @@ class BackupModel {
final raw = json['daten'] as Map; final raw = json['daten'] as Map;
final data = <String, List<Map<String, dynamic>>>{ final data = <String, List<Map<String, dynamic>>>{
for (final key in keys) for (final key in keys)
key: (raw[key] as List) key:
.map((item) => Map<String, dynamic>.from(item as Map)) ((key == StorageKeys.abfaelle && !raw.containsKey(key))
.toList(), ? <dynamic>[]
: raw[key] as List)
.map((item) => Map<String, dynamic>.from(item as Map))
.toList(),
}; };
final filamentIds = <String>{}; final filamentIds = <String>{};
for (final json in data[StorageKeys.filamente]!) { for (final json in data[StorageKeys.filamente]!) {
@@ -45,7 +50,10 @@ class BackupModel {
!filamentIds.add(item.filamentId) || !filamentIds.add(item.filamentId) ||
item.material.trim().isEmpty || item.material.trim().isEmpty ||
!_positive(item.gewichtGramm) || !_positive(item.gewichtGramm) ||
!_nonNegative(item.preis)) { !_nonNegative(item.preis) ||
(item.bruttoGewichtGramm != null &&
(!_positive(item.bruttoGewichtGramm) ||
item.bruttoGewichtGramm! < item.gewichtGramm!))) {
throw const FormatException(); throw const FormatException();
} }
} }
@@ -71,6 +79,11 @@ class BackupModel {
item.druckdauerMinuten <= 0 || item.druckdauerMinuten <= 0 ||
!_positive(item.gewichtGramm) || !_positive(item.gewichtGramm) ||
!_nonNegative(item.abfallGramm) || !_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.gesamtpreis != null && !_nonNegative(item.gesamtpreis!)) ||
item.filamente.isEmpty || item.filamente.isEmpty ||
item.filamente.any( item.filamente.any(
@@ -82,6 +95,16 @@ class BackupModel {
} }
if (item.fotoBase64 != null) base64Decode(item.fotoBase64!); if (item.fotoBase64 != null) base64Decode(item.fotoBase64!);
} }
final wasteIds = <String>{};
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]!; final materials = data[StorageKeys.materialien]!;
if (materials.isNotEmpty) { if (materials.isNotEmpty) {
if (materials.length != 1) throw const FormatException(); if (materials.length != 1) throw const FormatException();
+27
View File
@@ -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<BauteilModel> bauteile;
static bool preisBekannt(BauteilModel bauteil) =>
bauteil.gesamtpreis != null &&
bauteil.gesamtpreis!.isFinite &&
bauteil.gesamtpreis! >= 0;
static double summeFuer(Iterable<BauteilModel> bauteile) => double.parse(
bauteile
.where(preisBekannt)
.fold<double>(0, (sum, item) => sum + item.gesamtpreis!)
.toStringAsFixed(2),
);
double get summe => summeFuer(bauteile);
int get ohnePreis => bauteile.where((item) => !preisBekannt(item)).length;
}
+20 -1
View File
@@ -35,6 +35,9 @@ class BauteilModel {
final double gewichtGramm; final double gewichtGramm;
final double abfallGramm; final double abfallGramm;
final double? gesamtpreis; final double? gesamtpreis;
final double? abfallKosten;
final double? materialKosten;
final double? aufschlagProzent;
final List<BauteilFilamentModel> filamente; final List<BauteilFilamentModel> filamente;
final String? fotoBase64; final String? fotoBase64;
@@ -48,6 +51,9 @@ class BauteilModel {
required this.abfallGramm, required this.abfallGramm,
required this.filamente, required this.filamente,
this.gesamtpreis, this.gesamtpreis,
this.abfallKosten,
this.materialKosten,
this.aufschlagProzent,
this.fotoBase64, this.fotoBase64,
}); });
@@ -65,6 +71,10 @@ class BauteilModel {
BauteilModel copyWith({ BauteilModel copyWith({
required String bauteilId, required String bauteilId,
required DateTime erstelltAm, required DateTime erstelltAm,
double? abfallKosten,
double? materialKosten,
double? aufschlagProzent,
double? gesamtpreis,
}) => BauteilModel( }) => BauteilModel(
bauteilId: bauteilId, bauteilId: bauteilId,
erstelltAm: erstelltAm, erstelltAm: erstelltAm,
@@ -73,7 +83,10 @@ class BauteilModel {
druckdauerMinuten: druckdauerMinuten, druckdauerMinuten: druckdauerMinuten,
gewichtGramm: gewichtGramm, gewichtGramm: gewichtGramm,
abfallGramm: abfallGramm, abfallGramm: abfallGramm,
gesamtpreis: gesamtpreis, gesamtpreis: gesamtpreis ?? this.gesamtpreis,
abfallKosten: abfallKosten ?? this.abfallKosten,
materialKosten: materialKosten ?? this.materialKosten,
aufschlagProzent: aufschlagProzent ?? this.aufschlagProzent,
filamente: filamente, filamente: filamente,
fotoBase64: fotoBase64, fotoBase64: fotoBase64,
); );
@@ -87,6 +100,9 @@ class BauteilModel {
'gewichtGramm': gewichtGramm, 'gewichtGramm': gewichtGramm,
'abfallGramm': abfallGramm, 'abfallGramm': abfallGramm,
'gesamtpreis': gesamtpreis, 'gesamtpreis': gesamtpreis,
'abfallKosten': abfallKosten,
'materialKosten': materialKosten,
'aufschlagProzent': aufschlagProzent,
'filamente': filamente.map((item) => item.toJson()).toList(), 'filamente': filamente.map((item) => item.toJson()).toList(),
'fotoBase64': fotoBase64, 'fotoBase64': fotoBase64,
}; };
@@ -100,6 +116,9 @@ class BauteilModel {
gewichtGramm: (json['gewichtGramm'] as num).toDouble(), gewichtGramm: (json['gewichtGramm'] as num).toDouble(),
abfallGramm: (json['abfallGramm'] as num).toDouble(), abfallGramm: (json['abfallGramm'] as num).toDouble(),
gesamtpreis: (json['gesamtpreis'] 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?, fotoBase64: json['fotoBase64'] as String?,
filamente: (json['filamente'] as List) filamente: (json['filamente'] as List)
.map( .map(
+8 -1
View File
@@ -6,7 +6,9 @@ class KaufFilamentModel {
final String material; final String material;
final double preis; final double preis;
final String farbe; final String farbe;
// Original net material weight, without the spool.
final String gewicht; final String gewicht;
final double? bruttoGewichtGramm;
const KaufFilamentModel({ const KaufFilamentModel({
required this.filamentId, required this.filamentId,
@@ -17,9 +19,10 @@ class KaufFilamentModel {
required this.preis, required this.preis,
required this.farbe, required this.farbe,
required this.gewicht, 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 { double? get gewichtGramm {
final match = RegExp( final match = RegExp(
r'^\s*(\d+(?:[.,]\d+)?)\s*(kg|g)?\s*$', r'^\s*(\d+(?:[.,]\d+)?)\s*(kg|g)?\s*$',
@@ -41,6 +44,7 @@ class KaufFilamentModel {
'preis': preis, 'preis': preis,
'farbe': farbe, 'farbe': farbe,
'gewicht': gewicht, 'gewicht': gewicht,
'bruttoGewichtGramm': bruttoGewichtGramm,
}; };
factory KaufFilamentModel.fromJson(Map<String, dynamic> json) { factory KaufFilamentModel.fromJson(Map<String, dynamic> json) {
@@ -53,6 +57,7 @@ class KaufFilamentModel {
preis: (json['preis'] as num).toDouble(), preis: (json['preis'] as num).toDouble(),
farbe: json['farbe'] as String, farbe: json['farbe'] as String,
gewicht: json['gewicht'] ?? '1000', gewicht: json['gewicht'] ?? '1000',
bruttoGewichtGramm: (json['bruttoGewichtGramm'] as num?)?.toDouble(),
); );
} }
@@ -65,6 +70,7 @@ class KaufFilamentModel {
double? preis, double? preis,
String? farbe, String? farbe,
String? gewicht, String? gewicht,
double? bruttoGewichtGramm,
}) { }) {
return KaufFilamentModel( return KaufFilamentModel(
filamentId: filamentId ?? this.filamentId, filamentId: filamentId ?? this.filamentId,
@@ -75,6 +81,7 @@ class KaufFilamentModel {
preis: preis ?? this.preis, preis: preis ?? this.preis,
farbe: farbe ?? this.farbe, farbe: farbe ?? this.farbe,
gewicht: gewicht ?? this.gewicht, gewicht: gewicht ?? this.gewicht,
bruttoGewichtGramm: bruttoGewichtGramm ?? this.bruttoGewichtGramm,
); );
} }
} }
+48
View File
@@ -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<AbfallController> {
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<void>(),
),
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),
),
],
),
),
),
),
);
}
+191
View File
@@ -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<BauteilAuswertungController> {
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<void>(),
),
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 = <Object>[
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<BauteilZeitraum>(
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)),
),
],
],
),
);
}
+16 -1
View File
@@ -8,6 +8,7 @@ import '../widgets/bauteil_card.dart';
import '../widgets/bauteil_delete_dialog.dart'; import '../widgets/bauteil_delete_dialog.dart';
import '../widgets/bauteil_form.dart'; import '../widgets/bauteil_form.dart';
import '../widgets/entry_panel.dart'; import '../widgets/entry_panel.dart';
import 'bauteil_auswertung_view.dart';
class BauteilView extends GetView<BauteilController> { class BauteilView extends GetView<BauteilController> {
static const routeName = '/bauteile-view'; static const routeName = '/bauteile-view';
@@ -15,7 +16,21 @@ class BauteilView extends GetView<BauteilController> {
@override @override
Widget build(BuildContext context) => Scaffold( 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( body: SafeArea(
top: false, top: false,
child: Align( child: Align(
+4 -1
View File
@@ -27,7 +27,7 @@ class ExportView extends GetView<ExportController> {
children: [ children: [
EntryPanel( EntryPanel(
title: 'Daten exportieren', 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( child: FilledButton.icon(
onPressed: busy ? null : controller.exportData, onPressed: busy ? null : controller.exportData,
icon: const Icon(Icons.file_download_outlined), icon: const Icon(Icons.file_download_outlined),
@@ -63,6 +63,9 @@ class ExportView extends GetView<ExportController> {
? 'Materialarten: Standardliste' ? 'Materialarten: Standardliste'
: '${(backup.data[StorageKeys.materialien]!.single['werte'] as List).length} Materialarten', : '${(backup.data[StorageKeys.materialien]!.single['werte'] as List).length} Materialarten',
), ),
Text(
'${backup.data[StorageKeys.abfaelle]!.length} Abfalleinträge',
),
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton.icon( FilledButton.icon(
onPressed: busy onPressed: busy
+6
View File
@@ -9,6 +9,7 @@ import '../widgets/filament_inventory.dart';
import '../widgets/app_version.dart'; import '../widgets/app_version.dart';
import 'anwendung_view.dart'; import 'anwendung_view.dart';
import 'bauteil_view.dart'; import 'bauteil_view.dart';
import 'abfall_view.dart';
import 'export_view.dart'; import 'export_view.dart';
class HomeView extends GetView<HomeController> { class HomeView extends GetView<HomeController> {
@@ -26,6 +27,11 @@ class HomeView extends GetView<HomeController> {
icon: const Icon(Icons.view_in_ar_outlined), icon: const Icon(Icons.view_in_ar_outlined),
onPressed: () => Get.toNamed(BauteilView.routeName), onPressed: () => Get.toNamed(BauteilView.routeName),
), ),
IconButton(
tooltip: 'Abfall verwalten',
icon: const Icon(Icons.delete_outline),
onPressed: () => Get.toNamed(AbfallView.routeName),
),
const SizedBox(width: 8), const SizedBox(width: 8),
], ],
title: LayoutBuilder( title: LayoutBuilder(
+139
View File
@@ -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,
),
],
),
),
);
});
}
+189
View File
@@ -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<String>(
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<void> _confirmDelete(BuildContext context, AbfallModel entry) async {
final confirmed = await showDialog<bool>(
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,
),
),
],
),
);
}
+12
View File
@@ -110,6 +110,18 @@ class BauteilCard extends StatelessWidget {
'Bauteil ${DisplayFormats.number(bauteil.gewichtGramm)} g', 'Bauteil ${DisplayFormats.number(bauteil.gewichtGramm)} g',
), ),
_detail('Abfall ${DisplayFormats.number(bauteil.abfallGramm)} 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( _detail(
'Gesamt ${DisplayFormats.number(bauteil.gesamtverbrauchGramm)} g', 'Gesamt ${DisplayFormats.number(bauteil.gesamtverbrauchGramm)} g',
), ),
+47 -4
View File
@@ -11,6 +11,29 @@ class BauteilFilamentAuswahl extends StatelessWidget {
const BauteilFilamentAuswahl({super.key, required this.controller}); const BauteilFilamentAuswahl({super.key, required this.controller});
final BauteilController controller; final BauteilController controller;
Future<void> _waehleFilament(BuildContext context, String id) async {
controller.toggleFilament(id, true);
final warnung = controller.pruefeFilamentVorrat();
if (warnung == null || !context.mounted) return;
await showDialog<void>(
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 @override
Widget build(BuildContext context) => Obx(() { Widget build(BuildContext context) => Obx(() {
final ids = controller.selectedFilamente.keys.toList(); final ids = controller.selectedFilamente.keys.toList();
@@ -36,21 +59,41 @@ class BauteilFilamentAuswahl extends StatelessWidget {
.toString(), .toString(),
), ),
isExpanded: true, isExpanded: true,
itemHeight: 64,
decoration: const InputDecoration(labelText: 'Filament hinzufügen'), decoration: const InputDecoration(labelText: 'Filament hinzufügen'),
hint: const Text('Filament auswählen'), hint: const Text('Filament auswählen'),
items: availableFilamente items: availableFilamente
.map( .map(
(item) => DropdownMenuItem( (item) => DropdownMenuItem(
value: item.filamentId, value: item.filamentId,
child: Text( child: Column(
controller.filamentLabel(item.filamentId), mainAxisAlignment: MainAxisAlignment.center,
overflow: TextOverflow.ellipsis, 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(), .toList(),
onChanged: (id) { onChanged: (id) {
if (id != null) controller.toggleFilament(id, true); if (id != null) _waehleFilament(context, id);
}, },
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
+13 -6
View File
@@ -6,6 +6,7 @@ import '../helper/input_validators.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import 'bauteil_filament_auswahl.dart'; import 'bauteil_filament_auswahl.dart';
import 'bauteil_photo_picker.dart'; import 'bauteil_photo_picker.dart';
import 'bauteil_preis_uebersicht.dart';
import 'entry_panel.dart'; import 'entry_panel.dart';
class BauteilForm extends StatelessWidget { class BauteilForm extends StatelessWidget {
@@ -65,14 +66,18 @@ class BauteilForm extends StatelessWidget {
validator: InputValidators.nonNegative, validator: InputValidators.nonNegative,
), ),
EntryNumberField( EntryNumberField(
controller: controller.gesamtpreisController, controller: controller.aufschlagController,
label: 'Gesamtpreis (optional)', label: 'Aufschlag',
unit: '€', unit: '%',
validator: (value) => validator: InputValidators.nonNegative,
InputValidators.nonNegative(value, optional: true),
), ),
], ],
), ),
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), const SizedBox(height: 24),
BauteilPhotoPicker(controller: controller), BauteilPhotoPicker(controller: controller),
const SizedBox(height: 24), const SizedBox(height: 24),
@@ -87,7 +92,9 @@ class BauteilForm extends StatelessWidget {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
BauteilFilamentAuswahl(controller: controller), BauteilFilamentAuswahl(controller: controller),
const SizedBox(height: 12), const SizedBox(height: 16),
BauteilPreisUebersicht(controller: controller),
const SizedBox(height: 16),
Obx( Obx(
() => FilledButton.icon( () => FilledButton.icon(
onPressed: onPressed:
+59
View File
@@ -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: '€',
),
),
],
),
);
});
}
+19 -3
View File
@@ -75,11 +75,13 @@ class EntryNumberField extends StatelessWidget {
required this.label, required this.label,
required this.unit, required this.unit,
required this.validator, required this.validator,
this.onChanged,
}); });
final TextEditingController controller; final TextEditingController controller;
final String label; final String label;
final String unit; final String unit;
final FormFieldValidator<String> validator; final FormFieldValidator<String> validator;
final ValueChanged<String>? onChanged;
@override @override
Widget build(BuildContext context) => TextFormField( Widget build(BuildContext context) => TextFormField(
@@ -87,6 +89,7 @@ class EntryNumberField extends StatelessWidget {
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(labelText: label, suffixText: unit), decoration: InputDecoration(labelText: label, suffixText: unit),
validator: validator, validator: validator,
onChanged: onChanged,
); );
} }
@@ -176,11 +179,13 @@ class SavedEntryCard extends StatelessWidget {
required this.subtitle, required this.subtitle,
required this.details, required this.details,
this.notes = '', this.notes = '',
this.trailing,
}); });
final String title; final String title;
final String subtitle; final String subtitle;
final List<String> details; final List<String> details;
final String notes; final String notes;
final Widget? trailing;
@override @override
Widget build(BuildContext context) => Card( Widget build(BuildContext context) => Card(
@@ -194,9 +199,20 @@ class SavedEntryCard extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Row(
title, crossAxisAlignment: CrossAxisAlignment.start,
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 18), 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), const SizedBox(height: 6),
Text( Text(
+15 -1
View File
@@ -137,6 +137,11 @@ class FilamentCard extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
LinearProgressIndicator( LinearProgressIndicator(
value: (rest! / start).clamp(0.0, 1.0), value: (rest! / start).clamp(0.0, 1.0),
color: rest < 100
? Colors.red
: rest <= start / 2
? Colors.blue
: null,
minHeight: 6, minHeight: 6,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
backgroundColor: AppTheme.inputSurface, backgroundColor: AppTheme.inputSurface,
@@ -156,7 +161,16 @@ class FilamentCard extends StatelessWidget {
label: filament.material, label: filament.material,
icon: Icons.layers_outlined, 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) if (hatProfil)
const _DetailPill( const _DetailPill(
label: 'Profil erstellt', label: 'Profil erstellt',
+38 -51
View File
@@ -145,57 +145,44 @@ class FilamentForm extends StatelessWidget {
: null, : null,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
LayoutBuilder( TextFormField(
builder: (context, constraints) { controller: controller.preisController,
final fields = [ keyboardType: const TextInputType.numberWithOptions(
TextFormField( decimal: true,
controller: controller.preisController, ),
keyboardType: const TextInputType.numberWithOptions( decoration: const InputDecoration(
decimal: true, labelText: 'Preis',
), suffixText: '€',
decoration: const InputDecoration( ),
labelText: 'Preis', validator: InputValidators.nonNegative,
suffixText: '€', ),
), const SizedBox(height: 16),
validator: (value) { TextFormField(
final price = double.tryParse( controller: controller.gewichtController,
(value ?? '').replaceAll(',', '.'), keyboardType: const TextInputType.numberWithOptions(
); decimal: true,
return price == null || !price.isFinite || price < 0 ),
? 'Preis ungültig' decoration: const InputDecoration(
: null; labelText: 'Nettogewicht',
}, suffixText: 'g',
), helperText: 'Startgewicht ohne Spule, nur Material.',
TextFormField( helperMaxLines: 2,
controller: controller.gewichtController, ),
keyboardType: const TextInputType.numberWithOptions( validator: InputValidators.positive,
decimal: true, ),
), const SizedBox(height: 16),
decoration: const InputDecoration( TextFormField(
labelText: 'Startgewicht', controller: controller.bruttoGewichtController,
suffixText: 'g', keyboardType: const TextInputType.numberWithOptions(
), decimal: true,
validator: InputValidators.positive, ),
), decoration: const InputDecoration(
]; labelText: 'Bruttogewicht (optional)',
if (constraints.maxWidth < 280) { suffixText: 'g',
return Column( helperText: 'Material und Spule zusammen.',
children: [ helperMaxLines: 2,
fields[0], ),
const SizedBox(height: 16), validator: controller.validateBruttogewicht,
fields[1],
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: fields[0]),
const SizedBox(width: 12),
Expanded(child: fields[1]),
],
);
},
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Obx( Obx(
+221
View File
@@ -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<GetStorageService>(storage);
});
tearDown(() => Get.reset());
Future<AbfallController> 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<void> 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: <Map<String, dynamic>>[],
};
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,
);
},
);
}
+286
View File
@@ -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<BauteilFilamentModel> 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<GetStorageService>(storage);
});
tearDown(() => Get.reset());
Future<BauteilController> 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<void> 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);
},
);
}
+191
View File
@@ -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<GetStorageService>(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<int>(
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<BauteilController>();
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<BauteilAuswertungController>();
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<BauteilAuswertungController>(), 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<BauteilAuswertungController>().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);
}
},
);
}
+21 -16
View File
@@ -143,6 +143,12 @@ void main() {
Future<void> add(String id) async { Future<void> add(String id) async {
await tester.tap(find.byType(DropdownButtonFormField<String>)); await tester.tap(find.byType(DropdownButtonFormField<String>));
await tester.pumpAndSettle(); 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.tap(find.text(controller.filamentLabel(id)).last);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
} }
@@ -201,7 +207,6 @@ void main() {
(tester) async { (tester) async {
await show(tester); await show(tester);
fill(); fill();
controller.gesamtpreisController.text = '12,50';
photos.result = png; photos.result = png;
await controller.pickPhoto(); await controller.pickPhoto();
await controller.saveBauteil(); await controller.saveBauteil();
@@ -209,19 +214,19 @@ void main() {
expect(remaining('b'), 960); expect(remaining('b'), 960);
final originalId = controller.bauteile.single.bauteilId; final originalId = controller.bauteile.single.bauteilId;
final originalDate = controller.bauteile.single.erstelltAm; final originalDate = controller.bauteile.single.erstelltAm;
expect(controller.bauteile.single.gesamtpreis, 12.5); expect(controller.bauteile.single.gesamtpreis, 3);
expect( expect(
storage storage
.getModelList(BauteilController.storageKey) .getModelList(BauteilController.storageKey)
.single['gesamtpreis'], .single['gesamtpreis'],
12.5, 3,
); );
expect(controller.gesamtpreisController.text, isEmpty); expect(controller.gesamtpreisController.text, isEmpty);
await edit(tester, controller.bauteile.single); await edit(tester, controller.bauteile.single);
expect(controller.foto.value, png); expect(controller.foto.value, png);
expect(controller.gesamtpreisController.text, '12,5'); expect(controller.gesamtpreisController.text, '3,00');
controller.gesamtpreisController.text = '18,90'; controller.aufschlagController.text = '20';
expect(controller.verfuegbarGramm('a'), 1000); expect(controller.verfuegbarGramm('a'), 1000);
controller.verbrauchController('a').text = '20'; controller.verbrauchController('a').text = '20';
controller.verbrauchController('b').text = '80'; controller.verbrauchController('b').text = '80';
@@ -242,7 +247,7 @@ void main() {
isTrue, isTrue,
); );
expect( expect(
controller.bauteile.every((item) => item.gesamtpreis == 18.9), controller.bauteile.every((item) => item.gesamtpreis == 2.4),
isTrue, isTrue,
); );
expect(remaining('a'), 960); expect(remaining('a'), 960);
@@ -255,7 +260,7 @@ void main() {
expect(remaining('a'), 980); expect(remaining('a'), 980);
final reloaded = Get.put(BauteilController(), tag: 'reload'); final reloaded = Get.put(BauteilController(), tag: 'reload');
expect(reloaded.bauteile.single.fotoBytes, png); 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); expect(reloaded.bauteile.single.filamente.last.verbrauchGramm, 80);
// Purchases retain the original weight; the persisted ledger is deducted. // Purchases retain the original weight; the persisted ledger is deducted.
expect( expect(
@@ -285,27 +290,27 @@ void main() {
); );
testWidgets( 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 { (tester) async {
await show(tester); await show(tester);
fill(); fill();
for (final value in ['-1', 'NaN', 'Infinity', 'abc']) { for (final value in ['-1', 'NaN', 'Infinity', 'abc']) {
controller.gesamtpreisController.text = value; controller.aufschlagController.text = value;
await controller.saveBauteil(); await controller.saveBauteil();
expect(storage.getModelList(BauteilController.storageKey), isEmpty); expect(storage.getModelList(BauteilController.storageKey), isEmpty);
} }
controller.gesamtpreisController.text = '0'; controller.aufschlagController.text = '0';
await controller.saveBauteil(); await controller.saveBauteil();
expect(controller.bauteile.single.gesamtpreis, 0); expect(controller.bauteile.single.gesamtpreis, 2);
await edit(tester, controller.bauteile.single); await edit(tester, controller.bauteile.single);
controller.gesamtpreisController.clear(); controller.aufschlagController.text = '25';
await controller.saveBauteil(); await controller.saveBauteil();
expect(controller.bauteile.single.gesamtpreis, isNull); expect(controller.bauteile.single.gesamtpreis, 2.5);
expect( expect(
storage storage
.getModelList(BauteilController.storageKey) .getModelList(BauteilController.storageKey)
.single['gesamtpreis'], .single['gesamtpreis'],
isNull, 2.5,
); );
}, },
); );
@@ -322,6 +327,7 @@ void main() {
expect(remaining('a'), 40); expect(remaining('a'), 40);
await edit(tester, first); await edit(tester, first);
expect(controller.verfuegbarGramm('a'), 940); expect(controller.verfuegbarGramm('a'), 940);
expect(controller.restgewichtGramm('a'), 40);
controller.gewichtController.text = '940'; controller.gewichtController.text = '940';
controller.verbrauchController('a').text = '950'; controller.verbrauchController('a').text = '950';
await controller.saveBauteil(); await controller.saveBauteil();
@@ -402,7 +408,6 @@ void main() {
(tester) async { (tester) async {
await show(tester, narrow: true); await show(tester, narrow: true);
fill(); fill();
controller.gesamtpreisController.text = '12,50';
photos.result = png; photos.result = png;
await controller.pickPhoto(); await controller.pickPhoto();
await controller.saveBauteil(); await controller.saveBauteil();
@@ -414,7 +419,7 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
find.text('Gesamtpreis ${DisplayFormats.currency(12.5)}'), find.text('Gesamtpreis ${DisplayFormats.currency(3)}'),
findsOneWidget, findsOneWidget,
); );
expect( expect(
+250
View File
@@ -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<GetStorageService>(storage);
controller = Get.put(BauteilController());
});
tearDown(() => Get.reset());
Future<void> 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<void> 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<TextField>(
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: <Map<String, dynamic>>[],
};
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,
);
}
});
}
+1
View File
@@ -57,6 +57,7 @@ void main() {
late ExportController controller; late ExportController controller;
Map<String, List<Map<String, dynamic>>> fixture() => { Map<String, List<Map<String, dynamic>>> fixture() => {
StorageKeys.abfaelle: [],
StorageKeys.filamente: [ StorageKeys.filamente: [
KaufFilamentModel( KaufFilamentModel(
filamentId: 'f1', filamentId: 'f1',
+206
View File
@@ -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<GetStorageService>(storage);
controller = Get.put(HomeController());
});
tearDown(() => Get.reset());
Future<void> 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<void> 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: <Map<String, dynamic>>[],
};
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);
},
);
}
+160
View File
@@ -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<GetStorageService>(storage);
controller = Get.put(BauteilController());
});
tearDown(() => Get.reset());
Future<void> 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<void> select(WidgetTester tester, String id) async {
await tester.ensureVisible(find.byType(DropdownButtonFormField<String>));
await tester.tap(find.byType(DropdownButtonFormField<String>));
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);
},
);
}
+2 -2
View File
@@ -73,7 +73,7 @@ void main() {
tester.getTopLeft(find.byType(FilamentCard)).dx, tester.getTopLeft(find.byType(FilamentCard)).dx,
greaterThan(tester.getTopRight(find.byType(FilamentForm)).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); expect(find.text('Testmarke'), findsOneWidget);
await tester.tap(find.byTooltip('Filament verwalten')); await tester.tap(find.byTooltip('Filament verwalten'));
@@ -114,7 +114,7 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Testmarke'), findsOneWidget); expect(find.text('Testmarke'), findsOneWidget);
expect(find.text('1000 g'), findsOneWidget); expect(find.text('Netto: 1000 g'), findsOneWidget);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });