550 lines
20 KiB
Dart
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'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|