feat(app): смена сервера API и очистка данных устройства при выходе
Адрес бэкенда хранится в shared_preferences и читается до runApp; switchApiBaseUrl сбрасывает токены и кэш старого сервера. Выход стирает refresh-токен и sqlite-кэш ответов. Экран настроек переделан под разделы.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 ?? 'Ошибка запроса',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
|
||||
Vendored
+2
-1
@@ -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
@@ -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
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user