transition 2
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user