add some new features
This commit is contained in:
@@ -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 '../helper/display_formats.dart';
|
||||
import '../helper/bauteil_preis_berechnung.dart';
|
||||
import '../helper/filament_bestand.dart';
|
||||
import '../helper/input_validators.dart';
|
||||
import '../helper/storage_keys.dart';
|
||||
@@ -15,12 +16,14 @@ import '../models/kauf_filament_model.dart';
|
||||
import '../services/bauteil_photo_service.dart';
|
||||
import '../services/get_storage_services.dart';
|
||||
import 'home_controller.dart';
|
||||
import 'abfall_controller.dart';
|
||||
|
||||
class BauteilController extends GetxController {
|
||||
BauteilController({BauteilPhotoService? photoService})
|
||||
: photoService = photoService ?? BauteilPhotoService();
|
||||
|
||||
static const storageKey = StorageKeys.bauteile;
|
||||
static const double standardAufschlagProzent = 50;
|
||||
final storage = Get.find<GetStorageService>();
|
||||
final BauteilPhotoService photoService;
|
||||
final formKey = GlobalKey<FormState>();
|
||||
@@ -32,6 +35,12 @@ class BauteilController extends GetxController {
|
||||
final gewichtController = TextEditingController();
|
||||
final abfallController = TextEditingController(text: '0');
|
||||
final gesamtpreisController = TextEditingController();
|
||||
final aufschlagController = TextEditingController(
|
||||
text: standardAufschlagProzent.toStringAsFixed(0),
|
||||
);
|
||||
final preisDetails = Rxn<BauteilPreis>();
|
||||
bool _preisPausiert = false;
|
||||
BauteilModel? _bearbeitetesBauteil;
|
||||
final _verbrauchControllers = <String, TextEditingController>{};
|
||||
final filamente = <KaufFilamentModel>[].obs;
|
||||
final profile = <AnwendungModel>[].obs;
|
||||
@@ -60,13 +69,20 @@ class BauteilController extends GetxController {
|
||||
);
|
||||
bauteile.assignAll(_loadBauteile());
|
||||
bauteile.sort((a, b) => b.erstelltAm.compareTo(a.erstelltAm));
|
||||
gewichtController.addListener(berechnePreis);
|
||||
abfallController.addListener(berechnePreis);
|
||||
aufschlagController.addListener(berechnePreis);
|
||||
}
|
||||
|
||||
List<BauteilModel> _loadBauteile() =>
|
||||
storage.getModelList(storageKey).map(BauteilModel.fromJson).toList();
|
||||
|
||||
TextEditingController verbrauchController(String id) =>
|
||||
_verbrauchControllers.putIfAbsent(id, () => TextEditingController());
|
||||
_verbrauchControllers.putIfAbsent(id, () {
|
||||
final field = TextEditingController();
|
||||
field.addListener(berechnePreis);
|
||||
return field;
|
||||
});
|
||||
|
||||
List<AnwendungModel> profilesFor(String filamentId) =>
|
||||
profile.where((item) => item.filamentId == filamentId).toList()
|
||||
@@ -78,6 +94,15 @@ class BauteilController extends GetxController {
|
||||
return '${item.marke} · ${item.material} · ${item.farbe}';
|
||||
}
|
||||
|
||||
double? restgewichtGramm(String id) {
|
||||
final startgewicht = filamente
|
||||
.firstWhereOrNull((item) => item.filamentId == id)
|
||||
?.gewichtGramm;
|
||||
if (startgewicht == null) return null;
|
||||
final verbrauch = FilamentBestand.verbrauch(bauteile);
|
||||
return startgewicht - (verbrauch[id] ?? 0);
|
||||
}
|
||||
|
||||
double? verfuegbarGramm(String id) {
|
||||
final start = filamente
|
||||
.firstWhereOrNull((item) => item.filamentId == id)
|
||||
@@ -97,6 +122,42 @@ class BauteilController extends GetxController {
|
||||
} else {
|
||||
selectedFilamente.remove(id);
|
||||
}
|
||||
berechnePreis();
|
||||
}
|
||||
|
||||
String? pruefeFilamentVorrat() {
|
||||
if (selectedFilamente.isEmpty) return null;
|
||||
final gewicht = InputValidators.number(gewichtController.text);
|
||||
final abfall = abfallController.text.trim().isEmpty
|
||||
? 0.0
|
||||
: InputValidators.number(abfallController.text);
|
||||
if (gewicht == null ||
|
||||
!gewicht.isFinite ||
|
||||
gewicht <= 0 ||
|
||||
abfall == null ||
|
||||
!abfall.isFinite ||
|
||||
abfall < 0) {
|
||||
return null;
|
||||
}
|
||||
final bedarf = gewicht + abfall;
|
||||
if (!bedarf.isFinite) return null;
|
||||
var verfuegbar = 0.0;
|
||||
for (final id in selectedFilamente.keys) {
|
||||
final rest = verfuegbarGramm(id);
|
||||
if (rest == null || !rest.isFinite) {
|
||||
return 'Die Restmenge einer ausgewählten Rolle ist unbekannt. Bitte das Startgewicht und den bisherigen Verbrauch prüfen.';
|
||||
}
|
||||
verfuegbar += rest > 0 ? rest : 0;
|
||||
}
|
||||
if (bedarf - verfuegbar <= 0.000001) return null;
|
||||
final rollen = selectedFilamente.length == 1
|
||||
? 'der ausgewählten Rolle'
|
||||
: 'der ausgewählten Rollen zusammen';
|
||||
return 'Die Restmenge $rollen reicht nicht aus.\n\n'
|
||||
'Benötigt (inklusive Abfall): ${DisplayFormats.number(bedarf)} g\n'
|
||||
'Verfügbar: ${DisplayFormats.number(verfuegbar)} g\n'
|
||||
'Es fehlen: ${DisplayFormats.number(bedarf - verfuegbar)} g\n\n'
|
||||
'Füge eine weitere Rolle hinzu oder wähle eine andere Rolle aus.';
|
||||
}
|
||||
|
||||
void selectProfile(String filamentId, String? anwendungId) {
|
||||
@@ -190,8 +251,60 @@ class BauteilController extends GetxController {
|
||||
if (Get.isRegistered<HomeController>()) {
|
||||
Get.find<HomeController>().loadVerbrauch();
|
||||
}
|
||||
if (Get.isRegistered<AbfallController>()) {
|
||||
Get.find<AbfallController>().loadAbfaelle();
|
||||
}
|
||||
}
|
||||
|
||||
void berechnePreis() {
|
||||
if (_preisPausiert || _closed) return;
|
||||
final gewicht = InputValidators.number(gewichtController.text);
|
||||
final abfall = InputValidators.number(abfallController.text);
|
||||
final aufschlag = InputValidators.number(aufschlagController.text);
|
||||
BauteilPreis? preis;
|
||||
if (gewicht != null && abfall != null && aufschlag != null) {
|
||||
final entry = BauteilModel(
|
||||
bauteilId: editingId.value ?? '',
|
||||
erstelltAm: DateTime.now(),
|
||||
name: '',
|
||||
verwendung: '',
|
||||
druckdauerMinuten: 1,
|
||||
gewichtGramm: gewicht,
|
||||
abfallGramm: abfall,
|
||||
filamente: selectedFilamente.keys
|
||||
.map(
|
||||
(id) => BauteilFilamentModel(
|
||||
filamentId: id,
|
||||
verbrauchGramm:
|
||||
InputValidators.number(_verbrauchControllers[id]?.text) ??
|
||||
0,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
preis = BauteilPreisBerechnung.berechne(
|
||||
entry,
|
||||
filamente,
|
||||
aufschlag,
|
||||
previous: _bearbeitetesBauteil,
|
||||
);
|
||||
}
|
||||
preisDetails.value = preis;
|
||||
gesamtpreisController.text = preis == null
|
||||
? ''
|
||||
: preis.gesamtpreis.toStringAsFixed(2).replaceAll('.', ',');
|
||||
}
|
||||
|
||||
BauteilPreis? _kalkulation(
|
||||
BauteilModel entry,
|
||||
double aufschlag, {
|
||||
BauteilModel? previous,
|
||||
}) => BauteilPreisBerechnung.berechne(
|
||||
entry,
|
||||
storage.getModelList(StorageKeys.filamente).map(KaufFilamentModel.fromJson),
|
||||
aufschlag,
|
||||
previous: previous,
|
||||
);
|
||||
Future<void> saveBauteil() async {
|
||||
if (isSaving.value || isPickingPhoto.value) return;
|
||||
message.value = null;
|
||||
@@ -225,7 +338,7 @@ class BauteilController extends GetxController {
|
||||
druckdauerMinuten: duration,
|
||||
gewichtGramm: InputValidators.number(gewichtController.text)!,
|
||||
abfallGramm: InputValidators.number(abfallController.text)!,
|
||||
gesamtpreis: InputValidators.number(gesamtpreisController.text),
|
||||
aufschlagProzent: InputValidators.number(aufschlagController.text),
|
||||
fotoBase64: foto.value == null ? null : base64Encode(foto.value!),
|
||||
filamente: selectedFilamente.entries
|
||||
.map(
|
||||
@@ -246,13 +359,32 @@ class BauteilController extends GetxController {
|
||||
_error(error);
|
||||
return;
|
||||
}
|
||||
final preis = _kalkulation(
|
||||
entry,
|
||||
entry.aufschlagProzent!,
|
||||
previous: previous,
|
||||
);
|
||||
if (preis == null) {
|
||||
_error(
|
||||
'Preis nicht berechenbar. Bitte Verbrauch, Einkaufspreis und Startgewicht der Rollen sowie den Aufschlag prüfen.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final gespeichert = entry.copyWith(
|
||||
bauteilId: entry.bauteilId,
|
||||
erstelltAm: entry.erstelltAm,
|
||||
abfallKosten: preis.abfallKosten,
|
||||
materialKosten: preis.materialKosten,
|
||||
aufschlagProzent: preis.aufschlagProzent,
|
||||
gesamtpreis: preis.gesamtpreis,
|
||||
);
|
||||
await _commit([
|
||||
...existing.where((item) => item.bauteilId != entry.bauteilId),
|
||||
entry,
|
||||
gespeichert,
|
||||
]);
|
||||
resetForm();
|
||||
hasError.value = false;
|
||||
message.value = 'Bauteil und Filamentverbrauch gespeichert.';
|
||||
message.value = 'Bauteil, Filamentverbrauch und Abfall gespeichert.';
|
||||
} catch (_) {
|
||||
_error('Speichern fehlgeschlagen. Bitte erneut versuchen.');
|
||||
} finally {
|
||||
@@ -263,6 +395,8 @@ class BauteilController extends GetxController {
|
||||
Future<void> editBauteil(BauteilModel entry) async {
|
||||
if (isSaving.value || isPickingPhoto.value) return;
|
||||
resetForm();
|
||||
_preisPausiert = true;
|
||||
_bearbeitetesBauteil = entry;
|
||||
message.value = null;
|
||||
nameController.text = entry.name;
|
||||
verwendungController.text = entry.verwendung;
|
||||
@@ -279,7 +413,13 @@ class BauteilController extends GetxController {
|
||||
: '';
|
||||
selectedFilamente[item.filamentId] = item.anwendungId;
|
||||
}
|
||||
aufschlagController.text =
|
||||
(entry.aufschlagProzent ?? standardAufschlagProzent)
|
||||
.toString()
|
||||
.replaceAll('.', ',');
|
||||
editingId.value = entry.bauteilId;
|
||||
_preisPausiert = false;
|
||||
berechnePreis();
|
||||
await scrollToForm();
|
||||
}
|
||||
|
||||
@@ -298,7 +438,27 @@ class BauteilController extends GetxController {
|
||||
await scrollToForm();
|
||||
return;
|
||||
}
|
||||
await _commit([...existing, copy]);
|
||||
final preis = _kalkulation(
|
||||
copy,
|
||||
copy.aufschlagProzent ?? standardAufschlagProzent,
|
||||
);
|
||||
if (preis == null) {
|
||||
_error(
|
||||
'Abfallkosten nicht berechenbar. Bitte Einkaufspreis und Startgewicht der verwendeten Rollen prüfen.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
await _commit([
|
||||
...existing,
|
||||
copy.copyWith(
|
||||
bauteilId: copy.bauteilId,
|
||||
erstelltAm: copy.erstelltAm,
|
||||
abfallKosten: preis.abfallKosten,
|
||||
materialKosten: preis.materialKosten,
|
||||
aufschlagProzent: preis.aufschlagProzent,
|
||||
gesamtpreis: preis.gesamtpreis,
|
||||
),
|
||||
]);
|
||||
hasError.value = false;
|
||||
message.value = 'Bauteil dupliziert und Verbrauch erneut gebucht.';
|
||||
} catch (_) {
|
||||
@@ -338,6 +498,8 @@ class BauteilController extends GetxController {
|
||||
}
|
||||
|
||||
void resetForm() {
|
||||
_preisPausiert = true;
|
||||
_bearbeitetesBauteil = null;
|
||||
formKey.currentState?.reset();
|
||||
editingId.value = null;
|
||||
nameController.clear();
|
||||
@@ -352,6 +514,9 @@ class BauteilController extends GetxController {
|
||||
controller.clear();
|
||||
}
|
||||
foto.value = null;
|
||||
aufschlagController.text = standardAufschlagProzent.toStringAsFixed(0);
|
||||
preisDetails.value = null;
|
||||
_preisPausiert = false;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -365,6 +530,7 @@ class BauteilController extends GetxController {
|
||||
gewichtController.dispose();
|
||||
abfallController.dispose();
|
||||
gesamtpreisController.dispose();
|
||||
aufschlagController.dispose();
|
||||
for (final controller in _verbrauchControllers.values) {
|
||||
controller.dispose();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../models/backup_model.dart';
|
||||
import '../services/backup_file_service.dart';
|
||||
import '../services/get_storage_services.dart';
|
||||
import 'home_controller.dart';
|
||||
import 'abfall_controller.dart';
|
||||
|
||||
class ExportController extends GetxController {
|
||||
ExportController({BackupFileService? fileService})
|
||||
@@ -76,6 +77,9 @@ class ExportController extends GetxController {
|
||||
home.loadMaterialien();
|
||||
home.resetForm();
|
||||
}
|
||||
if (Get.isRegistered<AbfallController>()) {
|
||||
Get.find<AbfallController>().loadAbfaelle();
|
||||
}
|
||||
pendingBackup.value = null;
|
||||
hasError.value = false;
|
||||
message.value = 'Alle Daten wurden importiert.';
|
||||
|
||||
@@ -54,6 +54,7 @@ class HomeController extends GetxController {
|
||||
final preisController = TextEditingController();
|
||||
final markeController = TextEditingController();
|
||||
final gewichtController = TextEditingController();
|
||||
final bruttoGewichtController = TextEditingController();
|
||||
final materialController = TextEditingController(text: 'PLA');
|
||||
|
||||
final kaufdatum = DateTime.now().obs;
|
||||
@@ -163,6 +164,18 @@ class HomeController extends GetxController {
|
||||
);
|
||||
}
|
||||
|
||||
String? validateBruttogewicht(String? value) {
|
||||
if ((value ?? '').trim().isEmpty) return null;
|
||||
final error = InputValidators.positive(value);
|
||||
if (error != null) return error;
|
||||
final brutto = InputValidators.number(value)!;
|
||||
final netto = InputValidators.number(gewichtController.text);
|
||||
if (netto != null && netto.isFinite && brutto < netto) {
|
||||
return 'Bruttogewicht muss mindestens dem Nettogewicht entsprechen.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> saveFilament() async {
|
||||
if (!formKey.currentState!.validate()) return;
|
||||
|
||||
@@ -186,7 +199,7 @@ class HomeController extends GetxController {
|
||||
gewicht < bereitsVerbraucht) {
|
||||
Get.snackbar(
|
||||
'Ungültiges Gewicht',
|
||||
'Das Startgewicht muss positiv sein und mindestens dem bereits gebuchten Verbrauch entsprechen.',
|
||||
'Das Nettostartgewicht muss positiv sein und mindestens dem bereits gebuchten Verbrauch entsprechen.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -202,6 +215,7 @@ class HomeController extends GetxController {
|
||||
preis: preis,
|
||||
farbe: farbeController.text.trim(),
|
||||
gewicht: gewicht.toString(),
|
||||
bruttoGewichtGramm: InputValidators.number(bruttoGewichtController.text),
|
||||
);
|
||||
|
||||
if (editingId.value == null) {
|
||||
@@ -231,6 +245,8 @@ class HomeController extends GetxController {
|
||||
haendlerController.text = filament.haendler;
|
||||
selectMaterial(filament.material);
|
||||
preisController.text = filament.preis.toStringAsFixed(2);
|
||||
bruttoGewichtController.text =
|
||||
filament.bruttoGewichtGramm?.toString().replaceAll('.', ',') ?? '';
|
||||
farbeController.text = filament.farbe;
|
||||
gewichtController.text = filament.gewichtGramm == null
|
||||
? filament.gewicht
|
||||
@@ -272,6 +288,7 @@ class HomeController extends GetxController {
|
||||
selectMaterial(materialien.firstOrNull ?? '');
|
||||
materialMessage.value = null;
|
||||
gewichtController.clear();
|
||||
bruttoGewichtController.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -283,6 +300,7 @@ class HomeController extends GetxController {
|
||||
farbeController.dispose();
|
||||
preisController.dispose();
|
||||
gewichtController.dispose();
|
||||
bruttoGewichtController.dispose();
|
||||
materialController.dispose();
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
@@ -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 '../controller/anwendung_controller.dart';
|
||||
import '../controller/bauteil_controller.dart';
|
||||
import '../controller/bauteil_auswertung_controller.dart';
|
||||
import '../controller/export_controller.dart';
|
||||
import '../pages/export_view.dart';
|
||||
import '../pages/anwendung_view.dart';
|
||||
import '../pages/bauteil_view.dart';
|
||||
import '../pages/bauteil_auswertung_view.dart';
|
||||
import '../pages/home_view.dart';
|
||||
import 'sample_bindings.dart';
|
||||
|
||||
class SampleRouts {
|
||||
static final sampleBindings = SampleBindings();
|
||||
static List<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(
|
||||
name: ExportView.routeName,
|
||||
page: () => const ExportView(),
|
||||
|
||||
@@ -3,4 +3,5 @@ class StorageKeys {
|
||||
static const materialien = 'filament_materialien';
|
||||
static const anwendungen = 'filament_anwendungen';
|
||||
static const bauteile = 'gedruckte_bauteile';
|
||||
static const abfaelle = 'abfall_eintraege';
|
||||
}
|
||||
|
||||
@@ -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 'anwendung_model.dart';
|
||||
import 'abfall_model.dart';
|
||||
import 'bauteil_model.dart';
|
||||
import 'kauf_filament_model.dart';
|
||||
|
||||
@@ -11,6 +12,7 @@ class BackupModel {
|
||||
StorageKeys.anwendungen,
|
||||
StorageKeys.bauteile,
|
||||
StorageKeys.materialien,
|
||||
StorageKeys.abfaelle,
|
||||
];
|
||||
final Map<String, List<Map<String, dynamic>>> data;
|
||||
|
||||
@@ -34,9 +36,12 @@ class BackupModel {
|
||||
final raw = json['daten'] as Map;
|
||||
final data = <String, List<Map<String, dynamic>>>{
|
||||
for (final key in keys)
|
||||
key: (raw[key] as List)
|
||||
.map((item) => Map<String, dynamic>.from(item as Map))
|
||||
.toList(),
|
||||
key:
|
||||
((key == StorageKeys.abfaelle && !raw.containsKey(key))
|
||||
? <dynamic>[]
|
||||
: raw[key] as List)
|
||||
.map((item) => Map<String, dynamic>.from(item as Map))
|
||||
.toList(),
|
||||
};
|
||||
final filamentIds = <String>{};
|
||||
for (final json in data[StorageKeys.filamente]!) {
|
||||
@@ -45,7 +50,10 @@ class BackupModel {
|
||||
!filamentIds.add(item.filamentId) ||
|
||||
item.material.trim().isEmpty ||
|
||||
!_positive(item.gewichtGramm) ||
|
||||
!_nonNegative(item.preis)) {
|
||||
!_nonNegative(item.preis) ||
|
||||
(item.bruttoGewichtGramm != null &&
|
||||
(!_positive(item.bruttoGewichtGramm) ||
|
||||
item.bruttoGewichtGramm! < item.gewichtGramm!))) {
|
||||
throw const FormatException();
|
||||
}
|
||||
}
|
||||
@@ -71,6 +79,11 @@ class BackupModel {
|
||||
item.druckdauerMinuten <= 0 ||
|
||||
!_positive(item.gewichtGramm) ||
|
||||
!_nonNegative(item.abfallGramm) ||
|
||||
(item.abfallKosten != null && !_nonNegative(item.abfallKosten!)) ||
|
||||
(item.materialKosten != null &&
|
||||
!_nonNegative(item.materialKosten!)) ||
|
||||
(item.aufschlagProzent != null &&
|
||||
!_nonNegative(item.aufschlagProzent!)) ||
|
||||
(item.gesamtpreis != null && !_nonNegative(item.gesamtpreis!)) ||
|
||||
item.filamente.isEmpty ||
|
||||
item.filamente.any(
|
||||
@@ -82,6 +95,16 @@ class BackupModel {
|
||||
}
|
||||
if (item.fotoBase64 != null) base64Decode(item.fotoBase64!);
|
||||
}
|
||||
final wasteIds = <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]!;
|
||||
if (materials.isNotEmpty) {
|
||||
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 abfallGramm;
|
||||
final double? gesamtpreis;
|
||||
final double? abfallKosten;
|
||||
final double? materialKosten;
|
||||
final double? aufschlagProzent;
|
||||
final List<BauteilFilamentModel> filamente;
|
||||
final String? fotoBase64;
|
||||
|
||||
@@ -48,6 +51,9 @@ class BauteilModel {
|
||||
required this.abfallGramm,
|
||||
required this.filamente,
|
||||
this.gesamtpreis,
|
||||
this.abfallKosten,
|
||||
this.materialKosten,
|
||||
this.aufschlagProzent,
|
||||
this.fotoBase64,
|
||||
});
|
||||
|
||||
@@ -65,6 +71,10 @@ class BauteilModel {
|
||||
BauteilModel copyWith({
|
||||
required String bauteilId,
|
||||
required DateTime erstelltAm,
|
||||
double? abfallKosten,
|
||||
double? materialKosten,
|
||||
double? aufschlagProzent,
|
||||
double? gesamtpreis,
|
||||
}) => BauteilModel(
|
||||
bauteilId: bauteilId,
|
||||
erstelltAm: erstelltAm,
|
||||
@@ -73,7 +83,10 @@ class BauteilModel {
|
||||
druckdauerMinuten: druckdauerMinuten,
|
||||
gewichtGramm: gewichtGramm,
|
||||
abfallGramm: abfallGramm,
|
||||
gesamtpreis: gesamtpreis,
|
||||
gesamtpreis: gesamtpreis ?? this.gesamtpreis,
|
||||
abfallKosten: abfallKosten ?? this.abfallKosten,
|
||||
materialKosten: materialKosten ?? this.materialKosten,
|
||||
aufschlagProzent: aufschlagProzent ?? this.aufschlagProzent,
|
||||
filamente: filamente,
|
||||
fotoBase64: fotoBase64,
|
||||
);
|
||||
@@ -87,6 +100,9 @@ class BauteilModel {
|
||||
'gewichtGramm': gewichtGramm,
|
||||
'abfallGramm': abfallGramm,
|
||||
'gesamtpreis': gesamtpreis,
|
||||
'abfallKosten': abfallKosten,
|
||||
'materialKosten': materialKosten,
|
||||
'aufschlagProzent': aufschlagProzent,
|
||||
'filamente': filamente.map((item) => item.toJson()).toList(),
|
||||
'fotoBase64': fotoBase64,
|
||||
};
|
||||
@@ -100,6 +116,9 @@ class BauteilModel {
|
||||
gewichtGramm: (json['gewichtGramm'] as num).toDouble(),
|
||||
abfallGramm: (json['abfallGramm'] as num).toDouble(),
|
||||
gesamtpreis: (json['gesamtpreis'] as num?)?.toDouble(),
|
||||
abfallKosten: (json['abfallKosten'] as num?)?.toDouble(),
|
||||
materialKosten: (json['materialKosten'] as num?)?.toDouble(),
|
||||
aufschlagProzent: (json['aufschlagProzent'] as num?)?.toDouble(),
|
||||
fotoBase64: json['fotoBase64'] as String?,
|
||||
filamente: (json['filamente'] as List)
|
||||
.map(
|
||||
|
||||
@@ -6,7 +6,9 @@ class KaufFilamentModel {
|
||||
final String material;
|
||||
final double preis;
|
||||
final String farbe;
|
||||
// Original net material weight, without the spool.
|
||||
final String gewicht;
|
||||
final double? bruttoGewichtGramm;
|
||||
|
||||
const KaufFilamentModel({
|
||||
required this.filamentId,
|
||||
@@ -17,9 +19,10 @@ class KaufFilamentModel {
|
||||
required this.preis,
|
||||
required this.farbe,
|
||||
required this.gewicht,
|
||||
this.bruttoGewichtGramm,
|
||||
});
|
||||
|
||||
/// Supports old purchases saved as "1kg" as well as gram values.
|
||||
/// Original net material weight. Supports legacy "1kg" and gram values.
|
||||
double? get gewichtGramm {
|
||||
final match = RegExp(
|
||||
r'^\s*(\d+(?:[.,]\d+)?)\s*(kg|g)?\s*$',
|
||||
@@ -41,6 +44,7 @@ class KaufFilamentModel {
|
||||
'preis': preis,
|
||||
'farbe': farbe,
|
||||
'gewicht': gewicht,
|
||||
'bruttoGewichtGramm': bruttoGewichtGramm,
|
||||
};
|
||||
|
||||
factory KaufFilamentModel.fromJson(Map<String, dynamic> json) {
|
||||
@@ -53,6 +57,7 @@ class KaufFilamentModel {
|
||||
preis: (json['preis'] as num).toDouble(),
|
||||
farbe: json['farbe'] as String,
|
||||
gewicht: json['gewicht'] ?? '1000',
|
||||
bruttoGewichtGramm: (json['bruttoGewichtGramm'] as num?)?.toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,6 +70,7 @@ class KaufFilamentModel {
|
||||
double? preis,
|
||||
String? farbe,
|
||||
String? gewicht,
|
||||
double? bruttoGewichtGramm,
|
||||
}) {
|
||||
return KaufFilamentModel(
|
||||
filamentId: filamentId ?? this.filamentId,
|
||||
@@ -75,6 +81,7 @@ class KaufFilamentModel {
|
||||
preis: preis ?? this.preis,
|
||||
farbe: farbe ?? this.farbe,
|
||||
gewicht: gewicht ?? this.gewicht,
|
||||
bruttoGewichtGramm: bruttoGewichtGramm ?? this.bruttoGewichtGramm,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_form.dart';
|
||||
import '../widgets/entry_panel.dart';
|
||||
import 'bauteil_auswertung_view.dart';
|
||||
|
||||
class BauteilView extends GetView<BauteilController> {
|
||||
static const routeName = '/bauteile-view';
|
||||
@@ -15,7 +16,21 @@ class BauteilView extends GetView<BauteilController> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Gedruckte Bauteile')),
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'Gedruckte Bauteile',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Bauteile auswerten',
|
||||
icon: const Icon(Icons.bar_chart_outlined),
|
||||
onPressed: () => Get.toNamed(BauteilAuswertungView.routeName),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: Align(
|
||||
|
||||
@@ -27,7 +27,7 @@ class ExportView extends GetView<ExportController> {
|
||||
children: [
|
||||
EntryPanel(
|
||||
title: 'Daten exportieren',
|
||||
subtitle: 'Sichere Filamente, Verarbeitungsprofile, Bauteile inklusive Fotos und Verbrauch sowie deine Materialarten in einer JSON-Datei.\n\nSpeichere die Datei außerhalb der App, zum Beispiel in Dokumente oder in deiner Cloud. So kannst du sie nach einer Neuinstallation wieder importieren.',
|
||||
subtitle: 'Sichere Filamente, Verarbeitungsprofile, Bauteile inklusive Fotos und Verbrauch sowie deine Materialarten und Abfalleinträge in einer JSON-Datei.\n\nSpeichere die Datei außerhalb der App, zum Beispiel in Dokumente oder in deiner Cloud. So kannst du sie nach einer Neuinstallation wieder importieren.',
|
||||
child: FilledButton.icon(
|
||||
onPressed: busy ? null : controller.exportData,
|
||||
icon: const Icon(Icons.file_download_outlined),
|
||||
@@ -63,6 +63,9 @@ class ExportView extends GetView<ExportController> {
|
||||
? 'Materialarten: Standardliste'
|
||||
: '${(backup.data[StorageKeys.materialien]!.single['werte'] as List).length} Materialarten',
|
||||
),
|
||||
Text(
|
||||
'${backup.data[StorageKeys.abfaelle]!.length} Abfalleinträge',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: busy
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../widgets/filament_inventory.dart';
|
||||
import '../widgets/app_version.dart';
|
||||
import 'anwendung_view.dart';
|
||||
import 'bauteil_view.dart';
|
||||
import 'abfall_view.dart';
|
||||
import 'export_view.dart';
|
||||
|
||||
class HomeView extends GetView<HomeController> {
|
||||
@@ -26,6 +27,11 @@ class HomeView extends GetView<HomeController> {
|
||||
icon: const Icon(Icons.view_in_ar_outlined),
|
||||
onPressed: () => Get.toNamed(BauteilView.routeName),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Abfall verwalten',
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: () => Get.toNamed(AbfallView.routeName),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
title: LayoutBuilder(
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
_detail('Abfall ${DisplayFormats.number(bauteil.abfallGramm)} g'),
|
||||
if (bauteil.materialKosten != null)
|
||||
_detail(
|
||||
'Filamentkosten ${DisplayFormats.currency(bauteil.materialKosten!)}',
|
||||
),
|
||||
if (bauteil.aufschlagProzent != null)
|
||||
_detail(
|
||||
'Aufschlag ${DisplayFormats.number(bauteil.aufschlagProzent!)} %',
|
||||
),
|
||||
if (bauteil.abfallGramm > 0 && bauteil.abfallKosten != null)
|
||||
_detail(
|
||||
'Abfallkosten ${DisplayFormats.currency(bauteil.abfallKosten!)}',
|
||||
),
|
||||
_detail(
|
||||
'Gesamt ${DisplayFormats.number(bauteil.gesamtverbrauchGramm)} g',
|
||||
),
|
||||
|
||||
@@ -11,6 +11,29 @@ class BauteilFilamentAuswahl extends StatelessWidget {
|
||||
const BauteilFilamentAuswahl({super.key, required this.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
|
||||
Widget build(BuildContext context) => Obx(() {
|
||||
final ids = controller.selectedFilamente.keys.toList();
|
||||
@@ -36,21 +59,41 @@ class BauteilFilamentAuswahl extends StatelessWidget {
|
||||
.toString(),
|
||||
),
|
||||
isExpanded: true,
|
||||
itemHeight: 64,
|
||||
decoration: const InputDecoration(labelText: 'Filament hinzufügen'),
|
||||
hint: const Text('Filament auswählen'),
|
||||
items: availableFilamente
|
||||
.map(
|
||||
(item) => DropdownMenuItem(
|
||||
value: item.filamentId,
|
||||
child: Text(
|
||||
controller.filamentLabel(item.filamentId),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
controller.filamentLabel(item.filamentId),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
controller.restgewichtGramm(item.filamentId) == null
|
||||
? 'Restgewicht unbekannt'
|
||||
: 'Restgewicht: ${DisplayFormats.number(controller.restgewichtGramm(item.filamentId)!)} g',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.teal,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (id) {
|
||||
if (id != null) controller.toggleFilament(id, true);
|
||||
if (id != null) _waehleFilament(context, id);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../helper/input_validators.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import 'bauteil_filament_auswahl.dart';
|
||||
import 'bauteil_photo_picker.dart';
|
||||
import 'bauteil_preis_uebersicht.dart';
|
||||
import 'entry_panel.dart';
|
||||
|
||||
class BauteilForm extends StatelessWidget {
|
||||
@@ -65,14 +66,18 @@ class BauteilForm extends StatelessWidget {
|
||||
validator: InputValidators.nonNegative,
|
||||
),
|
||||
EntryNumberField(
|
||||
controller: controller.gesamtpreisController,
|
||||
label: 'Gesamtpreis (optional)',
|
||||
unit: '€',
|
||||
validator: (value) =>
|
||||
InputValidators.nonNegative(value, optional: true),
|
||||
controller: controller.aufschlagController,
|
||||
label: 'Aufschlag',
|
||||
unit: '%',
|
||||
validator: InputValidators.nonNegative,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Abfall wird automatisch in die Abfallverwaltung übernommen. Seine Kosten ergeben sich aus Einkaufspreis und Startgewicht der verwendeten Rollen. Bei mehreren Filamenten wird der Abfall nach ihrem Verbrauch aufgeteilt.',
|
||||
style: TextStyle(color: AppTheme.muted, height: 1.5),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
BauteilPhotoPicker(controller: controller),
|
||||
const SizedBox(height: 24),
|
||||
@@ -87,7 +92,9 @@ class BauteilForm extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
BauteilFilamentAuswahl(controller: controller),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 16),
|
||||
BauteilPreisUebersicht(controller: controller),
|
||||
const SizedBox(height: 16),
|
||||
Obx(
|
||||
() => FilledButton.icon(
|
||||
onPressed:
|
||||
|
||||
@@ -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.unit,
|
||||
required this.validator,
|
||||
this.onChanged,
|
||||
});
|
||||
final TextEditingController controller;
|
||||
final String label;
|
||||
final String unit;
|
||||
final FormFieldValidator<String> validator;
|
||||
final ValueChanged<String>? onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => TextFormField(
|
||||
@@ -87,6 +89,7 @@ class EntryNumberField extends StatelessWidget {
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: InputDecoration(labelText: label, suffixText: unit),
|
||||
validator: validator,
|
||||
onChanged: onChanged,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -176,11 +179,13 @@ class SavedEntryCard extends StatelessWidget {
|
||||
required this.subtitle,
|
||||
required this.details,
|
||||
this.notes = '',
|
||||
this.trailing,
|
||||
});
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final List<String> details;
|
||||
final String notes;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Card(
|
||||
@@ -194,9 +199,20 @@ class SavedEntryCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 18),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[const SizedBox(width: 8), trailing!],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
|
||||
@@ -137,6 +137,11 @@ class FilamentCard extends StatelessWidget {
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(
|
||||
value: (rest! / start).clamp(0.0, 1.0),
|
||||
color: rest < 100
|
||||
? Colors.red
|
||||
: rest <= start / 2
|
||||
? Colors.blue
|
||||
: null,
|
||||
minHeight: 6,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
backgroundColor: AppTheme.inputSurface,
|
||||
@@ -156,7 +161,16 @@ class FilamentCard extends StatelessWidget {
|
||||
label: filament.material,
|
||||
icon: Icons.layers_outlined,
|
||||
),
|
||||
_DetailPill(label: weight, icon: Icons.scale_outlined),
|
||||
_DetailPill(
|
||||
label: 'Netto: $weight',
|
||||
icon: Icons.scale_outlined,
|
||||
),
|
||||
if (filament.bruttoGewichtGramm != null)
|
||||
_DetailPill(
|
||||
label:
|
||||
'Brutto mit Spule: ${DisplayFormats.number(filament.bruttoGewichtGramm!)} g',
|
||||
icon: Icons.scale_outlined,
|
||||
),
|
||||
if (hatProfil)
|
||||
const _DetailPill(
|
||||
label: 'Profil erstellt',
|
||||
|
||||
@@ -145,57 +145,44 @@ class FilamentForm extends StatelessWidget {
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final fields = [
|
||||
TextFormField(
|
||||
controller: controller.preisController,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Preis',
|
||||
suffixText: '€',
|
||||
),
|
||||
validator: (value) {
|
||||
final price = double.tryParse(
|
||||
(value ?? '').replaceAll(',', '.'),
|
||||
);
|
||||
return price == null || !price.isFinite || price < 0
|
||||
? 'Preis ungültig'
|
||||
: null;
|
||||
},
|
||||
),
|
||||
TextFormField(
|
||||
controller: controller.gewichtController,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Startgewicht',
|
||||
suffixText: 'g',
|
||||
),
|
||||
validator: InputValidators.positive,
|
||||
),
|
||||
];
|
||||
if (constraints.maxWidth < 280) {
|
||||
return Column(
|
||||
children: [
|
||||
fields[0],
|
||||
const SizedBox(height: 16),
|
||||
fields[1],
|
||||
],
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: fields[0]),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: fields[1]),
|
||||
],
|
||||
);
|
||||
},
|
||||
TextFormField(
|
||||
controller: controller.preisController,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Preis',
|
||||
suffixText: '€',
|
||||
),
|
||||
validator: InputValidators.nonNegative,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: controller.gewichtController,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nettogewicht',
|
||||
suffixText: 'g',
|
||||
helperText: 'Startgewicht ohne Spule, nur Material.',
|
||||
helperMaxLines: 2,
|
||||
),
|
||||
validator: InputValidators.positive,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: controller.bruttoGewichtController,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Bruttogewicht (optional)',
|
||||
suffixText: 'g',
|
||||
helperText: 'Material und Spule zusammen.',
|
||||
helperMaxLines: 2,
|
||||
),
|
||||
validator: controller.validateBruttogewicht,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Obx(
|
||||
|
||||
Reference in New Issue
Block a user