Gallery
This commit is contained in:
+12
-8
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user