upload, kleine thumbs

This commit is contained in:
2025-10-23 20:58:16 +02:00
parent bede0000a8
commit 66c20e628f
9 changed files with 1175 additions and 78 deletions
+56
View File
@@ -0,0 +1,56 @@
<?php
// CORS wie in deinem Beispiel (Wildcard-Ports & Subdomains unterstützt)
const WH_ALLOWED_ORIGINS = [
// Produktion
'https://windesign.at',
'https://hedgehogs.windesign.at',
'https://api.windesign.at',
// FlutLab
'https://preview.flutlab.io',
'https://*.flutlab.io',
// Entwicklung beliebiger Port:
'http://localhost:*',
'http://127.0.0.1:*',
'https://localhost:*',
'https://127.0.0.1:*',
];
// DB
define('DATABASE_NAME', 'hedgehogs');
define('DATABASE_USER', 'hedgehogs');
define('DATABASE_PASSWORD', 'WeissIchNicht8');
define('DATABASE_HOST', 'localhost');
// Uploads (Dateisystem-Pfade relativ zu hedgehogs.php im Webroot)
define('UPLOAD_DIR', __DIR__ . '/hedgehogs/uploads');
define('UPLOAD_THUMB_DIR', __DIR__ . '/hedgehogs/uploads/thumbs');
// Öffentliche Basis-URL zu genau diesen Ordnern
define('UPLOAD_BASE_URL', 'https://api.windesign.at/hedgehogs/uploads');
define('MAX_IMAGE_SIZE', 8 * 1024 * 1024);
if (!defined('ALLOWED_MIME')) {
define('ALLOWED_MIME', json_encode(['image/jpeg','image/png','image/webp','image/gif']));
}
// JWT / Auth
define('JWT_SECRET', 'sehrlangesjwtsecretdasswirklichsehrgeheimist');
define('JWT_ISSUER', 'https://windesign.at');
define('JWT_ACCESS_TTL', 900); // 15 Minuten
define('JWT_REFRESH_TTL', 60*60*24*30); // 30 Tage
// Mail-Konfiguration
define('SMTP_HOST', 'smtp-mail.outlook.com');
define('SMTP_PORT', 587);
define('SMTP_USER', 'Herwig.Birke@windesign.at');
define('SMTP_PASS', 'GehtDichNixAn1');
define('SMTP_SECURE', 'tls'); // oder 'ssl'
define('MAIL_FROM', 'Herwig.Birke@windesign.at');
define('MAIL_FROM_NAME', 'Hedgehogs');
define('VERIFY_BASE_URL', 'https://api.windesign.at/hedgehogs.php');
define('EMAIL_DEV_MODE', true);
?>
+685
View File
@@ -0,0 +1,685 @@
<?php
/**
* Single-file PHP API for multi-user "Igel" management
* - Auth (JWT + Refresh)
* - Igel CRUD
* - Bilder (Liste/Upload/Löschen) nutzt UPLOAD_* aus hedgehogs-settings.php
* - Messwerte pro Igel (Liste/Anlegen/Aktualisieren/Löschen)
*
* Konfiguration: require_once 'hedgehogs-settings.php';
*/
declare(strict_types=1);
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
error_reporting(E_ALL);
// --- Load settings -----------------------------------------------------------
require_once __DIR__ . '/hedgehogs-settings.php';
// --- PHP 7 polyfills ---------------------------------------------------------
if (!function_exists('str_starts_with')) {
function str_starts_with($haystack, $needle) { return $needle === '' || strpos($haystack, $needle) === 0; }
}
if (!function_exists('str_ends_with')) {
function str_ends_with($haystack, $needle) {
if ($needle === '') return true;
$len = strlen($needle);
return $len <= strlen($haystack) && substr($haystack, -$len) === $needle;
}
}
// --- CORS --------------------------------------------------------------------
header('Vary: Origin');
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($origin && origin_allowed($origin, WH_ALLOWED_ORIGINS)) {
header("Access-Control-Allow-Origin: $origin");
header('Access-Control-Allow-Credentials: true');
}
header('Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') { http_response_code(204); exit; }
// --- DB ----------------------------------------------------------------------
$pdo = db();
// --- Router ------------------------------------------------------------------
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
// Optional: alternative Routen-Param ?r=/path
if (isset($_GET['r']) && is_string($_GET['r']) && $_GET['r'] !== '') {
$path = $_GET['r'];
}
// strip leading script name if fronted by /hedgehogs.php/...
$script = $_SERVER['SCRIPT_NAME'] ?? '';
if ($script && str_starts_with($path, $script)) {
$path = substr($path, strlen($script));
if ($path === '') $path = '/';
}
try {
// --- Auth ------------------------------------------------------------------
if ($path === '/auth/register' && $method === 'POST') { return auth_register($pdo); }
if ($path === '/auth/login' && $method === 'POST') { return auth_login($pdo); }
if ($path === '/auth/refresh' && $method === 'POST') { return auth_refresh($pdo); }
if ($path === '/auth/logout' && $method === 'POST') { return auth_logout($pdo); }
// --- Messwerte Update/Delete (TOP-LEVEL!) ---------------------------------
// /messwerte/{id} → PUT/DELETE
if (preg_match('#^/messwerte/(\d+)$#', $path, $m)) {
$uid = require_user($pdo);
$mid = (int)$m[1];
if ($method === 'PUT') { return messwerte_update($pdo, $uid, $mid); }
if ($method === 'DELETE') { return messwerte_delete($pdo, $uid, $mid); }
}
// --- Igel + Unterressourcen -----------------------------------------------
if (str_starts_with($path, '/igel')) {
$uid = require_user($pdo);
// /igel
if ($path === '/igel' && $method === 'GET') { return igel_list($pdo, $uid); }
if ($path === '/igel' && $method === 'POST') { return igel_create($pdo, $uid); }
// /igel/{id}
if (preg_match('#^/igel/(\d+)$#', $path, $m)) {
$id = (int)$m[1];
if ($method === 'GET') { return igel_get($pdo, $uid, $id); }
if ($method === 'PUT') { return igel_update($pdo, $uid, $id); }
if ($method === 'DELETE') { return igel_delete($pdo, $uid, $id); }
}
// /igel/{id}/images
if (preg_match('#^/igel/(\d+)/images$#', $path, $m)) {
$igId = (int)$m[1];
if ($method === 'GET') { return igel_images_list($pdo, $uid, $igId); }
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];
if ($method === 'GET') { return messwerte_list($pdo, $uid, $igId); }
if ($method === 'POST') { return messwerte_create($pdo, $uid, $igId); }
}
}
json(['error' => 'Not Found', 'path' => $path], 404);
} catch (Throwable $e) {
error_log('[hedgehogs.php] Exception: '.$e->getMessage());
json(['error' => 'Server error'], 500);
}
// =============================================================================
// AUTH
// =============================================================================
function auth_register(PDO $pdo): void {
$in = body_json();
$email = strtolower(trim((string)($in['email'] ?? '')));
$pass = (string)($in['password'] ?? '');
// E-Mail-Check ohne filter-Extension
$emailOk = (bool)preg_match('/^[^\s@]+@[^\s@]+\.[^\s@]+$/', $email);
if (!$emailOk || strlen($pass) < 8) { json(['error' => 'Invalid input'], 422); return; }
$hash = password_hash($pass, defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_DEFAULT);
try {
$stmt = $pdo->prepare('INSERT INTO users(email, password_hash) VALUES(?, ?)');
$stmt->execute([$email, $hash]);
} catch (PDOException $e) {
if ((int)($e->errorInfo[1] ?? 0) === 1062) { json(['error' => 'Email already exists'], 409); return; }
throw $e;
}
json(['ok' => true], 201);
}
function auth_login(PDO $pdo): void {
$in = body_json();
$email = strtolower(trim((string)($in['email'] ?? '')));
$pass = (string)($in['password'] ?? '');
$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE email = ?');
$stmt->execute([$email]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row || !password_verify($pass, (string)$row['password_hash'])) {
json(['error' => 'Invalid credentials'], 401); return;
}
$uid = (int)$row['id'];
[$access, $refresh] = issue_tokens($pdo, $uid);
json(['access_token' => $access, 'refresh_token' => $refresh]);
}
function auth_refresh(PDO $pdo): void {
$in = body_json();
$refresh = (string)($in['refresh_token'] ?? '');
$stmt = $pdo->prepare('SELECT user_id FROM refresh_tokens WHERE token = ? AND expires_at > NOW()');
$stmt->execute([$refresh]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) { json(['error' => 'Invalid refresh'], 401); return; }
$uid = (int)$row['user_id'];
$now = time();
$access = jwt_encode(['iss' => JWT_ISSUER, 'iat' => $now, 'exp' => $now + JWT_ACCESS_TTL, 'sub' => $uid], JWT_SECRET);
json(['access_token' => $access]);
}
function auth_logout(PDO $pdo): void {
$in = body_json();
$refresh = (string)($in['refresh_token'] ?? '');
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE token = ?');
$stmt->execute([$refresh]);
json(['ok' => true]);
}
function require_user(PDO $pdo): int {
$hdr = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/Bearer\s+(.*)/i', $hdr, $m)) { json(['error' => 'Unauthorized'], 401); exit; }
try {
$payload = jwt_decode($m[1], JWT_SECRET);
if (($payload['iss'] ?? null) !== JWT_ISSUER) throw new Exception('bad iss');
$sub = (int)($payload['sub'] ?? 0);
if ($sub <= 0) throw new Exception('bad sub');
return $sub;
} catch (Throwable $e) {
json(['error' => 'Unauthorized'], 401); exit;
}
}
function issue_tokens(PDO $pdo, int $uid): array {
$now = time();
$access = jwt_encode(['iss' => JWT_ISSUER, 'iat' => $now, 'exp' => $now + JWT_ACCESS_TTL, 'sub' => $uid], JWT_SECRET);
$refresh = bin2hex(random_bytes(32));
$stmt = $pdo->prepare('INSERT INTO refresh_tokens(user_id, token, expires_at) VALUES(?,?, FROM_UNIXTIME(?))');
$stmt->execute([$uid, $refresh, $now + JWT_REFRESH_TTL]);
return [$access, $refresh];
}
// =============================================================================
// IGEL
// =============================================================================
function igel_list(PDO $pdo, int $uid): void {
$stmt = $pdo->prepare('SELECT id, name, gender, feature, note, created_at, updated_at FROM igel WHERE user_id = ? ORDER BY created_at DESC');
$stmt->execute([$uid]);
json($stmt->fetchAll(PDO::FETCH_ASSOC));
}
function igel_create(PDO $pdo, int $uid): void {
$in = body_json();
$name = trim((string)($in['name'] ?? ''));
$gender = isset($in['gender']) ? (string)$in['gender'] : null;
$feature = isset($in['feature']) ? (string)$in['feature'] : null;
$note = isset($in['note']) ? (string)$in['note'] : null;
if ($name === '') { json(['error' => 'Name required'], 422); return; }
$stmt = $pdo->prepare('INSERT INTO igel(user_id, name, gender, feature, note) VALUES(?,?,?,?,?)');
$stmt->execute([$uid, $name, $gender, $feature, $note]);
$id = (int)$pdo->lastInsertId();
json(['id' => $id, 'name' => $name, 'gender' => $gender, 'feature' => $feature, 'note' => $note], 201);
}
function igel_get(PDO $pdo, int $uid, int $id): void {
$stmt = $pdo->prepare('SELECT id, name, gender, feature, note, created_at, updated_at FROM igel WHERE id=? AND user_id=?');
$stmt->execute([$id, $uid]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) { json(['error' => 'Not found'], 404); return; }
json($row);
}
function igel_update(PDO $pdo, int $uid, int $id): void {
$in = body_json();
$name = trim((string)($in['name'] ?? ''));
$gender = isset($in['gender']) ? (string)$in['gender'] : null;
$feature = isset($in['feature']) ? (string)$in['feature'] : null;
$note = isset($in['note']) ? (string)$in['note'] : null;
if ($name === '') { json(['error' => 'Name required'], 422); return; }
$stmt = $pdo->prepare('UPDATE igel SET name=?, gender=?, feature=?, note=? WHERE id=? AND user_id=?');
$stmt->execute([$name, $gender, $feature, $note, $id, $uid]);
json(['ok' => true]);
}
function igel_delete(PDO $pdo, int $uid, int $id): void {
$stmt = $pdo->prepare('DELETE FROM igel WHERE id=? AND user_id=?');
$stmt->execute([$id, $uid]);
json(['ok' => true]);
}
// =============================================================================
// IGEL BILDER (Liste/Upload/Löschen)
// =============================================================================
function igel_images_list(PDO $pdo, int $uid, int $igId): void {
// Besitz prüfen
$own=$pdo->prepare('SELECT id FROM igel WHERE id=? AND user_id=?');
$own->execute([$igId,$uid]);
if(!$own->fetch()) { json(['error'=>'Not found'],404); return; }
try {
$stmt=$pdo->prepare('SELECT id,url,thumb_url,original_name,mime,size_bytes,created_at,taken_at
FROM igel_images WHERE igel_id=? ORDER BY id DESC');
$stmt->execute([$igId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
// Fallback, falls thumb_url-Spalte fehlt
$stmt=$pdo->prepare('SELECT id,url,original_name,mime,size_bytes,created_at,taken_at
FROM igel_images WHERE igel_id=? ORDER BY id DESC');
$stmt->execute([$igId]);
$tmp = $stmt->fetchAll(PDO::FETCH_ASSOC);
$rows = [];
foreach ($tmp as $r) {
$r['thumb_url'] = $r['url'];
$rows[] = $r;
}
}
json($rows);
}
function igel_images_upload(PDO $pdo, int $uid, int $igId): void {
// Besitz prüfen
$own=$pdo->prepare('SELECT id FROM igel WHERE id=? AND user_id=?');
$own->execute([$igId,$uid]);
if(!$own->fetch()) { json(['error'=>'Not found'],404); return; }
// multipart/form-data: files[]
if (!isset($_FILES['files'])) { json(['error' => 'No files'], 400); return; }
$files = $_FILES['files'];
// Optionales paralleles Feld: taken_at[] (ISO-8601 vom Client aus EXIF)
$takenArr = [];
if (isset($_POST['taken_at'])) {
$takenArr = is_array($_POST['taken_at']) ? $_POST['taken_at'] : [$_POST['taken_at']];
}
$out = [];
$count = is_array($files['name']) ? count($files['name']) : 0;
for ($i=0; $i<$count; $i++) {
if ((int)$files['error'][$i] !== UPLOAD_ERR_OK) continue;
$tmp = (string)$files['tmp_name'][$i];
$orig = (string)$files['name'][$i];
$size = (int)$files['size'][$i];
if ($size <= 0 || $size > MAX_IMAGE_SIZE) continue;
// MIME grob anhand Endung
$lower = strtolower($orig);
$ext = '.bin';
$mime = 'application/octet-stream';
if (preg_match('/\.(jpg|jpeg)$/', $lower)) { $ext = '.jpg'; $mime='image/jpeg'; }
elseif (preg_match('/\.png$/', $lower)) { $ext = '.png'; $mime='image/png'; }
elseif (preg_match('/\.webp$/', $lower)) { $ext = '.webp'; $mime='image/webp'; }
elseif (preg_match('/\.gif$/', $lower)) { $ext = '.gif'; $mime='image/gif'; }
// sichere Dateinamen
$base = bin2hex(random_bytes(8));
$fn = $base . $ext;
$destDir = rtrim(UPLOAD_DIR, '/');
if (!is_dir($destDir)) { @mkdir($destDir, 0755, true); }
$dest = $destDir . '/' . $fn;
if (!move_uploaded_file($tmp, $dest)) continue;
// Thumb
$thumbUrl = null;
try {
$thumbDir = rtrim(UPLOAD_THUMB_DIR, '/');
if (!is_dir($thumbDir)) { @mkdir($thumbDir, 0755, true); }
$thumbPath = $thumbDir . '/' . $fn;
create_thumbnail($dest, $thumbPath, 512, 512); // Quadrat-Box
$thumbUrl = rtrim(UPLOAD_BASE_URL,'/') . '/thumbs/' . $fn;
} catch (Throwable $e) {
$thumbUrl = null; // ok
}
$url = rtrim(UPLOAD_BASE_URL,'/') . '/' . $fn;
// EXIF-Aufnahmezeit (taken_at[]) → DATETIME oder NULL
$takenAtMysql = null;
if (isset($takenArr[$i])) {
$raw = (string)$takenArr[$i];
$ts = strtotime($raw);
if ($ts !== false) {
$takenAtMysql = date('Y-m-d H:i:s', $ts);
}
}
// DB: created_at via DEFAULT CURRENT_TIMESTAMP, taken_at separat speichern
$stmt = $pdo->prepare('INSERT INTO igel_images
(igel_id, url, thumb_url, original_name, mime, size_bytes, taken_at)
VALUES(?,?,?,?,?,?,?)');
$stmt->execute([$igId, $url, $thumbUrl, $orig, $mime, $size, $takenAtMysql]);
$id = (int)$pdo->lastInsertId();
// created_at aus DB holen (für Response, damit Frontend sofort beides hat)
$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];
$out[] = [
'id' => $id,
'url' => $url,
'thumb_url' => $thumbUrl,
'original_name' => $orig,
'mime' => $mime,
'size_bytes' => $size,
'created_at' => (string)($times['created_at'] ?? ''),
'taken_at' => (string)($times['taken_at'] ?? ''),
];
}
json($out, 201);
}
function igel_images_delete(PDO $pdo, int $uid, int $imgId): void {
// Besitz prüfen (Join)
$stmt=$pdo->prepare('SELECT i.id, i.url, i.thumb_url
FROM igel_images i
JOIN igel g ON g.id = i.igel_id
WHERE i.id=? AND g.user_id=?');
$stmt->execute([$imgId,$uid]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) { json(['error'=>'Not found'],404); return; }
// Dateien optional entfernen
try {
$url = (string)$row['url'];
$thumb = (string)($row['thumb_url'] ?? '');
$fn = basename(parse_url($url, PHP_URL_PATH) ?? '');
$fnT = $thumb ? basename(parse_url($thumb, PHP_URL_PATH) ?? '') : '';
$p = rtrim(UPLOAD_DIR,'/').'/'.$fn;
if (is_file($p)) @unlink($p);
if ($fnT !== '') {
$pt = rtrim(UPLOAD_THUMB_DIR,'/').'/'.$fnT;
if (is_file($pt)) @unlink($pt);
}
} catch (Throwable $e) {}
$del=$pdo->prepare('DELETE FROM igel_images WHERE id=?');
$del->execute([$imgId]);
json(['ok'=>true]);
}
// --- Thumbnail Helper (GD) ---------------------------------------------------
function create_thumbnail(string $src, string $dest, int $maxW, int $maxH): void {
if (!extension_loaded('gd')) throw new Exception('GD not loaded');
[$w,$h,$type] = getimagesize($src);
if (!$w || !$h) throw new Exception('bad image');
switch ($type) {
case IMAGETYPE_JPEG: $im = imagecreatefromjpeg($src); break;
case IMAGETYPE_PNG: $im = imagecreatefrompng($src); break;
case IMAGETYPE_WEBP: if (function_exists('imagecreatefromwebp')) { $im = imagecreatefromwebp($src); } else { throw new Exception('webp not supported'); } break;
case IMAGETYPE_GIF: $im = imagecreatefromgif($src); break;
default: throw new Exception('unsupported type');
}
$ratio = min($maxW/$w, $maxH/$h, 1.0);
$nw = (int)round($w*$ratio);
$nh = (int)round($h*$ratio);
$thumb = imagecreatetruecolor($nw, $nh);
// transparent für PNG/GIF
if (in_array($type, [IMAGETYPE_PNG, IMAGETYPE_GIF], true)) {
imagecolortransparent($thumb, imagecolorallocatealpha($thumb, 0, 0, 0, 127));
imagealphablending($thumb, false);
imagesavealpha($thumb, true);
}
imagecopyresampled($thumb, $im, 0,0,0,0, $nw,$nh,$w,$h);
$ext = strtolower(pathinfo($dest, PATHINFO_EXTENSION));
if ($ext === 'png') imagepng($thumb, $dest, 6);
elseif ($ext === 'gif') imagegif($thumb, $dest);
elseif ($ext === 'webp' && function_exists('imagewebp')) imagewebp($thumb, $dest, 85);
else imagejpeg($thumb, $dest, 85);
imagedestroy($im);
imagedestroy($thumb);
}
// =============================================================================
// MESSWERTE
// =============================================================================
function messwerte_list(PDO $pdo, int $uid, int $igId): void {
// Besitz
$own=$pdo->prepare('SELECT id FROM igel WHERE id=? AND user_id=?');
$own->execute([$igId,$uid]);
if(!$own->fetch()) { json(['error'=>'Not found'],404); return; }
$stmt=$pdo->prepare('SELECT id, igel_id, DATE_FORMAT(datum, "%Y-%m-%dT%H:%i:%sZ") AS datum,
gewicht, behandlung, bemerkung, created_at
FROM messwerte
WHERE igel_id=? ORDER BY datum DESC, id DESC');
$stmt->execute([$igId]);
json($stmt->fetchAll(PDO::FETCH_ASSOC));
}
function messwerte_create(PDO $pdo, int $uid, int $igId): void {
// Besitz
$own=$pdo->prepare('SELECT id FROM igel WHERE id=? AND user_id=?');
$own->execute([$igId,$uid]);
if(!$own->fetch()) { json(['error'=>'Not found'],404); return; }
$in = body_json();
$datumRaw = (string)($in['datum'] ?? '');
$gewicht = (int)($in['gewicht'] ?? 0);
$behandlung = isset($in['behandlung']) ? (string)$in['behandlung'] : null;
$bemerkung = isset($in['bemerkung']) ? (string)$in['bemerkung'] : null;
// Datum akzeptiert ISO-8601 oder "YYYY-MM-DD HH:MM"
$ts = $datumRaw !== '' ? strtotime($datumRaw) : time();
if ($ts === false) { json(['error'=>'Invalid date'],422); return; }
if ($gewicht < 1 || $gewicht > 100000) { json(['error'=>'Invalid weight'],422); return; }
$mysql = date('Y-m-d H:i:s', $ts);
$stmt=$pdo->prepare('INSERT INTO messwerte (igel_id, datum, gewicht, behandlung, bemerkung) VALUES(?,?,?,?,?)');
$stmt->execute([$igId, $mysql, $gewicht, $behandlung, $bemerkung]);
$id = (int)$pdo->lastInsertId();
json([
'id'=>$id,
'igel_id'=>$igId,
'datum'=>gmdate('Y-m-d\TH:i:s\Z', $ts),
'gewicht'=>$gewicht,
'behandlung'=>$behandlung,
'bemerkung'=>$bemerkung
], 201);
}
function messwerte_update(PDO $pdo, int $uid, int $mid): void {
// Besitz via Join prüfen
$own=$pdo->prepare('SELECT m.id, m.igel_id FROM messwerte m JOIN igel g ON g.id=m.igel_id WHERE m.id=? AND g.user_id=?');
$own->execute([$mid,$uid]);
$row=$own->fetch(PDO::FETCH_ASSOC);
if(!$row) { json(['error'=>'Not found'],404); return; }
$in = body_json();
// Alle Felder optional, aber validieren, falls vorhanden
$set = [];
$args= [];
if (isset($in['datum'])) {
$ts = strtotime((string)$in['datum']);
if ($ts === false) { json(['error'=>'Invalid date'],422); return; }
$set[]='datum=?'; $args[]=date('Y-m-d H:i:s',$ts);
}
if (isset($in['gewicht'])) {
$gewicht=(int)$in['gewicht'];
if ($gewicht < 1 || $gewicht > 100000) { json(['error'=>'Invalid weight'],422); return; }
$set[]='gewicht=?'; $args[]=$gewicht;
}
if (array_key_exists('behandlung',$in)) { $set[]='behandlung=?'; $args[]=(string)$in['behandlung']; }
if (array_key_exists('bemerkung',$in)) { $set[]='bemerkung=?'; $args[]=(string)$in['bemerkung']; }
if (empty($set)) { json(['error'=>'No fields'],400); return; }
$args[]=$mid;
$sql='UPDATE messwerte SET '.implode(',', $set).' WHERE id=?';
$stmt=$pdo->prepare($sql);
$stmt->execute($args);
json(['ok'=>true]);
}
function messwerte_delete(PDO $pdo, int $uid, int $mid): void {
// Besitz via Join prüfen
$own=$pdo->prepare('SELECT m.id FROM messwerte m JOIN igel g ON g.id=m.igel_id WHERE m.id=? AND g.user_id=?');
$own->execute([$mid,$uid]);
if(!$own->fetch()) { json(['error'=>'Not found'],404); return; }
$del=$pdo->prepare('DELETE FROM messwerte WHERE id=?');
$del->execute([$mid]);
json(['ok'=>true]);
}
// =============================================================================
// Utilities
// =============================================================================
function json($data, int $code = 200): void {
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE);
}
function body_json(): array {
$raw = file_get_contents('php://input');
if ($raw === false || $raw === '') return [];
$data = json_decode($raw, true);
return is_array($data) ? $data : [];
}
function db(): PDO {
$dsn = 'mysql:host=' . DATABASE_HOST . ';dbname=' . DATABASE_NAME . ';charset=utf8mb4';
$pdo = new PDO($dsn, DATABASE_USER, DATABASE_PASSWORD, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
return $pdo;
}
function origin_allowed(string $origin, array $allowed): bool {
$u = parse_url($origin);
if (!$u || !isset($u['scheme'], $u['host'])) return false;
$oScheme = $u['scheme']; $oHost = $u['host']; $oPort = (string)($u['port'] ?? '');
foreach ($allowed as $pat) {
$pu = parse_url($pat);
if (!$pu || !isset($pu['scheme'])) continue;
if ($pu['scheme'] !== $oScheme) continue;
$pHost = $pu['host'] ?? '';
$pPort = $pu['port'] ?? '';
$hostOk = false;
if ($pHost === $oHost) $hostOk = true;
elseif (str_starts_with($pHost, '*.' )) { $suffix = substr($pHost, 1); if (str_ends_with($oHost, $suffix)) $hostOk = true; }
elseif ($pHost === '' && isset($pu['path'])) {
$p = $pu['path']; // z.B. localhost:*
if ($p === $oHost || (str_starts_with($p, '*.') && str_ends_with($oHost, substr($p,1)))) $hostOk = true;
}
if (!$hostOk) continue;
$patHasWildcardPort = str_ends_with($pat, ':*');
$portOk = $patHasWildcardPort || ($pPort !== '' && (string)$pPort === $oPort) || ($pPort === '' && $oPort === '');
if ($portOk) return true;
}
return false;
}
// --- Minimal JWT HS256 -------------------------------------------------------
function b64url_encode(string $data): string { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); }
function b64url_decode(string $data): string { return base64_decode(strtr($data, '-_', '+/')) ?: ''; }
function jwt_encode(array $payload, string $secret): string {
$header = ['typ' => 'JWT', 'alg' => 'HS256'];
$segments = [b64url_encode(json_encode($header)), b64url_encode(json_encode($payload))];
$signingInput = implode('.', $segments);
$signature = hash_hmac('sha256', $signingInput, $secret, true);
$segments[] = b64url_encode($signature);
return implode('.', $segments);
}
function jwt_decode(string $token, string $secret): array {
$parts = explode('.', $token);
if (count($parts) !== 3) throw new Exception('bad token');
[$h64, $p64, $s64] = $parts;
$header = json_decode(b64url_decode($h64), true) ?: [];
if (($header['alg'] ?? '') !== 'HS256') throw new Exception('alg');
$payload = json_decode(b64url_decode($p64), true) ?: [];
$sig = b64url_decode($s64);
$expected = hash_hmac('sha256', "$h64.$p64", $secret, true);
if (!hash_equals($expected, $sig)) throw new Exception('sig');
if (isset($payload['exp']) && time() >= (int)$payload['exp']) throw new Exception('exp');
return $payload;
}
/*
-- SQL Reference (run once)
CREATE TABLE users (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
email VARCHAR(191) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE refresh_tokens (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
token VARCHAR(255) NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX (user_id), INDEX (token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE igel (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(120) NOT NULL,
gender VARCHAR(30) NULL,
feature VARCHAR(255) NULL,
note TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX (user_id), INDEX (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE igel_images (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
igel_id BIGINT UNSIGNED NOT NULL,
url VARCHAR(500) NOT NULL,
thumb_url VARCHAR(500) NULL,
original_name VARCHAR(255) NULL,
mime VARCHAR(100) NULL,
size_bytes BIGINT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (igel_id) REFERENCES igel(id) ON DELETE CASCADE,
INDEX (igel_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE messwerte (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
igel_id BIGINT UNSIGNED NOT NULL,
datum DATETIME NOT NULL,
gewicht INT UNSIGNED NOT NULL,
behandlung VARCHAR(255) NULL,
bemerkung TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (igel_id) REFERENCES igel(id) ON DELETE CASCADE,
INDEX (igel_id), INDEX (datum)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
*/
?>
+250
View File
@@ -0,0 +1,250 @@
-- phpMyAdmin SQL Dump
-- version 5.1.1deb5ubuntu1
-- https://www.phpmyadmin.net/
--
-- Host: localhost:3306
-- Generation Time: Oct 23, 2025 at 06:57 PM
-- Server version: 10.6.22-MariaDB-ubu2204-log
-- PHP Version: 8.2.28
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
START TRANSACTION;
SET time_zone = "+00:00";
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8mb4 */;
--
-- Database: `hedgehogs`
--
-- --------------------------------------------------------
--
-- Table structure for table `email_verifications`
--
CREATE TABLE `email_verifications` (
`id` bigint(20) UNSIGNED NOT NULL,
`user_id` bigint(20) UNSIGNED NOT NULL,
`token` varchar(255) NOT NULL,
`expires_at` datetime NOT NULL,
`sent_at` datetime NOT NULL DEFAULT current_timestamp(),
`used_at` datetime DEFAULT NULL,
`ip` varchar(64) DEFAULT NULL,
`user_agent` varchar(255) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- --------------------------------------------------------
--
-- Table structure for table `igel`
--
CREATE TABLE `igel` (
`id` bigint(20) UNSIGNED NOT NULL,
`user_id` bigint(20) UNSIGNED NOT NULL,
`name` varchar(120) NOT NULL,
`gender` varchar(10) DEFAULT NULL,
`note` text DEFAULT NULL,
`feature` text DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`updated_at` timestamp NULL DEFAULT NULL ON UPDATE current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- --------------------------------------------------------
--
-- Table structure for table `igel_images`
--
CREATE TABLE `igel_images` (
`id` bigint(20) UNSIGNED NOT NULL,
`igel_id` bigint(20) UNSIGNED NOT NULL,
`url` varchar(500) NOT NULL,
`thumb_url` varchar(500) DEFAULT NULL,
`original_name` varchar(255) DEFAULT NULL,
`mime` varchar(100) DEFAULT NULL,
`size_bytes` bigint(20) UNSIGNED DEFAULT NULL,
`taken_at` datetime DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- --------------------------------------------------------
--
-- Table structure for table `messwerte`
--
CREATE TABLE `messwerte` (
`id` bigint(20) UNSIGNED NOT NULL,
`igel_id` bigint(20) UNSIGNED NOT NULL,
`datum` datetime NOT NULL,
`gewicht` int(10) UNSIGNED NOT NULL,
`behandlung` varchar(255) DEFAULT NULL,
`bemerkung` text DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- --------------------------------------------------------
--
-- Table structure for table `refresh_tokens`
--
CREATE TABLE `refresh_tokens` (
`id` bigint(20) UNSIGNED NOT NULL,
`user_id` bigint(20) UNSIGNED NOT NULL,
`token` varchar(255) NOT NULL,
`expires_at` datetime NOT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- --------------------------------------------------------
--
-- Table structure for table `users`
--
CREATE TABLE `users` (
`id` bigint(20) UNSIGNED NOT NULL,
`email` varchar(191) NOT NULL,
`password_hash` varchar(255) NOT NULL,
`verified_at` datetime DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
--
-- Indexes for dumped tables
--
--
-- Indexes for table `email_verifications`
--
ALTER TABLE `email_verifications`
ADD PRIMARY KEY (`id`),
ADD UNIQUE KEY `token` (`token`),
ADD KEY `user_id` (`user_id`),
ADD KEY `token_2` (`token`);
--
-- Indexes for table `igel`
--
ALTER TABLE `igel`
ADD PRIMARY KEY (`id`),
ADD KEY `user_id` (`user_id`),
ADD KEY `name` (`name`);
--
-- Indexes for table `igel_images`
--
ALTER TABLE `igel_images`
ADD PRIMARY KEY (`id`),
ADD KEY `igel_id` (`igel_id`),
ADD KEY `idx_igel_images_taken_at` (`taken_at`);
--
-- Indexes for table `messwerte`
--
ALTER TABLE `messwerte`
ADD PRIMARY KEY (`id`),
ADD KEY `igel_id` (`igel_id`),
ADD KEY `datum` (`datum`);
--
-- Indexes for table `refresh_tokens`
--
ALTER TABLE `refresh_tokens`
ADD PRIMARY KEY (`id`),
ADD UNIQUE KEY `token` (`token`),
ADD KEY `user_id` (`user_id`),
ADD KEY `token_2` (`token`);
--
-- Indexes for table `users`
--
ALTER TABLE `users`
ADD PRIMARY KEY (`id`),
ADD UNIQUE KEY `email` (`email`);
--
-- AUTO_INCREMENT for dumped tables
--
--
-- AUTO_INCREMENT for table `email_verifications`
--
ALTER TABLE `email_verifications`
MODIFY `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT;
--
-- AUTO_INCREMENT for table `igel`
--
ALTER TABLE `igel`
MODIFY `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT;
--
-- AUTO_INCREMENT for table `igel_images`
--
ALTER TABLE `igel_images`
MODIFY `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT;
--
-- AUTO_INCREMENT for table `messwerte`
--
ALTER TABLE `messwerte`
MODIFY `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT;
--
-- AUTO_INCREMENT for table `refresh_tokens`
--
ALTER TABLE `refresh_tokens`
MODIFY `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT;
--
-- AUTO_INCREMENT for table `users`
--
ALTER TABLE `users`
MODIFY `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT;
--
-- Constraints for dumped tables
--
--
-- Constraints for table `email_verifications`
--
ALTER TABLE `email_verifications`
ADD CONSTRAINT `email_verifications_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE;
--
-- Constraints for table `igel`
--
ALTER TABLE `igel`
ADD CONSTRAINT `igel_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE;
--
-- Constraints for table `igel_images`
--
ALTER TABLE `igel_images`
ADD CONSTRAINT `igel_images_ibfk_1` FOREIGN KEY (`igel_id`) REFERENCES `igel` (`id`) ON DELETE CASCADE;
--
-- Constraints for table `messwerte`
--
ALTER TABLE `messwerte`
ADD CONSTRAINT `messwerte_ibfk_1` FOREIGN KEY (`igel_id`) REFERENCES `igel` (`id`) ON DELETE CASCADE;
--
-- Constraints for table `refresh_tokens`
--
ALTER TABLE `refresh_tokens`
ADD CONSTRAINT `refresh_tokens_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE;
COMMIT;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
@@ -2,46 +2,7 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../../shared/api_client.dart';
class IgelImage {
final int id;
final String url;
final String? thumbUrl;
final String? originalName;
final String? mime;
final int? sizeBytes;
final DateTime? createdAt; // 🆕
IgelImage({
required this.id,
required this.url,
this.thumbUrl,
this.originalName,
this.mime,
this.sizeBytes,
this.createdAt,
});
factory IgelImage.fromMap(Map<String, dynamic> m) {
// created_at kann als "YYYY-MM-DD HH:mm:ss" kommen
DateTime? parsed;
final raw = m['created_at'];
if (raw is String && raw.isNotEmpty) {
parsed = DateTime.tryParse(raw) ??
DateTime.tryParse(raw.replaceFirst(' ', 'T'));
}
return IgelImage(
id: (m['id'] as num).toInt(),
url: (m['url'] ?? '') as String,
thumbUrl: (m['thumb_url'] ?? m['thumbUrl']) as String?,
originalName: m['original_name'] as String?,
mime: m['mime'] as String?,
sizeBytes: (m['size_bytes'] as num?)?.toInt(),
createdAt: parsed,
);
}
}
import '../../igel/domain/igel_image.dart';
class IgelImagesRepository {
final ApiClient api;
@@ -60,7 +21,10 @@ class IgelImagesRepository {
/// Multipart-Upload. Gibt die neu hochgeladenen Bilder zurück.
Future<List<IgelImage>> upload(
int igelId, List<http.MultipartFile> files) async {
int igelId,
List<http.MultipartFile> files, {
List<DateTime?>? takenAt, // 🆕 optional: Aufnahmedaten pro Bild
}) async {
final uri = Uri.parse('${api.baseUrl}/igel/$igelId/images');
final req = http.MultipartRequest('POST', uri);
@@ -69,19 +33,28 @@ class IgelImagesRepository {
req.headers['Authorization'] = 'Bearer $token';
}
// WICHTIG: PHP erwartet ein Array-Feld: "files[]"
// Wir bauen für jedes geleiferte MultipartFile ein NEUES mit dem Namen "files[]".
// Dateien (Name MUSS "files[]" sein!)
for (final f in files) {
final mf = http.MultipartFile(
'files[]', // <-- entscheidend!
f.finalize(), // Stream vom bestehenden MultipartFile übernehmen
f.length, // Länge übernehmen (ist bei http >=1.x ein int)
'files[]',
f.finalize(),
f.length,
filename: f.filename,
contentType: f.contentType,
);
req.files.add(mf);
}
// Aufnahmedaten als paralleles Array "taken_at[]"
if (takenAt != null && takenAt.isNotEmpty) {
for (final dt in takenAt) {
req.files.add(http.MultipartFile.fromString(
'taken_at[]',
dt?.toIso8601String() ?? '',
));
}
}
final streamRes = await req.send();
final body = await streamRes.stream.bytesToString();
@@ -95,7 +68,7 @@ class IgelImagesRepository {
final uploaded = list
.cast<Map<String, dynamic>>()
.map(IgelImage.fromMap)
// Fallback, wenn der Upload-Response kein created_at enthält:
// Falls Upload-Response kein created_at liefert: fallback auf now()
.map((img) => img.createdAt == null
? IgelImage(
id: img.id,
@@ -109,12 +82,9 @@ class IgelImagesRepository {
: img)
.toList();
// Sicherheitsleine: Wenn der Server nichts verarbeitet hat, als Fehler behandeln.
if (uploaded.isEmpty) {
throw ApiException(
500,
'Upload fehlgeschlagen: Server hat keine Dateien empfangen (prüfe Feldname "files[]").',
);
500, 'Upload leer: Server hat keine Dateien übernommen.');
}
return uploaded;
+68 -20
View File
@@ -1,3 +1,4 @@
// lib/features/igel/domain/igel_image.dart
class IgelImage {
final int id;
final String url;
@@ -5,9 +6,14 @@ class IgelImage {
final String? originalName;
final String? mime;
final int? sizeBytes;
final DateTime? createdAt; // 🆕
IgelImage({
/// Upload-Zeit auf dem Server (DB: created_at)
final DateTime? createdAt;
/// Aufnahmezeit laut EXIF (DB: taken_at) bevorzugt für die Anzeige
final DateTime? takenAt;
const IgelImage({
required this.id,
required this.url,
this.thumbUrl,
@@ -15,24 +21,66 @@ class IgelImage {
this.mime,
this.sizeBytes,
this.createdAt,
this.takenAt,
});
factory IgelImage.fromMap(Map<String, dynamic> m) => IgelImage(
id: (m['id'] as num).toInt(),
url: (m['url'] ?? '') as String,
thumbUrl: (m['thumb_url'] ?? m['thumbUrl']) as String?,
originalName: m['original_name'] as String?,
mime: m['mime'] as String?,
sizeBytes: (m['size_bytes'] as num?)?.toInt(),
// PHP liefert created_at (TIMESTAMP) bei GET /igel/{id}/images
createdAt: (() {
final s = m['created_at'];
if (s is String && s.isNotEmpty) {
// z.B. "2025-10-21 12:34:56"
return DateTime.tryParse(s) ??
DateTime.tryParse(s.replaceFirst(' ', 'T'));
}
return null;
})(),
);
static DateTime? _parseDate(dynamic v) {
if (v == null) return null;
if (v is DateTime) return v;
if (v is String && v.isNotEmpty) {
// akzeptiere "YYYY-MM-DD HH:MM:SS" (MySQL) und ISO-8601
final s1 = DateTime.tryParse(v);
if (s1 != null) return s1;
// MySQL „YYYY-MM-DD HH:MM:SS“ -> „YYYY-MM-DDTHH:MM:SS“
final s2 = DateTime.tryParse(v.replaceFirst(' ', 'T'));
if (s2 != null) return s2;
}
return null;
}
factory IgelImage.fromMap(Map<String, dynamic> m) {
return IgelImage(
id: (m['id'] as num).toInt(),
url: (m['url'] ?? '') as String,
thumbUrl: (m['thumb_url'] ?? m['thumbUrl']) as String?,
originalName: m['original_name'] as String?,
mime: m['mime'] as String?,
sizeBytes: (m['size_bytes'] as num?)?.toInt(),
createdAt: _parseDate(m['created_at']),
takenAt: _parseDate(m['taken_at']),
);
}
Map<String, dynamic> toMap() => {
'id': id,
'url': url,
if (thumbUrl != null) 'thumb_url': thumbUrl,
if (originalName != null) 'original_name': originalName,
if (mime != null) 'mime': mime,
if (sizeBytes != null) 'size_bytes': sizeBytes,
if (createdAt != null) 'created_at': createdAt!.toIso8601String(),
if (takenAt != null) 'taken_at': takenAt!.toIso8601String(),
};
IgelImage copyWith({
int? id,
String? url,
String? thumbUrl,
String? originalName,
String? mime,
int? sizeBytes,
DateTime? createdAt,
DateTime? takenAt,
}) {
return IgelImage(
id: id ?? this.id,
url: url ?? this.url,
thumbUrl: thumbUrl ?? this.thumbUrl,
originalName: originalName ?? this.originalName,
mime: mime ?? this.mime,
sizeBytes: sizeBytes ?? this.sizeBytes,
createdAt: createdAt ?? this.createdAt,
takenAt: takenAt ?? this.takenAt,
);
}
}
@@ -1,6 +1,7 @@
// 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';
@@ -15,6 +16,7 @@ import '../../../main.dart';
import '../../igel/data/igel_images_repository.dart';
import '../../igel/data/igel_repository.dart';
import '../domain/igel.dart';
import '../domain/igel_image.dart';
// Messwerte
import '../../messwerte/domain/messwert.dart';
@@ -173,21 +175,46 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
try {
final picker = ImagePicker();
final picks = await picker.pickMultiImage(
maxWidth: 4096, maxHeight: 4096, imageQuality: 90);
maxWidth: 4096,
maxHeight: 4096,
imageQuality: 90,
);
if (picks.isEmpty) return;
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)
if (kIsWeb) {
final bytes = await x.readAsBytes();
files.add(http.MultipartFile.fromBytes('files[]', bytes,
filename: x.name, contentType: _mimeFromName(x.name)));
files.add(http.MultipartFile.fromBytes(
'files[]',
bytes,
filename: x.name,
contentType: _mimeFromName(x.name), // nutzt deine bestehende Helper
));
} else {
files.add(
await http.MultipartFile.fromPath('files[]', File(x.path).path));
files.add(await http.MultipartFile.fromPath(
'files[]',
File(x.path).path,
contentType: _mimeFromName(x.name),
));
}
}
await imagesRepo.upload(widget.igelId, files);
// 3) Upload + taken_at[] mitgeben
await imagesRepo.upload(
widget.igelId,
files,
takenAt: taken, // <- entscheidend
);
// 4) Liste neu laden & UI aktualisieren
images = await imagesRepo.list(widget.igelId);
if (mounted) {
setState(() {});
@@ -240,6 +267,44 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
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':
@@ -788,7 +853,12 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
final thumb = img.thumbUrl ?? img.url;
final String? ts = (() {
final dt = img.createdAt;
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')
@@ -9,6 +9,7 @@ import 'package:http/http.dart' as http;
import '../../../main.dart';
import '../../igel/data/igel_images_repository.dart';
import '../domain/igel_image.dart';
class IgelGalleryScreen extends ConsumerStatefulWidget {
const IgelGalleryScreen(
+16
View File
@@ -182,6 +182,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.7"
exif:
dependency: "direct main"
description:
name: exif
sha256: a7980fdb3b7ffcd0b035e5b8a5e1eef7cadfe90ea6a4e85ebb62f87b96c7a172
url: "https://pub.dev"
source: hosted
version: "3.3.0"
ffi:
dependency: transitive
description:
@@ -693,6 +701,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.0"
sprintf:
dependency: transitive
description:
name: sprintf
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
stack_trace:
dependency: transitive
description:
+1
View File
@@ -13,6 +13,7 @@ dependencies:
image_picker: ^1.0.7
intl: ^0.20.2
http_parser: ^4.0.2
exif: ^3.3.0
dev_dependencies:
build_runner: ^2.4.11