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
+15 -16
View File
@@ -4,27 +4,21 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_controller.dart';
import '../cache/cache_interceptor.dart';
import '../cache/response_cache.dart';
import '../cache/response_cache_provider.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),
));
final dio = Dio(
BaseOptions(
baseUrl: ref.watch(apiBaseUrlProvider),
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)));
@@ -67,7 +61,10 @@ class _AuthInterceptor extends QueuedInterceptor {
}
@override
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
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);
@@ -81,7 +78,9 @@ class _AuthInterceptor extends QueuedInterceptor {
..headers['Authorization'] = 'Bearer $token'
..extra['retried'] = true;
try {
handler.resolve(await Dio(BaseOptions(baseUrl: opts.baseUrl)).fetch(opts));
handler.resolve(
await Dio(BaseOptions(baseUrl: opts.baseUrl)).fetch(opts),
);
} on DioException catch (e) {
handler.next(e);
}
+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 ?? 'Ошибка запроса',
};
}
+17
View File
@@ -0,0 +1,17 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../config.dart';
import 'auth_controller.dart';
/// Points the app at [url] (null = the default). A different server means a different
/// account, so the session ends first — while the clients still talk to the OLD server, so
/// the refresh token is revoked there and never sent to the new one — and only then is the
/// address changed. Takes a container rather than a `WidgetRef`: signing out replaces the
/// calling screen, and a disposed widget's `ref` throws.
Future<void> switchApiBaseUrl(ProviderContainer container, String? url) async {
final next = url ?? defaultApiBaseUrl();
if (next != container.read(apiBaseUrlProvider)) {
await container.read(authControllerProvider.notifier).logout();
}
await container.read(apiBaseUrlProvider.notifier).save(url);
}
+3 -2
View File
@@ -4,12 +4,13 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// (single-user, TLS-only deployment; see plan §7 open question 1).
class TokenStore {
TokenStore([FlutterSecureStorage? storage])
: _storage = storage ?? const FlutterSecureStorage();
: _storage = storage ?? const FlutterSecureStorage();
final FlutterSecureStorage _storage;
static const _refreshKey = 'refresh_token';
Future<String?> readRefresh() => _storage.read(key: _refreshKey);
Future<void> writeRefresh(String token) => _storage.write(key: _refreshKey, value: token);
Future<void> writeRefresh(String token) =>
_storage.write(key: _refreshKey, value: token);
Future<void> clear() => _storage.delete(key: _refreshKey);
}
+12 -4
View File
@@ -28,7 +28,10 @@ class CacheInterceptor extends Interceptor {
};
@override
void onResponse(Response<dynamic> response, ResponseInterceptorHandler handler) {
void onResponse(
Response<dynamic> response,
ResponseInterceptorHandler handler,
) {
if (response.requestOptions.method == 'GET' && response.statusCode == 200) {
// Never block the UI on the write: the caller already has its data.
unawaited(_db.put(requestKey(response.requestOptions), response.data));
@@ -37,8 +40,12 @@ class CacheInterceptor extends Interceptor {
}
@override
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.requestOptions.method != 'GET' || !_offlineTypes.contains(err.type)) {
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
if (err.requestOptions.method != 'GET' ||
!_offlineTypes.contains(err.type)) {
return handler.next(err);
}
final cached = await _db.get(requestKey(err.requestOptions));
@@ -56,7 +63,8 @@ class CacheInterceptor extends Interceptor {
/// `path?sorted=query` — deliberately excludes the host, so switching the API
/// origin (dev vs. prod) does not orphan a device's cache.
static String requestKey(RequestOptions options) {
final params = options.queryParameters.entries.toList()..sort((a, b) => a.key.compareTo(b.key));
final params = options.queryParameters.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
final query = params.map((e) => '${e.key}=${e.value}').join('&');
return '${options.path}?$query';
}
+2 -1
View File
@@ -15,7 +15,8 @@ extension CachedResponseX<T> on Response<T> {
/// Wraps this response's data with the staleness [CacheInterceptor] recorded
/// in `extra['fetchedAt']`. A provider that wants offline support returns
/// `r.cached` instead of `r.data!`.
Cached<T> get cached => Cached(data as T, fetchedAt: extra['fetchedAt'] as DateTime?);
Cached<T> get cached =>
Cached(data as T, fetchedAt: extra['fetchedAt'] as DateTime?);
}
/// The oldest of several fetch times, or null if none of them is stale.
+11 -7
View File
@@ -38,7 +38,8 @@ class ResponseCacheDatabase extends _$ResponseCacheDatabase {
/// Replaces whatever was cached for [requestKey] — one row per endpoint+params,
/// never a history.
Future<void> put(String requestKey, Object? body) => into(cachedResponses).insertOnConflictUpdate(
Future<void> put(String requestKey, Object? body) =>
into(cachedResponses).insertOnConflictUpdate(
CachedResponsesCompanion.insert(
requestKey: requestKey,
body: jsonEncode(body),
@@ -46,6 +47,9 @@ class ResponseCacheDatabase extends _$ResponseCacheDatabase {
),
);
/// Drops every cached body — on sign-out, so the next account (or nobody) never sees them.
Future<void> clear() => delete(cachedResponses).go();
/// The body last stored for [requestKey], or null if this endpoint+params was
/// never fetched successfully on this device.
Future<CacheEntry?> get(String requestKey) async {
@@ -63,9 +67,9 @@ class ResponseCacheDatabase extends _$ResponseCacheDatabase {
/// the compiled assets it loads there (regenerate with `just app-web-assets`
/// whenever the `drift`/`sqlite3` package versions change).
QueryExecutor _openConnection() => driftDatabase(
name: 'response_cache',
web: DriftWebOptions(
sqlite3Wasm: Uri.parse('sqlite3.wasm'),
driftWorker: Uri.parse('drift_worker.js'),
),
);
name: 'response_cache',
web: DriftWebOptions(
sqlite3Wasm: Uri.parse('sqlite3.wasm'),
driftWorker: Uri.parse('drift_worker.js'),
),
);
+11
View File
@@ -0,0 +1,11 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'response_cache.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;
});
+49 -2
View File
@@ -1,11 +1,58 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Backend origin. Override at build time:
const _prefsKey = 'api_base_url';
/// Backend origin when the user has not chosen one. Override at build time:
/// `flutter run --dart-define=API_BASE_URL=https://fin.example.com`.
/// On web the default is the page's own origin (Caddy serves app and API together).
String apiBaseUrl() {
String defaultApiBaseUrl() {
const fromEnv = String.fromEnvironment('API_BASE_URL');
if (fromEnv.isNotEmpty) return fromEnv;
if (kIsWeb) return Uri.base.origin;
return 'http://127.0.0.1:8000';
}
/// The address saved on this device, read in `main()` before `runApp`. Loading it up front,
/// rather than lazily like the theme, matters: the session restore fires a request on
/// startup and it must already go to the right server.
Future<String?> loadStoredApiBaseUrl() async =>
(await SharedPreferences.getInstance()).getString(_prefsKey);
/// Overridden in `main()` with [loadStoredApiBaseUrl]; null (tests, nothing saved) means default.
final initialApiBaseUrlProvider = Provider<String?>((_) => null);
/// Backend origin every client is built against. Watched by `apiProvider` and
/// `authDioProvider`, so changing it rebuilds them. To switch servers use
/// `switchApiBaseUrl`: tokens and cached data belong to the old one.
final apiBaseUrlProvider = NotifierProvider<ApiBaseUrlController, String>(
ApiBaseUrlController.new,
);
class ApiBaseUrlController extends Notifier<String> {
@override
String build() => ref.watch(initialApiBaseUrlProvider) ?? defaultApiBaseUrl();
/// [input] as an origin-like URL without a trailing slash, or null if it is not an
/// absolute http(s) URL.
static String? normalize(String input) {
final uri = Uri.tryParse(input.trim());
if (uri == null || !uri.hasAuthority || uri.host.isEmpty) return null;
if (uri.scheme != 'http' && uri.scheme != 'https') return null;
final s = uri.toString();
return s.endsWith('/') ? s.substring(0, s.length - 1) : s;
}
/// Persists [url]; null goes back to [defaultApiBaseUrl].
Future<void> save(String? url) async {
final prefs = await SharedPreferences.getInstance();
if (url == null) {
await prefs.remove(_prefsKey);
state = defaultApiBaseUrl();
} else {
await prefs.setString(_prefsKey, url);
state = url;
}
}
}