This commit is contained in:
2025-10-23 22:30:34 +02:00
parent 66c20e628f
commit c2430e30b5
4 changed files with 864 additions and 577 deletions
+12 -8
View File
@@ -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 (
@@ -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<IgelDetailScreen> {
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<IgelDetailScreen> {
);
if (picks.isEmpty) return;
setState(() {
_uploading = true;
_uploadDone = 0;
_uploadTotal = picks.length;
});
final files = <http.MultipartFile>[];
final taken = <DateTime?>[];
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<IgelDetailScreen> {
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<IgelDetailScreen> {
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<DateTime?> _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<IgelDetailScreen> {
),
],
),
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 =
@@ -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<PointerDeviceKind> 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<IgelGalleryScreen> {
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<IgelGalleryScreen> {
}
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<IgelGalleryScreen> {
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<IgelGalleryScreen> {
}
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<IgelGalleryScreen> {
}
}
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<void> _deleteCurrent() async {
if (images.isEmpty) return;
final img = images[currentIndex];
@@ -187,15 +234,34 @@ class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
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<IgelGalleryScreen> {
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<void> _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 = <http.MultipartFile>[];
// Erwartet: List<DateTime?>? → wir füllen (noch) mit nulls
final takenAt = <DateTime?>[];
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<DateTime?>)
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 <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.arrowRight): ActivateIntent(),
SingleActivator(LogicalKeyboardKey.arrowLeft): DismissIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{
ActivateIntent: CallbackAction<Intent>(onInvoke: (_) {
_next();
return null;
}),
DismissIntent: CallbackAction<Intent>(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),
),
],
),
),
),
),
],
),
),
),
),
+13
View File
@@ -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<PointerDeviceKind> 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
);
}
}