feat(app): Flutter-клиент — логин, дашборд, потоки, категории, транзакции, правила
Riverpod + go_router с auth-guard, NavigationRail на широком экране и bottom bar на узком. Токены в flutter_secure_storage, на web access живёт в памяти. Интерцептор подставляет токен и делает ровно один refresh на 401. Деньги приходят строками и форматируются через Decimal: парсить их в double значило бы терять копейки ровно там, где бэкенд их бережёт.
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
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 '../config.dart';
|
||||
|
||||
/// Authenticated client: bearer header on every call, one transparent refresh on 401.
|
||||
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));
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user