import 'package:dio/dio.dart'; import 'package:fintracker_api/fintracker_api.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../cache/response_cache_provider.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()); /// A bare Dio for auth calls: no auth interceptor, so a refresh can never recurse. final authDioProvider = Provider( (ref) => Dio( BaseOptions( baseUrl: ref.watch(apiBaseUrlProvider), connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 20), ), ), ); final authControllerProvider = NotifierProvider( AuthController.new, ); class AuthController extends Notifier { @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 _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 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 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 _wipeLocal(); return false; } } Future _accept(TokenPair pair) async { await _store.writeRefresh(pair.refreshToken); state = AuthState( AuthStatus.signedIn, accessToken: pair.accessToken, email: state.email, ); } Future 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 _wipeLocal(); state = const AuthState(AuthStatus.signedOut); } /// Everything this device keeps for the account: the refresh token and the cached /// responses (the whole financial picture, in plain sqlite / IndexedDB). Future _wipeLocal() async { await _store.clear(); try { await ref.read(responseCacheDbProvider).clear(); } on Object { // the token is already gone; a cache that cannot be opened must not block signing out } } } /// 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 ?? 'Ошибка запроса', }; }