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
+102
View File
@@ -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,
),
),
],
),
),
);
}
}
+136
View File
@@ -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)),
);
}
+24
View File
@@ -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'),
),
],
);
}
+127
View File
@@ -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(),
);
});
}
+123
View File
@@ -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,
),
),
],
),
),
),
);
}
+37
View File
@@ -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,
),
);
}
+59
View File
@@ -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'),
),
],
),
],
),
),
],
),
);
}
+240
View File
@@ -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),
),
],
],
),
),
);
}
+234
View File
@@ -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),
),
),
],
),
);
}
}
+255
View File
@@ -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'),
),
],
],
),
),
],
),
),
);
}
}
+193
View File
@@ -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,
),
),
],
),
);
}
}