add import export and items
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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/>
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user