From e7ac4718dc44eda1fcfb78fa1468d3d0ebde7dd2 Mon Sep 17 00:00:00 2001 From: Herwig Birke Date: Tue, 21 Oct 2025 21:42:24 +0200 Subject: [PATCH] Create Igel --- lib/features/igel/data/igel_repository.dart | 10 +- .../igel/presentation/igel_list_screen.dart | 210 ++++++++++++------ lib/main.dart | 14 +- lib/shared/api_client.dart | 57 +++-- 4 files changed, 198 insertions(+), 93 deletions(-) diff --git a/lib/features/igel/data/igel_repository.dart b/lib/features/igel/data/igel_repository.dart index c78a340..c7a4e2f 100644 --- a/lib/features/igel/data/igel_repository.dart +++ b/lib/features/igel/data/igel_repository.dart @@ -6,21 +6,21 @@ class IgelRepository { IgelRepository(this.api); Future> list() async { - final res = await api.get('/igel'); + final res = await api.get('/igel'); final list = (res as List).cast>(); return list.map(Igel.fromMap).toList(); } Future 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('/igel', {'name': name, 'note': note}); + return Igel.fromMap(res as Map); } Future update(int id, String name, {String? note}) async { - await api.put('/igel/$id', {'name': name, 'note': note}); + await api.put('/igel/$id', {'name': name, 'note': note}); } Future delete(int id) async { - await api.delete('/igel/$id'); + await api.delete('/igel/$id'); } } diff --git a/lib/features/igel/presentation/igel_list_screen.dart b/lib/features/igel/presentation/igel_list_screen.dart index 01786ab..6ca8c5c 100644 --- a/lib/features/igel/presentation/igel_list_screen.dart +++ b/lib/features/igel/presentation/igel_list_screen.dart @@ -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 { List 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 { } } - Future _create() async { - final name = await showDialog( - context: context, builder: (_) => const _NewIgelDialog()); - if (name != null && name.isNotEmpty) { - await repo.create(name); - await _load(); + Future _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( - 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( + 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')), + ], + ); } -} +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 8578abf..d337402 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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((ref) => TokenStorage(kApiBase)); -final apiClientProvider = Provider((ref) => ApiClient(kApiBase, - getAccessToken: () => - ref.read(tokenStorageProvider).getValidAccessToken())); +final apiClientProvider = Provider((ref) => ApiClient( + kApiBase, + getAccessToken: () => + ref.read(tokenStorageProvider).getValidAccessToken(), + onUnauthorized: () => ref.read(tokenStorageProvider).refreshAccess(), + )); final igelRepoProvider = Provider( (ref) => IgelRepository(ref.read(apiClientProvider))); diff --git a/lib/shared/api_client.dart b/lib/shared/api_client.dart index 1641f76..be40f24 100644 --- a/lib/shared/api_client.dart +++ b/lib/shared/api_client.dart @@ -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 Function()? getAccessToken; + final Future Function()? onUnauthorized; Future _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> get(String path) async => - _decode(await _send('GET', path)); - Future> post( - String path, Map body) async => - _decode(await _send('POST', path, body: body)); - Future> put( - String path, Map body) async => - _decode(await _send('PUT', path, body: body)); - Future> delete(String path) async => - _decode(await _send('DELETE', path)); - Map _decode(http.Response r) { - if (r.statusCode >= 200 && r.statusCode < 300) { - if (r.body.isEmpty) return {}; - return jsonDecode(r.body) as Map; + Future _requestWithRetry(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 get(String path) async => _requestWithRetry('GET', path); + Future post(String path, Object body) async => + _requestWithRetry('POST', path, body: body); + Future put(String path, Object body) async => + _requestWithRetry('PUT', path, body: body); + Future delete(String path) async => + _requestWithRetry('DELETE', path); } class ApiException implements Exception {