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
+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(