Add session persistence with shared_preferences - maintain login across page refreshes

This commit is contained in:
Leon Bösche
2026-01-08 22:08:23 +01:00
parent 6a01fe84ac
commit 37e1c1a616
6 changed files with 114 additions and 13 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 KiB

View File

@@ -252,7 +252,21 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
CheckAuthRequested event,
Emitter<AuthState> emit,
) async {
// Check if token is valid in SessionBloc
emit(AuthUnauthenticated());
// Try to restore session from persistent storage
final sessionState = sessionBloc.state;
if (sessionState is SessionActive) {
// Session already active
emit(AuthAuthenticated(
token: sessionState.token,
userId: '',
username: '',
email: '',
));
} else {
// Try to restore from SharedPreferences
await Future.delayed(const Duration(milliseconds: 100)); // Give time for restoration
emit(AuthUnauthenticated());
}
}
}

View File

@@ -1,42 +1,98 @@
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'session_event.dart';
import 'session_state.dart';
class SessionBloc extends Bloc<SessionEvent, SessionState> {
Timer? _expiryTimer;
static const String _tokenKey = 'auth_token';
static const String _expiryKey = 'auth_expiry';
SessionBloc() : super(SessionInitial()) {
on<SessionStarted>(_onSessionStarted);
on<SessionExpired>(_onSessionExpired);
on<SessionRefreshed>(_onSessionRefreshed);
on<SessionEnded>(_onSessionEnded);
on<SessionRestored>(_onSessionRestored);
}
void _onSessionStarted(SessionStarted event, Emitter<SessionState> emit) {
void _onSessionStarted(SessionStarted event, Emitter<SessionState> emit) async {
final expiresAt = DateTime.now().add(
const Duration(minutes: 15),
); // Match Go
// Save token to persistent storage
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, event.token);
await prefs.setString(_expiryKey, expiresAt.toIso8601String());
emit(SessionActive(token: event.token, expiresAt: expiresAt));
_startExpiryTimer(expiresAt);
}
void _onSessionExpired(SessionExpired event, Emitter<SessionState> emit) {
_expiryTimer?.cancel();
_clearStoredSession();
emit(SessionExpiredState());
}
void _onSessionRefreshed(SessionRefreshed event, Emitter<SessionState> emit) {
void _onSessionRefreshed(SessionRefreshed event, Emitter<SessionState> emit) async {
final expiresAt = DateTime.now().add(const Duration(minutes: 15));
// Update stored token
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, event.newToken);
await prefs.setString(_expiryKey, expiresAt.toIso8601String());
emit(SessionActive(token: event.newToken, expiresAt: expiresAt));
_startExpiryTimer(expiresAt);
}
void _onSessionEnded(SessionEnded event, Emitter<SessionState> emit) {
_expiryTimer?.cancel();
_clearStoredSession();
emit(SessionInitial());
}
void _onSessionRestored(SessionRestored event, Emitter<SessionState> emit) {
final expiresAt = event.expiresAt;
final now = DateTime.now();
// Check if token is still valid
if (expiresAt.isAfter(now)) {
emit(SessionActive(token: event.token, expiresAt: expiresAt));
_startExpiryTimer(expiresAt);
} else {
// Token expired, clear it
_clearStoredSession();
emit(SessionInitial());
}
}
Future<void> _clearStoredSession() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_tokenKey);
await prefs.remove(_expiryKey);
}
static Future<void> restoreSession(SessionBloc bloc) async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(_tokenKey);
final expiryStr = prefs.getString(_expiryKey);
if (token != null && expiryStr != null) {
try {
final expiresAt = DateTime.parse(expiryStr);
bloc.add(SessionRestored(token: token, expiresAt: expiresAt));
} catch (e) {
// Invalid stored data, clear it
await prefs.remove(_tokenKey);
await prefs.remove(_expiryKey);
}
}
}
void _startExpiryTimer(DateTime expiresAt) {
_expiryTimer?.cancel();
final duration = expiresAt.difference(DateTime.now());

View File

@@ -28,3 +28,13 @@ class SessionRefreshed extends SessionEvent {
}
class SessionEnded extends SessionEvent {}
class SessionRestored extends SessionEvent {
final String token;
final DateTime expiresAt;
const SessionRestored({required this.token, required this.expiresAt});
@override
List<Object> get props => [token, expiresAt];
}

View File

@@ -41,23 +41,37 @@ void main() {
runApp(const MainApp());
}
class MainApp extends StatelessWidget {
class MainApp extends StatefulWidget {
const MainApp({super.key});
@override
State<MainApp> createState() => _MainAppState();
}
class _MainAppState extends State<MainApp> {
final _sessionBloc = SessionBloc();
late final AuthBloc _authBloc;
@override
void initState() {
super.initState();
_authBloc = AuthBloc(
apiClient: ApiClient(_sessionBloc),
sessionBloc: _sessionBloc,
);
// Restore session from persistent storage
SessionBloc.restoreSession(_sessionBloc);
}
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider<SessionBloc>(create: (_) => SessionBloc()),
BlocProvider<AuthBloc>(
create: (context) => AuthBloc(
apiClient: ApiClient(context.read<SessionBloc>()),
sessionBloc: context.read<SessionBloc>(),
),
),
BlocProvider<SessionBloc>.value(value: _sessionBloc),
BlocProvider<AuthBloc>.value(value: _authBloc),
BlocProvider<ActivityBloc>(
create: (context) =>
ActivityBloc(ActivityApi(ApiClient(context.read<SessionBloc>()))),
ActivityBloc(ActivityApi(ApiClient(_sessionBloc))),
),
],
child: MaterialApp.router(
@@ -66,4 +80,11 @@ class MainApp extends StatelessWidget {
),
);
}
@override
void dispose() {
_authBloc.close();
_sessionBloc.close();
super.dispose();
}
}