transition 1

This commit is contained in:
2025-10-21 22:29:18 +02:00
parent e7ac4718dc
commit e38ab2aafa
6 changed files with 282 additions and 110 deletions
+4 -2
View File
@@ -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()),
@@ -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<SplashScreen> createState() => _SplashState();
}
class _SplashState extends ConsumerState<SplashScreen> {
@override
void initState() {
super.initState();
_boot();
}
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
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<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
} catch (_) {
return false;
}
}
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
}
+18
View File
@@ -39,4 +39,22 @@ class TokenStorage {
await clear();
}
}
Future<bool> 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<String, dynamic>;
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;
}
}
}
+1 -1
View File
@@ -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;
@@ -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<IgelListScreen> {
Future<void> _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<IgelListScreen> {
@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<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 (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<void> Function(String newName) onEdit;
final Future<void> Function() onDelete;
@override
Widget build(BuildContext context) {
return TweenAnimationBuilder<double>(
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<String>(
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')),
],
);
}
}
}
+1 -1
View File
@@ -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),
);
}