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, CheckAuthRequested event,
Emitter<AuthState> emit, Emitter<AuthState> emit,
) async { ) async {
// Check if token is valid in SessionBloc // 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()); emit(AuthUnauthenticated());
} }
} }
}

View File

@@ -1,42 +1,98 @@
import 'dart:async'; import 'dart:async';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'session_event.dart'; import 'session_event.dart';
import 'session_state.dart'; import 'session_state.dart';
class SessionBloc extends Bloc<SessionEvent, SessionState> { class SessionBloc extends Bloc<SessionEvent, SessionState> {
Timer? _expiryTimer; Timer? _expiryTimer;
static const String _tokenKey = 'auth_token';
static const String _expiryKey = 'auth_expiry';
SessionBloc() : super(SessionInitial()) { SessionBloc() : super(SessionInitial()) {
on<SessionStarted>(_onSessionStarted); on<SessionStarted>(_onSessionStarted);
on<SessionExpired>(_onSessionExpired); on<SessionExpired>(_onSessionExpired);
on<SessionRefreshed>(_onSessionRefreshed); on<SessionRefreshed>(_onSessionRefreshed);
on<SessionEnded>(_onSessionEnded); 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( final expiresAt = DateTime.now().add(
const Duration(minutes: 15), const Duration(minutes: 15),
); // Match Go ); // 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)); emit(SessionActive(token: event.token, expiresAt: expiresAt));
_startExpiryTimer(expiresAt); _startExpiryTimer(expiresAt);
} }
void _onSessionExpired(SessionExpired event, Emitter<SessionState> emit) { void _onSessionExpired(SessionExpired event, Emitter<SessionState> emit) {
_expiryTimer?.cancel(); _expiryTimer?.cancel();
_clearStoredSession();
emit(SessionExpiredState()); 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)); 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)); emit(SessionActive(token: event.newToken, expiresAt: expiresAt));
_startExpiryTimer(expiresAt); _startExpiryTimer(expiresAt);
} }
void _onSessionEnded(SessionEnded event, Emitter<SessionState> emit) { void _onSessionEnded(SessionEnded event, Emitter<SessionState> emit) {
_expiryTimer?.cancel(); _expiryTimer?.cancel();
_clearStoredSession();
emit(SessionInitial()); 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) { void _startExpiryTimer(DateTime expiresAt) {
_expiryTimer?.cancel(); _expiryTimer?.cancel();
final duration = expiresAt.difference(DateTime.now()); final duration = expiresAt.difference(DateTime.now());

View File

@@ -28,3 +28,13 @@ class SessionRefreshed extends SessionEvent {
} }
class SessionEnded 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()); runApp(const MainApp());
} }
class MainApp extends StatelessWidget { class MainApp extends StatefulWidget {
const MainApp({super.key}); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiBlocProvider( return MultiBlocProvider(
providers: [ providers: [
BlocProvider<SessionBloc>(create: (_) => SessionBloc()), BlocProvider<SessionBloc>.value(value: _sessionBloc),
BlocProvider<AuthBloc>( BlocProvider<AuthBloc>.value(value: _authBloc),
create: (context) => AuthBloc(
apiClient: ApiClient(context.read<SessionBloc>()),
sessionBloc: context.read<SessionBloc>(),
),
),
BlocProvider<ActivityBloc>( BlocProvider<ActivityBloc>(
create: (context) => create: (context) =>
ActivityBloc(ActivityApi(ApiClient(context.read<SessionBloc>()))), ActivityBloc(ActivityApi(ApiClient(_sessionBloc))),
), ),
], ],
child: MaterialApp.router( child: MaterialApp.router(
@@ -66,4 +80,11 @@ class MainApp extends StatelessWidget {
), ),
); );
} }
@override
void dispose() {
_authBloc.close();
_sessionBloc.close();
super.dispose();
}
} }