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
@@ -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'),
),
],
);
}