vor Import/Export

This commit is contained in:
2025-10-24 12:39:10 +02:00
parent 77ad6b3823
commit 8abe0ca966
6 changed files with 214 additions and 113 deletions
+38 -69
View File
@@ -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;
*/
?>
+3 -1
View File
@@ -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;
+36 -12
View File
@@ -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<Igel> 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<dynamic>('/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<String, dynamic>);
}
Future<void> update(int id, String name,
{String? note, String? gender, String? feature}) async {
await api.put<dynamic>('/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<String, dynamic>());
}
}
Future<void> delete(int id) async {
@@ -40,4 +44,24 @@ class IgelRepository {
final map = await api.get<Map<String, dynamic>>('/igel/$id');
return Igel.fromMap(map);
}
/// Update hedgehog data. Backend requires name; other fields optional.
Future<void> 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<dynamic>('/igel/$id', body);
}
}
+47 -27
View File
@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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,
);
@@ -43,6 +43,13 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
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<IgelDetailScreen> {
featureC.addListener(_markDirtyBasics);
noteC.addListener(_markDirtyBasics);
locationC.addListener(_markDirtyBasics); // NEU
_loadAll();
}
@@ -79,6 +87,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
nameC.dispose();
featureC.dispose();
noteC.dispose();
locationC.dispose(); // NEU
mwGewichtC.dispose();
mwBehandlungC.dispose();
mwBemerkungC.dispose();
@@ -91,14 +100,25 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
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<IgelDetailScreen> {
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<IgelDetailScreen> {
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<IgelDetailScreen> {
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<IgelDetailScreen> {
),
],
)),
]),
]), // for
],
),
),
+1 -1
View File
@@ -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"