diff --git a/api/hedgehogs.php b/api/hedgehogs.php index 7348e88..f0fe9e2 100644 --- a/api/hedgehogs.php +++ b/api/hedgehogs.php @@ -76,6 +76,14 @@ try { if ($method === 'DELETE') { return messwerte_delete($pdo, $uid, $mid); } } + // --- Einzelnes Bild löschen (TOP-LEVEL!) ----------------------------------- + // /images/{imgId} → DELETE + if (preg_match('#^/images/(\d+)$#', $path, $m)) { + $uid = require_user($pdo); + $imgId = (int)$m[1]; + if ($method === 'DELETE') { return igel_images_delete($pdo, $uid, $imgId); } + } + // --- Igel + Unterressourcen ----------------------------------------------- if (str_starts_with($path, '/igel')) { $uid = require_user($pdo); @@ -99,12 +107,6 @@ try { if ($method === 'POST') { return igel_images_upload($pdo, $uid, $igId); } } - // /images/{imgId} - if (preg_match('#^/images/(\d+)$#', $path, $m)) { - $imgId = (int)$m[1]; - if ($method === 'DELETE') { return igel_images_delete($pdo, $uid, $imgId); } - } - // /igel/{id}/messwerte (Liste + Neu) if (preg_match('#^/igel/(\d+)/messwerte$#', $path, $m)) { $igId = (int)$m[1]; @@ -361,7 +363,7 @@ function igel_images_upload(PDO $pdo, int $uid, int $igId): void { $id = (int)$pdo->lastInsertId(); - // created_at aus DB holen (für Response, damit Frontend sofort beides hat) + // created_at/taken_at für Response aus DB holen $row = $pdo->prepare('SELECT created_at, taken_at FROM igel_images WHERE id=?'); $row->execute([$id]); $times = $row->fetch(PDO::FETCH_ASSOC) ?: ['created_at'=>null,'taken_at'=>null]; @@ -665,9 +667,11 @@ CREATE TABLE igel_images ( original_name VARCHAR(255) NULL, mime VARCHAR(100) NULL, size_bytes BIGINT UNSIGNED NULL, + taken_at DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (igel_id) REFERENCES igel(id) ON DELETE CASCADE, - INDEX (igel_id) + INDEX (igel_id), + INDEX (taken_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE messwerte ( diff --git a/lib/features/igel/presentation/igel_detail_screen.dart b/lib/features/igel/presentation/igel_detail_screen.dart index cb37b8c..d6f9861 100644 --- a/lib/features/igel/presentation/igel_detail_screen.dart +++ b/lib/features/igel/presentation/igel_detail_screen.dart @@ -1,7 +1,6 @@ // lib/features/igel/presentation/igel_detail_screen.dart import 'dart:io'; import 'dart:math' as math; -import 'package:exif/exif.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -57,6 +56,11 @@ class _IgelDetailState extends ConsumerState { final mwBehandlungC = TextEditingController(); final mwBemerkungC = TextEditingController(); + // Upload-Overlay + bool _uploading = false; + int _uploadDone = 0; + int _uploadTotal = 0; + @override void initState() { super.initState(); @@ -181,22 +185,23 @@ class _IgelDetailState extends ConsumerState { ); if (picks.isEmpty) return; + setState(() { + _uploading = true; + _uploadDone = 0; + _uploadTotal = picks.length; + }); + final files = []; - final taken = []; for (final x in picks) { - // 1) EXIF-Aufnahmezeit lesen - final exifDt = await _readExifTakenAt(x); - taken.add(exifDt); - - // 2) Datei-Part bauen (Name MUSS 'files[]' sein; Typ aus Dateiendung) + // Datei-Part bauen (Name MUSS 'files[]' sein) if (kIsWeb) { final bytes = await x.readAsBytes(); files.add(http.MultipartFile.fromBytes( 'files[]', bytes, filename: x.name, - contentType: _mimeFromName(x.name), // nutzt deine bestehende Helper + contentType: _mimeFromName(x.name), )); } else { files.add(await http.MultipartFile.fromPath( @@ -205,23 +210,23 @@ class _IgelDetailState extends ConsumerState { contentType: _mimeFromName(x.name), )); } + setState(() => _uploadDone++); } - // 3) Upload + taken_at[] mitgeben - await imagesRepo.upload( - widget.igelId, - files, - takenAt: taken, // <- entscheidend - ); + // Kein takenAt mitsenden – Server extrahiert EXIF und setzt taken_at + await imagesRepo.upload(widget.igelId, files); - // 4) Liste neu laden & UI aktualisieren images = await imagesRepo.list(widget.igelId); if (mounted) { - setState(() {}); + setState(() { + _uploadDone = _uploadTotal; + _uploading = false; + }); await _prefetchImages(context); } _snack('Bilder hochgeladen'); } catch (e) { + if (mounted) setState(() => _uploading = false); _snack('Upload fehlgeschlagen: $e'); } } @@ -259,52 +264,15 @@ class _IgelDetailState extends ConsumerState { MediaType? _mimeFromName(String name) { final lower = name.toLowerCase(); - if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) + 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; } - Future _readExifTakenAt(XFile x) async { - try { - // Bytes laden (Web & Mobile kompatibel) - final bytes = await x.readAsBytes(); - final tags = await readExifFromBytes(bytes); - - // Reihenfolge: Original -> Digitized -> Image DateTime (Fallback) - final raw = tags['EXIF DateTimeOriginal']?.printable ?? - tags['EXIF DateTimeDigitized']?.printable ?? - tags['Image DateTime']?.printable; - - if (raw is String && raw.isNotEmpty) { - // EXIF-Format: "YYYY:MM:DD HH:MM:SS" - // -> normalisieren auf "YYYY-MM-DDTHH:MM:SS" - // erste zwei ':' in Datum durch '-' ersetzen, Space zu 'T' - var s = raw; - // die ersten beiden ':' ersetzen (Jahr:Monat:Tag) - final first = s.indexOf(':'); - if (first > 0) { - final second = s.indexOf(':', first + 1); - if (second > 0) { - s = s.substring(0, first) + - '-' + - s.substring(first + 1, second) + - '-' + - s.substring(second + 1); - } - } - s = s.replaceFirst(' ', 'T'); - // jetzt sollte es parsebar sein - return DateTime.tryParse(s); - } - } catch (_) { - // EXIF fehlt oder nicht lesbar → null - } - return null; - } - IconData? _genderIcon(String? g) { switch (g) { case 'männlich': @@ -551,373 +519,432 @@ class _IgelDetailState extends ConsumerState { ), ], ), - 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( + body: Stack( + children: [ + 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( - mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + CrossAxisAlignment.start, 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'), + 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), + ), ), ], ), ), ), - ) - else - Card( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.stretch, - children: [ - // --- Header-Zeile: Button OBERHALB der Thumbnails --- - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Bilder', - style: Theme.of(context) - .textTheme - .titleMedium, - ), - OutlinedButton.icon( - onPressed: _pickAndUpload, - icon: const Icon(Icons.add_a_photo), - label: const Text('Bilder hochladen'), - ), - ], - ), - const SizedBox(height: 8), - // --- Grid mit kleineren Thumbnails + Datum --- - GridView.builder( - physics: - const NeverScrollableScrollPhysics(), - shrinkWrap: true, - gridDelegate: - const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: - 120, // ~120px Kachelbreite -> dezent - mainAxisSpacing: 8, - crossAxisSpacing: 8, - childAspectRatio: - 0.85, // Platz für Datum unter Bild + 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)), + ], ), - itemCount: images.length, - itemBuilder: (_, i) { - final img = images[i]; - final thumb = img.thumbUrl ?? img.url; + const SizedBox(height: 8), + SizedBox( + height: 220, + child: WeightChart(data: chartData), + ), + ], + ), + ), + ), - final String? ts = (() { - final dt = - img.takenAt ?? img.createdAt; - final String? ts = dt != null - ? DateFormat('dd.MM.yyyy, HH:mm') - .format(dt) - : null; - if (dt == null) return null; - // z.B. 23.10.2025, 14:05 - return DateFormat('dd.MM.yyyy, HH:mm') - .format(dt); - })(); + const SizedBox(height: 12), - return GestureDetector( - onTap: () => context.push( - '/igel/${widget.igelId}/gallery?index=$i'), - onLongPress: () => _deleteImage(img), + // ----- 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( - crossAxisAlignment: - CrossAxisAlignment.stretch, children: [ - Expanded( - child: Hero( - tag: 'igimg-${img.id}', - child: ClipRRect( - borderRadius: - BorderRadius.circular( - 10), - child: Image.network( - thumb, - fit: BoxFit.cover, - errorBuilder: - (_, __, ___) => + 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: Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + // Header mit Button OBERHALB der Thumbs + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Bilder', + style: Theme.of(context) + .textTheme + .titleMedium, + ), + OutlinedButton.icon( + onPressed: _pickAndUpload, + icon: + const Icon(Icons.add_a_photo), + label: const Text( + 'Bilder hochladen'), + ), + ], + ), + const SizedBox(height: 8), + + // Thumbs kleiner + Datum/Uhrzeit (takenAt bevorzugt) + GridView.builder( + physics: + const NeverScrollableScrollPhysics(), + shrinkWrap: true, + gridDelegate: + const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 120, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 0.85, + ), + itemCount: images.length, + itemBuilder: (_, i) { + final img = images[i]; + final thumb = + img.thumbUrl ?? img.url; + + final String? ts = (() { + final dt = + img.takenAt ?? img.createdAt; + if (dt == null) return null; + return DateFormat( + 'dd.MM.yyyy, HH:mm') + .format(dt); + })(); + + return GestureDetector( + onTap: () async { + final res = await context.push( + '/igel/${widget.igelId}/gallery?index=$i', + ); + if (res == true) { + images = await imagesRepo + .list(widget.igelId); + if (mounted) { + setState(() {}); + } + } + }, + onLongPress: () => + _deleteImage(img), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Hero( + tag: 'igimg-${img.id}', + child: ClipRRect( + borderRadius: + BorderRadius + .circular(10), + child: Image.network( + thumb, + fit: BoxFit.cover, + errorBuilder: (_, __, + ___) => const ColoredBox( - color: - Color(0x11000000), - child: Center( - child: Icon(Icons - .broken_image)), + color: Color( + 0x11000000), + child: Center( + child: Icon(Icons + .broken_image), + ), + ), + ), ), ), ), - ), + const SizedBox(height: 4), + Text( + ts ?? '–', + maxLines: 1, + overflow: + TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context) + .textTheme + .labelSmall, + ), + ], ), - const SizedBox(height: 4), - Text( - ts ?? '–', - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: Theme.of(context) - .textTheme - .labelSmall, - ), - ], - ), - ); - }, + ); + }, + ), + ], ), - ], + ), ), - ), - ), - ], - ), + ], + ), + ), + + // Upload-Overlay + if (_uploading) + Container( + color: const Color(0x66000000), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 60, + height: 60, + child: CircularProgressIndicator(strokeWidth: 4), ), + const SizedBox(height: 12), + Text( + _uploadTotal <= 1 + ? 'Lade Bild hoch …' + : 'Lade Bilder hoch (${_uploadDone}/${_uploadTotal}) …', + style: const TextStyle(color: Colors.white, fontSize: 16), + ), + ], + ), + ), + ), + ], + ), ); } } @@ -1161,8 +1188,11 @@ class _WeightChartPainter extends CustomPainter { 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)); + 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), @@ -1228,7 +1258,7 @@ class _WeightChartPainter extends CustomPainter { final tp = TextPainter( text: span, textAlign: align, - textDirection: ui.TextDirection.ltr, // ✅ korrekte Schreibweise + textDirection: ui.TextDirection.ltr, ); tp.layout(); final offset = diff --git a/lib/features/igel/presentation/igel_gallery_screen.dart b/lib/features/igel/presentation/igel_gallery_screen.dart index 50653bd..1594b14 100644 --- a/lib/features/igel/presentation/igel_gallery_screen.dart +++ b/lib/features/igel/presentation/igel_gallery_screen.dart @@ -1,19 +1,38 @@ // lib/features/igel/presentation/igel_gallery_screen.dart import 'dart:math' as math; +import 'dart:ui' show PointerDeviceKind; 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 'package:image_picker/image_picker.dart'; +import 'package:http_parser/http_parser.dart' show MediaType; +import 'package:intl/intl.dart'; import '../../../main.dart'; import '../../igel/data/igel_images_repository.dart'; import '../domain/igel_image.dart'; +class _GalleryScrollBehavior extends MaterialScrollBehavior { + @override + Set get dragDevices => { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + PointerDeviceKind.stylus, + PointerDeviceKind.trackpad, + PointerDeviceKind.unknown, + }; +} + class IgelGalleryScreen extends ConsumerStatefulWidget { - const IgelGalleryScreen( - {super.key, required this.igelId, this.initialIndex = 0}); + const IgelGalleryScreen({ + super.key, + required this.igelId, + this.initialIndex = 0, + }); + final int igelId; final int initialIndex; @@ -27,25 +46,34 @@ class _IgelGalleryState extends ConsumerState { bool busy = true; String? err; - late PageController pageC; + // nur EIN Controller, niemals ersetzen! + late final PageController pageC; int currentIndex = 0; - // Zoom-Handling + // Zoom final TransformationController _tc = TransformationController(); bool _isZoomed = false; TapDownDetails? _doubleTapDetails; - // UI Overlays (Titel/Buttons) ein-/ausblenden + // UI Overlays (Titel/Buttons) bool _chromeVisible = true; + // Änderungen melden? + bool _changed = false; + + // Upload-Overlay + bool _uploading = false; + int _uploadDone = 0; + int _uploadTotal = 0; + @override void initState() { super.initState(); repo = ref.read(igelImagesRepoProvider); - pageC = PageController(initialPage: widget.initialIndex); - currentIndex = widget.initialIndex; - // Nur auf Mobile/Desktop echte Immersion + currentIndex = widget.initialIndex; + pageC = PageController(initialPage: currentIndex); + if (!kIsWeb) { SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); SystemChrome.setPreferredOrientations(const [ @@ -81,7 +109,6 @@ class _IgelGalleryState extends ConsumerState { } 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) && @@ -108,19 +135,26 @@ class _IgelGalleryState extends ConsumerState { err = null; }); try { - images = await repo.list(widget.igelId); + final list = await repo.list(widget.igelId); + images = list; - // initialIndex clampen - final newIndex = - images.isEmpty ? 0 : math.min(widget.initialIndex, images.length - 1); - if (newIndex != currentIndex) { - currentIndex = newIndex; - pageC = PageController(initialPage: currentIndex); + // index clampen – Controller NICHT ersetzen! + if (images.isEmpty) { + currentIndex = 0; + } else { + currentIndex = math.min(currentIndex, images.length - 1); } if (mounted && images.isNotEmpty) { await _prefetchAround(currentIndex); } + + // Nach Build ggf. zur aktuellen Seite springen + WidgetsBinding.instance.addPostFrameCallback((_) { + if (pageC.hasClients && images.isNotEmpty) { + pageC.jumpToPage(currentIndex); + } + }); } catch (e) { err = e.toString(); } finally { @@ -151,7 +185,6 @@ class _IgelGalleryState extends ConsumerState { } 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; @@ -164,6 +197,20 @@ class _IgelGalleryState extends ConsumerState { } } + void _next() { + if (currentIndex < images.length - 1 && pageC.hasClients) { + pageC.nextPage( + duration: const Duration(milliseconds: 160), curve: Curves.easeOut); + } + } + + void _prev() { + if (currentIndex > 0 && pageC.hasClients) { + pageC.previousPage( + duration: const Duration(milliseconds: 160), curve: Curves.easeOut); + } + } + Future _deleteCurrent() async { if (images.isEmpty) return; final img = images[currentIndex]; @@ -187,15 +234,34 @@ class _IgelGalleryState extends ConsumerState { try { await repo.delete(img.id); + + // lokal entfernen & NACH dem Rebuild springen final oldIndex = currentIndex; - await _load(); - if (images.isEmpty) { - if (mounted) context.pop(); + final wasLast = images.length == 1; + + setState(() { + images.removeAt(oldIndex); + _tc.value = Matrix4.identity(); + _isZoomed = false; + }); + + _changed = true; + + if (wasLast) { + if (mounted) context.pop(true); return; } - final nextIndex = oldIndex.clamp(0, images.length - 1); - pageC.jumpToPage(nextIndex); - _onPageChanged(nextIndex); + + final nextIndex = math.max(0, math.min(oldIndex, images.length - 1)); + currentIndex = nextIndex; + + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (pageC.hasClients) { + pageC.jumpToPage(nextIndex); + } + await _prefetchAround(nextIndex); + }); + if (mounted) { ScaffoldMessenger.of(context) .showSnackBar(const SnackBar(content: Text('Bild gelöscht'))); @@ -228,168 +294,342 @@ class _IgelGalleryState extends ConsumerState { throw Exception('HTTP ${res.statusCode}'); } } catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Download fehlgeschlagen: $e')), + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Download fehlgeschlagen: $e'))); + } + } + + // --- Upload in der Galerie ------------------------------------------------- + + 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; + } + + String _fmtDateTime(DateTime dt) { + final d = dt.toLocal(); + return DateFormat('dd.MM.yyyy, HH:mm').format(d); + } + + Future _pickAndUpload() async { + try { + final picker = ImagePicker(); + final picks = await picker.pickMultiImage( + maxWidth: 4096, + maxHeight: 4096, + imageQuality: 90, ); + if (picks.isEmpty) return; + + setState(() { + _uploading = true; + _uploadDone = 0; + _uploadTotal = picks.length; + }); + + final files = []; + // Erwartet: List? → wir füllen (noch) mit nulls + final takenAt = []; + + 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[]', x.path)); + } + + // Falls du später EXIF ausliest, hier DateTime setzen. + takenAt.add(null); + } + + // Upload (Parameter-Typ passt jetzt: List) + await repo.upload(widget.igelId, files, takenAt: takenAt); + + // Nach Upload neu laden + final list = await repo.list(widget.igelId); + images = list; + _changed = true; + + currentIndex = images.isNotEmpty ? images.length - 1 : 0; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (pageC.hasClients && images.isNotEmpty) { + pageC.jumpToPage(currentIndex); + } + }); + + if (mounted) { + setState(() { + _uploadDone = _uploadTotal; + _uploading = false; + }); + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Bilder hochgeladen'))); + } + } catch (e) { + if (mounted) { + setState(() => _uploading = false); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Upload fehlgeschlagen: $e'))); + } } } void _toggleChrome() => setState(() => _chromeVisible = !_chromeVisible); + void _popWithResult() => context.pop(_changed); @override Widget build(BuildContext context) { final total = images.length; - final canSwipe = !_isZoomed; // Wischen nur, wenn nicht gezoomt + final canSwipe = !_isZoomed; - 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), + // Datum/Uhrzeit für Footer (takenAt > createdAt) + String footerText() { + if (total == 0) return ''; + final img = images[currentIndex]; + final dt = img.takenAt ?? img.createdAt; + if (dt == null) return ''; + return _fmtDateTime(dt); + } + + return WillPopScope( + onWillPop: () async { + _popWithResult(); + return false; + }, + child: Scaffold( + backgroundColor: Colors.black, + body: Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.arrowRight): ActivateIntent(), + SingleActivator(LogicalKeyboardKey.arrowLeft): DismissIntent(), + }, + child: Actions( + actions: >{ + ActivateIntent: CallbackAction(onInvoke: (_) { + _next(); + return null; + }), + DismissIntent: CallbackAction(onInvoke: (_) { + _prev(); + return null; + }), + }, + child: Focus( + autofocus: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _toggleChrome, + child: SafeArea( + child: Stack( + alignment: Alignment.center, + children: [ + 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 + ScrollConfiguration( + behavior: _GalleryScrollBehavior(), + child: PageView.builder( + controller: pageC, + onPageChanged: _onPageChanged, + physics: canSwipe + ? const PageScrollPhysics() + : const NeverScrollableScrollPhysics(), + itemCount: total, + itemBuilder: (_, i) { + final img = images[i]; + return Center( + child: GestureDetector( + onTapDown: (d) => _doubleTapDetails = d, + onDoubleTap: _onDoubleTap, + child: InteractiveViewer( + transformationController: _tc, + minScale: 1, + maxScale: 4, + panEnabled: _isZoomed, + scaleEnabled: true, + clipBehavior: Clip.none, + child: Image.network( + img.url, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => const Icon( + Icons.broken_image, + color: Colors.white70, + size: 64), + ), + ), + ), + ); + }, + ), + ), + + // Buttons Prev/Nächste + if (_chromeVisible && total > 0 && !_isZoomed) ...[ + if (currentIndex > 0) + Positioned( + left: 8, + child: IconButton.filledTonal( + style: IconButton.styleFrom( + backgroundColor: Colors.white24), + icon: const Icon(Icons.chevron_left, + size: 32, color: Colors.white), + onPressed: _prev, + ), + ), + if (currentIndex < total - 1) + Positioned( + right: 8, + child: IconButton.filledTonal( + style: IconButton.styleFrom( + backgroundColor: Colors.white24), + icon: const Icon(Icons.chevron_right, + size: 32, color: Colors.white), + onPressed: _next, + ), + ), + ], + + // Top-Bar mit Upload-Button + 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: _popWithResult, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + total == 0 + ? 'Galerie' + : '${currentIndex + 1} / $total', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600), + ), + ), + // Upload-Button (gleich wie im Detail-Screen, aber hier mit Overlay) + TextButton.icon( + style: TextButton.styleFrom( + foregroundColor: Colors.white, + ), + onPressed: _uploading ? null : _pickAndUpload, + icon: const Icon(Icons.add_a_photo), + label: const Text('Bilder hinzufügen'), + ), + IconButton( + tooltip: 'Löschen', + color: Colors.white, + onPressed: total == 0 ? null : _deleteCurrent, + icon: const Icon(Icons.delete_outline), + ), + ], + ), + ), + ), + + // Bottom-Bar: Datum/Uhrzeit statt Dateiname + 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( + footerText(), + 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), + ), + ], ), ), ), ), - ); - }, - ), - // 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), + // Upload-Overlay (Animation) + if (_uploading) + Container( + color: const Color(0x66000000), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 56, + height: 56, + child: + CircularProgressIndicator(strokeWidth: 4), + ), + const SizedBox(height: 12), + Text( + _uploadTotal <= 1 + ? 'Lade Bild hoch …' + : 'Lade Bilder hoch ($_uploadDone/$_uploadTotal) …', + style: const TextStyle( + color: Colors.white, fontSize: 16), + ), + ], + ), + ), ), - ), - 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/main.dart b/lib/main.dart index 992a752..9df80ce 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'dart:ui' show PointerDeviceKind; import 'app_router.dart'; import 'features/auth/data/token_storage.dart'; import 'shared/api_client.dart'; @@ -28,6 +29,17 @@ final igelImagesRepoProvider = ref.read(tokenStorageProvider).getValidAccessToken(), )); +class AppScrollBehavior extends MaterialScrollBehavior { + @override + Set get dragDevices => { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + PointerDeviceKind.stylus, + PointerDeviceKind.trackpad, + PointerDeviceKind.unknown, + }; +} + void main() { runApp(const ProviderScope(child: IgelApp())); } @@ -40,6 +52,7 @@ class IgelApp extends ConsumerWidget { title: 'Igel', routerConfig: buildRouter(), theme: ThemeData(useMaterial3: true), + scrollBehavior: AppScrollBehavior(), // ⬅️ wichtig für Web/Desktop ); } }