From 4a1731f98687b1daf0c627e05c2dae6ae774fe25 Mon Sep 17 00:00:00 2001 From: atseirjo Date: Wed, 28 Oct 2026 08:10:55 +0100 Subject: [PATCH] add import export and items --- .metadata | 15 -- lib/controller/anwendung_controller.dart | 45 ++-- lib/controller/export_controller.dart | 105 ++++++++ lib/controller/home_controller.dart | 77 +++++- lib/helper/sample_routes.dart | 9 + lib/helper/storage_keys.dart | 1 + lib/models/backup_model.dart | 105 ++++++++ lib/pages/anwendung_view.dart | 28 --- lib/pages/export_view.dart | 117 +++++++++ lib/pages/home_view.dart | 19 ++ lib/services/backup_file_service.dart | 24 ++ lib/widgets/anwendung_form.dart | 2 +- lib/widgets/filament_form.dart | 25 +- lib/widgets/filament_material_field.dart | 126 ++++++++++ macos/Flutter/GeneratedPluginRegistrant.swift | 2 + macos/Runner/DebugProfile.entitlements | 2 +- macos/Runner/Release.entitlements | 2 +- pubspec.lock | 100 +++++++- pubspec.yaml | 1 + test/anwendung_bauteil_test.dart | 82 +++++- test/export_test.dart | 234 ++++++++++++++++++ test/widget_test.dart | 103 ++++++++ 22 files changed, 1118 insertions(+), 106 deletions(-) create mode 100644 lib/controller/export_controller.dart create mode 100644 lib/models/backup_model.dart create mode 100644 lib/pages/export_view.dart create mode 100644 lib/services/backup_file_service.dart create mode 100644 lib/widgets/filament_material_field.dart create mode 100644 test/export_test.dart diff --git a/.metadata b/.metadata index f194874..bfe2caf 100644 --- a/.metadata +++ b/.metadata @@ -18,21 +18,6 @@ migration: - platform: android create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 - - platform: ios - create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 - base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 - - platform: linux - create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 - base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 - - platform: macos - create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 - base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 - - platform: web - create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 - base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 - - platform: windows - create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 - base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 # User provided section diff --git a/lib/controller/anwendung_controller.dart b/lib/controller/anwendung_controller.dart index 041070a..d21a5fc 100644 --- a/lib/controller/anwendung_controller.dart +++ b/lib/controller/anwendung_controller.dart @@ -36,13 +36,22 @@ class AnwendungController extends GetxController { 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)); + final profiles = + storage + .getModelList(storageKey) + .map(AnwendungModel.fromJson) + .where((item) => item.filamentId == filamentId) + .toList() + ..sort((a, b) => b.erstelltAm.compareTo(a.erstelltAm)); + if (profiles.isNotEmpty) { + final profile = profiles.first; + anwendungen.assignAll([profile]); + druckbettController.text = profile.druckbettTemperatur.toString(); + druckController.text = profile.druckTemperatur.toString(); + bauraumController.text = profile.bauraumTemperatur?.toString() ?? ''; + sonstigesController.text = profile.sonstiges; + bauraumGeschlossen.value = profile.bauraumGeschlossen; + } } Future saveAnwendung() async { @@ -51,10 +60,11 @@ class AnwendungController extends GetxController { isSaving.value = true; message.value = null; try { + final existing = anwendungen.firstOrNull; final entry = AnwendungModel( - anwendungId: const Uuid().v4(), + anwendungId: existing?.anwendungId ?? const Uuid().v4(), filamentId: filamentId, - erstelltAm: DateTime.now(), + erstelltAm: existing?.erstelltAm ?? DateTime.now(), druckbettTemperatur: InputValidators.number(druckbettController.text)!, druckTemperatur: InputValidators.number(druckController.text)!, bauraumGeschlossen: bauraumGeschlossen.value, @@ -62,9 +72,11 @@ class AnwendungController extends GetxController { sonstiges: sonstigesController.text.trim(), ); final entries = storage.getModelList(storageKey); - await storage.saveModelList(storageKey, [...entries, entry.toJson()]); - anwendungen.insert(0, entry); - resetForm(); + await storage.saveModelList(storageKey, [ + ...entries.where((item) => item['filamentId'] != filamentId), + entry.toJson(), + ]); + anwendungen.assignAll([entry]); hasError.value = false; message.value = 'Verarbeitungsprofil gespeichert.'; } catch (_) { @@ -75,15 +87,6 @@ class AnwendungController extends GetxController { } } - void resetForm() { - formKey.currentState?.reset(); - druckbettController.clear(); - druckController.clear(); - bauraumController.clear(); - sonstigesController.clear(); - bauraumGeschlossen.value = false; - } - @override void onClose() { druckbettController.dispose(); diff --git a/lib/controller/export_controller.dart b/lib/controller/export_controller.dart new file mode 100644 index 0000000..74da7ee --- /dev/null +++ b/lib/controller/export_controller.dart @@ -0,0 +1,105 @@ +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; + +import '../models/backup_model.dart'; +import '../services/backup_file_service.dart'; +import '../services/get_storage_services.dart'; +import 'home_controller.dart'; + +class ExportController extends GetxController { + ExportController({BackupFileService? fileService}) + : fileService = fileService ?? BackupFileService(); + + final BackupFileService fileService; + final storage = Get.find(); + final isBusy = false.obs; + final message = RxnString(); + final hasError = false.obs; + final pendingBackup = Rxn(); + + Future exportData() async { + if (isBusy.value) return; + isBusy.value = true; + message.value = null; + try { + final backup = BackupModel({ + for (final key in BackupModel.keys) key: storage.getModelList(key), + }); + final name = + 'filament_backup_${DateFormat('yyyy-MM-dd_HH-mm-ss').format(DateTime.now())}.json'; + if (await fileService.save(backup.encode(), name)) { + hasError.value = false; + message.value = 'Datensicherung als JSON-Datei exportiert.'; + } + } catch (_) { + _error('Export fehlgeschlagen. Bitte erneut versuchen.'); + } finally { + isBusy.value = false; + } + } + + Future selectImport() async { + if (isBusy.value) return; + isBusy.value = true; + message.value = null; + pendingBackup.value = null; + try { + final source = await fileService.open(); + if (source != null) pendingBackup.value = BackupModel.decode(source); + } on FormatException catch (error) { + _error(error.message); + } catch (_) { + _error('Datei konnte nicht gelesen werden. Bitte erneut versuchen.'); + } finally { + isBusy.value = false; + } + } + + Future importData() async { + final backup = pendingBackup.value; + if (isBusy.value || backup == null) return; + isBusy.value = true; + message.value = null; + final previous = >>{}; + final attempted = []; + try { + for (final key in BackupModel.keys) { + previous[key] = storage.getModelList(key); + } + for (final key in BackupModel.keys) { + attempted.add(key); + await storage.saveModelList(key, backup.data[key]!); + } + if (Get.isRegistered()) { + final home = Get.find(); + home.loadFilamente(); + home.loadMaterialien(); + home.resetForm(); + } + pendingBackup.value = null; + hasError.value = false; + message.value = 'Alle Daten wurden importiert.'; + } catch (_) { + var restored = true; + for (final key in attempted.reversed) { + try { + await storage.saveModelList(key, previous[key]!); + } catch (_) { + restored = false; + } + } + _error( + restored + ? 'Import fehlgeschlagen. Die bisherigen Daten wurden beibehalten.' + : 'Import und Wiederherstellung fehlgeschlagen. Bitte die Datensicherung erneut importieren.', + ); + } finally { + isBusy.value = false; + } + } + + void _error(String text) { + hasError.value = true; + message.value = text; + } +} diff --git a/lib/controller/home_controller.dart b/lib/controller/home_controller.dart index cd9a014..46ff34d 100644 --- a/lib/controller/home_controller.dart +++ b/lib/controller/home_controller.dart @@ -43,17 +43,17 @@ class HomeController extends GetxController { final preisController = TextEditingController(); final markeController = TextEditingController(); final gewichtController = TextEditingController(); + final materialController = TextEditingController(text: 'PLA'); final kaufdatum = DateTime.now().obs; final material = 'PLA'.obs; final editingId = RxnString(); - final materialien = [ + static const defaultMaterialien = [ 'PLA', 'PLA+', 'PLA light', 'PLA-CF', - 'PLA+', 'PETG', 'PETG-CF', 'ABS', @@ -61,11 +61,74 @@ class HomeController extends GetxController { 'TPU', 'Sonstiges', ]; + final materialien = defaultMaterialien.toList().obs; + final materialMessage = RxnString(); + final isSavingMaterial = false.obs; @override void onInit() { super.onInit(); loadFilamente(); + loadMaterialien(); + selectMaterial(materialien.firstOrNull ?? ''); + } + + void loadMaterialien() { + final saved = storage.getModelList(StorageKeys.materialien); + if (saved.isNotEmpty) { + materialien.assignAll((saved.single['werte'] as List).cast()); + } else { + materialien.assignAll({ + ...defaultMaterialien, + ...filamente.map((filament) => filament.material), + }); + } + } + + void selectMaterial(String value) { + material.value = value; + materialController.text = value; + } + + Future addMaterial() async { + final value = materialController.text.trim(); + if (value.isEmpty) { + materialMessage.value = 'Bitte eine Materialart eingeben.'; + return false; + } + final existing = materialien.firstWhereOrNull( + (item) => item.toLowerCase() == value.toLowerCase(), + ); + if (existing != null) { + materialMessage.value = null; + selectMaterial(existing); + return true; + } + final saved = await _saveMaterialien([...materialien, value]); + if (saved) selectMaterial(value); + return saved; + } + + Future removeMaterial(String value) => + _saveMaterialien(materialien.where((item) => item != value).toList()); + + Future _saveMaterialien(List values) async { + if (isSavingMaterial.value) return false; + isSavingMaterial.value = true; + materialMessage.value = null; + try { + await storage.saveModelList(StorageKeys.materialien, [ + {'werte': values}, + ]); + materialien.assignAll(values); + return true; + } catch (_) { + materialMessage.value = + 'Artenliste konnte nicht gespeichert werden. Bitte erneut versuchen.'; + return false; + } finally { + isSavingMaterial.value = false; + } } void loadFilamente() { @@ -116,6 +179,8 @@ class HomeController extends GetxController { return; } + if (!await addMaterial()) return; + material.value = materialController.text.trim(); final filament = KaufFilamentModel( filamentId: editingId.value ?? uuid.v4(), marke: markeController.text.trim(), @@ -152,7 +217,7 @@ class HomeController extends GetxController { kaufdatum.value = filament.kaufdatum; markeController.text = filament.marke; haendlerController.text = filament.haendler; - material.value = filament.material; + selectMaterial(filament.material); preisController.text = filament.preis.toStringAsFixed(2); farbeController.text = filament.farbe; gewichtController.text = filament.gewichtGramm == null @@ -185,15 +250,16 @@ class HomeController extends GetxController { } void resetForm() { + formKey.currentState?.reset(); editingId.value = null; haendlerController.clear(); markeController.clear(); farbeController.clear(); preisController.clear(); kaufdatum.value = DateTime.now(); - material.value = 'PLA'; + selectMaterial(materialien.firstOrNull ?? ''); + materialMessage.value = null; gewichtController.clear(); - formKey.currentState?.reset(); } @override @@ -205,6 +271,7 @@ class HomeController extends GetxController { farbeController.dispose(); preisController.dispose(); gewichtController.dispose(); + materialController.dispose(); super.onClose(); } } diff --git a/lib/helper/sample_routes.dart b/lib/helper/sample_routes.dart index 23aced8..a134452 100644 --- a/lib/helper/sample_routes.dart +++ b/lib/helper/sample_routes.dart @@ -2,6 +2,8 @@ import 'package:get/get.dart'; import '../controller/anwendung_controller.dart'; import '../controller/bauteil_controller.dart'; +import '../controller/export_controller.dart'; +import '../pages/export_view.dart'; import '../pages/anwendung_view.dart'; import '../pages/bauteil_view.dart'; import '../pages/home_view.dart'; @@ -10,6 +12,13 @@ import 'sample_bindings.dart'; class SampleRouts { static final sampleBindings = SampleBindings(); static List> samplePages = [ + GetPage( + name: ExportView.routeName, + page: () => const ExportView(), + binding: BindingsBuilder( + () => Get.lazyPut(() => ExportController()), + ), + ), GetPage( name: '${AnwendungView.routeName}/:filamentId', page: () => const AnwendungView(), diff --git a/lib/helper/storage_keys.dart b/lib/helper/storage_keys.dart index bea7a1b..82daa2a 100644 --- a/lib/helper/storage_keys.dart +++ b/lib/helper/storage_keys.dart @@ -1,5 +1,6 @@ class StorageKeys { static const filamente = 'kauf_filamente'; + static const materialien = 'filament_materialien'; static const anwendungen = 'filament_anwendungen'; static const bauteile = 'gedruckte_bauteile'; } diff --git a/lib/models/backup_model.dart b/lib/models/backup_model.dart new file mode 100644 index 0000000..985f039 --- /dev/null +++ b/lib/models/backup_model.dart @@ -0,0 +1,105 @@ +import 'dart:convert'; + +import '../helper/storage_keys.dart'; +import 'anwendung_model.dart'; +import 'bauteil_model.dart'; +import 'kauf_filament_model.dart'; + +class BackupModel { + static const keys = [ + StorageKeys.filamente, + StorageKeys.anwendungen, + StorageKeys.bauteile, + StorageKeys.materialien, + ]; + final Map>> data; + + const BackupModel(this.data); + + String encode() => const JsonEncoder.withIndent(' ').convert({ + 'format': 'ertl_filament_verwaltung', + 'version': 1, + 'exportiertAm': DateTime.now().toUtc().toIso8601String(), + 'daten': data, + }); + + factory BackupModel.decode(String source) { + try { + final json = + jsonDecode(source.replaceFirst(RegExp(r'^\uFEFF'), '')) as Map; + if (json['format'] != 'ertl_filament_verwaltung' || + json['version'] != 1) { + throw const FormatException(); + } + final raw = json['daten'] as Map; + final data = >>{ + for (final key in keys) + key: (raw[key] as List) + .map((item) => Map.from(item as Map)) + .toList(), + }; + final filamentIds = {}; + for (final json in data[StorageKeys.filamente]!) { + final item = KaufFilamentModel.fromJson(json); + if (item.filamentId.isEmpty || + !filamentIds.add(item.filamentId) || + item.material.trim().isEmpty || + !_positive(item.gewichtGramm) || + !_nonNegative(item.preis)) { + throw const FormatException(); + } + } + final profileIds = {}; + for (final json in data[StorageKeys.anwendungen]!) { + final item = AnwendungModel.fromJson(json); + if (item.anwendungId.isEmpty || + !profileIds.add(item.anwendungId) || + item.filamentId.isEmpty || + !_positive(item.druckTemperatur) || + !_nonNegative(item.druckbettTemperatur) || + (item.bauraumTemperatur != null && + !_nonNegative(item.bauraumTemperatur!))) { + throw const FormatException(); + } + } + final partIds = {}; + for (final json in data[StorageKeys.bauteile]!) { + final item = BauteilModel.fromJson(json); + if (item.bauteilId.isEmpty || + !partIds.add(item.bauteilId) || + item.name.trim().isEmpty || + item.druckdauerMinuten <= 0 || + !_positive(item.gewichtGramm) || + !_nonNegative(item.abfallGramm) || + item.filamente.isEmpty || + item.filamente.any( + (entry) => + entry.filamentId.isEmpty || + !_nonNegative(entry.verbrauchGramm), + )) { + throw const FormatException(); + } + if (item.fotoBase64 != null) base64Decode(item.fotoBase64!); + } + final materials = data[StorageKeys.materialien]!; + if (materials.isNotEmpty) { + if (materials.length != 1) throw const FormatException(); + final values = (materials.single['werte'] as List).cast(); + if (values.any((value) => value.trim().isEmpty) || + values.map((value) => value.toLowerCase()).toSet().length != + values.length) { + throw const FormatException(); + } + } + return BackupModel(data); + } catch (_) { + throw const FormatException( + 'Ungültige oder nicht unterstützte Datensicherung. Bitte eine vollständige JSON-Datei dieser App auswählen.', + ); + } + } + + static bool _nonNegative(num value) => value.isFinite && value >= 0; + static bool _positive(num? value) => + value != null && value.isFinite && value > 0; +} diff --git a/lib/pages/anwendung_view.dart b/lib/pages/anwendung_view.dart index 8fb5fee..73f151d 100644 --- a/lib/pages/anwendung_view.dart +++ b/lib/pages/anwendung_view.dart @@ -2,9 +2,7 @@ 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'; @@ -39,32 +37,6 @@ class AnwendungView extends GetView { 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, - ); - }, - ); - }), - ), ], ); }), diff --git a/lib/pages/export_view.dart b/lib/pages/export_view.dart new file mode 100644 index 0000000..45616e8 --- /dev/null +++ b/lib/pages/export_view.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../controller/export_controller.dart'; +import '../helper/storage_keys.dart'; +import '../widgets/entry_panel.dart'; + +class ExportView extends GetView { + static const routeName = '/export-view'; + const ExportView({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('Datensicherung')), + body: SafeArea( + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Obx(() { + final busy = controller.isBusy.value; + final backup = controller.pendingBackup.value; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + EntryPanel( + title: 'Daten exportieren', + subtitle: 'Sichere Filamente, Verarbeitungsprofile, Bauteile inklusive Fotos und Verbrauch sowie deine Materialarten in einer JSON-Datei.\n\nSpeichere die Datei außerhalb der App, zum Beispiel in Dokumente oder in deiner Cloud. So kannst du sie nach einer Neuinstallation wieder importieren.', + child: FilledButton.icon( + onPressed: busy ? null : controller.exportData, + icon: const Icon(Icons.file_download_outlined), + label: const Text('Als JSON exportieren'), + ), + ), + const SizedBox(height: 20), + EntryPanel( + title: 'Daten importieren', + subtitle: 'Wähle eine JSON-Datensicherung dieser App. Beim Import werden alle aktuell gespeicherten Daten durch den Inhalt der Datei ersetzt. Exportiere bei Bedarf vorher deinen aktuellen Bestand.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + OutlinedButton.icon( + onPressed: busy ? null : controller.selectImport, + icon: const Icon(Icons.file_upload_outlined), + label: const Text('JSON-Datei auswählen'), + ), + if (backup != null) ...[ + const SizedBox(height: 16), + const Text('Geprüfte Datensicherung:'), + Text( + '${backup.data[StorageKeys.filamente]!.length} Filamente', + ), + Text( + '${backup.data[StorageKeys.anwendungen]!.length} Profile', + ), + Text( + '${backup.data[StorageKeys.bauteile]!.length} Bauteile', + ), + Text( + backup.data[StorageKeys.materialien]!.isEmpty + ? 'Materialarten: Standardliste' + : '${(backup.data[StorageKeys.materialien]!.single['werte'] as List).length} Materialarten', + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: busy + ? null + : () => _confirmImport(context), + icon: const Icon(Icons.restore), + label: const Text('Daten ersetzen und importieren'), + ), + ], + ], + ), + ), + if (busy) ...[ + const SizedBox(height: 20), + const LinearProgressIndicator(), + ], + EntryMessage( + message: controller.message.value, + hasError: controller.hasError.value, + ), + ], + ); + }), + ), + ), + ), + ), + ); + + Future _confirmImport(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Alle Daten ersetzen?'), + content: const Text( + 'Der aktuelle Bestand wird vollständig durch die ausgewählte Datensicherung ersetzt. Auch eine leere Sicherung ersetzt den Bestand.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Abbrechen'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Daten ersetzen'), + ), + ], + ), + ); + if (confirmed == true) await controller.importData(); + } +} diff --git a/lib/pages/home_view.dart b/lib/pages/home_view.dart index 87f9414..903fd3b 100644 --- a/lib/pages/home_view.dart +++ b/lib/pages/home_view.dart @@ -8,6 +8,7 @@ import '../widgets/filament_form.dart'; import '../widgets/filament_inventory.dart'; import 'anwendung_view.dart'; import 'bauteil_view.dart'; +import 'export_view.dart'; class HomeView extends GetView { static const routeName = '/home-view'; @@ -39,6 +40,24 @@ class HomeView extends GetView { ], ), ), + drawer: Drawer( + child: SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + const ListTile(title: Text('Filamentverwaltung')), + ListTile( + leading: const Icon(Icons.import_export), + title: const Text('Daten exportieren / importieren'), + onTap: () { + Navigator.of(context).pop(); + Get.toNamed(ExportView.routeName); + }, + ), + ], + ), + ), + ), body: SafeArea( top: false, child: Align( diff --git a/lib/services/backup_file_service.dart b/lib/services/backup_file_service.dart new file mode 100644 index 0000000..3b5e396 --- /dev/null +++ b/lib/services/backup_file_service.dart @@ -0,0 +1,24 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:file_picker/file_picker.dart'; + +class BackupFileService { + Future save(String json, String name) async => + await FilePicker.saveFile( + dialogTitle: 'Datensicherung speichern', + fileName: name, + bytes: Uint8List.fromList(utf8.encode(json)), + ) != + null; + + Future open() async { + final file = await FilePicker.pickFile( + dialogTitle: 'Datensicherung auswählen', + type: FileType.custom, + allowedExtensions: ['json'], + ); + if (file == null) return null; + return utf8.decode(await file.readAsBytes()); + } +} diff --git a/lib/widgets/anwendung_form.dart b/lib/widgets/anwendung_form.dart index fd0bf75..b83d8bd 100644 --- a/lib/widgets/anwendung_form.dart +++ b/lib/widgets/anwendung_form.dart @@ -13,7 +13,7 @@ class AnwendungForm extends StatelessWidget { Widget build(BuildContext context) { final filament = controller.filament.value!; return EntryPanel( - title: 'Verarbeitung erfassen', + title: 'Verarbeitungsprofil bearbeiten', subtitle: '${filament.marke} · ${filament.material} · ${filament.farbe}\n' 'Speichere die Einstellungen für dieses Filament als Profil.', diff --git a/lib/widgets/filament_form.dart b/lib/widgets/filament_form.dart index 1144972..488f621 100644 --- a/lib/widgets/filament_form.dart +++ b/lib/widgets/filament_form.dart @@ -5,6 +5,7 @@ import 'package:intl/intl.dart'; import '../controller/home_controller.dart'; import '../theme/app_theme.dart'; import '../helper/input_validators.dart'; +import 'filament_material_field.dart'; class FilamentForm extends StatelessWidget { const FilamentForm({super.key, required this.controller}); @@ -130,29 +131,7 @@ class FilamentForm extends StatelessWidget { : 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; - }, - ), - ), + FilamentMaterialField(controller: controller), const SizedBox(height: 16), TextFormField( controller: controller.farbeController, diff --git a/lib/widgets/filament_material_field.dart b/lib/widgets/filament_material_field.dart new file mode 100644 index 0000000..751bec7 --- /dev/null +++ b/lib/widgets/filament_material_field.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../controller/home_controller.dart'; + +class FilamentMaterialField extends StatelessWidget { + const FilamentMaterialField({super.key, required this.controller}); + + final HomeController controller; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + controller: controller.materialController, + onChanged: (value) => controller.material.value = value, + validator: (value) => value == null || value.trim().isEmpty + ? 'Materialart eingeben oder auswählen' + : null, + decoration: InputDecoration( + labelText: 'Material', + helperText: 'Auswählen oder eigene Art eingeben', + prefixIcon: const Icon(Icons.layers_outlined), + suffixIcon: Obx( + () => PopupMenuButton( + tooltip: 'Material auswählen', + enabled: controller.materialien.isNotEmpty, + icon: const Icon(Icons.expand_more), + onSelected: controller.selectMaterial, + itemBuilder: (context) => controller.materialien + .map((item) => PopupMenuItem(value: item, child: Text(item))) + .toList(), + ), + ), + ), + ), + Obx( + () => Wrap( + spacing: 8, + children: [ + TextButton.icon( + onPressed: controller.isSavingMaterial.value + ? null + : () async { + if (await controller.addMaterial() && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Art ist in der Liste.'), + ), + ); + } + }, + icon: const Icon(Icons.add), + label: const Text('Art hinzufügen'), + ), + TextButton.icon( + onPressed: () => _manage(context), + icon: const Icon(Icons.tune), + label: const Text('Arten verwalten'), + ), + ], + ), + ), + _message(), + ], + ); + + Widget _message() => Obx(() { + final message = controller.materialMessage.value; + return message == null + ? const SizedBox.shrink() + : Text(message, style: const TextStyle(color: Colors.red)); + }); + + Future _manage(BuildContext context) => showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Materialarten verwalten'), + content: SizedBox( + width: 360, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Entfernte Arten verschwinden aus der Auswahl. Gespeicherte Filamente bleiben erhalten.', + ), + const SizedBox(height: 12), + Flexible( + child: Obx(() { + final values = controller.materialien.toList(); + final busy = controller.isSavingMaterial.value; + if (values.isEmpty) { + return const Text('Keine Materialarten in der Liste.'); + } + return ListView.builder( + shrinkWrap: true, + itemCount: values.length, + itemBuilder: (context, index) => ListTile( + contentPadding: EdgeInsets.zero, + title: Text(values[index]), + trailing: IconButton( + tooltip: '${values[index]} aus Liste entfernen', + icon: const Icon(Icons.delete_outline), + onPressed: busy + ? null + : () => controller.removeMaterial(values[index]), + ), + ), + ); + }), + ), + _message(), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Schließen'), + ), + ], + ), + ); +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 14b5f7c..ac14e24 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,8 +5,10 @@ import FlutterMacOS import Foundation +import file_picker_darwin import file_selector_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) } diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index 87fbf28..643099f 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -2,7 +2,7 @@ - com.apple.security.files.user-selected.read-only + com.apple.security.files.user-selected.read-write com.apple.security.app-sandbox diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index e8ef7f6..a7cf386 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -2,7 +2,7 @@ - com.apple.security.files.user-selected.read-only + com.apple.security.files.user-selected.read-write com.apple.security.app-sandbox diff --git a/pubspec.lock b/pubspec.lock index 4fc6fdd..ed2d941 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + android_file_picker: + dependency: transitive + description: + name: android_file_picker + sha256: "03d3dd3cb578ab26b1e528aeea27bf6b976942f5a24e3da82d270369b8e161a8" + url: "https://pub.dev" + source: hosted + version: "2.1.2" args: dependency: transitive description: @@ -89,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.4" + dbus: + dependency: transitive + description: + name: dbus + sha256: f7db8504e715b835f5ebe5fe74604ccd961d504c73389a5d8e81bafc2e2f3612 + url: "https://pub.dev" + source: hosted + version: "0.8.0" fake_async: dependency: transitive description: @@ -105,6 +121,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: e67f1bcbef00f8b28f3b6ce09a1f0d2d9de37cf1e1e7e73b70df7a20d3eee8be + url: "https://pub.dev" + source: hosted + version: "13.3.0" + file_picker_darwin: + dependency: transitive + description: + name: file_picker_darwin + sha256: "13e731433fb3e59c1aff5a1197e41c5c5f930fad16342d101579086271a70d7b" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + file_picker_linux: + dependency: transitive + description: + name: file_picker_linux + sha256: c6562fc7d426db2fb85ea66fe2627953d2709932df5584ceaacf0aa5e39cf4da + url: "https://pub.dev" + source: hosted + version: "2.0.2" + file_picker_platform_interface: + dependency: transitive + description: + name: file_picker_platform_interface + sha256: "336e7168306834a2b68e6168ed768e00d3fabeac23154d5793e277ca9f97cf20" + url: "https://pub.dev" + source: hosted + version: "4.2.0" + file_picker_web: + dependency: transitive + description: + name: file_picker_web + sha256: a2288008280aabe371aff029466b3405e60838e0a1d714767488c59b926c8b8c + url: "https://pub.dev" + source: hosted + version: "4.2.0" file_selector: dependency: "direct main" description: @@ -265,10 +329,10 @@ packages: dependency: transitive description: name: jni - sha256: "6b9ca0602fef0230e83149afbe3f9f8227e94f2bbe84a1da4f14d699742f402d" + sha256: d8df6dab6847855512fe44dd9032b0bbe34dda7bf7597e41d30750879779813f url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" jni_flutter: dependency: transitive description: @@ -429,6 +493,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" platform: dependency: transitive description: @@ -554,6 +626,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + win32: + dependency: transitive + description: + name: win32 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" + source: hosted + version: "6.4.0" + windows_file_picker: + dependency: transitive + description: + name: windows_file_picker + sha256: "18452aa293de3b6961c6d05b0e71b4be7bb90c67635abdd3aa5c4e75dd875072" + url: "https://pub.dev" + source: hosted + version: "2.0.1" xdg_directories: dependency: transitive description: @@ -562,6 +650,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" yaml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index de23b6a..62ba7c6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,7 @@ dependencies: google_fonts: ^9.0.0 uuid: ^4.6.0 file_selector: ^1.1.0 + file_picker: ^13.3.0 dev_dependencies: flutter_test: diff --git a/test/anwendung_bauteil_test.dart b/test/anwendung_bauteil_test.dart index 55a7ffe..08ee75c 100644 --- a/test/anwendung_bauteil_test.dart +++ b/test/anwendung_bauteil_test.dart @@ -16,7 +16,6 @@ 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'; @@ -131,19 +130,84 @@ void main() { tag: 'reload', ); expect(reloaded.anwendungen.single.anwendungId, saved.anwendungId); + expect(reloaded.druckController.text, '220.5'); + expect(reloaded.druckbettController.text, '65.0'); + expect(reloaded.bauraumController.text, '40.0'); + expect(reloaded.bauraumGeschlossen.value, isTrue); + expect(reloaded.sonstigesController.text, 'Luefter 50 %'); 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(controller.druckController.text, '220,5'); + expect(find.text('Gespeicherte Profile'), findsNothing); expect(tester.takeException(), isNull); }, ); + testWidgets('Editing replaces the profile and keeps its identity', ( + tester, + ) async { + final otherProfile = storage + .getModelList(AnwendungController.storageKey) + .single; + final original = profile('profile-a', 'a'); + storage.tables[AnwendungController.storageKey]!.add(original.toJson()); + final controller = Get.put(AnwendungController(filamentId: 'a')); + await show(tester, const AnwendungView()); + expect(controller.druckController.text, '210.0'); + expect(controller.druckbettController.text, '60.0'); + expect(controller.bauraumController.text, isEmpty); + + controller.druckController.text = '225'; + controller.sonstigesController.text = 'Aktualisiert'; + storage.failWrites = true; + await controller.saveAnwendung(); + expect(controller.hasError.value, isTrue); + expect(controller.anwendungen.single.druckTemperatur, 210); + expect(controller.druckController.text, '225'); + + storage.failWrites = false; + await controller.saveAnwendung(); + controller.druckController.text = '230'; + await controller.saveAnwendung(); + final stored = storage.getModelList(AnwendungController.storageKey); + expect(stored, hasLength(2)); + expect(stored.first, otherProfile); + final updated = AnwendungModel.fromJson(stored.last); + expect(updated.anwendungId, original.anwendungId); + expect(updated.erstelltAm, original.erstelltAm); + expect(updated.druckTemperatur, 230); + expect(updated.sonstiges, 'Aktualisiert'); + expect(controller.anwendungen, hasLength(1)); + }); + + testWidgets('Legacy profiles load the latest and save one per filament', ( + tester, + ) async { + final latest = AnwendungModel( + anwendungId: 'latest-a', + filamentId: 'a', + erstelltAm: DateTime(2026, 10, 27), + druckbettTemperatur: 70, + druckTemperatur: 240, + bauraumGeschlossen: true, + bauraumTemperatur: 45, + sonstiges: 'Neuestes Profil', + ); + storage.tables[AnwendungController.storageKey]!.addAll([ + latest.toJson(), + profile('older-a', 'a').toJson(), + ]); + final controller = Get.put(AnwendungController(filamentId: 'a')); + await show(tester, const AnwendungView()); + expect(controller.druckController.text, '240.0'); + expect(controller.bauraumGeschlossen.value, isTrue); + expect(controller.sonstigesController.text, 'Neuestes Profil'); + await controller.saveAnwendung(); + final stored = storage.getModelList(AnwendungController.storageKey); + expect(stored, hasLength(2)); + expect(stored.where((item) => item['filamentId'] == 'a'), hasLength(1)); + expect(stored.last['anwendungId'], 'latest-a'); + }); + testWidgets('Profile write failure retains input and does not add history', ( tester, ) async { diff --git a/test/export_test.dart b/test/export_test.dart new file mode 100644 index 0000000..26cd404 --- /dev/null +++ b/test/export_test.dart @@ -0,0 +1,234 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; +import 'package:ertl_filament_verwaltung/controller/export_controller.dart'; +import 'package:ertl_filament_verwaltung/controller/home_controller.dart'; +import 'package:ertl_filament_verwaltung/helper/sample_routes.dart'; +import 'package:ertl_filament_verwaltung/helper/storage_keys.dart'; +import 'package:ertl_filament_verwaltung/models/anwendung_model.dart'; +import 'package:ertl_filament_verwaltung/models/backup_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/export_view.dart'; +import 'package:ertl_filament_verwaltung/pages/home_view.dart'; +import 'package:ertl_filament_verwaltung/services/backup_file_service.dart'; +import 'package:ertl_filament_verwaltung/services/get_storage_services.dart'; +import 'package:ertl_filament_verwaltung/theme/app_theme.dart'; + +import 'helpers/memory_storage_service.dart'; + +class FakeBackupFiles extends BackupFileService { + String? source; + String? exported; + bool cancelSave = false; + + @override + Future open() async => source; + + @override + Future save(String json, String name) async { + if (cancelSave) return false; + exported = json; + return true; + } +} + +class FailOnceStorage extends MemoryStorageService { + String? failKey; + + @override + Future saveModelList( + String key, + List> items, + ) async { + if (failKey == key) { + failKey = null; + throw StateError('Write failed'); + } + await super.saveModelList(key, items); + } +} + +void main() { + late FailOnceStorage storage; + late FakeBackupFiles files; + late ExportController controller; + + Map>> fixture() => { + StorageKeys.filamente: [ + KaufFilamentModel( + filamentId: 'f1', + kaufdatum: DateTime(2026), + haendler: 'Shop', + marke: 'Marke', + material: 'PA12', + preis: 20, + farbe: 'Grün', + gewicht: '1000', + ).toJson(), + ], + StorageKeys.anwendungen: [ + AnwendungModel( + anwendungId: 'p1', + filamentId: 'f1', + erstelltAm: DateTime(2026), + druckbettTemperatur: 60, + druckTemperatur: 220, + bauraumGeschlossen: true, + ).toJson(), + ], + StorageKeys.bauteile: [ + BauteilModel( + bauteilId: 'b1', + erstelltAm: DateTime(2026), + name: 'Halter', + verwendung: 'Werkstatt', + druckdauerMinuten: 60, + gewichtGramm: 40, + abfallGramm: 10, + filamente: [ + const BauteilFilamentModel( + filamentId: 'f1', + anwendungId: 'p1', + verbrauchGramm: 50, + ), + ], + fotoBase64: 'aGVsbG8=', + ).toJson(), + ], + StorageKeys.materialien: [ + { + 'werte': ['PA12', 'PLA'], + }, + ], + }; + + setUp(() { + Get.testMode = true; + storage = FailOnceStorage(); + Get.put(storage); + files = FakeBackupFiles(); + controller = Get.put(ExportController(fileService: files)); + }); + tearDown(() => Get.reset()); + + test( + 'Complete JSON backup restores photos, IDs, stock and custom materials', + () async { + storage.tables.addAll(fixture()); + await controller.exportData(); + expect(files.exported, isNotNull); + expect(BackupModel.decode(files.exported!).data, fixture()); + storage.tables.clear(); + final home = Get.put(HomeController()); + files.source = files.exported; + await controller.selectImport(); + expect(storage.tables, isEmpty); + await controller.importData(); + expect(storage.tables, fixture()); + expect(home.materialien, ['PA12', 'PLA']); + expect(home.restgewicht(home.filamente.single), 950); + expect(controller.hasError.value, isFalse); + }, + ); + + test( + 'Malformed, incomplete, future-version and duplicate data do not write', + () async { + storage.tables.addAll(fixture()); + final valid = + jsonDecode(BackupModel(fixture()).encode()) as Map; + final badData = fixture(); + badData[StorageKeys.filamente]!.add( + badData[StorageKeys.filamente]!.single, + ); + for (final source in [ + 'not json', + '{}', + jsonEncode({...valid, 'version': 2}), + jsonEncode({ + ...valid, + 'daten': {StorageKeys.filamente: []}, + }), + BackupModel(badData).encode(), + ]) { + files.source = source; + await controller.selectImport(); + await controller.importData(); + expect(controller.pendingBackup.value, isNull); + expect(controller.hasError.value, isTrue); + expect(storage.tables, fixture()); + } + }, + ); + + test('Cancelled dialogs leave data unchanged', () async { + storage.tables.addAll(fixture()); + files.cancelSave = true; + await controller.exportData(); + await controller.selectImport(); + expect(controller.message.value, isNull); + expect(controller.pendingBackup.value, isNull); + expect(storage.tables, fixture()); + }); + + test('A failed import restores previously written tables', () async { + storage.tables.addAll(fixture()); + files.source = BackupModel({for (final key in BackupModel.keys) key: []}) + .encode(); + await controller.selectImport(); + storage.failKey = StorageKeys.bauteile; + await controller.importData(); + expect(storage.tables, fixture()); + expect(controller.hasError.value, isTrue); + expect(controller.isBusy.value, isFalse); + }); + + testWidgets('Import preview requires confirmation and works on a phone', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 640); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + storage.tables.addAll(fixture()); + files.source = BackupModel({for (final key in BackupModel.keys) key: []}) + .encode(); + await tester.pumpWidget( + GetMaterialApp(theme: AppTheme.light, home: const ExportView()), + ); + await controller.selectImport(); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Daten ersetzen und importieren')); + await tester.tap(find.text('Daten ersetzen und importieren')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Abbrechen')); + await tester.pumpAndSettle(); + expect(storage.tables, fixture()); + await tester.tap(find.text('Daten ersetzen und importieren')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Daten ersetzen')); + await tester.pumpAndSettle(); + expect(storage.tables.values.every((value) => value.isEmpty), isTrue); + expect(tester.takeException(), isNull); + }); + + testWidgets('Hamburger menu opens the backup route', (tester) async { + await tester.pumpWidget( + GetMaterialApp( + theme: AppTheme.light, + initialRoute: HomeView.routeName, + getPages: SampleRouts.samplePages, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.menu)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Daten exportieren / importieren')); + await tester.pumpAndSettle(); + expect(find.byType(ExportView), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 2b10461..ca74163 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:get/get.dart'; import 'package:ertl_filament_verwaltung/controller/home_controller.dart'; +import 'package:ertl_filament_verwaltung/helper/storage_keys.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'; @@ -114,4 +115,106 @@ void main() { ); expect(tester.takeException(), isNull); }); + + testWidgets('Custom material can be typed, selected and removed on a phone', ( + tester, + ) async { + await showHome(tester, const Size(320, 640)); + final field = find.byWidgetPredicate( + (widget) => + widget is TextFormField && + widget.controller == controller.materialController, + ); + await tester.ensureVisible(field); + await tester.enterText(field, 'PA12'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.ensureVisible(find.text('Art hinzufügen')); + await tester.tap(find.text('Art hinzufügen')); + await tester.pumpAndSettle(); + expect(controller.materialien, contains('PA12')); + + await Scrollable.ensureVisible( + tester.element(find.byTooltip('Material auswählen')), + alignment: 0.5, + ); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Material auswählen')); + await tester.pumpAndSettle(); + await tester.tap(find.text('PETG').last); + await tester.pumpAndSettle(); + expect(controller.materialController.text, 'PETG'); + + await tester.ensureVisible(find.text('Arten verwalten')); + await tester.tap(find.text('Arten verwalten')); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.byTooltip('PA12 aus Liste entfernen'), + 180, + scrollable: find.descendant( + of: find.byType(AlertDialog), + matching: find.byType(Scrollable), + ), + ); + await tester.tap(find.byTooltip('PA12 aus Liste entfernen')); + await tester.pumpAndSettle(); + expect(controller.materialien, isNot(contains('PA12'))); + expect(tester.takeException(), isNull); + await tester.tap(find.text('Schließen')); + await tester.pumpAndSettle(); + final reloaded = Get.put(HomeController(), tag: 'materials-reload'); + expect(reloaded.materialien, isNot(contains('PA12'))); + }); + + testWidgets('Saving adds custom material and removal preserves purchases', ( + tester, + ) async { + await showHome(tester, const Size(1280, 900)); + controller.haendlerController.text = 'Shop'; + controller.markeController.text = 'Marke'; + controller.farbeController.text = 'Rot'; + controller.preisController.text = '20'; + controller.gewichtController.text = '1000'; + controller.materialController.text = ' PA12 '; + await controller.saveFilament(); + expect(controller.filamente.single.material, 'PA12'); + final reloaded = Get.put(HomeController(), tag: 'custom-reload'); + expect(reloaded.materialien, contains('PA12')); + await controller.removeMaterial('PA12'); + await controller.editFilament(controller.filamente.single); + await tester.pumpAndSettle(); + expect(controller.materialController.text, 'PA12'); + expect(controller.materialien, isNot(contains('PA12'))); + expect(controller.filamente.single.material, 'PA12'); + expect(tester.takeException(), isNull); + }); + + test( + 'An empty material list survives reload and failed writes keep the list', + () async { + final storage = Get.find() as MemoryStorageService; + controller.materialController.text = 'pa12'; + expect(await controller.addMaterial(), isTrue); + controller.materialController.text = ' PA12 '; + expect(await controller.addMaterial(), isTrue); + expect( + controller.materialien.where((item) => item.toLowerCase() == 'pa12'), + hasLength(1), + ); + storage.failWrites = true; + expect(await controller.removeMaterial('pa12'), isFalse); + expect(controller.materialien, contains('pa12')); + expect(controller.materialMessage.value, isNotNull); + storage.failWrites = false; + for (final value in controller.materialien.toList()) { + await controller.removeMaterial(value); + } + final reloaded = Get.put(HomeController(), tag: 'empty-reload'); + expect(reloaded.materialien, isEmpty); + expect(reloaded.materialController.text, isEmpty); + expect( + storage.getModelList(StorageKeys.materialien).single['werte'], + isEmpty, + ); + }, + ); }