transition 2

This commit is contained in:
2025-10-21 22:48:56 +02:00
parent e38ab2aafa
commit 31afb2f342
2 changed files with 226 additions and 31 deletions
@@ -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<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();
// 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<void> _load() async {
@@ -63,13 +88,31 @@ class _State extends ConsumerState<IgelListScreen> {
}
}
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<Igel> 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<IgelListScreen> {
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<IgelListScreen> {
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<IgelListScreen> {
),
),
),
// 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<IgelListScreen> {
}
},
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<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 (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<IgelListScreen> {
}
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<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,
),
);
}
}
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;
@@ -226,11 +363,12 @@ class _IgelTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return TweenAnimationBuilder<double>(
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<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,
@@ -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;
+1 -1
View File
@@ -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);