add some new features

This commit is contained in:
2026-10-29 15:03:48 +01:00
parent c876405a8f
commit 20012db615
39 changed files with 2908 additions and 97 deletions
+212
View File
@@ -0,0 +1,212 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:uuid/uuid.dart';
import '../helper/input_validators.dart';
import '../helper/abfall_berechnung.dart';
import '../models/bauteil_model.dart';
import '../models/kauf_filament_model.dart';
import '../helper/storage_keys.dart';
import '../models/abfall_model.dart';
import '../services/get_storage_services.dart';
class AbfallController extends GetxController {
final storage = Get.find<GetStorageService>();
final abfaelle = <AbfallModel>[].obs;
final datumZeit = DateTime.now().obs;
final editingId = RxnString();
final isSaving = false.obs;
final message = RxnString();
final hasError = false.obs;
final formKey = GlobalKey<FormState>();
final scrollController = ScrollController();
final kilopreisController = TextEditingController();
final gewichtController = TextEditingController();
final kostenController = TextEditingController();
double get summeAbfall =>
abfaelle.fold(0, (sum, entry) => sum + entry.gewichtGramm);
double get summeKosten =>
abfaelle.fold(0, (sum, entry) => sum + entry.kosten);
@override
void onInit() {
super.onInit();
loadAbfaelle();
}
void loadAbfaelle() {
final entries = storage
.getModelList(StorageKeys.abfaelle)
.map(AbfallModel.fromJson)
.toList();
final filamente = storage
.getModelList(StorageKeys.filamente)
.map(KaufFilamentModel.fromJson)
.toList();
for (final bauteil
in storage
.getModelList(StorageKeys.bauteile)
.map(BauteilModel.fromJson)) {
if (bauteil.abfallGramm <= 0) continue;
final kosten =
bauteil.abfallKosten ?? AbfallBerechnung.kosten(bauteil, filamente);
entries.add(
AbfallModel(
abfallId: 'bauteil:${bauteil.bauteilId}',
bauteilId: bauteil.bauteilId,
bauteilName: bauteil.name,
datumZeit: bauteil.erstelltAm,
gewichtGramm: bauteil.abfallGramm,
kosten: kosten ?? 0,
kostenUnbekannt: kosten == null,
kostenNachtraeglich: bauteil.abfallKosten == null && kosten != null,
),
);
}
entries.sort((a, b) => b.datumZeit.compareTo(a.datumZeit));
abfaelle.assignAll(entries);
}
void berechneKosten(String _) {
final kilopreis = InputValidators.number(kilopreisController.text);
final gewicht = InputValidators.number(gewichtController.text);
if (kilopreis == null ||
!kilopreis.isFinite ||
kilopreis < 0 ||
gewicht == null ||
!gewicht.isFinite ||
gewicht <= 0) {
return;
}
final kosten = gewicht / 1000 * kilopreis;
if (!kosten.isFinite) return;
kostenController.text = kosten.toStringAsFixed(2).replaceAll('.', ',');
}
void setDatum(DateTime date) {
final current = datumZeit.value;
datumZeit.value = DateTime(
date.year,
date.month,
date.day,
current.hour,
current.minute,
);
}
void setUhrzeit(TimeOfDay time) {
final current = datumZeit.value;
datumZeit.value = DateTime(
current.year,
current.month,
current.day,
time.hour,
time.minute,
);
}
Future<void> _commit(List<AbfallModel> entries) async {
await storage.saveModelList(
StorageKeys.abfaelle,
entries.map((entry) => entry.toJson()).toList(),
);
loadAbfaelle();
}
Future<void> saveAbfall() async {
if (isSaving.value) return;
message.value = null;
if (!(formKey.currentState?.validate() ?? false)) return;
isSaving.value = true;
try {
final entries = storage
.getModelList(StorageKeys.abfaelle)
.map(AbfallModel.fromJson)
.toList();
final id = editingId.value;
if (id != null && !entries.any((entry) => entry.abfallId == id)) {
throw StateError('Eintrag nicht gefunden');
}
final entry = AbfallModel(
abfallId: id ?? const Uuid().v4(),
datumZeit: datumZeit.value,
gewichtGramm: InputValidators.number(gewichtController.text)!,
kosten: InputValidators.number(kostenController.text)!,
);
await _commit([
...entries.where((item) => item.abfallId != entry.abfallId),
entry,
]);
resetForm();
hasError.value = false;
message.value = 'Abfall gespeichert.';
} catch (_) {
hasError.value = true;
message.value = 'Speichern fehlgeschlagen. Bitte erneut versuchen.';
} finally {
isSaving.value = false;
}
}
Future<void> editAbfall(AbfallModel entry) async {
if (isSaving.value || entry.ausBauteil) return;
message.value = null;
editingId.value = entry.abfallId;
datumZeit.value = entry.datumZeit;
gewichtController.text = entry.gewichtGramm.toString().replaceAll('.', ',');
kostenController.text = entry.kosten.toString().replaceAll('.', ',');
if (scrollController.hasClients) {
await scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
);
}
}
Future<void> deleteAbfall(String id) async {
if (isSaving.value ||
abfaelle.any((item) => item.abfallId == id && item.ausBauteil)) {
return;
}
isSaving.value = true;
try {
final entries = storage
.getModelList(StorageKeys.abfaelle)
.map(AbfallModel.fromJson)
.where((entry) => entry.abfallId != id)
.toList();
await _commit(entries);
if (editingId.value == id) resetForm();
hasError.value = false;
message.value = 'Abfall gelöscht.';
} catch (_) {
hasError.value = true;
message.value = 'Löschen fehlgeschlagen. Bitte erneut versuchen.';
} finally {
isSaving.value = false;
}
}
void resetForm() {
final kilopreis = kilopreisController.text;
formKey.currentState?.reset();
kilopreisController.text = kilopreis;
editingId.value = null;
datumZeit.value = DateTime.now();
gewichtController.clear();
kostenController.clear();
message.value = null;
hasError.value = false;
}
@override
void onClose() {
scrollController.dispose();
kilopreisController.dispose();
gewichtController.dispose();
kostenController.dispose();
super.onClose();
}
}
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import '../helper/storage_keys.dart';
import '../models/bauteil_auswertung_model.dart';
import '../models/bauteil_model.dart';
import '../services/get_storage_services.dart';
class BauteilAuswertungController extends GetxController {
final storage = Get.find<GetStorageService>();
final bauteile = <BauteilModel>[].obs;
final zeitraum = BauteilZeitraum.monat.obs;
final scrollController = ScrollController();
@override
void onInit() {
super.onInit();
final entries = storage
.getModelList(StorageKeys.bauteile)
.map(BauteilModel.fromJson)
.toList();
entries.sort((a, b) {
final datum = b.erstelltAm.compareTo(a.erstelltAm);
return datum == 0 ? a.bauteilId.compareTo(b.bauteilId) : datum;
});
bauteile.assignAll(entries);
}
double get gesamtsumme => BauteilAuswertungsGruppe.summeFuer(bauteile);
int get ohnePreis => bauteile
.where((item) => !BauteilAuswertungsGruppe.preisBekannt(item))
.length;
List<BauteilAuswertungsGruppe> get gruppen {
final auswahl = zeitraum.value;
final gruppiert = <DateTime, List<BauteilModel>>{};
for (final bauteil in bauteile) {
final datum = bauteil.erstelltAm.toLocal();
final beginn = switch (auswahl) {
BauteilZeitraum.jahr => DateTime(datum.year),
BauteilZeitraum.monat => DateTime(datum.year, datum.month),
BauteilZeitraum.tag => DateTime(datum.year, datum.month, datum.day),
};
gruppiert.putIfAbsent(beginn, () => []).add(bauteil);
}
final zeitpunkte = gruppiert.keys.toList()..sort((a, b) => b.compareTo(a));
return zeitpunkte
.map(
(beginn) => BauteilAuswertungsGruppe(
beginn: beginn,
bauteile: List.unmodifiable(gruppiert[beginn]!),
),
)
.toList();
}
String gruppenTitel(DateTime beginn) => switch (zeitraum.value) {
BauteilZeitraum.jahr => 'Jahr ${beginn.year}',
BauteilZeitraum.monat => 'Monat ${DateFormat('MM.yyyy').format(beginn)}',
BauteilZeitraum.tag => DateFormat('dd.MM.yyyy').format(beginn),
};
void waehleZeitraum(BauteilZeitraum auswahl) {
if (auswahl == zeitraum.value) return;
if (scrollController.hasClients) scrollController.jumpTo(0);
zeitraum.value = auswahl;
}
@override
void onClose() {
scrollController.dispose();
super.onClose();
}
}
+171 -5
View File
@@ -6,6 +6,7 @@ import 'package:get/get.dart';
import 'package:uuid/uuid.dart';
import '../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();
}
+4
View File
@@ -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.';
+19 -1
View File
@@ -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();
}
+71
View File
@@ -0,0 +1,71 @@
import '../models/bauteil_model.dart';
import '../models/kauf_filament_model.dart';
class AbfallBerechnung {
/// Roll price is divided by its original weight, never by remaining stock.
/// For multi-filament prints, waste follows each filament's usage share.
static double? kosten(
BauteilModel bauteil,
Iterable<KaufFilamentModel> filamente,
) {
if (bauteil.abfallGramm == 0) return 0;
if (!bauteil.abfallGramm.isFinite ||
bauteil.abfallGramm < 0 ||
bauteil.filamente.isEmpty) {
return null;
}
final preis = preisProGramm(bauteil, filamente);
if (preis == null) return null;
final kosten = bauteil.abfallGramm * preis;
return kosten.isFinite ? double.parse(kosten.toStringAsFixed(2)) : null;
}
static double? preisProGramm(
BauteilModel bauteil,
Iterable<KaufFilamentModel> filamente,
) {
if (bauteil.filamente.isEmpty) return null;
final rollen = {for (final rolle in filamente) rolle.filamentId: rolle};
final gesamtverbrauch = bauteil.filamente.fold<double>(
0,
(sum, item) => sum + item.verbrauchGramm,
);
if (bauteil.filamente.length > 1 &&
(!gesamtverbrauch.isFinite || gesamtverbrauch <= 0)) {
return null;
}
var preis = 0.0;
for (final verbrauch in bauteil.filamente) {
final rolle = rollen[verbrauch.filamentId];
final startgewicht = rolle?.gewichtGramm;
if (rolle == null ||
startgewicht == null ||
startgewicht <= 0 ||
!rolle.preis.isFinite ||
rolle.preis < 0 ||
!verbrauch.verbrauchGramm.isFinite ||
verbrauch.verbrauchGramm < 0 ||
(bauteil.filamente.length > 1 && verbrauch.verbrauchGramm == 0)) {
return null;
}
final anteil = bauteil.filamente.length == 1
? 1.0
: verbrauch.verbrauchGramm / gesamtverbrauch;
preis += anteil * (rolle.preis / startgewicht);
}
return preis.isFinite ? preis : null;
}
/// Changing metadata alone must not change an already recorded material cost.
static bool gleicheBasis(BauteilModel a, BauteilModel b) =>
a.gewichtGramm == b.gewichtGramm &&
a.abfallGramm == b.abfallGramm &&
a.filamente.length == b.filamente.length &&
a.filamente.every(
(item) => b.filamente.any(
(other) =>
item.filamentId == other.filamentId &&
item.verbrauchGramm == other.verbrauchGramm,
),
);
}
+73
View File
@@ -0,0 +1,73 @@
import '../models/bauteil_model.dart';
import '../models/kauf_filament_model.dart';
import 'abfall_berechnung.dart';
class BauteilPreis {
const BauteilPreis({
required this.materialKosten,
required this.abfallKosten,
required this.aufschlagProzent,
required this.gesamtpreis,
});
final double materialKosten;
final double abfallKosten;
final double aufschlagProzent;
final double gesamtpreis;
double get aufschlagKosten => double.parse(
(gesamtpreis - materialKosten - abfallKosten).toStringAsFixed(2),
);
}
class BauteilPreisBerechnung {
static double _cent(double value) => double.parse(value.toStringAsFixed(2));
static BauteilPreis? berechne(
BauteilModel entry,
Iterable<KaufFilamentModel> rollen,
double aufschlag, {
BauteilModel? previous,
}) {
if (!entry.gewichtGramm.isFinite ||
entry.gewichtGramm <= 0 ||
!entry.abfallGramm.isFinite ||
entry.abfallGramm < 0 ||
!aufschlag.isFinite ||
aufschlag < 0) {
return null;
}
double material;
double abfall;
if (previous?.materialKosten != null &&
previous?.abfallKosten != null &&
AbfallBerechnung.gleicheBasis(entry, previous!)) {
material = previous.materialKosten!;
abfall = previous.abfallKosten!;
} else {
if (entry.filamente.length > 1) {
final summe = entry.filamente.fold<double>(
0,
(sum, item) => sum + item.verbrauchGramm,
);
if (!summe.isFinite ||
(summe - entry.gesamtverbrauchGramm).abs() > .01) {
return null;
}
}
final preis = AbfallBerechnung.preisProGramm(entry, rollen);
if (preis == null) return null;
final materialRoh = entry.gewichtGramm * preis;
final abfallRoh = entry.abfallGramm * preis;
if (!materialRoh.isFinite || !abfallRoh.isFinite) return null;
material = _cent(materialRoh);
abfall = _cent(abfallRoh);
}
final gesamt = (material + abfall) * (1 + aufschlag / 100);
if (!gesamt.isFinite) return null;
return BauteilPreis(
materialKosten: material,
abfallKosten: abfall,
aufschlagProzent: aufschlag,
gesamtpreis: _cent(gesamt),
);
}
}
+21
View File
@@ -1,17 +1,38 @@
import '../controller/abfall_controller.dart';
import '../pages/abfall_view.dart';
import 'package:get/get.dart';
import '../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(),
+1
View File
@@ -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';
}
+38
View File
@@ -0,0 +1,38 @@
class AbfallModel {
const AbfallModel({
required this.abfallId,
required this.datumZeit,
required this.gewichtGramm,
required this.kosten,
this.bauteilId,
this.bauteilName,
this.kostenUnbekannt = false,
this.kostenNachtraeglich = false,
});
final String abfallId;
final DateTime datumZeit;
final double gewichtGramm;
final double kosten;
// Linked records are derived from parts, never stored a second time.
final String? bauteilId;
final String? bauteilName;
final bool kostenUnbekannt;
final bool kostenNachtraeglich;
bool get ausBauteil => bauteilId != null;
factory AbfallModel.fromJson(Map<String, dynamic> json) => AbfallModel(
abfallId: json['abfallId'] as String,
datumZeit: DateTime.parse(json['datumZeit'] as String).toLocal(),
gewichtGramm: (json['gewichtGramm'] as num).toDouble(),
kosten: (json['kosten'] as num).toDouble(),
);
Map<String, dynamic> toJson() => {
'abfallId': abfallId,
'datumZeit': datumZeit.toIso8601String(),
'gewichtGramm': gewichtGramm,
'kosten': kosten,
};
}
+27 -4
View File
@@ -2,6 +2,7 @@ import 'dart:convert';
import '../helper/storage_keys.dart';
import '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();
+27
View File
@@ -0,0 +1,27 @@
import 'bauteil_model.dart';
enum BauteilZeitraum { jahr, monat, tag }
class BauteilAuswertungsGruppe {
const BauteilAuswertungsGruppe({
required this.beginn,
required this.bauteile,
});
final DateTime beginn;
final List<BauteilModel> bauteile;
static bool preisBekannt(BauteilModel bauteil) =>
bauteil.gesamtpreis != null &&
bauteil.gesamtpreis!.isFinite &&
bauteil.gesamtpreis! >= 0;
static double summeFuer(Iterable<BauteilModel> bauteile) => double.parse(
bauteile
.where(preisBekannt)
.fold<double>(0, (sum, item) => sum + item.gesamtpreis!)
.toStringAsFixed(2),
);
double get summe => summeFuer(bauteile);
int get ohnePreis => bauteile.where((item) => !preisBekannt(item)).length;
}
+20 -1
View File
@@ -35,6 +35,9 @@ class BauteilModel {
final double gewichtGramm;
final double 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(
+8 -1
View File
@@ -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,
);
}
}
+48
View File
@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controller/abfall_controller.dart';
import '../widgets/abfall_form.dart';
import '../widgets/abfall_inventory.dart';
class AbfallView extends GetView<AbfallController> {
static const routeName = '/abfall-view';
const AbfallView({super.key});
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
leading: IconButton(
tooltip: 'Zurück',
icon: const Icon(Icons.arrow_back),
onPressed: () => Get.back<void>(),
),
title: const Text('Abfallverwaltung'),
),
body: SafeArea(
top: false,
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1000),
child: CustomScrollView(
controller: controller.scrollController,
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
slivers: [
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverToBoxAdapter(
child: AbfallForm(controller: controller),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 24),
sliver: AbfallInventory(controller: controller),
),
],
),
),
),
),
);
}
+191
View File
@@ -0,0 +1,191 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controller/bauteil_auswertung_controller.dart';
import '../helper/display_formats.dart';
import '../models/bauteil_auswertung_model.dart';
import '../models/bauteil_model.dart';
import '../theme/app_theme.dart';
import '../widgets/entry_panel.dart';
class BauteilAuswertungView extends GetView<BauteilAuswertungController> {
static const routeName = '/bauteile-auswertung-view';
const BauteilAuswertungView({super.key});
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
leading: IconButton(
tooltip: 'Zurück zu den Bauteilen',
icon: const Icon(Icons.arrow_back),
onPressed: () => Get.back<void>(),
),
title: const Text('Bauteilauswertung'),
),
body: SafeArea(
top: false,
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1000),
child: Obx(() {
final gruppen = controller.gruppen;
// Headers and entries share one lazy list, even with many periods.
final zeilen = <Object>[
for (final gruppe in gruppen) ...[gruppe, ...gruppe.bauteile],
];
return CustomScrollView(
controller: controller.scrollController,
slivers: [
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverToBoxAdapter(child: _uebersicht()),
),
if (zeilen.isEmpty)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'Noch keine Bauteile gespeichert.',
textAlign: TextAlign.center,
style: TextStyle(color: AppTheme.muted),
),
),
)
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
sliver: SliverList.builder(
itemCount: zeilen.length,
itemBuilder: (context, index) {
final zeile = zeilen[index];
if (zeile is BauteilAuswertungsGruppe) {
return _gruppenKopf(zeile);
}
final bauteil = zeile as BauteilModel;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: SavedEntryCard(
key: ValueKey('auswertung-${bauteil.bauteilId}'),
title: bauteil.name,
subtitle: DisplayFormats.date(
bauteil.erstelltAm.toLocal(),
),
notes: bauteil.verwendung,
details: [
BauteilAuswertungsGruppe.preisBekannt(bauteil)
? 'Gesamtpreis: ${DisplayFormats.currency(bauteil.gesamtpreis!)}'
: 'Gesamtpreis nicht erfasst',
'${DisplayFormats.number(bauteil.gewichtGramm)} g Bauteil',
'${DisplayFormats.number(bauteil.abfallGramm)} g Abfall',
DisplayFormats.duration(
bauteil.druckdauerMinuten,
),
],
),
);
},
),
),
],
);
}),
),
),
),
);
Widget _uebersicht() => Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppTheme.inputSurface,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: const Color(0xFFCEE6DF)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Alle gedruckten Bauteile',
style: TextStyle(fontSize: 23, fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Text(
'${controller.bauteile.length} Bauteile · neueste zuerst',
style: const TextStyle(color: AppTheme.muted),
),
const SizedBox(height: 16),
const Text('Gesamtsumme', style: TextStyle(color: AppTheme.muted)),
Text(
DisplayFormats.currency(controller.gesamtsumme),
style: const TextStyle(
color: AppTheme.teal,
fontSize: 28,
fontWeight: FontWeight.w700,
),
),
if (controller.ohnePreis > 0) ...[
const SizedBox(height: 8),
Text(
'${controller.ohnePreis} Bauteile ohne Gesamtpreis sind in der Liste enthalten, aber nicht in der Preissumme.',
style: const TextStyle(color: AppTheme.muted, height: 1.5),
),
],
const SizedBox(height: 24),
const Text(
'Gruppieren nach',
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
SegmentedButton<BauteilZeitraum>(
showSelectedIcon: false,
segments: const [
ButtonSegment(value: BauteilZeitraum.jahr, label: Text('Jahr')),
ButtonSegment(value: BauteilZeitraum.monat, label: Text('Monat')),
ButtonSegment(value: BauteilZeitraum.tag, label: Text('Tag')),
],
selected: {controller.zeitraum.value},
onSelectionChanged: (auswahl) =>
controller.waehleZeitraum(auswahl.single),
),
],
),
);
Widget _gruppenKopf(BauteilAuswertungsGruppe gruppe) => Container(
margin: const EdgeInsets.only(top: 8, bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.inventorySurface,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
controller.gruppenTitel(gruppe.beginn),
style: const TextStyle(
color: Colors.white,
fontSize: 21,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'${gruppe.bauteile.length} Bauteile · Summe: ${DisplayFormats.currency(gruppe.summe)}',
style: const TextStyle(
color: Color(0xFF79D7C6),
fontWeight: FontWeight.w600,
),
),
if (gruppe.ohnePreis > 0) ...[
const SizedBox(height: 6),
Text(
'${gruppe.ohnePreis} ohne Gesamtpreis',
style: const TextStyle(color: Color(0xFFB9CCD9)),
),
],
],
),
);
}
+16 -1
View File
@@ -8,6 +8,7 @@ import '../widgets/bauteil_card.dart';
import '../widgets/bauteil_delete_dialog.dart';
import '../widgets/bauteil_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(
+4 -1
View File
@@ -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
+6
View File
@@ -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(
+139
View File
@@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import '../controller/abfall_controller.dart';
import '../helper/input_validators.dart';
import 'entry_panel.dart';
class AbfallForm extends StatelessWidget {
const AbfallForm({super.key, required this.controller});
final AbfallController controller;
@override
Widget build(BuildContext context) => Obx(() {
final date = controller.datumZeit.value;
final busy = controller.isSaving.value;
final editing = controller.editingId.value != null;
return EntryPanel(
title: editing ? 'Abfall bearbeiten' : 'Abfall erfassen',
subtitle: 'Erfasse zusätzlichen Abfall. Abfall aus gespeicherten Bauteilen erscheint automatisch in der Übersicht.',
child: Form(
key: controller.formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
EntryFieldGrid(
children: [
OutlinedButton.icon(
onPressed: busy
? null
: () async {
final picked = await showDatePicker(
context: context,
initialDate: date,
firstDate: DateTime(1900),
lastDate: DateTime(2100, 12, 31),
helpText: 'Datum auswählen',
cancelText: 'Abbrechen',
confirmText: 'Übernehmen',
);
if (picked != null && context.mounted) {
controller.setDatum(picked);
}
},
icon: const Icon(Icons.calendar_today_outlined),
label: Text(
'Datum: ${DateFormat('dd.MM.yyyy').format(date)}',
),
),
OutlinedButton.icon(
onPressed: busy
? null
: () async {
final picked = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(date),
helpText: 'Uhrzeit auswählen',
cancelText: 'Abbrechen',
confirmText: 'Übernehmen',
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context)
.copyWith(alwaysUse24HourFormat: true),
child: child!,
),
);
if (picked != null && context.mounted) {
controller.setUhrzeit(picked);
}
},
icon: const Icon(Icons.schedule),
label: Text('Uhrzeit: ${DateFormat('HH:mm').format(date)}'),
),
],
),
const SizedBox(height: 20),
const Text(
'Durchschnittlicher Preis pro kg',
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
EntryNumberField(
controller: controller.kilopreisController,
label: 'Kilopreis (optional)',
unit: '€/kg',
validator: (value) =>
InputValidators.nonNegative(value, optional: true),
onChanged: controller.berechneKosten,
),
const SizedBox(height: 16),
EntryFieldGrid(
children: [
EntryNumberField(
controller: controller.gewichtController,
onChanged: controller.berechneKosten,
label: 'Abfallgewicht',
unit: 'g',
validator: InputValidators.positive,
),
EntryNumberField(
controller: controller.kostenController,
label: 'Abfallkosten',
unit: '€',
validator: InputValidators.nonNegative,
),
],
),
const SizedBox(height: 8),
const Text(
'Abfallkosten sind manuell änderbar. Bei Änderung von Gewicht oder Kilopreis werden sie neu berechnet.',
style: TextStyle(fontSize: 12),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: busy ? null : controller.saveAbfall,
icon: Icon(editing ? Icons.check : Icons.add),
label: Text(
busy
? 'Wird gespeichert …'
: editing
? 'Änderungen speichern'
: 'Abfall speichern',
),
),
if (editing)
TextButton.icon(
onPressed: busy ? null : controller.resetForm,
icon: const Icon(Icons.close),
label: const Text('Bearbeitung abbrechen'),
),
EntryMessage(
message: controller.message.value,
hasError: controller.hasError.value,
),
],
),
),
);
});
}
+189
View File
@@ -0,0 +1,189 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controller/abfall_controller.dart';
import '../helper/display_formats.dart';
import '../models/abfall_model.dart';
import '../theme/app_theme.dart';
import 'entry_panel.dart';
class AbfallInventory extends StatelessWidget {
const AbfallInventory({super.key, required this.controller});
final AbfallController controller;
@override
Widget build(BuildContext context) => DecoratedSliver(
decoration: BoxDecoration(
color: AppTheme.inventorySurface,
borderRadius: BorderRadius.circular(24),
),
sliver: SliverPadding(
padding: const EdgeInsets.all(24),
sliver: Obx(() {
final entries = controller.abfaelle.toList();
final busy = controller.isSaving.value;
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Abfallübersicht',
style: TextStyle(
color: Colors.white,
fontSize: 25,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Text(
'${entries.length} Einträge · die neuesten zuerst.',
style: const TextStyle(color: Color(0xFFB9CCD9)),
),
const SizedBox(height: 20),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
_AbfallStat(
label: 'Summe Abfall',
value:
'${DisplayFormats.number(controller.summeAbfall)} g',
),
_AbfallStat(
label: 'Summe Abfallkosten',
value: DisplayFormats.currency(controller.summeKosten),
),
],
),
if (entries.any((entry) => entry.kostenUnbekannt)) ...[
const SizedBox(height: 12),
const Text(
'Bei einigen älteren Bauteilen fehlen Filamentdaten. Ihr Gewicht ist enthalten; ihre unbekannten Kosten fehlen in der Kostensumme.',
style: TextStyle(color: Color(0xFFB9CCD9), height: 1.5),
),
],
const SizedBox(height: 24),
],
),
),
if (entries.isEmpty)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Text(
'Noch kein Abfall gespeichert. Erfasse deinen ersten Eintrag.',
style: TextStyle(color: Color(0xFFB9CCD9), height: 1.5),
),
),
)
else
SliverList.separated(
itemCount: entries.length,
separatorBuilder: (context, index) =>
const SizedBox(height: 12),
itemBuilder: (context, index) {
final entry = entries[index];
return SavedEntryCard(
key: ValueKey(entry.abfallId),
title:
'${DisplayFormats.number(entry.gewichtGramm)} g Abfall',
subtitle: DisplayFormats.date(entry.datumZeit),
notes: entry.ausBauteil
? 'Aus Bauteil „${entry.bauteilName}“. Änderungen und Löschen über die Bauteilverwaltung.${entry.kostenNachtraeglich ? ' Kosten nachträglich aus den aktuellen Rollenpreisen und Startgewichten berechnet.' : ''}'
: '',
details: [
entry.kostenUnbekannt
? 'Abfallkosten: nicht ermittelbar'
: 'Abfallkosten: ${DisplayFormats.currency(entry.kosten)}',
],
trailing: entry.ausBauteil
? null
: PopupMenuButton<String>(
tooltip: 'Abfalleintrag verwalten',
enabled: !busy,
iconColor: AppTheme.ink,
onSelected: (action) {
if (action == 'edit') {
controller.editAbfall(entry);
} else if (action == 'delete') {
_confirmDelete(context, entry);
}
},
itemBuilder: (context) => const [
PopupMenuItem(
value: 'edit',
child: Text('Bearbeiten'),
),
PopupMenuItem(
value: 'delete',
child: Text('Löschen'),
),
],
),
);
},
),
],
);
}),
),
);
Future<void> _confirmDelete(BuildContext context, AbfallModel entry) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Abfall löschen?'),
content: Text(
'${DisplayFormats.number(entry.gewichtGramm)} g Abfall vom ${DisplayFormats.date(entry.datumZeit)} löschen?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Löschen'),
),
],
),
);
if (confirmed == true) await controller.deleteAbfall(entry.abfallId);
}
}
class _AbfallStat extends StatelessWidget {
const _AbfallStat({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: const Color(0xFF243E50),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(color: Color(0xFFB9CCD9), fontSize: 12),
),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
+12
View File
@@ -110,6 +110,18 @@ class BauteilCard extends StatelessWidget {
'Bauteil ${DisplayFormats.number(bauteil.gewichtGramm)} g',
),
_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',
),
+47 -4
View File
@@ -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),
+13 -6
View File
@@ -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:
+59
View File
@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controller/bauteil_controller.dart';
import '../helper/display_formats.dart';
import '../theme/app_theme.dart';
class BauteilPreisUebersicht extends StatelessWidget {
const BauteilPreisUebersicht({super.key, required this.controller});
final BauteilController controller;
@override
Widget build(BuildContext context) => Obx(() {
final preis = controller.preisDetails.value;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Preiskalkulation',
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 18),
),
const SizedBox(height: 12),
if (preis != null) ...[
Text(
'Filamentkosten (Bauteil): ${DisplayFormats.currency(preis.materialKosten)}',
),
const SizedBox(height: 6),
Text(
'Abfallkosten: ${DisplayFormats.currency(preis.abfallKosten)}',
),
const SizedBox(height: 6),
Text(
'Aufschlag (${DisplayFormats.number(preis.aufschlagProzent)} %): ${DisplayFormats.currency(preis.aufschlagKosten)}',
),
] else
const Text(
'Für die Berechnung Gewicht, Abfallmenge und Filament auswählen. Bei mehreren Rollen zusätzlich den Verbrauch je Rolle passend zum Gesamtgewicht eingeben.',
style: TextStyle(color: AppTheme.muted, height: 1.5),
),
const SizedBox(height: 16),
TextFormField(
controller: controller.gesamtpreisController,
readOnly: true,
decoration: const InputDecoration(
labelText: 'Gesamtpreis (automatisch)',
suffixText: '€',
),
),
],
),
);
});
}
+19 -3
View File
@@ -75,11 +75,13 @@ class EntryNumberField extends StatelessWidget {
required this.label,
required this.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(
+15 -1
View File
@@ -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',
+38 -51
View File
@@ -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(