11import 'dart:async' ;
22import 'package:dio/dio.dart' ;
33import 'package:flutter/foundation.dart' show kIsWeb; // narrow imports
4- import 'package:shared_preferences/shared_preferences.dart' ;
54import 'package:leaderboard_app/config/api_config.dart' ;
5+ import 'package:leaderboard_app/services/core/token_manager.dart' ;
66
77/// Provides a configured singleton Dio instance with auth header + logging.
88class DioProvider {
99 static Dio ? _dio;
10+ static Future <String ?>? _refreshingFuture;
1011
1112 static Future <Dio > getInstance ({String ? baseUrl}) async {
1213 if (_dio != null ) return _dio! ;
13- final prefs = await SharedPreferences . getInstance ();
14+ // Note: Token values are retrieved on-demand via TokenManager.
1415 final dio = Dio (
1516 BaseOptions (
1617 baseUrl: baseUrl ?? ApiConfig .baseUrl,
17- connectTimeout: const Duration (seconds: 10 ),
18- receiveTimeout: const Duration (seconds: 20 ),
18+ connectTimeout: const Duration (seconds: 15 ),
19+ receiveTimeout: const Duration (seconds: 60 ),
1920 headers: {'Content-Type' : 'application/json' },
2021 ),
2122 );
2223
2324 dio.interceptors.add (InterceptorsWrapper (onRequest: (options, handler) async {
24- final token = prefs.getString ('authToken' );
25+ // Skip auth header for explicit opt-out
26+ if (options.extra['skipAuth' ] == true ) {
27+ handler.next (options);
28+ return ;
29+ }
30+ final token = await TokenManager .getAccessToken ();
2531 if (token != null && token.isNotEmpty) {
2632 options.headers['Authorization' ] = 'Bearer $token ' ;
2733 }
2834 handler.next (options);
29- }, onError: (e, handler) {
35+ }, onError: (e, handler) async {
3036 // Simple retry for idempotent GETs on network issues
3137 if (_shouldRetry (e)) {
3238 _retry (dio, e.requestOptions).then (handler.resolve).catchError ((_) => handler.next (e));
33- } else {
34- handler.next (e);
39+ return ;
3540 }
41+
42+ // Refresh on 401 Unauthorized, excluding auth endpoints to avoid loops
43+ final status = e.response? .statusCode;
44+ final path = e.requestOptions.path;
45+ final isAuthPath = path.contains ('/auth/login' ) || path.contains ('/auth/signup' ) || path.contains ('/auth/refresh' );
46+ final alreadyRetried = e.requestOptions.extra['retried' ] == true ;
47+
48+ if (status == 401 && ! isAuthPath && ! alreadyRetried) {
49+ try {
50+ final newToken = await _refreshTokenIfNeeded (dio);
51+ if (newToken != null && newToken.isNotEmpty) {
52+ // Clone and retry original request with new token
53+ final opts = Options (
54+ method: e.requestOptions.method,
55+ headers: {
56+ ...e.requestOptions.headers,
57+ 'Authorization' : 'Bearer $newToken ' ,
58+ },
59+ responseType: e.requestOptions.responseType,
60+ contentType: e.requestOptions.contentType,
61+ sendTimeout: e.requestOptions.sendTimeout,
62+ receiveTimeout: e.requestOptions.receiveTimeout,
63+ extra: {
64+ ...e.requestOptions.extra,
65+ 'retried' : true ,
66+ },
67+ );
68+ final rerun = await dio.request <dynamic >(
69+ e.requestOptions.path,
70+ data: e.requestOptions.data,
71+ queryParameters: e.requestOptions.queryParameters,
72+ options: opts,
73+ );
74+ handler.resolve (rerun);
75+ return ;
76+ }
77+ } catch (_) {
78+ // fall through to next
79+ }
80+ }
81+
82+ handler.next (e);
3683 }));
3784
3885 // Basic log interceptor (custom to avoid extra dependency)
@@ -43,17 +90,28 @@ class DioProvider {
4390 }
4491
4592 static bool _shouldRetry (DioException e) {
46- return e.type == DioExceptionType .connectionError && e.requestOptions.method == 'GET' ;
93+ final isGet = e.requestOptions.method == 'GET' ;
94+ final isNet = e.type == DioExceptionType .connectionError || e.type == DioExceptionType .receiveTimeout;
95+ if (! (isGet && isNet)) return false ;
96+ final attempts = (e.requestOptions.extra['retryCount' ] as int ? ) ?? 0 ;
97+ return attempts < 1 ; // retry at most once
4798 }
4899
49100 static Future <Response <dynamic >> _retry (Dio dio, RequestOptions requestOptions) async {
101+ final attempts = (requestOptions.extra['retryCount' ] as int ? ) ?? 0 ;
102+ // brief backoff before retrying
103+ await Future <void >.delayed (Duration (milliseconds: 500 * (attempts + 1 )));
50104 final opts = Options (
51105 method: requestOptions.method,
52106 headers: requestOptions.headers,
53107 responseType: requestOptions.responseType,
54108 contentType: requestOptions.contentType,
55109 sendTimeout: requestOptions.sendTimeout,
56110 receiveTimeout: requestOptions.receiveTimeout,
111+ extra: {
112+ ...requestOptions.extra,
113+ 'retryCount' : attempts + 1 ,
114+ },
57115 );
58116 return dio.request <dynamic >(
59117 requestOptions.path,
@@ -68,6 +126,72 @@ class DioProvider {
68126 static void reset () {
69127 _dio = null ;
70128 }
129+
130+ /// Ensure only one refresh happens at a time. Returns new access token or null.
131+ static Future <String ?> _refreshTokenIfNeeded (Dio baseDio) async {
132+ // If a refresh is already ongoing, await the same future
133+ final ongoing = _refreshingFuture;
134+ if (ongoing != null ) {
135+ return ongoing;
136+ }
137+
138+ final completer = Completer <String ?>();
139+ _refreshingFuture = completer.future;
140+
141+ try {
142+ final newToken = await _callRefreshEndpoint (baseDio);
143+ completer.complete (newToken);
144+ return newToken;
145+ } catch (err) {
146+ completer.complete (null );
147+ return null ;
148+ } finally {
149+ _refreshingFuture = null ;
150+ }
151+ }
152+
153+ /// Call the refresh endpoint using a bare Dio to avoid interceptor loops.
154+ static Future <String ?> _callRefreshEndpoint (Dio baseDio) async {
155+ final refreshToken = await TokenManager .getRefreshToken ();
156+ final refreshDio = Dio (
157+ BaseOptions (
158+ baseUrl: baseDio.options.baseUrl,
159+ connectTimeout: baseDio.options.connectTimeout,
160+ receiveTimeout: baseDio.options.receiveTimeout,
161+ headers: {'Content-Type' : 'application/json' },
162+ ),
163+ );
164+
165+ Response res;
166+ try {
167+ if (refreshToken != null && refreshToken.isNotEmpty) {
168+ res = await refreshDio.post ('/auth/refresh' , data: {'refreshToken' : refreshToken});
169+ } else {
170+ // Some backends use HttpOnly cookie refresh; attempt without body
171+ res = await refreshDio.post ('/auth/refresh' );
172+ }
173+ } on DioException {
174+ // Refresh failed
175+ await TokenManager .clearTokens ();
176+ return null ;
177+ }
178+
179+ final data = res.data is Map <String , dynamic >
180+ ? res.data as Map <String , dynamic >
181+ : < String , dynamic > {};
182+ final payload = (data['data' ] ?? data) as Map <String , dynamic >;
183+ final newAccess = (payload['token' ] ?? payload['accessToken' ] ?? '' ) as String ;
184+ final newRefresh = (payload['refreshToken' ] ?? payload['refresh_token' ] ?? '' ) as String ;
185+
186+ if (newAccess.isEmpty) {
187+ await TokenManager .clearTokens ();
188+ return null ;
189+ }
190+
191+ // Persist the new tokens
192+ await TokenManager .saveTokens (accessToken: newAccess, refreshToken: newRefresh.isEmpty ? null : newRefresh);
193+ return newAccess;
194+ }
71195}
72196
73197class _LogInterceptor extends Interceptor {
0 commit comments