From 66c20e628f05aeb3997b1c1e37f41d31f227bc9a Mon Sep 17 00:00:00 2001 From: Herwig Birke Date: Thu, 23 Oct 2025 20:58:16 +0200 Subject: [PATCH] upload, kleine thumbs --- api/hedgehogs-settings.php | 56 ++ api/hedgehogs.php | 685 ++++++++++++++++++ api/hedgehogs.sql | 250 +++++++ .../igel/data/igel_images_repository.dart | 72 +- lib/features/igel/domain/igel_image.dart | 88 ++- .../igel/presentation/igel_detail_screen.dart | 84 ++- .../presentation/igel_gallery_screen.dart | 1 + pubspec.lock | 16 + pubspec.yaml | 1 + 9 files changed, 1175 insertions(+), 78 deletions(-) create mode 100644 api/hedgehogs-settings.php create mode 100644 api/hedgehogs.php create mode 100644 api/hedgehogs.sql diff --git a/api/hedgehogs-settings.php b/api/hedgehogs-settings.php new file mode 100644 index 0000000..98868f1 --- /dev/null +++ b/api/hedgehogs-settings.php @@ -0,0 +1,56 @@ + diff --git a/api/hedgehogs.php b/api/hedgehogs.php new file mode 100644 index 0000000..7348e88 --- /dev/null +++ b/api/hedgehogs.php @@ -0,0 +1,685 @@ + '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; +*/ +?> diff --git a/api/hedgehogs.sql b/api/hedgehogs.sql new file mode 100644 index 0000000..458fa18 --- /dev/null +++ b/api/hedgehogs.sql @@ -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 */; diff --git a/lib/features/igel/data/igel_images_repository.dart b/lib/features/igel/data/igel_images_repository.dart index 83b8244..c6c2a0a 100644 --- a/lib/features/igel/data/igel_images_repository.dart +++ b/lib/features/igel/data/igel_images_repository.dart @@ -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 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> upload( - int igelId, List files) async { + int igelId, + List files, { + List? 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(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; diff --git a/lib/features/igel/domain/igel_image.dart b/lib/features/igel/domain/igel_image.dart index 87c83df..9a6c82d 100644 --- a/lib/features/igel/domain/igel_image.dart +++ b/lib/features/igel/domain/igel_image.dart @@ -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 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 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 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, + ); + } } diff --git a/lib/features/igel/presentation/igel_detail_screen.dart b/lib/features/igel/presentation/igel_detail_screen.dart index f17ae85..cb37b8c 100644 --- a/lib/features/igel/presentation/igel_detail_screen.dart +++ b/lib/features/igel/presentation/igel_detail_screen.dart @@ -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 { 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 = []; + final taken = []; + for (final x in picks) { + // 1) EXIF-Aufnahmezeit lesen + final exifDt = await _readExifTakenAt(x); + taken.add(exifDt); + + // 2) Datei-Part bauen (Name MUSS 'files[]' sein; Typ aus Dateiendung) 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 { return null; } + Future _readExifTakenAt(XFile x) async { + try { + // Bytes laden (Web & Mobile kompatibel) + final bytes = await x.readAsBytes(); + final tags = await readExifFromBytes(bytes); + + // Reihenfolge: Original -> Digitized -> Image DateTime (Fallback) + final raw = tags['EXIF DateTimeOriginal']?.printable ?? + tags['EXIF DateTimeDigitized']?.printable ?? + tags['Image DateTime']?.printable; + + if (raw is String && raw.isNotEmpty) { + // EXIF-Format: "YYYY:MM:DD HH:MM:SS" + // -> normalisieren auf "YYYY-MM-DDTHH:MM:SS" + // erste zwei ':' in Datum durch '-' ersetzen, Space zu 'T' + var s = raw; + // die ersten beiden ':' ersetzen (Jahr:Monat:Tag) + final first = s.indexOf(':'); + if (first > 0) { + final second = s.indexOf(':', first + 1); + if (second > 0) { + s = s.substring(0, first) + + '-' + + s.substring(first + 1, second) + + '-' + + s.substring(second + 1); + } + } + s = s.replaceFirst(' ', 'T'); + // jetzt sollte es parsebar sein + return DateTime.tryParse(s); + } + } catch (_) { + // EXIF fehlt oder nicht lesbar → null + } + return null; + } + IconData? _genderIcon(String? g) { switch (g) { case 'männlich': @@ -788,7 +853,12 @@ class _IgelDetailState extends ConsumerState { 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') diff --git a/lib/features/igel/presentation/igel_gallery_screen.dart b/lib/features/igel/presentation/igel_gallery_screen.dart index 45a97b7..50653bd 100644 --- a/lib/features/igel/presentation/igel_gallery_screen.dart +++ b/lib/features/igel/presentation/igel_gallery_screen.dart @@ -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( diff --git a/pubspec.lock b/pubspec.lock index 6c71707..93a379b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 3bd184d..610dbe4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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