add two views
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user