transition 1
This commit is contained in:
+4
-2
@@ -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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,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
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user