From e38ab2aafa8b64a2feda0d86e0bdb436af0d0a88 Mon Sep 17 00:00:00 2001 From: Herwig Birke Date: Tue, 21 Oct 2025 22:29:18 +0200 Subject: [PATCH] transition 1 --- lib/app_router.dart | 6 +- .../auth/application/splash_screen.dart | 66 ++++ lib/features/auth/data/token_storage.dart | 18 ++ lib/features/igel/data/igel_repository.dart | 2 +- .../igel/presentation/igel_list_screen.dart | 298 +++++++++++------- lib/main.dart | 2 +- 6 files changed, 282 insertions(+), 110 deletions(-) create mode 100644 lib/features/auth/application/splash_screen.dart diff --git a/lib/app_router.dart b/lib/app_router.dart index 775f62a..5f9ea79 100644 --- a/lib/app_router.dart +++ b/lib/app_router.dart @@ -3,10 +3,12 @@ import 'package:go_router/go_router.dart'; import 'features/auth/presentation/login_screen.dart'; import 'features/auth/presentation/register_screen.dart'; import 'features/igel/presentation/igel_list_screen.dart'; +import 'features/auth/application/splash_screen.dart'; -GoRouter buildRouter({required bool isAuthed}) => GoRouter( - initialLocation: isAuthed ? '/igel' : '/login', +GoRouter buildRouter() => GoRouter( + initialLocation: '/splash', routes: [ + GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()), GoRoute(path: '/login', builder: (_, __) => const LoginScreen()), GoRoute(path: '/register', builder: (_, __) => const RegisterScreen()), GoRoute(path: '/igel', builder: (_, __) => const IgelListScreen()), diff --git a/lib/features/auth/application/splash_screen.dart b/lib/features/auth/application/splash_screen.dart new file mode 100644 index 0000000..4cd02ae --- /dev/null +++ b/lib/features/auth/application/splash_screen.dart @@ -0,0 +1,66 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../auth/data/token_storage.dart'; +import '../../../main.dart'; +import 'package:go_router/go_router.dart'; + +class SplashScreen extends ConsumerStatefulWidget { + const SplashScreen({super.key}); + @override + ConsumerState createState() => _SplashState(); +} + +class _SplashState extends ConsumerState { + @override + void initState() { + super.initState(); + _boot(); + } + + Future _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 + await tokens.refreshAccess(); + } + + final access2 = await tokens.access; + final authed = _isJwtValid(access2); + + if (!mounted) return; + if (authed) { + context.go('/igel'); + } else { + context.go('/login'); + } + } + + bool _isJwtValid(String? jwt) { + if (jwt == null || jwt.isEmpty) return false; + try { + final parts = jwt.split('.'); + if (parts.length != 3) return false; + final payload = jsonDecode( + utf8.decode(base64Url.decode(base64Url.normalize(parts[1])))) + as Map; + 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 + } catch (_) { + return false; + } + } + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } +} diff --git a/lib/features/auth/data/token_storage.dart b/lib/features/auth/data/token_storage.dart index 3ed20fa..3442766 100644 --- a/lib/features/auth/data/token_storage.dart +++ b/lib/features/auth/data/token_storage.dart @@ -39,4 +39,22 @@ class TokenStorage { await clear(); } } + + Future hasValidAccessToken() async { + final a = await access; + if (a == null || a.isEmpty) return false; + try { + final parts = a.split('.'); + if (parts.length != 3) return false; + final payload = jsonDecode( + utf8.decode(base64Url.decode(base64Url.normalize(parts[1])))) + as Map; + final exp = (payload['exp'] as num?)?.toInt(); + if (exp == null) return true; + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return now < exp - 15; + } catch (_) { + return false; + } + } } diff --git a/lib/features/igel/data/igel_repository.dart b/lib/features/igel/data/igel_repository.dart index c7a4e2f..48360e7 100644 --- a/lib/features/igel/data/igel_repository.dart +++ b/lib/features/igel/data/igel_repository.dart @@ -1,5 +1,5 @@ import 'package:hedgehog/shared/api_client.dart'; -import 'package:hedgehog/features/igel/domain/igel.dart'; +import '../domain/igel.dart'; class IgelRepository { final ApiClient api; diff --git a/lib/features/igel/presentation/igel_list_screen.dart b/lib/features/igel/presentation/igel_list_screen.dart index 6ca8c5c..1eedf4c 100644 --- a/lib/features/igel/presentation/igel_list_screen.dart +++ b/lib/features/igel/presentation/igel_list_screen.dart @@ -1,5 +1,6 @@ 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'; @@ -31,10 +32,13 @@ class _State extends ConsumerState { Future _load() async { setState(() => busy = true); try { - items = await repo.list(); - err = null; + final data = await repo.list(); + setState(() { + items = data; + err = null; + }); } catch (e) { - err = e.toString(); + setState(() => err = e.toString()); } finally { if (mounted) setState(() => busy = false); } @@ -67,116 +71,192 @@ class _State extends ConsumerState { @override Widget build(BuildContext ctx) { return Scaffold( - appBar: AppBar(title: const Text('Meine Igel')), - body: busy - ? const Center(child: CircularProgressIndicator()) - : err != null - ? Center(child: Text(err!)) - : 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( + appBar: AppBar( + title: const Text('Meine Igel'), + actions: [ + IconButton( + tooltip: 'Logout', + icon: const Icon(Icons.logout), + onPressed: () async { + // Tokens löschen + Server-Refresh-Token invalidieren + try { + // Optional: logout-Call, falls du den Refresh-Token revoken willst + // final tokens = ref.read(tokenStorageProvider); + // final r = await tokens.refresh; + // if (r != null) { + // await http.post(Uri.parse('$kApiBase/auth/logout'), headers: {'Content-Type':'application/json'}, body: jsonEncode({'refresh_token': r})); + // } + } catch (_) {} + 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.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - TextField( - controller: nameC, - decoration: const InputDecoration(labelText: 'Name'), + FilledButton.icon( + onPressed: () => + setState(() => showForm = !showForm), + icon: + Icon(showForm ? Icons.close : Icons.add), + label: Text(showForm ? 'Abbrechen' : 'Neu'), ), - 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'), - ), - ], - ) ], ), ), ), - ), - - 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'); - } - } + 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), + 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'), + ), + ], + ) + ], + ), + ), + ), + ), + ), + SliverList.separated( + itemCount: items.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, i) { + final x = items[i]; + 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'); } }, - ), - ); - }, - ), + onDelete: () async { + try { + await repo.delete(x.id); + await _load(); + _snack('Gelöscht'); + } catch (e) { + _snack('Fehler beim Löschen: $e'); + } + }, + ); + }, + ), + if (items.isEmpty) + const SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Text( + 'Noch keine Igel – lege den ersten an.')), + ), + ], ), - ], - ), + ), + ), + ); + } +} + +class _IgelTile extends StatelessWidget { + const _IgelTile( + {super.key, + required this.igel, + required this.onEdit, + required this.onDelete}); + final Igel igel; + final Future Function(String newName) onEdit; + final Future Function() onDelete; + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + duration: const Duration(milliseconds: 200), + tween: Tween(begin: 0.95, end: 1), + curve: Curves.easeOut, + builder: (context, scale, child) => + Transform.scale(scale: scale, 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')), + ], + onSelected: (v) async { + if (v == 'del') { + await onDelete(); + } else if (v == 'edit') { + final newName = await showDialog( + context: context, + builder: (_) => _EditDialog(initial: igel.name), + ); + if (newName != null && newName.trim().isNotEmpty) { + await onEdit(newName); + } + } + }, + ), + ), ); } } @@ -195,15 +275,21 @@ class _EditDialogState extends State<_EditDialog> { super.initState(); c = TextEditingController(text: widget.initial); } + @override Widget build(BuildContext context) { return AlertDialog( title: const Text('Igel bearbeiten'), - content: TextField(controller: c, decoration: const InputDecoration(labelText: 'Name')), + 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')), + 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 d337402..a2d6811 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -29,7 +29,7 @@ class IgelApp extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { return MaterialApp.router( title: 'Igel', - routerConfig: buildRouter(isAuthed: false), // TODO: Token prüfen + routerConfig: buildRouter(), theme: ThemeData(useMaterial3: true), ); }