add import export and items

This commit is contained in:
atseirjo
2026-10-28 08:10:55 +01:00
parent 07a1bb02fd
commit 4a1731f986
22 changed files with 1118 additions and 106 deletions
-15
View File
@@ -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
+21 -18
View File
@@ -36,13 +36,22 @@ class AnwendungController extends GetxController {
filament.value = filamente.firstWhereOrNull(
(item) => item.filamentId == filamentId,
);
anwendungen.assignAll(
final profiles =
storage
.getModelList(storageKey)
.map(AnwendungModel.fromJson)
.where((item) => item.filamentId == filamentId),
);
anwendungen.sort((a, b) => b.erstelltAm.compareTo(a.erstelltAm));
.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<void> 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();
+105
View File
@@ -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<GetStorageService>();
final isBusy = false.obs;
final message = RxnString();
final hasError = false.obs;
final pendingBackup = Rxn<BackupModel>();
Future<void> 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<void> 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<void> importData() async {
final backup = pendingBackup.value;
if (isBusy.value || backup == null) return;
isBusy.value = true;
message.value = null;
final previous = <String, List<Map<String, dynamic>>>{};
final attempted = <String>[];
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<HomeController>()) {
final home = Get.find<HomeController>();
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;
}
}
+72 -5
View File
@@ -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<String>());
} else {
materialien.assignAll({
...defaultMaterialien,
...filamente.map((filament) => filament.material),
});
}
}
void selectMaterial(String value) {
material.value = value;
materialController.text = value;
}
Future<bool> 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<bool> removeMaterial(String value) =>
_saveMaterialien(materialien.where((item) => item != value).toList());
Future<bool> _saveMaterialien(List<String> 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();
}
}
+9
View File
@@ -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<GetPage<dynamic>> samplePages = [
GetPage(
name: ExportView.routeName,
page: () => const ExportView(),
binding: BindingsBuilder(
() => Get.lazyPut<ExportController>(() => ExportController()),
),
),
GetPage(
name: '${AnwendungView.routeName}/:filamentId',
page: () => const AnwendungView(),
+1
View File
@@ -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';
}
+105
View File
@@ -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<String, List<Map<String, dynamic>>> 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 = <String, List<Map<String, dynamic>>>{
for (final key in keys)
key: (raw[key] as List)
.map((item) => Map<String, dynamic>.from(item as Map))
.toList(),
};
final filamentIds = <String>{};
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 = <String>{};
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 = <String>{};
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<String>();
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;
}
-28
View File
@@ -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<AnwendungController> {
static const routeName = '/anwendung-view';
@@ -39,32 +37,6 @@ class AnwendungView extends GetView<AnwendungController> {
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,
);
},
);
}),
),
],
);
}),
+117
View File
@@ -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<ExportController> {
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<void> _confirmImport(BuildContext context) async {
final confirmed = await showDialog<bool>(
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();
}
}
+19
View File
@@ -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<HomeController> {
static const routeName = '/home-view';
@@ -39,6 +40,24 @@ class HomeView extends GetView<HomeController> {
],
),
),
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(
+24
View File
@@ -0,0 +1,24 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
class BackupFileService {
Future<bool> save(String json, String name) async =>
await FilePicker.saveFile(
dialogTitle: 'Datensicherung speichern',
fileName: name,
bytes: Uint8List.fromList(utf8.encode(json)),
) !=
null;
Future<String?> 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());
}
}
+1 -1
View File
@@ -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.',
+2 -23
View File
@@ -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<String>(
key: ValueKey(
'${controller.editingId.value ?? "new"}_${controller.material.value}',
),
initialValue: controller.material.value,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Material',
prefixIcon: Icon(Icons.layers_outlined),
),
items: controller.materialien
.toSet()
.map(
(item) =>
DropdownMenuItem(value: item, child: Text(item)),
)
.toList(),
onChanged: (value) {
if (value != null) controller.material.value = value;
},
),
),
FilamentMaterialField(controller: controller),
const SizedBox(height: 16),
TextFormField(
controller: controller.farbeController,
+126
View File
@@ -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<String>(
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<void> _manage(BuildContext context) => showDialog<void>(
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'),
),
],
),
);
}
@@ -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"))
}
+1 -1
View File
@@ -2,7 +2,7 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.files.user-selected.read-only</key>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.app-sandbox</key>
<true/>
+1 -1
View File
@@ -2,7 +2,7 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.files.user-selected.read-only</key>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.app-sandbox</key>
<true/>
+98 -2
View File
@@ -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:
+1
View File
@@ -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:
+73 -9
View File
@@ -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 {
+234
View File
@@ -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<String?> open() async => source;
@override
Future<bool> save(String json, String name) async {
if (cancelSave) return false;
exported = json;
return true;
}
}
class FailOnceStorage extends MemoryStorageService {
String? failKey;
@override
Future<void> saveModelList(
String key,
List<Map<String, dynamic>> 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<String, List<Map<String, dynamic>>> 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<GetStorageService>(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<String, dynamic>;
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);
});
}
+103
View File
@@ -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<GetStorageService>() 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,
);
},
);
}