From 8abe0ca966cf36e6a01ee5e8cbd2365b22018ef6 Mon Sep 17 00:00:00 2001 From: Herwig Birke Date: Fri, 24 Oct 2025 12:39:10 +0200 Subject: [PATCH] vor Import/Export --- api/hedgehogs.php | 107 +++++++----------- api/hedgehogs.sql | 4 +- lib/features/igel/data/igel_repository.dart | 48 ++++++-- lib/features/igel/domain/igel.dart | 74 +++++++----- .../igel/presentation/igel_detail_screen.dart | 92 ++++++++++++++- windows/runner/CMakeLists.txt | 2 +- 6 files changed, 214 insertions(+), 113 deletions(-) diff --git a/api/hedgehogs.php b/api/hedgehogs.php index f0fe9e2..37f423f 100644 --- a/api/hedgehogs.php +++ b/api/hedgehogs.php @@ -211,7 +211,7 @@ function issue_tokens(PDO $pdo, int $uid): array { // ============================================================================= 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 = $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)); } @@ -222,16 +222,36 @@ function igel_create(PDO $pdo, int $uid): void { $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; } - $stmt = $pdo->prepare('INSERT INTO igel(user_id, name, gender, feature, note) VALUES(?,?,?,?,?)'); - $stmt->execute([$uid, $name, $gender, $feature, $note]); + if ($rescuedAt !== null && $rescuedAt !== '' && strtotime($rescuedAt) === false) { + 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; + } + $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(); - json(['id' => $id, 'name' => $name, 'gender' => $gender, 'feature' => $feature, 'note' => $note], 201); + + json([ + 'id' => $id, + 'name' => $name, + 'gender' => $gender, + 'feature' => $feature, + 'note' => $note, + 'rescued_at' => $rescuedAt, + 'location' => $location + ], 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 = $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]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { json(['error' => 'Not found'], 404); return; } @@ -244,10 +264,21 @@ function igel_update(PDO $pdo, int $uid, int $id): void { $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; } - $stmt = $pdo->prepare('UPDATE igel SET name=?, gender=?, feature=?, note=? WHERE id=? AND user_id=?'); - $stmt->execute([$name, $gender, $feature, $note, $id, $uid]); + if ($rescuedAt !== null && $rescuedAt !== '' && strtotime($rescuedAt) === false) { + 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; + } + + $stmt = $pdo->prepare( + 'UPDATE igel SET name=?, gender=?, feature=?, note=?, rescued_at=?, location=? WHERE id=? AND user_id=?' + ); + $stmt->execute([$name, $gender, $feature, $note, $rescuedAt ?: null, $location ?: null, $id, $uid]); json(['ok' => true]); } @@ -625,65 +656,3 @@ function jwt_decode(string $token, string $secret): array { 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, - taken_at DATETIME NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (igel_id) REFERENCES igel(id) ON DELETE CASCADE, - INDEX (igel_id), - INDEX (taken_at) -) 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 index 458fa18..9044277 100644 --- a/api/hedgehogs.sql +++ b/api/hedgehogs.sql @@ -3,7 +3,7 @@ -- https://www.phpmyadmin.net/ -- -- Host: localhost:3306 --- Generation Time: Oct 23, 2025 at 06:57 PM +-- Generation Time: Oct 24, 2025 at 10:36 AM -- Server version: 10.6.22-MariaDB-ubu2204-log -- PHP Version: 8.2.28 @@ -51,6 +51,8 @@ CREATE TABLE `igel` ( `gender` varchar(10) DEFAULT NULL, `note` text DEFAULT NULL, `feature` text DEFAULT NULL, + `rescued_at` date DEFAULT NULL, + `location` varchar(255) 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; diff --git a/lib/features/igel/data/igel_repository.dart b/lib/features/igel/data/igel_repository.dart index 080a972..c1dc904 100644 --- a/lib/features/igel/data/igel_repository.dart +++ b/lib/features/igel/data/igel_repository.dart @@ -1,3 +1,4 @@ +// lib/features/igel/data/igel_repository.dart import '../../../shared/api_client.dart'; import '../domain/igel.dart'; @@ -12,24 +13,27 @@ class IgelRepository { } Future create(String name, - {String? note, String? gender, String? feature}) async { + {String? note, + String? gender, + String? feature, + DateTime? rescuedAt, + String? location}) async { + String? fmtDate(DateTime? d) => + d == null ? null : d.toIso8601String().split('T').first; final res = await api.post('/igel', { 'name': name, 'note': note, 'gender': gender, 'feature': feature, + 'rescued_at': fmtDate(rescuedAt), + 'location': (location?.trim().isEmpty ?? true) ? null : location!.trim(), }); - return Igel.fromMap(res as Map); - } - - Future update(int id, String name, - {String? note, String? gender, String? feature}) async { - await api.put('/igel/$id', { - 'name': name, - 'note': note, - 'gender': gender, - 'feature': feature, - }); + try { + final id = (res['id'] as num).toInt(); + return await get(id); + } catch (_) { + return Igel.fromMap((res as Map).cast()); + } } Future delete(int id) async { @@ -40,4 +44,24 @@ class IgelRepository { final map = await api.get>('/igel/$id'); return Igel.fromMap(map); } + + /// Update hedgehog data. Backend requires name; other fields optional. + Future update(int id, String name, + {String? note, + String? gender, + String? feature, + DateTime? rescuedAt, + String? location}) async { + String? fmtDate(DateTime? d) => + d == null ? null : d.toIso8601String().split('T').first; + final body = { + 'name': name, + 'note': note, + 'gender': gender, + 'feature': feature, + 'rescued_at': fmtDate(rescuedAt), + 'location': (location?.trim().isEmpty ?? true) ? null : location!.trim(), + }; + await api.put('/igel/$id', body); + } } diff --git a/lib/features/igel/domain/igel.dart b/lib/features/igel/domain/igel.dart index f89301a..4a81dc1 100644 --- a/lib/features/igel/domain/igel.dart +++ b/lib/features/igel/domain/igel.dart @@ -4,6 +4,11 @@ class Igel { final String? gender; // 'männlich' | 'weiblich' | 'unbekannt' final String? feature; // Merkmal final String? note; // Information + + // 🆕 NEU: + final DateTime? rescuedAt; // YYYY-MM-DD (Datum) + final String? location; // Ort / Fundstelle + final DateTime? createdAt; final DateTime? updatedAt; @@ -13,52 +18,67 @@ class Igel { this.gender, this.feature, this.note, + this.rescuedAt, + this.location, this.createdAt, this.updatedAt, }); - // ---------- Factory fromMap ---------- - factory Igel.fromMap(Map map) => Igel( - id: (map['id'] as num).toInt(), - name: map['name'] as String, - gender: map['gender'] as String?, - feature: map['feature'] as String?, - note: map['note'] as String?, - createdAt: map['created_at'] != null - ? DateTime.parse(map['created_at']) - : null, - updatedAt: map['updated_at'] != null - ? DateTime.parse(map['updated_at']) - : null, - ); + static DateTime? _parseDateOrNull(String? s) { + if (s == null || s.isEmpty) return null; + try { + return DateTime.parse(s); + } catch (_) { + return null; + } + } - // ---------- toMap (optional für PUT/POST) ---------- - Map toMap() => { - 'id': id, - 'name': name, - if (gender != null) 'gender': gender, - if (feature != null) 'feature': feature, - if (note != null) 'note': note, - if (createdAt != null) 'created_at': createdAt!.toIso8601String(), - if (updatedAt != null) 'updated_at': updatedAt!.toIso8601String(), - }; + factory Igel.fromMap(Map m) { + return Igel( + id: (m['id'] as num).toInt(), + name: (m['name'] ?? '') as String, + gender: m['gender'] as String?, + feature: m['feature'] as String?, + note: m['note'] as String?, + rescuedAt: _parseDateOrNull(m['rescued_at'] as String?), + location: m['location'] as String?, + createdAt: _parseDateOrNull(m['created_at'] as String?), + updatedAt: _parseDateOrNull(m['updated_at'] as String?), + ); + } + + Map toMapForCreateUpdate() { + String? fmtDate(DateTime? d) => + d == null ? null : d.toIso8601String().split('T').first; + + return { + 'name': name, + 'gender': gender, + 'feature': feature, + 'note': note, + 'rescued_at': fmtDate(rescuedAt), + 'location': location?.trim().isEmpty == true ? null : location, + }; + } - // ---------- copyWith ---------- Igel copyWith({ - int? id, String? name, String? gender, String? feature, String? note, + DateTime? rescuedAt, + String? location, DateTime? createdAt, DateTime? updatedAt, }) { return Igel( - id: id ?? this.id, + id: id, name: name ?? this.name, gender: gender ?? this.gender, feature: feature ?? this.feature, note: note ?? this.note, + rescuedAt: rescuedAt ?? this.rescuedAt, + location: location ?? this.location, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, ); diff --git a/lib/features/igel/presentation/igel_detail_screen.dart b/lib/features/igel/presentation/igel_detail_screen.dart index d6f9861..b5e7c33 100644 --- a/lib/features/igel/presentation/igel_detail_screen.dart +++ b/lib/features/igel/presentation/igel_detail_screen.dart @@ -43,6 +43,13 @@ class _IgelDetailState extends ConsumerState { final nameC = TextEditingController(); final featureC = TextEditingController(); final noteC = TextEditingController(); + + // NEU: Ort/Fundstelle + Gerettet am + final locationC = TextEditingController(); + DateTime? rescuedAt; + DateTime? _initRescuedAt; + String? _initLocation; + String? gender; // 'männlich' | 'weiblich' | 'unbekannt' | null // Bilder @@ -70,6 +77,7 @@ class _IgelDetailState extends ConsumerState { featureC.addListener(_markDirtyBasics); noteC.addListener(_markDirtyBasics); + locationC.addListener(_markDirtyBasics); // NEU _loadAll(); } @@ -79,6 +87,7 @@ class _IgelDetailState extends ConsumerState { nameC.dispose(); featureC.dispose(); noteC.dispose(); + locationC.dispose(); // NEU mwGewichtC.dispose(); mwBehandlungC.dispose(); mwBemerkungC.dispose(); @@ -91,14 +100,25 @@ class _IgelDetailState extends ConsumerState { 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(); - final changed = newName != igel!.name || + + bool changed = newName != igel!.name || newFeature != igel!.feature || newNote != igel!.note || gender != igel!.gender; + + // NEU: 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; } @@ -116,6 +136,12 @@ class _IgelDetailState extends ConsumerState { noteC.text = data.note ?? ''; gender = data.gender; + // NEU: rescuedAt + location aus Domain (falls Backend schon erweitert) + 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); @@ -158,15 +184,31 @@ class _IgelDetailState extends ConsumerState { return; } try { + // NEU: rescuedAt + location mitsenden (Repo wurde entsprechend erweitert) await igelRepo.update( widget.igelId, newName, note: newNote, gender: gender, feature: newFeature, + rescuedAt: rescuedAt, + location: locationC.text.trim().isEmpty ? null : locationC.text.trim(), ); + igel = igel!.copyWith( - name: newName, note: newNote, gender: gender, feature: newFeature); + name: newName, + note: newNote, + gender: gender, + feature: newFeature, + rescuedAt: rescuedAt, + location: locationC.text.trim().isEmpty ? null : locationC.text.trim(), + ); + + // NEU: Init-Stand für Change-Detection aktualisieren + _initRescuedAt = rescuedAt; + _initLocation = + locationC.text.trim().isEmpty ? null : locationC.text.trim(); + setState(() {}); _snack('Gespeichert'); } catch (e) { @@ -547,6 +589,50 @@ class _IgelDetailState extends ConsumerState { prefixIcon: Icon(Icons.style), ), ), + const SizedBox(height: 12), + + // NEU: Gerettet am (Datum) + ListTile( + contentPadding: EdgeInsets.zero, + leading: + const Icon(Icons.calendar_today), + title: const Text('Gerettet am'), + subtitle: Text( + rescuedAt == null + ? '—' + : 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), + ), + const SizedBox(height: 12), + + // NEU: Ort / Fundstelle + TextField( + controller: locationC, + decoration: const InputDecoration( + labelText: + 'Ort / Fundstelle (optional)', + prefixIcon: + Icon(Icons.place_outlined), + ), + ), + const SizedBox(height: 12), TextField( controller: noteC, @@ -758,7 +844,7 @@ class _IgelDetailState extends ConsumerState { ), ], )), - ]), + ]), // for ], ), ), diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt index 394917c..0ef4121 100644 --- a/windows/runner/CMakeLists.txt +++ b/windows/runner/CMakeLists.txt @@ -9,7 +9,7 @@ project(runner LANGUAGES CXX) add_executable(${BINARY_NAME} WIN32 "flutter_window.cpp" "main.cpp" - "utils.cpp" + "win32_window.cpp" "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" "Runner.rc"