Geschlecht und Merkmal
This commit is contained in:
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user