From 74414e7728239cdb6ea5f9983df6562fbe4808c8 Mon Sep 17 00:00:00 2001 From: Herwig Birke Date: Fri, 24 Oct 2025 23:34:56 +0200 Subject: [PATCH] igel share --- api/hedgehogs.php | 916 +++++++++++++----- lib/app_router.dart | 29 +- lib/features/igel/data/access_helper.dart | 19 + lib/features/igel/data/share.dart | 66 ++ lib/features/igel/data/share_service.dart | 85 ++ .../igel/presentation/igel_detail_screen.dart | 572 +++++------ .../igel/presentation/igel_list_screen.dart | 9 +- .../igel/presentation/share_bottom_sheet.dart | 214 ++++ .../igel/presentation/share_igel_page.dart | 103 ++ 9 files changed, 1493 insertions(+), 520 deletions(-) create mode 100644 lib/features/igel/data/access_helper.dart create mode 100644 lib/features/igel/data/share.dart create mode 100644 lib/features/igel/data/share_service.dart create mode 100644 lib/features/igel/presentation/share_bottom_sheet.dart create mode 100644 lib/features/igel/presentation/share_igel_page.dart diff --git a/api/hedgehogs.php b/api/hedgehogs.php index 37f423f..c32a8ff 100644 --- a/api/hedgehogs.php +++ b/api/hedgehogs.php @@ -2,9 +2,9 @@ /** * 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) + * - Igel CRUD (+ Sharing: viewer/editor/owner) + * - Bilder (Liste/Upload/Löschen) + * - Messwerte (Liste/Anlegen/Aktualisieren/Löschen) * * Konfiguration: require_once 'hedgehogs-settings.php'; */ @@ -20,11 +20,16 @@ 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; } + 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; + function str_ends_with($haystack, $needle) + { + if ($needle === '') + return true; $len = strlen($needle); return $len <= strlen($haystack) && substr($haystack, -$len) === $needle; } @@ -37,9 +42,12 @@ 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-Methods: GET,POST,PUT,PATCH,DELETE,OPTIONS'); header('Access-Control-Allow-Headers: Content-Type, Authorization'); -if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') { http_response_code(204); exit; } +if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') { + http_response_code(204); + exit; +} // --- DB ---------------------------------------------------------------------- $pdo = db(); @@ -57,31 +65,83 @@ if (isset($_GET['r']) && is_string($_GET['r']) && $_GET['r'] !== '') { $script = $_SERVER['SCRIPT_NAME'] ?? ''; if ($script && str_starts_with($path, $script)) { $path = substr($path, strlen($script)); - if ($path === '') $path = '/'; + 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); } + 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); } + $mid = (int) $m[1]; + if ($method === 'PUT') { + return messwerte_update($pdo, $uid, $mid); + } + 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); } + $imgId = (int) $m[1]; + if ($method === 'DELETE') { + return igel_images_delete($pdo, $uid, $imgId); + } + } + + // --- Sharing-Routen -------------------------------------------------------- + // /igel/{id}/shares + if (preg_match('#^/igel/(\d+)/shares$#', $path, $m)) { + $uid = require_user($pdo); + $hid = (int) $m[1]; + if ($method === 'GET') { + return shares_list($pdo, $uid, $hid); + } + if ($method === 'POST') { + return shares_create($pdo, $uid, $hid); + } + } + + // /shares/{shareId} + if (preg_match('#^/shares/(\d+)$#', $path, $m)) { + $uid = require_user($pdo); + $sid = (int) $m[1]; + if ($method === 'PATCH') { + return shares_update_role($pdo, $uid, $sid); + } + if ($method === 'DELETE') { + return shares_revoke_or_leave($pdo, $uid, $sid); + } + } + + // /invites/accept + if ($path === '/invites/accept' && $method === 'POST') { + $uid = require_user($pdo); + return invites_accept($pdo, $uid); + } + + // /me/shared + if ($path === '/me/shared' && $method === 'GET') { + $uid = require_user($pdo); + return me_shared($pdo, $uid); } // --- Igel + Unterressourcen ----------------------------------------------- @@ -89,35 +149,53 @@ try { $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); } + 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); } + $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); } + $igId = (int) $m[1]; + if ($method === 'GET') { + return igel_images_list($pdo, $uid, $igId); + } + if ($method === 'POST') { + return igel_images_upload($pdo, $uid, $igId); + } } // /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); } + $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()); + error_log('[hedgehogs.php] Exception: ' . $e->getMessage()); json(['error' => 'Server error'], 500); } @@ -125,79 +203,101 @@ try { // AUTH // ============================================================================= -function auth_register(PDO $pdo): void { +function auth_register(PDO $pdo): void +{ $in = body_json(); - $email = strtolower(trim((string)($in['email'] ?? ''))); - $pass = (string)($in['password'] ?? ''); + $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; } + $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; } + 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 { +function auth_login(PDO $pdo): void +{ $in = body_json(); - $email = strtolower(trim((string)($in['email'] ?? ''))); - $pass = (string)($in['password'] ?? ''); + $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; + if (!$row || !password_verify($pass, (string) $row['password_hash'])) { + json(['error' => 'Invalid credentials'], 401); + return; } - $uid = (int)$row['id']; + $uid = (int) $row['id']; [$access, $refresh] = issue_tokens($pdo, $uid); json(['access_token' => $access, 'refresh_token' => $refresh]); } -function auth_refresh(PDO $pdo): void { +function auth_refresh(PDO $pdo): void +{ $in = body_json(); - $refresh = (string)($in['refresh_token'] ?? ''); + $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']; + 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 { +function auth_logout(PDO $pdo): void +{ $in = body_json(); - $refresh = (string)($in['refresh_token'] ?? ''); + $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 { +function require_user(PDO $pdo): int +{ $hdr = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; - if (!preg_match('/Bearer\s+(.*)/i', $hdr, $m)) { json(['error' => 'Unauthorized'], 401); exit; } + 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'); + 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; + json(['error' => 'Unauthorized'], 401); + exit; } } -function issue_tokens(PDO $pdo, int $uid): array { +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)); @@ -206,38 +306,80 @@ function issue_tokens(PDO $pdo, int $uid): array { return [$access, $refresh]; } +// ============================================================================= +// SHARING: Ownership & Access Checks +// ============================================================================= + +function is_owner(PDO $pdo, int $userId, int $hedgehogId): bool +{ + $st = $pdo->prepare('SELECT 1 FROM igel WHERE id=? AND user_id=?'); + $st->execute([$hedgehogId, $userId]); + return (bool) $st->fetchColumn(); +} + +function can_view(PDO $pdo, int $userId, int $hedgehogId): bool +{ + if (is_owner($pdo, $userId, $hedgehogId)) + return true; + $st = $pdo->prepare("SELECT 1 FROM hedgehog_shares + WHERE hedgehog_id=? AND status='accepted' + AND role IN ('viewer','editor') + AND target_user_id=?"); + $st->execute([$hedgehogId, $userId]); + return (bool) $st->fetchColumn(); +} + +function can_edit(PDO $pdo, int $userId, int $hedgehogId): bool +{ + if (is_owner($pdo, $userId, $hedgehogId)) + return true; + $st = $pdo->prepare("SELECT 1 FROM hedgehog_shares + WHERE hedgehog_id=? AND status='accepted' + AND role='editor' + AND target_user_id=?"); + $st->execute([$hedgehogId, $userId]); + return (bool) $st->fetchColumn(); +} + // ============================================================================= // IGEL // ============================================================================= -function igel_list(PDO $pdo, int $uid): void { +function igel_list(PDO $pdo, int $uid): void +{ $stmt = $pdo->prepare('SELECT id, name, gender, feature, note, rescued_at, location, 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 { +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; - $rescuedAt = isset($in['rescued_at']) ? (string)$in['rescued_at'] : null; // "YYYY-MM-DD" - $location = isset($in['location']) ? trim((string)$in['location']) : null; + $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; + $rescuedAt = isset($in['rescued_at']) ? (string) $in['rescued_at'] : null; // "YYYY-MM-DD" + $location = isset($in['location']) ? trim((string) $in['location']) : null; - if ($name === '') { json(['error' => 'Name required'], 422); return; } + if ($name === '') { + json(['error' => 'Name required'], 422); + return; + } if ($rescuedAt !== null && $rescuedAt !== '' && strtotime($rescuedAt) === false) { - json(['error' => 'Invalid rescued_at (expected YYYY-MM-DD)'], 422); return; + json(['error' => 'Invalid rescued_at (expected YYYY-MM-DD)'], 422); + return; } if ($location !== null && strlen($location) > 255) { - json(['error' => 'Location too long (max 255)'], 422); return; + json(['error' => 'Location too long (max 255)'], 422); + return; } $stmt = $pdo->prepare( 'INSERT INTO igel(user_id, name, gender, feature, note, rescued_at, location) VALUES(?,?,?,?,?,?,?)' ); $stmt->execute([$uid, $name, $gender, $feature, $note, $rescuedAt ?: null, $location ?: null]); - $id = (int)$pdo->lastInsertId(); + $id = (int) $pdo->lastInsertId(); json([ 'id' => $id, @@ -250,41 +392,65 @@ function igel_create(PDO $pdo, int $uid): void { ], 201); } -function igel_get(PDO $pdo, int $uid, int $id): void { - $stmt = $pdo->prepare('SELECT id, name, gender, feature, note, rescued_at, location, created_at, updated_at FROM igel WHERE id=? AND user_id=?'); - $stmt->execute([$id, $uid]); +function igel_get(PDO $pdo, int $uid, int $id): void +{ + if (!can_view($pdo, $uid, $id)) { + json(['error' => 'Not found'], 404); + return; + } + $stmt = $pdo->prepare('SELECT id, name, gender, feature, note, rescued_at, location, created_at, updated_at FROM igel WHERE id=?'); + $stmt->execute([$id]); $row = $stmt->fetch(PDO::FETCH_ASSOC); - if (!$row) { json(['error' => 'Not found'], 404); return; } + 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; - $rescuedAt = array_key_exists('rescued_at', $in) ? (string)$in['rescued_at'] : null; - $location = array_key_exists('location', $in) ? trim((string)$in['location']) : null; +function igel_update(PDO $pdo, int $uid, int $id): void +{ + if (!can_edit($pdo, $uid, $id)) { + json(['error' => 'Forbidden'], 403); + return; + } - if ($name === '') { json(['error' => 'Name required'], 422); return; } + $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; + $rescuedAt = array_key_exists('rescued_at', $in) ? (string) $in['rescued_at'] : null; + $location = array_key_exists('location', $in) ? trim((string) $in['location']) : null; + + if ($name === '') { + json(['error' => 'Name required'], 422); + return; + } if ($rescuedAt !== null && $rescuedAt !== '' && strtotime($rescuedAt) === false) { - json(['error' => 'Invalid rescued_at (expected YYYY-MM-DD)'], 422); return; + json(['error' => 'Invalid rescued_at (expected YYYY-MM-DD)'], 422); + return; } if ($location !== null && strlen($location) > 255) { - json(['error' => 'Location too long (max 255)'], 422); return; + json(['error' => 'Location too long (max 255)'], 422); + return; } $stmt = $pdo->prepare( - 'UPDATE igel SET name=?, gender=?, feature=?, note=?, rescued_at=?, location=? WHERE id=? AND user_id=?' + 'UPDATE igel SET name=?, gender=?, feature=?, note=?, rescued_at=?, location=? WHERE id=?' ); - $stmt->execute([$name, $gender, $feature, $note, $rescuedAt ?: null, $location ?: null, $id, $uid]); + $stmt->execute([$name, $gender, $feature, $note, $rescuedAt ?: null, $location ?: null, $id]); 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]); +function igel_delete(PDO $pdo, int $uid, int $id): void +{ + if (!is_owner($pdo, $uid, $id)) { + json(['error' => 'Forbidden'], 403); + return; + } + $stmt = $pdo->prepare('DELETE FROM igel WHERE id=?'); + $stmt->execute([$id]); json(['ok' => true]); } @@ -292,20 +458,21 @@ function igel_delete(PDO $pdo, int $uid, int $id): void { // 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; } +function igel_images_list(PDO $pdo, int $uid, int $igId): void +{ + if (!can_view($pdo, $uid, $igId)) { + json(['error' => 'Not found'], 404); + return; + } try { - $stmt=$pdo->prepare('SELECT id,url,thumb_url,original_name,mime,size_bytes,created_at,taken_at + $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 + $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); @@ -318,14 +485,18 @@ function igel_images_list(PDO $pdo, int $uid, int $igId): void { 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; } +function igel_images_upload(PDO $pdo, int $uid, int $igId): void +{ + if (!can_edit($pdo, $uid, $igId)) { + json(['error' => 'Forbidden'], 403); + return; + } // multipart/form-data: files[] - if (!isset($_FILES['files'])) { json(['error' => 'No files'], 400); return; } + if (!isset($_FILES['files'])) { + json(['error' => 'No files'], 400); + return; + } $files = $_FILES['files']; // Optionales paralleles Feld: taken_at[] (ISO-8601 vom Client aus EXIF) @@ -336,50 +507,66 @@ function igel_images_upload(PDO $pdo, int $uid, int $igId): void { $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]; + 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; + 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'; } + 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); } + if (!is_dir($destDir)) { + @mkdir($destDir, 0755, true); + } $dest = $destDir . '/' . $fn; - if (!move_uploaded_file($tmp, $dest)) continue; + if (!move_uploaded_file($tmp, $dest)) + continue; // Thumb $thumbUrl = null; try { $thumbDir = rtrim(UPLOAD_THUMB_DIR, '/'); - if (!is_dir($thumbDir)) { @mkdir($thumbDir, 0755, true); } + 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; + $thumbUrl = rtrim(UPLOAD_BASE_URL, '/') . '/thumbs/' . $fn; } catch (Throwable $e) { $thumbUrl = null; // ok } - $url = rtrim(UPLOAD_BASE_URL,'/') . '/' . $fn; + $url = rtrim(UPLOAD_BASE_URL, '/') . '/' . $fn; // EXIF-Aufnahmezeit (taken_at[]) → DATETIME oder NULL $takenAtMysql = null; if (isset($takenArr[$i])) { - $raw = (string)$takenArr[$i]; + $raw = (string) $takenArr[$i]; $ts = strtotime($raw); if ($ts !== false) { $takenAtMysql = date('Y-m-d H:i:s', $ts); @@ -392,12 +579,12 @@ function igel_images_upload(PDO $pdo, int $uid, int $igId): void { VALUES(?,?,?,?,?,?,?)'); $stmt->execute([$igId, $url, $thumbUrl, $orig, $mime, $size, $takenAtMysql]); - $id = (int)$pdo->lastInsertId(); + $id = (int) $pdo->lastInsertId(); // 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]; + $times = $row->fetch(PDO::FETCH_ASSOC) ?: ['created_at' => null, 'taken_at' => null]; $out[] = [ 'id' => $id, @@ -406,61 +593,89 @@ function igel_images_upload(PDO $pdo, int $uid, int $igId): void { 'original_name' => $orig, 'mime' => $mime, 'size_bytes' => $size, - 'created_at' => (string)($times['created_at'] ?? ''), - 'taken_at' => (string)($times['taken_at'] ?? ''), + '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 +function igel_images_delete(PDO $pdo, int $uid, int $imgId): void +{ + // Bild + zugehörigen Igel finden + $stmt = $pdo->prepare('SELECT i.id, i.url, i.thumb_url, i.igel_id FROM igel_images i - JOIN igel g ON g.id = i.igel_id - WHERE i.id=? AND g.user_id=?'); - $stmt->execute([$imgId,$uid]); + WHERE i.id=?'); + $stmt->execute([$imgId]); $row = $stmt->fetch(PDO::FETCH_ASSOC); - if (!$row) { json(['error'=>'Not found'],404); return; } + if (!$row) { + json(['error' => 'Not found'], 404); + return; + } + + $igelId = (int) $row['igel_id']; + if (!can_edit($pdo, $uid, $igelId)) { + json(['error' => 'Forbidden'], 403); + return; + } // Dateien optional entfernen try { - $url = (string)$row['url']; - $thumb = (string)($row['thumb_url'] ?? ''); + $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); + $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); + $pt = rtrim(UPLOAD_THUMB_DIR, '/') . '/' . $fnT; + if (is_file($pt)) + @unlink($pt); } - } catch (Throwable $e) {} + } catch (Throwable $e) { + } - $del=$pdo->prepare('DELETE FROM igel_images WHERE id=?'); + $del = $pdo->prepare('DELETE FROM igel_images WHERE id=?'); $del->execute([$imgId]); - json(['ok'=>true]); + 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'); +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'); + 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); + $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 @@ -470,13 +685,17 @@ function create_thumbnail(string $src, string $dest, int $maxW, int $maxH): void imagesavealpha($thumb, true); } - imagecopyresampled($thumb, $im, 0,0,0,0, $nw,$nh,$w,$h); + 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); + 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); @@ -486,13 +705,14 @@ function create_thumbnail(string $src, string $dest, int $maxW, int $maxH): void // 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; } +function messwerte_list(PDO $pdo, int $uid, int $igId): void +{ + if (!can_view($pdo, $uid, $igId)) { + json(['error' => 'Not found'], 404); + return; + } - $stmt=$pdo->prepare('SELECT id, igel_id, DATE_FORMAT(datum, "%Y-%m-%dT%H:%i:%sZ") AS datum, + $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'); @@ -500,102 +720,313 @@ function messwerte_list(PDO $pdo, int $uid, int $igId): void { 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; } +function messwerte_create(PDO $pdo, int $uid, int $igId): void +{ + if (!can_edit($pdo, $uid, $igId)) { + json(['error' => 'Forbidden'], 403); + 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; + $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; } + 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 = $pdo->prepare('INSERT INTO messwerte (igel_id, datum, gewicht, behandlung, bemerkung) VALUES(?,?,?,?,?)'); $stmt->execute([$igId, $mysql, $gewicht, $behandlung, $bemerkung]); - $id = (int)$pdo->lastInsertId(); + $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 + '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; } +function messwerte_update(PDO $pdo, int $uid, int $mid): void +{ + // igel_id bestimmen + $own = $pdo->prepare('SELECT m.igel_id FROM messwerte m WHERE m.id=?'); + $own->execute([$mid]); + $row = $own->fetch(PDO::FETCH_ASSOC); + if (!$row) { + json(['error' => 'Not found'], 404); + return; + } + if (!can_edit($pdo, $uid, (int) $row['igel_id'])) { + json(['error' => 'Forbidden'], 403); + return; + } $in = body_json(); // Alle Felder optional, aber validieren, falls vorhanden $set = []; - $args= []; + $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); + $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; + $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 (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; } + if (empty($set)) { + json(['error' => 'No fields'], 400); + return; + } - $args[]=$mid; - $sql='UPDATE messwerte SET '.implode(',', $set).' WHERE id=?'; - $stmt=$pdo->prepare($sql); + $args[] = $mid; + $sql = 'UPDATE messwerte SET ' . implode(',', $set) . ' WHERE id=?'; + $stmt = $pdo->prepare($sql); $stmt->execute($args); - json(['ok'=>true]); + 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; } +function messwerte_delete(PDO $pdo, int $uid, int $mid): void +{ + // igel_id bestimmen + $own = $pdo->prepare('SELECT m.igel_id FROM messwerte m WHERE m.id=?'); + $own->execute([$mid]); + $row = $own->fetch(PDO::FETCH_ASSOC); + if (!$row) { + json(['error' => 'Not found'], 404); + return; + } + if (!can_edit($pdo, $uid, (int) $row['igel_id'])) { + json(['error' => 'Forbidden'], 403); + return; + } - $del=$pdo->prepare('DELETE FROM messwerte WHERE id=?'); + $del = $pdo->prepare('DELETE FROM messwerte WHERE id=?'); $del->execute([$mid]); - json(['ok'=>true]); + json(['ok' => true]); +} + +// ============================================================================= +// SHARES: Endpoints +// ============================================================================= + +function shares_list(PDO $pdo, int $uid, int $hedgehogId): void +{ + if (!can_view($pdo, $uid, $hedgehogId)) { + json(['error' => 'Forbidden'], 403); + return; + } + + $st = $pdo->prepare(" + SELECT + s.id, + s.hedgehog_id, + s.owner_user_id, + s.target_user_id, + s.invited_email, + tu.email AS target_email, -- <— NEU: E-Mail des akzeptierten Users + s.role, + s.status, + s.created_at, + s.updated_at + FROM hedgehog_shares s + LEFT JOIN users tu ON tu.id = s.target_user_id + WHERE s.hedgehog_id = ? + AND s.status IN ('pending','accepted') + ORDER BY s.created_at DESC + "); + + $st->execute([$hedgehogId]); + json($st->fetchAll()); +} + +function shares_create(PDO $pdo, int $uid, int $hedgehogId): void +{ + // nur Owner darf teilen + if (!is_owner($pdo, $uid, $hedgehogId)) { + json(['error' => 'Forbidden'], 403); + return; + } + + $in = body_json(); + $email = strtolower(trim((string) ($in['email'] ?? ''))); + $role = in_array(($in['role'] ?? 'viewer'), ['viewer', 'editor'], true) ? $in['role'] : 'viewer'; + if ($email === '') { + json(['error' => 'Email required'], 422); + return; + } + + // existiert der User schon? + $st = $pdo->prepare('SELECT id FROM users WHERE email=?'); + $st->execute([$email]); + $target = $st->fetch(); + + if ($target) { + // sofort akzeptiert + $ins = $pdo->prepare("INSERT INTO hedgehog_shares (hedgehog_id, owner_user_id, target_user_id, role, status) + VALUES (?,?,?,?, 'accepted')"); + $ins->execute([$hedgehogId, $uid, (int) $target['id'], $role]); + json(['status' => 'accepted'], 201); + } else { + $token = bin2hex(random_bytes(32)); + $ins = $pdo->prepare("INSERT INTO hedgehog_shares + (hedgehog_id, owner_user_id, invited_email, role, status, invite_token, expires_at) + VALUES (?,?,?,?, 'pending', ?, DATE_ADD(NOW(), INTERVAL 7 DAY))"); + $ins->execute([$hedgehogId, $uid, $email, $role, $token]); + + // TODO: sendInviteEmail($email, $token, $hedgehogId); + json(['status' => 'pending'], 201); + } +} + +function shares_update_role(PDO $pdo, int $uid, int $shareId): void +{ + $st = $pdo->prepare('SELECT id, hedgehog_id FROM hedgehog_shares WHERE id=?'); + $st->execute([$shareId]); + $share = $st->fetch(); + if (!$share) { + json(['error' => 'Not found'], 404); + return; + } + + // nur Owner des Igels + if (!is_owner($pdo, $uid, (int) $share['hedgehog_id'])) { + json(['error' => 'Forbidden'], 403); + return; + } + + $in = body_json(); + $role = (string) ($in['role'] ?? ''); + if (!in_array($role, ['viewer', 'editor'], true)) { + json(['error' => 'Invalid role'], 422); + return; + } + + $up = $pdo->prepare("UPDATE hedgehog_shares SET role=?, updated_at=NOW() WHERE id=?"); + $up->execute([$role, $shareId]); + json(['ok' => true]); +} + +function shares_revoke_or_leave(PDO $pdo, int $uid, int $shareId): void +{ + $st = $pdo->prepare('SELECT id, hedgehog_id, target_user_id FROM hedgehog_shares WHERE id=?'); + $st->execute([$shareId]); + $s = $st->fetch(); + if (!$s) { + json(['error' => 'Not found'], 404); + return; + } + + $hedgehogId = (int) $s['hedgehog_id']; + $targetId = (int) ($s['target_user_id'] ?? 0); + + // Owner darf immer; eingeladener User darf seine eigene Freigabe beenden + if (!is_owner($pdo, $uid, $hedgehogId) && $uid !== $targetId) { + json(['error' => 'Forbidden'], 403); + return; + } + + $up = $pdo->prepare("UPDATE hedgehog_shares SET status='revoked', updated_at=NOW() WHERE id=?"); + $up->execute([$shareId]); + json(['ok' => true]); +} + +function invites_accept(PDO $pdo, int $uid): void +{ + $in = body_json(); + $token = (string) ($in['token'] ?? ''); + if ($token === '') { + json(['error' => 'Token required'], 422); + return; + } + + $st = $pdo->prepare("SELECT * FROM hedgehog_shares + WHERE invite_token=? AND status='pending' + AND (expires_at IS NULL OR expires_at>NOW())"); + $st->execute([$token]); + $s = $st->fetch(); + if (!$s) { + json(['error' => 'Invalid or expired'], 400); + return; + } + + $up = $pdo->prepare("UPDATE hedgehog_shares + SET target_user_id=?, status='accepted', invite_token=NULL, updated_at=NOW() + WHERE id=?"); + $up->execute([$uid, (int) $s['id']]); + json(['ok' => true]); +} + +function me_shared(PDO $pdo, int $uid): void +{ + $st = $pdo->prepare("SELECT s.hedgehog_id AS id, + COALESCE(i.name, CONCAT('Igel #', s.hedgehog_id)) AS name, + s.role, s.owner_user_id, + u.email AS owner_email + FROM hedgehog_shares s + JOIN igel i ON i.id = s.hedgehog_id + JOIN users u ON u.id = s.owner_user_id + WHERE s.target_user_id=? AND s.status='accepted' + ORDER BY i.id DESC"); + $st->execute([$uid]); + json($st->fetchAll()); } // ============================================================================= // Utilities // ============================================================================= -function json($data, int $code = 200): void { +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 { +function body_json(): array +{ $raw = file_get_contents('php://input'); - if ($raw === false || $raw === '') return []; + if ($raw === false || $raw === '') + return []; $data = json_decode($raw, true); return is_array($data) ? $data : []; } -function db(): PDO { +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, @@ -604,37 +1035,57 @@ function db(): PDO { return $pdo; } -function origin_allowed(string $origin, array $allowed): bool { +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'] ?? ''); + 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; + 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'])) { + 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 ($p === $oHost || (str_starts_with($p, '*.') && str_ends_with($oHost, substr($p, 1)))) + $hostOk = true; } - if (!$hostOk) continue; + if (!$hostOk) + continue; $patHasWildcardPort = str_ends_with($pat, ':*'); - $portOk = $patHasWildcardPort || ($pPort !== '' && (string)$pPort === $oPort) || ($pPort === '' && $oPort === ''); - if ($portOk) return true; + $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 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 { +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); @@ -643,16 +1094,21 @@ function jwt_encode(array $payload, string $secret): string { return implode('.', $segments); } -function jwt_decode(string $token, string $secret): array { +function jwt_decode(string $token, string $secret): array +{ $parts = explode('.', $token); - if (count($parts) !== 3) throw new Exception('bad 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'); + 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'); + if (!hash_equals($expected, $sig)) + throw new Exception('sig'); + if (isset($payload['exp']) && time() >= (int) $payload['exp']) + throw new Exception('exp'); return $payload; } diff --git a/lib/app_router.dart b/lib/app_router.dart index d779c85..b031d07 100644 --- a/lib/app_router.dart +++ b/lib/app_router.dart @@ -5,8 +5,9 @@ import 'features/auth/presentation/login_screen.dart'; import 'features/auth/presentation/register_screen.dart'; import 'features/auth/application/splash_screen.dart'; import 'features/igel/presentation/igel_list_screen.dart'; -import 'features/igel/presentation/igel_detail_screen.dart'; // <-- WICHTIG +import 'features/igel/presentation/igel_detail_screen.dart'; import 'features/igel/presentation/igel_gallery_screen.dart'; +import 'features/igel/presentation/share_igel_page.dart'; GoRouter buildRouter() => GoRouter( initialLocation: '/splash', @@ -14,22 +15,33 @@ GoRouter buildRouter() => GoRouter( GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()), GoRoute(path: '/login', builder: (_, __) => const LoginScreen()), GoRoute(path: '/register', builder: (_, __) => const RegisterScreen()), - - // Liste GoRoute(path: '/igel', builder: (_, __) => const IgelListScreen()), - // Detail: /igel/:id (z. B. /igel/3) + // ⚠️ Wichtig: ZUERST die exakte Route, damit sie nicht von /igel/:id „geschluckt“ wird + GoRoute( + path: '/igel/shared', builder: (_, __) => const SharedIgelRoute()), + + // Danach die parametrisierte Route GoRoute( path: '/igel/:id', builder: (ctx, st) { - final idStr = st.pathParameters['id'] ?? ''; - final id = int.tryParse(idStr); + final id = int.tryParse(st.pathParameters['id'] ?? ''); if (id == null) { return const Scaffold( body: Center(child: Text('Fehlerhafte ID')), ); } - return IgelDetailScreen(igelId: id); + // NEU: Query-Parameter lesen + final role = + st.uri.queryParameters['role']; // "viewer" | "editor" | null + final from = st.uri.queryParameters['from']; // "shared" | null + + return IgelDetailScreen( + igelId: id, + initialRole: + role, // <-- optionales neues Argument (siehe Schritt 3) + from: from, // <-- optionales neues Argument (siehe Schritt 3) + ); }, ), GoRoute( @@ -40,7 +52,8 @@ GoRouter buildRouter() => GoRouter( int.tryParse(st.uri.queryParameters['index'] ?? '0') ?? 0; if (id == null) { return const Scaffold( - body: Center(child: Text('Fehlerhafte ID'))); + body: Center(child: Text('Fehlerhafte ID')), + ); } return IgelGalleryScreen(igelId: id, initialIndex: initial); }, diff --git a/lib/features/igel/data/access_helper.dart b/lib/features/igel/data/access_helper.dart new file mode 100644 index 0000000..786ba0c --- /dev/null +++ b/lib/features/igel/data/access_helper.dart @@ -0,0 +1,19 @@ +import 'share.dart'; + +class AccessCache { + // Map + final _map = {}; + void setRole(int igelId, AccessRole role) => _map[igelId] = role; + AccessRole getRole(int igelId) => _map[igelId] ?? AccessRole.none; +} + +AccessRole roleFromString(String s) { + switch (s) { + case 'viewer': + return AccessRole.viewer; + case 'editor': + return AccessRole.editor; + default: + return AccessRole.none; + } +} diff --git a/lib/features/igel/data/share.dart b/lib/features/igel/data/share.dart new file mode 100644 index 0000000..c55ca99 --- /dev/null +++ b/lib/features/igel/data/share.dart @@ -0,0 +1,66 @@ +class Share { + final int id; + final int hedgehogId; + final int ownerUserId; + final int? targetUserId; + final String? invitedEmail; + final String? targetEmail; // bei accepted (vom Backend) + final String role; // 'viewer' | 'editor' + final String status; // 'pending' | 'accepted' | 'revoked' + final DateTime? createdAt; + final DateTime? updatedAt; + + Share({ + required this.id, + required this.hedgehogId, + required this.ownerUserId, + this.targetUserId, + this.invitedEmail, + this.targetEmail, + required this.role, + required this.status, + this.createdAt, + this.updatedAt, + }); + + factory Share.fromJson(Map j) => Share( + id: j['id'] as int, + hedgehogId: j['hedgehog_id'] as int, + ownerUserId: j['owner_user_id'] as int, + targetUserId: j['target_user_id'] as int?, + invitedEmail: j['invited_email'] as String?, + targetEmail: j['target_email'] as String?, + role: j['role'] as String, + status: j['status'] as String, + createdAt: + j['created_at'] != null ? DateTime.tryParse(j['created_at']) : null, + updatedAt: + j['updated_at'] != null ? DateTime.tryParse(j['updated_at']) : null, + ); +} + +class SharedIgelItem { + final int id; // igel.id + final String name; + final String role; // viewer|editor + final int ownerUserId; + final String? ownerEmail; // <— NEU + + SharedIgelItem({ + required this.id, + required this.name, + required this.role, + required this.ownerUserId, + this.ownerEmail, // <— NEU + }); + + factory SharedIgelItem.fromJson(Map j) => SharedIgelItem( + id: j['id'] as int, + name: j['name'] as String, + role: j['role'] as String, + ownerUserId: j['owner_user_id'] as int, + ownerEmail: j['owner_email'] as String?, // <— NEU + ); +} + +enum AccessRole { owner, editor, viewer, none } diff --git a/lib/features/igel/data/share_service.dart b/lib/features/igel/data/share_service.dart new file mode 100644 index 0000000..de8ca5f --- /dev/null +++ b/lib/features/igel/data/share_service.dart @@ -0,0 +1,85 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'share.dart'; + +class ShareService { + ShareService({http.Client? client, required this.baseUrl}) + : _client = client ?? http.Client(); + final http.Client _client; + final String baseUrl; // z.B. https://api.windesign.at + + Map _auth(String token, {Map? extra}) => + {'Authorization': 'Bearer $token', if (extra != null) ...extra}; + + Future> listShares( + {required int igelId, required String token}) async { + final r = await _client.get( + Uri.parse('$baseUrl/hedgehogs.php?r=/igel/$igelId/shares'), + headers: _auth(token), + ); + if (r.statusCode != 200) + throw Exception('listShares failed (${r.statusCode}) ${r.body}'); + final List data = jsonDecode(r.body) as List; + return data.map((e) => Share.fromJson(e as Map)).toList(); + } + + Future invite( + {required int igelId, + required String email, + required String role, + required String token}) async { + final r = await _client.post( + Uri.parse('$baseUrl/hedgehogs.php?r=/igel/$igelId/shares'), + headers: _auth(token, extra: {'Content-Type': 'application/json'}), + body: jsonEncode({'email': email, 'role': role}), + ); + if (r.statusCode != 201) + throw Exception('invite failed (${r.statusCode}) ${r.body}'); + } + + Future updateRole( + {required int shareId, + required String role, + required String token}) async { + final r = await _client.patch( + Uri.parse('$baseUrl/hedgehogs.php?r=/shares/$shareId'), + headers: _auth(token, extra: {'Content-Type': 'application/json'}), + body: jsonEncode({'role': role}), + ); + if (r.statusCode != 200) + throw Exception('updateRole failed (${r.statusCode}) ${r.body}'); + } + + Future revoke({required int shareId, required String token}) async { + final r = await _client.delete( + Uri.parse('$baseUrl/hedgehogs.php?r=/shares/$shareId'), + headers: _auth(token), + ); + if (r.statusCode != 200) + throw Exception('revoke failed (${r.statusCode}) ${r.body}'); + } + + Future acceptInvite( + {required String tokenJwt, required String inviteToken}) async { + final r = await _client.post( + Uri.parse('$baseUrl/hedgehogs.php?r=/invites/accept'), + headers: _auth(tokenJwt, extra: {'Content-Type': 'application/json'}), + body: jsonEncode({'token': inviteToken}), + ); + if (r.statusCode != 200) + throw Exception('acceptInvite failed (${r.statusCode}) ${r.body}'); + } + + Future> listSharedWithMe({required String token}) async { + final r = await _client.get( + Uri.parse('$baseUrl/hedgehogs.php?r=/me/shared'), + headers: _auth(token), + ); + if (r.statusCode != 200) + throw Exception('listSharedWithMe failed (${r.statusCode}) ${r.body}'); + final List data = jsonDecode(r.body) as List; + return data + .map((e) => SharedIgelItem.fromJson(e as Map)) + .toList(); + } +} diff --git a/lib/features/igel/presentation/igel_detail_screen.dart b/lib/features/igel/presentation/igel_detail_screen.dart index 19edd13..4e7b59d 100644 --- a/lib/features/igel/presentation/igel_detail_screen.dart +++ b/lib/features/igel/presentation/igel_detail_screen.dart @@ -11,6 +11,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../data/share.dart'; +import '../data/share_service.dart'; +import 'share_bottom_sheet.dart'; import 'package:http/http.dart' as http; import 'package:http_parser/http_parser.dart' show MediaType; import 'package:image_picker/image_picker.dart'; @@ -30,9 +33,17 @@ import '../../messwerte/data/messwerte_repository.dart'; import '../../messwerte/domain/messwert.dart'; class IgelDetailScreen extends ConsumerStatefulWidget { - const IgelDetailScreen({super.key, required this.igelId}); + final String? initialRole; // "viewer" | "editor" | null + final String? from; // "shared" | null final int igelId; + const IgelDetailScreen({ + super.key, + required this.igelId, + this.initialRole, + this.from, + }); + @override ConsumerState createState() => _IgelDetailState(); } @@ -41,54 +52,75 @@ class _IgelDetailState extends ConsumerState { late final IgelImagesRepository imagesRepo; late final IgelRepository igelRepo; late final MesswerteRepository messRepo; + ShareService? _shareService; + String? _accessToken; + Igel? igel; bool busy = true; String? err; - Igel? igel; + // --- Zugriff / Rolle --- + late final AccessRole _role; + late final bool canEdit; // owner || editor + late final bool cameFromShared; // from=shared - // Basisdaten (editierbar via AppBar-Dialog) + AccessRole _mapRole(String? r) { + switch (r) { + case 'viewer': + return AccessRole.viewer; + case 'editor': + return AccessRole.editor; + default: + return AccessRole.owner; + } + } + + // --- Controller / State --- final nameC = TextEditingController(); final featureC = TextEditingController(); final noteC = TextEditingController(); - - // Ort/Fundstelle + Gerettet am final locationC = TextEditingController(); DateTime? rescuedAt; DateTime? _initRescuedAt; String? _initLocation; + String? gender; - String? gender; // 'männlich' | 'weiblich' | 'unbekannt' | null - - // Bilder List images = []; - - // Messwerte List messwerte = []; + + // Messwert-Form bool mwShowForm = false; DateTime mwDatum = DateTime.now(); final mwGewichtC = TextEditingController(); final mwBehandlungC = TextEditingController(); final mwBemerkungC = TextEditingController(); - // Upload-Overlay + // Upload bool _uploading = false; int _uploadDone = 0; int _uploadTotal = 0; - // Export: Chart als Bild rendern final GlobalKey _chartKey = GlobalKey(); @override void initState() { super.initState(); + + _role = _mapRole(widget.initialRole); + canEdit = _role == AccessRole.owner || _role == AccessRole.editor; + cameFromShared = widget.from == 'shared'; + imagesRepo = ref.read(igelImagesRepoProvider); igelRepo = ref.read(igelRepoProvider); messRepo = ref.read(messwerteRepoProvider); - featureC.addListener(_markDirtyBasics); - noteC.addListener(_markDirtyBasics); - locationC.addListener(_markDirtyBasics); + final ts = ref.read(tokenStorageProvider); + ts.access.then((tok) { + if (mounted) setState(() => _accessToken = tok); + }); + + final base = kApiBase.replaceFirst('/hedgehogs.php?r=', ''); + _shareService = ShareService(baseUrl: base); _loadAll(); } @@ -105,59 +137,23 @@ class _IgelDetailState extends ConsumerState { super.dispose(); } - void _markDirtyBasics() { - if (mounted) setState(() {}); - } - - bool get _basicsChanged { - if (igel == null) return false; - - final newName = nameC.text.trim(); - final newFeature = - featureC.text.trim().isEmpty ? null : featureC.text.trim(); - final newNote = noteC.text.trim().isEmpty ? null : noteC.text.trim(); - - bool changed = newName != igel!.name || - newFeature != igel!.feature || - newNote != igel!.note || - gender != igel!.gender; - - // rescuedAt & location berücksichtigen - String? fmt(DateTime? d) => - d == null ? null : DateFormat('yyyy-MM-dd').format(d); - if (fmt(rescuedAt) != fmt(_initRescuedAt)) changed = true; - - final newLoc = locationC.text.trim().isEmpty ? null : locationC.text.trim(); - if (newLoc != (_initLocation?.trim())) changed = true; - - return changed; - } - Future _loadAll() async { setState(() { busy = true; err = null; }); try { - // Igel - final data = await igelRepo.get(widget.igelId); - igel = data; - nameC.text = data.name; - featureC.text = data.feature ?? ''; - noteC.text = data.note ?? ''; - gender = data.gender; + igel = await igelRepo.get(widget.igelId); + nameC.text = igel!.name; + featureC.text = igel!.feature ?? ''; + noteC.text = igel!.note ?? ''; + gender = igel!.gender; + rescuedAt = igel!.rescuedAt; + locationC.text = igel!.location ?? ''; + _initRescuedAt = igel!.rescuedAt; + _initLocation = igel!.location; - // rescuedAt + location (sofern im Model vorhanden) - rescuedAt = data.rescuedAt; - locationC.text = data.location ?? ''; - _initRescuedAt = data.rescuedAt; - _initLocation = data.location; - - // Bilder images = await imagesRepo.list(widget.igelId); - if (mounted) await _prefetchImages(context); - - // Messwerte messwerte = await messRepo.list(widget.igelId); } catch (e) { err = e.toString(); @@ -166,67 +162,53 @@ class _IgelDetailState extends ConsumerState { } } - /// Prefetcht bis zu 12 Thumbs und bis zu 3 Full-Images. - Future _prefetchImages(BuildContext context) async { - if (!mounted || images.isEmpty) return; - final thumbCount = math.min(12, images.length); - for (var i = 0; i < thumbCount; i++) { - final url = images[i].thumbUrl ?? images[i].url; - try { - await precacheImage(NetworkImage(url), context); - } catch (_) {} - } - final fullCount = math.min(3, images.length); - for (var i = 0; i < fullCount; i++) { - try { - await precacheImage(NetworkImage(images[i].url), context); - } catch (_) {} - } + bool get _basicsChanged { + if (igel == null) return false; + bool changed = nameC.text.trim() != igel!.name || + featureC.text.trim() != (igel!.feature ?? '') || + noteC.text.trim() != (igel!.note ?? '') || + gender != igel!.gender; + String? fmt(DateTime? d) => + d == null ? null : DateFormat('yyyy-MM-dd').format(d); + if (fmt(rescuedAt) != fmt(_initRescuedAt)) changed = true; + if (locationC.text.trim() != (_initLocation ?? '')) changed = true; + return changed; } Future _saveIgel() async { + if (!canEdit) return; if (!_basicsChanged || igel == null) return; - final newName = nameC.text.trim(); - final newFeature = - featureC.text.trim().isEmpty ? null : featureC.text.trim(); - final newNote = noteC.text.trim().isEmpty ? null : noteC.text.trim(); - if (newName.isEmpty) { - _snack('Bitte einen Namen eingeben'); + if (nameC.text.trim().isEmpty) { + _snack('Bitte Namen eingeben'); return; } + try { await igelRepo.update( widget.igelId, - newName, - note: newNote, + nameC.text.trim(), + note: noteC.text.trim().isEmpty ? null : noteC.text.trim(), gender: gender, - feature: newFeature, + feature: featureC.text.trim().isEmpty ? null : featureC.text.trim(), rescuedAt: rescuedAt, location: locationC.text.trim().isEmpty ? null : locationC.text.trim(), ); - - igel = igel!.copyWith( - name: newName, - note: newNote, - gender: gender, - feature: newFeature, - rescuedAt: rescuedAt, - location: locationC.text.trim().isEmpty ? null : locationC.text.trim(), - ); - - _initRescuedAt = rescuedAt; - _initLocation = - locationC.text.trim().isEmpty ? null : locationC.text.trim(); - - setState(() {}); _snack('Gespeichert'); + _initRescuedAt = rescuedAt; + _initLocation = locationC.text.trim(); } catch (e) { _snack('Speichern fehlgeschlagen: $e'); } } + void _snack(String msg) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + } + // --- Images --- Future _pickAndUpload() async { + if (!canEdit) return; try { final picker = ImagePicker(); final picks = await picker.pickMultiImage( @@ -245,7 +227,6 @@ class _IgelDetailState extends ConsumerState { final files = []; for (final x in picks) { - // Datei-Part bauen (Name MUSS 'files[]' sein) if (kIsWeb) { final bytes = await x.readAsBytes(); files.add(http.MultipartFile.fromBytes( @@ -264,7 +245,6 @@ class _IgelDetailState extends ConsumerState { setState(() => _uploadDone++); } - // Kein takenAt mitsenden – Server extrahiert EXIF und setzt taken_at await imagesRepo.upload(widget.igelId, files); images = await imagesRepo.list(widget.igelId); @@ -273,7 +253,6 @@ class _IgelDetailState extends ConsumerState { _uploadDone = _uploadTotal; _uploading = false; }); - await _prefetchImages(context); } _snack('Bilder hochgeladen'); } catch (e) { @@ -283,6 +262,7 @@ class _IgelDetailState extends ConsumerState { } Future _deleteImage(IgelImage img) async { + if (!canEdit) return; final ok = await showDialog( context: context, builder: (_) => AlertDialog( @@ -303,10 +283,7 @@ class _IgelDetailState extends ConsumerState { try { await imagesRepo.delete(img.id); images = await imagesRepo.list(widget.igelId); - if (mounted) { - setState(() {}); - await _prefetchImages(context); - } + if (mounted) setState(() {}); _snack('Bild gelöscht'); } catch (e) { _snack('Löschen fehlgeschlagen: $e'); @@ -352,6 +329,7 @@ class _IgelDetailState extends ConsumerState { // --- Messwerte helpers --- Future _pickMwDateTime() async { + if (!canEdit) return; final d = await showDatePicker( context: context, initialDate: mwDatum, @@ -368,6 +346,7 @@ class _IgelDetailState extends ConsumerState { } Future _createMesswert() async { + if (!canEdit) return; final gewicht = int.tryParse(mwGewichtC.text.trim()); if (gewicht == null || gewicht <= 0) { _snack('Bitte Gewicht in Gramm angeben'); @@ -393,6 +372,7 @@ class _IgelDetailState extends ConsumerState { } Future _editMesswert(Messwert m) async { + if (!canEdit) return; final res = await showDialog( context: context, builder: (_) => _EditMesswertDialog(initial: m), @@ -409,6 +389,7 @@ class _IgelDetailState extends ConsumerState { } Future _deleteMesswert(Messwert m) async { + if (!canEdit) return; final ok = await showDialog( context: context, builder: (_) => AlertDialog( @@ -436,8 +417,7 @@ class _IgelDetailState extends ConsumerState { } } - // --- Export: CSV (nur dieser Igel, denormalisiert) ------------------------- - + // --- Export Helfer --- String _fmtDate(DateTime? dt) => dt == null ? '' : DateFormat('yyyy-MM-dd').format(dt); @@ -460,7 +440,6 @@ class _IgelDetailState extends ConsumerState { Future _exportCsvSingleIgel() async { if (igel == null) return; - // Sicherheits-Reload der Messwerte, damit CSV aktuell ist final mw = await messRepo.list(widget.igelId); const sep = ';'; @@ -529,8 +508,7 @@ class _IgelDetailState extends ConsumerState { _snack('CSV exportiert'); } - // --- Export: PDF ----------------------------------------------------------- - + // --- Export PDF --- Future _captureChartPngBytes() async { try { final ctx = _chartKey.currentContext; @@ -595,13 +573,11 @@ class _IgelDetailState extends ConsumerState { ), actions: [ TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Abbrechen'), - ), + onPressed: () => Navigator.pop(context, false), + child: const Text('Abbrechen')), FilledButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('Exportieren'), - ), + onPressed: () => Navigator.pop(context, true), + child: const Text('Exportieren')), ], ); }, @@ -617,14 +593,12 @@ class _IgelDetailState extends ConsumerState { {required bool includeMesswerte, required bool includeBilder}) async { if (igel == null) return; - // Aktuelle Messwerte/Bilderdaten holen final mw = await messRepo.list(widget.igelId); final chartBytes = await _captureChartPngBytes(); final imageBytesList = includeBilder ? await _fetchImagesForPdf() : const []; final doc = pw.Document(); - final textStyle = pw.TextStyle(fontSize: 12); final headerStyle = pw.TextStyle(fontSize: 18, fontWeight: pw.FontWeight.bold); @@ -641,7 +615,6 @@ class _IgelDetailState extends ConsumerState { ), ); - // Seite 1: Stammdaten + Chart + optional Messwerte/Bilder (alles synchron) doc.addPage( pw.MultiPage( margin: const pw.EdgeInsets.all(24), @@ -762,12 +735,8 @@ class _IgelDetailState extends ConsumerState { String _fmtGramm(int g) => '$g g'; String? _emptyToNull(String s) => s.trim().isEmpty ? null : s.trim(); - void _snack(String msg) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); - } - Future _openEditBasicsDialog() async { + if (!canEdit) return; final tmpName = TextEditingController(text: nameC.text); String? tmpGender = gender; @@ -842,7 +811,7 @@ class _IgelDetailState extends ConsumerState { if (ok == true) { nameC.text = tmpName.text.trim(); gender = tmpGender; - _markDirtyBasics(); + setState(() {}); } tmpName.dispose(); } @@ -857,10 +826,18 @@ class _IgelDetailState extends ConsumerState { return Scaffold( appBar: AppBar( - leading: BackButton(onPressed: () => context.go('/igel')), + leading: BackButton( + onPressed: () { + if (cameFromShared) { + context.go('/igel/shared'); + } else { + context.go('/igel'); + } + }, + ), title: InkWell( borderRadius: BorderRadius.circular(6), - onTap: _openEditBasicsDialog, + onTap: canEdit ? _openEditBasicsDialog : null, child: Row( children: [ if (titleIcon != null) ...[ @@ -868,36 +845,60 @@ class _IgelDetailState extends ConsumerState { const SizedBox(width: 8), ], Flexible(child: Text(title)), - const SizedBox(width: 8), - const Icon(Icons.edit, size: 18, color: Colors.black54), + if (canEdit) ...[ + const SizedBox(width: 8), + const Icon(Icons.edit, size: 18, color: Colors.black54), + ], ], ), ), actions: [ + if (_role == AccessRole.owner) + IconButton( + tooltip: 'Teilen', + icon: const Icon(Icons.share), + onPressed: () { + if (_shareService == null || _accessToken == null) return; + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => SizedBox( + height: MediaQuery.of(context).size.height * 0.85, + child: ShareBottomSheet( + igelId: widget.igelId, + shareService: _shareService!, + token: _accessToken!, + accessRole: _role, + ), + ), + ); + }, + ), IconButton( - onPressed: _basicsChanged ? _saveIgel : null, - tooltip: - _basicsChanged ? 'Änderungen speichern' : 'Keine Änderungen', + onPressed: canEdit && _basicsChanged ? _saveIgel : null, + tooltip: _basicsChanged && canEdit + ? 'Änderungen speichern' + : 'Keine Änderungen', icon: const Icon(Icons.save), ), - IconButton( - tooltip: 'CSV für diesen Igel importieren…', - icon: const Icon(Icons.upload_file), - onPressed: () async { - final service = IgelCsvImportService( - igelRepo: ref.read(igelRepoProvider), - messRepo: ref.read(messwerteRepoProvider), - ); - await service.pickAndImportForIgel( - context, - igelId: widget.igelId, - igelNameHint: igel?.name, // hilft beim Name-Matching - ); - // danach Messwerte neu laden - messwerte = await messRepo.list(widget.igelId); - if (mounted) setState(() {}); - }, - ), + if (canEdit) + IconButton( + tooltip: 'CSV für diesen Igel importieren…', + icon: const Icon(Icons.upload_file), + onPressed: () async { + final service = IgelCsvImportService( + igelRepo: ref.read(igelRepoProvider), + messRepo: ref.read(messwerteRepoProvider), + ); + await service.pickAndImportForIgel( + context, + igelId: widget.igelId, + igelNameHint: igel?.name, + ); + messwerte = await messRepo.list(widget.igelId); + if (mounted) setState(() {}); + }, + ), PopupMenuButton( tooltip: 'Export', onSelected: (v) async { @@ -913,11 +914,12 @@ class _IgelDetailState extends ConsumerState { ], icon: const Icon(Icons.ios_share), ), - IconButton( - onPressed: _pickAndUpload, - tooltip: 'Bilder hinzufügen', - icon: const Icon(Icons.add_a_photo), - ), + if (canEdit) + IconButton( + onPressed: _pickAndUpload, + tooltip: 'Bilder hinzufügen', + icon: const Icon(Icons.add_a_photo), + ), ], ), body: Stack( @@ -943,15 +945,17 @@ class _IgelDetailState extends ConsumerState { children: [ TextField( controller: featureC, + enabled: canEdit, + readOnly: !canEdit, decoration: const InputDecoration( labelText: 'Merkmal (optional)', prefixIcon: Icon(Icons.style), ), ), const SizedBox(height: 12), - - // Gerettet am (Datum) + // Gerettet am ListTile( + enabled: canEdit, contentPadding: EdgeInsets.zero, leading: const Icon(Icons.calendar_today), @@ -962,28 +966,35 @@ class _IgelDetailState extends ConsumerState { : DateFormat('dd.MM.yyyy') .format(rescuedAt!), ), - onTap: () async { - final now = DateTime.now(); - final init = rescuedAt ?? now; - final picked = await showDatePicker( - context: context, - initialDate: init, - firstDate: DateTime(2000), - lastDate: - DateTime(now.year + 1, 12, 31), - ); - if (picked != null) { - setState(() => rescuedAt = picked); - } - }, - onLongPress: () => - setState(() => rescuedAt = null), + onTap: !canEdit + ? null + : () async { + final now = DateTime.now(); + final init = rescuedAt ?? now; + final picked = + await showDatePicker( + context: context, + initialDate: init, + firstDate: DateTime(2000), + lastDate: DateTime( + now.year + 1, 12, 31), + ); + if (picked != null) { + setState( + () => rescuedAt = picked); + } + }, + onLongPress: !canEdit + ? null + : () => setState( + () => rescuedAt = null), ), const SizedBox(height: 12), - // Ort / Fundstelle TextField( controller: locationC, + enabled: canEdit, + readOnly: !canEdit, decoration: const InputDecoration( labelText: 'Ort / Fundstelle (optional)', @@ -991,10 +1002,11 @@ class _IgelDetailState extends ConsumerState { Icon(Icons.place_outlined), ), ), - const SizedBox(height: 12), TextField( controller: noteC, + enabled: canEdit, + readOnly: !canEdit, minLines: 2, maxLines: 5, decoration: const InputDecoration( @@ -1006,10 +1018,8 @@ class _IgelDetailState extends ConsumerState { ), ), ), - const SizedBox(height: 12), - - // ----- Gewicht-Chart (mit Achsen) + // ----- Gewicht-Chart Card( child: Padding( padding: const EdgeInsets.all(12), @@ -1028,9 +1038,10 @@ class _IgelDetailState extends ConsumerState { const Spacer(), if (chartData.isNotEmpty) Text( - '${chartData.first.datum.year}–${chartData.last.datum.year}', - style: const TextStyle( - color: Colors.black54)), + '${chartData.first.datum.year}–${chartData.last.datum.year}', + style: const TextStyle( + color: Colors.black54), + ), ], ), const SizedBox(height: 8), @@ -1045,10 +1056,8 @@ class _IgelDetailState extends ConsumerState { ), ), ), - const SizedBox(height: 12), - - // ----- Messwerte (Form + Tabelle) + // ----- Messwerte Card( child: Padding( padding: const EdgeInsets.all(12), @@ -1066,22 +1075,23 @@ class _IgelDetailState extends ConsumerState { 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'), - ), + if (canEdit) + 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: mwShowForm && canEdit ? CrossFadeState.showFirst : CrossFadeState.showSecond, firstChild: Padding( @@ -1163,50 +1173,60 @@ class _IgelDetailState extends ConsumerState { SingleChildScrollView( scrollDirection: Axis.horizontal, child: DataTable( - columns: const [ - DataColumn( + columns: [ + const DataColumn( label: Text('Datum/Uhrzeit')), - DataColumn( + const DataColumn( label: Text('Gewicht (g)')), - DataColumn( + const DataColumn( label: Text('Behandlung')), - DataColumn( + const DataColumn( label: Text('Bemerkung')), - DataColumn( - label: Text('Aktionen')), + if (canEdit) + const 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), + DataRow( + cells: [ + DataCell(Text( + _fmtDateTime(m.datum))), + DataCell( + Text('${m.gewicht}')), + DataCell(Text( + m.behandlung ?? '')), + DataCell(Text( + m.bemerkung ?? '')), + if (canEdit) + 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), + ), + ], + ), ), - IconButton( - tooltip: 'Löschen', - icon: const Icon(Icons - .delete_outline), - onPressed: () => - _deleteMesswert(m), - ), - ], - )), - ]), // for + ], + ), ], ), ), @@ -1214,10 +1234,8 @@ class _IgelDetailState extends ConsumerState { ), ), ), - const SizedBox(height: 12), - - // ----- Bilder-Grid (GANZ UNTEN) + // ----- Bilder-Grid if (images.isEmpty) Card( child: SizedBox( @@ -1231,13 +1249,16 @@ class _IgelDetailState extends ConsumerState { 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'), - ), + if (canEdit) ...[ + const SizedBox(height: 8), + FilledButton.icon( + onPressed: _pickAndUpload, + icon: + const Icon(Icons.add_a_photo), + label: const Text( + 'Bilder hochladen'), + ), + ], ], ), ), @@ -1251,29 +1272,25 @@ class _IgelDetailState extends ConsumerState { 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'), - ), + Text('Bilder', + style: Theme.of(context) + .textTheme + .titleMedium), + if (canEdit) + 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(), @@ -1290,7 +1307,6 @@ class _IgelDetailState extends ConsumerState { final img = images[i]; final thumb = img.thumbUrl ?? img.url; - final String? ts = (() { final dt = img.takenAt ?? img.createdAt; @@ -1299,7 +1315,6 @@ class _IgelDetailState extends ConsumerState { 'dd.MM.yyyy, HH:mm') .format(dt); })(); - return GestureDetector( onTap: () async { final res = await context.push( @@ -1308,13 +1323,12 @@ class _IgelDetailState extends ConsumerState { if (res == true) { images = await imagesRepo .list(widget.igelId); - if (mounted) { - setState(() {}); - } + if (mounted) setState(() {}); } }, - onLongPress: () => - _deleteImage(img), + onLongPress: canEdit + ? () => _deleteImage(img) + : null, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -1335,9 +1349,8 @@ class _IgelDetailState extends ConsumerState { color: Color( 0x11000000), child: Center( - child: Icon(Icons - .broken_image), - ), + child: Icon(Icons + .broken_image)), ), ), ), @@ -1366,7 +1379,6 @@ class _IgelDetailState extends ConsumerState { ], ), ), - // Upload-Overlay if (_uploading) Container( @@ -1376,15 +1388,14 @@ class _IgelDetailState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ const SizedBox( - width: 60, - height: 60, - child: CircularProgressIndicator(strokeWidth: 4), - ), + width: 60, + height: 60, + child: CircularProgressIndicator(strokeWidth: 4)), const SizedBox(height: 12), Text( _uploadTotal <= 1 ? 'Lade Bild hoch …' - : 'Lade Bilder hoch ($_uploadDone/$_uploadTotal) …', + : 'Lade Bilder hoch (${_uploadDone}/${_uploadTotal}) …', style: const TextStyle(color: Colors.white, fontSize: 16), ), ], @@ -1502,8 +1513,9 @@ class _EditMesswertDialogState extends State<_EditMesswertDialog> { onPressed: () { final gewicht = int.tryParse(gewichtC.text.trim()); if (gewicht == null || gewicht <= 0) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar( - content: Text('Bitte Gewicht in Gramm angeben'))); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Bitte Gewicht in Gramm angeben')), + ); return; } Navigator.pop( @@ -1604,20 +1616,18 @@ class _WeightChartPainter extends CustomPainter { size.height - paddingTop - paddingBottom, ); - // Achsen final axisPaint = Paint() ..color = _axisColor ..strokeWidth = 1; - // Y-Achse links + + // Achsen canvas.drawLine( Offset(area.left, area.top), Offset(area.left, area.bottom), axisPaint); - // X-Achse unten canvas.drawLine(Offset(area.left, area.bottom), Offset(area.right, area.bottom), axisPaint); if (data.isEmpty) return; - // Wertebereiche int minG = data.map((e) => e.gewicht).reduce(math.min); int maxG = data.map((e) => e.gewicht).reduce(math.max); if (minG == maxG) { diff --git a/lib/features/igel/presentation/igel_list_screen.dart b/lib/features/igel/presentation/igel_list_screen.dart index bf23512..4a10bab 100644 --- a/lib/features/igel/presentation/igel_list_screen.dart +++ b/lib/features/igel/presentation/igel_list_screen.dart @@ -289,7 +289,14 @@ class _IgelListState extends ConsumerState { await service.pickAndImport(context); await _load(); // nach Import Liste aktualisieren }, - ), // ⬇️ Export-Menü + ), + // ➕ NEU: Freigegebene Igel + IconButton( + tooltip: 'Freigegebene Igel', + icon: const Icon(Icons.people_alt), + onPressed: () => context.push('/igel/shared'), + ), + // ⬇️ Export-Menü PopupMenuButton( onSelected: (v) async { if (v == 'export_csv_denorm') { diff --git a/lib/features/igel/presentation/share_bottom_sheet.dart b/lib/features/igel/presentation/share_bottom_sheet.dart new file mode 100644 index 0000000..82643f0 --- /dev/null +++ b/lib/features/igel/presentation/share_bottom_sheet.dart @@ -0,0 +1,214 @@ +import 'package:flutter/material.dart'; +import '../data/share_service.dart'; +import '../data/share.dart'; + +class ShareBottomSheet extends StatefulWidget { + const ShareBottomSheet({ + super.key, + required this.igelId, + required this.shareService, + required this.accessRole, // AccessRole.owner/editor/viewer + required this.token, + }); + + final int igelId; + final ShareService shareService; + final String token; + final AccessRole accessRole; + + @override + State createState() => _ShareBottomSheetState(); +} + +class _ShareBottomSheetState extends State { + final _emailCtrl = TextEditingController(); + String _role = 'viewer'; + Future>? _future; + + @override + void initState() { + super.initState(); + _future = widget.shareService + .listShares(igelId: widget.igelId, token: widget.token); + } + + Future _reload() async { + setState(() { + _future = widget.shareService + .listShares(igelId: widget.igelId, token: widget.token); + }); + } + + Future _invite() async { + final email = _emailCtrl.text.trim(); + if (email.isEmpty) return; + await widget.shareService.invite( + igelId: widget.igelId, + email: email, + role: _role, + token: widget.token, + ); + _emailCtrl.clear(); + await _reload(); + } + + Future _updateRole(Share s, String newRole) async { + await widget.shareService + .updateRole(shareId: s.id, role: newRole, token: widget.token); + await _reload(); + } + + Future _revoke(Share s) async { + await widget.shareService.revoke(shareId: s.id, token: widget.token); + await _reload(); + } + + @override + Widget build(BuildContext context) { + final canInvite = widget.accessRole == AccessRole.owner; + + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row(children: [ + const Icon(Icons.share), + const SizedBox(width: 8), + Text('Igel teilen', + style: Theme.of(context).textTheme.titleLarge), + const Spacer(), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ]), + const SizedBox(height: 12), + + // Einladen (nur Owner) + if (canInvite) + _InviteRow( + emailCtrl: _emailCtrl, + role: _role, + onRoleChanged: (v) => setState(() => _role = v), + onInvite: _invite, + ), + if (canInvite) const SizedBox(height: 16), + + // Liste der Freigaben + Expanded( + child: FutureBuilder>( + future: _future, + builder: (context, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snap.hasError) { + return Center(child: Text('Fehler: ${snap.error}')); + } + final items = snap.data ?? const []; + if (items.isEmpty) { + return const Center(child: Text('Keine Freigaben')); + } + return ListView.separated( + itemCount: items.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (ctx, i) { + final s = items[i]; + + // Priorität: pending -> invitedEmail, accepted -> targetEmail + final label = + (s.invitedEmail != null && s.invitedEmail!.isNotEmpty) + ? s.invitedEmail! + : ((s.targetEmail != null && + s.targetEmail!.isNotEmpty) + ? s.targetEmail! + : 'Unbekannt'); + + return ListTile( + leading: Icon( + s.status == 'accepted' + ? Icons.check_circle_outline + : Icons.hourglass_bottom, + ), + title: Text(label), + subtitle: + Text('Rolle: ${s.role} · Status: ${s.status}'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (canInvite && s.status == 'accepted') + PopupMenuButton( + onSelected: (v) => _updateRole(s, v), + itemBuilder: (_) => const [ + PopupMenuItem( + value: 'viewer', child: Text('Viewer')), + PopupMenuItem( + value: 'editor', child: Text('Editor')), + ], + child: const Icon(Icons.admin_panel_settings), + ), + if (canInvite) + IconButton( + icon: const Icon(Icons.remove_circle_outline), + tooltip: 'Zugriff entfernen', + onPressed: () => _revoke(s), + ), + ], + ), + ); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class _InviteRow extends StatelessWidget { + const _InviteRow({ + required this.emailCtrl, + required this.role, + required this.onRoleChanged, + required this.onInvite, + }); + final TextEditingController emailCtrl; + final String role; + final ValueChanged onRoleChanged; + final VoidCallback onInvite; + + @override + Widget build(BuildContext context) { + return Row(children: [ + Expanded( + child: TextField( + controller: emailCtrl, + decoration: const InputDecoration(labelText: 'E-Mail einladen'), + keyboardType: TextInputType.emailAddress, + ), + ), + const SizedBox(width: 8), + DropdownButton( + value: role, + items: const [ + DropdownMenuItem(value: 'viewer', child: Text('Viewer')), + DropdownMenuItem(value: 'editor', child: Text('Editor')), + ], + onChanged: (v) { + if (v != null) onRoleChanged(v); + }, + ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: onInvite, + icon: const Icon(Icons.send), + label: const Text('Einladen'), + ), + ]); + } +} diff --git a/lib/features/igel/presentation/share_igel_page.dart b/lib/features/igel/presentation/share_igel_page.dart new file mode 100644 index 0000000..0caea61 --- /dev/null +++ b/lib/features/igel/presentation/share_igel_page.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../main.dart'; +import '../data/share_service.dart'; +import '../data/share.dart'; +import 'package:go_router/go_router.dart'; + +class SharedIgelPage extends StatefulWidget { + const SharedIgelPage({super.key, required this.service, required this.token}); + final ShareService service; + final String token; + + @override + State createState() => _SharedIgelPageState(); +} + +class _SharedIgelPageState extends State { + late Future> _future; + + @override + void initState() { + super.initState(); + _future = widget.service.listSharedWithMe(token: widget.token); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: BackButton( + onPressed: () => context.go('/igel')), // ⬅️ zurück zu „Meine Igel“ + title: const Text('Für mich freigegeben'), + actions: [ + IconButton( + tooltip: 'Meine Igel', + icon: const Icon(Icons.pets), + onPressed: () => context.go('/igel'), + ), + ], + ), + body: FutureBuilder>( + future: _future, + builder: (context, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snap.hasError) { + return Center(child: Text('Fehler: ${snap.error}')); + } + final items = snap.data ?? const []; + if (items.isEmpty) { + return const Center(child: Text('Keine freigegebenen Igel')); + } + return ListView.separated( + itemCount: items.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (ctx, i) { + final it = items[i]; + + // Owner-Anzeige: bevorzugt E-Mail, sonst Fallback auf #ID + final ownerLabel = + (it.ownerEmail != null && it.ownerEmail!.isNotEmpty) + ? it.ownerEmail! + : '#${it.ownerUserId}'; + + return ListTile( + leading: const CircleAvatar(child: Icon(Icons.people_alt)), + title: Text(it.name), + subtitle: Text('Rolle: ${it.role} · Owner: $ownerLabel'), + onTap: () { + // Detail öffnen; 'from=shared' sorgt dafür, dass der Back-Button wieder hierher führt + context.push('/igel/${it.id}?role=${it.role}&from=shared}'); + }, + ); + }, + ); + }, + ), + ); + } +} + +class SharedIgelRoute extends ConsumerWidget { + const SharedIgelRoute({super.key}); + @override + Widget build(BuildContext context, WidgetRef ref) { + final ts = ref.read(tokenStorageProvider); + return FutureBuilder( + future: ts.access, + builder: (context, snap) { + if (!snap.hasData) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + final token = snap.data!; + final base = kApiBase.replaceFirst('/hedgehogs.php?r=', ''); + final service = ShareService(baseUrl: base); + return SharedIgelPage(service: service, token: token); + }, + ); + } +}