import 'package:dio/dio.dart'; import 'package:cookie_jar/cookie_jar.dart'; import 'package:dio_cookie_manager/dio_cookie_manager.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:path_provider/path_provider.dart'; /// Zentraler HTTP-Client. Access-Token liegt in Secure Storage, Refresh-Token /// kommt vom Backend AUSSCHLIESSLICH als HttpOnly-Cookie (siehe auth.py: /// TokenResponse.refresh_token wird bei jedem Login/Refresh serverseitig auf /// None gesetzt) → persistente Cookie-Jar nötig, kein Body-Refresh-Token. class ApiClient { ApiClient._internal(); static final ApiClient instance = ApiClient._internal(); static const _storage = FlutterSecureStorage(); static const _keyBaseUrl = 'api_base_url'; static const _keyAccessToken = 'access_token'; late final Dio dio; late final PersistCookieJar _cookieJar; void Function()? onUnauthorized; String _baseUrl = 'https://timemaster.example.com/api/v1'; Future init() async { final saved = await _storage.read(key: _keyBaseUrl); if (saved != null && saved.isNotEmpty) _baseUrl = saved; final dir = await getApplicationSupportDirectory(); _cookieJar = PersistCookieJar(storage: FileStorage('${dir.path}/cookies')); dio = Dio(BaseOptions( baseUrl: _baseUrl, connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 15), )); dio.interceptors.add(CookieManager(_cookieJar)); dio.interceptors.add(InterceptorsWrapper( onRequest: (options, handler) async { final token = await _storage.read(key: _keyAccessToken); if (token != null) { options.headers['Authorization'] = 'Bearer $token'; } handler.next(options); }, onError: (error, handler) async { if (error.response?.statusCode == 401 && error.requestOptions.path != '/auth/refresh') { final refreshed = await _tryRefresh(); if (refreshed) { final opts = error.requestOptions; final token = await _storage.read(key: _keyAccessToken); opts.headers['Authorization'] = 'Bearer $token'; try { final response = await dio.fetch(opts); return handler.resolve(response); } catch (_) { // fällt durch zu logout unten } } await clearSession(); onUnauthorized?.call(); } handler.next(error); }, )); } Future _tryRefresh() async { try { // Cookie wird von CookieManager automatisch mitgeschickt. final response = await dio.post('/auth/refresh'); final data = response.data as Map; await _storage.write(key: _keyAccessToken, value: data['access_token'] as String); return true; } catch (_) { return false; } } Future setBaseUrl(String url) async { _baseUrl = url; dio.options.baseUrl = url; await _storage.write(key: _keyBaseUrl, value: url); } String get baseUrl => _baseUrl; Future saveAccessToken(String accessToken) async { await _storage.write(key: _keyAccessToken, value: accessToken); } Future clearSession() async { await _storage.delete(key: _keyAccessToken); await _cookieJar.deleteAll(); } Future hasSession() async { return (await _storage.read(key: _keyAccessToken)) != null; } }