add two views
This commit is contained in:
@@ -1,17 +1,95 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.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 {
|
class AnwendungController extends GetxController {
|
||||||
final count = 0.obs;
|
AnwendungController({required this.filamentId});
|
||||||
|
|
||||||
@override
|
static const storageKey = 'filament_anwendungen';
|
||||||
void onInit() {
|
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();
|
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 resetForm() {
|
||||||
void onReady() {}
|
formKey.currentState?.reset();
|
||||||
|
druckbettController.clear();
|
||||||
@override
|
druckController.clear();
|
||||||
void onClose() {}
|
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 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
import '../models/kauf_filament_model.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';
|
import '../services/get_storage_services.dart';
|
||||||
|
|
||||||
class HomeController extends GetxController {
|
class HomeController extends GetxController {
|
||||||
static const storageKey = 'kauf_filamente';
|
static const storageKey = StorageKeys.filamente;
|
||||||
|
|
||||||
final storage = Get.find<GetStorageService>();
|
final storage = Get.find<GetStorageService>();
|
||||||
final uuid = const Uuid();
|
final uuid = const Uuid();
|
||||||
|
|
||||||
final filamente = <KaufFilamentModel>[].obs;
|
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 formKey = GlobalKey<FormState>();
|
||||||
|
final pageScrollController = ScrollController();
|
||||||
|
final formScrollController = ScrollController();
|
||||||
|
|
||||||
final haendlerController = TextEditingController();
|
final haendlerController = TextEditingController();
|
||||||
final farbeController = TextEditingController();
|
final farbeController = TextEditingController();
|
||||||
@@ -46,19 +69,16 @@ class HomeController extends GetxController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void loadFilamente() {
|
void loadFilamente() {
|
||||||
|
loadVerbrauch();
|
||||||
final data = storage.getModelList(storageKey);
|
final data = storage.getModelList(storageKey);
|
||||||
|
|
||||||
filamente.assignAll(
|
filamente.assignAll(data.map(KaufFilamentModel.fromJson));
|
||||||
data.map(KaufFilamentModel.fromJson),
|
|
||||||
);
|
|
||||||
|
|
||||||
sortFilamente();
|
sortFilamente();
|
||||||
}
|
}
|
||||||
|
|
||||||
void sortFilamente() {
|
void sortFilamente() {
|
||||||
filamente.sort(
|
filamente.sort((a, b) => b.kaufdatum.compareTo(a.kaufdatum));
|
||||||
(a, b) => b.kaufdatum.compareTo(a.kaufdatum),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> persist() async {
|
Future<void> persist() async {
|
||||||
@@ -80,6 +100,22 @@ class HomeController extends GetxController {
|
|||||||
return;
|
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(
|
final filament = KaufFilamentModel(
|
||||||
filamentId: editingId.value ?? uuid.v4(),
|
filamentId: editingId.value ?? uuid.v4(),
|
||||||
marke: markeController.text.trim(),
|
marke: markeController.text.trim(),
|
||||||
@@ -88,9 +124,7 @@ class HomeController extends GetxController {
|
|||||||
material: material.value,
|
material: material.value,
|
||||||
preis: preis,
|
preis: preis,
|
||||||
farbe: farbeController.text.trim(),
|
farbe: farbeController.text.trim(),
|
||||||
gewicht: gewichtController.text.trim().isEmpty
|
gewicht: gewicht.toString(),
|
||||||
? '1kg'
|
|
||||||
: gewichtController.text.trim(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (editingId.value == null) {
|
if (editingId.value == null) {
|
||||||
@@ -113,7 +147,7 @@ class HomeController extends GetxController {
|
|||||||
resetForm();
|
resetForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
void editFilament(KaufFilamentModel filament) {
|
Future<void> editFilament(KaufFilamentModel filament) async {
|
||||||
editingId.value = filament.filamentId;
|
editingId.value = filament.filamentId;
|
||||||
kaufdatum.value = filament.kaufdatum;
|
kaufdatum.value = filament.kaufdatum;
|
||||||
markeController.text = filament.marke;
|
markeController.text = filament.marke;
|
||||||
@@ -121,15 +155,24 @@ class HomeController extends GetxController {
|
|||||||
material.value = filament.material;
|
material.value = filament.material;
|
||||||
preisController.text = filament.preis.toStringAsFixed(2);
|
preisController.text = filament.preis.toStringAsFixed(2);
|
||||||
farbeController.text = filament.farbe;
|
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(
|
Future<void> duplicateFilament(KaufFilamentModel filament) async {
|
||||||
KaufFilamentModel filament,
|
filamente.add(filament.copyWith(filamentId: uuid.v4()));
|
||||||
) async {
|
|
||||||
filamente.add(
|
|
||||||
filament.copyWith(filamentId: uuid.v4()),
|
|
||||||
);
|
|
||||||
|
|
||||||
sortFilamente();
|
sortFilamente();
|
||||||
await persist();
|
await persist();
|
||||||
@@ -151,11 +194,12 @@ class HomeController extends GetxController {
|
|||||||
material.value = 'PLA';
|
material.value = 'PLA';
|
||||||
gewichtController.clear();
|
gewichtController.clear();
|
||||||
formKey.currentState?.reset();
|
formKey.currentState?.reset();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onClose() {
|
void onClose() {
|
||||||
|
pageScrollController.dispose();
|
||||||
|
formScrollController.dispose();
|
||||||
haendlerController.dispose();
|
haendlerController.dispose();
|
||||||
markeController.dispose();
|
markeController.dispose();
|
||||||
farbeController.dispose();
|
farbeController.dispose();
|
||||||
@@ -163,4 +207,4 @@ class HomeController extends GetxController {
|
|||||||
gewichtController.dispose();
|
gewichtController.dispose();
|
||||||
super.onClose();
|
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 'package:get/get.dart';
|
||||||
import '../../controller/home_controller.dart';
|
|
||||||
import '../controller/anwendung_controller.dart';
|
import '../controller/home_controller.dart';
|
||||||
|
|
||||||
class SampleBindings extends Bindings {
|
class SampleBindings extends Bindings {
|
||||||
@override
|
@override
|
||||||
void dependencies() {
|
void dependencies() {
|
||||||
Get.lazyPut<HomeController>(() => HomeController());
|
Get.lazyPut<HomeController>(() => HomeController());
|
||||||
Get.lazyPut<AnwendungController>(() => AnwendungController());
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,15 +1,35 @@
|
|||||||
import 'package:get/get.dart';
|
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 '../pages/home_view.dart';
|
||||||
import 'sample_bindings.dart';
|
import 'sample_bindings.dart';
|
||||||
|
|
||||||
class SampleRouts {
|
class SampleRouts {
|
||||||
static final sampleBindings = SampleBindings();
|
static final sampleBindings = SampleBindings();
|
||||||
static List<GetPage<dynamic>> samplePages = [
|
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(
|
GetPage(
|
||||||
name: HomeView.routeName,
|
name: HomeView.routeName,
|
||||||
page: () => const HomeView(),
|
page: () => const HomeView(),
|
||||||
binding: sampleBindings,
|
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:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
import 'helper/sample_bindings.dart';
|
import 'helper/sample_bindings.dart';
|
||||||
import 'helper/sample_routes.dart';
|
import 'helper/sample_routes.dart';
|
||||||
import 'pages/home_view.dart';
|
import 'pages/home_view.dart';
|
||||||
|
|
||||||
import 'package:get_storage/get_storage.dart';
|
import 'package:get_storage/get_storage.dart';
|
||||||
|
|
||||||
import 'services/get_storage_services.dart';
|
import 'services/get_storage_services.dart';
|
||||||
|
import 'theme/app_theme.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await GetStorage.init();
|
await GetStorage.init();
|
||||||
Get.put<GetStorageService>(
|
Get.put<GetStorageService>(GetStorageService(), permanent: true);
|
||||||
GetStorageService(),
|
|
||||||
permanent: true,
|
|
||||||
);
|
|
||||||
runApp(const MyApp());
|
runApp(const MyApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,10 +25,10 @@ class MyApp extends StatelessWidget {
|
|||||||
return GetMaterialApp(
|
return GetMaterialApp(
|
||||||
title: 'Flutter Filament Verwaltung',
|
title: 'Flutter Filament Verwaltung',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: const Color.fromARGB(255, 38, 135, 184)), useMaterial3: true),
|
theme: AppTheme.light,
|
||||||
initialBinding: SampleBindings(),
|
initialBinding: SampleBindings(),
|
||||||
initialRoute: HomeView.routeName,
|
initialRoute: HomeView.routeName,
|
||||||
getPages: SampleRouts.samplePages,
|
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,
|
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() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'filamentId': filamentId,
|
'filamentId': filamentId,
|
||||||
'kaufdatum': kaufdatum.toIso8601String(),
|
'kaufdatum': kaufdatum.toIso8601String(),
|
||||||
@@ -39,7 +52,7 @@ class KaufFilamentModel {
|
|||||||
material: json['material'] as String,
|
material: json['material'] as String,
|
||||||
preis: (json['preis'] as num).toDouble(),
|
preis: (json['preis'] as num).toDouble(),
|
||||||
farbe: json['farbe'] as String,
|
farbe: json['farbe'] as String,
|
||||||
gewicht: json['gewicht'] ?? '1000'
|
gewicht: json['gewicht'] ?? '1000',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +74,7 @@ class KaufFilamentModel {
|
|||||||
material: material ?? this.material,
|
material: material ?? this.material,
|
||||||
preis: preis ?? this.preis,
|
preis: preis ?? this.preis,
|
||||||
farbe: farbe ?? this.farbe,
|
farbe: farbe ?? this.farbe,
|
||||||
gewicht: gewicht ?? this.gewicht
|
gewicht: gewicht ?? this.gewicht,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,75 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
import '../controller/anwendung_controller.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> {
|
class AnwendungView extends GetView<AnwendungController> {
|
||||||
static const routeName = '/anwendung-view';
|
static const routeName = '/anwendung-view';
|
||||||
const AnwendungView({super.key, required String filamentId});
|
const AnwendungView({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) => Scaffold(
|
||||||
return Scaffold(
|
appBar: AppBar(title: const Text('Filament verarbeiten')),
|
||||||
appBar: AppBar(
|
body: SafeArea(
|
||||||
title: const Text('Anwendung'),
|
top: false,
|
||||||
centerTitle: true,
|
child: Align(
|
||||||
leading: IconButton(
|
alignment: Alignment.topCenter,
|
||||||
icon: const Icon(Icons.arrow_back),
|
child: ConstrainedBox(
|
||||||
onPressed: () {
|
constraints: const BoxConstraints(maxWidth: 1000),
|
||||||
Get.back();
|
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:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
import '../controller/home_controller.dart';
|
import '../controller/home_controller.dart';
|
||||||
import '../models/kauf_filament_model.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 'anwendung_view.dart';
|
||||||
|
import 'bauteil_view.dart';
|
||||||
|
|
||||||
class HomeView extends GetView<HomeController> {
|
class HomeView extends GetView<HomeController> {
|
||||||
static const routeName = '/home-view';
|
static const routeName = '/home-view';
|
||||||
@@ -12,271 +15,91 @@ class HomeView extends GetView<HomeController> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final dateFormat = DateFormat('dd.MM.yyyy');
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Filament Einkaufsverwaltung'),
|
toolbarHeight: 80,
|
||||||
centerTitle: true,
|
actions: [
|
||||||
backgroundColor: Colors.blue.shade300,
|
IconButton(
|
||||||
),
|
tooltip: 'Bauteile verwalten',
|
||||||
body: Column(
|
icon: const Icon(Icons.view_in_ar_outlined),
|
||||||
children: [
|
onPressed: () => Get.toNamed(BauteilView.routeName),
|
||||||
// EINGABEBEREICH
|
),
|
||||||
Padding(
|
const SizedBox(width: 8),
|
||||||
padding: const EdgeInsets.all(12),
|
],
|
||||||
child: Form(
|
title: const Row(
|
||||||
key: controller.formKey,
|
children: [
|
||||||
child: Column(
|
Icon(Icons.all_inbox_rounded, color: AppTheme.teal, size: 28),
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
SizedBox(width: 12),
|
||||||
children: [
|
Flexible(
|
||||||
Obx(() => ListTile(
|
child: Text(
|
||||||
shape: RoundedRectangleBorder(
|
'Filamentverwaltung',
|
||||||
borderRadius: BorderRadius.circular(4),
|
style: TextStyle(fontSize: 21, fontWeight: FontWeight.w700),
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
|
),
|
||||||
const Divider(),
|
),
|
||||||
|
body: SafeArea(
|
||||||
// LISTENBEREICH
|
top: false,
|
||||||
Expanded(
|
child: Align(
|
||||||
child: Obx(() {
|
alignment: Alignment.topCenter,
|
||||||
final filamente = controller.filamente;
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1320),
|
||||||
if (filamente.isEmpty) {
|
child: LayoutBuilder(
|
||||||
return const Center(
|
builder: (context, constraints) {
|
||||||
child: Text('Noch keine Filamente gespeichert'),
|
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),
|
||||||
);
|
);
|
||||||
}
|
if (wide) {
|
||||||
|
return Padding(
|
||||||
return ListView.builder(
|
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
|
||||||
itemCount: filamente.length,
|
child: Row(
|
||||||
itemBuilder: (context, index) {
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
final filament = filamente[index];
|
children: [
|
||||||
|
SizedBox(
|
||||||
return Card(
|
width: 390,
|
||||||
margin: const EdgeInsets.symmetric(
|
child: SingleChildScrollView(
|
||||||
horizontal: 12,
|
controller: controller.formScrollController,
|
||||||
vertical: 4,
|
child: form,
|
||||||
),
|
),
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 24),
|
||||||
trailing: PopupMenuButton<String>(
|
Expanded(
|
||||||
onSelected: (action) =>
|
child: CustomScrollView(
|
||||||
_handleAction(context, action, filament),
|
controller: controller.pageScrollController,
|
||||||
itemBuilder: (context) => const [
|
slivers: [inventory],
|
||||||
PopupMenuItem(
|
|
||||||
value: 'edit',
|
|
||||||
child: ListTile(
|
|
||||||
leading: Icon(Icons.edit),
|
|
||||||
title: Text('Bearbeiten'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
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 {
|
) async {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'edit':
|
case 'edit':
|
||||||
controller.editFilament(filament);
|
await controller.editFilament(filament);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'duplicate':
|
case 'duplicate':
|
||||||
await controller.duplicateFilament(filament);
|
await controller.duplicateFilament(filament);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'delete':
|
case 'delete':
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) => AlertDialog(
|
builder: (dialogContext) => AlertDialog(
|
||||||
title: const Text('Filament löschen?'),
|
title: const Text('Filament löschen?'),
|
||||||
content: Text(
|
content: Text('${filament.material} – ${filament.farbe} löschen?'),
|
||||||
'${filament.material} – ${filament.farbe} löschen?',
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(dialogContext, false),
|
onPressed: () => Navigator.pop(dialogContext, false),
|
||||||
@@ -315,11 +134,10 @@ class HomeView extends GetView<HomeController> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed == true) {
|
if (confirmed == true) {
|
||||||
await controller.deleteFilament(filament.filamentId);
|
await controller.deleteFilament(filament.filamentId);
|
||||||
}
|
}
|
||||||
break;
|
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 "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <file_selector_linux/file_selector_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
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
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
file_selector_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import file_selector_macos
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>com.apple.security.files.user-selected.read-only</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.cs.allow-jit</key>
|
<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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>com.apple.security.files.user-selected.read-only</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
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:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -97,6 +105,70 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
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:
|
fixnum:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -128,6 +200,11 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_web_plugins:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
get:
|
get:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ dependencies:
|
|||||||
intl: ^0.20.3
|
intl: ^0.20.3
|
||||||
google_fonts: ^9.0.0
|
google_fonts: ^9.0.0
|
||||||
uuid: ^4.6.0
|
uuid: ^4.6.0
|
||||||
|
file_selector: ^1.1.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
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/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.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() {
|
void main() {
|
||||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
late HomeController controller;
|
||||||
// Build our app and trigger a frame.
|
|
||||||
await tester.pumpWidget(const MyApp());
|
|
||||||
|
|
||||||
// Verify that our counter starts at 0.
|
setUp(() {
|
||||||
expect(find.text('0'), findsOneWidget);
|
Get.testMode = true;
|
||||||
expect(find.text('1'), findsNothing);
|
Get.put<GetStorageService>(MemoryStorageService());
|
||||||
|
controller = Get.put(HomeController());
|
||||||
|
});
|
||||||
|
|
||||||
// Tap the '+' icon and trigger a frame.
|
tearDown(() => Get.reset());
|
||||||
await tester.tap(find.byIcon(Icons.add));
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
// Verify that our counter has incremented.
|
Future<void> showHome(WidgetTester tester, Size size) async {
|
||||||
expect(find.text('0'), findsNothing);
|
tester.view.physicalSize = size;
|
||||||
expect(find.text('1'), findsOneWidget);
|
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 "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <file_selector_windows/file_selector_windows.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
|
FileSelectorWindowsRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
file_selector_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
Reference in New Issue
Block a user