From 07a1bb02fd91a9d4f5305b31230bef743682eac0 Mon Sep 17 00:00:00 2001 From: atseirjo Date: Tue, 27 Oct 2026 15:00:16 +0100 Subject: [PATCH] add two views --- lib/controller/anwendung_controller.dart | 94 ++++- lib/controller/bauteil_controller.dart | 367 ++++++++++++++++++ lib/controller/home_controller.dart | 84 +++- lib/helper/display_formats.dart | 10 + lib/helper/filament_bestand.dart | 21 + lib/helper/input_validators.dart | 34 ++ lib/helper/sample_bindings.dart | 13 +- lib/helper/sample_routes.dart | 22 +- lib/helper/storage_keys.dart | 5 + lib/main.dart | 12 +- lib/models/anwendung_model.dart | 43 ++ lib/models/bauteil_model.dart | 107 +++++ lib/models/kauf_filament_model.dart | 19 +- lib/pages/anwendung_view.dart | 84 +++- lib/pages/bauteil_view.dart | 99 +++++ lib/pages/home_view.dart | 346 ++++------------- lib/services/bauteil_photo_service.dart | 67 ++++ lib/theme/app_theme.dart | 64 +++ lib/widgets/anwendung_form.dart | 102 +++++ lib/widgets/bauteil_card.dart | 136 +++++++ lib/widgets/bauteil_delete_dialog.dart | 24 ++ lib/widgets/bauteil_filament_auswahl.dart | 127 ++++++ lib/widgets/bauteil_form.dart | 123 ++++++ lib/widgets/bauteil_photo.dart | 37 ++ lib/widgets/bauteil_photo_picker.dart | 59 +++ lib/widgets/entry_panel.dart | 240 ++++++++++++ lib/widgets/filament_card.dart | 234 +++++++++++ lib/widgets/filament_form.dart | 255 ++++++++++++ lib/widgets/filament_inventory.dart | 193 +++++++++ linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + macos/Runner/DebugProfile.entitlements | 2 + macos/Runner/Release.entitlements | 2 + pubspec.lock | 77 ++++ pubspec.yaml | 1 + test/anwendung_bauteil_test.dart | 295 ++++++++++++++ test/bauteil_bestand_test.dart | 365 +++++++++++++++++ test/helpers/memory_storage_service.dart | 27 ++ test/widget_test.dart | 127 +++++- .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 42 files changed, 3582 insertions(+), 346 deletions(-) create mode 100644 lib/controller/bauteil_controller.dart create mode 100644 lib/helper/display_formats.dart create mode 100644 lib/helper/filament_bestand.dart create mode 100644 lib/helper/input_validators.dart create mode 100644 lib/helper/storage_keys.dart create mode 100644 lib/models/bauteil_model.dart create mode 100644 lib/pages/bauteil_view.dart create mode 100644 lib/services/bauteil_photo_service.dart create mode 100644 lib/theme/app_theme.dart create mode 100644 lib/widgets/anwendung_form.dart create mode 100644 lib/widgets/bauteil_card.dart create mode 100644 lib/widgets/bauteil_delete_dialog.dart create mode 100644 lib/widgets/bauteil_filament_auswahl.dart create mode 100644 lib/widgets/bauteil_form.dart create mode 100644 lib/widgets/bauteil_photo.dart create mode 100644 lib/widgets/bauteil_photo_picker.dart create mode 100644 lib/widgets/entry_panel.dart create mode 100644 lib/widgets/filament_card.dart create mode 100644 lib/widgets/filament_form.dart create mode 100644 lib/widgets/filament_inventory.dart create mode 100644 test/anwendung_bauteil_test.dart create mode 100644 test/bauteil_bestand_test.dart create mode 100644 test/helpers/memory_storage_service.dart diff --git a/lib/controller/anwendung_controller.dart b/lib/controller/anwendung_controller.dart index ff9a907..041070a 100644 --- a/lib/controller/anwendung_controller.dart +++ b/lib/controller/anwendung_controller.dart @@ -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(); + final formKey = GlobalKey(); + 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(); + final anwendungen = [].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 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(); + } } diff --git a/lib/controller/bauteil_controller.dart b/lib/controller/bauteil_controller.dart new file mode 100644 index 0000000..d937ac7 --- /dev/null +++ b/lib/controller/bauteil_controller.dart @@ -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(); + final BauteilPhotoService photoService; + final formKey = GlobalKey(); + 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 = {}; + final filamente = [].obs; + final profile = [].obs; + final bauteile = [].obs; + final selectedFilamente = {}.obs; + final editingId = RxnString(); + final foto = Rxn(); + 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 _loadBauteile() => + storage.getModelList(storageKey).map(BauteilModel.fromJson).toList(); + + TextEditingController verbrauchController(String id) => + _verbrauchControllers.putIfAbsent(id, () => TextEditingController()); + + List 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 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 existing, + ) { + if (entry.filamente.isEmpty) { + return 'Bitte mindestens ein Filament auswählen.'; + } + final sum = entry.filamente.fold( + 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 _commit(List 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()) { + Get.find().loadVerbrauch(); + } + } + + Future 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 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 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 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 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(); + } +} diff --git a/lib/controller/home_controller.dart b/lib/controller/home_controller.dart index ad0ccb8..cd9a014 100644 --- a/lib/controller/home_controller.dart +++ b/lib/controller/home_controller.dart @@ -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(); final uuid = const Uuid(); final filamente = [].obs; + final verbrauch = {}.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(); + 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 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 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 duplicateFilament( - KaufFilamentModel filament, - ) async { - filamente.add( - filament.copyWith(filamentId: uuid.v4()), - ); + Future 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(); } -} \ No newline at end of file +} diff --git a/lib/helper/display_formats.dart b/lib/helper/display_formats.dart new file mode 100644 index 0000000..3e81334 --- /dev/null +++ b/lib/helper/display_formats.dart @@ -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'; +} diff --git a/lib/helper/filament_bestand.dart b/lib/helper/filament_bestand.dart new file mode 100644 index 0000000..6b32f98 --- /dev/null +++ b/lib/helper/filament_bestand.dart @@ -0,0 +1,21 @@ +import '../models/bauteil_model.dart'; + +class FilamentBestand { + static Map verbrauch( + Iterable bauteile, { + String? ohneBauteilId, + }) { + final result = {}; + 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; + } +} diff --git a/lib/helper/input_validators.dart b/lib/helper/input_validators.dart new file mode 100644 index 0000000..b7b93ef --- /dev/null +++ b/lib/helper/input_validators.dart @@ -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; + } +} diff --git a/lib/helper/sample_bindings.dart b/lib/helper/sample_bindings.dart index a904f6b..296f802 100644 --- a/lib/helper/sample_bindings.dart +++ b/lib/helper/sample_bindings.dart @@ -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()); - Get.lazyPut(() => AnwendungController()); - } -} \ No newline at end of file + } +} diff --git a/lib/helper/sample_routes.dart b/lib/helper/sample_routes.dart index bf27991..23aced8 100644 --- a/lib/helper/sample_routes.dart +++ b/lib/helper/sample_routes.dart @@ -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> samplePages = [ + GetPage( + name: '${AnwendungView.routeName}/:filamentId', + page: () => const AnwendungView(), + binding: BindingsBuilder( + () => Get.lazyPut( + () => AnwendungController(filamentId: Get.parameters['filamentId']!), + ), + ), + ), + GetPage( + name: BauteilView.routeName, + page: () => const BauteilView(), + binding: BindingsBuilder( + () => Get.lazyPut(() => BauteilController()), + ), + ), GetPage( name: HomeView.routeName, page: () => const HomeView(), binding: sampleBindings, ), ]; -} \ No newline at end of file +} diff --git a/lib/helper/storage_keys.dart b/lib/helper/storage_keys.dart new file mode 100644 index 0000000..bea7a1b --- /dev/null +++ b/lib/helper/storage_keys.dart @@ -0,0 +1,5 @@ +class StorageKeys { + static const filamente = 'kauf_filamente'; + static const anwendungen = 'filament_anwendungen'; + static const bauteile = 'gedruckte_bauteile'; +} diff --git a/lib/main.dart b/lib/main.dart index 3be27b4..2ecf331 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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(), - permanent: true, - ); + Get.put(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, ); } -} \ No newline at end of file +} diff --git a/lib/models/anwendung_model.dart b/lib/models/anwendung_model.dart index e69de29..ebc4164 100644 --- a/lib/models/anwendung_model.dart +++ b/lib/models/anwendung_model.dart @@ -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 toJson() => { + 'anwendungId': anwendungId, + 'filamentId': filamentId, + 'erstelltAm': erstelltAm.toIso8601String(), + 'druckbettTemperatur': druckbettTemperatur, + 'druckTemperatur': druckTemperatur, + 'bauraumGeschlossen': bauraumGeschlossen, + 'bauraumTemperatur': bauraumTemperatur, + 'sonstiges': sonstiges, + }; + + factory AnwendungModel.fromJson(Map 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? ?? '', + ); +} diff --git a/lib/models/bauteil_model.dart b/lib/models/bauteil_model.dart new file mode 100644 index 0000000..5a4e048 --- /dev/null +++ b/lib/models/bauteil_model.dart @@ -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 toJson() => { + 'filamentId': filamentId, + 'anwendungId': anwendungId, + 'verbrauchGramm': verbrauchGramm, + }; + + factory BauteilFilamentModel.fromJson(Map 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 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 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 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.from(item as Map), + ), + ) + .toList(), + ); +} diff --git a/lib/models/kauf_filament_model.dart b/lib/models/kauf_filament_model.dart index 1834909..381e0ae 100644 --- a/lib/models/kauf_filament_model.dart +++ b/lib/models/kauf_filament_model.dart @@ -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 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, ); } -} \ No newline at end of file +} diff --git a/lib/pages/anwendung_view.dart b/lib/pages/anwendung_view.dart index 807668d..8fb5fee 100644 --- a/lib/pages/anwendung_view.dart +++ b/lib/pages/anwendung_view.dart @@ -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 { - 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(), - ); - } + ), + ); } diff --git a/lib/pages/bauteil_view.dart b/lib/pages/bauteil_view.dart new file mode 100644 index 0000000..383727a --- /dev/null +++ b/lib/pages/bauteil_view.dart @@ -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 { + 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 _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( + context: context, + builder: (context) => BauteilDeleteDialog(name: entry.name), + ); + if (confirmed == true) await controller.deleteBauteil(entry.bauteilId); + break; + } + } +} diff --git a/lib/pages/home_view.dart b/lib/pages/home_view.dart index 6748e6f..87f9414 100644 --- a/lib/pages/home_view.dart +++ b/lib/pages/home_view.dart @@ -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 { static const routeName = '/home-view'; @@ -12,271 +15,91 @@ class HomeView extends GetView { @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( - 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( - 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 { ) 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( 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 { ], ), ); - if (confirmed == true) { await controller.deleteFilament(filament.filamentId); } break; } } -} \ No newline at end of file +} diff --git a/lib/services/bauteil_photo_service.dart b/lib/services/bauteil_photo_service.dart new file mode 100644 index 0000000..7b16e0a --- /dev/null +++ b/lib/services/bauteil_photo_service.dart @@ -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 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 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(); + } + } +} diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart new file mode 100644 index 0000000..8fa722a --- /dev/null +++ b/lib/theme/app_theme.dart @@ -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), + ), + ), + ); + } +} diff --git a/lib/widgets/anwendung_form.dart b/lib/widgets/anwendung_form.dart new file mode 100644 index 0000000..fd0bf75 --- /dev/null +++ b/lib/widgets/anwendung_form.dart @@ -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, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/bauteil_card.dart b/lib/widgets/bauteil_card.dart new file mode 100644 index 0000000..0f02777 --- /dev/null +++ b/lib/widgets/bauteil_card.dart @@ -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 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( + 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)), + ); +} diff --git a/lib/widgets/bauteil_delete_dialog.dart b/lib/widgets/bauteil_delete_dialog.dart new file mode 100644 index 0000000..5c98723 --- /dev/null +++ b/lib/widgets/bauteil_delete_dialog.dart @@ -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'), + ), + ], + ); +} diff --git a/lib/widgets/bauteil_filament_auswahl.dart b/lib/widgets/bauteil_filament_auswahl.dart new file mode 100644 index 0000000..1df4c88 --- /dev/null +++ b/lib/widgets/bauteil_filament_auswahl.dart @@ -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( + 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(), + ); + }); +} diff --git a/lib/widgets/bauteil_form.dart b/lib/widgets/bauteil_form.dart new file mode 100644 index 0000000..0aa9244 --- /dev/null +++ b/lib/widgets/bauteil_form.dart @@ -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, + ), + ), + ], + ), + ), + ), + ); +} diff --git a/lib/widgets/bauteil_photo.dart b/lib/widgets/bauteil_photo.dart new file mode 100644 index 0000000..8a1307c --- /dev/null +++ b/lib/widgets/bauteil_photo.dart @@ -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, + ), + ); +} diff --git a/lib/widgets/bauteil_photo_picker.dart b/lib/widgets/bauteil_photo_picker.dart new file mode 100644 index 0000000..1085142 --- /dev/null +++ b/lib/widgets/bauteil_photo_picker.dart @@ -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'), + ), + ], + ), + ], + ), + ), + ], + ), + ); +} diff --git a/lib/widgets/entry_panel.dart b/lib/widgets/entry_panel.dart new file mode 100644 index 0000000..c0421bb --- /dev/null +++ b/lib/widgets/entry_panel.dart @@ -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 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 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 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), + ), + ], + ], + ), + ), + ); +} diff --git a/lib/widgets/filament_card.dart b/lib/widgets/filament_card.dart new file mode 100644 index 0000000..2f6aaa7 --- /dev/null +++ b/lib/widgets/filament_card.dart @@ -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 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( + 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), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/filament_form.dart b/lib/widgets/filament_form.dart new file mode 100644 index 0000000..1144972 --- /dev/null +++ b/lib/widgets/filament_form.dart @@ -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( + 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'), + ), + ], + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/filament_inventory.dart b/lib/widgets/filament_inventory.dart new file mode 100644 index 0000000..17ec659 --- /dev/null +++ b/lib/widgets/filament_inventory.dart @@ -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 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.from(controller.verbrauch); + final total = filamente.fold( + 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, + ), + ), + ], + ), + ); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..64a0ece 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index be1ee3e..a630fa9 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817..14b5f7c 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,8 @@ import FlutterMacOS import Foundation +import file_selector_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) } diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index dddb8a3..87fbf28 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -2,6 +2,8 @@ + com.apple.security.files.user-selected.read-only + com.apple.security.app-sandbox com.apple.security.cs.allow-jit diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 852fa1a..e8ef7f6 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -2,6 +2,8 @@ + com.apple.security.files.user-selected.read-only + com.apple.security.app-sandbox diff --git a/pubspec.lock b/pubspec.lock index 5a2a1d8..4fc6fdd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.dev" + source: hosted + version: "0.3.5+5" crypto: dependency: transitive description: @@ -97,6 +105,70 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + file_selector: + dependency: "direct main" + description: + name: file_selector + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a + url: "https://pub.dev" + source: hosted + version: "1.1.0" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: d670cd0ce77a2e785b18d8b4d0a8d6a222d6a813ec9b7ddf2790a1b4fb6fa92c + url: "https://pub.dev" + source: hosted + version: "0.5.2+11" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: "5b42ec0ef7626124b4624ea244341e9df780406793c204e89fa68a2e71af115c" + url: "https://pub.dev" + source: hosted + version: "0.5.3+7" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab + url: "https://pub.dev" + source: hosted + version: "0.9.4+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: d785431f1910aac8a50e5c492cae912320158030a88703c67b9ef7f1f96fd7ba + url: "https://pub.dev" + source: hosted + version: "0.9.5+2" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec + url: "https://pub.dev" + source: hosted + version: "0.9.3+6" fixnum: dependency: transitive description: @@ -128,6 +200,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" get: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 7c784f0..de23b6a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,7 @@ dependencies: intl: ^0.20.3 google_fonts: ^9.0.0 uuid: ^4.6.0 + file_selector: ^1.1.0 dev_dependencies: flutter_test: diff --git a/test/anwendung_bauteil_test.dart b/test/anwendung_bauteil_test.dart new file mode 100644 index 0000000..55a7ffe --- /dev/null +++ b/test/anwendung_bauteil_test.dart @@ -0,0 +1,295 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; + +import 'package:ertl_filament_verwaltung/controller/anwendung_controller.dart'; +import 'package:ertl_filament_verwaltung/controller/bauteil_controller.dart'; +import 'package:ertl_filament_verwaltung/controller/home_controller.dart'; +import 'package:ertl_filament_verwaltung/helper/input_validators.dart'; +import 'package:ertl_filament_verwaltung/helper/sample_routes.dart'; +import 'package:ertl_filament_verwaltung/models/anwendung_model.dart'; +import 'package:ertl_filament_verwaltung/models/bauteil_model.dart'; +import 'package:ertl_filament_verwaltung/models/kauf_filament_model.dart'; +import 'package:ertl_filament_verwaltung/pages/anwendung_view.dart'; +import 'package:ertl_filament_verwaltung/pages/bauteil_view.dart'; +import 'package:ertl_filament_verwaltung/pages/home_view.dart'; +import 'package:ertl_filament_verwaltung/services/get_storage_services.dart'; +import 'package:ertl_filament_verwaltung/theme/app_theme.dart'; +import 'package:ertl_filament_verwaltung/widgets/filament_card.dart'; +import 'package:ertl_filament_verwaltung/widgets/entry_panel.dart'; +import 'package:ertl_filament_verwaltung/widgets/bauteil_card.dart'; + +import 'helpers/memory_storage_service.dart'; + +void main() { + late MemoryStorageService storage; + final uuidPattern = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', + ); + + KaufFilamentModel filament(String id, String color) => KaufFilamentModel( + filamentId: id, + kaufdatum: DateTime(2026, 10, 27), + haendler: 'Shop', + marke: 'Marke $id', + material: 'PLA', + preis: 20, + farbe: color, + gewicht: '1000', + ); + + AnwendungModel profile(String id, String filamentId) => AnwendungModel( + anwendungId: id, + filamentId: filamentId, + erstelltAm: DateTime(2026, 10, 26), + druckbettTemperatur: 60, + druckTemperatur: 210, + bauraumGeschlossen: false, + ); + + setUp(() { + Get.testMode = true; + storage = MemoryStorageService(); + storage.tables[HomeController.storageKey] = [ + filament('a', 'Rot').toJson(), + filament('b', 'Blau').toJson(), + ]; + storage.tables[AnwendungController.storageKey] = [ + profile('profile-b', 'b').toJson(), + ]; + Get.put(storage); + }); + tearDown(() => Get.reset()); + + Future show( + WidgetTester tester, + Widget page, { + bool narrow = false, + }) async { + tester.view.physicalSize = narrow + ? const Size(320, 640) + : const Size(1100, 1400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(GetMaterialApp(theme: AppTheme.light, home: page)); + await tester.pumpAndSettle(); + } + + void fillPart(BauteilController controller) { + controller.nameController.text = 'Zweifarbiger Halter'; + controller.verwendungController.text = 'Werkstatt'; + controller.stundenController.text = '2'; + controller.minutenController.text = '35'; + controller.gewichtController.text = '42,5'; + controller.abfallController.text = '8,2'; + controller.verbrauchController('a').text = '50,7'; + controller.verbrauchController('b').text = '20,7'; + } + + test( + 'Numeric validation accepts comma decimals and rejects invalid values', + () { + expect(InputValidators.number('42,5'), 42.5); + for (final value in ['NaN', 'Infinity', '-1', 'abc']) { + expect(InputValidators.nonNegative(value), isNotNull); + } + expect(InputValidators.positive('0'), isNotNull); + expect(InputValidators.nonNegative('0'), isNull); + expect(InputValidators.nonNegative('', optional: true), isNull); + expect(InputValidators.minutes('60'), isNotNull); + expect(InputValidators.hours('1,5'), isNotNull); + }, + ); + + testWidgets( + 'Profiles persist selected filament UUID and preserve other profiles', + (tester) async { + final controller = Get.put(AnwendungController(filamentId: 'a')); + await show(tester, const AnwendungView(), narrow: true); + controller.druckController.text = '220,5'; + controller.druckbettController.text = '65'; + controller.bauraumController.text = '40'; + controller.bauraumGeschlossen.value = true; + controller.sonstigesController.text = 'Luefter 50 %'; + await controller.saveAnwendung(); + await tester.pumpAndSettle(); + + final stored = storage.getModelList(AnwendungController.storageKey); + expect(stored, hasLength(2)); + final saved = AnwendungModel.fromJson(stored.last); + expect(saved.anwendungId, matches(uuidPattern)); + expect(saved.filamentId, 'a'); + expect(saved.druckTemperatur, 220.5); + expect(saved.druckbettTemperatur, 65); + expect(saved.bauraumGeschlossen, isTrue); + expect(saved.bauraumTemperatur, 40); + expect(saved.sonstiges, 'Luefter 50 %'); + expect(controller.anwendungen, hasLength(1)); + final reloaded = Get.put( + AnwendungController(filamentId: 'a'), + tag: 'reload', + ); + expect(reloaded.anwendungen.single.anwendungId, saved.anwendungId); + expect(storage.getModelList(HomeController.storageKey), hasLength(2)); + + await tester.scrollUntilVisible( + find.byType(SavedEntryCard), + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + expect(find.text('Bauraum geschlossen: Ja'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('Profile write failure retains input and does not add history', ( + tester, + ) async { + final controller = Get.put(AnwendungController(filamentId: 'a')); + await show(tester, const AnwendungView()); + controller.druckController.text = '220'; + controller.druckbettController.text = '0'; + storage.failWrites = true; + await controller.saveAnwendung(); + expect(controller.hasError.value, isTrue); + expect(controller.druckController.text, '220'); + expect(controller.anwendungen, isEmpty); + expect(controller.isSaving.value, isFalse); + storage.failWrites = false; + await controller.saveAnwendung(); + expect(controller.anwendungen.single.bauraumTemperatur, isNull); + expect(controller.anwendungen.single.bauraumGeschlossen, isFalse); + }); + + testWidgets('Missing filament cannot create orphan profiles', (tester) async { + final controller = Get.put(AnwendungController(filamentId: 'missing')); + await show(tester, const AnwendungView()); + await controller.saveAnwendung(); + expect(controller.anwendungen, isEmpty); + expect(storage.getModelList(AnwendungController.storageKey), hasLength(1)); + expect(find.textContaining('nicht gefunden'), findsOneWidget); + }); + + testWidgets( + 'Multicolor part persists both filaments and matching processing profile', + (tester) async { + final controller = Get.put(BauteilController()); + await show(tester, const BauteilView(), narrow: true); + fillPart(controller); + controller.toggleFilament('a', true); + controller.toggleFilament('b', true); + controller.verbrauchController('a').text = '30'; + controller.selectProfile('a', 'profile-b'); + expect(controller.selectedFilamente['a'], isNull); + controller.selectProfile('b', 'profile-b'); + await tester.pumpAndSettle(); + await controller.saveBauteil(); + await tester.pumpAndSettle(); + + final stored = storage.getModelList(BauteilController.storageKey); + final saved = BauteilModel.fromJson(stored.single); + expect(saved.bauteilId, matches(uuidPattern)); + expect(saved.name, 'Zweifarbiger Halter'); + expect(saved.verwendung, 'Werkstatt'); + expect(saved.druckdauerMinuten, 155); + expect(saved.gewichtGramm, 42.5); + expect(saved.abfallGramm, 8.2); + expect(saved.gesamtverbrauchGramm, closeTo(50.7, 0.001)); + expect(saved.filamente.map((item) => item.filamentId), ['a', 'b']); + expect(saved.filamente.last.anwendungId, 'profile-b'); + expect(controller.selectedFilamente, isEmpty); + final reloaded = Get.put(BauteilController(), tag: 'reload'); + expect(reloaded.bauteile.single.filamente, hasLength(2)); + expect( + storage.getModelList(AnwendungController.storageKey), + hasLength(1), + ); + + await tester.scrollUntilVisible( + find.byType(BauteilCard), + 250, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + expect(find.text('Zweifarbiger Halter'), findsOneWidget); + expect(find.text('Mehrfarbdruck · 2 Filamente'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('Part requires filament, positive duration and valid weights', ( + tester, + ) async { + final controller = Get.put(BauteilController()); + await show(tester, const BauteilView()); + fillPart(controller); + await controller.saveBauteil(); + expect(controller.message.value, contains('mindestens ein Filament')); + controller.toggleFilament('a', true); + controller.stundenController.text = '0'; + controller.minutenController.text = '0'; + await controller.saveBauteil(); + expect(controller.message.value, contains('mindestens eine Minute')); + controller.minutenController.text = '30'; + controller.abfallController.text = '-1'; + await controller.saveBauteil(); + expect(storage.getModelList(BauteilController.storageKey), isEmpty); + expect(controller.nameController.text, isNotEmpty); + }); + + testWidgets('Part write failure keeps selection and inputs for retry', ( + tester, + ) async { + final controller = Get.put(BauteilController()); + await show(tester, const BauteilView()); + fillPart(controller); + controller.toggleFilament('a', true); + storage.failWrites = true; + await controller.saveBauteil(); + expect(controller.hasError.value, isTrue); + expect(controller.bauteile, isEmpty); + expect(controller.nameController.text, 'Zweifarbiger Halter'); + expect(controller.selectedFilamente.keys, ['a']); + expect(controller.isSaving.value, isFalse); + }); + + testWidgets( + 'Navigation opens a fresh controller for each selected filament', + (tester) async { + tester.view.physicalSize = const Size(1280, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + GetMaterialApp( + theme: AppTheme.light, + initialRoute: HomeView.routeName, + getPages: SampleRouts.samplePages, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byType(FilamentCard).first); + await tester.pumpAndSettle(); + expect(Get.find().filamentId, 'a'); + Get.find().sonstigesController.text = + 'Nicht uebernehmen'; + Get.back(); + await tester.pumpAndSettle(); + expect(Get.isRegistered(), isFalse); + await tester.tap(find.byType(FilamentCard).last); + await tester.pumpAndSettle(); + expect(Get.find().filamentId, 'b'); + expect(Get.find().sonstigesController.text, isEmpty); + expect(Get.find().anwendungen, hasLength(1)); + Get.back(); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Bauteile verwalten')); + await tester.pumpAndSettle(); + expect(find.byType(BauteilView), findsOneWidget); + expect(Get.find().filamente, hasLength(2)); + expect(tester.takeException(), isNull); + }, + ); +} diff --git a/test/bauteil_bestand_test.dart b/test/bauteil_bestand_test.dart new file mode 100644 index 0000000..a39d2ca --- /dev/null +++ b/test/bauteil_bestand_test.dart @@ -0,0 +1,365 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; + +import 'package:ertl_filament_verwaltung/controller/bauteil_controller.dart'; +import 'package:ertl_filament_verwaltung/controller/home_controller.dart'; +import 'package:ertl_filament_verwaltung/models/bauteil_model.dart'; +import 'package:ertl_filament_verwaltung/models/kauf_filament_model.dart'; +import 'package:ertl_filament_verwaltung/pages/bauteil_view.dart'; +import 'package:ertl_filament_verwaltung/pages/home_view.dart'; +import 'package:ertl_filament_verwaltung/services/bauteil_photo_service.dart'; +import 'package:ertl_filament_verwaltung/services/get_storage_services.dart'; +import 'package:ertl_filament_verwaltung/theme/app_theme.dart'; +import 'package:ertl_filament_verwaltung/widgets/bauteil_card.dart'; +import 'package:ertl_filament_verwaltung/widgets/bauteil_photo.dart'; + +import 'helpers/memory_storage_service.dart'; + +class TestPhotoService extends BauteilPhotoService { + Uint8List? result; + bool fail = false; + + @override + Future pickPhoto() async { + if (fail) throw const FormatException('Ungültiges Foto'); + return result; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late MemoryStorageService storage; + late HomeController home; + late BauteilController controller; + late TestPhotoService photos; + late Uint8List png; + + Future createPng(int width, int height) async { + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder); + canvas.drawRect( + ui.Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), + ui.Paint()..color = const Color(0xFF007F78), + ); + final picture = recorder.endRecording(); + final image = await picture.toImage(width, height); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + picture.dispose(); + return data!.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); + } + + setUpAll(() async { + png = await createPng(2, 2); + }); + + KaufFilamentModel spool(String id, String weight) => KaufFilamentModel( + filamentId: id, + kaufdatum: DateTime(2026, 10, 27), + haendler: 'Shop', + marke: 'Marke $id', + material: 'PLA', + preis: 20, + farbe: id, + gewicht: weight, + ); + + setUp(() { + Get.testMode = true; + storage = MemoryStorageService(); + storage.tables[HomeController.storageKey] = [ + spool('a', '1kg').toJson(), + spool('b', '1000').toJson(), + ]; + Get.put(storage); + home = Get.put(HomeController()); + photos = TestPhotoService(); + controller = Get.put(BauteilController(photoService: photos)); + }); + tearDown(() => Get.reset()); + + Future show(WidgetTester tester, {bool narrow = false}) async { + tester.view.physicalSize = narrow + ? const Size(320, 640) + : const Size(1100, 1600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + GetMaterialApp(theme: AppTheme.light, home: const BauteilView()), + ); + await tester.pumpAndSettle(); + } + + void fill({double a = 60, double b = 40}) { + controller.nameController.text = 'Testbauteil'; + controller.verwendungController.text = 'Werkstatt'; + controller.stundenController.text = '1'; + controller.minutenController.text = '10'; + controller.gewichtController.text = '${a + b - 10}'; + controller.abfallController.text = '10'; + if (a > 0) { + controller.toggleFilament('a', true); + controller.verbrauchController('a').text = '$a'; + } + if (b > 0) { + controller.toggleFilament('b', true); + controller.verbrauchController('b').text = '$b'; + } + } + + Future edit(WidgetTester tester, BauteilModel entry) async { + final pending = controller.editBauteil(entry); + await tester.pumpAndSettle(); + await pending; + } + + double remaining(String id) => home.restgewicht( + home.filamente.firstWhere((item) => item.filamentId == id), + )!; + + test( + 'Legacy records retain unknown usage and old weight units are supported', + () { + expect(spool('a', '1kg').gewichtGramm, 1000); + expect(spool('a', '0,75 KG').gewichtGramm, 750); + expect(spool('a', '500 g').gewichtGramm, 500); + expect(spool('a', 'NaN').gewichtGramm, isNull); + final legacy = BauteilModel.fromJson({ + 'bauteilId': 'old', + 'erstelltAm': DateTime.now().toIso8601String(), + 'name': 'Altbestand', + 'verwendung': 'Test', + 'druckdauerMinuten': 30, + 'gewichtGramm': 10, + 'abfallGramm': 2, + 'filamente': [ + {'filamentId': 'a', 'anwendungId': null}, + ], + }); + expect(legacy.filamente.single.verbrauchGramm, 0); + expect(legacy.fotoBytes, isNull); + }, + ); + + testWidgets( + 'Create, edit, duplicate and delete reconcile stock and survive reload', + (tester) async { + await show(tester); + fill(); + photos.result = png; + await controller.pickPhoto(); + await controller.saveBauteil(); + expect(remaining('a'), 940); + expect(remaining('b'), 960); + final originalId = controller.bauteile.single.bauteilId; + final originalDate = controller.bauteile.single.erstelltAm; + + await edit(tester, controller.bauteile.single); + expect(controller.foto.value, png); + expect(controller.verfuegbarGramm('a'), 1000); + controller.verbrauchController('a').text = '20'; + controller.verbrauchController('b').text = '80'; + await controller.saveBauteil(); + expect(controller.bauteile.single.bauteilId, originalId); + expect(controller.bauteile.single.erstelltAm, originalDate); + expect(remaining('a'), 980); + expect(remaining('b'), 920); + + await controller.duplicateBauteil(controller.bauteile.single); + expect(controller.bauteile, hasLength(2)); + expect( + controller.bauteile.map((item) => item.bauteilId).toSet(), + hasLength(2), + ); + expect( + controller.bauteile.every((item) => item.fotoBase64 != null), + isTrue, + ); + expect(remaining('a'), 960); + expect(remaining('b'), 840); + await controller.deleteBauteil(originalId); + expect(remaining('a'), 980); + expect(remaining('b'), 920); + + home.loadFilamente(); + expect(remaining('a'), 980); + final reloaded = Get.put(BauteilController(), tag: 'reload'); + expect(reloaded.bauteile.single.fotoBytes, png); + expect(reloaded.bauteile.single.filamente.last.verbrauchGramm, 80); + // Purchases retain the original weight; the persisted ledger is deducted. + expect( + storage.getModelList(HomeController.storageKey).first['gewicht'], + '1kg', + ); + }, + ); + + testWidgets( + 'Overdraw, missing usage and mismatched totals cannot change stock', + (tester) async { + await show(tester); + fill(a: 1001, b: 0); + await controller.saveBauteil(); + expect(controller.hasError.value, isTrue); + expect(controller.message.value, contains('1000 g verfügbar')); + expect(remaining('a'), 1000); + controller.verbrauchController('a').text = '1'; + await controller.saveBauteil(); + expect(controller.message.value, contains('Bauteilgewicht + Abfall')); + controller.verbrauchController('a').text = ''; + await controller.saveBauteil(); + expect(controller.bauteile, isEmpty); + expect(storage.getModelList(BauteilController.storageKey), isEmpty); + }, + ); + + testWidgets( + 'Editing credits only the edited booking; duplicates need new stock', + (tester) async { + await show(tester); + fill(a: 900, b: 0); + await controller.saveBauteil(); + final first = controller.bauteile.single; + fill(a: 60, b: 0); + await controller.saveBauteil(); + expect(remaining('a'), 40); + await edit(tester, first); + expect(controller.verfuegbarGramm('a'), 940); + controller.gewichtController.text = '940'; + controller.verbrauchController('a').text = '950'; + await controller.saveBauteil(); + expect(controller.message.value, contains('940 g verfügbar')); + expect(remaining('a'), 40); + await controller.duplicateBauteil(first); + expect(controller.bauteile, hasLength(2)); + expect(remaining('a'), 40); + }, + ); + + testWidgets( + 'Failed edit, duplicate and delete leave ledger and stocks unchanged', + (tester) async { + await show(tester); + fill(); + await controller.saveBauteil(); + final entry = controller.bauteile.single; + await edit(tester, entry); + controller.verbrauchController('a').text = '50'; + controller.verbrauchController('b').text = '50'; + storage.failWrites = true; + await controller.saveBauteil(); + expect(controller.hasError.value, isTrue); + expect(controller.editingId.value, entry.bauteilId); + expect(controller.verbrauchController('a').text, '50'); + expect(remaining('a'), 940); + await controller.duplicateBauteil(entry); + expect(controller.bauteile, hasLength(1)); + await controller.deleteBauteil(entry.bauteilId); + expect(controller.bauteile, hasLength(1)); + expect(remaining('b'), 960); + expect( + storage + .getModelList(BauteilController.storageKey) + .single['filamente'][0]['verbrauchGramm'], + 60, + ); + }, + ); + + testWidgets( + 'Photo selection, cancellation, errors and removal preserve the draft', + (tester) async { + await show(tester); + photos.result = png; + await controller.pickPhoto(); + expect(controller.foto.value, png); + photos.result = null; + await controller.pickPhoto(); + expect(controller.foto.value, png); + photos.fail = true; + await controller.pickPhoto(); + expect(controller.foto.value, png); + expect(controller.hasError.value, isTrue); + expect(controller.isPickingPhoto.value, isFalse); + controller.removePhoto(); + expect(controller.foto.value, isNull); + }, + ); + + testWidgets( + 'Native image processing converts valid images and rejects non-images', + (tester) async { + final service = BauteilPhotoService(); + // Codec operations need real asynchronous work rather than the fake clock. + final converted = await tester.runAsync(() => service.preparePhoto(png)); + expect(converted, isNotEmpty); + await expectLater( + service.preparePhoto(Uint8List.fromList([1, 2, 3])), + throwsA(anything), + ); + }, + ); + + testWidgets( + 'Narrow card shows thumbnail and menu; cancel and confirm deletion work', + (tester) async { + await show(tester, narrow: true); + fill(); + photos.result = png; + await controller.pickPhoto(); + await controller.saveBauteil(); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.byType(BauteilCard), + 250, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + expect( + find.descendant( + of: find.byType(BauteilCard), + matching: find.byType(BauteilPhoto), + ), + findsOneWidget, + ); + await tester.ensureVisible(find.byTooltip('Bauteil verwalten')); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Bauteil verwalten')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Löschen')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Abbrechen')); + await tester.pumpAndSettle(); + expect(remaining('a'), 940); + await tester.tap(find.byTooltip('Bauteil verwalten')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Löschen')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Löschen')); + await tester.pumpAndSettle(); + expect(controller.bauteile, isEmpty); + expect(remaining('a'), 1000); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('Home displays current remaining grams after saving a part', ( + tester, + ) async { + await show(tester); + fill(); + await controller.saveBauteil(); + await tester.pumpWidget( + GetMaterialApp(theme: AppTheme.light, home: const HomeView()), + ); + await tester.pumpAndSettle(); + expect(find.text('940 g verbleibend'), findsOneWidget); + expect(find.text('960 g verbleibend'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/helpers/memory_storage_service.dart b/test/helpers/memory_storage_service.dart new file mode 100644 index 0000000..67345e2 --- /dev/null +++ b/test/helpers/memory_storage_service.dart @@ -0,0 +1,27 @@ +import 'dart:convert'; + +import 'package:get/get.dart'; +import 'package:ertl_filament_verwaltung/services/get_storage_services.dart'; + +class MemoryStorageService extends GetxService implements GetStorageService { + final tables = >>{}; + bool failWrites = false; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); + + @override + List> getModelList(String key) => + (jsonDecode(jsonEncode(tables[key] ?? [])) as List) + .map((item) => Map.from(item as Map)) + .toList(); + + @override + Future saveModelList( + String key, + List> items, + ) async { + if (failWrites) throw StateError('Storage unavailable'); + tables[key] = items; + } +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 8417d4c..2b10461 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,30 +1,117 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; -import 'package:ertl_filament_verwaltung/main.dart'; +import 'package:ertl_filament_verwaltung/controller/home_controller.dart'; +import 'package:ertl_filament_verwaltung/models/kauf_filament_model.dart'; +import 'package:ertl_filament_verwaltung/pages/home_view.dart'; +import 'package:ertl_filament_verwaltung/services/get_storage_services.dart'; +import 'package:ertl_filament_verwaltung/theme/app_theme.dart'; +import 'package:ertl_filament_verwaltung/widgets/filament_card.dart'; +import 'package:ertl_filament_verwaltung/widgets/filament_form.dart'; + +import 'helpers/memory_storage_service.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); + late HomeController controller; - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); + setUp(() { + Get.testMode = true; + Get.put(MemoryStorageService()); + controller = Get.put(HomeController()); + }); - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); + tearDown(() => Get.reset()); - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + Future showHome(WidgetTester tester, Size size) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + GetMaterialApp(theme: AppTheme.light, home: const HomeView()), + ); + await tester.pumpAndSettle(); + } + + KaufFilamentModel sample() => KaufFilamentModel( + filamentId: 'sample', + kaufdatum: DateTime(2026, 10, 27), + haendler: 'Filament Shop', + marke: 'Testmarke', + material: 'PLA+', + preis: 24.90, + farbe: 'Ozeanblau', + gewicht: '1000', + ); + + testWidgets('Desktop shows both panes and edits PLA+', (tester) async { + controller.filamente.add(sample()); + await showHome(tester, const Size(1280, 900)); + + expect( + tester.getTopLeft(find.byType(FilamentCard)).dx, + greaterThan(tester.getTopRight(find.byType(FilamentForm)).dx), + ); + expect(find.text('1000 g'), findsOneWidget); + expect(find.text('Testmarke'), findsOneWidget); + + await tester.tap(find.byTooltip('Filament verwalten')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Bearbeiten')); + await tester.pumpAndSettle(); + + expect(find.text('Filament bearbeiten'), findsOneWidget); + expect(controller.markeController.text, 'Testmarke'); + expect(controller.material.value, 'PLA+'); + expect(tester.takeException(), isNull); + }); + + testWidgets('Small phone scrolls to save and shows the purchase', ( + tester, + ) async { + await showHome(tester, const Size(320, 640)); + controller.haendlerController.text = 'Filament Shop'; + controller.markeController.text = 'Testmarke'; + controller.farbeController.text = 'Ozeanblau'; + controller.preisController.text = '24,90'; + controller.gewichtController.text = '1000'; + + await tester.scrollUntilVisible( + find.text('Filament speichern'), + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Filament speichern')); + await tester.pumpAndSettle(); + expect(controller.filamente, hasLength(1)); + + await tester.scrollUntilVisible( + find.byType(FilamentCard), + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + expect(find.text('Testmarke'), findsOneWidget); + expect(find.text('1000 g'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('Empty inventory is accessible on a narrow screen', ( + tester, + ) async { + await showHome(tester, const Size(320, 640)); + await tester.scrollUntilVisible( + find.text('Hier beginnt dein Bestand'), + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + expect( + find.text('Hier beginnt dein Bestand').hitTestable(), + findsOneWidget, + ); + expect(tester.takeException(), isNull); }); } diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 8b6d468..77ab7a0 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,9 @@ #include "generated_plugin_registrant.h" +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 3ad69c6..277a56f 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST