feat(app): смена сервера API и очистка данных устройства при выходе

Адрес бэкенда хранится в shared_preferences и читается до runApp; switchApiBaseUrl сбрасывает токены и кэш старого сервера. Выход стирает refresh-токен и sqlite-кэш ответов. Экран настроек переделан под разделы.
This commit is contained in:
Dmitry
2026-09-19 22:14:14 +03:00
parent bfe74ca47c
commit a559d6de3e
17 changed files with 887 additions and 135 deletions
+39 -14
View File
@@ -2,6 +2,7 @@ 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';
@@ -20,14 +21,18 @@ 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),
)),
(ref) => Dio(
BaseOptions(
baseUrl: ref.watch(apiBaseUrlProvider),
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 20),
),
),
);
final authControllerProvider = NotifierProvider<AuthController, AuthState>(AuthController.new);
final authControllerProvider = NotifierProvider<AuthController, AuthState>(
AuthController.new,
);
class AuthController extends Notifier<AuthState> {
@override
@@ -51,7 +56,9 @@ class AuthController extends Notifier<AuthState> {
Future<String?> login(String email, String password) async {
try {
final r = await _api.authLogin(loginRequest: LoginRequest(email: email, password: password));
final r = await _api.authLogin(
loginRequest: LoginRequest(email: email, password: password),
);
await _accept(r.data!);
return null;
} on DioException catch (e) {
@@ -64,32 +71,51 @@ class AuthController extends Notifier<AuthState> {
final token = refresh ?? await _store.readRefresh();
if (token == null) return false;
try {
final r = await _api.authRefresh(refreshRequest: RefreshRequest(refreshToken: token));
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();
if (e.response?.statusCode == 401) await _wipeLocal();
return false;
}
}
Future<void> _accept(TokenPair pair) async {
await _store.writeRefresh(pair.refreshToken);
state = AuthState(AuthStatus.signedIn, accessToken: pair.accessToken, email: state.email);
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));
await _api.authLogout(
refreshRequest: RefreshRequest(refreshToken: refresh),
);
} on DioException {
// the server may already consider it revoked; local state wins
}
}
await _store.clear();
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<void> _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.
@@ -102,8 +128,7 @@ String problemMessage(DioException e) {
return switch (e.type) {
DioExceptionType.connectionError ||
DioExceptionType.connectionTimeout ||
DioExceptionType.receiveTimeout =>
'Сервер недоступен',
DioExceptionType.receiveTimeout => 'Сервер недоступен',
_ => e.message ?? 'Ошибка запроса',
};
}