From 53c72e48e6b6a63b0a70e092bff4b5bb2feb0948 Mon Sep 17 00:00:00 2001 From: qzl Date: Wed, 25 Feb 2026 14:25:17 +0800 Subject: [PATCH] docs: update Flutter auth plan with refresh token auto-refresh --- ...026-02-25-flutter-auth-integration-plan.md | 91 +++++++++++++++---- 1 file changed, 72 insertions(+), 19 deletions(-) diff --git a/docs/plans/2026-02-25-flutter-auth-integration-plan.md b/docs/plans/2026-02-25-flutter-auth-integration-plan.md index 51efb98..34379d9 100644 --- a/docs/plans/2026-02-25-flutter-auth-integration-plan.md +++ b/docs/plans/2026-02-25-flutter-auth-integration-plan.md @@ -254,12 +254,15 @@ import '../storage/token_storage.dart'; class ApiClient { final Dio _dio; final TokenStorage _tokenStorage; + final Future Function(String)? _refreshToken; ApiClient({ required String baseUrl, required TokenStorage tokenStorage, Dio? dio, + Future Function(String)? refreshToken, }) : _tokenStorage = tokenStorage, + _refreshToken = refreshToken, _dio = dio ?? Dio(BaseOptions(baseUrl: baseUrl)) { _dio.interceptors.add(ApiInterceptor( tokenStorage: _tokenStorage, @@ -270,7 +273,18 @@ class ApiClient { Dio get dio => _dio; Future _handleTokenRefresh() async { - return false; + final refreshToken = await _tokenStorage.getRefreshToken(); + if (refreshToken == null || _refreshToken == null) return false; + try { + final response = await _refreshToken!(refreshToken); + await _tokenStorage.saveTokens( + access: response.accessToken, + refresh: response.refreshToken, + ); + return true; + } catch (_) { + return false; + } } Future> get(String path, {Options? options}) async { @@ -811,6 +825,7 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:social_app/features/auth/data/auth_repository.dart'; +import 'package:social_app/features/auth/data/models/auth_response.dart'; import 'package:social_app/features/auth/presentation/bloc/auth_bloc.dart'; import 'package:social_app/features/auth/presentation/bloc/auth_event.dart'; import 'package:social_app/features/auth/presentation/bloc/auth_state.dart'; @@ -842,15 +857,34 @@ void main() { ); blocTest( - 'emits [AuthLoading, AuthAuthenticated] when AuthStarted with valid token', + 'emits [AuthLoading, AuthAuthenticated] when AuthStarted with valid refresh token', build: () { - when(() => mockRepository.getAccessToken()).thenAnswer((_) async => 'valid_token'); + when(() => mockRepository.getRefreshToken()).thenAnswer((_) async => 'valid_refresh'); + when(() => mockRepository.refresh('valid_refresh')).thenAnswer((_) async => AuthResponse( + accessToken: 'new_access', + refreshToken: 'new_refresh', + expiresIn: 3600, + tokenType: 'bearer', + user: const AuthUser(id: '123', email: 'test@example.com'), + )); return authBloc; }, act: (bloc) => bloc.add(AuthStarted()), expect: () => [AuthLoading(), isA()], ); + blocTest( + 'emits [AuthLoading, AuthUnauthenticated] when refresh token expired', + build: () { + when(() => mockRepository.getRefreshToken()).thenAnswer((_) async => 'expired_refresh'); + when(() => mockRepository.refresh('expired_refresh')).thenThrow(Exception('Invalid refresh token')); + when(() => mockRepository.logout()).thenAnswer((_) async {}); + return authBloc; + }, + act: (bloc) => bloc.add(AuthStarted()), + expect: () => [AuthLoading(), AuthUnauthenticated()], + ); + blocTest( 'emits [AuthUnauthenticated] when AuthLoggedOut', build: () { @@ -960,14 +994,19 @@ class AuthBloc extends Bloc { Future _onStarted(AuthStarted event, Emitter emit) async { emit(AuthLoading()); - final token = await _repository.getAccessToken(); - if (token != null) { - emit(const AuthAuthenticated( - user: AuthUser(id: '', email: ''), - )); - } else { - emit(AuthUnauthenticated()); + final refreshToken = await _repository.getRefreshToken(); + if (refreshToken != null) { + try { + final response = await _repository.refresh(refreshToken); + emit(AuthAuthenticated( + user: AuthUser(id: response.user.id, email: response.user.email), + )); + return; + } catch (_) { + await _repository.logout(); + } } + emit(AuthUnauthenticated()); } void _onLoggedIn(AuthLoggedIn event, Emitter emit) { @@ -1474,21 +1513,35 @@ final sl = GetIt.instance; Future configureDependencies() async { final dio = Dio(BaseOptions(baseUrl: Env.apiUrl)); final secureStorage = const FlutterSecureStorage(); + final tokenStorage = SecureTokenStorage(secureStorage); sl.registerSingleton(dio); sl.registerSingleton(secureStorage); - sl.registerSingleton(SecureTokenStorage(secureStorage)); + sl.registerSingleton(tokenStorage); + + // Register AuthApi and AuthRepository first (needed for ApiClient refresh) + final authApi = AuthApi(ApiClient( + baseUrl: Env.apiUrl, + tokenStorage: tokenStorage, + dio: dio, + )); + sl.registerSingleton(authApi); + + final authRepository = AuthRepositoryImpl( + api: authApi, + tokenStorage: tokenStorage, + ); + sl.registerSingleton(authRepository); + + // Re-register ApiClient with refresh capability sl.registerSingleton(ApiClient( baseUrl: Env.apiUrl, - tokenStorage: sl(), - dio: sl(), + tokenStorage: tokenStorage, + dio: dio, + refreshToken: (token) => authRepository.refresh(token), )); - sl.registerSingleton(AuthApi(sl())); - sl.registerSingleton(AuthRepositoryImpl( - api: sl(), - tokenStorage: sl(), - )); - sl.registerSingleton(AuthBloc(sl())); + + sl.registerSingleton(AuthBloc(authRepository)); } ```