Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,4 @@ app.*.map.json
/android/app/release

.env
*.g.dart
4 changes: 1 addition & 3 deletions lib/core/converter/date_time_converter.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import 'package:json_annotation/json_annotation.dart';

class UtcDateTimeConverter implements JsonConverter<DateTime, String> {

const UtcDateTimeConverter();

@override
Expand All @@ -13,5 +12,4 @@ class UtcDateTimeConverter implements JsonConverter<DateTime, String> {
String toJson(DateTime object) {
return object.toUtc().toIso8601String();
}

}
}
5 changes: 2 additions & 3 deletions lib/core/env/env.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import 'package:flutter_dotenv/flutter_dotenv.dart';

class Env {
static String get baseUrl =>
dotenv.env["BASE_URL"] ?? 'http://10.0.2.2:8080';
}
static String get baseUrl => dotenv.env["BASE_URL"] ?? 'http://10.0.2.2:8080';
}
40 changes: 40 additions & 0 deletions lib/core/network/auth_path_policy.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/// Auth route classification for [AuthInterceptor] (path only, no query).
class AuthPathPolicy {
const AuthPathPolicy._();

/// No bearer; no refresh-on-401 retry.
static const anonymousPaths = {
'/auth/login',
'/auth/register',
'/auth/refresh',
'/auth/external',
};

/// Bearer required; lives under `/auth/*` but is not anonymous.
static const protectedPaths = {
'/auth/logout',
'/auth/credentials',
'/auth/link-external',
'/auth/unlink-external',
'/auth/password',
};

static String normalize(String path) => Uri.parse(path).path;

static bool isAnonymous(String path) =>
anonymousPaths.contains(normalize(path));

static bool isProtectedAuth(String path) =>
protectedPaths.contains(normalize(path));

static bool requiresBearer(String path) {
final normalized = normalize(path);
if (isAnonymous(normalized)) {
return false;
}
if (normalized.startsWith('/auth/')) {
return isProtectedAuth(normalized);
}
return true;
}
}
83 changes: 70 additions & 13 deletions lib/core/network/interceptors/auth_interceptor.dart
Original file line number Diff line number Diff line change
@@ -1,34 +1,91 @@
import 'package:dio/dio.dart';
import 'package:food4student_next/core/network/auth_path_policy.dart';
import 'package:food4student_next/core/storage/token_storage.dart';

class AuthInterceptor extends Interceptor {
static const _retriedKey = 'auth_retried';

static const _ignoreRequestPattern = "/auth/"; // Replace with real auth endpoints pattern

final Dio dio;
final TokenStorage tokenStorage;
final Future<bool> Function() tryRefresh;
final Future<void> Function() onRefreshFailed;
Future<bool>? _ongoingRefresh;

AuthInterceptor({
required this.tokenStorage
required this.tokenStorage,
required this.dio,
required this.tryRefresh,
required this.onRefreshFailed,
});

@override
Future<void> onRequest (RequestOptions options, RequestInterceptorHandler handler) async{
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
final path = AuthPathPolicy.normalize(options.path);
final token = await tokenStorage.getAccessToken();

if (token != null && _isRequireAuthorizationHeader(options)) {
options.headers['Authorization'] = "Bearer $token";
if (token != null && AuthPathPolicy.requiresBearer(path)) {
options.headers['Authorization'] = 'Bearer $token';
}

super.onRequest(options, handler);
handler.next(options);
}

@override
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
// TODO: implement onError to handle refresh token flow
super.onError(err, handler);
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
final request = err.requestOptions;
final statusCode = err.response?.statusCode;
final alreadyRetried = request.extra[_retriedKey] == true;
final path = AuthPathPolicy.normalize(request.path);

final shouldAttemptRefresh =
statusCode == 401 &&
AuthPathPolicy.requiresBearer(path) &&
!AuthPathPolicy.isProtectedAuth(path) &&
!alreadyRetried;
if (!shouldAttemptRefresh) {
handler.next(err);
return;
}

final refreshed = await _refreshSingleFlight();
if (!refreshed) {
await onRefreshFailed();
handler.next(err);
return;
}

try {
final retryRequest = _cloneForRetry(request);
final response = await dio.fetch<dynamic>(retryRequest);
handler.resolve(response);
} on DioException catch (retryErr) {
handler.next(retryErr);
}
}

Future<bool> _refreshSingleFlight() async {
if (_ongoingRefresh != null) {
return _ongoingRefresh!;
}
final pending = tryRefresh();
_ongoingRefresh = pending;
try {
return await pending;
} finally {
_ongoingRefresh = null;
}
}

bool _isRequireAuthorizationHeader(RequestOptions options) {
return !options.path.contains(_ignoreRequestPattern);
RequestOptions _cloneForRetry(RequestOptions request) {
return request.copyWith(
headers: Map<String, dynamic>.from(request.headers),
extra: <String, dynamic>{...request.extra, _retriedKey: true},
);
}
}
}
4 changes: 2 additions & 2 deletions lib/core/network/interceptors/logger_interceptor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class LoggerInterceptor extends Interceptor {
lineLength: 75,
colors: true,
printEmojis: true,
dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart
dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart,
),
);

