From 47cbdf3b7c21f23d4a530bad64229a8e599f13fb Mon Sep 17 00:00:00 2001 From: Herwig Birke Date: Thu, 23 Oct 2025 08:19:18 +0200 Subject: [PATCH] Gallery --- lib/app_router.dart | 35 +- .../auth/application/splash_screen.dart | 1 - .../igel/data/igel_images_repository.dart | 68 + lib/features/igel/data/igel_repository.dart | 5 + lib/features/igel/domain/igel.dart | 72 +- .../igel/presentation/igel_detail_screen.dart | 1114 +++++++++++++++++ .../presentation/igel_gallery_screen.dart | 397 ++++++ .../igel/presentation/igel_list_screen.dart | 27 +- .../messwerte/data/messwerte_repository.dart | 91 ++ lib/features/messwerte/domain/messwert.dart | 47 + lib/main.dart | 7 + linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 128 ++ pubspec.yaml | 2 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 18 files changed, 1984 insertions(+), 21 deletions(-) create mode 100644 lib/features/igel/data/igel_images_repository.dart create mode 100644 lib/features/igel/presentation/igel_detail_screen.dart create mode 100644 lib/features/igel/presentation/igel_gallery_screen.dart create mode 100644 lib/features/messwerte/data/messwerte_repository.dart create mode 100644 lib/features/messwerte/domain/messwert.dart diff --git a/lib/app_router.dart b/lib/app_router.dart index 5f9ea79..d779c85 100644 --- a/lib/app_router.dart +++ b/lib/app_router.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; + import 'features/auth/presentation/login_screen.dart'; import 'features/auth/presentation/register_screen.dart'; -import 'features/igel/presentation/igel_list_screen.dart'; import 'features/auth/application/splash_screen.dart'; +import 'features/igel/presentation/igel_list_screen.dart'; +import 'features/igel/presentation/igel_detail_screen.dart'; // <-- WICHTIG +import 'features/igel/presentation/igel_gallery_screen.dart'; GoRouter buildRouter() => GoRouter( initialLocation: '/splash', @@ -11,6 +14,36 @@ GoRouter buildRouter() => GoRouter( GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()), GoRoute(path: '/login', builder: (_, __) => const LoginScreen()), GoRoute(path: '/register', builder: (_, __) => const RegisterScreen()), + + // Liste GoRoute(path: '/igel', builder: (_, __) => const IgelListScreen()), + + // Detail: /igel/:id (z. B. /igel/3) + GoRoute( + path: '/igel/:id', + builder: (ctx, st) { + final idStr = st.pathParameters['id'] ?? ''; + final id = int.tryParse(idStr); + if (id == null) { + return const Scaffold( + body: Center(child: Text('Fehlerhafte ID')), + ); + } + return IgelDetailScreen(igelId: id); + }, + ), + GoRoute( + path: '/igel/:id/gallery', + builder: (ctx, st) { + final id = int.tryParse(st.pathParameters['id'] ?? ''); + final initial = + int.tryParse(st.uri.queryParameters['index'] ?? '0') ?? 0; + if (id == null) { + return const Scaffold( + body: Center(child: Text('Fehlerhafte ID'))); + } + return IgelGalleryScreen(igelId: id, initialIndex: initial); + }, + ), ], ); diff --git a/lib/features/auth/application/splash_screen.dart b/lib/features/auth/application/splash_screen.dart index 1044644..a18716d 100644 --- a/lib/features/auth/application/splash_screen.dart +++ b/lib/features/auth/application/splash_screen.dart @@ -1,7 +1,6 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../auth/data/token_storage.dart'; import '../../../main.dart'; import 'package:go_router/go_router.dart'; diff --git a/lib/features/igel/data/igel_images_repository.dart b/lib/features/igel/data/igel_images_repository.dart new file mode 100644 index 0000000..0da3c65 --- /dev/null +++ b/lib/features/igel/data/igel_images_repository.dart @@ -0,0 +1,68 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import '../../../shared/api_client.dart'; + +class IgelImage { + final int id; + final String url; + final String? thumbUrl; // ✅ Thumbnail-Feld ergänzt + final String? originalName; + final String? mime; + final int? sizeBytes; + + IgelImage({ + required this.id, + required this.url, + this.thumbUrl, + this.originalName, + this.mime, + this.sizeBytes, + }); + + factory IgelImage.fromMap(Map m) => IgelImage( + id: (m['id'] as num).toInt(), + url: (m['url'] ?? '') as String, + thumbUrl: + (m['thumb_url'] ?? m['thumbUrl']) as String?, // ✅ Feld gemappt + originalName: m['original_name'] as String?, + mime: m['mime'] as String?, + sizeBytes: (m['size_bytes'] as num?)?.toInt(), + ); +} + +class IgelImagesRepository { + IgelImagesRepository(this.api, {required this.getAccessToken}); + final ApiClient api; + final Future Function() getAccessToken; + + Future> list(int igelId) async { + final res = await api.get('/igel/$igelId/images'); + final list = (res as List).cast>(); + return list.map(IgelImage.fromMap).toList(); + } + + Future> upload( + int igelId, List files) async { + final uri = Uri.parse( + '${api.baseUrl}/igel/$igelId/images'); // api.baseUrl endet auf ?r= + final req = http.MultipartRequest('POST', uri); + final token = await getAccessToken(); + if (token != null) req.headers['Authorization'] = 'Bearer $token'; + // keine Content-Type-Header setzen -> MultipartRequest macht das korrekt + for (final f in files) { + req.files.add(f); // Feldname egal: server sammelt alle + } + final streamRes = await req.send(); + final body = await streamRes.stream.bytesToString(); + if (streamRes.statusCode < 200 || streamRes.statusCode >= 300) { + throw ApiException(streamRes.statusCode, body); + } + final decoded = jsonDecode(body); + final uploaded = (decoded['uploaded'] as List).cast>(); + return uploaded.map(IgelImage.fromMap).toList(); + } + + Future delete(int imageId) async { + await api.delete('/igel/images/$imageId'); + } +} diff --git a/lib/features/igel/data/igel_repository.dart b/lib/features/igel/data/igel_repository.dart index 04e0ced..080a972 100644 --- a/lib/features/igel/data/igel_repository.dart +++ b/lib/features/igel/data/igel_repository.dart @@ -35,4 +35,9 @@ class IgelRepository { Future delete(int id) async { await api.delete('/igel/$id'); } + + Future get(int id) async { + final map = await api.get>('/igel/$id'); + return Igel.fromMap(map); + } } diff --git a/lib/features/igel/domain/igel.dart b/lib/features/igel/domain/igel.dart index dcc8569..f89301a 100644 --- a/lib/features/igel/domain/igel.dart +++ b/lib/features/igel/domain/igel.dart @@ -1,22 +1,66 @@ class Igel { final int id; final String name; - final String? note; - final String? gender; // männlich | weiblich | unbekannt + final String? gender; // 'männlich' | 'weiblich' | 'unbekannt' final String? feature; // Merkmal + final String? note; // Information + final DateTime? createdAt; + final DateTime? updatedAt; - Igel( - {required this.id, - required this.name, - this.note, - this.gender, - this.feature}); + Igel({ + required this.id, + required this.name, + this.gender, + this.feature, + this.note, + this.createdAt, + this.updatedAt, + }); - factory Igel.fromMap(Map m) => Igel( - id: (m['id'] as num).toInt(), - name: (m['name'] ?? '') as String, - note: m['note'] as String?, - gender: m['gender'] as String?, - feature: m['feature'] as String?, + // ---------- Factory fromMap ---------- + factory Igel.fromMap(Map map) => Igel( + id: (map['id'] as num).toInt(), + name: map['name'] as String, + gender: map['gender'] as String?, + feature: map['feature'] as String?, + note: map['note'] as String?, + createdAt: map['created_at'] != null + ? DateTime.parse(map['created_at']) + : null, + updatedAt: map['updated_at'] != null + ? DateTime.parse(map['updated_at']) + : null, ); + + // ---------- toMap (optional für PUT/POST) ---------- + Map toMap() => { + 'id': id, + 'name': name, + if (gender != null) 'gender': gender, + if (feature != null) 'feature': feature, + if (note != null) 'note': note, + if (createdAt != null) 'created_at': createdAt!.toIso8601String(), + if (updatedAt != null) 'updated_at': updatedAt!.toIso8601String(), + }; + + // ---------- copyWith ---------- + Igel copyWith({ + int? id, + String? name, + String? gender, + String? feature, + String? note, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return Igel( + id: id ?? this.id, + name: name ?? this.name, + gender: gender ?? this.gender, + feature: feature ?? this.feature, + note: note ?? this.note, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } } diff --git a/lib/features/igel/presentation/igel_detail_screen.dart b/lib/features/igel/presentation/igel_detail_screen.dart new file mode 100644 index 0000000..8bd7a33 --- /dev/null +++ b/lib/features/igel/presentation/igel_detail_screen.dart @@ -0,0 +1,1114 @@ +// lib/features/igel/presentation/igel_detail_screen.dart +import 'dart:io'; +import 'dart:math' as math; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; + +import '../../../main.dart'; +import '../../igel/data/igel_images_repository.dart'; +import '../../igel/data/igel_repository.dart'; +import '../domain/igel.dart'; + +// Messwerte +import '../../messwerte/domain/messwert.dart'; +import '../../messwerte/data/messwerte_repository.dart'; + +class IgelDetailScreen extends ConsumerStatefulWidget { + const IgelDetailScreen({super.key, required this.igelId}); + final int igelId; + + @override + ConsumerState createState() => _IgelDetailState(); +} + +class _IgelDetailState extends ConsumerState { + late final IgelImagesRepository imagesRepo; + late final IgelRepository igelRepo; + late final MesswerteRepository messRepo; + + bool busy = true; + String? err; + + Igel? igel; + + // Basisdaten (editierbar via AppBar-Dialog) + final nameC = TextEditingController(); + final featureC = TextEditingController(); + final noteC = TextEditingController(); + String? gender; // 'männlich' | 'weiblich' | 'unbekannt' | null + + // Bilder + List images = []; + + // Messwerte + List messwerte = []; + bool mwShowForm = false; + DateTime mwDatum = DateTime.now(); + final mwGewichtC = TextEditingController(); + final mwBehandlungC = TextEditingController(); + final mwBemerkungC = TextEditingController(); + + @override + void initState() { + super.initState(); + imagesRepo = ref.read(igelImagesRepoProvider); + igelRepo = ref.read(igelRepoProvider); + messRepo = ref.read(messwerteRepoProvider); + + featureC.addListener(_markDirtyBasics); + noteC.addListener(_markDirtyBasics); + + _loadAll(); + } + + @override + void dispose() { + nameC.dispose(); + featureC.dispose(); + noteC.dispose(); + mwGewichtC.dispose(); + mwBehandlungC.dispose(); + mwBemerkungC.dispose(); + super.dispose(); + } + + void _markDirtyBasics() { + if (mounted) setState(() {}); + } + + bool get _basicsChanged { + if (igel == null) return false; + final newName = nameC.text.trim(); + final newFeature = + featureC.text.trim().isEmpty ? null : featureC.text.trim(); + final newNote = noteC.text.trim().isEmpty ? null : noteC.text.trim(); + final changed = newName != igel!.name || + newFeature != igel!.feature || + newNote != igel!.note || + gender != igel!.gender; + return changed; + } + + Future _loadAll() async { + setState(() { + busy = true; + err = null; + }); + try { + // Igel + final data = await igelRepo.get(widget.igelId); + igel = data; + nameC.text = data.name; + featureC.text = data.feature ?? ''; + noteC.text = data.note ?? ''; + gender = data.gender; + + // Bilder + images = await imagesRepo.list(widget.igelId); + if (mounted) await _prefetchImages(context); + + // Messwerte + messwerte = await messRepo.list(widget.igelId); + } catch (e) { + err = e.toString(); + } finally { + if (mounted) setState(() => busy = false); + } + } + + /// Prefetcht bis zu 12 Thumbs und bis zu 3 Full-Images. + Future _prefetchImages(BuildContext context) async { + if (!mounted || images.isEmpty) return; + final thumbCount = math.min(12, images.length); + for (var i = 0; i < thumbCount; i++) { + final url = images[i].thumbUrl ?? images[i].url; + try { + await precacheImage(NetworkImage(url), context); + } catch (_) {} + } + final fullCount = math.min(3, images.length); + for (var i = 0; i < fullCount; i++) { + try { + await precacheImage(NetworkImage(images[i].url), context); + } catch (_) {} + } + } + + Future _saveIgel() async { + if (!_basicsChanged || igel == null) return; + final newName = nameC.text.trim(); + final newFeature = + featureC.text.trim().isEmpty ? null : featureC.text.trim(); + final newNote = noteC.text.trim().isEmpty ? null : noteC.text.trim(); + if (newName.isEmpty) { + _snack('Bitte einen Namen eingeben'); + return; + } + try { + await igelRepo.update( + widget.igelId, + newName, + note: newNote, + gender: gender, + feature: newFeature, + ); + igel = igel!.copyWith( + name: newName, note: newNote, gender: gender, feature: newFeature); + setState(() {}); + _snack('Gespeichert'); + } catch (e) { + _snack('Speichern fehlgeschlagen: $e'); + } + } + + // --- Images --- + Future _pickAndUpload() async { + try { + final picker = ImagePicker(); + final picks = await picker.pickMultiImage( + maxWidth: 4096, maxHeight: 4096, imageQuality: 90); + if (picks.isEmpty) return; + + final files = []; + for (final x in picks) { + if (kIsWeb) { + final bytes = await x.readAsBytes(); + files.add(http.MultipartFile.fromBytes('files[]', bytes, + filename: x.name, contentType: _mimeFromName(x.name))); + } else { + files.add( + await http.MultipartFile.fromPath('files[]', File(x.path).path)); + } + } + await imagesRepo.upload(widget.igelId, files); + images = await imagesRepo.list(widget.igelId); + if (mounted) { + setState(() {}); + await _prefetchImages(context); + } + _snack('Bilder hochgeladen'); + } catch (e) { + _snack('Upload fehlgeschlagen: $e'); + } + } + + Future _deleteImage(IgelImage img) async { + final ok = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Bild löschen?'), + content: const Text('Dieses Bild wirklich löschen?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Abbrechen')), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Löschen')), + ], + ), + ) ?? + false; + if (!ok) return; + try { + await imagesRepo.delete(img.id); + images = await imagesRepo.list(widget.igelId); + if (mounted) { + setState(() {}); + await _prefetchImages(context); + } + _snack('Bild gelöscht'); + } catch (e) { + _snack('Löschen fehlgeschlagen: $e'); + } + } + + MediaType? _mimeFromName(String name) { + final lower = name.toLowerCase(); + if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) + return MediaType('image', 'jpeg'); + if (lower.endsWith('.png')) return MediaType('image', 'png'); + if (lower.endsWith('.webp')) return MediaType('image', 'webp'); + if (lower.endsWith('.gif')) return MediaType('image', 'gif'); + return null; + } + + IconData? _genderIcon(String? g) { + switch (g) { + case 'männlich': + return Icons.male; + case 'weiblich': + return Icons.female; + case 'unbekannt': + return Icons.help_outline; + default: + return null; + } + } + + Color? _genderColor(String? g) { + switch (g) { + case 'männlich': + return Colors.blue[600]; + case 'weiblich': + return Colors.pink[400]; + case 'unbekannt': + return Colors.grey[600]; + default: + return null; + } + } + + // --- Messwerte helpers --- + Future _pickMwDateTime() async { + final d = await showDatePicker( + context: context, + initialDate: mwDatum, + firstDate: DateTime(2020), + lastDate: DateTime(2100), + ); + if (d == null) return; + final t = await showTimePicker( + context: context, initialTime: TimeOfDay.fromDateTime(mwDatum)); + if (t == null) return; + setState(() { + mwDatum = DateTime(d.year, d.month, d.day, t.hour, t.minute); + }); + } + + Future _createMesswert() async { + final gewicht = int.tryParse(mwGewichtC.text.trim()); + if (gewicht == null || gewicht <= 0) { + _snack('Bitte Gewicht in Gramm angeben'); + return; + } + try { + await messRepo.create( + igelId: widget.igelId, + datum: mwDatum, + gewicht: gewicht, + behandlung: _emptyToNull(mwBehandlungC.text), + bemerkung: _emptyToNull(mwBemerkungC.text), + ); + messwerte = await messRepo.list(widget.igelId); + mwGewichtC.clear(); + mwBehandlungC.clear(); + mwBemerkungC.clear(); + setState(() => mwShowForm = false); + _snack('Messwert gespeichert'); + } catch (e) { + _snack('Fehler: $e'); + } + } + + Future _editMesswert(Messwert m) async { + final res = await showDialog( + context: context, + builder: (_) => _EditMesswertDialog(initial: m), + ); + if (res == null) return; + try { + await messRepo.update(m.id, res); + messwerte = await messRepo.list(widget.igelId); + setState(() {}); + _snack('Messwert aktualisiert'); + } catch (e) { + _snack('Fehler: $e'); + } + } + + Future _deleteMesswert(Messwert m) async { + final ok = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Messwert löschen?'), + content: Text(_fmtDateTime(m.datum)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Abbrechen')), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Löschen')), + ], + ), + ) ?? + false; + if (!ok) return; + try { + await messRepo.delete(m.id); + messwerte = await messRepo.list(widget.igelId); + setState(() {}); + _snack('Gelöscht'); + } catch (e) { + _snack('Fehler: $e'); + } + } + + String _fmtDateTime(DateTime dt) { + final d = dt.toLocal(); + String two(int x) => x.toString().padLeft(2, '0'); + return '${two(d.day)}.${two(d.month)}.${d.year} ${two(d.hour)}:${two(d.minute)}'; + } + + String _fmtGramm(int g) => '$g g'; + String? _emptyToNull(String s) => s.trim().isEmpty ? null : s.trim(); + + void _snack(String msg) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + } + + Future _openEditBasicsDialog() async { + final tmpName = TextEditingController(text: nameC.text); + String? tmpGender = gender; + + final ok = await showDialog( + context: context, + builder: (_) => StatefulBuilder( + builder: (context, setStateDialog) { + return AlertDialog( + title: const Text('Name & Geschlecht bearbeiten'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: tmpName, + decoration: const InputDecoration( + labelText: 'Name', + prefixIcon: Icon(Icons.pets), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _GenderChoice( + label: 'Männlich', + icon: Icons.male, + color: _genderColor('männlich') ?? Colors.blue, + selected: tmpGender == 'männlich', + onTap: () => + setStateDialog(() => tmpGender = 'männlich'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _GenderChoice( + label: 'Weiblich', + icon: Icons.female, + color: _genderColor('weiblich') ?? Colors.pink, + selected: tmpGender == 'weiblich', + onTap: () => + setStateDialog(() => tmpGender = 'weiblich'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _GenderChoice( + label: 'Unbekannt', + icon: Icons.help_outline, + color: _genderColor('unbekannt') ?? Colors.grey, + selected: tmpGender == 'unbekannt', + onTap: () => + setStateDialog(() => tmpGender = 'unbekannt'), + ), + ), + ], + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Abbrechen')), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Übernehmen')), + ], + ); + }, + ), + ); + + if (ok == true) { + nameC.text = tmpName.text.trim(); + gender = tmpGender; + _markDirtyBasics(); + } + tmpName.dispose(); + } + + @override + Widget build(BuildContext context) { + final title = + nameC.text.trim().isEmpty ? (igel?.name ?? 'Igel') : nameC.text.trim(); + final titleIcon = _genderIcon(gender); + final chartData = [...messwerte] + ..sort((a, b) => a.datum.compareTo(b.datum)); + + return Scaffold( + appBar: AppBar( + leading: BackButton(onPressed: () => context.go('/igel')), + title: InkWell( + borderRadius: BorderRadius.circular(6), + onTap: _openEditBasicsDialog, + child: Row( + children: [ + if (titleIcon != null) ...[ + Icon(titleIcon, color: _genderColor(gender)), + const SizedBox(width: 8), + ], + Flexible(child: Text(title)), + const SizedBox(width: 8), + const Icon(Icons.edit, size: 18, color: Colors.black54), + ], + ), + ), + actions: [ + IconButton( + onPressed: _basicsChanged ? _saveIgel : null, + tooltip: + _basicsChanged ? 'Änderungen speichern' : 'Keine Änderungen', + icon: const Icon(Icons.save), + ), + IconButton( + onPressed: _pickAndUpload, + tooltip: 'Bilder hinzufügen', + icon: const Icon(Icons.add_a_photo), + ), + ], + ), + body: busy + ? const Center(child: CircularProgressIndicator()) + : err != null + ? Center(child: Text(err!)) + : igel == null + ? const Center(child: Text('Igel nicht gefunden')) + : RefreshIndicator( + onRefresh: _loadAll, + child: ListView( + padding: const EdgeInsets.all(12), + children: [ + // ----- Stammdaten (ohne Name/Geschlecht) + Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: featureC, + decoration: const InputDecoration( + labelText: 'Merkmal (optional)', + prefixIcon: Icon(Icons.style), + ), + ), + const SizedBox(height: 12), + TextField( + controller: noteC, + minLines: 2, + maxLines: 5, + decoration: const InputDecoration( + labelText: 'Information (optional)', + prefixIcon: Icon(Icons.info_outline), + ), + ), + // kein zusätzlicher Speichern-Button – AppBar reicht + ], + ), + ), + ), + + const SizedBox(height: 12), + + // ----- Gewicht-Chart (mit Achsen) + Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.show_chart), + const SizedBox(width: 8), + const Text('Gewichtsverlauf', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600)), + const Spacer(), + if (chartData.isNotEmpty) + Text( + '${chartData.first.datum.year}–${chartData.last.datum.year}', + style: const TextStyle( + color: Colors.black54)), + ], + ), + const SizedBox(height: 8), + SizedBox( + height: + 220, // etwas höher wegen Achsenbeschriftungen + child: WeightChart(data: chartData), + ), + ], + ), + ), + ), + + const SizedBox(height: 12), + + // ----- Messwerte (Form + Tabelle) + Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.monitor_weight_outlined), + const SizedBox(width: 8), + const Text('Messwerte', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600)), + const Spacer(), + TextButton.icon( + onPressed: () => setState( + () => mwShowForm = !mwShowForm), + icon: Icon(mwShowForm + ? Icons.close + : Icons.add), + label: Text( + mwShowForm ? 'Abbrechen' : 'Neu'), + ), + ], + ), + AnimatedCrossFade( + duration: const Duration(milliseconds: 180), + crossFadeState: mwShowForm + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Datum/Uhrzeit: ${_fmtDateTime(mwDatum)}')), + TextButton.icon( + onPressed: _pickMwDateTime, + icon: const Icon( + Icons.calendar_today), + label: const Text('Ändern'), + ), + ], + ), + const SizedBox(height: 8), + TextField( + controller: mwGewichtC, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Gewicht (g)', + prefixIcon: Icon(Icons.scale), + ), + ), + const SizedBox(height: 8), + TextField( + controller: mwBehandlungC, + decoration: const InputDecoration( + labelText: + 'Medikament/Behandlung (optional)', + prefixIcon: + Icon(Icons.medication), + ), + ), + const SizedBox(height: 8), + TextField( + controller: mwBemerkungC, + minLines: 1, + maxLines: 3, + decoration: const InputDecoration( + labelText: 'Bemerkung (optional)', + prefixIcon: Icon(Icons.notes), + ), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: FilledButton.icon( + onPressed: _createMesswert, + icon: const Icon(Icons.save), + label: const Text('Speichern'), + ), + ), + const Divider(height: 24), + ], + ), + ), + secondChild: const SizedBox.shrink(), + ), + if (messwerte.isEmpty) + const Padding( + padding: + EdgeInsets.symmetric(vertical: 4), + child: Text('Keine Messwerte'), + ) + else + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: DataTable( + columns: const [ + DataColumn( + label: Text('Datum/Uhrzeit')), + DataColumn( + label: Text('Gewicht (g)')), + DataColumn(label: Text('Behandlung')), + DataColumn(label: Text('Bemerkung')), + DataColumn(label: Text('Aktionen')), + ], + rows: [ + for (final m in messwerte) + DataRow(cells: [ + DataCell( + Text(_fmtDateTime(m.datum))), + DataCell(Text('${m.gewicht}')), + DataCell( + Text(m.behandlung ?? '')), + DataCell(Text(m.bemerkung ?? '')), + DataCell(Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Bearbeiten', + icon: + const Icon(Icons.edit), + onPressed: () => + _editMesswert(m), + ), + IconButton( + tooltip: 'Löschen', + icon: const Icon( + Icons.delete_outline), + onPressed: () => + _deleteMesswert(m), + ), + ], + )), + ]), + ], + ), + ), + ], + ), + ), + ), + + const SizedBox(height: 12), + + // ----- Bilder-Grid (GANZ UNTEN) + if (images.isEmpty) + Card( + child: SizedBox( + height: 180, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.photo_library_outlined, + size: 48), + const SizedBox(height: 8), + const Text('Noch keine Bilder'), + const SizedBox(height: 8), + FilledButton.icon( + onPressed: _pickAndUpload, + icon: const Icon(Icons.add_a_photo), + label: const Text('Bilder hochladen'), + ), + ], + ), + ), + ), + ) + else + Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: GridView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: images.length, + itemBuilder: (_, i) { + final img = images[i]; + final thumb = img.thumbUrl ?? img.url; + return GestureDetector( + onTap: () => context.push( + '/igel/${widget.igelId}/gallery?index=$i'), + onLongPress: () => _deleteImage(img), + child: Hero( + tag: 'igimg-${img.id}', + child: ClipRRect( + borderRadius: + BorderRadius.circular(12), + child: Image.network( + thumb, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + const ColoredBox( + color: Color(0x11000000), + child: Center( + child: + Icon(Icons.broken_image)), + ), + ), + ), + ), + ); + }, + ), + ), + ), + ], + ), + ), + ); + } +} + +// ---------- Edit-Dialog für Messwert ---------- +class _EditMesswertDialog extends StatefulWidget { + const _EditMesswertDialog({required this.initial}); + final Messwert initial; + + @override + State<_EditMesswertDialog> createState() => _EditMesswertDialogState(); +} + +class _EditMesswertDialogState extends State<_EditMesswertDialog> { + late DateTime datum; + late final TextEditingController gewichtC; + late final TextEditingController behandlungC; + late final TextEditingController bemerkungC; + + @override + void initState() { + super.initState(); + datum = widget.initial.datum; + gewichtC = TextEditingController(text: widget.initial.gewicht.toString()); + behandlungC = TextEditingController(text: widget.initial.behandlung ?? ''); + bemerkungC = TextEditingController(text: widget.initial.bemerkung ?? ''); + } + + @override + void dispose() { + gewichtC.dispose(); + behandlungC.dispose(); + bemerkungC.dispose(); + super.dispose(); + } + + String _fmtDateTime(DateTime dt) { + final d = dt.toLocal(); + String two(int x) => x.toString().padLeft(2, '0'); + return '${two(d.day)}.${two(d.month)}.${d.year} ${two(d.hour)}:${two(d.minute)}'; + } + + Future _pickDateTime() async { + final d = await showDatePicker( + context: context, + initialDate: datum, + firstDate: DateTime(2020), + lastDate: DateTime(2100), + ); + if (d == null) return; + final t = await showTimePicker( + context: context, initialTime: TimeOfDay.fromDateTime(datum)); + if (t == null) return; + setState(() { + datum = DateTime(d.year, d.month, d.day, t.hour, t.minute); + }); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Messwert bearbeiten'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded(child: Text('Datum/Uhrzeit: ${_fmtDateTime(datum)}')), + TextButton.icon( + onPressed: _pickDateTime, + icon: const Icon(Icons.calendar_today), + label: const Text('Ändern')), + ], + ), + const SizedBox(height: 8), + TextField( + controller: gewichtC, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Gewicht (g)', prefixIcon: Icon(Icons.scale)), + ), + const SizedBox(height: 8), + TextField( + controller: behandlungC, + decoration: const InputDecoration( + labelText: 'Medikament/Behandlung (optional)', + prefixIcon: Icon(Icons.medication)), + ), + const SizedBox(height: 8), + TextField( + controller: bemerkungC, + minLines: 1, + maxLines: 3, + decoration: const InputDecoration( + labelText: 'Bemerkung (optional)', + prefixIcon: Icon(Icons.notes)), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Abbrechen')), + FilledButton( + onPressed: () { + final gewicht = int.tryParse(gewichtC.text.trim()); + if (gewicht == null || gewicht <= 0) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('Bitte Gewicht in Gramm angeben'))); + return; + } + Navigator.pop( + context, + widget.initial.copyWith( + datum: datum, + gewicht: gewicht, + behandlung: behandlungC.text.trim().isEmpty + ? null + : behandlungC.text.trim(), + bemerkung: bemerkungC.text.trim().isEmpty + ? null + : bemerkungC.text.trim(), + ), + ); + }, + child: const Text('Speichern'), + ), + ], + ); + } +} + +// ---------- UI-Helfer: Gender-Choice-Kachel ---------- +class _GenderChoice extends StatelessWidget { + const _GenderChoice({ + required this.label, + required this.icon, + required this.color, + required this.selected, + required this.onTap, + }); + final String label; + final IconData icon; + final Color color; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: selected ? color : const Color(0x22000000)), + color: selected ? color.withOpacity(0.08) : null, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: selected ? color : Colors.grey[700]), + const SizedBox(height: 6), + Text(label, + style: TextStyle(color: selected ? color : Colors.black87)), + ], + ), + ), + ); + } +} + +// ---------- Gewichts-Chart mit Achsen ---------- +class WeightChart extends StatelessWidget { + const WeightChart({super.key, required this.data}); + final List data; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WeightChartPainter(data: data), + child: const SizedBox.expand(), + ); + } +} + +class _WeightChartPainter extends CustomPainter { + _WeightChartPainter({required this.data}); + final List data; + + final _axisColor = const Color(0x66000000); + final _lineColor = const Color(0xFF1565C0); + final _fillColor = const Color(0x331565C0); + + @override + void paint(Canvas canvas, Size size) { + final paddingLeft = 44.0; // Platz für Y-Labels + final paddingBottom = 28.0; // Platz für X-Labels + final paddingTop = 8.0; + final paddingRight = 12.0; + + final area = Rect.fromLTWH( + paddingLeft, + paddingTop, + size.width - paddingLeft - paddingRight, + size.height - paddingTop - paddingBottom, + ); + + // Achsen + final axisPaint = Paint() + ..color = _axisColor + ..strokeWidth = 1; + // Y-Achse links + canvas.drawLine( + Offset(area.left, area.top), Offset(area.left, area.bottom), axisPaint); + // X-Achse unten + canvas.drawLine(Offset(area.left, area.bottom), + Offset(area.right, area.bottom), axisPaint); + + if (data.isEmpty) return; + + // Wertebereiche + int minG = data.map((e) => e.gewicht).reduce(math.min); + int maxG = data.map((e) => e.gewicht).reduce(math.max); + if (minG == maxG) { + minG -= 1; + maxG += 1; + } + + final minT = data.first.datum.millisecondsSinceEpoch.toDouble(); + final maxT = data.last.datum.millisecondsSinceEpoch.toDouble(); + final spanT = (maxT - minT).abs() < 1 ? 1 : (maxT - minT); + final spanG = (maxG - minG).toDouble(); + + // Y-Ticks (5) + final yTicks = 4; + for (var i = 0; i <= yTicks; i++) { + final ty = i / yTicks; + final y = area.bottom - ty * area.height; + // Grid + canvas.drawLine(Offset(area.left, y), Offset(area.right, y), + axisPaint..color = _axisColor.withOpacity(0.35)); + // Label + final gVal = (minG + ty * spanG).round(); + _drawText(canvas, '${gVal} g', Offset(area.left - 6, y), + align: TextAlign.right, anchor: const Offset(1, 0.5)); + } + + // X-Labels: min / mid / max Datum + final dates = [minT, minT + spanT / 2, maxT]; + final dateStrings = dates.map((ms) { + final d = DateTime.fromMillisecondsSinceEpoch(ms.round()).toLocal(); + String two(int x) => x.toString().padLeft(2, '0'); + return '${two(d.day)}.${two(d.month)}.${d.year}'; + }).toList(); + for (var i = 0; i < dates.length; i++) { + final tx = (dates[i] - minT) / spanT; + final x = area.left + tx * area.width; + _drawText(canvas, dateStrings[i], Offset(x, area.bottom + 4), + align: TextAlign.center, anchor: const Offset(0.5, 0)); + } + + // Datenlinie + Fläche + final linePaint = Paint() + ..color = _lineColor + ..strokeWidth = 2 + ..style = PaintingStyle.stroke; + final fillPaint = Paint() + ..color = _fillColor + ..style = PaintingStyle.fill; + + final path = Path(); + final fill = Path(); + for (var i = 0; i < data.length; i++) { + final d = data[i]; + final tx = (d.datum.millisecondsSinceEpoch - minT) / spanT; + final ty = (d.gewicht - minG) / spanG; + final x = area.left + tx * area.width; + final y = area.bottom - ty * area.height; + + if (i == 0) { + path.moveTo(x, y); + fill.moveTo(x, area.bottom); + fill.lineTo(x, y); + } else { + path.lineTo(x, y); + fill.lineTo(x, y); + } + if (i == data.length - 1) { + fill.lineTo(x, area.bottom); + fill.close(); + } + } + + canvas.drawPath(fill, fillPaint); + canvas.drawPath(path, linePaint); + } + + void _drawText(Canvas canvas, String text, Offset pos, + {TextAlign align = TextAlign.left, Offset anchor = Offset.zero}) { + final span = TextSpan( + text: text, + style: const TextStyle(fontSize: 11, color: Colors.black87)); + final tp = TextPainter( + text: span, textAlign: align, textDirection: TextDirection.ltr); + tp.layout(); + final offset = + Offset(pos.dx - anchor.dx * tp.width, pos.dy - anchor.dy * tp.height); + tp.paint(canvas, offset); + } + + @override + bool shouldRepaint(covariant _WeightChartPainter old) { + if (old.data.length != data.length) return true; + for (var i = 0; i < data.length; i++) { + if (old.data[i].id != data[i].id || + old.data[i].gewicht != data[i].gewicht || + old.data[i].datum != data[i].datum) { + return true; + } + } + return false; + } +} diff --git a/lib/features/igel/presentation/igel_gallery_screen.dart b/lib/features/igel/presentation/igel_gallery_screen.dart new file mode 100644 index 0000000..45a97b7 --- /dev/null +++ b/lib/features/igel/presentation/igel_gallery_screen.dart @@ -0,0 +1,397 @@ +// lib/features/igel/presentation/igel_gallery_screen.dart +import 'dart:math' as math; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:http/http.dart' as http; + +import '../../../main.dart'; +import '../../igel/data/igel_images_repository.dart'; + +class IgelGalleryScreen extends ConsumerStatefulWidget { + const IgelGalleryScreen( + {super.key, required this.igelId, this.initialIndex = 0}); + final int igelId; + final int initialIndex; + + @override + ConsumerState createState() => _IgelGalleryState(); +} + +class _IgelGalleryState extends ConsumerState { + late final IgelImagesRepository repo; + List images = []; + bool busy = true; + String? err; + + late PageController pageC; + int currentIndex = 0; + + // Zoom-Handling + final TransformationController _tc = TransformationController(); + bool _isZoomed = false; + TapDownDetails? _doubleTapDetails; + + // UI Overlays (Titel/Buttons) ein-/ausblenden + bool _chromeVisible = true; + + @override + void initState() { + super.initState(); + repo = ref.read(igelImagesRepoProvider); + pageC = PageController(initialPage: widget.initialIndex); + currentIndex = widget.initialIndex; + + // Nur auf Mobile/Desktop echte Immersion + if (!kIsWeb) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + SystemChrome.setPreferredOrientations(const [ + DeviceOrientation.portraitUp, + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + } + + _tc.addListener(_onMatrixChanged); + _load(); + } + + @override + void dispose() { + _tc.removeListener(_onMatrixChanged); + _tc.dispose(); + pageC.dispose(); + + if (!kIsWeb) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + SystemChrome.setPreferredOrientations(DeviceOrientation.values); + } + super.dispose(); + } + + void _onMatrixChanged() { + final m = _tc.value; + final zoomedNow = !_matrixIsIdentity(m); + if (zoomedNow != _isZoomed && mounted) { + setState(() => _isZoomed = zoomedNow); + } + } + + bool _matrixIsIdentity(Matrix4 m, {double eps = 1e-3}) { + // Tolerante Prüfung auf Identität + final s = m.storage; + bool eq(double a, double b) => (a - b).abs() <= eps; + return eq(s[0], 1) && + eq(s[5], 1) && + eq(s[10], 1) && + eq(s[15], 1) && + eq(s[1], 0) && + eq(s[2], 0) && + eq(s[3], 0) && + eq(s[4], 0) && + eq(s[6], 0) && + eq(s[7], 0) && + eq(s[8], 0) && + eq(s[9], 0) && + eq(s[11], 0) && + eq(s[12], 0) && + eq(s[13], 0) && + eq(s[14], 0); + } + + Future _load() async { + setState(() { + busy = true; + err = null; + }); + try { + images = await repo.list(widget.igelId); + + // initialIndex clampen + final newIndex = + images.isEmpty ? 0 : math.min(widget.initialIndex, images.length - 1); + if (newIndex != currentIndex) { + currentIndex = newIndex; + pageC = PageController(initialPage: currentIndex); + } + + if (mounted && images.isNotEmpty) { + await _prefetchAround(currentIndex); + } + } catch (e) { + err = e.toString(); + } finally { + if (mounted) setState(() => busy = false); + } + } + + Future _prefetchAround(int index) async { + if (!mounted || images.isEmpty) return; + Future pre(String url) async { + try { + await precacheImage(NetworkImage(url), context); + } catch (_) {} + } + + await pre(images[index].url); + if (index - 1 >= 0) await pre(images[index - 1].url); + if (index + 1 < images.length) await pre(images[index + 1].url); + } + + void _onPageChanged(int i) async { + setState(() { + currentIndex = i; + _tc.value = Matrix4.identity(); + _isZoomed = false; + }); + await _prefetchAround(i); + } + + void _onDoubleTap() { + // Toggle Zoom (2x) um Tap-Position + if (!_isZoomed && _tc.value == Matrix4.identity()) { + final pos = _doubleTapDetails?.localPosition ?? const Offset(0, 0); + const scale = 2.0; + final z = Matrix4.identity() + ..translate(-pos.dx * (scale - 1), -pos.dy * (scale - 1)) + ..scale(scale); + _tc.value = z; + } else { + _tc.value = Matrix4.identity(); + } + } + + Future _deleteCurrent() async { + if (images.isEmpty) return; + final img = images[currentIndex]; + final ok = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Bild löschen?'), + content: const Text('Dieses Bild wirklich löschen?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Abbrechen')), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Löschen')), + ], + ), + ) ?? + false; + if (!ok) return; + + try { + await repo.delete(img.id); + final oldIndex = currentIndex; + await _load(); + if (images.isEmpty) { + if (mounted) context.pop(); + return; + } + final nextIndex = oldIndex.clamp(0, images.length - 1); + pageC.jumpToPage(nextIndex); + _onPageChanged(nextIndex); + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Bild gelöscht'))); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Löschen fehlgeschlagen: $e'))); + } + } + } + + Future _downloadCurrent() async { + if (images.isEmpty) return; + final url = images[currentIndex].url; + if (kIsWeb) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Im Web per Rechtsklick/Neuer Tab speichern.')), + ); + return; + } + try { + final res = await http.get(Uri.parse(url)); + if (res.statusCode == 200) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Bild heruntergeladen (im Speicher).')), + ); + } else { + throw Exception('HTTP ${res.statusCode}'); + } + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Download fehlgeschlagen: $e')), + ); + } + } + + void _toggleChrome() => setState(() => _chromeVisible = !_chromeVisible); + + @override + Widget build(BuildContext context) { + final total = images.length; + final canSwipe = !_isZoomed; // Wischen nur, wenn nicht gezoomt + + return Scaffold( + backgroundColor: Colors.black, + // keine AppBar -> echtes Fullscreen. Overlays bauen wir selbst. + body: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _toggleChrome, // Overlay ein-/ausblenden + child: SafeArea( + // SafeArea, damit Notch nicht überlagert; wir blenden UI überlagert ein. + child: Stack( + children: [ + // Seiten + if (busy) + const Center(child: CircularProgressIndicator()) + else if (err != null) + Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: + Text(err!, style: const TextStyle(color: Colors.white)), + ), + ) + else if (total == 0) + const Center( + child: Text('Keine Bilder', + style: TextStyle(color: Colors.white70))) + else + PageView.builder( + controller: pageC, + onPageChanged: _onPageChanged, + physics: canSwipe + ? const PageScrollPhysics() + : const NeverScrollableScrollPhysics(), + itemCount: total, + itemBuilder: (_, i) { + final img = images[i]; + final full = img.url; + return Listener( + // Maus/Touch-Events nicht "schlucken", wenn nicht gezoomt, damit PageView scrollt + behavior: HitTestBehavior.deferToChild, + child: Center( + child: GestureDetector( + onTapDown: (d) => _doubleTapDetails = d, + onDoubleTap: _onDoubleTap, + child: InteractiveViewer( + transformationController: _tc, + minScale: 1, + maxScale: 4, + panEnabled: _isZoomed, // Pan nur im Zoom + scaleEnabled: true, + clipBehavior: Clip.none, + child: Image.network( + full, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => const Icon( + Icons.broken_image, + color: Colors.white70, + size: 64), + ), + ), + ), + ), + ); + }, + ), + + // Top-Bar (Back, Index, Löschen) – ein-/ausblendbar + AnimatedPositioned( + duration: const Duration(milliseconds: 180), + top: _chromeVisible ? 0 : -80, + left: 0, + right: 0, + child: Container( + color: const Color(0x66000000), + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 6), + child: Row( + children: [ + IconButton( + color: Colors.white, + icon: const Icon(Icons.arrow_back), + onPressed: () { + final r = GoRouter.of(context); + if (r.canPop()) { + context.pop(); + } else { + context.go('/igel/${widget.igelId}'); + } + }, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + total == 0 + ? 'Galerie' + : '${currentIndex + 1} / $total', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600), + ), + ), + IconButton( + tooltip: 'Löschen', + color: Colors.white, + onPressed: total == 0 ? null : _deleteCurrent, + icon: const Icon(Icons.delete_outline), + ), + ], + ), + ), + ), + + // Bottom-Bar (Dateiname + Download) – ein-/ausblendbar + AnimatedPositioned( + duration: const Duration(milliseconds: 180), + bottom: _chromeVisible ? 0 : -72, + left: 0, + right: 0, + child: SafeArea( + top: false, + child: Container( + color: const Color(0x66000000), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + Expanded( + child: Text( + total == 0 + ? '' + : (images[currentIndex].originalName ?? ''), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white70), + ), + ), + const SizedBox(width: 8), + IconButton( + tooltip: 'Download', + color: Colors.white, + onPressed: total == 0 ? null : _downloadCurrent, + icon: const Icon(Icons.download), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/igel/presentation/igel_list_screen.dart b/lib/features/igel/presentation/igel_list_screen.dart index d861243..f0204e9 100644 --- a/lib/features/igel/presentation/igel_list_screen.dart +++ b/lib/features/igel/presentation/igel_list_screen.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; + import '../../../main.dart'; import '../../igel/data/igel_repository.dart'; import '../domain/igel.dart'; @@ -9,25 +10,30 @@ import '../domain/igel.dart'; class IgelListScreen extends ConsumerStatefulWidget { const IgelListScreen({super.key}); @override - ConsumerState createState() => _State(); + ConsumerState createState() => _IgelListState(); } -class _State extends ConsumerState { +class _IgelListState extends ConsumerState { late final IgelRepository repo; + List items = []; bool busy = true; String? err; + // Suche final searchC = TextEditingController(); String query = ''; Timer? _debounce; + // Inline-Form bool showForm = false; final nameC = TextEditingController(); - final noteC = TextEditingController(); + final noteC = + TextEditingController(); // Notiz bleibt im Edit/Erstellen, wird aber NICHT in der Liste gezeigt final featureC = TextEditingController(); - String? gender; + String? gender; // 'männlich' | 'weiblich' | 'unbekannt' | null (keine Angabe) + // Undo Igel? _lastDeleted; @override @@ -97,7 +103,7 @@ class _State extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(msg), - action: onAction != null && actionLabel != null + action: (onAction != null && actionLabel != null) ? SnackBarAction(label: actionLabel, onPressed: onAction) : null, ), @@ -146,6 +152,7 @@ class _State extends ConsumerState { child: CustomScrollView( physics: const AlwaysScrollableScrollPhysics(), slivers: [ + // Suche SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(12, 12, 12, 4), @@ -159,6 +166,7 @@ class _State extends ConsumerState { ), ), ), + // Neu-Button SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric( @@ -176,6 +184,7 @@ class _State extends ConsumerState { ), ), ), + // Inline-Form if (showForm) SliverToBoxAdapter( child: Padding( @@ -266,6 +275,7 @@ class _State extends ConsumerState { ), ), ), + // Liste SliverList.separated( itemCount: visible.length, separatorBuilder: (_, __) => const Divider(height: 1), @@ -274,6 +284,8 @@ class _State extends ConsumerState { return _IgelTile( key: ValueKey('igel-${x.id}'), igel: x, + onTap: () => + context.go('/igel/${x.id}'), // Detailseite onEditAll: (result) async { await repo.update( x.id, @@ -329,11 +341,13 @@ class _IgelTile extends StatelessWidget { const _IgelTile({ super.key, required this.igel, + required this.onTap, required this.onEditAll, required this.onDelete, }); final Igel igel; + final VoidCallback onTap; final Future Function(_IgelEditResult updated) onEditAll; final Future Function() onDelete; @@ -375,6 +389,7 @@ class _IgelTile extends StatelessWidget { ?.copyWith(fontWeight: FontWeight.w600); return ListTile( + onTap: onTap, leading: CircleAvatar( child: Text(igel.name.isNotEmpty ? igel.name[0].toUpperCase() : '?')), title: Row( @@ -437,6 +452,8 @@ class _IgelTile extends StatelessWidget { } } +// ----- Edit-Dialog: Name, Geschlecht (optional), Merkmal, Notiz -------------- + class _IgelEditResult { final String name; final String? gender; diff --git a/lib/features/messwerte/data/messwerte_repository.dart b/lib/features/messwerte/data/messwerte_repository.dart new file mode 100644 index 0000000..1514042 --- /dev/null +++ b/lib/features/messwerte/data/messwerte_repository.dart @@ -0,0 +1,91 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +import '../../../main.dart'; // kApiBase +import '../../auth/data/token_storage.dart'; // tokenStorageProvider +import '../domain/messwert.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class MesswerteRepository { + MesswerteRepository(this._tokens); + final TokenStorage _tokens; + + Future> list(int igelId) async { + final t = await _tokens.access; + final r = await http.get( + Uri.parse('$kApiBase/igel/$igelId/messwerte'), + headers: {'Authorization': 'Bearer $t'}, + ); + if (r.statusCode != 200) { + throw Exception('API ${r.statusCode}: ${r.body}'); + } + final List data = jsonDecode(r.body) as List; + return data + .map((e) => Messwert.fromMap(e as Map)) + .toList(); + } + + Future create({ + required int igelId, + required DateTime datum, + required int gewicht, + String? behandlung, + String? bemerkung, + }) async { + final t = await _tokens.access; + final r = await http.post( + Uri.parse('$kApiBase/igel/$igelId/messwerte'), + headers: { + 'Authorization': 'Bearer $t', + 'Content-Type': 'application/json', + }, + body: jsonEncode({ + 'datum': datum.toUtc().toIso8601String(), + 'gewicht': gewicht, + 'behandlung': behandlung, + 'bemerkung': bemerkung, + }), + ); + if (r.statusCode != 201) { + throw Exception('API ${r.statusCode}: ${r.body}'); + } + } + + Future update(int id, Messwert m) async { + final t = await _tokens.access; + final r = await http.put( + Uri.parse('$kApiBase/messwerte/$id'), + headers: { + 'Authorization': 'Bearer $t', + 'Content-Type': 'application/json', + }, + body: jsonEncode({ + // Wichtig: KEIN 'id' im Body mitschicken – der steht bereits in der URL! + 'datum': m.datum.toUtc().toIso8601String(), + 'gewicht': m.gewicht, + 'behandlung': m.behandlung, + 'bemerkung': m.bemerkung, + }), + ); + if (r.statusCode != 200) { + throw Exception('API ${r.statusCode}: ${r.body}'); + } + } + + Future delete(int id) async { + final t = await _tokens.access; + final r = await http.delete( + Uri.parse('$kApiBase/messwerte/$id'), + headers: {'Authorization': 'Bearer $t'}, + ); + if (r.statusCode != 200) { + throw Exception('API ${r.statusCode}: ${r.body}'); + } + } +} + +// Riverpod Provider +final messwerteRepoProvider = Provider((ref) { + final tokens = ref.read(tokenStorageProvider); + return MesswerteRepository(tokens); +}); diff --git a/lib/features/messwerte/domain/messwert.dart b/lib/features/messwerte/domain/messwert.dart new file mode 100644 index 0000000..46dc403 --- /dev/null +++ b/lib/features/messwerte/domain/messwert.dart @@ -0,0 +1,47 @@ +class Messwert { + final int id; + final DateTime datum; + final int gewicht; + final String? behandlung; + final String? bemerkung; + + Messwert({ + required this.id, + required this.datum, + required this.gewicht, + this.behandlung, + this.bemerkung, + }); + + factory Messwert.fromMap(Map m) => Messwert( + id: (m['id'] as num).toInt(), + datum: DateTime.parse(m['datum'] as String).toLocal(), + gewicht: (m['gewicht'] as num).toInt(), + behandlung: m['behandlung'] as String?, + bemerkung: m['bemerkung'] as String?, + ); + + Map toMap() => { + 'id': id, + 'datum': datum.toIso8601String(), + 'gewicht': gewicht, + 'behandlung': behandlung, + 'bemerkung': bemerkung, + }; + + Messwert copyWith({ + int? id, + DateTime? datum, + int? gewicht, + String? behandlung, + String? bemerkung, + }) { + return Messwert( + id: id ?? this.id, + datum: datum ?? this.datum, + gewicht: gewicht ?? this.gewicht, + behandlung: behandlung ?? this.behandlung, + bemerkung: bemerkung ?? this.bemerkung, + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 7e306a9..992a752 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import 'app_router.dart'; import 'features/auth/data/token_storage.dart'; import 'shared/api_client.dart'; import 'features/igel/data/igel_repository.dart'; +import 'features/igel/data/igel_images_repository.dart'; const kApiBase = String.fromEnvironment( 'API_BASE', @@ -20,6 +21,12 @@ final apiClientProvider = Provider((ref) => ApiClient( )); final igelRepoProvider = Provider( (ref) => IgelRepository(ref.read(apiClientProvider))); +final igelImagesRepoProvider = + Provider((ref) => IgelImagesRepository( + ref.read(apiClientProvider), + getAccessToken: () => + ref.read(tokenStorageProvider).getValidAccessToken(), + )); void main() { runApp(const ProviderScope(child: IgelApp())); diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index d0e7f79..85a2413 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,9 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index b29e9ba..62e3ed5 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux flutter_secure_storage_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 15a1671..5d35054 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,10 +5,12 @@ import FlutterMacOS import Foundation +import file_selector_macos import flutter_secure_storage_macos import path_provider_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 987522a..8bb26cc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -150,6 +150,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" crypto: dependency: transitive description: @@ -182,6 +190,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33" + url: "https://pub.dev" + source: hosted + version: "0.9.3+2" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "271ab9986df0c135d45c3cdb6bd0faa5db6f4976d3e4b437cf7d0f258d941bfc" + url: "https://pub.dev" + source: hosted + version: "0.9.4+2" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b + url: "https://pub.dev" + source: hosted + version: "2.6.2" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b" + url: "https://pub.dev" + source: hosted + version: "0.9.3+4" fixnum: dependency: transitive description: @@ -195,6 +235,22 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "1c2b787f99bdca1f3718543f81d38aa1b124817dfeb9fb196201bea85b6134bf" + url: "https://pub.dev" + source: hosted + version: "2.0.26" flutter_riverpod: dependency: "direct main" description: @@ -312,6 +368,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "021834d9c0c3de46bf0fe40341fa07168407f694d9b2bb18d532dc1261867f7a" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "82652a75e3dd667a91187769a6a2cc81bd8c111bbead698d8e938d2b63e5e89a" + url: "https://pub.dev" + source: hosted + version: "0.8.12+21" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "717eb042ab08c40767684327be06a5d8dbb341fe791d514e4b92c7bbe1b7bb83" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: "05da758e67bc7839e886b3959848aa6b44ff123ab4b28f67891008afe8ef9100" + url: "https://pub.dev" + source: hosted + version: "0.8.12+2" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "34a65f6740df08bbbeb0a1abd8e6d32107941fd4868f67a507b25601651022c9" + url: "https://pub.dev" + source: hosted + version: "0.2.1+2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "1b90ebbd9dcf98fb6c1d01427e49a55bd96b5d67b8c67cf955d60a5de74207c1" + url: "https://pub.dev" + source: hosted + version: "0.2.1+2" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "886d57f0be73c4b140004e78b9f28a8914a09e50c2d816bdd0520051a71236a0" + url: "https://pub.dev" + source: hosted + version: "2.10.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" io: dependency: transitive description: @@ -344,6 +464,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.9.0" + lints: + dependency: transitive + description: + name: lints + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + url: "https://pub.dev" + source: hosted + version: "3.0.0" logging: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 873d8ed..1cefcb6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,10 +10,12 @@ dependencies: http: ^1.2.2 flutter_secure_storage: ^9.2.2 json_annotation: ^4.9.0 + image_picker: ^1.0.7 dev_dependencies: build_runner: ^2.4.11 json_serializable: ^6.9.0 + flutter_lints: ^3.0.2 flutter: uses-material-design: true \ No newline at end of file diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 0c50753..b53f20e 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,9 +6,12 @@ #include "generated_plugin_registrant.h" +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 4fc759c..2b9f993 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows flutter_secure_storage_windows )