add two views

This commit is contained in:
atseirjo
2026-10-27 15:00:16 +01:00
parent 864da57537
commit 07a1bb02fd
42 changed files with 3582 additions and 346 deletions
+367
View File
@@ -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();
}
}