add two views

This commit is contained in:
atseirjo
2026-10-27 15:00:16 +01:00
parent 864da57537
commit 07a1bb02fd
42 changed files with 3582 additions and 346 deletions
+86 -8
View File
@@ -1,17 +1,95 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:uuid/uuid.dart';
import '../helper/input_validators.dart';
import '../models/anwendung_model.dart';
import '../models/kauf_filament_model.dart';
import '../services/get_storage_services.dart';
import 'home_controller.dart';
class AnwendungController extends GetxController {
final count = 0.obs;
AnwendungController({required this.filamentId});
@override
void onInit() {
static const storageKey = 'filament_anwendungen';
final String filamentId;
final storage = Get.find<GetStorageService>();
final formKey = GlobalKey<FormState>();
final druckbettController = TextEditingController();
final druckController = TextEditingController();
final bauraumController = TextEditingController();
final sonstigesController = TextEditingController();
final bauraumGeschlossen = false.obs;
final isSaving = false.obs;
final message = RxnString();
final hasError = false.obs;
final filament = Rxn<KaufFilamentModel>();
final anwendungen = <AnwendungModel>[].obs;
@override
void onInit() {
super.onInit();
final filamente = storage
.getModelList(HomeController.storageKey)
.map(KaufFilamentModel.fromJson)
.toList();
filament.value = filamente.firstWhereOrNull(
(item) => item.filamentId == filamentId,
);
anwendungen.assignAll(
storage
.getModelList(storageKey)
.map(AnwendungModel.fromJson)
.where((item) => item.filamentId == filamentId),
);
anwendungen.sort((a, b) => b.erstelltAm.compareTo(a.erstelltAm));
}
Future<void> saveAnwendung() async {
if (isSaving.value || filament.value == null) return;
if (!(formKey.currentState?.validate() ?? false)) return;
isSaving.value = true;
message.value = null;
try {
final entry = AnwendungModel(
anwendungId: const Uuid().v4(),
filamentId: filamentId,
erstelltAm: DateTime.now(),
druckbettTemperatur: InputValidators.number(druckbettController.text)!,
druckTemperatur: InputValidators.number(druckController.text)!,
bauraumGeschlossen: bauraumGeschlossen.value,
bauraumTemperatur: InputValidators.number(bauraumController.text),
sonstiges: sonstigesController.text.trim(),
);
final entries = storage.getModelList(storageKey);
await storage.saveModelList(storageKey, [...entries, entry.toJson()]);
anwendungen.insert(0, entry);
resetForm();
hasError.value = false;
message.value = 'Verarbeitungsprofil gespeichert.';
} catch (_) {
hasError.value = true;
message.value = 'Speichern fehlgeschlagen. Bitte erneut versuchen.';
} finally {
isSaving.value = false;
}
}
@override
void onReady() {}
@override
void onClose() {}
void resetForm() {
formKey.currentState?.reset();
druckbettController.clear();
druckController.clear();
bauraumController.clear();
sonstigesController.clear();
bauraumGeschlossen.value = false;
}
@override
void onClose() {
druckbettController.dispose();
druckController.dispose();
bauraumController.dispose();
sonstigesController.dispose();
super.onClose();
}
}
+367
View File
@@ -0,0 +1,367 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:uuid/uuid.dart';
import '../helper/display_formats.dart';
import '../helper/filament_bestand.dart';
import '../helper/input_validators.dart';
import '../helper/storage_keys.dart';
import '../models/anwendung_model.dart';
import '../models/bauteil_model.dart';
import '../models/kauf_filament_model.dart';
import '../services/bauteil_photo_service.dart';
import '../services/get_storage_services.dart';
import 'home_controller.dart';
class BauteilController extends GetxController {
BauteilController({BauteilPhotoService? photoService})
: photoService = photoService ?? BauteilPhotoService();
static const storageKey = StorageKeys.bauteile;
final storage = Get.find<GetStorageService>();
final BauteilPhotoService photoService;
final formKey = GlobalKey<FormState>();
final scrollController = ScrollController();
final nameController = TextEditingController();
final verwendungController = TextEditingController();
final stundenController = TextEditingController(text: '0');
final minutenController = TextEditingController(text: '0');
final gewichtController = TextEditingController();
final abfallController = TextEditingController(text: '0');
final _verbrauchControllers = <String, TextEditingController>{};
final filamente = <KaufFilamentModel>[].obs;
final profile = <AnwendungModel>[].obs;
final bauteile = <BauteilModel>[].obs;
final selectedFilamente = <String, String?>{}.obs;
final editingId = RxnString();
final foto = Rxn<Uint8List>();
final isPickingPhoto = false.obs;
final isSaving = false.obs;
final message = RxnString();
final hasError = false.obs;
bool _closed = false;
@override
void onInit() {
super.onInit();
filamente.assignAll(
storage
.getModelList(StorageKeys.filamente)
.map(KaufFilamentModel.fromJson),
);
profile.assignAll(
storage
.getModelList(StorageKeys.anwendungen)
.map(AnwendungModel.fromJson),
);
bauteile.assignAll(_loadBauteile());
bauteile.sort((a, b) => b.erstelltAm.compareTo(a.erstelltAm));
}
List<BauteilModel> _loadBauteile() =>
storage.getModelList(storageKey).map(BauteilModel.fromJson).toList();
TextEditingController verbrauchController(String id) =>
_verbrauchControllers.putIfAbsent(id, () => TextEditingController());
List<AnwendungModel> profilesFor(String filamentId) =>
profile.where((item) => item.filamentId == filamentId).toList()
..sort((a, b) => b.erstelltAm.compareTo(a.erstelltAm));
String filamentLabel(String id) {
final item = filamente.firstWhereOrNull((item) => item.filamentId == id);
if (item == null) return 'Gelöschtes Filament ($id)';
return '${item.marke} · ${item.material} · ${item.farbe}';
}
double? verfuegbarGramm(String id) {
final start = filamente
.firstWhereOrNull((item) => item.filamentId == id)
?.gewichtGramm;
if (start == null) return null;
final used = FilamentBestand.verbrauch(
bauteile,
ohneBauteilId: editingId.value,
);
return start - (used[id] ?? 0);
}
void toggleFilament(String id, bool selected) {
if (selected) {
verbrauchController(id);
selectedFilamente[id] = null;
} else {
selectedFilamente.remove(id);
}
}
void selectProfile(String filamentId, String? anwendungId) {
if (!selectedFilamente.containsKey(filamentId)) return;
if (anwendungId != null &&
!profilesFor(filamentId)
.any((item) => item.anwendungId == anwendungId)) {
return;
}
selectedFilamente[filamentId] = anwendungId;
}
Future<void> pickPhoto() async {
if (isPickingPhoto.value || isSaving.value) return;
isPickingPhoto.value = true;
try {
final bytes = await photoService.pickPhoto();
if (!_closed && bytes != null) foto.value = bytes;
} on FormatException catch (error) {
if (!_closed) _error(error.message);
} catch (_) {
if (!_closed) {
_error(
'Das Foto konnte nicht geladen werden. Bitte ein JPG-, PNG- oder WebP-Bild auswählen.',
);
}
} finally {
if (!_closed) isPickingPhoto.value = false;
}
}
void removePhoto() => foto.value = null;
void _error(String text) {
hasError.value = true;
message.value = text;
}
String? _validateConsumption(
BauteilModel entry,
List<BauteilModel> existing,
) {
if (entry.filamente.isEmpty) {
return 'Bitte mindestens ein Filament auswählen.';
}
final sum = entry.filamente.fold<double>(
0,
(sum, item) => sum + item.verbrauchGramm,
);
if (entry.filamente.any(
(item) => !item.verbrauchGramm.isFinite || item.verbrauchGramm <= 0,
)) {
return 'Bitte für jedes verwendete Filament einen Verbrauch größer 0 eingeben.';
}
if (!sum.isFinite || (sum - entry.gesamtverbrauchGramm).abs() > 0.01) {
return 'Der Filamentverbrauch muss zusammen ${DisplayFormats.number(entry.gesamtverbrauchGramm)} g ergeben (Bauteilgewicht + Abfall).';
}
final used = FilamentBestand.verbrauch(
existing,
ohneBauteilId: entry.bauteilId,
);
final currentFilamente = storage
.getModelList(StorageKeys.filamente)
.map(KaufFilamentModel.fromJson)
.toList();
for (final item in entry.filamente) {
final filament = currentFilamente.firstWhereOrNull(
(filament) => filament.filamentId == item.filamentId,
);
final start = filament?.gewichtGramm;
if (start == null) {
return 'Filament fehlt oder sein Startgewicht ist ungültig. Bitte die Auswahl bzw. das Filament prüfen.';
}
final available = start - (used[item.filamentId] ?? 0);
if (item.verbrauchGramm - available > 0.000001) {
return '${filamentLabel(item.filamentId)}: nur ${DisplayFormats.number(available)} g verfügbar.';
}
}
return null;
}
/// One persisted ledger determines all remaining weights. Updating or deleting
/// an entry therefore cannot leave a second stock table out of sync.
Future<void> _commit(List<BauteilModel> entries) async {
await storage.saveModelList(
storageKey,
entries.map((item) => item.toJson()).toList(),
);
entries.sort((a, b) => b.erstelltAm.compareTo(a.erstelltAm));
bauteile.assignAll(entries);
if (Get.isRegistered<HomeController>()) {
Get.find<HomeController>().loadVerbrauch();
}
}
Future<void> saveBauteil() async {
if (isSaving.value || isPickingPhoto.value) return;
message.value = null;
if (!(formKey.currentState?.validate() ?? false)) return;
if (selectedFilamente.isEmpty) {
_error('Bitte mindestens ein Filament auswählen.');
return;
}
final duration =
int.parse(stundenController.text.trim()) * 60 +
int.parse(minutenController.text.trim());
if (duration <= 0) {
_error('Die Druckdauer muss mindestens eine Minute betragen.');
return;
}
isSaving.value = true;
try {
final existing = _loadBauteile();
final previous = existing.firstWhereOrNull(
(item) => item.bauteilId == editingId.value,
);
if (editingId.value != null && previous == null) {
_error('Das bearbeitete Bauteil wurde nicht gefunden.');
return;
}
final entry = BauteilModel(
bauteilId: editingId.value ?? const Uuid().v4(),
erstelltAm: previous?.erstelltAm ?? DateTime.now(),
name: nameController.text.trim(),
verwendung: verwendungController.text.trim(),
druckdauerMinuten: duration,
gewichtGramm: InputValidators.number(gewichtController.text)!,
abfallGramm: InputValidators.number(abfallController.text)!,
fotoBase64: foto.value == null ? null : base64Encode(foto.value!),
filamente: selectedFilamente.entries
.map(
(item) => BauteilFilamentModel(
filamentId: item.key,
anwendungId: item.value,
verbrauchGramm:
InputValidators.number(
verbrauchController(item.key).text,
) ??
double.nan,
),
)
.toList(),
);
final error = _validateConsumption(entry, existing);
if (error != null) {
_error(error);
return;
}
await _commit([
...existing.where((item) => item.bauteilId != entry.bauteilId),
entry,
]);
resetForm();
hasError.value = false;
message.value = 'Bauteil und Filamentverbrauch gespeichert.';
} catch (_) {
_error('Speichern fehlgeschlagen. Bitte erneut versuchen.');
} finally {
isSaving.value = false;
}
}
Future<void> editBauteil(BauteilModel entry) async {
if (isSaving.value || isPickingPhoto.value) return;
resetForm();
message.value = null;
nameController.text = entry.name;
verwendungController.text = entry.verwendung;
stundenController.text = '${entry.druckdauerMinuten ~/ 60}';
minutenController.text = '${entry.druckdauerMinuten % 60}';
gewichtController.text = DisplayFormats.number(entry.gewichtGramm);
abfallController.text = DisplayFormats.number(entry.abfallGramm);
foto.value = entry.fotoBytes;
for (final item in entry.filamente) {
verbrauchController(item.filamentId).text = item.verbrauchGramm > 0
? DisplayFormats.number(item.verbrauchGramm)
: '';
selectedFilamente[item.filamentId] = item.anwendungId;
}
editingId.value = entry.bauteilId;
await scrollToForm();
}
Future<void> duplicateBauteil(BauteilModel entry) async {
if (isSaving.value || isPickingPhoto.value) return;
isSaving.value = true;
try {
final existing = _loadBauteile();
final copy = entry.copyWith(
bauteilId: const Uuid().v4(),
erstelltAm: DateTime.now(),
);
final error = _validateConsumption(copy, existing);
if (error != null) {
_error(error);
await scrollToForm();
return;
}
await _commit([...existing, copy]);
hasError.value = false;
message.value = 'Bauteil dupliziert und Verbrauch erneut gebucht.';
} catch (_) {
_error('Duplizieren fehlgeschlagen. Bitte erneut versuchen.');
await scrollToForm();
} finally {
isSaving.value = false;
}
}
Future<void> deleteBauteil(String id) async {
if (isSaving.value || isPickingPhoto.value) return;
isSaving.value = true;
try {
await _commit(
_loadBauteile().where((item) => item.bauteilId != id).toList(),
);
if (editingId.value == id) resetForm();
hasError.value = false;
message.value = 'Bauteil gelöscht und Verbrauch zurückgebucht.';
} catch (_) {
_error('Löschen fehlgeschlagen. Bitte erneut versuchen.');
await scrollToForm();
} finally {
isSaving.value = false;
}
}
Future<void> scrollToForm() async {
if (scrollController.hasClients) {
await scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
);
}
}
void resetForm() {
formKey.currentState?.reset();
editingId.value = null;
nameController.clear();
verwendungController.clear();
stundenController.text = '0';
minutenController.text = '0';
gewichtController.clear();
abfallController.text = '0';
selectedFilamente.clear();
for (final controller in _verbrauchControllers.values) {
controller.clear();
}
foto.value = null;
}
@override
void onClose() {
_closed = true;
scrollController.dispose();
nameController.dispose();
verwendungController.dispose();
stundenController.dispose();
minutenController.dispose();
gewichtController.dispose();
abfallController.dispose();
for (final controller in _verbrauchControllers.values) {
controller.dispose();
}
super.onClose();
}
}
+64 -20
View File
@@ -3,17 +3,40 @@ import 'package:get/get.dart';
import 'package:uuid/uuid.dart';
import '../models/kauf_filament_model.dart';
import '../models/bauteil_model.dart';
import '../helper/storage_keys.dart';
import '../helper/filament_bestand.dart';
import '../helper/display_formats.dart';
import '../helper/input_validators.dart';
import '../services/get_storage_services.dart';
class HomeController extends GetxController {
static const storageKey = 'kauf_filamente';
static const storageKey = StorageKeys.filamente;
final storage = Get.find<GetStorageService>();
final uuid = const Uuid();
final filamente = <KaufFilamentModel>[].obs;
final verbrauch = <String, double>{}.obs;
void loadVerbrauch() {
verbrauch.assignAll(
FilamentBestand.verbrauch(
storage.getModelList(StorageKeys.bauteile).map(BauteilModel.fromJson),
),
);
}
double? restgewicht(KaufFilamentModel filament) {
final gewicht = filament.gewichtGramm;
return gewicht == null
? null
: gewicht - (verbrauch[filament.filamentId] ?? 0);
}
final formKey = GlobalKey<FormState>();
final pageScrollController = ScrollController();
final formScrollController = ScrollController();
final haendlerController = TextEditingController();
final farbeController = TextEditingController();
@@ -46,19 +69,16 @@ class HomeController extends GetxController {
}
void loadFilamente() {
loadVerbrauch();
final data = storage.getModelList(storageKey);
filamente.assignAll(
data.map(KaufFilamentModel.fromJson),
);
filamente.assignAll(data.map(KaufFilamentModel.fromJson));
sortFilamente();
}
void sortFilamente() {
filamente.sort(
(a, b) => b.kaufdatum.compareTo(a.kaufdatum),
);
filamente.sort((a, b) => b.kaufdatum.compareTo(a.kaufdatum));
}
Future<void> persist() async {
@@ -80,6 +100,22 @@ class HomeController extends GetxController {
return;
}
loadVerbrauch();
final gewicht = InputValidators.number(gewichtController.text);
final bereitsVerbraucht = editingId.value == null
? 0
: (verbrauch[editingId.value!] ?? 0);
if (gewicht == null ||
!gewicht.isFinite ||
gewicht <= 0 ||
gewicht < bereitsVerbraucht) {
Get.snackbar(
'Ungültiges Gewicht',
'Das Startgewicht muss positiv sein und mindestens dem bereits gebuchten Verbrauch entsprechen.',
);
return;
}
final filament = KaufFilamentModel(
filamentId: editingId.value ?? uuid.v4(),
marke: markeController.text.trim(),
@@ -88,9 +124,7 @@ class HomeController extends GetxController {
material: material.value,
preis: preis,
farbe: farbeController.text.trim(),
gewicht: gewichtController.text.trim().isEmpty
? '1kg'
: gewichtController.text.trim(),
gewicht: gewicht.toString(),
);
if (editingId.value == null) {
@@ -113,7 +147,7 @@ class HomeController extends GetxController {
resetForm();
}
void editFilament(KaufFilamentModel filament) {
Future<void> editFilament(KaufFilamentModel filament) async {
editingId.value = filament.filamentId;
kaufdatum.value = filament.kaufdatum;
markeController.text = filament.marke;
@@ -121,15 +155,24 @@ class HomeController extends GetxController {
material.value = filament.material;
preisController.text = filament.preis.toStringAsFixed(2);
farbeController.text = filament.farbe;
gewichtController.text = filament.gewicht;
gewichtController.text = filament.gewichtGramm == null
? filament.gewicht
: DisplayFormats.number(filament.gewichtGramm!);
final scroll = formScrollController.hasClients
? formScrollController
: pageScrollController;
if (scroll.hasClients) {
await scroll.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
);
}
}
Future<void> duplicateFilament(
KaufFilamentModel filament,
) async {
filamente.add(
filament.copyWith(filamentId: uuid.v4()),
);
Future<void> duplicateFilament(KaufFilamentModel filament) async {
filamente.add(filament.copyWith(filamentId: uuid.v4()));
sortFilamente();
await persist();
@@ -151,11 +194,12 @@ class HomeController extends GetxController {
material.value = 'PLA';
gewichtController.clear();
formKey.currentState?.reset();
}
@override
void onClose() {
pageScrollController.dispose();
formScrollController.dispose();
haendlerController.dispose();
markeController.dispose();
farbeController.dispose();
@@ -163,4 +207,4 @@ class HomeController extends GetxController {
gewichtController.dispose();
super.onClose();
}
}
}
+10
View File
@@ -0,0 +1,10 @@
import 'package:intl/intl.dart';
class DisplayFormats {
static String number(num value) =>
NumberFormat('0.##', 'de_AT').format(value);
static String date(DateTime value) =>
DateFormat('dd.MM.yyyy · HH:mm').format(value);
static String duration(int minutes) =>
'${minutes ~/ 60} h ${minutes % 60} min';
}
+21
View File
@@ -0,0 +1,21 @@
import '../models/bauteil_model.dart';
class FilamentBestand {
static Map<String, double> verbrauch(
Iterable<BauteilModel> bauteile, {
String? ohneBauteilId,
}) {
final result = <String, double>{};
for (final bauteil in bauteile) {
if (bauteil.bauteilId == ohneBauteilId) continue;
for (final filament in bauteil.filamente) {
result.update(
filament.filamentId,
(value) => value + filament.verbrauchGramm,
ifAbsent: () => filament.verbrauchGramm,
);
}
}
return result;
}
}
+34
View File
@@ -0,0 +1,34 @@
class InputValidators {
static double? number(String? value) =>
double.tryParse((value ?? '').trim().replaceAll(',', '.'));
static String? requiredText(String? value) =>
value == null || value.trim().isEmpty ? 'Bitte ausfüllen' : null;
static String? nonNegative(String? value, {bool optional = false}) {
if (optional && (value ?? '').trim().isEmpty) return null;
final parsed = number(value);
return parsed == null || !parsed.isFinite || parsed < 0
? 'Zahl ab 0 eingeben'
: null;
}
static String? positive(String? value) {
final parsed = number(value);
return parsed == null || !parsed.isFinite || parsed <= 0
? 'Zahl größer 0 eingeben'
: null;
}
static String? hours(String? value) {
final parsed = int.tryParse((value ?? '').trim());
return parsed == null || parsed < 0 ? 'Ganze Zahl ab 0 eingeben' : null;
}
static String? minutes(String? value) {
final parsed = int.tryParse((value ?? '').trim());
return parsed == null || parsed < 0 || parsed > 59
? '0 bis 59 eingeben'
: null;
}
}
+6 -7
View File
@@ -1,11 +1,10 @@
import 'package:get/get.dart';
import '../../controller/home_controller.dart';
import '../controller/anwendung_controller.dart';
import '../controller/home_controller.dart';
class SampleBindings extends Bindings {
@override
void dependencies() {
@override
void dependencies() {
Get.lazyPut<HomeController>(() => HomeController());
Get.lazyPut<AnwendungController>(() => AnwendungController());
}
}
}
}
+21 -1
View File
@@ -1,15 +1,35 @@
import 'package:get/get.dart';
import '../controller/anwendung_controller.dart';
import '../controller/bauteil_controller.dart';
import '../pages/anwendung_view.dart';
import '../pages/bauteil_view.dart';
import '../pages/home_view.dart';
import 'sample_bindings.dart';
class SampleRouts {
static final sampleBindings = SampleBindings();
static List<GetPage<dynamic>> samplePages = [
GetPage(
name: '${AnwendungView.routeName}/:filamentId',
page: () => const AnwendungView(),
binding: BindingsBuilder(
() => Get.lazyPut<AnwendungController>(
() => AnwendungController(filamentId: Get.parameters['filamentId']!),
),
),
),
GetPage(
name: BauteilView.routeName,
page: () => const BauteilView(),
binding: BindingsBuilder(
() => Get.lazyPut<BauteilController>(() => BauteilController()),
),
),
GetPage(
name: HomeView.routeName,
page: () => const HomeView(),
binding: sampleBindings,
),
];
}
}
+5
View File
@@ -0,0 +1,5 @@
class StorageKeys {
static const filamente = 'kauf_filamente';
static const anwendungen = 'filament_anwendungen';
static const bauteile = 'gedruckte_bauteile';
}
+6 -6
View File
@@ -1,19 +1,19 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'helper/sample_bindings.dart';
import 'helper/sample_routes.dart';
import 'pages/home_view.dart';
import 'package:get_storage/get_storage.dart';
import 'services/get_storage_services.dart';
import 'theme/app_theme.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await GetStorage.init();
Get.put<GetStorageService>(
GetStorageService(),
permanent: true,
);
Get.put<GetStorageService>(GetStorageService(), permanent: true);
runApp(const MyApp());
}
@@ -25,10 +25,10 @@ class MyApp extends StatelessWidget {
return GetMaterialApp(
title: 'Flutter Filament Verwaltung',
debugShowCheckedModeBanner: false,
theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: const Color.fromARGB(255, 38, 135, 184)), useMaterial3: true),
theme: AppTheme.light,
initialBinding: SampleBindings(),
initialRoute: HomeView.routeName,
getPages: SampleRouts.samplePages,
);
}
}
}
+43
View File
@@ -0,0 +1,43 @@
class AnwendungModel {
final String anwendungId;
final String filamentId;
final DateTime erstelltAm;
final double druckbettTemperatur;
final double druckTemperatur;
final bool bauraumGeschlossen;
final double? bauraumTemperatur;
final String sonstiges;
const AnwendungModel({
required this.anwendungId,
required this.filamentId,
required this.erstelltAm,
required this.druckbettTemperatur,
required this.druckTemperatur,
required this.bauraumGeschlossen,
this.bauraumTemperatur,
this.sonstiges = '',
});
Map<String, dynamic> toJson() => {
'anwendungId': anwendungId,
'filamentId': filamentId,
'erstelltAm': erstelltAm.toIso8601String(),
'druckbettTemperatur': druckbettTemperatur,
'druckTemperatur': druckTemperatur,
'bauraumGeschlossen': bauraumGeschlossen,
'bauraumTemperatur': bauraumTemperatur,
'sonstiges': sonstiges,
};
factory AnwendungModel.fromJson(Map<String, dynamic> json) => AnwendungModel(
anwendungId: json['anwendungId'] as String,
filamentId: json['filamentId'] as String,
erstelltAm: DateTime.parse(json['erstelltAm'] as String),
druckbettTemperatur: (json['druckbettTemperatur'] as num).toDouble(),
druckTemperatur: (json['druckTemperatur'] as num).toDouble(),
bauraumGeschlossen: json['bauraumGeschlossen'] as bool,
bauraumTemperatur: (json['bauraumTemperatur'] as num?)?.toDouble(),
sonstiges: json['sonstiges'] as String? ?? '',
);
}
+107
View File
@@ -0,0 +1,107 @@
import 'dart:convert';
import 'dart:typed_data';
class BauteilFilamentModel {
final String filamentId;
final String? anwendungId;
final double verbrauchGramm;
const BauteilFilamentModel({
required this.filamentId,
this.anwendungId,
this.verbrauchGramm = 0,
});
Map<String, dynamic> toJson() => {
'filamentId': filamentId,
'anwendungId': anwendungId,
'verbrauchGramm': verbrauchGramm,
};
factory BauteilFilamentModel.fromJson(Map<String, dynamic> json) =>
BauteilFilamentModel(
filamentId: json['filamentId'] as String,
anwendungId: json['anwendungId'] as String?,
verbrauchGramm: (json['verbrauchGramm'] as num?)?.toDouble() ?? 0,
);
}
class BauteilModel {
final String bauteilId;
final DateTime erstelltAm;
final String name;
final String verwendung;
final int druckdauerMinuten;
final double gewichtGramm;
final double abfallGramm;
final List<BauteilFilamentModel> filamente;
final String? fotoBase64;
const BauteilModel({
required this.bauteilId,
required this.erstelltAm,
required this.name,
required this.verwendung,
required this.druckdauerMinuten,
required this.gewichtGramm,
required this.abfallGramm,
required this.filamente,
this.fotoBase64,
});
double get gesamtverbrauchGramm => gewichtGramm + abfallGramm;
Uint8List? get fotoBytes {
if (fotoBase64 == null) return null;
try {
return base64Decode(fotoBase64!);
} on FormatException {
return null;
}
}
BauteilModel copyWith({
required String bauteilId,
required DateTime erstelltAm,
}) => BauteilModel(
bauteilId: bauteilId,
erstelltAm: erstelltAm,
name: name,
verwendung: verwendung,
druckdauerMinuten: druckdauerMinuten,
gewichtGramm: gewichtGramm,
abfallGramm: abfallGramm,
filamente: filamente,
fotoBase64: fotoBase64,
);
Map<String, dynamic> toJson() => {
'bauteilId': bauteilId,
'erstelltAm': erstelltAm.toIso8601String(),
'name': name,
'verwendung': verwendung,
'druckdauerMinuten': druckdauerMinuten,
'gewichtGramm': gewichtGramm,
'abfallGramm': abfallGramm,
'filamente': filamente.map((item) => item.toJson()).toList(),
'fotoBase64': fotoBase64,
};
factory BauteilModel.fromJson(Map<String, dynamic> json) => BauteilModel(
bauteilId: json['bauteilId'] as String,
erstelltAm: DateTime.parse(json['erstelltAm'] as String),
name: json['name'] as String,
verwendung: json['verwendung'] as String,
druckdauerMinuten: json['druckdauerMinuten'] as int,
gewichtGramm: (json['gewichtGramm'] as num).toDouble(),
abfallGramm: (json['abfallGramm'] as num).toDouble(),
fotoBase64: json['fotoBase64'] as String?,
filamente: (json['filamente'] as List)
.map(
(item) => BauteilFilamentModel.fromJson(
Map<String, dynamic>.from(item as Map),
),
)
.toList(),
);
}
+16 -3
View File
@@ -19,6 +19,19 @@ class KaufFilamentModel {
required this.gewicht,
});
/// Supports old purchases saved as "1kg" as well as gram values.
double? get gewichtGramm {
final match = RegExp(
r'^\s*(\d+(?:[.,]\d+)?)\s*(kg|g)?\s*$',
caseSensitive: false,
).firstMatch(gewicht);
if (match == null) return null;
final value = double.tryParse(match.group(1)!.replaceAll(',', '.'));
if (value == null || !value.isFinite) return null;
final grams = value * (match.group(2)?.toLowerCase() == 'kg' ? 1000 : 1);
return grams.isFinite ? grams : null;
}
Map<String, dynamic> toJson() => {
'filamentId': filamentId,
'kaufdatum': kaufdatum.toIso8601String(),
@@ -39,7 +52,7 @@ class KaufFilamentModel {
material: json['material'] as String,
preis: (json['preis'] as num).toDouble(),
farbe: json['farbe'] as String,
gewicht: json['gewicht'] ?? '1000'
gewicht: json['gewicht'] ?? '1000',
);
}
@@ -61,7 +74,7 @@ class KaufFilamentModel {
material: material ?? this.material,
preis: preis ?? this.preis,
farbe: farbe ?? this.farbe,
gewicht: gewicht ?? this.gewicht
gewicht: gewicht ?? this.gewicht,
);
}
}
}
+67 -17
View File
@@ -1,25 +1,75 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controller/anwendung_controller.dart';
import '../helper/display_formats.dart';
import '../widgets/anwendung_form.dart';
import '../widgets/entry_panel.dart';
class AnwendungView extends GetView<AnwendungController> {
static const routeName = '/anwendung-view';
const AnwendungView({super.key, required String filamentId});
static const routeName = '/anwendung-view';
const AnwendungView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Anwendung'),
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
Get.back();
},
)
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Filament verarbeiten')),
body: SafeArea(
top: false,
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1000),
child: Obx(() {
if (controller.filament.value == null) {
return const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'Das ausgewählte Filament wurde nicht gefunden. Bitte wähle ein gespeichertes Filament aus.',
),
),
);
}
return CustomScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
slivers: [
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverToBoxAdapter(
child: AnwendungForm(controller: controller),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 24),
sliver: Obx(() {
final entries = controller.anwendungen.toList();
return SavedEntries(
title: 'Gespeicherte Profile',
count: entries.length,
emptyText: 'Noch keine Verarbeitungsprofile für dieses Filament gespeichert.',
itemBuilder: (context, index) {
final entry = entries[index];
return SavedEntryCard(
key: ValueKey(entry.anwendungId),
title:
'Druck ${DisplayFormats.number(entry.druckTemperatur)} °C · Bett ${DisplayFormats.number(entry.druckbettTemperatur)} °C',
subtitle: DisplayFormats.date(entry.erstelltAm),
details: [
'Bauraum geschlossen: ${entry.bauraumGeschlossen ? "Ja" : "Nein"}',
if (entry.bauraumTemperatur != null)
'Bauraum ${DisplayFormats.number(entry.bauraumTemperatur!)} °C',
],
notes: entry.sonstiges,
);
},
);
}),
),
],
);
}),
),
),
body: Container(),
);
}
),
);
}
+99
View File
@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controller/bauteil_controller.dart';
import '../helper/display_formats.dart';
import '../models/bauteil_model.dart';
import '../widgets/bauteil_card.dart';
import '../widgets/bauteil_delete_dialog.dart';
import '../widgets/bauteil_form.dart';
import '../widgets/entry_panel.dart';
class BauteilView extends GetView<BauteilController> {
static const routeName = '/bauteile-view';
const BauteilView({super.key});
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Gedruckte Bauteile')),
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: BauteilForm(controller: controller),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 24),
sliver: Obx(() {
final entries = controller.bauteile.toList();
return SavedEntries(
title: 'Deine Bauteile',
count: entries.length,
emptyText: 'Noch keine Bauteile gespeichert. Erfasse deinen ersten Druck.',
itemBuilder: (context, index) {
final entry = entries[index];
return BauteilCard(
key: ValueKey(entry.bauteilId),
bauteil: entry,
onAction: (action) =>
_handleAction(context, action, entry),
filamentDetails: entry.filamente
.map((item) {
final profile = controller.profile
.firstWhereOrNull(
(profile) =>
profile.anwendungId == item.anwendungId,
);
final suffix = profile == null
? ''
: ' · Profil ${DisplayFormats.number(profile.druckTemperatur)} °C / Bett ${DisplayFormats.number(profile.druckbettTemperatur)} °C';
final usage = item.verbrauchGramm > 0
? '${DisplayFormats.number(item.verbrauchGramm)} g'
: 'Verbrauch noch nicht erfasst';
return '${controller.filamentLabel(item.filamentId)} · $usage$suffix';
})
.join('\n'),
);
},
);
}),
),
],
),
),
),
),
);
Future<void> _handleAction(
BuildContext context,
String action,
BauteilModel entry,
) async {
switch (action) {
case 'edit':
await controller.editBauteil(entry);
break;
case 'duplicate':
await controller.duplicateBauteil(entry);
break;
case 'delete':
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => BauteilDeleteDialog(name: entry.name),
);
if (confirmed == true) await controller.deleteBauteil(entry.bauteilId);
break;
}
}
}
+82 -264
View File
@@ -1,10 +1,13 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import '../controller/home_controller.dart';
import '../models/kauf_filament_model.dart';
import '../theme/app_theme.dart';
import '../widgets/filament_form.dart';
import '../widgets/filament_inventory.dart';
import 'anwendung_view.dart';
import 'bauteil_view.dart';
class HomeView extends GetView<HomeController> {
static const routeName = '/home-view';
@@ -12,271 +15,91 @@ class HomeView extends GetView<HomeController> {
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat('dd.MM.yyyy');
return Scaffold(
appBar: AppBar(
title: const Text('Filament Einkaufsverwaltung'),
centerTitle: true,
backgroundColor: Colors.blue.shade300,
),
body: Column(
children: [
// EINGABEBEREICH
Padding(
padding: const EdgeInsets.all(12),
child: Form(
key: controller.formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Obx(() => ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
side: const BorderSide(
color: Colors.grey,
width: 1,
),
),
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
title: const Text('Kaufdatum'),
subtitle: Text(
dateFormat.format(controller.kaufdatum.value),
),
trailing: const Icon(Icons.calendar_month),
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: controller.kaufdatum.value,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (date != null) {
controller.kaufdatum.value = date;
}
},
)),
const SizedBox(height: 8),
TextFormField(
controller: controller.haendlerController,
decoration: const InputDecoration(
labelText: 'Wo gekauft / Händler',
border: OutlineInputBorder(),
),
validator: (value) =>
value == null || value.trim().isEmpty
? 'Händler eingeben'
: null,
),
const SizedBox(height: 8),
TextFormField(
controller: controller.markeController,
decoration: const InputDecoration(
labelText: 'Marke',
border: OutlineInputBorder(),
),
validator: (value) =>
value == null || value.trim().isEmpty
? 'Marke eingeben'
: null,
),
const SizedBox(height: 8),
Obx(() => DropdownButtonFormField<String>(
key: ValueKey(
'${controller.editingId.value ?? "new"}'
'_${controller.material.value}',
),
initialValue: controller.material.value,
decoration: const InputDecoration(
labelText: 'Material',
border: OutlineInputBorder(),
),
items: controller.materialien.map((item) {
return DropdownMenuItem(
value: item,
child: Text(item),
);
}).toList(),
onChanged: (value) {
if (value != null) {
controller.material.value = value;
}
},
)),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextFormField(
controller: controller.farbeController,
decoration: const InputDecoration(
labelText: 'Farbe',
border: OutlineInputBorder(),
),
validator: (value) =>
value == null || value.trim().isEmpty
? 'Farbe eingeben'
: null,
),
),
const SizedBox(width: 8),
Expanded(
child: TextFormField(
controller: controller.preisController,
keyboardType:
const TextInputType.numberWithOptions(
decimal: true,
),
decoration: const InputDecoration(
labelText: 'Preis (€)',
border: OutlineInputBorder(),
),
validator: (value) {
final price = double.tryParse(
(value ?? '').replaceAll(',', '.'),
);
if (price == null ||
!price.isFinite ||
price < 0) {
return 'Preis ungültig';
}
return null;
},
),
),
],
),
const SizedBox(height: 8),
TextFormField(
controller: controller.gewichtController,
decoration: const InputDecoration(
labelText: 'Gewicht in Gramm (g)',
border: OutlineInputBorder(),
),
validator: (value) =>
value == null || value.trim().isEmpty
? 'Gewicht eingeben g'
: null,
),
const SizedBox(height: 12),
Obx(() => Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: controller.saveFilament,
icon: Icon(
controller.editingId.value == null
? Icons.add
: Icons.save,
),
label: Text(
controller.editingId.value == null
? 'Filament speichern'
: 'Änderungen speichern',
),
),
),
if (controller.editingId.value != null)
IconButton(
tooltip: 'Bearbeitung abbrechen',
onPressed: controller.resetForm,
icon: const Icon(Icons.close),
),
],
)),
],
toolbarHeight: 80,
actions: [
IconButton(
tooltip: 'Bauteile verwalten',
icon: const Icon(Icons.view_in_ar_outlined),
onPressed: () => Get.toNamed(BauteilView.routeName),
),
const SizedBox(width: 8),
],
title: const Row(
children: [
Icon(Icons.all_inbox_rounded, color: AppTheme.teal, size: 28),
SizedBox(width: 12),
Flexible(
child: Text(
'Filamentverwaltung',
style: TextStyle(fontSize: 21, fontWeight: FontWeight.w700),
),
),
),
const Divider(),
// LISTENBEREICH
Expanded(
child: Obx(() {
final filamente = controller.filamente;
if (filamente.isEmpty) {
return const Center(
child: Text('Noch keine Filamente gespeichert'),
],
),
),
body: SafeArea(
top: false,
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1320),
child: LayoutBuilder(
builder: (context, constraints) {
final wide = constraints.maxWidth >= 900;
final form = FilamentForm(controller: controller);
final inventory = FilamentInventory(
controller: controller,
onOpen: (filament) => Get.toNamed(
'${AnwendungView.routeName}/${Uri.encodeComponent(filament.filamentId)}',
),
onAction: (action, filament) =>
_handleAction(context, action, filament),
);
}
return ListView.builder(
itemCount: filamente.length,
itemBuilder: (context, index) {
final filament = filamente[index];
return Card(
margin: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
child: ListTile(
leading: const CircleAvatar(
child: Icon(Icons.inventory_2_outlined),
),
title: Text(
'${filament.material} – ${filament.farbe}',
),
subtitle: Text(
'${filament.haendler}\n'
'${dateFormat.format(filament.kaufdatum)}'
' | ${filament.preis.toStringAsFixed(2)} €',
),
isThreeLine: true,
onTap: () => Get.to(
() => AnwendungView(
filamentId: filament.filamentId,
if (wide) {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 390,
child: SingleChildScrollView(
controller: controller.formScrollController,
child: form,
),
),
),
trailing: PopupMenuButton<String>(
onSelected: (action) =>
_handleAction(context, action, filament),
itemBuilder: (context) => const [
PopupMenuItem(
value: 'edit',
child: ListTile(
leading: Icon(Icons.edit),
title: Text('Bearbeiten'),
),
const SizedBox(width: 24),
Expanded(
child: CustomScrollView(
controller: controller.pageScrollController,
slivers: [inventory],
),
PopupMenuItem(
value: 'duplicate',
child: ListTile(
leading: Icon(Icons.copy),
title: Text('Duplizieren'),
),
),
PopupMenuItem(
value: 'delete',
child: ListTile(
leading: Icon(Icons.delete_outline),
title: Text('Löschen'),
),
),
],
),
),
],
),
);
},
);
}),
}
return CustomScrollView(
controller: controller.pageScrollController,
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
sliver: SliverToBoxAdapter(child: form),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
sliver: inventory,
),
],
);
},
),
),
],
),
),
);
}
@@ -288,21 +111,17 @@ class HomeView extends GetView<HomeController> {
) async {
switch (action) {
case 'edit':
controller.editFilament(filament);
await controller.editFilament(filament);
break;
case 'duplicate':
await controller.duplicateFilament(filament);
break;
case 'delete':
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Filament löschen?'),
content: Text(
'${filament.material} – ${filament.farbe} löschen?',
),
content: Text('${filament.material} – ${filament.farbe} löschen?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
@@ -315,11 +134,10 @@ class HomeView extends GetView<HomeController> {
],
),
);
if (confirmed == true) {
await controller.deleteFilament(filament.filamentId);
}
break;
}
}
}
}
+67
View File
@@ -0,0 +1,67 @@
import 'dart:math' as math;
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:file_selector/file_selector.dart';
class BauteilPhotoService {
Future<Uint8List?> pickPhoto() async {
const images = XTypeGroup(
label: 'Fotos',
extensions: ['jpg', 'jpeg', 'png', 'webp'],
uniformTypeIdentifiers: [
'public.jpeg',
'public.png',
'org.webmproject.webp',
],
mimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
);
final file = await openFile(acceptedTypeGroups: [images]);
if (file == null) return null;
if (await file.length() > 15 * 1024 * 1024) {
throw const FormatException('Das Foto darf höchstens 15 MB groß sein.');
}
return preparePhoto(await file.readAsBytes());
}
/// Stores a small, self-contained PNG, independent of temporary file paths.
Future<Uint8List> preparePhoto(Uint8List bytes) async {
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
try {
final descriptor = await ui.ImageDescriptor.encoded(buffer);
try {
final scale = math.min(
1.0,
960 / math.max(descriptor.width, descriptor.height),
);
final codec = await descriptor.instantiateCodec(
targetWidth: math.max(1, (descriptor.width * scale).round()),
targetHeight: math.max(1, (descriptor.height * scale).round()),
);
try {
final frame = await codec.getNextFrame();
try {
final data = await frame.image.toByteData(
format: ui.ImageByteFormat.png,
);
if (data == null) {
throw const FormatException('Foto konnte nicht gelesen werden.');
}
return data.buffer.asUint8List(
data.offsetInBytes,
data.lengthInBytes,
);
} finally {
frame.image.dispose();
}
} finally {
codec.dispose();
}
} finally {
descriptor.dispose();
}
} finally {
buffer.dispose();
}
}
}
+64
View File
@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
class AppTheme {
static const ink = Color(0xFF152C3D);
static const muted = Color(0xFF5C7080);
static const teal = Color(0xFF007F78);
static const inputSurface = Color(0xFFEDF8F5);
static const inventorySurface = Color(0xFF152C3D);
static ThemeData get light {
final scheme = ColorScheme.fromSeed(
seedColor: teal,
primary: teal,
onPrimary: Colors.white,
surface: Colors.white,
onSurface: ink,
);
final border = OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFFCCDEDA)),
);
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor: const Color(0xFFF4F7F9),
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFFF4F7F9),
foregroundColor: ink,
surfaceTintColor: Colors.transparent,
centerTitle: false,
elevation: 0,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.white,
labelStyle: const TextStyle(color: muted),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 17,
),
border: border,
enabledBorder: border,
focusedBorder: border.copyWith(
borderSide: const BorderSide(color: teal, width: 2),
),
errorBorder: border.copyWith(
borderSide: BorderSide(color: scheme.error),
),
focusedErrorBorder: border.copyWith(
borderSide: BorderSide(color: scheme.error, width: 2),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
),
);
}
}
+102
View File
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controller/anwendung_controller.dart';
import '../helper/input_validators.dart';
import 'entry_panel.dart';
class AnwendungForm extends StatelessWidget {
const AnwendungForm({super.key, required this.controller});
final AnwendungController controller;
@override
Widget build(BuildContext context) {
final filament = controller.filament.value!;
return EntryPanel(
title: 'Verarbeitung erfassen',
subtitle:
'${filament.marke} · ${filament.material} · ${filament.farbe}\n'
'Speichere die Einstellungen für dieses Filament als Profil.',
child: Form(
key: controller.formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
EntryFieldGrid(
children: [
EntryNumberField(
controller: controller.druckController,
label: 'Drucktemperatur',
unit: '°C',
validator: InputValidators.positive,
),
EntryNumberField(
controller: controller.druckbettController,
label: 'Druckbetttemperatur',
unit: '°C',
validator: InputValidators.nonNegative,
),
],
),
const SizedBox(height: 16),
Material(
color: Colors.transparent,
child: Obx(
() => SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('Bauraum geschlossen'),
subtitle: Text(
controller.bauraumGeschlossen.value ? 'Ja' : 'Nein',
),
value: controller.bauraumGeschlossen.value,
onChanged: (value) =>
controller.bauraumGeschlossen.value = value,
),
),
),
const SizedBox(height: 16),
EntryNumberField(
controller: controller.bauraumController,
label: 'Bauraumtemperatur (optional)',
unit: '°C',
validator: (value) =>
InputValidators.nonNegative(value, optional: true),
),
const SizedBox(height: 16),
TextFormField(
controller: controller.sonstigesController,
minLines: 3,
maxLines: 6,
decoration: const InputDecoration(
labelText: 'Sonstiges (optional)',
alignLabelWithHint: true,
hintText:
'Zum Beispiel Geschwindigkeit, Kühlung oder Besonderheiten',
),
),
const SizedBox(height: 24),
Obx(
() => FilledButton.icon(
onPressed: controller.isSaving.value
? null
: controller.saveAnwendung,
icon: const Icon(Icons.save_outlined),
label: Text(
controller.isSaving.value
? 'Wird gespeichert …'
: 'Verarbeitungsprofil speichern',
),
),
),
Obx(
() => EntryMessage(
message: controller.message.value,
hasError: controller.hasError.value,
),
),
],
),
),
);
}
}
+136
View File
@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import '../helper/display_formats.dart';
import '../models/bauteil_model.dart';
import '../theme/app_theme.dart';
import 'bauteil_photo.dart';
class BauteilCard extends StatelessWidget {
const BauteilCard({
super.key,
required this.bauteil,
required this.filamentDetails,
required this.onAction,
});
final BauteilModel bauteil;
final String filamentDetails;
final ValueChanged<String> onAction;
@override
Widget build(BuildContext context) => Card(
margin: EdgeInsets.zero,
elevation: 0,
color: Colors.white,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
bauteil.name,
style: const TextStyle(
fontWeight: FontWeight.w700,
fontSize: 18,
),
),
const SizedBox(height: 6),
Text(
bauteil.verwendung,
style: const TextStyle(color: AppTheme.muted),
),
const SizedBox(height: 6),
Text(
DisplayFormats.date(bauteil.erstelltAm),
style: const TextStyle(
color: AppTheme.muted,
fontSize: 12,
),
),
],
),
),
const SizedBox(width: 12),
Column(
children: [
PopupMenuButton<String>(
tooltip: 'Bauteil verwalten',
onSelected: onAction,
itemBuilder: (context) => const [
PopupMenuItem(
value: 'edit',
child: ListTile(
leading: Icon(Icons.edit_outlined),
title: Text('Bearbeiten'),
contentPadding: EdgeInsets.zero,
),
),
PopupMenuItem(
value: 'duplicate',
child: ListTile(
leading: Icon(Icons.copy_outlined),
title: Text('Duplizieren'),
contentPadding: EdgeInsets.zero,
),
),
PopupMenuItem(
value: 'delete',
child: ListTile(
leading: Icon(Icons.delete_outline),
title: Text('Löschen'),
contentPadding: EdgeInsets.zero,
),
),
],
),
BauteilPhoto(bytes: bauteil.fotoBytes, size: 72),
],
),
],
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_detail(DisplayFormats.duration(bauteil.druckdauerMinuten)),
_detail(
'Bauteil ${DisplayFormats.number(bauteil.gewichtGramm)} g',
),
_detail('Abfall ${DisplayFormats.number(bauteil.abfallGramm)} g'),
_detail(
'Gesamt ${DisplayFormats.number(bauteil.gesamtverbrauchGramm)} g',
),
if (bauteil.filamente.length > 1)
_detail(
'Mehrfarbdruck · ${bauteil.filamente.length} Filamente',
),
],
),
const SizedBox(height: 14),
Text(
filamentDetails,
style: const TextStyle(color: AppTheme.muted, height: 1.5),
),
],
),
),
);
Widget _detail(String text) => Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: AppTheme.inputSurface,
borderRadius: BorderRadius.circular(8),
),
child: Text(text, style: const TextStyle(color: AppTheme.teal)),
);
}
+24
View File
@@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
class BauteilDeleteDialog extends StatelessWidget {
const BauteilDeleteDialog({super.key, required this.name});
final String name;
@override
Widget build(BuildContext context) => AlertDialog(
title: const Text('Bauteil löschen?'),
content: Text(
'„$name“ löschen? Der gebuchte Filamentverbrauch wird den Rollen wieder gutgeschrieben.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Löschen'),
),
],
);
}
+127
View File
@@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controller/bauteil_controller.dart';
import '../helper/display_formats.dart';
import '../helper/input_validators.dart';
import '../theme/app_theme.dart';
import 'entry_panel.dart';
class BauteilFilamentAuswahl extends StatelessWidget {
const BauteilFilamentAuswahl({super.key, required this.controller});
final BauteilController controller;
@override
Widget build(BuildContext context) => Obx(() {
final ids = {
...controller.filamente.map((item) => item.filamentId),
...controller.selectedFilamente.keys,
};
if (ids.isEmpty) {
return const Text(
'Erfasse zuerst ein Filament in der Einkaufsverwaltung.',
);
}
return Column(
children: ids.map((id) {
final selected = controller.selectedFilamente.containsKey(id);
final profiles = controller.profilesFor(id);
final available = controller.verfuegbarGramm(id);
final checkbox = CheckboxListTile(
key: ValueKey('filament-$id'),
title: Text(controller.filamentLabel(id)),
subtitle: Text(
available == null
? 'Startgewicht unbekannt'
: '${DisplayFormats.number(available)} g verfügbar',
),
value: selected,
controlAffinity: ListTileControlAffinity.leading,
onChanged: (value) => controller.toggleFilament(id, value ?? false),
);
final consumption = selected
? Padding(
padding: const EdgeInsets.all(16),
child: EntryNumberField(
controller: controller.verbrauchController(id),
label: 'Verbrauch',
unit: 'g',
validator: InputValidators.positive,
),
)
: const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Material(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
LayoutBuilder(
builder: (context, constraints) =>
constraints.maxWidth >= 500 && selected
? Row(
children: [
Expanded(child: checkbox),
SizedBox(width: 190, child: consumption),
],
)
: Column(children: [checkbox, if (selected) consumption]),
),
if (selected && profiles.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: DropdownButtonFormField<String>(
key: ValueKey(
'profile-$id-${controller.selectedFilamente[id]}',
),
initialValue:
profiles.any(
(item) =>
item.anwendungId ==
controller.selectedFilamente[id],
)
? controller.selectedFilamente[id]
: '',
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Verarbeitungsprofil',
),
items: [
const DropdownMenuItem(
value: '',
child: Text('Ohne Profil'),
),
...profiles.map(
(profile) => DropdownMenuItem(
value: profile.anwendungId,
child: Text(
'${DisplayFormats.number(profile.druckTemperatur)} °C · Bett ${DisplayFormats.number(profile.druckbettTemperatur)} °C · ${DisplayFormats.date(profile.erstelltAm)}',
overflow: TextOverflow.ellipsis,
),
),
),
],
onChanged: (value) => controller.selectProfile(
id,
value == '' ? null : value,
),
),
),
if (selected && profiles.isEmpty)
const Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Text(
'Noch kein Profil. Einstellungen kannst du über das Filament in der Einkaufsübersicht erfassen.',
style: TextStyle(color: AppTheme.muted),
),
),
],
),
),
);
}).toList(),
);
});
}
+123
View File
@@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controller/bauteil_controller.dart';
import '../helper/input_validators.dart';
import '../theme/app_theme.dart';
import 'bauteil_filament_auswahl.dart';
import 'bauteil_photo_picker.dart';
import 'entry_panel.dart';
class BauteilForm extends StatelessWidget {
const BauteilForm({super.key, required this.controller});
final BauteilController controller;
@override
Widget build(BuildContext context) => Obx(
() => EntryPanel(
title: controller.editingId.value == null
? 'Bauteil erfassen'
: 'Bauteil bearbeiten',
subtitle: 'Dokumentiere deinen Druck und den Verbrauch je Filament.',
child: Form(
key: controller.formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: controller.nameController,
decoration: const InputDecoration(labelText: 'Bauteilname'),
validator: InputValidators.requiredText,
),
const SizedBox(height: 16),
TextFormField(
controller: controller.verwendungController,
decoration: const InputDecoration(
labelText: 'Art der Verwendung',
),
validator: InputValidators.requiredText,
),
const SizedBox(height: 16),
EntryFieldGrid(
children: [
EntryNumberField(
controller: controller.stundenController,
label: 'Druckdauer: Stunden',
unit: 'h',
validator: InputValidators.hours,
),
EntryNumberField(
controller: controller.minutenController,
label: 'Druckdauer: Minuten',
unit: 'min',
validator: InputValidators.minutes,
),
EntryNumberField(
controller: controller.gewichtController,
label: 'Bauteilgewicht',
unit: 'g',
validator: InputValidators.positive,
),
EntryNumberField(
controller: controller.abfallController,
label: 'Abfallmenge',
unit: 'g',
validator: InputValidators.nonNegative,
),
],
),
const SizedBox(height: 24),
BauteilPhotoPicker(controller: controller),
const SizedBox(height: 24),
const Text(
'Verwendete Filamente',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
),
const SizedBox(height: 6),
const Text(
'Wähle für Mehrfarbdruck mehrere Filamente. Gib je Rolle den gesamten Verbrauch inklusive ihres Abfallanteils an. Die Summe muss Bauteilgewicht + Abfall ergeben.',
style: TextStyle(color: AppTheme.muted, height: 1.5),
),
const SizedBox(height: 12),
BauteilFilamentAuswahl(controller: controller),
const SizedBox(height: 12),
Obx(
() => FilledButton.icon(
onPressed:
controller.isSaving.value ||
controller.isPickingPhoto.value ||
controller.filamente.isEmpty
? null
: controller.saveBauteil,
icon: Icon(
controller.editingId.value == null ? Icons.add : Icons.check,
),
label: Text(
controller.isSaving.value
? 'Wird gespeichert …'
: controller.editingId.value == null
? 'Bauteil speichern'
: 'Änderungen speichern',
),
),
),
if (controller.editingId.value != null)
TextButton.icon(
onPressed: controller.isSaving.value
? null
: controller.resetForm,
icon: const Icon(Icons.close),
label: const Text('Bearbeitung abbrechen'),
),
Obx(
() => EntryMessage(
message: controller.message.value,
hasError: controller.hasError.value,
),
),
],
),
),
),
);
}
+37
View File
@@ -0,0 +1,37 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
class BauteilPhoto extends StatelessWidget {
const BauteilPhoto({super.key, required this.bytes, this.size = 80});
final Uint8List? bytes;
final double size;
@override
Widget build(BuildContext context) => ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: size,
height: size,
child: bytes == null
? _placeholder()
: Image.memory(
bytes!,
fit: BoxFit.cover,
semanticLabel: 'Foto des Bauteils',
errorBuilder: (context, error, stackTrace) => _placeholder(),
),
),
);
Widget _placeholder() => Container(
color: AppTheme.inputSurface,
child: const Icon(
Icons.view_in_ar_outlined,
color: AppTheme.teal,
size: 32,
),
);
}
+59
View File
@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controller/bauteil_controller.dart';
import 'bauteil_photo.dart';
class BauteilPhotoPicker extends StatelessWidget {
const BauteilPhotoPicker({super.key, required this.controller});
final BauteilController controller;
@override
Widget build(BuildContext context) => Obx(
() => Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
BauteilPhoto(bytes: controller.foto.value),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Bauteilfoto (optional)',
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
onPressed:
controller.isPickingPhoto.value ||
controller.isSaving.value
? null
: controller.pickPhoto,
icon: const Icon(Icons.add_photo_alternate_outlined),
label: Text(
controller.isPickingPhoto.value
? 'Wird geladen …'
: 'Foto auswählen',
),
),
if (controller.foto.value != null)
TextButton(
onPressed: controller.isSaving.value
? null
: controller.removePhoto,
child: const Text('Foto entfernen'),
),
],
),
],
),
),
],
),
);
}
+240
View File
@@ -0,0 +1,240 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
class EntryPanel extends StatelessWidget {
const EntryPanel({
super.key,
required this.title,
required this.subtitle,
required this.child,
});
final String title;
final String subtitle;
final Widget child;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppTheme.inputSurface,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: const Color(0xFFCEE6DF)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
title,
style: const TextStyle(fontSize: 25, fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Text(
subtitle,
style: const TextStyle(color: AppTheme.muted, height: 1.5),
),
const SizedBox(height: 24),
child,
],
),
);
}
class EntryMessage extends StatelessWidget {
const EntryMessage({
super.key,
required this.message,
required this.hasError,
});
final String? message;
final bool hasError;
@override
Widget build(BuildContext context) => message == null
? const SizedBox.shrink()
: Padding(
padding: const EdgeInsets.only(top: 16),
child: Semantics(
liveRegion: true,
child: Text(
message!,
style: TextStyle(
color: hasError
? Theme.of(context).colorScheme.error
: AppTheme.teal,
),
),
),
);
}
class EntryNumberField extends StatelessWidget {
const EntryNumberField({
super.key,
required this.controller,
required this.label,
required this.unit,
required this.validator,
});
final TextEditingController controller;
final String label;
final String unit;
final FormFieldValidator<String> validator;
@override
Widget build(BuildContext context) => TextFormField(
controller: controller,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(labelText: label, suffixText: unit),
validator: validator,
);
}
class EntryFieldGrid extends StatelessWidget {
const EntryFieldGrid({super.key, required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, constraints) {
final columns = constraints.maxWidth >= 600 ? 2 : 1;
final width = (constraints.maxWidth - (columns - 1) * 16) / columns;
return Wrap(
spacing: 16,
runSpacing: 16,
children: children
.map((child) => SizedBox(width: width, child: child))
.toList(),
);
},
);
}
/// Lazily builds saved entries below the input form.
class SavedEntries extends StatelessWidget {
const SavedEntries({
super.key,
required this.title,
required this.count,
required this.emptyText,
required this.itemBuilder,
});
final String title;
final int count;
final String emptyText;
final IndexedWidgetBuilder itemBuilder;
@override
Widget build(BuildContext context) => DecoratedSliver(
decoration: BoxDecoration(
color: AppTheme.inventorySurface,
borderRadius: BorderRadius.circular(24),
),
sliver: SliverPadding(
padding: const EdgeInsets.all(24),
sliver: SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Text(
'$title ($count)',
style: const TextStyle(
color: Colors.white,
fontSize: 23,
fontWeight: FontWeight.w700,
),
),
),
),
if (count == 0)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Text(
emptyText,
style: const TextStyle(color: Color(0xFFB9CCD9), height: 1.5),
),
),
)
else
SliverList.separated(
itemCount: count,
itemBuilder: itemBuilder,
separatorBuilder: (context, index) => const SizedBox(height: 12),
),
],
),
),
);
}
class SavedEntryCard extends StatelessWidget {
const SavedEntryCard({
super.key,
required this.title,
required this.subtitle,
required this.details,
this.notes = '',
});
final String title;
final String subtitle;
final List<String> details;
final String notes;
@override
Widget build(BuildContext context) => Card(
margin: EdgeInsets.zero,
elevation: 0,
color: Colors.white,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
title,
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 18),
),
const SizedBox(height: 6),
Text(
subtitle,
style: const TextStyle(color: AppTheme.muted, height: 1.5),
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: details
.map(
(detail) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: AppTheme.inputSurface,
borderRadius: BorderRadius.circular(8),
),
child: Text(
detail,
style: const TextStyle(color: AppTheme.teal),
),
),
)
.toList(),
),
if (notes.isNotEmpty) ...[
const SizedBox(height: 14),
Text(
notes,
style: const TextStyle(color: AppTheme.muted, height: 1.5),
),
],
],
),
),
);
}
+234
View File
@@ -0,0 +1,234 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/kauf_filament_model.dart';
import '../theme/app_theme.dart';
import '../helper/display_formats.dart';
class FilamentCard extends StatelessWidget {
const FilamentCard({
super.key,
required this.filament,
required this.onTap,
required this.onAction,
this.verbrauchGramm = 0,
});
final KaufFilamentModel filament;
final VoidCallback onTap;
final ValueChanged<String> onAction;
final double verbrauchGramm;
@override
Widget build(BuildContext context) {
final price = NumberFormat.currency(
locale: 'de_AT',
symbol: '€',
).format(filament.preis);
final weight = filament.gewichtGramm != null
? '${DisplayFormats.number(filament.gewichtGramm!)} g'
: RegExp(r'[a-zA-Z]').hasMatch(filament.gewicht)
? filament.gewicht
: '${filament.gewicht} g';
final start = filament.gewichtGramm;
final rest = start == null ? null : start - verbrauchGramm;
return Card(
margin: EdgeInsets.zero,
color: Colors.white,
surfaceTintColor: Colors.transparent,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.inputSurface,
borderRadius: BorderRadius.circular(14),
),
child: const Icon(
Icons.inventory_2_outlined,
color: AppTheme.teal,
size: 26,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
filament.marke.isEmpty
? filament.material
: filament.marke,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
filament.farbe,
style: const TextStyle(
color: AppTheme.muted,
fontSize: 14,
),
),
],
),
),
PopupMenuButton<String>(
tooltip: 'Filament verwalten',
onSelected: onAction,
itemBuilder: (context) => const [
PopupMenuItem(
value: 'edit',
child: ListTile(
leading: Icon(Icons.edit_outlined),
title: Text('Bearbeiten'),
contentPadding: EdgeInsets.zero,
),
),
PopupMenuItem(
value: 'duplicate',
child: ListTile(
leading: Icon(Icons.copy_outlined),
title: Text('Duplizieren'),
contentPadding: EdgeInsets.zero,
),
),
PopupMenuItem(
value: 'delete',
child: ListTile(
leading: Icon(Icons.delete_outline),
title: Text('Löschen'),
contentPadding: EdgeInsets.zero,
),
),
],
),
],
),
const SizedBox(height: 16),
Text(
rest == null
? 'Restgewicht unbekannt'
: '${DisplayFormats.number(rest)} g verbleibend',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: rest != null && rest < 0
? Theme.of(context).colorScheme.error
: AppTheme.teal,
),
),
if (start != null && start > 0) ...[
const SizedBox(height: 8),
LinearProgressIndicator(
value: (rest! / start).clamp(0.0, 1.0),
minHeight: 6,
borderRadius: BorderRadius.circular(8),
backgroundColor: AppTheme.inputSurface,
),
],
const SizedBox(height: 8),
Text(
'${DisplayFormats.number(verbrauchGramm)} g verbraucht',
style: const TextStyle(color: AppTheme.muted, fontSize: 12),
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_DetailPill(
label: filament.material,
icon: Icons.layers_outlined,
),
_DetailPill(label: weight, icon: Icons.scale_outlined),
],
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Divider(height: 1, color: Color(0xFFE7EDF0)),
),
Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 16,
runSpacing: 12,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
filament.haendler,
style: const TextStyle(color: AppTheme.muted),
),
const SizedBox(height: 4),
Text(
DateFormat('dd.MM.yyyy').format(filament.kaufdatum),
style: const TextStyle(
color: AppTheme.muted,
fontSize: 12,
),
),
],
),
Text(
price,
style: const TextStyle(
color: AppTheme.teal,
fontSize: 21,
fontWeight: FontWeight.w700,
),
),
],
),
],
),
),
),
);
}
}
class _DetailPill extends StatelessWidget {
const _DetailPill({required this.label, required this.icon});
final String label;
final IconData icon;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFF0F5F7),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: AppTheme.muted),
const SizedBox(width: 6),
Flexible(
child: Text(
label,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
),
],
),
);
}
}
+255
View File
@@ -0,0 +1,255 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import '../controller/home_controller.dart';
import '../theme/app_theme.dart';
import '../helper/input_validators.dart';
class FilamentForm extends StatelessWidget {
const FilamentForm({super.key, required this.controller});
final HomeController controller;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppTheme.inputSurface,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: const Color(0xFFCEE6DF)),
),
child: Form(
key: controller.formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Row(
children: [
Icon(Icons.add_circle_outline, color: AppTheme.teal),
SizedBox(width: 10),
Flexible(
child: Text(
'EINKAUF ERFASSEN',
style: TextStyle(
color: AppTheme.teal,
fontSize: 12,
fontWeight: FontWeight.w700,
letterSpacing: 1.4,
),
),
),
],
),
const SizedBox(height: 14),
Obx(
() => Text(
controller.editingId.value == null
? 'Neues Filament'
: 'Filament bearbeiten',
style: const TextStyle(
fontSize: 25,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(height: 6),
const Text(
'Alle Details zu deinem Einkauf an einem Ort.',
style: TextStyle(color: AppTheme.muted, height: 1.5),
),
const SizedBox(height: 24),
Obx(
() => Material(
color: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Color(0xFFCCDEDA)),
),
clipBehavior: Clip.antiAlias,
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 3,
),
leading: const Icon(
Icons.calendar_today_outlined,
color: AppTheme.teal,
),
title: const Text(
'Kaufdatum',
style: TextStyle(fontSize: 12, color: AppTheme.muted),
),
subtitle: Text(
DateFormat('dd.MM.yyyy').format(controller.kaufdatum.value),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.ink,
),
),
trailing: const Icon(
Icons.expand_more,
color: AppTheme.muted,
),
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: controller.kaufdatum.value,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (date != null) controller.kaufdatum.value = date;
},
),
),
),
const SizedBox(height: 16),
TextFormField(
controller: controller.haendlerController,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Händler',
prefixIcon: Icon(Icons.storefront_outlined),
),
validator: (value) => value == null || value.trim().isEmpty
? 'Händler eingeben'
: null,
),
const SizedBox(height: 16),
TextFormField(
controller: controller.markeController,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Marke',
prefixIcon: Icon(Icons.sell_outlined),
),
validator: (value) => value == null || value.trim().isEmpty
? 'Marke eingeben'
: null,
),
const SizedBox(height: 16),
Obx(
() => DropdownButtonFormField<String>(
key: ValueKey(
'${controller.editingId.value ?? "new"}_${controller.material.value}',
),
initialValue: controller.material.value,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Material',
prefixIcon: Icon(Icons.layers_outlined),
),
items: controller.materialien
.toSet()
.map(
(item) =>
DropdownMenuItem(value: item, child: Text(item)),
)
.toList(),
onChanged: (value) {
if (value != null) controller.material.value = value;
},
),
),
const SizedBox(height: 16),
TextFormField(
controller: controller.farbeController,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Farbe',
prefixIcon: Icon(Icons.palette_outlined),
),
validator: (value) => value == null || value.trim().isEmpty
? 'Farbe eingeben'
: 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]),
],
);
},
),
const SizedBox(height: 24),
Obx(
() => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton.icon(
onPressed: controller.saveFilament,
icon: Icon(
controller.editingId.value == null
? Icons.add
: Icons.check,
),
label: Text(
controller.editingId.value == null
? 'Filament speichern'
: 'Änderungen speichern',
),
),
if (controller.editingId.value != null) ...[
const SizedBox(height: 8),
TextButton.icon(
onPressed: controller.resetForm,
icon: const Icon(Icons.close),
label: const Text('Bearbeitung abbrechen'),
),
],
],
),
),
],
),
),
);
}
}
+193
View File
@@ -0,0 +1,193 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import '../controller/home_controller.dart';
import '../models/kauf_filament_model.dart';
import '../theme/app_theme.dart';
import 'filament_card.dart';
/// A lazy list section for both the shared mobile scroll and the desktop pane.
class FilamentInventory extends StatelessWidget {
const FilamentInventory({
super.key,
required this.controller,
required this.onOpen,
required this.onAction,
});
final HomeController controller;
final ValueChanged<KaufFilamentModel> onOpen;
final void Function(String, KaufFilamentModel) onAction;
@override
Widget build(BuildContext context) {
return DecoratedSliver(
decoration: BoxDecoration(
color: AppTheme.inventorySurface,
borderRadius: BorderRadius.circular(24),
),
sliver: SliverPadding(
padding: const EdgeInsets.all(24),
sliver: Obx(() {
final filamente = controller.filamente.toList();
final verbrauch = Map<String, double>.from(controller.verbrauch);
final total = filamente.fold<double>(
0,
(sum, item) => sum + item.preis,
);
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(
Icons.inventory_2_outlined,
color: Color(0xFF79D7C6),
size: 21,
),
SizedBox(width: 10),
Flexible(
child: Text(
'DEIN BESTAND',
style: TextStyle(
color: Color(0xFF79D7C6),
fontSize: 12,
fontWeight: FontWeight.w700,
letterSpacing: 1.4,
),
),
),
],
),
const SizedBox(height: 14),
const Text(
'Filamentübersicht',
style: TextStyle(
color: Colors.white,
fontSize: 25,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
const Text(
'Deine Einkäufe, die neuesten zuerst.',
style: TextStyle(color: Color(0xFFB9CCD9), height: 1.5),
),
const SizedBox(height: 20),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
_InventoryStat(
label: 'Filamente',
value: '${filamente.length}',
),
_InventoryStat(
label: 'Gesamtausgaben',
value: NumberFormat.currency(
locale: 'de_AT',
symbol: '€',
).format(total),
),
],
),
const SizedBox(height: 24),
],
),
),
if (filamente.isEmpty)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 36),
child: Column(
children: [
Icon(
Icons.inventory_2_outlined,
size: 48,
color: Color(0xFF79D7C6),
),
SizedBox(height: 16),
Text(
'Hier beginnt dein Bestand',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 8),
Text(
'Erfasse deinen ersten Einkauf.\nDeine Filamente erscheinen dann hier.',
textAlign: TextAlign.center,
style: TextStyle(
color: Color(0xFFB9CCD9),
height: 1.6,
),
),
],
),
),
)
else
SliverList.separated(
itemCount: filamente.length,
separatorBuilder: (context, index) =>
const SizedBox(height: 12),
itemBuilder: (context, index) {
final filament = filamente[index];
return FilamentCard(
key: ValueKey(filament.filamentId),
filament: filament,
verbrauchGramm: verbrauch[filament.filamentId] ?? 0,
onTap: () => onOpen(filament),
onAction: (action) => onAction(action, filament),
);
},
),
],
);
}),
),
);
}
}
class _InventoryStat extends StatelessWidget {
const _InventoryStat({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return 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,
),
),
],
),
);
}
}