Files
fin-tracker/app/lib/core/auth/auth_controller.dart
T
Dmitry ce0966fca2 feat(app): Flutter-клиент — логин, дашборд, потоки, категории, транзакции, правила
Riverpod + go_router с auth-guard, NavigationRail на широком экране и bottom bar
на узком. Токены в flutter_secure_storage, на web access живёт в памяти.
Интерцептор подставляет токен и делает ровно один refresh на 401.

Деньги приходят строками и форматируются через Decimal: парсить их в double
значило бы терять копейки ровно там, где бэкенд их бережёт.
2026-09-18 13:44:09 +03:00

110 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 '../config.dart';
import 'token_store.dart';
enum AuthStatus { unknown, signedOut, signedIn }
class AuthState {
const AuthState(this.status, {this.accessToken, this.email});
final AuthStatus status;
final String? accessToken;
final String? email;
bool get signedIn => status == AuthStatus.signedIn;
}
final tokenStoreProvider = Provider<TokenStore>((_) => TokenStore());
/// A bare Dio for auth calls: no auth interceptor, so a refresh can never recurse.
final authDioProvider = Provider<Dio>(
(_) => Dio(BaseOptions(
baseUrl: apiBaseUrl(),
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 20),
)),
);
final authControllerProvider = NotifierProvider<AuthController, AuthState>(AuthController.new);
class AuthController extends Notifier<AuthState> {
@override
AuthState build() {
Future.microtask(_restore);
return const AuthState(AuthStatus.unknown);
}
AuthApi get _api => AuthApi(ref.read(authDioProvider));
TokenStore get _store => ref.read(tokenStoreProvider);
Future<void> _restore() async {
final refresh = await _store.readRefresh();
if (refresh == null) {
state = const AuthState(AuthStatus.signedOut);
return;
}
final ok = await refreshSession(refresh);
if (!ok) state = const AuthState(AuthStatus.signedOut);
}
Future<String?> login(String email, String password) async {
try {
final r = await _api.authLogin(loginRequest: LoginRequest(email: email, password: password));
await _accept(r.data!);
return null;
} on DioException catch (e) {
return problemMessage(e);
}
}
/// Exchange a refresh token for a new pair. Returns false when it is no longer valid.
Future<bool> refreshSession([String? refresh]) async {
final token = refresh ?? await _store.readRefresh();
if (token == null) return false;
try {
final r = await _api.authRefresh(refreshRequest: RefreshRequest(refreshToken: token));
await _accept(r.data!);
return true;
} on DioException catch (e) {
if (e.response?.statusCode == 401) await _store.clear();
return false;
}
}
Future<void> _accept(TokenPair pair) async {
await _store.writeRefresh(pair.refreshToken);
state = AuthState(AuthStatus.signedIn, accessToken: pair.accessToken, email: state.email);
}
Future<void> logout() async {
final refresh = await _store.readRefresh();
if (refresh != null) {
try {
await _api.authLogout(refreshRequest: RefreshRequest(refreshToken: refresh));
} on DioException {
// the server may already consider it revoked; local state wins
}
}
await _store.clear();
state = const AuthState(AuthStatus.signedOut);
}
}
/// Human-readable text from an RFC 7807 body, falling back to the transport error.
String problemMessage(DioException e) {
final data = e.response?.data;
if (data is Map) {
final detail = data['detail'] ?? data['title'];
if (detail is String && detail.isNotEmpty) return detail;
}
return switch (e.type) {
DioExceptionType.connectionError ||
DioExceptionType.connectionTimeout ||
DioExceptionType.receiveTimeout =>
'Сервер недоступен',
_ => e.message ?? 'Ошибка запроса',
};
}