From c084347fbd2d95f678c7bcb118c762df9d3be11d Mon Sep 17 00:00:00 2001 From: Herwig Birke Date: Tue, 21 Oct 2025 23:30:50 +0200 Subject: [PATCH] Geschlecht und Merkmal --- .../auth/application/splash_screen.dart | 53 +- lib/features/igel/data/igel_repository.dart | 22 +- lib/features/igel/domain/igel.dart | 20 +- .../igel/presentation/igel_list_screen.dart | 467 ++++++++++-------- lib/main.dart | 6 +- lib/shared/api_client.dart | 9 +- 6 files changed, 337 insertions(+), 240 deletions(-) diff --git a/lib/features/auth/application/splash_screen.dart b/lib/features/auth/application/splash_screen.dart index 4cd02ae..1044644 100644 --- a/lib/features/auth/application/splash_screen.dart +++ b/lib/features/auth/application/splash_screen.dart @@ -11,7 +11,12 @@ class SplashScreen extends ConsumerStatefulWidget { ConsumerState createState() => _SplashState(); } -class _SplashState extends ConsumerState { +class _SplashState extends ConsumerState + 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 { Future _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 { utf8.decode(base64Url.decode(base64Url.normalize(parts[1])))) as Map; 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(), + ], + ), + ), + ), ); } } diff --git a/lib/features/igel/data/igel_repository.dart b/lib/features/igel/data/igel_repository.dart index 48360e7..04e0ced 100644 --- a/lib/features/igel/data/igel_repository.dart +++ b/lib/features/igel/data/igel_repository.dart @@ -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 create(String name, {String? note}) async { - final res = await api.post('/igel', {'name': name, 'note': note}); + Future create(String name, + {String? note, String? gender, String? feature}) async { + final res = await api.post('/igel', { + 'name': name, + 'note': note, + 'gender': gender, + 'feature': feature, + }); return Igel.fromMap(res as Map); } - Future update(int id, String name, {String? note}) async { - await api.put('/igel/$id', {'name': name, 'note': note}); + 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, + }); } Future delete(int id) async { diff --git a/lib/features/igel/domain/igel.dart b/lib/features/igel/domain/igel.dart index 7457d23..dcc8569 100644 --- a/lib/features/igel/domain/igel.dart +++ b/lib/features/igel/domain/igel.dart @@ -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 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?, + ); } diff --git a/lib/features/igel/presentation/igel_list_screen.dart b/lib/features/igel/presentation/igel_list_screen.dart index 3f7905e..d861243 100644 --- a/lib/features/igel/presentation/igel_list_screen.dart +++ b/lib/features/igel/presentation/igel_list_screen.dart @@ -18,17 +18,16 @@ class _State extends ConsumerState { 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 { searchC.dispose(); nameC.dispose(); noteC.dispose(); + featureC.dispose(); super.dispose(); } @@ -72,14 +72,17 @@ class _State extends ConsumerState { Future _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 { 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 { 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 { ), ), ), - - // Neu-Button + Inline-Form SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric( @@ -189,6 +194,44 @@ class _State extends ConsumerState { labelText: 'Name'), ), const SizedBox(height: 8), + DropdownButtonFormField( + 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 { 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 { ), ), ), - - // Liste SliverList.separated( itemCount: visible.length, separatorBuilder: (_, __) => const Divider(height: 1), @@ -229,60 +274,35 @@ class _State extends ConsumerState { 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( - 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 { } } +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 Function(String newName) onEdit; - final Future Function() onDelete; - final Future 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( - 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 Function(_IgelEditResult updated) onEditAll; + final Future 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 Function(String newName) onEdit; - final Future 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( - 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( - 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( - 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( + 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( + 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'), + ), ], ); } diff --git a/lib/main.dart b/lib/main.dart index a2d6811..7e306a9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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((ref) => TokenStorage(kApiBase)); diff --git a/lib/shared/api_client.dart b/lib/shared/api_client.dart index 8f6f8f9..bf33722 100644 --- a/lib/shared/api_client.dart +++ b/lib/shared/api_client.dart @@ -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 _requestWithRetry(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); }