Geschlecht und Merkmal
This commit is contained in:
@@ -11,7 +11,12 @@ class SplashScreen extends ConsumerStatefulWidget {
|
||||
ConsumerState<SplashScreen> createState() => _SplashState();
|
||||
}
|
||||
|
||||
class _SplashState extends ConsumerState<SplashScreen> {
|
||||
class _SplashState extends ConsumerState<SplashScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _ac = AnimationController(
|
||||
vsync: this, duration: const Duration(milliseconds: 600))
|
||||
..forward();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -20,24 +25,16 @@ class _SplashState extends ConsumerState<SplashScreen> {
|
||||
|
||||
Future<void> _boot() async {
|
||||
final tokens = ref.read(tokenStorageProvider);
|
||||
// 1) Versuch: vorhandenes Access prüfen
|
||||
final access = await tokens.access;
|
||||
final isValid = _isJwtValid(access);
|
||||
|
||||
if (!isValid) {
|
||||
// 2) Refresh versuchen
|
||||
var authed = _isJwtValid(access);
|
||||
if (!authed) {
|
||||
await tokens.refreshAccess();
|
||||
authed = _isJwtValid(await tokens.access);
|
||||
}
|
||||
|
||||
final access2 = await tokens.access;
|
||||
final authed = _isJwtValid(access2);
|
||||
|
||||
if (!mounted) return;
|
||||
if (authed) {
|
||||
context.go('/igel');
|
||||
} else {
|
||||
context.go('/login');
|
||||
}
|
||||
await Future.delayed(
|
||||
const Duration(milliseconds: 300)); // kleines Fade-Finish
|
||||
context.go(authed ? '/igel' : '/login');
|
||||
}
|
||||
|
||||
bool _isJwtValid(String? jwt) {
|
||||
@@ -49,18 +46,36 @@ class _SplashState extends ConsumerState<SplashScreen> {
|
||||
utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))))
|
||||
as Map<String, dynamic>;
|
||||
final exp = (payload['exp'] as num?)?.toInt();
|
||||
if (exp == null) return true; // kein exp => als gültig betrachten
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
return now < exp - 15; // kleine Toleranz
|
||||
return exp == null ? true : now < exp - 15;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ac.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
final t = CurvedAnimation(parent: _ac, curve: Curves.easeOut);
|
||||
return Scaffold(
|
||||
body: FadeTransition(
|
||||
opacity: t,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
Icon(Icons.pets, size: 96),
|
||||
SizedBox(height: 12),
|
||||
CircularProgressIndicator(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:hedgehog/shared/api_client.dart';
|
||||
import '../../../shared/api_client.dart';
|
||||
import '../domain/igel.dart';
|
||||
|
||||
class IgelRepository {
|
||||
@@ -11,13 +11,25 @@ class IgelRepository {
|
||||
return list.map(Igel.fromMap).toList();
|
||||
}
|
||||
|
||||
Future<Igel> create(String name, {String? note}) async {
|
||||
final res = await api.post<dynamic>('/igel', {'name': name, 'note': note});
|
||||
Future<Igel> create(String name,
|
||||
{String? note, String? gender, String? feature}) async {
|
||||
final res = await api.post<dynamic>('/igel', {
|
||||
'name': name,
|
||||
'note': note,
|
||||
'gender': gender,
|
||||
'feature': feature,
|
||||
});
|
||||
return Igel.fromMap(res as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<void> update(int id, String name, {String? note}) async {
|
||||
await api.put<dynamic>('/igel/$id', {'name': name, 'note': note});
|
||||
Future<void> update(int id, String name,
|
||||
{String? note, String? gender, String? feature}) async {
|
||||
await api.put<dynamic>('/igel/$id', {
|
||||
'name': name,
|
||||
'note': note,
|
||||
'gender': gender,
|
||||
'feature': feature,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> delete(int id) async {
|
||||
|
||||
@@ -2,9 +2,21 @@ class Igel {
|
||||
final int id;
|
||||
final String name;
|
||||
final String? note;
|
||||
Igel({required this.id, required this.name, this.note});
|
||||
final String? gender; // männlich | weiblich | unbekannt
|
||||
final String? feature; // Merkmal
|
||||
|
||||
Igel(
|
||||
{required this.id,
|
||||
required this.name,
|
||||
this.note,
|
||||
this.gender,
|
||||
this.feature});
|
||||
|
||||
factory Igel.fromMap(Map<String, dynamic> m) => Igel(
|
||||
id: m['id'] as int,
|
||||
name: m['name'] as String,
|
||||
note: m['note'] as String?);
|
||||
id: (m['id'] as num).toInt(),
|
||||
name: (m['name'] ?? '') as String,
|
||||
note: m['note'] as String?,
|
||||
gender: m['gender'] as String?,
|
||||
feature: m['feature'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
+4
-2
@@ -5,8 +5,10 @@ import 'features/auth/data/token_storage.dart';
|
||||
import 'shared/api_client.dart';
|
||||
import 'features/igel/data/igel_repository.dart';
|
||||
|
||||
const kApiBase = String.fromEnvironment('API_BASE',
|
||||
defaultValue: 'https://api.windesign.at/hedgehogs.php?r=');
|
||||
const kApiBase = String.fromEnvironment(
|
||||
'API_BASE',
|
||||
defaultValue: 'https://api.windesign.at/hedgehogs.php?r=',
|
||||
);
|
||||
|
||||
final tokenStorageProvider =
|
||||
Provider<TokenStorage>((ref) => TokenStorage(kApiBase));
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Lightweight API-Client mit einfachem 401-Retry via Refresh-Token.
|
||||
class ApiClient {
|
||||
ApiClient(
|
||||
this.baseUrl, {
|
||||
this.getAccessToken,
|
||||
this.onUnauthorized, // z.B. () => tokenStorage.refreshAccess()
|
||||
this.onUnauthorized,
|
||||
});
|
||||
|
||||
final String baseUrl; // z.B. https://api.windesign.at/hedgehogs.php?r=
|
||||
@@ -38,9 +37,8 @@ class ApiClient {
|
||||
|
||||
Future<T> _requestWithRetry<T>(String method, String path,
|
||||
{Object? body}) async {
|
||||
http.Response res = await _send(method, path, body: body);
|
||||
var res = await _send(method, path, body: body);
|
||||
|
||||
// Bei 401 einmal Refresh versuchen
|
||||
if (res.statusCode == 401 && onUnauthorized != null) {
|
||||
await onUnauthorized!.call();
|
||||
res = await _send(method, path, body: body);
|
||||
@@ -49,9 +47,8 @@ class ApiClient {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
if (res.body.isEmpty) return (null as T);
|
||||
final decoded = jsonDecode(res.body);
|
||||
return decoded as T; // Aufrufer achtet auf Typ (Map/List)
|
||||
return decoded as T;
|
||||
}
|
||||
|
||||
throw ApiException(res.statusCode, res.body);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user