add some new features
This commit is contained in:
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"java.configuration.updateBuildConfiguration": "interactive"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.';
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,7 +36,10 @@ 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:
|
||||||
|
((key == StorageKeys.abfaelle && !raw.containsKey(key))
|
||||||
|
? <dynamic>[]
|
||||||
|
: raw[key] as List)
|
||||||
.map((item) => Map<String, dynamic>.from(item as Map))
|
.map((item) => Map<String, dynamic>.from(item as Map))
|
||||||
.toList(),
|
.toList(),
|
||||||
};
|
};
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
controller.filamentLabel(item.filamentId),
|
controller.filamentLabel(item.filamentId),
|
||||||
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
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),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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: '€',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
title,
|
title,
|
||||||
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 18),
|
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(
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -145,9 +145,6 @@ class FilamentForm extends StatelessWidget {
|
|||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final fields = [
|
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: controller.preisController,
|
controller: controller.preisController,
|
||||||
keyboardType: const TextInputType.numberWithOptions(
|
keyboardType: const TextInputType.numberWithOptions(
|
||||||
@@ -157,45 +154,35 @@ class FilamentForm extends StatelessWidget {
|
|||||||
labelText: 'Preis',
|
labelText: 'Preis',
|
||||||
suffixText: '€',
|
suffixText: '€',
|
||||||
),
|
),
|
||||||
validator: (value) {
|
validator: InputValidators.nonNegative,
|
||||||
final price = double.tryParse(
|
|
||||||
(value ?? '').replaceAll(',', '.'),
|
|
||||||
);
|
|
||||||
return price == null || !price.isFinite || price < 0
|
|
||||||
? 'Preis ungültig'
|
|
||||||
: null;
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: controller.gewichtController,
|
controller: controller.gewichtController,
|
||||||
keyboardType: const TextInputType.numberWithOptions(
|
keyboardType: const TextInputType.numberWithOptions(
|
||||||
decimal: true,
|
decimal: true,
|
||||||
),
|
),
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Startgewicht',
|
labelText: 'Nettogewicht',
|
||||||
suffixText: 'g',
|
suffixText: 'g',
|
||||||
|
helperText: 'Startgewicht ohne Spule, nur Material.',
|
||||||
|
helperMaxLines: 2,
|
||||||
),
|
),
|
||||||
validator: InputValidators.positive,
|
validator: InputValidators.positive,
|
||||||
),
|
),
|
||||||
];
|
|
||||||
if (constraints.maxWidth < 280) {
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
fields[0],
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
fields[1],
|
TextFormField(
|
||||||
],
|
controller: controller.bruttoGewichtController,
|
||||||
);
|
keyboardType: const TextInputType.numberWithOptions(
|
||||||
}
|
decimal: true,
|
||||||
return Row(
|
),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
decoration: const InputDecoration(
|
||||||
children: [
|
labelText: 'Bruttogewicht (optional)',
|
||||||
Expanded(child: fields[0]),
|
suffixText: 'g',
|
||||||
const SizedBox(width: 12),
|
helperText: 'Material und Spule zusammen.',
|
||||||
Expanded(child: fields[1]),
|
helperMaxLines: 2,
|
||||||
],
|
),
|
||||||
);
|
validator: controller.validateBruttogewicht,
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Obx(
|
Obx(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user