Expand All @@ -34,4 +34,4 @@ class LoggerInterceptor extends Interceptor {
logger.d('StatusCode: ${response.statusCode}, Data: ${response.data}');
return super.onResponse(response, handler);
}
}
}
36 changes: 25 additions & 11 deletions lib/core/network/providers/network_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,44 @@ import 'package:food4student_next/core/env/env.dart';
import 'package:food4student_next/core/network/interceptors/auth_interceptor.dart';
import 'package:food4student_next/core/network/interceptors/logger_interceptor.dart';
import 'package:food4student_next/core/storage/providers/token_provider.dart';
import 'package:food4student_next/features/auth/session/auth_session_controller.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';


part 'network_provider.g.dart';

/// Anonymous API client: no bearer, no refresh-on-401 (login, refresh, etc.).
@Riverpod(keepAlive: true)
Dio dio(Ref ref) {
Dio anonDio(Ref ref) {
const timeoutDuration = 10;

final dio = Dio(
final client = Dio(
BaseOptions(
baseUrl: Env.baseUrl,
connectTimeout: const Duration(seconds: timeoutDuration),
receiveTimeout: const Duration(seconds: timeoutDuration),
headers: {
'Content-Type': 'application/json',
},
headers: {'Content-Type': 'application/json'},
),
);

dio.interceptors.addAll([
client.interceptors.addAll([LoggerInterceptor()]);

return client;
}

/// Authenticated API client: bearer + 401 refresh + retry.
@Riverpod(keepAlive: true)
Dio authedDio(Ref ref) {
final client = Dio(ref.watch(anonDioProvider).options);
client.interceptors.addAll([
LoggerInterceptor(),
AuthInterceptor(tokenStorage: ref.watch(tokenStorageProvider)),
AuthInterceptor(
tokenStorage: ref.watch(tokenStorageProvider),
dio: client,
tryRefresh: ref.read(authSessionControllerProvider.notifier).refresh,
onRefreshFailed: ref
.read(authSessionControllerProvider.notifier)
.forceLogoutLocal,
),
]);

return dio;
}
return client;
}
65 changes: 57 additions & 8 deletions lib/core/network/providers/network_provider.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion lib/core/storage/providers/storage_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,3 @@ part 'storage_provider.g.dart';
FlutterSecureStorage secureStorage(Ref ref) {
return const FlutterSecureStorage();
}

6 changes: 2 additions & 4 deletions lib/core/storage/providers/token_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,5 @@ part 'token_provider.g.dart';

@Riverpod(keepAlive: true)
TokenStorage tokenStorage(Ref ref) {
return TokenStorage(
ref.watch(secureStorageProvider),
);
}
return TokenStorage(ref.watch(secureStorageProvider));
}
Loading
Loading