From 4e385c28bfa18460038861da7e5852135bfb1e1e Mon Sep 17 00:00:00 2001 From: josiadmin Date: Tue, 3 Mar 2026 09:56:06 +0100 Subject: [PATCH] add detail page --- lib/controllers/detail_controller.dart | 129 +++++ lib/controllers/home_controller.dart | 138 +++--- lib/helpers/sample_bindings.dart | 4 +- lib/helpers/samples_routes.dart | 13 +- lib/main.dart | 2 +- lib/models/filament_model.dart | 30 +- lib/pages/detail_view.dart | 657 +++++++++++++++++++++++++ lib/pages/{home => }/home_view.dart | 94 ++-- lib/services/appwrite_service.dart | 66 +++ lib/widgets/add_filament_dialog.dart | 548 +++++++++++++++++++++ lib/widgets/filament_card.dart | 326 ++++++++++++ 11 files changed, 1863 insertions(+), 144 deletions(-) create mode 100644 lib/controllers/detail_controller.dart create mode 100644 lib/pages/detail_view.dart rename lib/pages/{home => }/home_view.dart (51%) create mode 100644 lib/widgets/add_filament_dialog.dart create mode 100644 lib/widgets/filament_card.dart diff --git a/lib/controllers/detail_controller.dart b/lib/controllers/detail_controller.dart new file mode 100644 index 0000000..d29fab9 --- /dev/null +++ b/lib/controllers/detail_controller.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../models/filament_model.dart'; +import '../services/appwrite_service.dart'; +import '../widgets/add_filament_dialog.dart'; + +class DetailController extends GetxController { + final appwriteService = AppwriteService(); + + /// Das aktuell angezeigte Filament – wird beim Öffnen der Seite gesetzt. + final filament = Rx(FilamentModel.empty); + final isLoading = false.obs; + + @override + void onInit() { + super.onInit(); + // Filament kommt via Get.arguments von der HomeView + if (Get.arguments != null && Get.arguments is FilamentModel) { + filament.value = Get.arguments as FilamentModel; + } + } + + @override + void onReady() {} + + @override + void onClose() {} + + double get remaining => (filament.value.weight - filament.value.weightUsed) + .clamp(0, filament.value.weight); + + double get progress => + filament.value.weight > 0 ? remaining / filament.value.weight : 0; + + /// Öffnet den Bearbeitungs-Dialog und aktualisiert lokal + in Appwrite. + Future openEditDialog() async { + final updated = await AddFilamentDialog.showEdit(entry: filament.value); + if (updated == null) return; + + isLoading.value = true; + try { + final success = await appwriteService.updateFilamentDocument( + updated.documentId, + FilamentModel.toMapForAppwrite(updated), + ); + if (success) filament.value = updated; // bleibt auf Detail-Seite + } catch (e) { + print('Fehler beim Aktualisieren: $e'); + } finally { + isLoading.value = false; + } + } + + /// Löscht das Filament und navigiert zurück. + Future deleteFilament() async { + final confirm = await Get.dialog( + AlertDialog( + backgroundColor: const Color(0xFF1A2035), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text( + 'Filament löschen?', + style: TextStyle(color: Colors.white), + ), + content: Text( + '${filament.value.name} wird dauerhaft gelöscht.', + style: const TextStyle(color: Colors.white70), + ), + actions: [ + TextButton( + onPressed: () => Get.back(result: false), + child: const Text( + 'Abbrechen', + style: TextStyle(color: Colors.white60), + ), + ), + TextButton( + onPressed: () => Get.back(result: true), + child: const Text( + 'Löschen', + style: TextStyle(color: Color(0xFFFF6B6B)), + ), + ), + ], + ), + ); + if (confirm != true) return; + + isLoading.value = true; + try { + await appwriteService.deleteFilamentDocument(filament.value.documentId); + } catch (e) { + print('Fehler beim Löschen: $e'); + } finally { + isLoading.value = false; + } + Get.back(result: 'deleted'); + } + + /// Aktualisiert nur das verbrauchte Gewicht. + Future updateWeightUsed(double newWeightUsed) async { + final updated = FilamentModel( + documentId: filament.value.documentId, + name: filament.value.name, + type: filament.value.type, + color: filament.value.color, + weight: filament.value.weight, + weightUsed: newWeightUsed, + price: filament.value.price, + manufacturer: filament.value.manufacturer, + purchaseDate: filament.value.purchaseDate, + notes: filament.value.notes, + printingTemp: filament.value.printingTemp, + bedTemp: filament.value.bedTemp, + ); + isLoading.value = true; + try { + final success = await appwriteService.updateFilamentDocument( + updated.documentId, + FilamentModel.toMapForAppwrite(updated), + ); + if (success) filament.value = updated; + } catch (e) { + print('Fehler beim Aktualisieren des Gewichts: $e'); + } finally { + isLoading.value = false; + } + } +} diff --git a/lib/controllers/home_controller.dart b/lib/controllers/home_controller.dart index 6849d30..ccbf67e 100644 --- a/lib/controllers/home_controller.dart +++ b/lib/controllers/home_controller.dart @@ -1,17 +1,17 @@ import 'package:get/get.dart'; -import '../models/home_model.dart'; +import '../models/filament_model.dart'; import '../services/appwrite_service.dart'; -import '../widgets/add_weight_dialog.dart'; +import '../widgets/add_filament_dialog.dart'; class HomeController extends GetxController { final isloading = false.obs; - final List weights = [].obs; + final filaments = [].obs; final appwriteService = AppwriteService(); @override void onInit() { - _loadDataList(); + _loadFilaments(); super.onInit(); } @@ -21,85 +21,73 @@ class HomeController extends GetxController { @override void onClose() {} - void _loadDataList() async { + void _loadFilaments() async { isloading.value = true; - if (weights.isNotEmpty) { - weights.clear(); - } - final loggedIn = await appwriteService.login(); - if (!loggedIn) { - print('Login fehlgeschlagen – Daten können nicht geladen werden.'); + filaments.clear(); + try { + final loggedIn = await appwriteService.login(); + if (!loggedIn) { + print('Login fehlgeschlagen – Daten können nicht geladen werden.'); + return; + } + final documents = await appwriteService.getFilamentDocuments(); + if (documents.isEmpty) { + print('Keine Filamente gefunden.'); + } else { + print('${documents.length} Filamente geladen.'); + filaments.assignAll( + documents.map( + (doc) => FilamentModel.fromJson({'\$id': doc.$id, ...doc.data}), + ), + ); + } + } catch (e, st) { + print('Fehler beim Laden der Filamente: $e\n$st'); + } finally { isloading.value = false; - return; - } - final documents = await appwriteService.getDocumentsFromCollection(); - if (documents.isEmpty) { - print('Keine Dokumente gefunden.'); - } else { - print('${documents.length} Einträge geladen.'); - weights.assignAll(documents.map((doc) => WeightModel.fromJson(doc.data))); - } - isloading.value = false; - update(); - } - - void addWeightEntry(WeightModel entry) { - weights.add(entry); - var map = WeightModel.toMapForAppwrite(entry); - appwriteService.createDocumentInCollection(map); - update(); - } - - void editWeightEntry(WeightModel updated) { - final idx = weights.indexWhere( - (w) => w.documentId == updated.documentId && w.date == updated.date, - ); - if (idx != -1) { - weights[idx] = updated; - var map = WeightModel.toMapForAppwrite(updated); - appwriteService.updateDocumentInCollection(updated.documentId, map); update(); } } - /// Gewicht des Eintrags unmittelbar VOR [date] einer Person. - /// Wird für die weightChange-Berechnung beim Bearbeiten genutzt. - double getPreviousWeight(String personName, DateTime date) { - final before = weights - .where((w) => w.name == personName && w.date.isBefore(date)) - .toList(); - if (before.isEmpty) return 0.0; - before.sort((a, b) => b.date.compareTo(a.date)); - return before.first.weight; - } - - /// Gibt das zuletzt eingetragene Gewicht einer Person zurück. - /// 0.0 wenn noch kein Eintrag existiert (wird als "erster Eintrag" behandelt). - double getLastWeight(String personName) { - final personEntries = weights.where((w) => w.name == personName).toList(); - if (personEntries.isEmpty) return 0.0; - personEntries.sort((a, b) => b.date.compareTo(a.date)); - return personEntries.first.weight; - } - - void addWeight(String personName) {} - - // ── Dialog-Logik ───────────────────────────────────────────────────────── - - Future openAddDialog(String personName, String userId) async { - final entry = await AddWeightDialog.show( - userId: userId, - personName: personName, - lastWeight: getLastWeight(personName), + void addFilament(FilamentModel entry) { + filaments.add(entry); + appwriteService.createFilamentDocument( + FilamentModel.toMapForAppwrite(entry), ); - if (entry != null) addWeightEntry(entry); + update(); } - Future openEditDialog(WeightModel existing) async { - final updated = await AddWeightDialog.showEdit( - entry: existing, - previousWeight: getPreviousWeight(existing.name, existing.date), - ); - if (updated != null) editWeightEntry(updated); + void editFilament(FilamentModel updated) { + final idx = filaments.indexWhere((f) => f.documentId == updated.documentId); + if (idx != -1) { + filaments[idx] = updated; + appwriteService.updateFilamentDocument( + updated.documentId, + FilamentModel.toMapForAppwrite(updated), + ); + update(); + } + } + + void deleteFilament(FilamentModel filament) { + filaments.remove(filament); + appwriteService.deleteFilamentDocument(filament.documentId); + update(); + } + + /// Nur lokal löschen (Appwrite wurde bereits vom DetailController aufgerufen). + void deleteFilamentLocal(FilamentModel filament) { + filaments.removeWhere((f) => f.documentId == filament.documentId); + update(); + } + + Future openAddDialog() async { + final entry = await AddFilamentDialog.show(); + if (entry != null) addFilament(entry); + } + + Future openEditDialog(FilamentModel existing) async { + final updated = await AddFilamentDialog.showEdit(entry: existing); + if (updated != null) editFilament(updated); } } diff --git a/lib/helpers/sample_bindings.dart b/lib/helpers/sample_bindings.dart index bfe8cdc..ee737da 100644 --- a/lib/helpers/sample_bindings.dart +++ b/lib/helpers/sample_bindings.dart @@ -1,6 +1,7 @@ import 'package:get/get.dart'; +import '../controllers/detail_controller.dart'; import '../controllers/home_controller.dart'; @@ -12,7 +13,8 @@ class SampleBindings extends Bindings { @override void dependencies() { // Define your dependencies here no permanent Binding - Get.lazyPut(() => HomeController()); + Get.lazyPut(() => HomeController()); + Get.lazyPut(() => DetailController()); } } diff --git a/lib/helpers/samples_routes.dart b/lib/helpers/samples_routes.dart index 04d2ae0..defd30d 100644 --- a/lib/helpers/samples_routes.dart +++ b/lib/helpers/samples_routes.dart @@ -1,18 +1,21 @@ import 'package:get/get.dart'; -import '../pages/home/home_view.dart'; +import '../pages/detail_view.dart'; +import '../pages/home_view.dart'; import 'sample_bindings.dart'; - class SampleRouts { static final sampleBindings = SampleBindings(); - static List> samplePages = [ - + static List> samplePages = [ GetPage( name: HomePage.namedRoute, page: () => const HomePage(), binding: sampleBindings, ), - + GetPage( + name: DetailPage.namedRoute, + page: () => const DetailPage(), + binding: sampleBindings, + ), ]; } diff --git a/lib/main.dart b/lib/main.dart index 923119f..89a75e0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'helpers/samples_routes.dart'; -import 'pages/home/home_view.dart'; +import 'pages/home_view.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/models/filament_model.dart b/lib/models/filament_model.dart index ec3d773..cedde9c 100644 --- a/lib/models/filament_model.dart +++ b/lib/models/filament_model.dart @@ -6,7 +6,7 @@ class FilamentModel { final double weight; final double weightUsed; final double price; - final String manufacture; + final String manufacturer; final String purchaseDate; final String? notes; final int printingTemp; @@ -20,7 +20,7 @@ class FilamentModel { required this.weight, required this.weightUsed, required this.price, - required this.manufacture, + required this.manufacturer, required this.purchaseDate, this.notes, required this.printingTemp, @@ -29,18 +29,18 @@ class FilamentModel { static FilamentModel fromJson(Map json) { return FilamentModel( - documentId: json['\$id'], - name: json['name'], - type: json['type'], - color: json['color'], - weight: (json['weight'] as num).toDouble(), - weightUsed: (json['weightUsed'] as num).toDouble(), - price: (json['price'] as num).toDouble(), - manufacture: json['manufacture'], - purchaseDate: json['purchaseDate'], + documentId: json['\$id'] as String? ?? '', + name: json['name'] as String? ?? '', + type: json['type'] as String? ?? '', + color: json['color'] as String? ?? '', + weight: (json['weight'] as num?)?.toDouble() ?? 0.0, + weightUsed: (json['weightUsed'] as num?)?.toDouble() ?? 0.0, + price: (json['price'] as num?)?.toDouble() ?? 0.0, + manufacturer: json['manufacturer'] as String? ?? '', + purchaseDate: json['purchaseDate'] as String? ?? '', notes: json['notes'] as String?, - printingTemp: json['printingTemp'], - bedTemp: json['bedTemp'], + printingTemp: (json['printingTemp'] as num?)?.toInt() ?? 0, + bedTemp: (json['bedTemp'] as num?)?.toInt() ?? 0, ); } @@ -52,7 +52,7 @@ class FilamentModel { 'weight': filamentModel.weight, 'weightUsed': filamentModel.weightUsed, 'price': filamentModel.price, - 'manufacture': filamentModel.manufacture, + 'manufacturer': filamentModel.manufacturer, 'purchaseDate': filamentModel.purchaseDate, if (filamentModel.notes != null) 'notes': filamentModel.notes, 'printingTemp': filamentModel.printingTemp, @@ -69,7 +69,7 @@ class FilamentModel { weight: 0.0, weightUsed: 0.0, price: 0.0, - manufacture: '', + manufacturer: '', purchaseDate: '', notes: null, printingTemp: 0, diff --git a/lib/pages/detail_view.dart b/lib/pages/detail_view.dart new file mode 100644 index 0000000..70c652d --- /dev/null +++ b/lib/pages/detail_view.dart @@ -0,0 +1,657 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../controllers/detail_controller.dart'; +import '../models/filament_model.dart'; + +class DetailPage extends GetView { + static const String namedRoute = '/detail-page'; + const DetailPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + extendBodyBehindAppBar: true, + backgroundColor: const Color(0xFF0D0F14), + body: PopScope( + // Beim System-Zurück das aktuelle Filament zurückgeben + canPop: false, + onPopInvokedWithResult: (_, _) => + Get.back(result: controller.filament.value), + child: Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + final f = controller.filament.value; + return Stack( + children: [ + // ── Hintergrund-Gradient ────────────────────────────── + Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color(0xFF0D0F14), + Color(0xFF141824), + Color(0xFF1A2035), + Color(0xFF0F1520), + ], + stops: [0.0, 0.35, 0.65, 1.0], + ), + ), + ), + + // ── Farbiger Glow oben ──────────────────────────────── + Positioned( + top: -80, + left: -60, + child: Container( + width: 280, + height: 280, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _parseColor(f.color).withValues(alpha: 0.12), + ), + ), + ), + + // ── Content ────────────────────────────────────────── + SafeArea( + child: CustomScrollView( + slivers: [ + // ── App Bar ─────────────────────────────────── + SliverAppBar( + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + elevation: 0, + pinned: true, + leading: IconButton( + icon: const Icon( + Icons.arrow_back_ios_new_rounded, + color: Colors.white, + ), + // Aktuelles Filament beim Zurücknavigieren übergeben + onPressed: () => + Get.back(result: controller.filament.value), + ), + title: Obx( + () => Text( + controller.filament.value.name, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + actions: [ + IconButton( + icon: const Icon( + Icons.edit_outlined, + color: Colors.white70, + ), + onPressed: controller.openEditDialog, + tooltip: 'Bearbeiten', + ), + IconButton( + icon: const Icon( + Icons.delete_outline_rounded, + color: Color(0xFFFF6B6B), + ), + onPressed: controller.deleteFilament, + tooltip: 'Löschen', + ), + const SizedBox(width: 8), + ], + ), + + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), + sliver: SliverList( + delegate: SliverChildListDelegate([ + // ── Hero-Header ────────────────────────── + _HeroHeader(filament: f), + const SizedBox(height: 20), + + // ── Gewicht-Karte ──────────────────────── + _WeightCard(controller: controller), + const SizedBox(height: 16), + + // ── Druck-Parameter ────────────────────── + _PrintParamsCard(filament: f), + const SizedBox(height: 16), + + // ── Kauf-Info ──────────────────────────── + _PurchaseCard(filament: f), + const SizedBox(height: 16), + + // ── Notizen ────────────────────────────── + if (f.notes != null && f.notes!.isNotEmpty) + _NotesCard(notes: f.notes!), + ]), + ), + ), + ], + ), + ), + ], + ); + }), + ), // PopScope + ); + } + + Color _parseColor(String colorStr) { + final known = { + 'rot': Colors.red, + 'blau': Colors.blue, + 'grün': Colors.green, + 'grau': Colors.grey, + 'schwarz': Colors.black, + 'weiß': Colors.white, + 'gelb': Colors.yellow, + 'orange': Colors.orange, + 'lila': Colors.purple, + 'pink': Colors.pink, + 'braun': Colors.brown, + }; + final lower = colorStr.toLowerCase(); + if (known.containsKey(lower)) return known[lower]!; + final hex = colorStr.replaceAll('#', ''); + if (hex.length == 6) { + final v = int.tryParse('FF$hex', radix: 16); + if (v != null) return Color(v); + } + return const Color(0xFF7B9FFF); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Hero Header +// ───────────────────────────────────────────────────────────────────────────── + +class _HeroHeader extends StatelessWidget { + final FilamentModel filament; + const _HeroHeader({required this.filament}); + + Color get _color { + final known = { + 'rot': Colors.red, + 'blau': Colors.blue, + 'grün': Colors.green, + 'grau': Colors.grey, + 'schwarz': Colors.black, + 'weiß': Colors.white, + 'gelb': Colors.yellow, + 'orange': Colors.orange, + 'lila': Colors.purple, + 'pink': Colors.pink, + 'braun': Colors.brown, + }; + final lower = filament.color.toLowerCase(); + if (known.containsKey(lower)) return known[lower]!; + final hex = filament.color.replaceAll('#', ''); + if (hex.length == 6) { + final v = int.tryParse('FF$hex', radix: 16); + if (v != null) return Color(v); + } + return const Color(0xFF7B9FFF); + } + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(20), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.07), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.white.withValues(alpha: 0.15)), + ), + padding: const EdgeInsets.all(20), + child: Row( + children: [ + // Großer Farb-Kreis + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _color, + boxShadow: [ + BoxShadow( + color: _color.withValues(alpha: 0.45), + blurRadius: 20, + spreadRadius: 2, + ), + ], + border: Border.all( + color: Colors.white.withValues(alpha: 0.3), + width: 2, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + filament.name, + style: const TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + _Badge( + label: filament.type, + color: const Color(0xFF7B9FFF), + ), + const SizedBox(width: 8), + _Badge(label: filament.color, color: _color), + ], + ), + const SizedBox(height: 4), + Text( + filament.manufacturer, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.5), + fontSize: 13, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Gewicht-Karte mit interaktivem Slider +// ───────────────────────────────────────────────────────────────────────────── + +class _WeightCard extends StatefulWidget { + final DetailController controller; + const _WeightCard({required this.controller}); + + @override + State<_WeightCard> createState() => _WeightCardState(); +} + +class _WeightCardState extends State<_WeightCard> { + late double _sliderValue; + + @override + void initState() { + super.initState(); + _sliderValue = widget.controller.filament.value.weightUsed; + } + + double get _remaining => + (widget.controller.filament.value.weight - _sliderValue).clamp( + 0, + widget.controller.filament.value.weight, + ); + + Color get _progressColor { + final p = widget.controller.filament.value.weight > 0 + ? _remaining / widget.controller.filament.value.weight + : 0; + if (p > 0.5) return const Color(0xFF4FFFB0); + if (p > 0.2) return const Color(0xFFFFD166); + return const Color(0xFFFF6B6B); + } + + @override + Widget build(BuildContext context) { + final f = widget.controller.filament.value; + final maxWeight = f.weight; + + return _GlassCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _CardTitle(icon: Icons.scale_outlined, label: 'Füllstand'), + const SizedBox(height: 16), + + // Kreisanzeige + Zahlen + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 120, + height: 120, + child: Stack( + alignment: Alignment.center, + children: [ + SizedBox.expand( + child: CircularProgressIndicator( + value: maxWeight > 0 ? _remaining / maxWeight : 0, + strokeWidth: 10, + backgroundColor: Colors.white.withValues(alpha: 0.1), + valueColor: AlwaysStoppedAnimation(_progressColor), + strokeCap: StrokeCap.round, + ), + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _remaining.toStringAsFixed(0), + style: TextStyle( + color: _progressColor, + fontSize: 26, + fontWeight: FontWeight.bold, + ), + ), + Text( + 'von ${maxWeight.toStringAsFixed(0)} g', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.45), + fontSize: 11, + ), + ), + ], + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + + // Slider für verbrauchtes Gewicht + Text( + 'Verbraucht: ${_sliderValue.toStringAsFixed(0)} g', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.7), + fontSize: 13, + ), + ), + const SizedBox(height: 6), + SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: _progressColor, + inactiveTrackColor: Colors.white.withValues(alpha: 0.12), + thumbColor: _progressColor, + overlayColor: _progressColor.withValues(alpha: 0.2), + trackHeight: 6, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 10), + ), + child: Slider( + value: _sliderValue.clamp(0, maxWeight), + min: 0, + max: maxWeight > 0 ? maxWeight : 1, + divisions: maxWeight > 0 ? maxWeight.toInt() : 1, + onChanged: (v) => setState(() => _sliderValue = v), + onChangeEnd: (v) => widget.controller.updateWeightUsed(v), + ), + ), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Druck-Parameter +// ───────────────────────────────────────────────────────────────────────────── + +class _PrintParamsCard extends StatelessWidget { + final FilamentModel filament; + const _PrintParamsCard({required this.filament}); + + @override + Widget build(BuildContext context) { + return _GlassCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _CardTitle( + icon: Icons.settings_outlined, + label: 'Druckparameter', + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _ParamTile( + icon: Icons.thermostat_outlined, + label: 'Düsentemperatur', + value: '${filament.printingTemp} °C', + color: const Color(0xFFFF9F43), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _ParamTile( + icon: Icons.bed_outlined, + label: 'Betttemperatur', + value: '${filament.bedTemp} °C', + color: const Color(0xFF7B9FFF), + ), + ), + ], + ), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Kauf-Info +// ───────────────────────────────────────────────────────────────────────────── + +class _PurchaseCard extends StatelessWidget { + final FilamentModel filament; + const _PurchaseCard({required this.filament}); + + @override + Widget build(BuildContext context) { + return _GlassCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _CardTitle( + icon: Icons.receipt_long_outlined, + label: 'Kaufinfo', + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _ParamTile( + icon: Icons.euro_outlined, + label: 'Preis', + value: '${filament.price.toStringAsFixed(2)} €', + color: const Color(0xFF4FFFB0), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _ParamTile( + icon: Icons.calendar_today_outlined, + label: 'Kaufdatum', + value: filament.purchaseDate.isNotEmpty + ? filament.purchaseDate + : '–', + color: const Color(0xFFFFD166), + ), + ), + ], + ), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Notizen +// ───────────────────────────────────────────────────────────────────────────── + +class _NotesCard extends StatelessWidget { + final String notes; + const _NotesCard({required this.notes}); + + @override + Widget build(BuildContext context) { + return _GlassCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _CardTitle(icon: Icons.notes_outlined, label: 'Notizen'), + const SizedBox(height: 12), + Text( + notes, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.75), + fontSize: 14, + height: 1.5, + ), + ), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Shared Helper Widgets +// ───────────────────────────────────────────────────────────────────────────── + +class _GlassCard extends StatelessWidget { + final Widget child; + const _GlassCard({required this.child}); + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(20), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.07), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.white.withValues(alpha: 0.12)), + ), + padding: const EdgeInsets.all(20), + child: child, + ), + ), + ); + } +} + +class _CardTitle extends StatelessWidget { + final IconData icon; + final String label; + const _CardTitle({required this.icon, required this.label}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(icon, color: Colors.white54, size: 18), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.55), + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 1.1, + ), + ), + ], + ); + } +} + +class _ParamTile extends StatelessWidget { + final IconData icon; + final String label; + final String value; + final Color color; + const _ParamTile({ + required this.icon, + required this.label, + required this.value, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: color.withValues(alpha: 0.25)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: color, size: 20), + const SizedBox(height: 8), + Text( + value, + style: TextStyle( + color: color, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + Text( + label, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.45), + fontSize: 11, + ), + ), + ], + ), + ); + } +} + +class _Badge extends StatelessWidget { + final String label; + final Color color; + const _Badge({required this.label, required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withValues(alpha: 0.4)), + ), + child: Text( + label, + style: TextStyle( + color: color, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/lib/pages/home/home_view.dart b/lib/pages/home_view.dart similarity index 51% rename from lib/pages/home/home_view.dart rename to lib/pages/home_view.dart index b80d860..9d3e0ca 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home_view.dart @@ -1,24 +1,16 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import '../../controllers/home_controller.dart'; -import '../../models/home_model.dart'; -import '../../widgets/glass_app_bar.dart'; -import '../../widgets/person_weight_card.dart'; + +import '../controllers/home_controller.dart'; +import '../models/filament_model.dart'; +import '../widgets/filament_card.dart'; +import '../widgets/glass_app_bar.dart'; +import '../pages/detail_view.dart'; class HomePage extends GetView { static const String namedRoute = '/home-page'; const HomePage({super.key}); - /// Gruppiert eine flache Liste nach `name` und gibt eine Map zurück, - /// die je Person alle zugehörigen Einträge enthält. - Map> _groupByName(List weights) { - final map = >{}; - for (final w in weights) { - map.putIfAbsent(w.name, () => []).add(w); - } - return map; - } - @override Widget build(BuildContext context) { final homeCtrl = controller; @@ -26,8 +18,15 @@ class HomePage extends GetView { return Scaffold( extendBodyBehindAppBar: true, appBar: GlassAppBar( - title: 'Weight Tracker', - subtitle: 'Verfolge dein Gewicht', + title: 'Filament Tracker', + subtitle: 'Verwalte deine Filamente', + ), + floatingActionButton: FloatingActionButton( + onPressed: homeCtrl.openAddDialog, + backgroundColor: const Color(0xFF7B9FFF), + foregroundColor: Colors.white, + tooltip: 'Filament hinzufügen', + child: const Icon(Icons.add_rounded), ), body: Container( decoration: const BoxDecoration( @@ -48,22 +47,18 @@ class HomePage extends GetView { return const Center(child: CircularProgressIndicator()); } - if (homeCtrl.weights.isEmpty) { + if (homeCtrl.filaments.isEmpty) { return const Center( child: Text( - 'Noch keine Gewichtsangaben.\nKlicke auf das "+" Symbol, um deinen ersten Eintrag hinzuzufügen.', + 'Noch keine Filamente vorhanden.\nKlicke auf "+" um ein Filament hinzuzufügen.', textAlign: TextAlign.center, - style: TextStyle(fontSize: 18, color: Colors.white70), + style: TextStyle(fontSize: 16, color: Colors.white70), ), ); } - final grouped = _groupByName(homeCtrl.weights); - final names = grouped.keys.toList()..sort(); - return LayoutBuilder( builder: (context, constraints) { - // Responsive: ab 700 px Breite → 2-spaltiges Grid final isWide = constraints.maxWidth >= 700; return CustomScrollView( @@ -73,47 +68,33 @@ class HomePage extends GetView { top: MediaQuery.of(context).padding.top + 96, left: isWide ? 24 : 16, right: isWide ? 24 : 16, - bottom: 24, + bottom: 96, ), sliver: isWide ? SliverGrid( gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 600, + maxCrossAxisExtent: 560, mainAxisSpacing: 16, crossAxisSpacing: 16, - childAspectRatio: 0.95, + childAspectRatio: 1.3, ), delegate: SliverChildBuilderDelegate( - (context, i) => PersonWeightCard( - personName: names[i], - entries: grouped[names[i]]!, - onAddWeight: () => homeCtrl.openAddDialog( - names[i], - grouped[names[i]]!.first.documentId, - ), - onEditEntry: (entry) => - homeCtrl.openEditDialog(entry), - ), - childCount: names.length, + (context, i) => + _buildCard(homeCtrl, homeCtrl.filaments[i]), + childCount: homeCtrl.filaments.length, ), ) : SliverList( delegate: SliverChildBuilderDelegate( (context, i) => Padding( padding: const EdgeInsets.only(bottom: 16), - child: PersonWeightCard( - personName: names[i], - entries: grouped[names[i]]!, - onAddWeight: () => homeCtrl.openAddDialog( - names[i], - grouped[names[i]]!.first.documentId, - ), - onEditEntry: (entry) => - homeCtrl.openEditDialog(entry), + child: _buildCard( + homeCtrl, + homeCtrl.filaments[i], ), ), - childCount: names.length, + childCount: homeCtrl.filaments.length, ), ), ), @@ -125,4 +106,23 @@ class HomePage extends GetView { ), ); } + + Widget _buildCard(HomeController ctrl, FilamentModel filament) { + return FilamentCard( + filament: filament, + onTap: () async { + final result = await Get.toNamed(DetailPage.namedRoute, arguments: filament); + // 'deleted' → Filament aus der lokalen Liste entfernen + if (result == 'deleted') { + ctrl.deleteFilamentLocal(filament); + } + // Wurde bearbeitet → geändertes Filament zurück übergeben + if (result is FilamentModel) { + ctrl.editFilament(result); + } + }, + onEdit: () => ctrl.openEditDialog(filament), + onDelete: () => ctrl.deleteFilament(filament), + ); + } } diff --git a/lib/services/appwrite_service.dart b/lib/services/appwrite_service.dart index b234be3..e9b8cad 100644 --- a/lib/services/appwrite_service.dart +++ b/lib/services/appwrite_service.dart @@ -134,4 +134,70 @@ class AppwriteService { return false; } } + + // ── Filament Collection ────────────────────────────────────────────────── + + Future> getFilamentDocuments() async { + try { + final result = await _databases.listDocuments( + databaseId: databaseId, + collectionId: realtimeCollectionId, + queries: [Query.orderAsc('name')], + ); + return result.documents; + } catch (e) { + print('Fehler beim Abrufen der Filamente: $e'); + return []; + } + } + + Future createFilamentDocument(Map data) async { + try { + await _databases.createDocument( + databaseId: databaseId, + collectionId: realtimeCollectionId, + documentId: ID.unique(), + data: data, + ); + print('Filament erfolgreich erstellt'); + return true; + } catch (e) { + print('Fehler beim Erstellen des Filaments: $e'); + return false; + } + } + + Future updateFilamentDocument( + String documentId, + Map data, + ) async { + try { + await _databases.updateDocument( + databaseId: databaseId, + collectionId: realtimeCollectionId, + documentId: documentId, + data: data, + ); + print('Filament erfolgreich aktualisiert'); + return true; + } catch (e) { + print('Fehler beim Aktualisieren des Filaments: $e'); + return false; + } + } + + Future deleteFilamentDocument(String documentId) async { + try { + await _databases.deleteDocument( + databaseId: databaseId, + collectionId: realtimeCollectionId, + documentId: documentId, + ); + print('Filament erfolgreich gelöscht'); + return true; + } catch (e) { + print('Fehler beim Löschen des Filaments: $e'); + return false; + } + } } diff --git a/lib/widgets/add_filament_dialog.dart b/lib/widgets/add_filament_dialog.dart new file mode 100644 index 0000000..4b40c3e --- /dev/null +++ b/lib/widgets/add_filament_dialog.dart @@ -0,0 +1,548 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; + +import '../models/filament_model.dart'; + +class AddFilamentDialog extends StatefulWidget { + final FilamentModel? existingEntry; + + const AddFilamentDialog({super.key, this.existingEntry}); + + static Future show() { + return Get.dialog( + const AddFilamentDialog(), + barrierColor: Colors.black.withValues(alpha: 0.55), + ); + } + + static Future showEdit({required FilamentModel entry}) { + return Get.dialog( + AddFilamentDialog(existingEntry: entry), + barrierColor: Colors.black.withValues(alpha: 0.55), + ); + } + + @override + State createState() => _AddFilamentDialogState(); +} + +class _AddFilamentDialogState extends State + with SingleTickerProviderStateMixin { + final _formKey = GlobalKey(); + + final _nameCtrl = TextEditingController(); + final _colorCtrl = TextEditingController(); + final _weightCtrl = TextEditingController(); + final _weightUsedCtrl = TextEditingController(); + final _priceCtrl = TextEditingController(); + final _manufacturerCtrl = TextEditingController(); + final _notesCtrl = TextEditingController(); + final _printingTempCtrl = TextEditingController(); + final _bedTempCtrl = TextEditingController(); + + String _selectedType = 'PLA'; + String _purchaseDate = ''; + + late AnimationController _anim; + late Animation _fadeScale; + + static const _filamentTypes = [ + 'PLA', + 'PETG', + 'ABS', + 'TPU', + 'ASA', + 'Nylon', + 'Resin', + 'Sonstiges', + ]; + + @override + void initState() { + super.initState(); + final e = widget.existingEntry; + if (e != null) { + _nameCtrl.text = e.name; + _selectedType = _filamentTypes.contains(e.type) ? e.type : 'Sonstiges'; + _colorCtrl.text = e.color; + _weightCtrl.text = e.weight.toString(); + _weightUsedCtrl.text = e.weightUsed.toString(); + _priceCtrl.text = e.price.toString(); + _manufacturerCtrl.text = e.manufacturer; + _purchaseDate = e.purchaseDate; + _notesCtrl.text = e.notes ?? ''; + _printingTempCtrl.text = e.printingTemp.toString(); + _bedTempCtrl.text = e.bedTemp.toString(); + } else { + _purchaseDate = DateTime.now().toIso8601String().substring(0, 10); + _weightCtrl.text = '1000'; + _weightUsedCtrl.text = '0'; + _printingTempCtrl.text = '210'; + _bedTempCtrl.text = '60'; + } + _anim = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 220), + ); + _fadeScale = CurvedAnimation(parent: _anim, curve: Curves.easeOutBack); + _anim.forward(); + } + + @override + void dispose() { + for (final c in [ + _nameCtrl, + _colorCtrl, + _weightCtrl, + _weightUsedCtrl, + _priceCtrl, + _manufacturerCtrl, + _notesCtrl, + _printingTempCtrl, + _bedTempCtrl, + ]) { + c.dispose(); + } + _anim.dispose(); + super.dispose(); + } + + Future _pickDate() async { + final initial = DateTime.tryParse(_purchaseDate) ?? DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: initial, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + builder: (context, child) => Theme( + data: Theme.of(context).copyWith( + colorScheme: const ColorScheme.dark( + primary: Color(0xFF7B9FFF), + onSurface: Colors.white, + surface: Color(0xFF1A2035), + ), + ), + child: child!, + ), + ); + if (picked != null) { + setState(() => _purchaseDate = picked.toIso8601String().substring(0, 10)); + } + } + + void _submit() { + if (!_formKey.currentState!.validate()) return; + final entry = FilamentModel( + documentId: + widget.existingEntry?.documentId ?? + DateTime.now().millisecondsSinceEpoch.toString(), + name: _nameCtrl.text.trim(), + type: _selectedType, + color: _colorCtrl.text.trim(), + weight: double.tryParse(_weightCtrl.text.replaceAll(',', '.')) ?? 0, + weightUsed: + double.tryParse(_weightUsedCtrl.text.replaceAll(',', '.')) ?? 0, + price: double.tryParse(_priceCtrl.text.replaceAll(',', '.')) ?? 0, + manufacturer: _manufacturerCtrl.text.trim(), + purchaseDate: _purchaseDate, + notes: _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim(), + printingTemp: int.tryParse(_printingTempCtrl.text) ?? 0, + bedTemp: int.tryParse(_bedTempCtrl.text) ?? 0, + ); + Navigator.of(context).pop(entry); + } + + @override + Widget build(BuildContext context) { + final isEdit = widget.existingEntry != null; + + return ScaleTransition( + scale: _fadeScale, + child: FadeTransition( + opacity: _anim, + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 32), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 24, sigmaY: 24), + child: Material( + type: MaterialType.transparency, + child: Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: Colors.white.withValues(alpha: 0.2), + width: 1, + ), + ), + child: Form( + key: _formKey, + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ── Titel ── + Text( + isEdit + ? 'Filament bearbeiten' + : 'Neues Filament', + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 20), + + // ── Name ── + _GlassField( + controller: _nameCtrl, + label: 'Name', + validator: (v) => v == null || v.trim().isEmpty + ? 'Pflichtfeld' + : null, + ), + const SizedBox(height: 12), + + // ── Typ Dropdown ── + _GlassDropdown( + value: _selectedType, + items: _filamentTypes, + label: 'Typ', + onChanged: (v) => + setState(() => _selectedType = v!), + ), + const SizedBox(height: 12), + + // ── Farbe ── + _GlassField( + controller: _colorCtrl, + label: 'Farbe', + validator: (v) => v == null || v.trim().isEmpty + ? 'Pflichtfeld' + : null, + ), + const SizedBox(height: 12), + + // ── Hersteller ── + _GlassField( + controller: _manufacturerCtrl, + label: 'Hersteller', + validator: (v) => v == null || v.trim().isEmpty + ? 'Pflichtfeld' + : null, + ), + const SizedBox(height: 12), + + // ── Gewicht / Verbraucht ── + Row( + children: [ + Expanded( + child: _GlassField( + controller: _weightCtrl, + label: 'Gewicht (g)', + keyboardType: + const TextInputType.numberWithOptions( + decimal: true, + ), + validator: (v) => + double.tryParse( + v?.replaceAll(',', '.') ?? '', + ) == + null + ? 'Ungültig' + : null, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _GlassField( + controller: _weightUsedCtrl, + label: 'Verbraucht (g)', + keyboardType: + const TextInputType.numberWithOptions( + decimal: true, + ), + validator: (v) => + double.tryParse( + v?.replaceAll(',', '.') ?? '', + ) == + null + ? 'Ungültig' + : null, + ), + ), + ], + ), + const SizedBox(height: 12), + + // ── Preis / Kaufdatum ── + Row( + children: [ + Expanded( + child: _GlassField( + controller: _priceCtrl, + label: 'Preis (€)', + keyboardType: + const TextInputType.numberWithOptions( + decimal: true, + ), + validator: (v) => + double.tryParse( + v?.replaceAll(',', '.') ?? '', + ) == + null + ? 'Ungültig' + : null, + ), + ), + const SizedBox(width: 12), + Expanded( + child: GestureDetector( + onTap: _pickDate, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 16, + ), + decoration: BoxDecoration( + color: Colors.white.withValues( + alpha: 0.08, + ), + borderRadius: BorderRadius.circular( + 12, + ), + border: Border.all( + color: Colors.white.withValues( + alpha: 0.2, + ), + ), + ), + child: Row( + children: [ + Expanded( + child: Text( + _purchaseDate.isEmpty + ? 'Kaufdatum' + : _purchaseDate, + style: TextStyle( + color: _purchaseDate.isEmpty + ? Colors.white38 + : Colors.white, + fontSize: 14, + ), + ), + ), + const Icon( + Icons.calendar_today_outlined, + color: Colors.white54, + size: 16, + ), + ], + ), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + + // ── Drucktemperatur / Betttemperatur ── + Row( + children: [ + Expanded( + child: _GlassField( + controller: _printingTempCtrl, + label: 'Drucktemp. (°C)', + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + validator: (v) => + int.tryParse(v ?? '') == null + ? 'Ungültig' + : null, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _GlassField( + controller: _bedTempCtrl, + label: 'Betttemp. (°C)', + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + validator: (v) => + int.tryParse(v ?? '') == null + ? 'Ungültig' + : null, + ), + ), + ], + ), + const SizedBox(height: 12), + + // ── Notizen (optional) ── + _GlassField( + controller: _notesCtrl, + label: 'Notizen (optional)', + maxLines: 3, + ), + const SizedBox(height: 24), + + // ── Buttons ── + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => + Navigator.of(context).pop(), + child: const Text( + 'Abbrechen', + style: TextStyle(color: Colors.white60), + ), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: _submit, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF7B9FFF), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + isEdit ? 'Speichern' : 'Hinzufügen', + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +// ── Hilfs-Widgets ──────────────────────────────────────────────────────────── + +class _GlassField extends StatelessWidget { + final TextEditingController controller; + final String label; + final int maxLines; + final TextInputType? keyboardType; + final List? inputFormatters; + final String? Function(String?)? validator; + + const _GlassField({ + required this.controller, + required this.label, + this.maxLines = 1, + this.keyboardType, + this.inputFormatters, + this.validator, + }); + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + maxLines: maxLines, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + style: const TextStyle(color: Colors.white), + validator: validator, + decoration: InputDecoration( + labelText: label, + labelStyle: const TextStyle(color: Colors.white60), + filled: true, + fillColor: Colors.white.withValues(alpha: 0.08), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.2)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.2)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFF7B9FFF)), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFFF6B6B)), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFFF6B6B)), + ), + ), + ); + } +} + +class _GlassDropdown extends StatelessWidget { + final String value; + final List items; + final String label; + final ValueChanged onChanged; + + const _GlassDropdown({ + required this.value, + required this.items, + required this.label, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + initialValue: value, + dropdownColor: const Color(0xFF1A2035), + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + labelText: label, + labelStyle: const TextStyle(color: Colors.white60), + filled: true, + fillColor: Colors.white.withValues(alpha: 0.08), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.2)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.2)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFF7B9FFF)), + ), + ), + items: items + .map((t) => DropdownMenuItem(value: t, child: Text(t))) + .toList(), + onChanged: onChanged, + ); + } +} diff --git a/lib/widgets/filament_card.dart b/lib/widgets/filament_card.dart new file mode 100644 index 0000000..5dc7660 --- /dev/null +++ b/lib/widgets/filament_card.dart @@ -0,0 +1,326 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import '../models/filament_model.dart'; + +class FilamentCard extends StatelessWidget { + final FilamentModel filament; + final VoidCallback? onEdit; + final VoidCallback? onDelete; + final VoidCallback? onTap; + + const FilamentCard({ + super.key, + required this.filament, + this.onEdit, + this.onDelete, + this.onTap, + }); + + double get _remaining => + (filament.weight - filament.weightUsed).clamp(0, filament.weight); + double get _progress => + filament.weight > 0 ? _remaining / filament.weight : 0; + + Color get _progressColor { + if (_progress > 0.5) return const Color(0xFF4FFFB0); + if (_progress > 0.2) return const Color(0xFFFFD166); + return const Color(0xFFFF6B6B); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.07), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Colors.white.withValues(alpha: 0.15), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ── Header ───────────────────────────────────────── + _CardHeader( + filament: filament, + onEdit: onEdit, + onDelete: onDelete, + ), + + // ── Weight Progress ───────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${_remaining.toStringAsFixed(0)} g verbleibend', + style: TextStyle( + color: _progressColor, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + Text( + 'von ${filament.weight.toStringAsFixed(0)} g', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.45), + fontSize: 12, + ), + ), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: _progress, + minHeight: 6, + backgroundColor: Colors.white.withValues(alpha: 0.12), + valueColor: AlwaysStoppedAnimation(_progressColor), + ), + ), + ], + ), + ), + + // ── Details ────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _InfoChip( + icon: Icons.thermostat_outlined, + label: '${filament.printingTemp}°C Druck', + ), + _InfoChip( + icon: Icons.bed_outlined, + label: '${filament.bedTemp}°C Bett', + ), + _InfoChip( + icon: Icons.euro_outlined, + label: filament.price.toStringAsFixed(2), + ), + if (filament.purchaseDate.isNotEmpty) + _InfoChip( + icon: Icons.calendar_today_outlined, + label: filament.purchaseDate, + ), + ], + ), + ), + + // ── Notizen ────────────────────────────────────────── + if (filament.notes != null && filament.notes!.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Text( + filament.notes!, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.5), + fontSize: 12, + fontStyle: FontStyle.italic, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +// ── Header ─────────────────────────────────────────────────────────────────── + +class _CardHeader extends StatelessWidget { + final FilamentModel filament; + final VoidCallback? onEdit; + final VoidCallback? onDelete; + + const _CardHeader({required this.filament, this.onEdit, this.onDelete}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(16, 16, 8, 12), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: Colors.white.withValues(alpha: 0.08)), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Farb-Kreis + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _parseColor(filament.color), + border: Border.all( + color: Colors.white.withValues(alpha: 0.3), + width: 1.5, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + filament.name, + style: const TextStyle( + color: Colors.white, + fontSize: 17, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + Row( + children: [ + _TypeBadge(label: filament.type), + const SizedBox(width: 6), + Text( + filament.manufacturer, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.5), + fontSize: 12, + ), + ), + ], + ), + ], + ), + ), + // Edit / Delete + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (onEdit != null) + IconButton( + icon: const Icon( + Icons.edit_outlined, + size: 18, + color: Colors.white60, + ), + onPressed: onEdit, + tooltip: 'Bearbeiten', + ), + if (onDelete != null) + IconButton( + icon: const Icon( + Icons.delete_outline, + size: 18, + color: Color(0xFFFF6B6B), + ), + onPressed: onDelete, + tooltip: 'Löschen', + ), + ], + ), + ], + ), + ); + } + + Color _parseColor(String colorStr) { + final knownColors = { + 'rot': Colors.red, + 'blau': Colors.blue, + 'grün': Colors.green, + 'grau': Colors.grey, + 'schwarz': Colors.black, + 'weiß': Colors.white, + 'gelb': Colors.yellow, + 'orange': Colors.orange, + 'lila': Colors.purple, + 'pink': Colors.pink, + 'braun': Colors.brown, + }; + final lower = colorStr.toLowerCase(); + final known = knownColors[lower]; + if (known != null) return known; + + // Hex-Format #RRGGBB oder RRGGBB + final hex = colorStr.replaceAll('#', ''); + if (hex.length == 6) { + final v = int.tryParse('FF$hex', radix: 16); + if (v != null) return Color(v); + } + return const Color(0xFF7B9FFF); + } +} + +class _TypeBadge extends StatelessWidget { + final String label; + const _TypeBadge({required this.label}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFF7B9FFF).withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: const Color(0xFF7B9FFF).withValues(alpha: 0.4), + ), + ), + child: Text( + label, + style: const TextStyle( + color: Color(0xFF7B9FFF), + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +class _InfoChip extends StatelessWidget { + final IconData icon; + final String label; + const _InfoChip({required this.icon, required this.label}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.07), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.white.withValues(alpha: 0.12)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 13, color: Colors.white54), + const SizedBox(width: 4), + Text( + label, + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + ], + ), + ); + } +}