Create Igel

This commit is contained in:
2025-10-21 21:42:24 +02:00
parent 9654072b1d
commit e7ac4718dc
4 changed files with 198 additions and 93 deletions
+5 -5
View File
@@ -6,21 +6,21 @@ class IgelRepository {
IgelRepository(this.api);
Future<List<Igel>> list() async {
final res = await api.get('/igel');
final res = await api.get<dynamic>('/igel');
final list = (res as List).cast<Map<String, dynamic>>();
return list.map(Igel.fromMap).toList();
}
Future<Igel> create(String name, {String? note}) async {
final res = await api.post('/igel', {'name': name, 'note': note});
return Igel.fromMap(res);
final res = await api.post<dynamic>('/igel', {'name': name, 'note': note});
return Igel.fromMap(res as Map<String, dynamic>);
}
Future<void> update(int id, String name, {String? note}) async {
await api.put('/igel/$id', {'name': name, 'note': note});
await api.put<dynamic>('/igel/$id', {'name': name, 'note': note});
}
Future<void> delete(int id) async {
await api.delete('/igel/$id');
await api.delete<dynamic>('/igel/$id');
}
}
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hedgehog/main.dart';
import 'package:hedgehog/features/igel/data/igel_repository.dart';
import 'package:hedgehog/features/igel/domain/igel.dart';
import '../../../main.dart';
import '../../igel/data/igel_repository.dart';
import '../domain/igel.dart';
class IgelListScreen extends ConsumerStatefulWidget {
const IgelListScreen({super.key});
@@ -15,6 +15,12 @@ class _State extends ConsumerState<IgelListScreen> {
List<Igel> items = [];
bool busy = true;
String? err;
// Inline-Form Steuerung
bool showForm = false;
final nameC = TextEditingController();
final noteC = TextEditingController();
@override
void initState() {
super.initState();
@@ -34,92 +40,170 @@ class _State extends ConsumerState<IgelListScreen> {
}
}
Future<void> _create() async {
final name = await showDialog<String>(
context: context, builder: (_) => const _NewIgelDialog());
if (name != null && name.isNotEmpty) {
await repo.create(name);
await _load();
Future<void> _createInline() async {
final name = nameC.text.trim();
final note = noteC.text.trim().isEmpty ? null : noteC.text.trim();
if (name.isEmpty) {
_snack('Bitte einen Namen eingeben');
return;
}
try {
await repo.create(name, note: note);
nameC.clear();
noteC.clear();
setState(() => showForm = false);
await _load();
_snack('Igel angelegt');
} catch (e) {
_snack('Fehler beim Anlegen: $e');
}
}
void _snack(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
@override
Widget build(BuildContext ctx) {
return Scaffold(
appBar: AppBar(title: const Text('Meine Igel')),
floatingActionButton: FloatingActionButton(
onPressed: _create, child: const Icon(Icons.add)),
body: busy
? const Center(child: CircularProgressIndicator())
: err != null
? Center(child: Text(err!))
: ListView.separated(
itemCount: items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (_, i) {
final x = items[i];
return ListTile(
title: Text(x.name),
subtitle: x.note != null ? Text(x.note!) : null,
trailing: PopupMenuButton(
itemBuilder: (_) => [
const PopupMenuItem(
value: 'edit', child: Text('Bearbeiten')),
const PopupMenuItem(
value: 'del', child: Text('Löschen')),
: Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
FilledButton.icon(
onPressed: () => setState(() => showForm = !showForm),
icon: Icon(showForm ? Icons.close : Icons.add),
label: Text(showForm ? 'Abbrechen' : 'Neu'),
),
],
),
),
if (showForm)
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),
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();
setState(() => showForm = false);
},
child: const Text('Abbrechen'),
),
],
)
],
onSelected: (v) async {
if (v == 'del') {
await repo.delete(x.id);
await _load();
}
if (v == 'edit') {
final name = await showDialog<String>(
context: context,
builder: (_) =>
_NewIgelDialog(initial: x.name));
if (name != null) {
await repo.update(x.id, name);
await _load();
}
}
}),
);
},
),
),
),
),
const SizedBox(height: 8),
const Divider(height: 1),
Expanded(
child: ListView.separated(
itemCount: items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (_, i) {
final x = items[i];
return ListTile(
title: Text(x.name),
subtitle: x.note != null ? Text(x.note!) : null,
trailing: PopupMenuButton(
itemBuilder: (_) => const [
PopupMenuItem(value: 'edit', child: Text('Bearbeiten')),
PopupMenuItem(value: 'del', child: Text('Löschen')),
],
onSelected: (v) async {
if (v == 'del') {
try {
await repo.delete(x.id);
await _load();
_snack('Gelöscht');
} catch (e) {
_snack('Fehler beim Löschen: $e');
}
}
if (v == 'edit') {
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');
}
}
}
},
),
);
},
),
),
],
),
);
}
}
class _NewIgelDialog extends StatefulWidget {
final String? initial;
const _NewIgelDialog({this.initial});
class _EditDialog extends StatefulWidget {
const _EditDialog({required this.initial});
final String initial;
@override
State<_NewIgelDialog> createState() => _NewIgelDialogState();
State<_EditDialog> createState() => _EditDialogState();
}
class _NewIgelDialogState extends State<_NewIgelDialog> {
class _EditDialogState extends State<_EditDialog> {
late final TextEditingController c;
@override
void initState() {
super.initState();
c = TextEditingController(text: widget.initial ?? '');
c = TextEditingController(text: widget.initial);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.initial == null ? 'Neuer Igel' : 'Igel bearbeiten'),
content: TextField(
controller: c,
decoration: const InputDecoration(labelText: 'Name')),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Abbrechen')),
FilledButton(
onPressed: () => Navigator.pop(context, c.text.trim()),
child: const Text('Speichern')),
]);
title: const Text('Igel bearbeiten'),
content: TextField(controller: c, decoration: const InputDecoration(labelText: 'Name')),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Abbrechen')),
FilledButton(onPressed: () => Navigator.pop(context, c.text), child: const Text('Speichern')),
],
);
}
}
}
+8 -6
View File
@@ -5,15 +5,17 @@ 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=');
// String.fromEnvironment('API_BASE', defaultValue: 'https://api.windesign.at/hedgehogs.php');
const kApiBase = String.fromEnvironment('API_BASE',
defaultValue: 'https://api.windesign.at/hedgehogs.php?r=');
final tokenStorageProvider =
Provider<TokenStorage>((ref) => TokenStorage(kApiBase));
final apiClientProvider = Provider<ApiClient>((ref) => ApiClient(kApiBase,
getAccessToken: () =>
ref.read(tokenStorageProvider).getValidAccessToken()));
final apiClientProvider = Provider<ApiClient>((ref) => ApiClient(
kApiBase,
getAccessToken: () =>
ref.read(tokenStorageProvider).getValidAccessToken(),
onUnauthorized: () => ref.read(tokenStorageProvider).refreshAccess(),
));
final igelRepoProvider = Provider<IgelRepository>(
(ref) => IgelRepository(ref.read(apiClientProvider)));
+38 -19
View File
@@ -1,10 +1,17 @@
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});
final String baseUrl; // z.B. https://api.example.com
ApiClient(
this.baseUrl, {
this.getAccessToken,
this.onUnauthorized, // z.B. () => tokenStorage.refreshAccess()
});
final String baseUrl; // z.B. https://api.windesign.at/hedgehogs.php?r=
final Future<String?> Function()? getAccessToken;
final Future<void> Function()? onUnauthorized;
Future<http.Response> _send(String method, String path,
{Object? body}) async {
@@ -12,13 +19,16 @@ class ApiClient {
final headers = {'Content-Type': 'application/json'};
final token = await getAccessToken?.call();
if (token != null) headers['Authorization'] = 'Bearer $token';
switch (method) {
case 'GET':
return http.get(uri, headers: headers);
case 'POST':
return http.post(uri, headers: headers, body: jsonEncode(body));
return http.post(uri,
headers: headers, body: body == null ? null : jsonEncode(body));
case 'PUT':
return http.put(uri, headers: headers, body: jsonEncode(body));
return http.put(uri,
headers: headers, body: body == null ? null : jsonEncode(body));
case 'DELETE':
return http.delete(uri, headers: headers);
default:
@@ -26,23 +36,32 @@ class ApiClient {
}
}
Future<Map<String, dynamic>> get(String path) async =>
_decode(await _send('GET', path));
Future<Map<String, dynamic>> post(
String path, Map<String, dynamic> body) async =>
_decode(await _send('POST', path, body: body));
Future<Map<String, dynamic>> put(
String path, Map<String, dynamic> body) async =>
_decode(await _send('PUT', path, body: body));
Future<Map<String, dynamic>> delete(String path) async =>
_decode(await _send('DELETE', path));
Map<String, dynamic> _decode(http.Response r) {
if (r.statusCode >= 200 && r.statusCode < 300) {
if (r.body.isEmpty) return {};
return jsonDecode(r.body) as Map<String, dynamic>;
Future<T> _requestWithRetry<T>(String method, String path,
{Object? body}) async {
http.Response 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);
}
throw ApiException(r.statusCode, r.body);
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)
}
throw ApiException(res.statusCode, res.body);
}
Future<T> get<T>(String path) async => _requestWithRetry<T>('GET', path);
Future<T> post<T>(String path, Object body) async =>
_requestWithRetry<T>('POST', path, body: body);
Future<T> put<T>(String path, Object body) async =>
_requestWithRetry<T>('PUT', path, body: body);
Future<T> delete<T>(String path) async =>
_requestWithRetry<T>('DELETE', path);
}
class ApiException implements Exception {