add two views
This commit is contained in:
@@ -1,17 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../helper/input_validators.dart';
|
||||
import '../models/anwendung_model.dart';
|
||||
import '../models/kauf_filament_model.dart';
|
||||
import '../services/get_storage_services.dart';
|
||||
import 'home_controller.dart';
|
||||
|
||||
class AnwendungController extends GetxController {
|
||||
final count = 0.obs;
|
||||
AnwendungController({required this.filamentId});
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
static const storageKey = 'filament_anwendungen';
|
||||
final String filamentId;
|
||||
final storage = Get.find<GetStorageService>();
|
||||
final formKey = GlobalKey<FormState>();
|
||||
final druckbettController = TextEditingController();
|
||||
final druckController = TextEditingController();
|
||||
final bauraumController = TextEditingController();
|
||||
final sonstigesController = TextEditingController();
|
||||
final bauraumGeschlossen = false.obs;
|
||||
final isSaving = false.obs;
|
||||
final message = RxnString();
|
||||
final hasError = false.obs;
|
||||
final filament = Rxn<KaufFilamentModel>();
|
||||
final anwendungen = <AnwendungModel>[].obs;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
final filamente = storage
|
||||
.getModelList(HomeController.storageKey)
|
||||
.map(KaufFilamentModel.fromJson)
|
||||
.toList();
|
||||
filament.value = filamente.firstWhereOrNull(
|
||||
(item) => item.filamentId == filamentId,
|
||||
);
|
||||
anwendungen.assignAll(
|
||||
storage
|
||||
.getModelList(storageKey)
|
||||
.map(AnwendungModel.fromJson)
|
||||
.where((item) => item.filamentId == filamentId),
|
||||
);
|
||||
anwendungen.sort((a, b) => b.erstelltAm.compareTo(a.erstelltAm));
|
||||
}
|
||||
|
||||
Future<void> saveAnwendung() async {
|
||||
if (isSaving.value || filament.value == null) return;
|
||||
if (!(formKey.currentState?.validate() ?? false)) return;
|
||||
isSaving.value = true;
|
||||
message.value = null;
|
||||
try {
|
||||
final entry = AnwendungModel(
|
||||
anwendungId: const Uuid().v4(),
|
||||
filamentId: filamentId,
|
||||
erstelltAm: DateTime.now(),
|
||||
druckbettTemperatur: InputValidators.number(druckbettController.text)!,
|
||||
druckTemperatur: InputValidators.number(druckController.text)!,
|
||||
bauraumGeschlossen: bauraumGeschlossen.value,
|
||||
bauraumTemperatur: InputValidators.number(bauraumController.text),
|
||||
sonstiges: sonstigesController.text.trim(),
|
||||
);
|
||||
final entries = storage.getModelList(storageKey);
|
||||
await storage.saveModelList(storageKey, [...entries, entry.toJson()]);
|
||||
anwendungen.insert(0, entry);
|
||||
resetForm();
|
||||
hasError.value = false;
|
||||
message.value = 'Verarbeitungsprofil gespeichert.';
|
||||
} catch (_) {
|
||||
hasError.value = true;
|
||||
message.value = 'Speichern fehlgeschlagen. Bitte erneut versuchen.';
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onReady() {}
|
||||
|
||||
@override
|
||||
void onClose() {}
|
||||
void resetForm() {
|
||||
formKey.currentState?.reset();
|
||||
druckbettController.clear();
|
||||
druckController.clear();
|
||||
bauraumController.clear();
|
||||
sonstigesController.clear();
|
||||
bauraumGeschlossen.value = false;
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
druckbettController.dispose();
|
||||
druckController.dispose();
|
||||
bauraumController.dispose();
|
||||
sonstigesController.dispose();
|
||||
super.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../helper/display_formats.dart';
|
||||
import '../helper/filament_bestand.dart';
|
||||
import '../helper/input_validators.dart';
|
||||
import '../helper/storage_keys.dart';
|
||||
import '../models/anwendung_model.dart';
|
||||
import '../models/bauteil_model.dart';
|
||||
import '../models/kauf_filament_model.dart';
|
||||
import '../services/bauteil_photo_service.dart';
|
||||
import '../services/get_storage_services.dart';
|
||||
import 'home_controller.dart';
|
||||
|
||||
class BauteilController extends GetxController {
|
||||
BauteilController({BauteilPhotoService? photoService})
|
||||
: photoService = photoService ?? BauteilPhotoService();
|
||||
|
||||
static const storageKey = StorageKeys.bauteile;
|
||||
final storage = Get.find<GetStorageService>();
|
||||
final BauteilPhotoService photoService;
|
||||
final formKey = GlobalKey<FormState>();
|
||||
final scrollController = ScrollController();
|
||||
final nameController = TextEditingController();
|
||||
final verwendungController = TextEditingController();
|
||||
final stundenController = TextEditingController(text: '0');
|
||||
final minutenController = TextEditingController(text: '0');
|
||||
final gewichtController = TextEditingController();
|
||||
final abfallController = TextEditingController(text: '0');
|
||||
final _verbrauchControllers = <String, TextEditingController>{};
|
||||
final filamente = <KaufFilamentModel>[].obs;
|
||||
final profile = <AnwendungModel>[].obs;
|
||||
final bauteile = <BauteilModel>[].obs;
|
||||
final selectedFilamente = <String, String?>{}.obs;
|
||||
final editingId = RxnString();
|
||||
final foto = Rxn<Uint8List>();
|
||||
final isPickingPhoto = false.obs;
|
||||
final isSaving = false.obs;
|
||||
final message = RxnString();
|
||||
final hasError = false.obs;
|
||||
bool _closed = false;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
filamente.assignAll(
|
||||
storage
|
||||
.getModelList(StorageKeys.filamente)
|
||||
.map(KaufFilamentModel.fromJson),
|
||||
);
|
||||
profile.assignAll(
|
||||
storage
|
||||
.getModelList(StorageKeys.anwendungen)
|
||||
.map(AnwendungModel.fromJson),
|
||||
);
|
||||
bauteile.assignAll(_loadBauteile());
|
||||
bauteile.sort((a, b) => b.erstelltAm.compareTo(a.erstelltAm));
|
||||
}
|
||||
|
||||
List<BauteilModel> _loadBauteile() =>
|
||||
storage.getModelList(storageKey).map(BauteilModel.fromJson).toList();
|
||||
|
||||
TextEditingController verbrauchController(String id) =>
|
||||
_verbrauchControllers.putIfAbsent(id, () => TextEditingController());
|
||||
|
||||
List<AnwendungModel> profilesFor(String filamentId) =>
|
||||
profile.where((item) => item.filamentId == filamentId).toList()
|
||||
..sort((a, b) => b.erstelltAm.compareTo(a.erstelltAm));
|
||||
|
||||
String filamentLabel(String id) {
|
||||
final item = filamente.firstWhereOrNull((item) => item.filamentId == id);
|
||||
if (item == null) return 'Gelöschtes Filament ($id)';
|
||||
return '${item.marke} · ${item.material} · ${item.farbe}';
|
||||
}
|
||||
|
||||
double? verfuegbarGramm(String id) {
|
||||
final start = filamente
|
||||
.firstWhereOrNull((item) => item.filamentId == id)
|
||||
?.gewichtGramm;
|
||||
if (start == null) return null;
|
||||
final used = FilamentBestand.verbrauch(
|
||||
bauteile,
|
||||
ohneBauteilId: editingId.value,
|
||||
);
|
||||
return start - (used[id] ?? 0);
|
||||
}
|
||||
|
||||
void toggleFilament(String id, bool selected) {
|
||||
if (selected) {
|
||||
verbrauchController(id);
|
||||
selectedFilamente[id] = null;
|
||||
} else {
|
||||
selectedFilamente.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
void selectProfile(String filamentId, String? anwendungId) {
|
||||
if (!selectedFilamente.containsKey(filamentId)) return;
|
||||
if (anwendungId != null &&
|
||||
!profilesFor(filamentId)
|
||||
.any((item) => item.anwendungId == anwendungId)) {
|
||||
return;
|
||||
}
|
||||
selectedFilamente[filamentId] = anwendungId;
|
||||
}
|
||||
|
||||
Future<void> pickPhoto() async {
|
||||
if (isPickingPhoto.value || isSaving.value) return;
|
||||
isPickingPhoto.value = true;
|
||||
try {
|
||||
final bytes = await photoService.pickPhoto();
|
||||
if (!_closed && bytes != null) foto.value = bytes;
|
||||
} on FormatException catch (error) {
|
||||
if (!_closed) _error(error.message);
|
||||
} catch (_) {
|
||||
if (!_closed) {
|
||||
_error(
|
||||
'Das Foto konnte nicht geladen werden. Bitte ein JPG-, PNG- oder WebP-Bild auswählen.',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!_closed) isPickingPhoto.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
void removePhoto() => foto.value = null;
|
||||
|
||||
void _error(String text) {
|
||||
hasError.value = true;
|
||||
message.value = text;
|
||||
}
|
||||
|
||||
String? _validateConsumption(
|
||||
BauteilModel entry,
|
||||
List<BauteilModel> existing,
|
||||
) {
|
||||
if (entry.filamente.isEmpty) {
|
||||
return 'Bitte mindestens ein Filament auswählen.';
|
||||
}
|
||||
final sum = entry.filamente.fold<double>(
|
||||
0,
|
||||
(sum, item) => sum + item.verbrauchGramm,
|
||||
);
|
||||
if (entry.filamente.any(
|
||||
(item) => !item.verbrauchGramm.isFinite || item.verbrauchGramm <= 0,
|
||||
)) {
|
||||
return 'Bitte für jedes verwendete Filament einen Verbrauch größer 0 eingeben.';
|
||||
}
|
||||
if (!sum.isFinite || (sum - entry.gesamtverbrauchGramm).abs() > 0.01) {
|
||||
return 'Der Filamentverbrauch muss zusammen ${DisplayFormats.number(entry.gesamtverbrauchGramm)} g ergeben (Bauteilgewicht + Abfall).';
|
||||
}
|
||||
final used = FilamentBestand.verbrauch(
|
||||
existing,
|
||||
ohneBauteilId: entry.bauteilId,
|
||||
);
|
||||
final currentFilamente = storage
|
||||
.getModelList(StorageKeys.filamente)
|
||||
.map(KaufFilamentModel.fromJson)
|
||||
.toList();
|
||||
for (final item in entry.filamente) {
|
||||
final filament = currentFilamente.firstWhereOrNull(
|
||||
(filament) => filament.filamentId == item.filamentId,
|
||||
);
|
||||
final start = filament?.gewichtGramm;
|
||||
if (start == null) {
|
||||
return 'Filament fehlt oder sein Startgewicht ist ungültig. Bitte die Auswahl bzw. das Filament prüfen.';
|
||||
}
|
||||
final available = start - (used[item.filamentId] ?? 0);
|
||||
if (item.verbrauchGramm - available > 0.000001) {
|
||||
return '${filamentLabel(item.filamentId)}: nur ${DisplayFormats.number(available)} g verfügbar.';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// One persisted ledger determines all remaining weights. Updating or deleting
|
||||
/// an entry therefore cannot leave a second stock table out of sync.
|
||||
Future<void> _commit(List<BauteilModel> entries) async {
|
||||
await storage.saveModelList(
|
||||
storageKey,
|
||||
entries.map((item) => item.toJson()).toList(),
|
||||
);
|
||||
entries.sort((a, b) => b.erstelltAm.compareTo(a.erstelltAm));
|
||||
bauteile.assignAll(entries);
|
||||
if (Get.isRegistered<HomeController>()) {
|
||||
Get.find<HomeController>().loadVerbrauch();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveBauteil() async {
|
||||
if (isSaving.value || isPickingPhoto.value) return;
|
||||
message.value = null;
|
||||
if (!(formKey.currentState?.validate() ?? false)) return;
|
||||
if (selectedFilamente.isEmpty) {
|
||||
_error('Bitte mindestens ein Filament auswählen.');
|
||||
return;
|
||||
}
|
||||
final duration =
|
||||
int.parse(stundenController.text.trim()) * 60 +
|
||||
int.parse(minutenController.text.trim());
|
||||
if (duration <= 0) {
|
||||
_error('Die Druckdauer muss mindestens eine Minute betragen.');
|
||||
return;
|
||||
}
|
||||
isSaving.value = true;
|
||||
try {
|
||||
final existing = _loadBauteile();
|
||||
final previous = existing.firstWhereOrNull(
|
||||
(item) => item.bauteilId == editingId.value,
|
||||
);
|
||||
if (editingId.value != null && previous == null) {
|
||||
_error('Das bearbeitete Bauteil wurde nicht gefunden.');
|
||||
return;
|
||||
}
|
||||
final entry = BauteilModel(
|
||||
bauteilId: editingId.value ?? const Uuid().v4(),
|
||||
erstelltAm: previous?.erstelltAm ?? DateTime.now(),
|
||||
name: nameController.text.trim(),
|
||||
verwendung: verwendungController.text.trim(),
|
||||
druckdauerMinuten: duration,
|
||||
gewichtGramm: InputValidators.number(gewichtController.text)!,
|
||||
abfallGramm: InputValidators.number(abfallController.text)!,
|
||||
fotoBase64: foto.value == null ? null : base64Encode(foto.value!),
|
||||
filamente: selectedFilamente.entries
|
||||
.map(
|
||||
(item) => BauteilFilamentModel(
|
||||
filamentId: item.key,
|
||||
anwendungId: item.value,
|
||||
verbrauchGramm:
|
||||
InputValidators.number(
|
||||
verbrauchController(item.key).text,
|
||||
) ??
|
||||
double.nan,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
final error = _validateConsumption(entry, existing);
|
||||
if (error != null) {
|
||||
_error(error);
|
||||
return;
|
||||
}
|
||||
await _commit([
|
||||
...existing.where((item) => item.bauteilId != entry.bauteilId),
|
||||
entry,
|
||||
]);
|
||||
resetForm();
|
||||
hasError.value = false;
|
||||
message.value = 'Bauteil und Filamentverbrauch gespeichert.';
|
||||
} catch (_) {
|
||||
_error('Speichern fehlgeschlagen. Bitte erneut versuchen.');
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> editBauteil(BauteilModel entry) async {
|
||||
if (isSaving.value || isPickingPhoto.value) return;
|
||||
resetForm();
|
||||
message.value = null;
|
||||
nameController.text = entry.name;
|
||||
verwendungController.text = entry.verwendung;
|
||||
stundenController.text = '${entry.druckdauerMinuten ~/ 60}';
|
||||
minutenController.text = '${entry.druckdauerMinuten % 60}';
|
||||
gewichtController.text = DisplayFormats.number(entry.gewichtGramm);
|
||||
abfallController.text = DisplayFormats.number(entry.abfallGramm);
|
||||
foto.value = entry.fotoBytes;
|
||||
for (final item in entry.filamente) {
|
||||
verbrauchController(item.filamentId).text = item.verbrauchGramm > 0
|
||||
? DisplayFormats.number(item.verbrauchGramm)
|
||||
: '';
|
||||
selectedFilamente[item.filamentId] = item.anwendungId;
|
||||
}
|
||||
editingId.value = entry.bauteilId;
|
||||
await scrollToForm();
|
||||
}
|
||||
|
||||
Future<void> duplicateBauteil(BauteilModel entry) async {
|
||||
if (isSaving.value || isPickingPhoto.value) return;
|
||||
isSaving.value = true;
|
||||
try {
|
||||
final existing = _loadBauteile();
|
||||
final copy = entry.copyWith(
|
||||
bauteilId: const Uuid().v4(),
|
||||
erstelltAm: DateTime.now(),
|
||||
);
|
||||
final error = _validateConsumption(copy, existing);
|
||||
if (error != null) {
|
||||
_error(error);
|
||||
await scrollToForm();
|
||||
return;
|
||||
}
|
||||
await _commit([...existing, copy]);
|
||||
hasError.value = false;
|
||||
message.value = 'Bauteil dupliziert und Verbrauch erneut gebucht.';
|
||||
} catch (_) {
|
||||
_error('Duplizieren fehlgeschlagen. Bitte erneut versuchen.');
|
||||
await scrollToForm();
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteBauteil(String id) async {
|
||||
if (isSaving.value || isPickingPhoto.value) return;
|
||||
isSaving.value = true;
|
||||
try {
|
||||
await _commit(
|
||||
_loadBauteile().where((item) => item.bauteilId != id).toList(),
|
||||
);
|
||||
if (editingId.value == id) resetForm();
|
||||
hasError.value = false;
|
||||
message.value = 'Bauteil gelöscht und Verbrauch zurückgebucht.';
|
||||
} catch (_) {
|
||||
_error('Löschen fehlgeschlagen. Bitte erneut versuchen.');
|
||||
await scrollToForm();
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> scrollToForm() async {
|
||||
if (scrollController.hasClients) {
|
||||
await scrollController.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void resetForm() {
|
||||
formKey.currentState?.reset();
|
||||
editingId.value = null;
|
||||
nameController.clear();
|
||||
verwendungController.clear();
|
||||
stundenController.text = '0';
|
||||
minutenController.text = '0';
|
||||
gewichtController.clear();
|
||||
abfallController.text = '0';
|
||||
selectedFilamente.clear();
|
||||
for (final controller in _verbrauchControllers.values) {
|
||||
controller.clear();
|
||||
}
|
||||
foto.value = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
_closed = true;
|
||||
scrollController.dispose();
|
||||
nameController.dispose();
|
||||
verwendungController.dispose();
|
||||
stundenController.dispose();
|
||||
minutenController.dispose();
|
||||
gewichtController.dispose();
|
||||
abfallController.dispose();
|
||||
for (final controller in _verbrauchControllers.values) {
|
||||
controller.dispose();
|
||||
}
|
||||
super.onClose();
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,40 @@ import 'package:get/get.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/kauf_filament_model.dart';
|
||||
import '../models/bauteil_model.dart';
|
||||
import '../helper/storage_keys.dart';
|
||||
import '../helper/filament_bestand.dart';
|
||||
import '../helper/display_formats.dart';
|
||||
import '../helper/input_validators.dart';
|
||||
import '../services/get_storage_services.dart';
|
||||
|
||||
class HomeController extends GetxController {
|
||||
static const storageKey = 'kauf_filamente';
|
||||
static const storageKey = StorageKeys.filamente;
|
||||
|
||||
final storage = Get.find<GetStorageService>();
|
||||
final uuid = const Uuid();
|
||||
|
||||
final filamente = <KaufFilamentModel>[].obs;
|
||||
final verbrauch = <String, double>{}.obs;
|
||||
|
||||
void loadVerbrauch() {
|
||||
verbrauch.assignAll(
|
||||
FilamentBestand.verbrauch(
|
||||
storage.getModelList(StorageKeys.bauteile).map(BauteilModel.fromJson),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double? restgewicht(KaufFilamentModel filament) {
|
||||
final gewicht = filament.gewichtGramm;
|
||||
return gewicht == null
|
||||
? null
|
||||
: gewicht - (verbrauch[filament.filamentId] ?? 0);
|
||||
}
|
||||
|
||||
final formKey = GlobalKey<FormState>();
|
||||
final pageScrollController = ScrollController();
|
||||
final formScrollController = ScrollController();
|
||||
|
||||
final haendlerController = TextEditingController();
|
||||
final farbeController = TextEditingController();
|
||||
@@ -46,19 +69,16 @@ class HomeController extends GetxController {
|
||||
}
|
||||
|
||||
void loadFilamente() {
|
||||
loadVerbrauch();
|
||||
final data = storage.getModelList(storageKey);
|
||||
|
||||
filamente.assignAll(
|
||||
data.map(KaufFilamentModel.fromJson),
|
||||
);
|
||||
filamente.assignAll(data.map(KaufFilamentModel.fromJson));
|
||||
|
||||
sortFilamente();
|
||||
}
|
||||
|
||||
void sortFilamente() {
|
||||
filamente.sort(
|
||||
(a, b) => b.kaufdatum.compareTo(a.kaufdatum),
|
||||
);
|
||||
filamente.sort((a, b) => b.kaufdatum.compareTo(a.kaufdatum));
|
||||
}
|
||||
|
||||
Future<void> persist() async {
|
||||
@@ -80,6 +100,22 @@ class HomeController extends GetxController {
|
||||
return;
|
||||
}
|
||||
|
||||
loadVerbrauch();
|
||||
final gewicht = InputValidators.number(gewichtController.text);
|
||||
final bereitsVerbraucht = editingId.value == null
|
||||
? 0
|
||||
: (verbrauch[editingId.value!] ?? 0);
|
||||
if (gewicht == null ||
|
||||
!gewicht.isFinite ||
|
||||
gewicht <= 0 ||
|
||||
gewicht < bereitsVerbraucht) {
|
||||
Get.snackbar(
|
||||
'Ungültiges Gewicht',
|
||||
'Das Startgewicht muss positiv sein und mindestens dem bereits gebuchten Verbrauch entsprechen.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final filament = KaufFilamentModel(
|
||||
filamentId: editingId.value ?? uuid.v4(),
|
||||
marke: markeController.text.trim(),
|
||||
@@ -88,9 +124,7 @@ class HomeController extends GetxController {
|
||||
material: material.value,
|
||||
preis: preis,
|
||||
farbe: farbeController.text.trim(),
|
||||
gewicht: gewichtController.text.trim().isEmpty
|
||||
? '1kg'
|
||||
: gewichtController.text.trim(),
|
||||
gewicht: gewicht.toString(),
|
||||
);
|
||||
|
||||
if (editingId.value == null) {
|
||||
@@ -113,7 +147,7 @@ class HomeController extends GetxController {
|
||||
resetForm();
|
||||
}
|
||||
|
||||
void editFilament(KaufFilamentModel filament) {
|
||||
Future<void> editFilament(KaufFilamentModel filament) async {
|
||||
editingId.value = filament.filamentId;
|
||||
kaufdatum.value = filament.kaufdatum;
|
||||
markeController.text = filament.marke;
|
||||
@@ -121,15 +155,24 @@ class HomeController extends GetxController {
|
||||
material.value = filament.material;
|
||||
preisController.text = filament.preis.toStringAsFixed(2);
|
||||
farbeController.text = filament.farbe;
|
||||
gewichtController.text = filament.gewicht;
|
||||
gewichtController.text = filament.gewichtGramm == null
|
||||
? filament.gewicht
|
||||
: DisplayFormats.number(filament.gewichtGramm!);
|
||||
|
||||
final scroll = formScrollController.hasClients
|
||||
? formScrollController
|
||||
: pageScrollController;
|
||||
if (scroll.hasClients) {
|
||||
await scroll.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> duplicateFilament(
|
||||
KaufFilamentModel filament,
|
||||
) async {
|
||||
filamente.add(
|
||||
filament.copyWith(filamentId: uuid.v4()),
|
||||
);
|
||||
Future<void> duplicateFilament(KaufFilamentModel filament) async {
|
||||
filamente.add(filament.copyWith(filamentId: uuid.v4()));
|
||||
|
||||
sortFilamente();
|
||||
await persist();
|
||||
@@ -151,11 +194,12 @@ class HomeController extends GetxController {
|
||||
material.value = 'PLA';
|
||||
gewichtController.clear();
|
||||
formKey.currentState?.reset();
|
||||
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
pageScrollController.dispose();
|
||||
formScrollController.dispose();
|
||||
haendlerController.dispose();
|
||||
markeController.dispose();
|
||||
farbeController.dispose();
|
||||
@@ -163,4 +207,4 @@ class HomeController extends GetxController {
|
||||
gewichtController.dispose();
|
||||
super.onClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class DisplayFormats {
|
||||
static String number(num value) =>
|
||||
NumberFormat('0.##', 'de_AT').format(value);
|
||||
static String date(DateTime value) =>
|
||||
DateFormat('dd.MM.yyyy · HH:mm').format(value);
|
||||
static String duration(int minutes) =>
|
||||
'${minutes ~/ 60} h ${minutes % 60} min';
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import '../models/bauteil_model.dart';
|
||||
|
||||
class FilamentBestand {
|
||||
static Map<String, double> verbrauch(
|
||||
Iterable<BauteilModel> bauteile, {
|
||||
String? ohneBauteilId,
|
||||
}) {
|
||||
final result = <String, double>{};
|
||||
for (final bauteil in bauteile) {
|
||||
if (bauteil.bauteilId == ohneBauteilId) continue;
|
||||
for (final filament in bauteil.filamente) {
|
||||
result.update(
|
||||
filament.filamentId,
|
||||
(value) => value + filament.verbrauchGramm,
|
||||
ifAbsent: () => filament.verbrauchGramm,
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
class InputValidators {
|
||||
static double? number(String? value) =>
|
||||
double.tryParse((value ?? '').trim().replaceAll(',', '.'));
|
||||
|
||||
static String? requiredText(String? value) =>
|
||||
value == null || value.trim().isEmpty ? 'Bitte ausfüllen' : null;
|
||||
|
||||
static String? nonNegative(String? value, {bool optional = false}) {
|
||||
if (optional && (value ?? '').trim().isEmpty) return null;
|
||||
final parsed = number(value);
|
||||
return parsed == null || !parsed.isFinite || parsed < 0
|
||||
? 'Zahl ab 0 eingeben'
|
||||
: null;
|
||||
}
|
||||
|
||||
static String? positive(String? value) {
|
||||
final parsed = number(value);
|
||||
return parsed == null || !parsed.isFinite || parsed <= 0
|
||||
? 'Zahl größer 0 eingeben'
|
||||
: null;
|
||||
}
|
||||
|
||||
static String? hours(String? value) {
|
||||
final parsed = int.tryParse((value ?? '').trim());
|
||||
return parsed == null || parsed < 0 ? 'Ganze Zahl ab 0 eingeben' : null;
|
||||
}
|
||||
|
||||
static String? minutes(String? value) {
|
||||
final parsed = int.tryParse((value ?? '').trim());
|
||||
return parsed == null || parsed < 0 || parsed > 59
|
||||
? '0 bis 59 eingeben'
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import 'package:get/get.dart';
|
||||
import '../../controller/home_controller.dart';
|
||||
import '../controller/anwendung_controller.dart';
|
||||
|
||||
import '../controller/home_controller.dart';
|
||||
|
||||
class SampleBindings extends Bindings {
|
||||
@override
|
||||
void dependencies() {
|
||||
@override
|
||||
void dependencies() {
|
||||
Get.lazyPut<HomeController>(() => HomeController());
|
||||
Get.lazyPut<AnwendungController>(() => AnwendungController());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,35 @@
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../controller/anwendung_controller.dart';
|
||||
import '../controller/bauteil_controller.dart';
|
||||
import '../pages/anwendung_view.dart';
|
||||
import '../pages/bauteil_view.dart';
|
||||
import '../pages/home_view.dart';
|
||||
import 'sample_bindings.dart';
|
||||
|
||||
class SampleRouts {
|
||||
static final sampleBindings = SampleBindings();
|
||||
static List<GetPage<dynamic>> samplePages = [
|
||||
GetPage(
|
||||
name: '${AnwendungView.routeName}/:filamentId',
|
||||
page: () => const AnwendungView(),
|
||||
binding: BindingsBuilder(
|
||||
() => Get.lazyPut<AnwendungController>(
|
||||
() => AnwendungController(filamentId: Get.parameters['filamentId']!),
|
||||
),
|
||||
),
|
||||
),
|
||||
GetPage(
|
||||
name: BauteilView.routeName,
|
||||
page: () => const BauteilView(),
|
||||
binding: BindingsBuilder(
|
||||
() => Get.lazyPut<BauteilController>(() => BauteilController()),
|
||||
),
|
||||
),
|
||||
GetPage(
|
||||
name: HomeView.routeName,
|
||||
page: () => const HomeView(),
|
||||
binding: sampleBindings,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class StorageKeys {
|
||||
static const filamente = 'kauf_filamente';
|
||||
static const anwendungen = 'filament_anwendungen';
|
||||
static const bauteile = 'gedruckte_bauteile';
|
||||
}
|
||||
+6
-6
@@ -1,19 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import 'helper/sample_bindings.dart';
|
||||
import 'helper/sample_routes.dart';
|
||||
import 'pages/home_view.dart';
|
||||
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
|
||||
import 'services/get_storage_services.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await GetStorage.init();
|
||||
Get.put<GetStorageService>(
|
||||
GetStorageService(),
|
||||
permanent: true,
|
||||
);
|
||||
Get.put<GetStorageService>(GetStorageService(), permanent: true);
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
@@ -25,10 +25,10 @@ class MyApp extends StatelessWidget {
|
||||
return GetMaterialApp(
|
||||
title: 'Flutter Filament Verwaltung',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: const Color.fromARGB(255, 38, 135, 184)), useMaterial3: true),
|
||||
theme: AppTheme.light,
|
||||
initialBinding: SampleBindings(),
|
||||
initialRoute: HomeView.routeName,
|
||||
getPages: SampleRouts.samplePages,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
class AnwendungModel {
|
||||
final String anwendungId;
|
||||
final String filamentId;
|
||||
final DateTime erstelltAm;
|
||||
final double druckbettTemperatur;
|
||||
final double druckTemperatur;
|
||||
final bool bauraumGeschlossen;
|
||||
final double? bauraumTemperatur;
|
||||
final String sonstiges;
|
||||
|
||||
const AnwendungModel({
|
||||
required this.anwendungId,
|
||||
required this.filamentId,
|
||||
required this.erstelltAm,
|
||||
required this.druckbettTemperatur,
|
||||
required this.druckTemperatur,
|
||||
required this.bauraumGeschlossen,
|
||||
this.bauraumTemperatur,
|
||||
this.sonstiges = '',
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'anwendungId': anwendungId,
|
||||
'filamentId': filamentId,
|
||||
'erstelltAm': erstelltAm.toIso8601String(),
|
||||
'druckbettTemperatur': druckbettTemperatur,
|
||||
'druckTemperatur': druckTemperatur,
|
||||
'bauraumGeschlossen': bauraumGeschlossen,
|
||||
'bauraumTemperatur': bauraumTemperatur,
|
||||
'sonstiges': sonstiges,
|
||||
};
|
||||
|
||||
factory AnwendungModel.fromJson(Map<String, dynamic> json) => AnwendungModel(
|
||||
anwendungId: json['anwendungId'] as String,
|
||||
filamentId: json['filamentId'] as String,
|
||||
erstelltAm: DateTime.parse(json['erstelltAm'] as String),
|
||||
druckbettTemperatur: (json['druckbettTemperatur'] as num).toDouble(),
|
||||
druckTemperatur: (json['druckTemperatur'] as num).toDouble(),
|
||||
bauraumGeschlossen: json['bauraumGeschlossen'] as bool,
|
||||
bauraumTemperatur: (json['bauraumTemperatur'] as num?)?.toDouble(),
|
||||
sonstiges: json['sonstiges'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
class BauteilFilamentModel {
|
||||
final String filamentId;
|
||||
final String? anwendungId;
|
||||
final double verbrauchGramm;
|
||||
|
||||
const BauteilFilamentModel({
|
||||
required this.filamentId,
|
||||
this.anwendungId,
|
||||
this.verbrauchGramm = 0,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'filamentId': filamentId,
|
||||
'anwendungId': anwendungId,
|
||||
'verbrauchGramm': verbrauchGramm,
|
||||
};
|
||||
|
||||
factory BauteilFilamentModel.fromJson(Map<String, dynamic> json) =>
|
||||
BauteilFilamentModel(
|
||||
filamentId: json['filamentId'] as String,
|
||||
anwendungId: json['anwendungId'] as String?,
|
||||
verbrauchGramm: (json['verbrauchGramm'] as num?)?.toDouble() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
class BauteilModel {
|
||||
final String bauteilId;
|
||||
final DateTime erstelltAm;
|
||||
final String name;
|
||||
final String verwendung;
|
||||
final int druckdauerMinuten;
|
||||
final double gewichtGramm;
|
||||
final double abfallGramm;
|
||||
final List<BauteilFilamentModel> filamente;
|
||||
final String? fotoBase64;
|
||||
|
||||
const BauteilModel({
|
||||
required this.bauteilId,
|
||||
required this.erstelltAm,
|
||||
required this.name,
|
||||
required this.verwendung,
|
||||
required this.druckdauerMinuten,
|
||||
required this.gewichtGramm,
|
||||
required this.abfallGramm,
|
||||
required this.filamente,
|
||||
this.fotoBase64,
|
||||
});
|
||||
|
||||
double get gesamtverbrauchGramm => gewichtGramm + abfallGramm;
|
||||
|
||||
Uint8List? get fotoBytes {
|
||||
if (fotoBase64 == null) return null;
|
||||
try {
|
||||
return base64Decode(fotoBase64!);
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
BauteilModel copyWith({
|
||||
required String bauteilId,
|
||||
required DateTime erstelltAm,
|
||||
}) => BauteilModel(
|
||||
bauteilId: bauteilId,
|
||||
erstelltAm: erstelltAm,
|
||||
name: name,
|
||||
verwendung: verwendung,
|
||||
druckdauerMinuten: druckdauerMinuten,
|
||||
gewichtGramm: gewichtGramm,
|
||||
abfallGramm: abfallGramm,
|
||||
filamente: filamente,
|
||||
fotoBase64: fotoBase64,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'bauteilId': bauteilId,
|
||||
'erstelltAm': erstelltAm.toIso8601String(),
|
||||
'name': name,
|
||||
'verwendung': verwendung,
|
||||
'druckdauerMinuten': druckdauerMinuten,
|
||||
'gewichtGramm': gewichtGramm,
|
||||
'abfallGramm': abfallGramm,
|
||||
'filamente': filamente.map((item) => item.toJson()).toList(),
|
||||
'fotoBase64': fotoBase64,
|
||||
};
|
||||
|
||||
factory BauteilModel.fromJson(Map<String, dynamic> json) => BauteilModel(
|
||||
bauteilId: json['bauteilId'] as String,
|
||||
erstelltAm: DateTime.parse(json['erstelltAm'] as String),
|
||||
name: json['name'] as String,
|
||||
verwendung: json['verwendung'] as String,
|
||||
druckdauerMinuten: json['druckdauerMinuten'] as int,
|
||||
gewichtGramm: (json['gewichtGramm'] as num).toDouble(),
|
||||
abfallGramm: (json['abfallGramm'] as num).toDouble(),
|
||||
fotoBase64: json['fotoBase64'] as String?,
|
||||
filamente: (json['filamente'] as List)
|
||||
.map(
|
||||
(item) => BauteilFilamentModel.fromJson(
|
||||
Map<String, dynamic>.from(item as Map),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,19 @@ class KaufFilamentModel {
|
||||
required this.gewicht,
|
||||
});
|
||||
|
||||
/// Supports old purchases saved as "1kg" as well as gram values.
|
||||
double? get gewichtGramm {
|
||||
final match = RegExp(
|
||||
r'^\s*(\d+(?:[.,]\d+)?)\s*(kg|g)?\s*$',
|
||||
caseSensitive: false,
|
||||
).firstMatch(gewicht);
|
||||
if (match == null) return null;
|
||||
final value = double.tryParse(match.group(1)!.replaceAll(',', '.'));
|
||||
if (value == null || !value.isFinite) return null;
|
||||
final grams = value * (match.group(2)?.toLowerCase() == 'kg' ? 1000 : 1);
|
||||
return grams.isFinite ? grams : null;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'filamentId': filamentId,
|
||||
'kaufdatum': kaufdatum.toIso8601String(),
|
||||
@@ -39,7 +52,7 @@ class KaufFilamentModel {
|
||||
material: json['material'] as String,
|
||||
preis: (json['preis'] as num).toDouble(),
|
||||
farbe: json['farbe'] as String,
|
||||
gewicht: json['gewicht'] ?? '1000'
|
||||
gewicht: json['gewicht'] ?? '1000',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,7 +74,7 @@ class KaufFilamentModel {
|
||||
material: material ?? this.material,
|
||||
preis: preis ?? this.preis,
|
||||
farbe: farbe ?? this.farbe,
|
||||
gewicht: gewicht ?? this.gewicht
|
||||
gewicht: gewicht ?? this.gewicht,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../controller/anwendung_controller.dart';
|
||||
import '../helper/display_formats.dart';
|
||||
import '../widgets/anwendung_form.dart';
|
||||
import '../widgets/entry_panel.dart';
|
||||
|
||||
class AnwendungView extends GetView<AnwendungController> {
|
||||
static const routeName = '/anwendung-view';
|
||||
const AnwendungView({super.key, required String filamentId});
|
||||
static const routeName = '/anwendung-view';
|
||||
const AnwendungView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Anwendung'),
|
||||
centerTitle: true,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () {
|
||||
Get.back();
|
||||
},
|
||||
)
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Filament verarbeiten')),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1000),
|
||||
child: Obx(() {
|
||||
if (controller.filament.value == null) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'Das ausgewählte Filament wurde nicht gefunden. Bitte wähle ein gespeichertes Filament aus.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return CustomScrollView(
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: AnwendungForm(controller: controller),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 24),
|
||||
sliver: Obx(() {
|
||||
final entries = controller.anwendungen.toList();
|
||||
return SavedEntries(
|
||||
title: 'Gespeicherte Profile',
|
||||
count: entries.length,
|
||||
emptyText: 'Noch keine Verarbeitungsprofile für dieses Filament gespeichert.',
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
return SavedEntryCard(
|
||||
key: ValueKey(entry.anwendungId),
|
||||
title:
|
||||
'Druck ${DisplayFormats.number(entry.druckTemperatur)} °C · Bett ${DisplayFormats.number(entry.druckbettTemperatur)} °C',
|
||||
subtitle: DisplayFormats.date(entry.erstelltAm),
|
||||
details: [
|
||||
'Bauraum geschlossen: ${entry.bauraumGeschlossen ? "Ja" : "Nein"}',
|
||||
if (entry.bauraumTemperatur != null)
|
||||
'Bauraum ${DisplayFormats.number(entry.bauraumTemperatur!)} °C',
|
||||
],
|
||||
notes: entry.sonstiges,
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
body: Container(),
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../controller/bauteil_controller.dart';
|
||||
import '../helper/display_formats.dart';
|
||||
import '../models/bauteil_model.dart';
|
||||
import '../widgets/bauteil_card.dart';
|
||||
import '../widgets/bauteil_delete_dialog.dart';
|
||||
import '../widgets/bauteil_form.dart';
|
||||
import '../widgets/entry_panel.dart';
|
||||
|
||||
class BauteilView extends GetView<BauteilController> {
|
||||
static const routeName = '/bauteile-view';
|
||||
const BauteilView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Gedruckte Bauteile')),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1000),
|
||||
child: CustomScrollView(
|
||||
controller: controller.scrollController,
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: BauteilForm(controller: controller),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 24),
|
||||
sliver: Obx(() {
|
||||
final entries = controller.bauteile.toList();
|
||||
return SavedEntries(
|
||||
title: 'Deine Bauteile',
|
||||
count: entries.length,
|
||||
emptyText: 'Noch keine Bauteile gespeichert. Erfasse deinen ersten Druck.',
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
return BauteilCard(
|
||||
key: ValueKey(entry.bauteilId),
|
||||
bauteil: entry,
|
||||
onAction: (action) =>
|
||||
_handleAction(context, action, entry),
|
||||
filamentDetails: entry.filamente
|
||||
.map((item) {
|
||||
final profile = controller.profile
|
||||
.firstWhereOrNull(
|
||||
(profile) =>
|
||||
profile.anwendungId == item.anwendungId,
|
||||
);
|
||||
final suffix = profile == null
|
||||
? ''
|
||||
: ' · Profil ${DisplayFormats.number(profile.druckTemperatur)} °C / Bett ${DisplayFormats.number(profile.druckbettTemperatur)} °C';
|
||||
final usage = item.verbrauchGramm > 0
|
||||
? '${DisplayFormats.number(item.verbrauchGramm)} g'
|
||||
: 'Verbrauch noch nicht erfasst';
|
||||
return '${controller.filamentLabel(item.filamentId)} · $usage$suffix';
|
||||
})
|
||||
.join('\n'),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> _handleAction(
|
||||
BuildContext context,
|
||||
String action,
|
||||
BauteilModel entry,
|
||||
) async {
|
||||
switch (action) {
|
||||
case 'edit':
|
||||
await controller.editBauteil(entry);
|
||||
break;
|
||||
case 'duplicate':
|
||||
await controller.duplicateBauteil(entry);
|
||||
break;
|
||||
case 'delete':
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => BauteilDeleteDialog(name: entry.name),
|
||||
);
|
||||
if (confirmed == true) await controller.deleteBauteil(entry.bauteilId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
-264
@@ -1,10 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../controller/home_controller.dart';
|
||||
import '../models/kauf_filament_model.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../widgets/filament_form.dart';
|
||||
import '../widgets/filament_inventory.dart';
|
||||
import 'anwendung_view.dart';
|
||||
import 'bauteil_view.dart';
|
||||
|
||||
class HomeView extends GetView<HomeController> {
|
||||
static const routeName = '/home-view';
|
||||
@@ -12,271 +15,91 @@ class HomeView extends GetView<HomeController> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dateFormat = DateFormat('dd.MM.yyyy');
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Filament Einkaufsverwaltung'),
|
||||
centerTitle: true,
|
||||
backgroundColor: Colors.blue.shade300,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// EINGABEBEREICH
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Form(
|
||||
key: controller.formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Obx(() => ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
side: const BorderSide(
|
||||
color: Colors.grey,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
title: const Text('Kaufdatum'),
|
||||
subtitle: Text(
|
||||
dateFormat.format(controller.kaufdatum.value),
|
||||
),
|
||||
trailing: const Icon(Icons.calendar_month),
|
||||
onTap: () async {
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: controller.kaufdatum.value,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
|
||||
if (date != null) {
|
||||
controller.kaufdatum.value = date;
|
||||
}
|
||||
},
|
||||
)),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
TextFormField(
|
||||
controller: controller.haendlerController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Wo gekauft / Händler',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) =>
|
||||
value == null || value.trim().isEmpty
|
||||
? 'Händler eingeben'
|
||||
: null,
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
TextFormField(
|
||||
controller: controller.markeController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Marke',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) =>
|
||||
value == null || value.trim().isEmpty
|
||||
? 'Marke eingeben'
|
||||
: null,
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Obx(() => DropdownButtonFormField<String>(
|
||||
key: ValueKey(
|
||||
'${controller.editingId.value ?? "new"}'
|
||||
'_${controller.material.value}',
|
||||
),
|
||||
initialValue: controller.material.value,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Material',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: controller.materialien.map((item) {
|
||||
return DropdownMenuItem(
|
||||
value: item,
|
||||
child: Text(item),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
controller.material.value = value;
|
||||
}
|
||||
},
|
||||
)),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: controller.farbeController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Farbe',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) =>
|
||||
value == null || value.trim().isEmpty
|
||||
? 'Farbe eingeben'
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: controller.preisController,
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Preis (€)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) {
|
||||
final price = double.tryParse(
|
||||
(value ?? '').replaceAll(',', '.'),
|
||||
);
|
||||
if (price == null ||
|
||||
!price.isFinite ||
|
||||
price < 0) {
|
||||
return 'Preis ungültig';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
TextFormField(
|
||||
controller: controller.gewichtController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Gewicht in Gramm (g)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) =>
|
||||
value == null || value.trim().isEmpty
|
||||
? 'Gewicht eingeben g'
|
||||
: null,
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Obx(() => Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: controller.saveFilament,
|
||||
icon: Icon(
|
||||
controller.editingId.value == null
|
||||
? Icons.add
|
||||
: Icons.save,
|
||||
),
|
||||
label: Text(
|
||||
controller.editingId.value == null
|
||||
? 'Filament speichern'
|
||||
: 'Änderungen speichern',
|
||||
),
|
||||
),
|
||||
),
|
||||
if (controller.editingId.value != null)
|
||||
IconButton(
|
||||
tooltip: 'Bearbeitung abbrechen',
|
||||
onPressed: controller.resetForm,
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
)),
|
||||
],
|
||||
toolbarHeight: 80,
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Bauteile verwalten',
|
||||
icon: const Icon(Icons.view_in_ar_outlined),
|
||||
onPressed: () => Get.toNamed(BauteilView.routeName),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.all_inbox_rounded, color: AppTheme.teal, size: 28),
|
||||
SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Filamentverwaltung',
|
||||
style: TextStyle(fontSize: 21, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// LISTENBEREICH
|
||||
Expanded(
|
||||
child: Obx(() {
|
||||
final filamente = controller.filamente;
|
||||
|
||||
if (filamente.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('Noch keine Filamente gespeichert'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1320),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final wide = constraints.maxWidth >= 900;
|
||||
final form = FilamentForm(controller: controller);
|
||||
final inventory = FilamentInventory(
|
||||
controller: controller,
|
||||
onOpen: (filament) => Get.toNamed(
|
||||
'${AnwendungView.routeName}/${Uri.encodeComponent(filament.filamentId)}',
|
||||
),
|
||||
onAction: (action, filament) =>
|
||||
_handleAction(context, action, filament),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: filamente.length,
|
||||
itemBuilder: (context, index) {
|
||||
final filament = filamente[index];
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
),
|
||||
child: ListTile(
|
||||
leading: const CircleAvatar(
|
||||
child: Icon(Icons.inventory_2_outlined),
|
||||
),
|
||||
title: Text(
|
||||
'${filament.material} – ${filament.farbe}',
|
||||
),
|
||||
subtitle: Text(
|
||||
'${filament.haendler}\n'
|
||||
'${dateFormat.format(filament.kaufdatum)}'
|
||||
' | ${filament.preis.toStringAsFixed(2)} €',
|
||||
),
|
||||
isThreeLine: true,
|
||||
onTap: () => Get.to(
|
||||
() => AnwendungView(
|
||||
filamentId: filament.filamentId,
|
||||
if (wide) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 390,
|
||||
child: SingleChildScrollView(
|
||||
controller: controller.formScrollController,
|
||||
child: form,
|
||||
),
|
||||
),
|
||||
),
|
||||
trailing: PopupMenuButton<String>(
|
||||
onSelected: (action) =>
|
||||
_handleAction(context, action, filament),
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.edit),
|
||||
title: Text('Bearbeiten'),
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
Expanded(
|
||||
child: CustomScrollView(
|
||||
controller: controller.pageScrollController,
|
||||
slivers: [inventory],
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'duplicate',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.copy),
|
||||
title: Text('Duplizieren'),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.delete_outline),
|
||||
title: Text('Löschen'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
}
|
||||
return CustomScrollView(
|
||||
controller: controller.pageScrollController,
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
|
||||
sliver: SliverToBoxAdapter(child: form),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||
sliver: inventory,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -288,21 +111,17 @@ class HomeView extends GetView<HomeController> {
|
||||
) async {
|
||||
switch (action) {
|
||||
case 'edit':
|
||||
controller.editFilament(filament);
|
||||
await controller.editFilament(filament);
|
||||
break;
|
||||
|
||||
case 'duplicate':
|
||||
await controller.duplicateFilament(filament);
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Filament löschen?'),
|
||||
content: Text(
|
||||
'${filament.material} – ${filament.farbe} löschen?',
|
||||
),
|
||||
content: Text('${filament.material} – ${filament.farbe} löschen?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
@@ -315,11 +134,10 @@ class HomeView extends GetView<HomeController> {
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
await controller.deleteFilament(filament.filamentId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:file_selector/file_selector.dart';
|
||||
|
||||
class BauteilPhotoService {
|
||||
Future<Uint8List?> pickPhoto() async {
|
||||
const images = XTypeGroup(
|
||||
label: 'Fotos',
|
||||
extensions: ['jpg', 'jpeg', 'png', 'webp'],
|
||||
uniformTypeIdentifiers: [
|
||||
'public.jpeg',
|
||||
'public.png',
|
||||
'org.webmproject.webp',
|
||||
],
|
||||
mimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
|
||||
);
|
||||
final file = await openFile(acceptedTypeGroups: [images]);
|
||||
if (file == null) return null;
|
||||
if (await file.length() > 15 * 1024 * 1024) {
|
||||
throw const FormatException('Das Foto darf höchstens 15 MB groß sein.');
|
||||
}
|
||||
return preparePhoto(await file.readAsBytes());
|
||||
}
|
||||
|
||||
/// Stores a small, self-contained PNG, independent of temporary file paths.
|
||||
Future<Uint8List> preparePhoto(Uint8List bytes) async {
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
|
||||
try {
|
||||
final descriptor = await ui.ImageDescriptor.encoded(buffer);
|
||||
try {
|
||||
final scale = math.min(
|
||||
1.0,
|
||||
960 / math.max(descriptor.width, descriptor.height),
|
||||
);
|
||||
final codec = await descriptor.instantiateCodec(
|
||||
targetWidth: math.max(1, (descriptor.width * scale).round()),
|
||||
targetHeight: math.max(1, (descriptor.height * scale).round()),
|
||||
);
|
||||
try {
|
||||
final frame = await codec.getNextFrame();
|
||||
try {
|
||||
final data = await frame.image.toByteData(
|
||||
format: ui.ImageByteFormat.png,
|
||||
);
|
||||
if (data == null) {
|
||||
throw const FormatException('Foto konnte nicht gelesen werden.');
|
||||
}
|
||||
return data.buffer.asUint8List(
|
||||
data.offsetInBytes,
|
||||
data.lengthInBytes,
|
||||
);
|
||||
} finally {
|
||||
frame.image.dispose();
|
||||
}
|
||||
} finally {
|
||||
codec.dispose();
|
||||
}
|
||||
} finally {
|
||||
descriptor.dispose();
|
||||
}
|
||||
} finally {
|
||||
buffer.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppTheme {
|
||||
static const ink = Color(0xFF152C3D);
|
||||
static const muted = Color(0xFF5C7080);
|
||||
static const teal = Color(0xFF007F78);
|
||||
static const inputSurface = Color(0xFFEDF8F5);
|
||||
static const inventorySurface = Color(0xFF152C3D);
|
||||
|
||||
static ThemeData get light {
|
||||
final scheme = ColorScheme.fromSeed(
|
||||
seedColor: teal,
|
||||
primary: teal,
|
||||
onPrimary: Colors.white,
|
||||
surface: Colors.white,
|
||||
onSurface: ink,
|
||||
);
|
||||
final border = OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: Color(0xFFCCDEDA)),
|
||||
);
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: const Color(0xFFF4F7F9),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Color(0xFFF4F7F9),
|
||||
foregroundColor: ink,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
centerTitle: false,
|
||||
elevation: 0,
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
labelStyle: const TextStyle(color: muted),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 17,
|
||||
),
|
||||
border: border,
|
||||
enabledBorder: border,
|
||||
focusedBorder: border.copyWith(
|
||||
borderSide: const BorderSide(color: teal, width: 2),
|
||||
),
|
||||
errorBorder: border.copyWith(
|
||||
borderSide: BorderSide(color: scheme.error),
|
||||
),
|
||||
focusedErrorBorder: border.copyWith(
|
||||
borderSide: BorderSide(color: scheme.error, width: 2),
|
||||
),
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../controller/anwendung_controller.dart';
|
||||
import '../helper/input_validators.dart';
|
||||
import 'entry_panel.dart';
|
||||
|
||||
class AnwendungForm extends StatelessWidget {
|
||||
const AnwendungForm({super.key, required this.controller});
|
||||
final AnwendungController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final filament = controller.filament.value!;
|
||||
return EntryPanel(
|
||||
title: 'Verarbeitung erfassen',
|
||||
subtitle:
|
||||
'${filament.marke} · ${filament.material} · ${filament.farbe}\n'
|
||||
'Speichere die Einstellungen für dieses Filament als Profil.',
|
||||
child: Form(
|
||||
key: controller.formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
EntryFieldGrid(
|
||||
children: [
|
||||
EntryNumberField(
|
||||
controller: controller.druckController,
|
||||
label: 'Drucktemperatur',
|
||||
unit: '°C',
|
||||
validator: InputValidators.positive,
|
||||
),
|
||||
EntryNumberField(
|
||||
controller: controller.druckbettController,
|
||||
label: 'Druckbetttemperatur',
|
||||
unit: '°C',
|
||||
validator: InputValidators.nonNegative,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: Obx(
|
||||
() => SwitchListTile.adaptive(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Bauraum geschlossen'),
|
||||
subtitle: Text(
|
||||
controller.bauraumGeschlossen.value ? 'Ja' : 'Nein',
|
||||
),
|
||||
value: controller.bauraumGeschlossen.value,
|
||||
onChanged: (value) =>
|
||||
controller.bauraumGeschlossen.value = value,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
EntryNumberField(
|
||||
controller: controller.bauraumController,
|
||||
label: 'Bauraumtemperatur (optional)',
|
||||
unit: '°C',
|
||||
validator: (value) =>
|
||||
InputValidators.nonNegative(value, optional: true),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: controller.sonstigesController,
|
||||
minLines: 3,
|
||||
maxLines: 6,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Sonstiges (optional)',
|
||||
alignLabelWithHint: true,
|
||||
hintText:
|
||||
'Zum Beispiel Geschwindigkeit, Kühlung oder Besonderheiten',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Obx(
|
||||
() => FilledButton.icon(
|
||||
onPressed: controller.isSaving.value
|
||||
? null
|
||||
: controller.saveAnwendung,
|
||||
icon: const Icon(Icons.save_outlined),
|
||||
label: Text(
|
||||
controller.isSaving.value
|
||||
? 'Wird gespeichert …'
|
||||
: 'Verarbeitungsprofil speichern',
|
||||
),
|
||||
),
|
||||
),
|
||||
Obx(
|
||||
() => EntryMessage(
|
||||
message: controller.message.value,
|
||||
hasError: controller.hasError.value,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../helper/display_formats.dart';
|
||||
import '../models/bauteil_model.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import 'bauteil_photo.dart';
|
||||
|
||||
class BauteilCard extends StatelessWidget {
|
||||
const BauteilCard({
|
||||
super.key,
|
||||
required this.bauteil,
|
||||
required this.filamentDetails,
|
||||
required this.onAction,
|
||||
});
|
||||
final BauteilModel bauteil;
|
||||
final String filamentDetails;
|
||||
final ValueChanged<String> onAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Card(
|
||||
margin: EdgeInsets.zero,
|
||||
elevation: 0,
|
||||
color: Colors.white,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
bauteil.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
bauteil.verwendung,
|
||||
style: const TextStyle(color: AppTheme.muted),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
DisplayFormats.date(bauteil.erstelltAm),
|
||||
style: const TextStyle(
|
||||
color: AppTheme.muted,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
children: [
|
||||
PopupMenuButton<String>(
|
||||
tooltip: 'Bauteil verwalten',
|
||||
onSelected: onAction,
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.edit_outlined),
|
||||
title: Text('Bearbeiten'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'duplicate',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.copy_outlined),
|
||||
title: Text('Duplizieren'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.delete_outline),
|
||||
title: Text('Löschen'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
BauteilPhoto(bytes: bauteil.fotoBytes, size: 72),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_detail(DisplayFormats.duration(bauteil.druckdauerMinuten)),
|
||||
_detail(
|
||||
'Bauteil ${DisplayFormats.number(bauteil.gewichtGramm)} g',
|
||||
),
|
||||
_detail('Abfall ${DisplayFormats.number(bauteil.abfallGramm)} g'),
|
||||
_detail(
|
||||
'Gesamt ${DisplayFormats.number(bauteil.gesamtverbrauchGramm)} g',
|
||||
),
|
||||
if (bauteil.filamente.length > 1)
|
||||
_detail(
|
||||
'Mehrfarbdruck · ${bauteil.filamente.length} Filamente',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
filamentDetails,
|
||||
style: const TextStyle(color: AppTheme.muted, height: 1.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _detail(String text) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.inputSurface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(text, style: const TextStyle(color: AppTheme.teal)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class BauteilDeleteDialog extends StatelessWidget {
|
||||
const BauteilDeleteDialog({super.key, required this.name});
|
||||
final String name;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AlertDialog(
|
||||
title: const Text('Bauteil löschen?'),
|
||||
content: Text(
|
||||
'„$name“ löschen? Der gebuchte Filamentverbrauch wird den Rollen wieder gutgeschrieben.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Löschen'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../controller/bauteil_controller.dart';
|
||||
import '../helper/display_formats.dart';
|
||||
import '../helper/input_validators.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import 'entry_panel.dart';
|
||||
|
||||
class BauteilFilamentAuswahl extends StatelessWidget {
|
||||
const BauteilFilamentAuswahl({super.key, required this.controller});
|
||||
final BauteilController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Obx(() {
|
||||
final ids = {
|
||||
...controller.filamente.map((item) => item.filamentId),
|
||||
...controller.selectedFilamente.keys,
|
||||
};
|
||||
if (ids.isEmpty) {
|
||||
return const Text(
|
||||
'Erfasse zuerst ein Filament in der Einkaufsverwaltung.',
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: ids.map((id) {
|
||||
final selected = controller.selectedFilamente.containsKey(id);
|
||||
final profiles = controller.profilesFor(id);
|
||||
final available = controller.verfuegbarGramm(id);
|
||||
final checkbox = CheckboxListTile(
|
||||
key: ValueKey('filament-$id'),
|
||||
title: Text(controller.filamentLabel(id)),
|
||||
subtitle: Text(
|
||||
available == null
|
||||
? 'Startgewicht unbekannt'
|
||||
: '${DisplayFormats.number(available)} g verfügbar',
|
||||
),
|
||||
value: selected,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
onChanged: (value) => controller.toggleFilament(id, value ?? false),
|
||||
);
|
||||
final consumption = selected
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: EntryNumberField(
|
||||
controller: controller.verbrauchController(id),
|
||||
label: 'Verbrauch',
|
||||
unit: 'g',
|
||||
validator: InputValidators.positive,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) =>
|
||||
constraints.maxWidth >= 500 && selected
|
||||
? Row(
|
||||
children: [
|
||||
Expanded(child: checkbox),
|
||||
SizedBox(width: 190, child: consumption),
|
||||
],
|
||||
)
|
||||
: Column(children: [checkbox, if (selected) consumption]),
|
||||
),
|
||||
if (selected && profiles.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: DropdownButtonFormField<String>(
|
||||
key: ValueKey(
|
||||
'profile-$id-${controller.selectedFilamente[id]}',
|
||||
),
|
||||
initialValue:
|
||||
profiles.any(
|
||||
(item) =>
|
||||
item.anwendungId ==
|
||||
controller.selectedFilamente[id],
|
||||
)
|
||||
? controller.selectedFilamente[id]
|
||||
: '',
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Verarbeitungsprofil',
|
||||
),
|
||||
items: [
|
||||
const DropdownMenuItem(
|
||||
value: '',
|
||||
child: Text('Ohne Profil'),
|
||||
),
|
||||
...profiles.map(
|
||||
(profile) => DropdownMenuItem(
|
||||
value: profile.anwendungId,
|
||||
child: Text(
|
||||
'${DisplayFormats.number(profile.druckTemperatur)} °C · Bett ${DisplayFormats.number(profile.druckbettTemperatur)} °C · ${DisplayFormats.date(profile.erstelltAm)}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (value) => controller.selectProfile(
|
||||
id,
|
||||
value == '' ? null : value,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (selected && profiles.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Text(
|
||||
'Noch kein Profil. Einstellungen kannst du über das Filament in der Einkaufsübersicht erfassen.',
|
||||
style: TextStyle(color: AppTheme.muted),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../controller/bauteil_controller.dart';
|
||||
import '../helper/input_validators.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import 'bauteil_filament_auswahl.dart';
|
||||
import 'bauteil_photo_picker.dart';
|
||||
import 'entry_panel.dart';
|
||||
|
||||
class BauteilForm extends StatelessWidget {
|
||||
const BauteilForm({super.key, required this.controller});
|
||||
final BauteilController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Obx(
|
||||
() => EntryPanel(
|
||||
title: controller.editingId.value == null
|
||||
? 'Bauteil erfassen'
|
||||
: 'Bauteil bearbeiten',
|
||||
subtitle: 'Dokumentiere deinen Druck und den Verbrauch je Filament.',
|
||||
child: Form(
|
||||
key: controller.formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: controller.nameController,
|
||||
decoration: const InputDecoration(labelText: 'Bauteilname'),
|
||||
validator: InputValidators.requiredText,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: controller.verwendungController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Art der Verwendung',
|
||||
),
|
||||
validator: InputValidators.requiredText,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
EntryFieldGrid(
|
||||
children: [
|
||||
EntryNumberField(
|
||||
controller: controller.stundenController,
|
||||
label: 'Druckdauer: Stunden',
|
||||
unit: 'h',
|
||||
validator: InputValidators.hours,
|
||||
),
|
||||
EntryNumberField(
|
||||
controller: controller.minutenController,
|
||||
label: 'Druckdauer: Minuten',
|
||||
unit: 'min',
|
||||
validator: InputValidators.minutes,
|
||||
),
|
||||
EntryNumberField(
|
||||
controller: controller.gewichtController,
|
||||
label: 'Bauteilgewicht',
|
||||
unit: 'g',
|
||||
validator: InputValidators.positive,
|
||||
),
|
||||
EntryNumberField(
|
||||
controller: controller.abfallController,
|
||||
label: 'Abfallmenge',
|
||||
unit: 'g',
|
||||
validator: InputValidators.nonNegative,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
BauteilPhotoPicker(controller: controller),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Verwendete Filamente',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'Wähle für Mehrfarbdruck mehrere Filamente. Gib je Rolle den gesamten Verbrauch inklusive ihres Abfallanteils an. Die Summe muss Bauteilgewicht + Abfall ergeben.',
|
||||
style: TextStyle(color: AppTheme.muted, height: 1.5),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
BauteilFilamentAuswahl(controller: controller),
|
||||
const SizedBox(height: 12),
|
||||
Obx(
|
||||
() => FilledButton.icon(
|
||||
onPressed:
|
||||
controller.isSaving.value ||
|
||||
controller.isPickingPhoto.value ||
|
||||
controller.filamente.isEmpty
|
||||
? null
|
||||
: controller.saveBauteil,
|
||||
icon: Icon(
|
||||
controller.editingId.value == null ? Icons.add : Icons.check,
|
||||
),
|
||||
label: Text(
|
||||
controller.isSaving.value
|
||||
? 'Wird gespeichert …'
|
||||
: controller.editingId.value == null
|
||||
? 'Bauteil speichern'
|
||||
: 'Änderungen speichern',
|
||||
),
|
||||
),
|
||||
),
|
||||
if (controller.editingId.value != null)
|
||||
TextButton.icon(
|
||||
onPressed: controller.isSaving.value
|
||||
? null
|
||||
: controller.resetForm,
|
||||
icon: const Icon(Icons.close),
|
||||
label: const Text('Bearbeitung abbrechen'),
|
||||
),
|
||||
Obx(
|
||||
() => EntryMessage(
|
||||
message: controller.message.value,
|
||||
hasError: controller.hasError.value,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
class BauteilPhoto extends StatelessWidget {
|
||||
const BauteilPhoto({super.key, required this.bytes, this.size = 80});
|
||||
final Uint8List? bytes;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: bytes == null
|
||||
? _placeholder()
|
||||
: Image.memory(
|
||||
bytes!,
|
||||
fit: BoxFit.cover,
|
||||
semanticLabel: 'Foto des Bauteils',
|
||||
errorBuilder: (context, error, stackTrace) => _placeholder(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _placeholder() => Container(
|
||||
color: AppTheme.inputSurface,
|
||||
child: const Icon(
|
||||
Icons.view_in_ar_outlined,
|
||||
color: AppTheme.teal,
|
||||
size: 32,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../controller/bauteil_controller.dart';
|
||||
import 'bauteil_photo.dart';
|
||||
|
||||
class BauteilPhotoPicker extends StatelessWidget {
|
||||
const BauteilPhotoPicker({super.key, required this.controller});
|
||||
final BauteilController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Obx(
|
||||
() => Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
BauteilPhoto(bytes: controller.foto.value),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Bauteilfoto (optional)',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed:
|
||||
controller.isPickingPhoto.value ||
|
||||
controller.isSaving.value
|
||||
? null
|
||||
: controller.pickPhoto,
|
||||
icon: const Icon(Icons.add_photo_alternate_outlined),
|
||||
label: Text(
|
||||
controller.isPickingPhoto.value
|
||||
? 'Wird geladen …'
|
||||
: 'Foto auswählen',
|
||||
),
|
||||
),
|
||||
if (controller.foto.value != null)
|
||||
TextButton(
|
||||
onPressed: controller.isSaving.value
|
||||
? null
|
||||
: controller.removePhoto,
|
||||
child: const Text('Foto entfernen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
class EntryPanel extends StatelessWidget {
|
||||
const EntryPanel({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.child,
|
||||
});
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.inputSurface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: const Color(0xFFCEE6DF)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(fontSize: 25, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(color: AppTheme.muted, height: 1.5),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class EntryMessage extends StatelessWidget {
|
||||
const EntryMessage({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.hasError,
|
||||
});
|
||||
final String? message;
|
||||
final bool hasError;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => message == null
|
||||
? const SizedBox.shrink()
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: Semantics(
|
||||
liveRegion: true,
|
||||
child: Text(
|
||||
message!,
|
||||
style: TextStyle(
|
||||
color: hasError
|
||||
? Theme.of(context).colorScheme.error
|
||||
: AppTheme.teal,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class EntryNumberField extends StatelessWidget {
|
||||
const EntryNumberField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.label,
|
||||
required this.unit,
|
||||
required this.validator,
|
||||
});
|
||||
final TextEditingController controller;
|
||||
final String label;
|
||||
final String unit;
|
||||
final FormFieldValidator<String> validator;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => TextFormField(
|
||||
controller: controller,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: InputDecoration(labelText: label, suffixText: unit),
|
||||
validator: validator,
|
||||
);
|
||||
}
|
||||
|
||||
class EntryFieldGrid extends StatelessWidget {
|
||||
const EntryFieldGrid({super.key, required this.children});
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final columns = constraints.maxWidth >= 600 ? 2 : 1;
|
||||
final width = (constraints.maxWidth - (columns - 1) * 16) / columns;
|
||||
return Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
children: children
|
||||
.map((child) => SizedBox(width: width, child: child))
|
||||
.toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Lazily builds saved entries below the input form.
|
||||
class SavedEntries extends StatelessWidget {
|
||||
const SavedEntries({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.count,
|
||||
required this.emptyText,
|
||||
required this.itemBuilder,
|
||||
});
|
||||
final String title;
|
||||
final int count;
|
||||
final String emptyText;
|
||||
final IndexedWidgetBuilder itemBuilder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => DecoratedSliver(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.inventorySurface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
sliver: SliverPadding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
sliver: SliverMainAxisGroup(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Text(
|
||||
'$title ($count)',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 23,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (count == 0)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Text(
|
||||
emptyText,
|
||||
style: const TextStyle(color: Color(0xFFB9CCD9), height: 1.5),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverList.separated(
|
||||
itemCount: count,
|
||||
itemBuilder: itemBuilder,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class SavedEntryCard extends StatelessWidget {
|
||||
const SavedEntryCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.details,
|
||||
this.notes = '',
|
||||
});
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final List<String> details;
|
||||
final String notes;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Card(
|
||||
margin: EdgeInsets.zero,
|
||||
elevation: 0,
|
||||
color: Colors.white,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 18),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(color: AppTheme.muted, height: 1.5),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: details
|
||||
.map(
|
||||
(detail) => Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.inputSurface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
detail,
|
||||
style: const TextStyle(color: AppTheme.teal),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
if (notes.isNotEmpty) ...[
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
notes,
|
||||
style: const TextStyle(color: AppTheme.muted, height: 1.5),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../models/kauf_filament_model.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../helper/display_formats.dart';
|
||||
|
||||
class FilamentCard extends StatelessWidget {
|
||||
const FilamentCard({
|
||||
super.key,
|
||||
required this.filament,
|
||||
required this.onTap,
|
||||
required this.onAction,
|
||||
this.verbrauchGramm = 0,
|
||||
});
|
||||
|
||||
final KaufFilamentModel filament;
|
||||
final VoidCallback onTap;
|
||||
final ValueChanged<String> onAction;
|
||||
final double verbrauchGramm;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final price = NumberFormat.currency(
|
||||
locale: 'de_AT',
|
||||
symbol: '€',
|
||||
).format(filament.preis);
|
||||
final weight = filament.gewichtGramm != null
|
||||
? '${DisplayFormats.number(filament.gewichtGramm!)} g'
|
||||
: RegExp(r'[a-zA-Z]').hasMatch(filament.gewicht)
|
||||
? filament.gewicht
|
||||
: '${filament.gewicht} g';
|
||||
final start = filament.gewichtGramm;
|
||||
final rest = start == null ? null : start - verbrauchGramm;
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
color: Colors.white,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.inputSurface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.inventory_2_outlined,
|
||||
color: AppTheme.teal,
|
||||
size: 26,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
filament.marke.isEmpty
|
||||
? filament.material
|
||||
: filament.marke,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
filament.farbe,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.muted,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
tooltip: 'Filament verwalten',
|
||||
onSelected: onAction,
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.edit_outlined),
|
||||
title: Text('Bearbeiten'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'duplicate',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.copy_outlined),
|
||||
title: Text('Duplizieren'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.delete_outline),
|
||||
title: Text('Löschen'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
rest == null
|
||||
? 'Restgewicht unbekannt'
|
||||
: '${DisplayFormats.number(rest)} g verbleibend',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: rest != null && rest < 0
|
||||
? Theme.of(context).colorScheme.error
|
||||
: AppTheme.teal,
|
||||
),
|
||||
),
|
||||
if (start != null && start > 0) ...[
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(
|
||||
value: (rest! / start).clamp(0.0, 1.0),
|
||||
minHeight: 6,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
backgroundColor: AppTheme.inputSurface,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${DisplayFormats.number(verbrauchGramm)} g verbraucht',
|
||||
style: const TextStyle(color: AppTheme.muted, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_DetailPill(
|
||||
label: filament.material,
|
||||
icon: Icons.layers_outlined,
|
||||
),
|
||||
_DetailPill(label: weight, icon: Icons.scale_outlined),
|
||||
],
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 14),
|
||||
child: Divider(height: 1, color: Color(0xFFE7EDF0)),
|
||||
),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.spaceBetween,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 16,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
filament.haendler,
|
||||
style: const TextStyle(color: AppTheme.muted),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
DateFormat('dd.MM.yyyy').format(filament.kaufdatum),
|
||||
style: const TextStyle(
|
||||
color: AppTheme.muted,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
price,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.teal,
|
||||
fontSize: 21,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailPill extends StatelessWidget {
|
||||
const _DetailPill({required this.label, required this.icon});
|
||||
|
||||
final String label;
|
||||
final IconData icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF0F5F7),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: AppTheme.muted),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../controller/home_controller.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../helper/input_validators.dart';
|
||||
|
||||
class FilamentForm extends StatelessWidget {
|
||||
const FilamentForm({super.key, required this.controller});
|
||||
|
||||
final HomeController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.inputSurface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: const Color(0xFFCEE6DF)),
|
||||
),
|
||||
child: Form(
|
||||
key: controller.formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.add_circle_outline, color: AppTheme.teal),
|
||||
SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'EINKAUF ERFASSEN',
|
||||
style: TextStyle(
|
||||
color: AppTheme.teal,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Obx(
|
||||
() => Text(
|
||||
controller.editingId.value == null
|
||||
? 'Neues Filament'
|
||||
: 'Filament bearbeiten',
|
||||
style: const TextStyle(
|
||||
fontSize: 25,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'Alle Details zu deinem Einkauf an einem Ort.',
|
||||
style: TextStyle(color: AppTheme.muted, height: 1.5),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Obx(
|
||||
() => Material(
|
||||
color: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: const BorderSide(color: Color(0xFFCCDEDA)),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 3,
|
||||
),
|
||||
leading: const Icon(
|
||||
Icons.calendar_today_outlined,
|
||||
color: AppTheme.teal,
|
||||
),
|
||||
title: const Text(
|
||||
'Kaufdatum',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.muted),
|
||||
),
|
||||
subtitle: Text(
|
||||
DateFormat('dd.MM.yyyy').format(controller.kaufdatum.value),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.ink,
|
||||
),
|
||||
),
|
||||
trailing: const Icon(
|
||||
Icons.expand_more,
|
||||
color: AppTheme.muted,
|
||||
),
|
||||
onTap: () async {
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: controller.kaufdatum.value,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
if (date != null) controller.kaufdatum.value = date;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: controller.haendlerController,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Händler',
|
||||
prefixIcon: Icon(Icons.storefront_outlined),
|
||||
),
|
||||
validator: (value) => value == null || value.trim().isEmpty
|
||||
? 'Händler eingeben'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: controller.markeController,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Marke',
|
||||
prefixIcon: Icon(Icons.sell_outlined),
|
||||
),
|
||||
validator: (value) => value == null || value.trim().isEmpty
|
||||
? 'Marke eingeben'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Obx(
|
||||
() => DropdownButtonFormField<String>(
|
||||
key: ValueKey(
|
||||
'${controller.editingId.value ?? "new"}_${controller.material.value}',
|
||||
),
|
||||
initialValue: controller.material.value,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Material',
|
||||
prefixIcon: Icon(Icons.layers_outlined),
|
||||
),
|
||||
items: controller.materialien
|
||||
.toSet()
|
||||
.map(
|
||||
(item) =>
|
||||
DropdownMenuItem(value: item, child: Text(item)),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) controller.material.value = value;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: controller.farbeController,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Farbe',
|
||||
prefixIcon: Icon(Icons.palette_outlined),
|
||||
),
|
||||
validator: (value) => value == null || value.trim().isEmpty
|
||||
? 'Farbe eingeben'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final fields = [
|
||||
TextFormField(
|
||||
controller: controller.preisController,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Preis',
|
||||
suffixText: '€',
|
||||
),
|
||||
validator: (value) {
|
||||
final price = double.tryParse(
|
||||
(value ?? '').replaceAll(',', '.'),
|
||||
);
|
||||
return price == null || !price.isFinite || price < 0
|
||||
? 'Preis ungültig'
|
||||
: null;
|
||||
},
|
||||
),
|
||||
TextFormField(
|
||||
controller: controller.gewichtController,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Startgewicht',
|
||||
suffixText: 'g',
|
||||
),
|
||||
validator: InputValidators.positive,
|
||||
),
|
||||
];
|
||||
if (constraints.maxWidth < 280) {
|
||||
return Column(
|
||||
children: [
|
||||
fields[0],
|
||||
const SizedBox(height: 16),
|
||||
fields[1],
|
||||
],
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: fields[0]),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: fields[1]),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Obx(
|
||||
() => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: controller.saveFilament,
|
||||
icon: Icon(
|
||||
controller.editingId.value == null
|
||||
? Icons.add
|
||||
: Icons.check,
|
||||
),
|
||||
label: Text(
|
||||
controller.editingId.value == null
|
||||
? 'Filament speichern'
|
||||
: 'Änderungen speichern',
|
||||
),
|
||||
),
|
||||
if (controller.editingId.value != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
TextButton.icon(
|
||||
onPressed: controller.resetForm,
|
||||
icon: const Icon(Icons.close),
|
||||
label: const Text('Bearbeitung abbrechen'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../controller/home_controller.dart';
|
||||
import '../models/kauf_filament_model.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import 'filament_card.dart';
|
||||
|
||||
/// A lazy list section for both the shared mobile scroll and the desktop pane.
|
||||
class FilamentInventory extends StatelessWidget {
|
||||
const FilamentInventory({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.onOpen,
|
||||
required this.onAction,
|
||||
});
|
||||
|
||||
final HomeController controller;
|
||||
final ValueChanged<KaufFilamentModel> onOpen;
|
||||
final void Function(String, KaufFilamentModel) onAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedSliver(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.inventorySurface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
sliver: SliverPadding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
sliver: Obx(() {
|
||||
final filamente = controller.filamente.toList();
|
||||
final verbrauch = Map<String, double>.from(controller.verbrauch);
|
||||
final total = filamente.fold<double>(
|
||||
0,
|
||||
(sum, item) => sum + item.preis,
|
||||
);
|
||||
return SliverMainAxisGroup(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.inventory_2_outlined,
|
||||
color: Color(0xFF79D7C6),
|
||||
size: 21,
|
||||
),
|
||||
SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'DEIN BESTAND',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF79D7C6),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const Text(
|
||||
'Filamentübersicht',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 25,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'Deine Einkäufe, die neuesten zuerst.',
|
||||
style: TextStyle(color: Color(0xFFB9CCD9), height: 1.5),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
_InventoryStat(
|
||||
label: 'Filamente',
|
||||
value: '${filamente.length}',
|
||||
),
|
||||
_InventoryStat(
|
||||
label: 'Gesamtausgaben',
|
||||
value: NumberFormat.currency(
|
||||
locale: 'de_AT',
|
||||
symbol: '€',
|
||||
).format(total),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (filamente.isEmpty)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 36),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.inventory_2_outlined,
|
||||
size: 48,
|
||||
color: Color(0xFF79D7C6),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Hier beginnt dein Bestand',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Erfasse deinen ersten Einkauf.\nDeine Filamente erscheinen dann hier.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Color(0xFFB9CCD9),
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverList.separated(
|
||||
itemCount: filamente.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final filament = filamente[index];
|
||||
return FilamentCard(
|
||||
key: ValueKey(filament.filamentId),
|
||||
filament: filament,
|
||||
verbrauchGramm: verbrauch[filament.filamentId] ?? 0,
|
||||
onTap: () => onOpen(filament),
|
||||
onAction: (action) => onAction(action, filament),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InventoryStat extends StatelessWidget {
|
||||
const _InventoryStat({required this.label, required this.value});
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF243E50),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(color: Color(0xFFB9CCD9), fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import file_selector_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<!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>
|
||||
<true/>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<!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>
|
||||
<true/>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
</dict>
|
||||
|
||||
@@ -57,6 +57,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cross_file
|
||||
sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.5+5"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -97,6 +105,70 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
file_selector:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: file_selector
|
||||
sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
file_selector_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_android
|
||||
sha256: d670cd0ce77a2e785b18d8b4d0a8d6a222d6a813ec9b7ddf2790a1b4fb6fa92c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.2+11"
|
||||
file_selector_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_ios
|
||||
sha256: "5b42ec0ef7626124b4624ea244341e9df780406793c204e89fa68a2e71af115c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.3+7"
|
||||
file_selector_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_linux
|
||||
sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.4+1"
|
||||
file_selector_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_macos
|
||||
sha256: d785431f1910aac8a50e5c492cae912320158030a88703c67b9ef7f1f96fd7ba
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.5+2"
|
||||
file_selector_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_platform_interface
|
||||
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
file_selector_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_web
|
||||
sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.5"
|
||||
file_selector_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_windows
|
||||
sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.3+6"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -128,6 +200,11 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
get:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -39,6 +39,7 @@ dependencies:
|
||||
intl: ^0.20.3
|
||||
google_fonts: ^9.0.0
|
||||
uuid: ^4.6.0
|
||||
file_selector: ^1.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import 'package:ertl_filament_verwaltung/controller/anwendung_controller.dart';
|
||||
import 'package:ertl_filament_verwaltung/controller/bauteil_controller.dart';
|
||||
import 'package:ertl_filament_verwaltung/controller/home_controller.dart';
|
||||
import 'package:ertl_filament_verwaltung/helper/input_validators.dart';
|
||||
import 'package:ertl_filament_verwaltung/helper/sample_routes.dart';
|
||||
import 'package:ertl_filament_verwaltung/models/anwendung_model.dart';
|
||||
import 'package:ertl_filament_verwaltung/models/bauteil_model.dart';
|
||||
import 'package:ertl_filament_verwaltung/models/kauf_filament_model.dart';
|
||||
import 'package:ertl_filament_verwaltung/pages/anwendung_view.dart';
|
||||
import 'package:ertl_filament_verwaltung/pages/bauteil_view.dart';
|
||||
import 'package:ertl_filament_verwaltung/pages/home_view.dart';
|
||||
import 'package:ertl_filament_verwaltung/services/get_storage_services.dart';
|
||||
import 'package:ertl_filament_verwaltung/theme/app_theme.dart';
|
||||
import 'package:ertl_filament_verwaltung/widgets/filament_card.dart';
|
||||
import 'package:ertl_filament_verwaltung/widgets/entry_panel.dart';
|
||||
import 'package:ertl_filament_verwaltung/widgets/bauteil_card.dart';
|
||||
|
||||
import 'helpers/memory_storage_service.dart';
|
||||
|
||||
void main() {
|
||||
late MemoryStorageService storage;
|
||||
final uuidPattern = RegExp(
|
||||
r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
|
||||
);
|
||||
|
||||
KaufFilamentModel filament(String id, String color) => KaufFilamentModel(
|
||||
filamentId: id,
|
||||
kaufdatum: DateTime(2026, 10, 27),
|
||||
haendler: 'Shop',
|
||||
marke: 'Marke $id',
|
||||
material: 'PLA',
|
||||
preis: 20,
|
||||
farbe: color,
|
||||
gewicht: '1000',
|
||||
);
|
||||
|
||||
AnwendungModel profile(String id, String filamentId) => AnwendungModel(
|
||||
anwendungId: id,
|
||||
filamentId: filamentId,
|
||||
erstelltAm: DateTime(2026, 10, 26),
|
||||
druckbettTemperatur: 60,
|
||||
druckTemperatur: 210,
|
||||
bauraumGeschlossen: false,
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
Get.testMode = true;
|
||||
storage = MemoryStorageService();
|
||||
storage.tables[HomeController.storageKey] = [
|
||||
filament('a', 'Rot').toJson(),
|
||||
filament('b', 'Blau').toJson(),
|
||||
];
|
||||
storage.tables[AnwendungController.storageKey] = [
|
||||
profile('profile-b', 'b').toJson(),
|
||||
];
|
||||
Get.put<GetStorageService>(storage);
|
||||
});
|
||||
tearDown(() => Get.reset());
|
||||
|
||||
Future<void> show(
|
||||
WidgetTester tester,
|
||||
Widget page, {
|
||||
bool narrow = false,
|
||||
}) async {
|
||||
tester.view.physicalSize = narrow
|
||||
? const Size(320, 640)
|
||||
: const Size(1100, 1400);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(GetMaterialApp(theme: AppTheme.light, home: page));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
void fillPart(BauteilController controller) {
|
||||
controller.nameController.text = 'Zweifarbiger Halter';
|
||||
controller.verwendungController.text = 'Werkstatt';
|
||||
controller.stundenController.text = '2';
|
||||
controller.minutenController.text = '35';
|
||||
controller.gewichtController.text = '42,5';
|
||||
controller.abfallController.text = '8,2';
|
||||
controller.verbrauchController('a').text = '50,7';
|
||||
controller.verbrauchController('b').text = '20,7';
|
||||
}
|
||||
|
||||
test(
|
||||
'Numeric validation accepts comma decimals and rejects invalid values',
|
||||
() {
|
||||
expect(InputValidators.number('42,5'), 42.5);
|
||||
for (final value in ['NaN', 'Infinity', '-1', 'abc']) {
|
||||
expect(InputValidators.nonNegative(value), isNotNull);
|
||||
}
|
||||
expect(InputValidators.positive('0'), isNotNull);
|
||||
expect(InputValidators.nonNegative('0'), isNull);
|
||||
expect(InputValidators.nonNegative('', optional: true), isNull);
|
||||
expect(InputValidators.minutes('60'), isNotNull);
|
||||
expect(InputValidators.hours('1,5'), isNotNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'Profiles persist selected filament UUID and preserve other profiles',
|
||||
(tester) async {
|
||||
final controller = Get.put(AnwendungController(filamentId: 'a'));
|
||||
await show(tester, const AnwendungView(), narrow: true);
|
||||
controller.druckController.text = '220,5';
|
||||
controller.druckbettController.text = '65';
|
||||
controller.bauraumController.text = '40';
|
||||
controller.bauraumGeschlossen.value = true;
|
||||
controller.sonstigesController.text = 'Luefter 50 %';
|
||||
await controller.saveAnwendung();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final stored = storage.getModelList(AnwendungController.storageKey);
|
||||
expect(stored, hasLength(2));
|
||||
final saved = AnwendungModel.fromJson(stored.last);
|
||||
expect(saved.anwendungId, matches(uuidPattern));
|
||||
expect(saved.filamentId, 'a');
|
||||
expect(saved.druckTemperatur, 220.5);
|
||||
expect(saved.druckbettTemperatur, 65);
|
||||
expect(saved.bauraumGeschlossen, isTrue);
|
||||
expect(saved.bauraumTemperatur, 40);
|
||||
expect(saved.sonstiges, 'Luefter 50 %');
|
||||
expect(controller.anwendungen, hasLength(1));
|
||||
final reloaded = Get.put(
|
||||
AnwendungController(filamentId: 'a'),
|
||||
tag: 'reload',
|
||||
);
|
||||
expect(reloaded.anwendungen.single.anwendungId, saved.anwendungId);
|
||||
expect(storage.getModelList(HomeController.storageKey), hasLength(2));
|
||||
|
||||
await tester.scrollUntilVisible(
|
||||
find.byType(SavedEntryCard),
|
||||
200,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Bauraum geschlossen: Ja'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('Profile write failure retains input and does not add history', (
|
||||
tester,
|
||||
) async {
|
||||
final controller = Get.put(AnwendungController(filamentId: 'a'));
|
||||
await show(tester, const AnwendungView());
|
||||
controller.druckController.text = '220';
|
||||
controller.druckbettController.text = '0';
|
||||
storage.failWrites = true;
|
||||
await controller.saveAnwendung();
|
||||
expect(controller.hasError.value, isTrue);
|
||||
expect(controller.druckController.text, '220');
|
||||
expect(controller.anwendungen, isEmpty);
|
||||
expect(controller.isSaving.value, isFalse);
|
||||
storage.failWrites = false;
|
||||
await controller.saveAnwendung();
|
||||
expect(controller.anwendungen.single.bauraumTemperatur, isNull);
|
||||
expect(controller.anwendungen.single.bauraumGeschlossen, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('Missing filament cannot create orphan profiles', (tester) async {
|
||||
final controller = Get.put(AnwendungController(filamentId: 'missing'));
|
||||
await show(tester, const AnwendungView());
|
||||
await controller.saveAnwendung();
|
||||
expect(controller.anwendungen, isEmpty);
|
||||
expect(storage.getModelList(AnwendungController.storageKey), hasLength(1));
|
||||
expect(find.textContaining('nicht gefunden'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'Multicolor part persists both filaments and matching processing profile',
|
||||
(tester) async {
|
||||
final controller = Get.put(BauteilController());
|
||||
await show(tester, const BauteilView(), narrow: true);
|
||||
fillPart(controller);
|
||||
controller.toggleFilament('a', true);
|
||||
controller.toggleFilament('b', true);
|
||||
controller.verbrauchController('a').text = '30';
|
||||
controller.selectProfile('a', 'profile-b');
|
||||
expect(controller.selectedFilamente['a'], isNull);
|
||||
controller.selectProfile('b', 'profile-b');
|
||||
await tester.pumpAndSettle();
|
||||
await controller.saveBauteil();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final stored = storage.getModelList(BauteilController.storageKey);
|
||||
final saved = BauteilModel.fromJson(stored.single);
|
||||
expect(saved.bauteilId, matches(uuidPattern));
|
||||
expect(saved.name, 'Zweifarbiger Halter');
|
||||
expect(saved.verwendung, 'Werkstatt');
|
||||
expect(saved.druckdauerMinuten, 155);
|
||||
expect(saved.gewichtGramm, 42.5);
|
||||
expect(saved.abfallGramm, 8.2);
|
||||
expect(saved.gesamtverbrauchGramm, closeTo(50.7, 0.001));
|
||||
expect(saved.filamente.map((item) => item.filamentId), ['a', 'b']);
|
||||
expect(saved.filamente.last.anwendungId, 'profile-b');
|
||||
expect(controller.selectedFilamente, isEmpty);
|
||||
final reloaded = Get.put(BauteilController(), tag: 'reload');
|
||||
expect(reloaded.bauteile.single.filamente, hasLength(2));
|
||||
expect(
|
||||
storage.getModelList(AnwendungController.storageKey),
|
||||
hasLength(1),
|
||||
);
|
||||
|
||||
await tester.scrollUntilVisible(
|
||||
find.byType(BauteilCard),
|
||||
250,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Zweifarbiger Halter'), findsOneWidget);
|
||||
expect(find.text('Mehrfarbdruck · 2 Filamente'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('Part requires filament, positive duration and valid weights', (
|
||||
tester,
|
||||
) async {
|
||||
final controller = Get.put(BauteilController());
|
||||
await show(tester, const BauteilView());
|
||||
fillPart(controller);
|
||||
await controller.saveBauteil();
|
||||
expect(controller.message.value, contains('mindestens ein Filament'));
|
||||
controller.toggleFilament('a', true);
|
||||
controller.stundenController.text = '0';
|
||||
controller.minutenController.text = '0';
|
||||
await controller.saveBauteil();
|
||||
expect(controller.message.value, contains('mindestens eine Minute'));
|
||||
controller.minutenController.text = '30';
|
||||
controller.abfallController.text = '-1';
|
||||
await controller.saveBauteil();
|
||||
expect(storage.getModelList(BauteilController.storageKey), isEmpty);
|
||||
expect(controller.nameController.text, isNotEmpty);
|
||||
});
|
||||
|
||||
testWidgets('Part write failure keeps selection and inputs for retry', (
|
||||
tester,
|
||||
) async {
|
||||
final controller = Get.put(BauteilController());
|
||||
await show(tester, const BauteilView());
|
||||
fillPart(controller);
|
||||
controller.toggleFilament('a', true);
|
||||
storage.failWrites = true;
|
||||
await controller.saveBauteil();
|
||||
expect(controller.hasError.value, isTrue);
|
||||
expect(controller.bauteile, isEmpty);
|
||||
expect(controller.nameController.text, 'Zweifarbiger Halter');
|
||||
expect(controller.selectedFilamente.keys, ['a']);
|
||||
expect(controller.isSaving.value, isFalse);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'Navigation opens a fresh controller for each selected filament',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(1280, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
GetMaterialApp(
|
||||
theme: AppTheme.light,
|
||||
initialRoute: HomeView.routeName,
|
||||
getPages: SampleRouts.samplePages,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byType(FilamentCard).first);
|
||||
await tester.pumpAndSettle();
|
||||
expect(Get.find<AnwendungController>().filamentId, 'a');
|
||||
Get.find<AnwendungController>().sonstigesController.text =
|
||||
'Nicht uebernehmen';
|
||||
Get.back<void>();
|
||||
await tester.pumpAndSettle();
|
||||
expect(Get.isRegistered<AnwendungController>(), isFalse);
|
||||
await tester.tap(find.byType(FilamentCard).last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(Get.find<AnwendungController>().filamentId, 'b');
|
||||
expect(Get.find<AnwendungController>().sonstigesController.text, isEmpty);
|
||||
expect(Get.find<AnwendungController>().anwendungen, hasLength(1));
|
||||
Get.back<void>();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byTooltip('Bauteile verwalten'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(BauteilView), findsOneWidget);
|
||||
expect(Get.find<BauteilController>().filamente, hasLength(2));
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import 'package:ertl_filament_verwaltung/controller/bauteil_controller.dart';
|
||||
import 'package:ertl_filament_verwaltung/controller/home_controller.dart';
|
||||
import 'package:ertl_filament_verwaltung/models/bauteil_model.dart';
|
||||
import 'package:ertl_filament_verwaltung/models/kauf_filament_model.dart';
|
||||
import 'package:ertl_filament_verwaltung/pages/bauteil_view.dart';
|
||||
import 'package:ertl_filament_verwaltung/pages/home_view.dart';
|
||||
import 'package:ertl_filament_verwaltung/services/bauteil_photo_service.dart';
|
||||
import 'package:ertl_filament_verwaltung/services/get_storage_services.dart';
|
||||
import 'package:ertl_filament_verwaltung/theme/app_theme.dart';
|
||||
import 'package:ertl_filament_verwaltung/widgets/bauteil_card.dart';
|
||||
import 'package:ertl_filament_verwaltung/widgets/bauteil_photo.dart';
|
||||
|
||||
import 'helpers/memory_storage_service.dart';
|
||||
|
||||
class TestPhotoService extends BauteilPhotoService {
|
||||
Uint8List? result;
|
||||
bool fail = false;
|
||||
|
||||
@override
|
||||
Future<Uint8List?> pickPhoto() async {
|
||||
if (fail) throw const FormatException('Ungültiges Foto');
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
late MemoryStorageService storage;
|
||||
late HomeController home;
|
||||
late BauteilController controller;
|
||||
late TestPhotoService photos;
|
||||
late Uint8List png;
|
||||
|
||||
Future<Uint8List> createPng(int width, int height) async {
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = ui.Canvas(recorder);
|
||||
canvas.drawRect(
|
||||
ui.Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()),
|
||||
ui.Paint()..color = const Color(0xFF007F78),
|
||||
);
|
||||
final picture = recorder.endRecording();
|
||||
final image = await picture.toImage(width, height);
|
||||
final data = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
image.dispose();
|
||||
picture.dispose();
|
||||
return data!.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
|
||||
}
|
||||
|
||||
setUpAll(() async {
|
||||
png = await createPng(2, 2);
|
||||
});
|
||||
|
||||
KaufFilamentModel spool(String id, String weight) => KaufFilamentModel(
|
||||
filamentId: id,
|
||||
kaufdatum: DateTime(2026, 10, 27),
|
||||
haendler: 'Shop',
|
||||
marke: 'Marke $id',
|
||||
material: 'PLA',
|
||||
preis: 20,
|
||||
farbe: id,
|
||||
gewicht: weight,
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
Get.testMode = true;
|
||||
storage = MemoryStorageService();
|
||||
storage.tables[HomeController.storageKey] = [
|
||||
spool('a', '1kg').toJson(),
|
||||
spool('b', '1000').toJson(),
|
||||
];
|
||||
Get.put<GetStorageService>(storage);
|
||||
home = Get.put(HomeController());
|
||||
photos = TestPhotoService();
|
||||
controller = Get.put(BauteilController(photoService: photos));
|
||||
});
|
||||
tearDown(() => Get.reset());
|
||||
|
||||
Future<void> show(WidgetTester tester, {bool narrow = false}) async {
|
||||
tester.view.physicalSize = narrow
|
||||
? const Size(320, 640)
|
||||
: const Size(1100, 1600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
GetMaterialApp(theme: AppTheme.light, home: const BauteilView()),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
void fill({double a = 60, double b = 40}) {
|
||||
controller.nameController.text = 'Testbauteil';
|
||||
controller.verwendungController.text = 'Werkstatt';
|
||||
controller.stundenController.text = '1';
|
||||
controller.minutenController.text = '10';
|
||||
controller.gewichtController.text = '${a + b - 10}';
|
||||
controller.abfallController.text = '10';
|
||||
if (a > 0) {
|
||||
controller.toggleFilament('a', true);
|
||||
controller.verbrauchController('a').text = '$a';
|
||||
}
|
||||
if (b > 0) {
|
||||
controller.toggleFilament('b', true);
|
||||
controller.verbrauchController('b').text = '$b';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> edit(WidgetTester tester, BauteilModel entry) async {
|
||||
final pending = controller.editBauteil(entry);
|
||||
await tester.pumpAndSettle();
|
||||
await pending;
|
||||
}
|
||||
|
||||
double remaining(String id) => home.restgewicht(
|
||||
home.filamente.firstWhere((item) => item.filamentId == id),
|
||||
)!;
|
||||
|
||||
test(
|
||||
'Legacy records retain unknown usage and old weight units are supported',
|
||||
() {
|
||||
expect(spool('a', '1kg').gewichtGramm, 1000);
|
||||
expect(spool('a', '0,75 KG').gewichtGramm, 750);
|
||||
expect(spool('a', '500 g').gewichtGramm, 500);
|
||||
expect(spool('a', 'NaN').gewichtGramm, isNull);
|
||||
final legacy = BauteilModel.fromJson({
|
||||
'bauteilId': 'old',
|
||||
'erstelltAm': DateTime.now().toIso8601String(),
|
||||
'name': 'Altbestand',
|
||||
'verwendung': 'Test',
|
||||
'druckdauerMinuten': 30,
|
||||
'gewichtGramm': 10,
|
||||
'abfallGramm': 2,
|
||||
'filamente': [
|
||||
{'filamentId': 'a', 'anwendungId': null},
|
||||
],
|
||||
});
|
||||
expect(legacy.filamente.single.verbrauchGramm, 0);
|
||||
expect(legacy.fotoBytes, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'Create, edit, duplicate and delete reconcile stock and survive reload',
|
||||
(tester) async {
|
||||
await show(tester);
|
||||
fill();
|
||||
photos.result = png;
|
||||
await controller.pickPhoto();
|
||||
await controller.saveBauteil();
|
||||
expect(remaining('a'), 940);
|
||||
expect(remaining('b'), 960);
|
||||
final originalId = controller.bauteile.single.bauteilId;
|
||||
final originalDate = controller.bauteile.single.erstelltAm;
|
||||
|
||||
await edit(tester, controller.bauteile.single);
|
||||
expect(controller.foto.value, png);
|
||||
expect(controller.verfuegbarGramm('a'), 1000);
|
||||
controller.verbrauchController('a').text = '20';
|
||||
controller.verbrauchController('b').text = '80';
|
||||
await controller.saveBauteil();
|
||||
expect(controller.bauteile.single.bauteilId, originalId);
|
||||
expect(controller.bauteile.single.erstelltAm, originalDate);
|
||||
expect(remaining('a'), 980);
|
||||
expect(remaining('b'), 920);
|
||||
|
||||
await controller.duplicateBauteil(controller.bauteile.single);
|
||||
expect(controller.bauteile, hasLength(2));
|
||||
expect(
|
||||
controller.bauteile.map((item) => item.bauteilId).toSet(),
|
||||
hasLength(2),
|
||||
);
|
||||
expect(
|
||||
controller.bauteile.every((item) => item.fotoBase64 != null),
|
||||
isTrue,
|
||||
);
|
||||
expect(remaining('a'), 960);
|
||||
expect(remaining('b'), 840);
|
||||
await controller.deleteBauteil(originalId);
|
||||
expect(remaining('a'), 980);
|
||||
expect(remaining('b'), 920);
|
||||
|
||||
home.loadFilamente();
|
||||
expect(remaining('a'), 980);
|
||||
final reloaded = Get.put(BauteilController(), tag: 'reload');
|
||||
expect(reloaded.bauteile.single.fotoBytes, png);
|
||||
expect(reloaded.bauteile.single.filamente.last.verbrauchGramm, 80);
|
||||
// Purchases retain the original weight; the persisted ledger is deducted.
|
||||
expect(
|
||||
storage.getModelList(HomeController.storageKey).first['gewicht'],
|
||||
'1kg',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'Overdraw, missing usage and mismatched totals cannot change stock',
|
||||
(tester) async {
|
||||
await show(tester);
|
||||
fill(a: 1001, b: 0);
|
||||
await controller.saveBauteil();
|
||||
expect(controller.hasError.value, isTrue);
|
||||
expect(controller.message.value, contains('1000 g verfügbar'));
|
||||
expect(remaining('a'), 1000);
|
||||
controller.verbrauchController('a').text = '1';
|
||||
await controller.saveBauteil();
|
||||
expect(controller.message.value, contains('Bauteilgewicht + Abfall'));
|
||||
controller.verbrauchController('a').text = '';
|
||||
await controller.saveBauteil();
|
||||
expect(controller.bauteile, isEmpty);
|
||||
expect(storage.getModelList(BauteilController.storageKey), isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'Editing credits only the edited booking; duplicates need new stock',
|
||||
(tester) async {
|
||||
await show(tester);
|
||||
fill(a: 900, b: 0);
|
||||
await controller.saveBauteil();
|
||||
final first = controller.bauteile.single;
|
||||
fill(a: 60, b: 0);
|
||||
await controller.saveBauteil();
|
||||
expect(remaining('a'), 40);
|
||||
await edit(tester, first);
|
||||
expect(controller.verfuegbarGramm('a'), 940);
|
||||
controller.gewichtController.text = '940';
|
||||
controller.verbrauchController('a').text = '950';
|
||||
await controller.saveBauteil();
|
||||
expect(controller.message.value, contains('940 g verfügbar'));
|
||||
expect(remaining('a'), 40);
|
||||
await controller.duplicateBauteil(first);
|
||||
expect(controller.bauteile, hasLength(2));
|
||||
expect(remaining('a'), 40);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'Failed edit, duplicate and delete leave ledger and stocks unchanged',
|
||||
(tester) async {
|
||||
await show(tester);
|
||||
fill();
|
||||
await controller.saveBauteil();
|
||||
final entry = controller.bauteile.single;
|
||||
await edit(tester, entry);
|
||||
controller.verbrauchController('a').text = '50';
|
||||
controller.verbrauchController('b').text = '50';
|
||||
storage.failWrites = true;
|
||||
await controller.saveBauteil();
|
||||
expect(controller.hasError.value, isTrue);
|
||||
expect(controller.editingId.value, entry.bauteilId);
|
||||
expect(controller.verbrauchController('a').text, '50');
|
||||
expect(remaining('a'), 940);
|
||||
await controller.duplicateBauteil(entry);
|
||||
expect(controller.bauteile, hasLength(1));
|
||||
await controller.deleteBauteil(entry.bauteilId);
|
||||
expect(controller.bauteile, hasLength(1));
|
||||
expect(remaining('b'), 960);
|
||||
expect(
|
||||
storage
|
||||
.getModelList(BauteilController.storageKey)
|
||||
.single['filamente'][0]['verbrauchGramm'],
|
||||
60,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'Photo selection, cancellation, errors and removal preserve the draft',
|
||||
(tester) async {
|
||||
await show(tester);
|
||||
photos.result = png;
|
||||
await controller.pickPhoto();
|
||||
expect(controller.foto.value, png);
|
||||
photos.result = null;
|
||||
await controller.pickPhoto();
|
||||
expect(controller.foto.value, png);
|
||||
photos.fail = true;
|
||||
await controller.pickPhoto();
|
||||
expect(controller.foto.value, png);
|
||||
expect(controller.hasError.value, isTrue);
|
||||
expect(controller.isPickingPhoto.value, isFalse);
|
||||
controller.removePhoto();
|
||||
expect(controller.foto.value, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'Native image processing converts valid images and rejects non-images',
|
||||
(tester) async {
|
||||
final service = BauteilPhotoService();
|
||||
// Codec operations need real asynchronous work rather than the fake clock.
|
||||
final converted = await tester.runAsync(() => service.preparePhoto(png));
|
||||
expect(converted, isNotEmpty);
|
||||
await expectLater(
|
||||
service.preparePhoto(Uint8List.fromList([1, 2, 3])),
|
||||
throwsA(anything),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'Narrow card shows thumbnail and menu; cancel and confirm deletion work',
|
||||
(tester) async {
|
||||
await show(tester, narrow: true);
|
||||
fill();
|
||||
photos.result = png;
|
||||
await controller.pickPhoto();
|
||||
await controller.saveBauteil();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.scrollUntilVisible(
|
||||
find.byType(BauteilCard),
|
||||
250,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(BauteilCard),
|
||||
matching: find.byType(BauteilPhoto),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.ensureVisible(find.byTooltip('Bauteil verwalten'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byTooltip('Bauteil verwalten'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Löschen'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Abbrechen'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(remaining('a'), 940);
|
||||
await tester.tap(find.byTooltip('Bauteil verwalten'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Löschen'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Löschen'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.bauteile, isEmpty);
|
||||
expect(remaining('a'), 1000);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('Home displays current remaining grams after saving a part', (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester);
|
||||
fill();
|
||||
await controller.saveBauteil();
|
||||
await tester.pumpWidget(
|
||||
GetMaterialApp(theme: AppTheme.light, home: const HomeView()),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('940 g verbleibend'), findsOneWidget);
|
||||
expect(find.text('960 g verbleibend'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:get/get.dart';
|
||||
import 'package:ertl_filament_verwaltung/services/get_storage_services.dart';
|
||||
|
||||
class MemoryStorageService extends GetxService implements GetStorageService {
|
||||
final tables = <String, List<Map<String, dynamic>>>{};
|
||||
bool failWrites = false;
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
|
||||
@override
|
||||
List<Map<String, dynamic>> getModelList(String key) =>
|
||||
(jsonDecode(jsonEncode(tables[key] ?? [])) as List)
|
||||
.map((item) => Map<String, dynamic>.from(item as Map))
|
||||
.toList();
|
||||
|
||||
@override
|
||||
Future<void> saveModelList(
|
||||
String key,
|
||||
List<Map<String, dynamic>> items,
|
||||
) async {
|
||||
if (failWrites) throw StateError('Storage unavailable');
|
||||
tables[key] = items;
|
||||
}
|
||||
}
|
||||
+107
-20
@@ -1,30 +1,117 @@
|
||||
// This is a basic Flutter widget test.
|
||||
//
|
||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||
// tree, read text, and verify that the values of widget properties are correct.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import 'package:ertl_filament_verwaltung/main.dart';
|
||||
import 'package:ertl_filament_verwaltung/controller/home_controller.dart';
|
||||
import 'package:ertl_filament_verwaltung/models/kauf_filament_model.dart';
|
||||
import 'package:ertl_filament_verwaltung/pages/home_view.dart';
|
||||
import 'package:ertl_filament_verwaltung/services/get_storage_services.dart';
|
||||
import 'package:ertl_filament_verwaltung/theme/app_theme.dart';
|
||||
import 'package:ertl_filament_verwaltung/widgets/filament_card.dart';
|
||||
import 'package:ertl_filament_verwaltung/widgets/filament_form.dart';
|
||||
|
||||
import 'helpers/memory_storage_service.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const MyApp());
|
||||
late HomeController controller;
|
||||
|
||||
// Verify that our counter starts at 0.
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('1'), findsNothing);
|
||||
setUp(() {
|
||||
Get.testMode = true;
|
||||
Get.put<GetStorageService>(MemoryStorageService());
|
||||
controller = Get.put(HomeController());
|
||||
});
|
||||
|
||||
// Tap the '+' icon and trigger a frame.
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pump();
|
||||
tearDown(() => Get.reset());
|
||||
|
||||
// Verify that our counter has incremented.
|
||||
expect(find.text('0'), findsNothing);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
Future<void> showHome(WidgetTester tester, Size size) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
GetMaterialApp(theme: AppTheme.light, home: const HomeView()),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
KaufFilamentModel sample() => KaufFilamentModel(
|
||||
filamentId: 'sample',
|
||||
kaufdatum: DateTime(2026, 10, 27),
|
||||
haendler: 'Filament Shop',
|
||||
marke: 'Testmarke',
|
||||
material: 'PLA+',
|
||||
preis: 24.90,
|
||||
farbe: 'Ozeanblau',
|
||||
gewicht: '1000',
|
||||
);
|
||||
|
||||
testWidgets('Desktop shows both panes and edits PLA+', (tester) async {
|
||||
controller.filamente.add(sample());
|
||||
await showHome(tester, const Size(1280, 900));
|
||||
|
||||
expect(
|
||||
tester.getTopLeft(find.byType(FilamentCard)).dx,
|
||||
greaterThan(tester.getTopRight(find.byType(FilamentForm)).dx),
|
||||
);
|
||||
expect(find.text('1000 g'), findsOneWidget);
|
||||
expect(find.text('Testmarke'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Filament verwalten'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Bearbeiten'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Filament bearbeiten'), findsOneWidget);
|
||||
expect(controller.markeController.text, 'Testmarke');
|
||||
expect(controller.material.value, 'PLA+');
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('Small phone scrolls to save and shows the purchase', (
|
||||
tester,
|
||||
) async {
|
||||
await showHome(tester, const Size(320, 640));
|
||||
controller.haendlerController.text = 'Filament Shop';
|
||||
controller.markeController.text = 'Testmarke';
|
||||
controller.farbeController.text = 'Ozeanblau';
|
||||
controller.preisController.text = '24,90';
|
||||
controller.gewichtController.text = '1000';
|
||||
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Filament speichern'),
|
||||
200,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Filament speichern'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.filamente, hasLength(1));
|
||||
|
||||
await tester.scrollUntilVisible(
|
||||
find.byType(FilamentCard),
|
||||
200,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Testmarke'), findsOneWidget);
|
||||
expect(find.text('1000 g'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('Empty inventory is accessible on a narrow screen', (
|
||||
tester,
|
||||
) async {
|
||||
await showHome(tester, const Size(320, 640));
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Hier beginnt dein Bestand'),
|
||||
200,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.text('Hier beginnt dein Bestand').hitTestable(),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
Reference in New Issue
Block a user