Geschlecht und Merkmal

This commit is contained in:
2025-10-21 23:30:50 +02:00
parent 31afb2f342
commit c084347fbd
6 changed files with 337 additions and 240 deletions
@@ -11,7 +11,12 @@ class SplashScreen extends ConsumerStatefulWidget {
ConsumerState<SplashScreen> createState() => _SplashState();
}
class _SplashState extends ConsumerState<SplashScreen> {
class _SplashState extends ConsumerState<SplashScreen>
with SingleTickerProviderStateMixin {
late final AnimationController _ac = AnimationController(
vsync: this, duration: const Duration(milliseconds: 600))
..forward();
@override
void initState() {
super.initState();
@@ -20,24 +25,16 @@ class _SplashState extends ConsumerState<SplashScreen> {
Future<void> _boot() async {
final tokens = ref.read(tokenStorageProvider);
// 1) Versuch: vorhandenes Access prüfen
final access = await tokens.access;
final isValid = _isJwtValid(access);
if (!isValid) {
// 2) Refresh versuchen
var authed = _isJwtValid(access);
if (!authed) {
await tokens.refreshAccess();
authed = _isJwtValid(await tokens.access);
}
final access2 = await tokens.access;
final authed = _isJwtValid(access2);
if (!mounted) return;
if (authed) {
context.go('/igel');
} else {
context.go('/login');
}
await Future.delayed(
const Duration(milliseconds: 300)); // kleines Fade-Finish
context.go(authed ? '/igel' : '/login');
}
bool _isJwtValid(String? jwt) {
@@ -49,18 +46,36 @@ class _SplashState extends ConsumerState<SplashScreen> {
utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))))
as Map<String, dynamic>;
final exp = (payload['exp'] as num?)?.toInt();
if (exp == null) return true; // kein exp => als gültig betrachten
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
return now < exp - 15; // kleine Toleranz
return exp == null ? true : now < exp - 15;
} catch (_) {
return false;
}
}
@override
void dispose() {
_ac.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
final t = CurvedAnimation(parent: _ac, curve: Curves.easeOut);
return Scaffold(
body: FadeTransition(
opacity: t,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(Icons.pets, size: 96),
SizedBox(height: 12),
CircularProgressIndicator(),
],
),
),
),
);
}
}
+17 -5
View File
@@ -1,4 +1,4 @@
import 'package:hedgehog/shared/api_client.dart';
import '../../../shared/api_client.dart';
import '../domain/igel.dart';
class IgelRepository {
@@ -11,13 +11,25 @@ class IgelRepository {
return list.map(Igel.fromMap).toList();
}
Future<Igel> create(String name, {String? note}) async {
final res = await api.post<dynamic>('/igel', {'name': name, 'note': note});
Future<Igel> create(String name,
{String? note, String? gender, String? feature}) async {
final res = await api.post<dynamic>('/igel', {
'name': name,
'note': note,
'gender': gender,
'feature': feature,
});
return Igel.fromMap(res as Map<String, dynamic>);
}
Future<void> update(int id, String name, {String? note}) async {
await api.put<dynamic>('/igel/$id', {'name': name, 'note': note});
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,
});
}
Future<void> delete(int id) async {
+16 -4
View File
@@ -2,9 +2,21 @@ class Igel {
final int id;
final String name;
final String? note;
Igel({required this.id, required this.name, this.note});
final String? gender; // männlich | weiblich | unbekannt
final String? feature; // Merkmal
Igel(
{required this.id,
required this.name,
this.note,
this.gender,
this.feature});
factory Igel.fromMap(Map<String, dynamic> m) => Igel(
id: m['id'] as int,
name: m['name'] as String,
note: m['note'] as String?);
id: (m['id'] as num).toInt(),
name: (m['name'] ?? '') as String,
note: m['note'] as String?,
gender: m['gender'] as String?,
feature: m['feature'] as String?,
);
}
@@ -18,17 +18,16 @@ class _State extends ConsumerState<IgelListScreen> {
bool busy = true;
String? err;
// Suche/Filter
final searchC = TextEditingController();
String query = '';
Timer? _debounce;
// Inline-Form Steuerung
bool showForm = false;
final nameC = TextEditingController();
final noteC = TextEditingController();
final featureC = TextEditingController();
String? gender;
// Für Undo
Igel? _lastDeleted;
@override
@@ -51,6 +50,7 @@ class _State extends ConsumerState<IgelListScreen> {
searchC.dispose();
nameC.dispose();
noteC.dispose();
featureC.dispose();
super.dispose();
}
@@ -72,14 +72,17 @@ class _State extends ConsumerState<IgelListScreen> {
Future<void> _createInline() async {
final name = nameC.text.trim();
final note = noteC.text.trim().isEmpty ? null : noteC.text.trim();
final feature = featureC.text.trim().isEmpty ? null : featureC.text.trim();
if (name.isEmpty) {
_snack('Bitte einen Namen eingeben');
return;
}
try {
await repo.create(name, note: note);
await repo.create(name, note: note, gender: gender, feature: feature);
nameC.clear();
noteC.clear();
featureC.clear();
gender = null;
setState(() => showForm = false);
await _load();
_snack('Igel angelegt');
@@ -106,7 +109,12 @@ class _State extends ConsumerState<IgelListScreen> {
return items.where((x) {
final n = x.name.toLowerCase();
final note = (x.note ?? '').toLowerCase();
return n.contains(query) || note.contains(query);
final feat = (x.feature ?? '').toLowerCase();
final gen = (x.gender ?? '').toLowerCase();
return n.contains(query) ||
note.contains(query) ||
feat.contains(query) ||
gen.contains(query);
}).toList();
}
@@ -138,7 +146,6 @@ class _State extends ConsumerState<IgelListScreen> {
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
// Suchfeld
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
@@ -152,8 +159,6 @@ class _State extends ConsumerState<IgelListScreen> {
),
),
),
// Neu-Button + Inline-Form
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(
@@ -189,6 +194,44 @@ class _State extends ConsumerState<IgelListScreen> {
labelText: 'Name'),
),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
value: gender,
decoration: const InputDecoration(
labelText: 'Geschlecht (optional)'),
items: const [
DropdownMenuItem(
value: null,
child: _GenderItem(
icon:
Icons.remove_circle_outline,
label: '— keine Angabe —'),
),
DropdownMenuItem(
value: 'männlich',
child: _GenderItem(
icon: Icons.male,
label: 'Männlich')),
DropdownMenuItem(
value: 'weiblich',
child: _GenderItem(
icon: Icons.female,
label: 'Weiblich')),
DropdownMenuItem(
value: 'unbekannt',
child: _GenderItem(
icon: Icons.help_outline,
label: 'Unbekannt')),
],
onChanged: (v) =>
setState(() => gender = v),
),
const SizedBox(height: 8),
TextField(
controller: featureC,
decoration: const InputDecoration(
labelText: 'Merkmal (optional)'),
),
const SizedBox(height: 8),
TextField(
controller: noteC,
decoration: const InputDecoration(
@@ -207,7 +250,11 @@ class _State extends ConsumerState<IgelListScreen> {
onPressed: () {
nameC.clear();
noteC.clear();
setState(() => showForm = false);
featureC.clear();
setState(() {
showForm = false;
gender = null;
});
},
child: const Text('Abbrechen'),
),
@@ -219,8 +266,6 @@ class _State extends ConsumerState<IgelListScreen> {
),
),
),
// Liste
SliverList.separated(
itemCount: visible.length,
separatorBuilder: (_, __) => const Divider(height: 1),
@@ -229,60 +274,35 @@ class _State extends ConsumerState<IgelListScreen> {
return _IgelTile(
key: ValueKey('igel-${x.id}'),
igel: x,
onEdit: (newName) async {
try {
await repo.update(x.id, newName.trim(),
note: x.note);
await _load();
_snack('Gespeichert');
} catch (e) {
_snack('Fehler beim Speichern: $e');
}
onEditAll: (result) async {
await repo.update(
x.id,
result.name.trim(),
note: result.note,
gender: result.gender,
feature: result.feature,
);
await _load();
_snack('Gespeichert');
},
onDelete: () async {
// Optimistisch löschen mit Undo-Angebot
_lastDeleted = x;
try {
await repo.delete(x.id);
await repo.delete(x.id);
await _load();
_snack('Gelöscht', actionLabel: 'Rückgängig',
onAction: () async {
final ig = _lastDeleted;
if (ig == null) return;
await repo.create(ig.name,
note: ig.note,
gender: ig.gender,
feature: ig.feature);
await _load();
_snack('Gelöscht', actionLabel: 'Rückgängig',
onAction: () async {
final ig = _lastDeleted;
if (ig == null) return;
try {
await repo.create(ig.name,
note: ig.note); // neue ID
await _load();
} catch (e) {
_snack(
'Wiederherstellen fehlgeschlagen: $e');
}
});
} catch (e) {
_snack('Fehler beim Löschen: $e');
}
},
onSwipeEdit: () async {
final newName = await showDialog<String>(
context: context,
builder: (_) => _EditDialog(initial: x.name),
);
if (newName != null &&
newName.trim().isNotEmpty) {
try {
await repo.update(x.id, newName.trim(),
note: x.note);
await _load();
_snack('Gespeichert');
} catch (e) {
_snack('Fehler beim Speichern: $e');
}
}
});
},
);
},
),
if (visible.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
@@ -296,194 +316,233 @@ class _State extends ConsumerState<IgelListScreen> {
}
}
class _GenderItem extends StatelessWidget {
const _GenderItem({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) => Row(
children: [Icon(icon, size: 18), const SizedBox(width: 8), Text(label)]);
}
class _IgelTile extends StatelessWidget {
const _IgelTile({
super.key,
required this.igel,
required this.onEdit,
required this.onEditAll,
required this.onDelete,
required this.onSwipeEdit,
});
final Igel igel;
final Future<void> Function(String newName) onEdit;
final Future<void> Function() onDelete;
final Future<void> Function() onSwipeEdit;
@override
Widget build(BuildContext context) {
return Dismissible(
key: ValueKey('dismiss-${igel.id}'),
direction: DismissDirection.horizontal,
background: const _EditBg(), // swipe nach rechts → Edit
secondaryBackground: const _DeleteBg(), // swipe nach links → Delete
confirmDismiss: (direction) async {
if (direction == DismissDirection.startToEnd) {
// Edit per Swipe (rechts)
await onSwipeEdit();
return false; // Element bleibt
} else {
// Delete per Swipe (links)
return await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Löschen?'),
content: Text('"${igel.name}" wirklich löschen?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Abbrechen')),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Löschen')),
],
),
) ??
false;
}
},
onDismissed: (_) async {
await onDelete();
},
child: _AnimatedTile(
igel: igel,
onEdit: onEdit,
onDelete: onDelete,
),
);
final Igel igel;
final Future<void> Function(_IgelEditResult updated) onEditAll;
final Future<void> Function() onDelete;
IconData? _genderIcon(String? g) {
switch (g) {
case 'männlich':
return Icons.male;
case 'weiblich':
return Icons.female;
case 'unbekannt':
return Icons.help_outline;
default:
return null;
}
}
}
class _AnimatedTile extends StatelessWidget {
const _AnimatedTile(
{required this.igel, required this.onEdit, required this.onDelete});
final Igel igel;
final Future<void> Function(String newName) onEdit;
final Future<void> Function() onDelete;
Color? _genderColor(String? g) {
switch (g) {
case 'männlich':
return Colors.blue[600];
case 'weiblich':
return Colors.pink[400];
case 'unbekannt':
return Colors.grey[600];
default:
return null;
}
}
@override
Widget build(BuildContext context) {
return TweenAnimationBuilder<double>(
duration: const Duration(milliseconds: 220),
tween: Tween(begin: 0.0, end: 1.0),
builder: (context, t, child) => Opacity(
opacity: t,
child: Transform.scale(scale: 0.95 + 0.05 * t, child: child),
),
child: ListTile(
leading: CircleAvatar(
child:
Text(igel.name.isNotEmpty ? igel.name[0].toUpperCase() : '?')),
title: Text(igel.name),
subtitle: igel.note != null ? Text(igel.note!) : null,
trailing: PopupMenuButton(
itemBuilder: (_) => const [
PopupMenuItem(value: 'edit', child: Text('Bearbeiten')),
PopupMenuItem(value: 'del', child: Text('Löschen')),
final icon = _genderIcon(igel.gender);
final iconColor = _genderColor(igel.gender);
final feature =
(igel.feature ?? '').trim().isEmpty ? null : igel.feature!.trim();
final titleStyle = Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w600);
return ListTile(
leading: CircleAvatar(
child: Text(igel.name.isNotEmpty ? igel.name[0].toUpperCase() : '?')),
title: Row(
children: [
if (icon != null) ...[
Icon(icon, size: 22, color: iconColor),
const SizedBox(width: 6),
],
onSelected: (v) async {
if (v == 'del') {
final ok = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Löschen?'),
content: Text('"${igel.name}" wirklich löschen?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Abbrechen')),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Löschen')),
],
),
) ??
false;
if (ok) {
await onDelete();
}
} else if (v == 'edit') {
final newName = await showDialog<String>(
context: context,
builder: (_) => _EditDialog(initial: igel.name),
);
if (newName != null && newName.trim().isNotEmpty) {
await onEdit(newName);
}
Expanded(child: Text(igel.name, style: titleStyle)),
],
),
subtitle: feature != null
? Padding(
padding: const EdgeInsets.only(top: 2),
child:
Text(feature, style: Theme.of(context).textTheme.bodyMedium),
)
: null,
trailing: PopupMenuButton(
itemBuilder: (_) => const [
PopupMenuItem(value: 'edit', child: Text('Bearbeiten')),
PopupMenuItem(value: 'del', child: Text('Löschen')),
],
onSelected: (v) async {
if (v == 'del') {
final ok = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Löschen?'),
content: Text('"${igel.name}" wirklich löschen?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Abbrechen')),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Löschen')),
],
),
) ??
false;
if (ok) await onDelete();
} else if (v == 'edit') {
final result = await showDialog<_IgelEditResult>(
context: context,
builder: (_) => _EditDialog(
initialName: igel.name,
initialGender: igel.gender,
initialFeature: igel.feature,
initialNote: igel.note,
),
);
if (result != null && result.name.trim().isNotEmpty) {
await onEditAll(result);
}
},
),
}
},
),
);
}
}
class _EditBg extends StatelessWidget {
const _EditBg();
@override
Widget build(BuildContext context) {
return Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16),
color: Colors.blue.withOpacity(0.10),
child: Row(
children: const [
Icon(Icons.edit),
SizedBox(width: 8),
Text('Bearbeiten'),
],
),
);
}
}
class _DeleteBg extends StatelessWidget {
const _DeleteBg();
@override
Widget build(BuildContext context) {
return Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.symmetric(horizontal: 16),
color: Colors.red.withOpacity(0.12),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: const [
Icon(Icons.delete_forever),
SizedBox(width: 8),
Text('Löschen'),
],
),
);
}
class _IgelEditResult {
final String name;
final String? gender;
final String? feature;
final String? note;
_IgelEditResult({required this.name, this.gender, this.feature, this.note});
}
class _EditDialog extends StatefulWidget {
const _EditDialog({required this.initial});
final String initial;
const _EditDialog({
required this.initialName,
required this.initialGender,
required this.initialFeature,
required this.initialNote,
});
final String initialName;
final String? initialGender;
final String? initialFeature;
final String? initialNote;
@override
State<_EditDialog> createState() => _EditDialogState();
}
class _EditDialogState extends State<_EditDialog> {
late final TextEditingController c;
late final TextEditingController nameC;
late final TextEditingController featureC;
late final TextEditingController noteC;
String? gender;
@override
void initState() {
super.initState();
c = TextEditingController(text: widget.initial);
nameC = TextEditingController(text: widget.initialName);
featureC = TextEditingController(text: widget.initialFeature ?? '');
noteC = TextEditingController(text: widget.initialNote ?? '');
gender = widget.initialGender;
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Igel bearbeiten'),
content: TextField(
controller: c, decoration: const InputDecoration(labelText: 'Name')),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameC,
decoration: const InputDecoration(labelText: 'Name')),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
value: gender,
decoration:
const InputDecoration(labelText: 'Geschlecht (optional)'),
items: const [
DropdownMenuItem(
value: null,
child: _GenderItem(
icon: Icons.remove_circle_outline,
label: '— keine Angabe —'),
),
DropdownMenuItem(
value: 'männlich',
child: _GenderItem(icon: Icons.male, label: 'Männlich')),
DropdownMenuItem(
value: 'weiblich',
child: _GenderItem(icon: Icons.female, label: 'Weiblich')),
DropdownMenuItem(
value: 'unbekannt',
child: _GenderItem(
icon: Icons.help_outline, label: 'Unbekannt')),
],
onChanged: (v) => setState(() => gender = v),
),
const SizedBox(height: 8),
TextField(
controller: featureC,
decoration:
const InputDecoration(labelText: 'Merkmal (optional)')),
const SizedBox(height: 8),
TextField(
controller: noteC,
decoration:
const InputDecoration(labelText: 'Notiz (optional)')),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Abbrechen')),
FilledButton(
onPressed: () => Navigator.pop(context, c.text),
child: const Text('Speichern')),
onPressed: () {
final res = _IgelEditResult(
name: nameC.text,
gender: gender,
feature:
featureC.text.trim().isEmpty ? null : featureC.text.trim(),
note: noteC.text.trim().isEmpty ? null : noteC.text.trim(),
);
Navigator.pop(context, res);
},
child: const Text('Speichern'),
),
],
);
}
+4 -2
View File
@@ -5,8 +5,10 @@ import 'features/auth/data/token_storage.dart';
import 'shared/api_client.dart';
import 'features/igel/data/igel_repository.dart';
const kApiBase = String.fromEnvironment('API_BASE',
defaultValue: 'https://api.windesign.at/hedgehogs.php?r=');
const kApiBase = String.fromEnvironment(
'API_BASE',
defaultValue: 'https://api.windesign.at/hedgehogs.php?r=',
);
final tokenStorageProvider =
Provider<TokenStorage>((ref) => TokenStorage(kApiBase));
+3 -6
View File
@@ -1,12 +1,11 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
/// Lightweight API-Client mit einfachem 401-Retry via Refresh-Token.
class ApiClient {
ApiClient(
this.baseUrl, {
this.getAccessToken,
this.onUnauthorized, // z.B. () => tokenStorage.refreshAccess()
this.onUnauthorized,
});
final String baseUrl; // z.B. https://api.windesign.at/hedgehogs.php?r=
@@ -38,9 +37,8 @@ class ApiClient {
Future<T> _requestWithRetry<T>(String method, String path,
{Object? body}) async {
http.Response res = await _send(method, path, body: body);
var res = await _send(method, path, body: body);
// Bei 401 einmal Refresh versuchen
if (res.statusCode == 401 && onUnauthorized != null) {
await onUnauthorized!.call();
res = await _send(method, path, body: body);
@@ -49,9 +47,8 @@ class ApiClient {
if (res.statusCode >= 200 && res.statusCode < 300) {
if (res.body.isEmpty) return (null as T);
final decoded = jsonDecode(res.body);
return decoded as T; // Aufrufer achtet auf Typ (Map/List)
return decoded as T;
}
throw ApiException(res.statusCode, res.body);
}