Files
fin-tracker/app/lib/core/api/api_client.dart
T
Dmitry ffc5ed959a feat(app): offline-кэш GET-запросов на дашборде — фаза 5
CacheInterceptor кэширует каждый успешный GET по путь+параметры в drift
(sqlite нативно, wasm+OPFS в браузере) и подменяет им сетевую ошибку;
провайдер отдаёт Cached<T>, экран показывает баннер «данные на …».
Контракт для остальных экранов — docs/ai/offline-cache.md.

flake.nix: libsecret/pkg-config для линуксовой сборки, jq/curl для
just app-web-assets (сборка sqlite3.wasm + drift_worker.js).
2026-09-19 12:40:08 +03:00

90 lines
3.4 KiB
Dart

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<ResponseCacheDatabase>((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<FintrackerApi>((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<void> 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);
}
}
}