diff --git a/lib/features/igel/presentation/igel_list_screen.dart b/lib/features/igel/presentation/igel_list_screen.dart index 1eedf4c..3f7905e 100644 --- a/lib/features/igel/presentation/igel_list_screen.dart +++ b/lib/features/igel/presentation/igel_list_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -17,16 +18,40 @@ 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(); + // Für Undo + Igel? _lastDeleted; + @override void initState() { super.initState(); repo = ref.read(igelRepoProvider); _load(); + searchC.addListener(() { + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 150), () { + if (!mounted) return; + setState(() => query = searchC.text.trim().toLowerCase()); + }); + }); + } + + @override + void dispose() { + _debounce?.cancel(); + searchC.dispose(); + nameC.dispose(); + noteC.dispose(); + super.dispose(); } Future _load() async { @@ -63,13 +88,31 @@ class _State extends ConsumerState { } } - void _snack(String msg) { + void _snack(String msg, {String? actionLabel, VoidCallback? onAction}) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(msg), + action: onAction != null && actionLabel != null + ? SnackBarAction(label: actionLabel, onPressed: onAction) + : null, + ), + ); + } + + List get _visibleItems { + if (query.isEmpty) return items; + return items.where((x) { + final n = x.name.toLowerCase(); + final note = (x.note ?? '').toLowerCase(); + return n.contains(query) || note.contains(query); + }).toList(); } @override Widget build(BuildContext ctx) { + final visible = _visibleItems; return Scaffold( appBar: AppBar( title: const Text('Meine Igel'), @@ -78,15 +121,6 @@ class _State extends ConsumerState { tooltip: 'Logout', icon: const Icon(Icons.logout), onPressed: () async { - // Tokens löschen + Server-Refresh-Token invalidieren - try { - // Optional: logout-Call, falls du den Refresh-Token revoken willst - // final tokens = ref.read(tokenStorageProvider); - // final r = await tokens.refresh; - // if (r != null) { - // await http.post(Uri.parse('$kApiBase/auth/logout'), headers: {'Content-Type':'application/json'}, body: jsonEncode({'refresh_token': r})); - // } - } catch (_) {} await ref.read(tokenStorageProvider).clear(); if (context.mounted) context.go('/login'); }, @@ -104,9 +138,26 @@ class _State extends ConsumerState { child: CustomScrollView( physics: const AlwaysScrollableScrollPhysics(), slivers: [ + // Suchfeld SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.fromLTRB(12, 12, 12, 4), + child: TextField( + controller: searchC, + decoration: const InputDecoration( + hintText: 'Suchen…', + prefixIcon: Icon(Icons.search), + border: OutlineInputBorder(), + ), + ), + ), + ), + + // Neu-Button + Inline-Form + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), child: Row( children: [ FilledButton.icon( @@ -168,11 +219,13 @@ class _State extends ConsumerState { ), ), ), + + // Liste SliverList.separated( - itemCount: items.length, + itemCount: visible.length, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (_, i) { - final x = items[i]; + final x = visible[i]; return _IgelTile( key: ValueKey('igel-${x.id}'), igel: x, @@ -187,23 +240,53 @@ class _State extends ConsumerState { } }, onDelete: () async { + // Optimistisch löschen mit Undo-Angebot + _lastDeleted = x; try { await repo.delete(x.id); await _load(); - _snack('Gelöscht'); + _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 (items.isEmpty) + + if (visible.isEmpty) const SliverFillRemaining( hasScrollBody: false, - child: Center( - child: Text( - 'Noch keine Igel – lege den ersten an.')), + child: Center(child: Text('Keine Treffer.')), ), ], ), @@ -214,11 +297,65 @@ class _State extends ConsumerState { } class _IgelTile extends StatelessWidget { - const _IgelTile( - {super.key, - required this.igel, - required this.onEdit, - required this.onDelete}); + const _IgelTile({ + super.key, + required this.igel, + required this.onEdit, + 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, + ), + ); + } +} + +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; @@ -226,11 +363,12 @@ class _IgelTile extends StatelessWidget { @override Widget build(BuildContext context) { return TweenAnimationBuilder( - duration: const Duration(milliseconds: 200), - tween: Tween(begin: 0.95, end: 1), - curve: Curves.easeOut, - builder: (context, scale, child) => - Transform.scale(scale: scale, child: child), + 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: @@ -244,7 +382,25 @@ class _IgelTile extends StatelessWidget { ], onSelected: (v) async { if (v == 'del') { - await onDelete(); + 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, @@ -261,6 +417,45 @@ class _IgelTile extends StatelessWidget { } } +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 _EditDialog extends StatefulWidget { const _EditDialog({required this.initial}); final String initial; diff --git a/lib/shared/api_client.dart b/lib/shared/api_client.dart index be40f24..8f6f8f9 100644 --- a/lib/shared/api_client.dart +++ b/lib/shared/api_client.dart @@ -40,7 +40,7 @@ class ApiClient { {Object? body}) async { http.Response res = await _send(method, path, body: body); - // Bei 401 einmal Refresh versuchen +// Bei 401 einmal Refresh versuchen if (res.statusCode == 401 && onUnauthorized != null) { await onUnauthorized!.call(); res = await _send(method, path, body: body);