import 'package:dio/dio.dart'; import 'package:fintracker_api/fintracker_api.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../auth/auth_controller.dart'; import '../cache/cache_interceptor.dart'; import '../cache/response_cache.dart'; import '../config.dart'; /// One sqlite-backed cache of the last successful GET per endpoint+params, /// shared by every screen. See `docs/ai/offline-cache.md`. final responseCacheDbProvider = Provider((ref) { final db = ResponseCacheDatabase(); ref.onDispose(db.close); return db; }); /// Authenticated client: bearer header on every call, one transparent refresh /// on 401, and — since [CacheInterceptor] is added last, so it sees a request /// only after auth has already handled it — the last cached answer when the /// network itself is unreachable. final apiProvider = Provider((ref) { final dio = Dio(BaseOptions( baseUrl: apiBaseUrl(), connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 30), )); dio.interceptors.add(DateQueryInterceptor()); dio.interceptors.add(_AuthInterceptor(ref)); dio.interceptors.add(CacheInterceptor(ref.watch(responseCacheDbProvider))); return FintrackerApi(dio: dio, interceptors: const []); }); /// Query dates as `YYYY-MM-DD`, which is what `format: date` in the spec means. /// /// openapi-generator types a `format: date` query parameter as `DateTime` and hands it to /// Dio unconverted, so the wire value becomes `DateTime.toString()` — /// `2026-09-18 10:31:29.084`, which FastAPI rejects with 422. Every date-typed query /// parameter in the spec is a `date` (none is a `date-time`), so truncating here is exactly /// the intended encoding rather than a lossy guess. Lives in the app, not in the generated /// package, because `just gen-client` overwrites the latter. class DateQueryInterceptor extends Interceptor { @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { options.queryParameters = { for (final e in options.queryParameters.entries) e.key: e.value is DateTime ? _isoDate(e.value as DateTime) : e.value, }; handler.next(options); } static String _isoDate(DateTime d) => '${d.year.toString().padLeft(4, '0')}-' '${d.month.toString().padLeft(2, '0')}-' '${d.day.toString().padLeft(2, '0')}'; } class _AuthInterceptor extends QueuedInterceptor { _AuthInterceptor(this._ref); final Ref _ref; @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { final token = _ref.read(authControllerProvider).accessToken; if (token != null) options.headers['Authorization'] = 'Bearer $token'; handler.next(options); } @override Future onError(DioException err, ErrorInterceptorHandler handler) async { final retried = err.requestOptions.extra['retried'] == true; if (err.response?.statusCode != 401 || retried) return handler.next(err); final auth = _ref.read(authControllerProvider.notifier); if (!await auth.refreshSession()) { await auth.logout(); return handler.next(err); } final token = _ref.read(authControllerProvider).accessToken; final opts = err.requestOptions ..headers['Authorization'] = 'Bearer $token' ..extra['retried'] = true; try { handler.resolve(await Dio(BaseOptions(baseUrl: opts.baseUrl)).fetch(opts)); } on DioException catch (e) { handler.next(e); } } }