Files
hedgehog/lib/features/igel/presentation/igel_list_screen.dart
T
2025-10-21 23:30:50 +02:00

550 lines
20 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../main.dart';
import '../../igel/data/igel_repository.dart';
import '../domain/igel.dart';
class IgelListScreen extends ConsumerStatefulWidget {
const IgelListScreen({super.key});
@override
ConsumerState<IgelListScreen> createState() => _State();
}
class _State extends ConsumerState<IgelListScreen> {
late final IgelRepository repo;
List<Igel> items = [];
bool busy = true;
String? err;
final searchC = TextEditingController();
String query = '';
Timer? _debounce;
bool showForm = false;
final nameC = TextEditingController();
final noteC = TextEditingController();
final featureC = TextEditingController();
String? gender;
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();
featureC.dispose();
super.dispose();
}
Future<void> _load() async {
setState(() => busy = true);
try {
final data = await repo.list();
setState(() {
items = data;
err = null;
});
} catch (e) {
setState(() => err = e.toString());
} finally {
if (mounted) setState(() => busy = false);
}
}
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, gender: gender, feature: feature);
nameC.clear();
noteC.clear();
featureC.clear();
gender = null;
setState(() => showForm = false);
await _load();
_snack('Igel angelegt');
} catch (e) {
_snack('Fehler beim Anlegen: $e');
}
}
void _snack(String msg, {String? actionLabel, VoidCallback? onAction}) {
if (!mounted) return;
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();
final feat = (x.feature ?? '').toLowerCase();
final gen = (x.gender ?? '').toLowerCase();
return n.contains(query) ||
note.contains(query) ||
feat.contains(query) ||
gen.contains(query);
}).toList();
}
@override
Widget build(BuildContext ctx) {
final visible = _visibleItems;
return Scaffold(
appBar: AppBar(
title: const Text('Meine Igel'),
actions: [
IconButton(
tooltip: 'Logout',
icon: const Icon(Icons.logout),
onPressed: () async {
await ref.read(tokenStorageProvider).clear();
if (context.mounted) context.go('/login');
},
),
],
),
body: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: busy
? const Center(child: CircularProgressIndicator())
: err != null
? Center(child: Text(err!))
: RefreshIndicator(
onRefresh: _load,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
child: TextField(
controller: searchC,
decoration: const InputDecoration(
hintText: 'Suchen…',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
child: Row(
children: [
FilledButton.icon(
onPressed: () =>
setState(() => showForm = !showForm),
icon:
Icon(showForm ? Icons.close : Icons.add),
label: Text(showForm ? 'Abbrechen' : 'Neu'),
),
],
),
),
),
if (showForm)
SliverToBoxAdapter(
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 12),
child: Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
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)'),
minLines: 1,
maxLines: 3,
),
const SizedBox(height: 12),
Row(
children: [
FilledButton(
onPressed: _createInline,
child: const Text('Speichern')),
const SizedBox(width: 8),
TextButton(
onPressed: () {
nameC.clear();
noteC.clear();
featureC.clear();
setState(() {
showForm = false;
gender = null;
});
},
child: const Text('Abbrechen'),
),
],
)
],
),
),
),
),
),
SliverList.separated(
itemCount: visible.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (_, i) {
final x = visible[i];
return _IgelTile(
key: ValueKey('igel-${x.id}'),
igel: x,
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 {
_lastDeleted = x;
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();
});
},
);
},
),
if (visible.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: Text('Keine Treffer.')),
),
],
),
),
),
);
}
}
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.onEditAll,
required this.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;
}
}
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) {
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),
],
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 _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.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 nameC;
late final TextEditingController featureC;
late final TextEditingController noteC;
String? gender;
@override
void initState() {
super.initState();
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: 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: () {
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'),
),
],
);
}
}