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 '../auth/auth_controller.dart';
|
||||||
import '../cache/cache_interceptor.dart';
|
import '../cache/cache_interceptor.dart';
|
||||||
import '../cache/response_cache.dart';
|
import '../cache/response_cache_provider.dart';
|
||||||
import '../config.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
|
/// Authenticated client: bearer header on every call, one transparent refresh
|
||||||
/// on 401, and — since [CacheInterceptor] is added last, so it sees a request
|
/// 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
|
/// only after auth has already handled it — the last cached answer when the
|
||||||
/// network itself is unreachable.
|
/// network itself is unreachable.
|
||||||
final apiProvider = Provider<FintrackerApi>((ref) {
|
final apiProvider = Provider<FintrackerApi>((ref) {
|
||||||
final dio = Dio(BaseOptions(
|
final dio = Dio(
|
||||||
baseUrl: apiBaseUrl(),
|
BaseOptions(
|
||||||
|
baseUrl: ref.watch(apiBaseUrlProvider),
|
||||||
connectTimeout: const Duration(seconds: 10),
|
connectTimeout: const Duration(seconds: 10),
|
||||||
receiveTimeout: const Duration(seconds: 30),
|
receiveTimeout: const Duration(seconds: 30),
|
||||||
));
|
),
|
||||||
|
);
|
||||||
dio.interceptors.add(DateQueryInterceptor());
|
dio.interceptors.add(DateQueryInterceptor());
|
||||||
dio.interceptors.add(_AuthInterceptor(ref));
|
dio.interceptors.add(_AuthInterceptor(ref));
|
||||||
dio.interceptors.add(CacheInterceptor(ref.watch(responseCacheDbProvider)));
|
dio.interceptors.add(CacheInterceptor(ref.watch(responseCacheDbProvider)));
|
||||||
@@ -67,7 +61,10 @@ class _AuthInterceptor extends QueuedInterceptor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
|
Future<void> onError(
|
||||||
|
DioException err,
|
||||||
|
ErrorInterceptorHandler handler,
|
||||||
|
) async {
|
||||||
final retried = err.requestOptions.extra['retried'] == true;
|
final retried = err.requestOptions.extra['retried'] == true;
|
||||||
if (err.response?.statusCode != 401 || retried) return handler.next(err);
|
if (err.response?.statusCode != 401 || retried) return handler.next(err);
|
||||||
|
|
||||||
@@ -81,7 +78,9 @@ class _AuthInterceptor extends QueuedInterceptor {
|
|||||||
..headers['Authorization'] = 'Bearer $token'
|
..headers['Authorization'] = 'Bearer $token'
|
||||||
..extra['retried'] = true;
|
..extra['retried'] = true;
|
||||||
try {
|
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) {
|
} on DioException catch (e) {
|
||||||
handler.next(e);
|
handler.next(e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
|
|||||||
import 'package:fintracker_api/fintracker_api.dart';
|
import 'package:fintracker_api/fintracker_api.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../cache/response_cache_provider.dart';
|
||||||
import '../config.dart';
|
import '../config.dart';
|
||||||
import 'token_store.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.
|
/// A bare Dio for auth calls: no auth interceptor, so a refresh can never recurse.
|
||||||
final authDioProvider = Provider<Dio>(
|
final authDioProvider = Provider<Dio>(
|
||||||
(_) => Dio(BaseOptions(
|
(ref) => Dio(
|
||||||
baseUrl: apiBaseUrl(),
|
BaseOptions(
|
||||||
|
baseUrl: ref.watch(apiBaseUrlProvider),
|
||||||
connectTimeout: const Duration(seconds: 10),
|
connectTimeout: const Duration(seconds: 10),
|
||||||
receiveTimeout: const Duration(seconds: 20),
|
receiveTimeout: const Duration(seconds: 20),
|
||||||
)),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final authControllerProvider = NotifierProvider<AuthController, AuthState>(AuthController.new);
|
final authControllerProvider = NotifierProvider<AuthController, AuthState>(
|
||||||
|
AuthController.new,
|
||||||
|
);
|
||||||
|
|
||||||
class AuthController extends Notifier<AuthState> {
|
class AuthController extends Notifier<AuthState> {
|
||||||
@override
|
@override
|
||||||
@@ -51,7 +56,9 @@ class AuthController extends Notifier<AuthState> {
|
|||||||
|
|
||||||
Future<String?> login(String email, String password) async {
|
Future<String?> login(String email, String password) async {
|
||||||
try {
|
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!);
|
await _accept(r.data!);
|
||||||
return null;
|
return null;
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
@@ -64,32 +71,51 @@ class AuthController extends Notifier<AuthState> {
|
|||||||
final token = refresh ?? await _store.readRefresh();
|
final token = refresh ?? await _store.readRefresh();
|
||||||
if (token == null) return false;
|
if (token == null) return false;
|
||||||
try {
|
try {
|
||||||
final r = await _api.authRefresh(refreshRequest: RefreshRequest(refreshToken: token));
|
final r = await _api.authRefresh(
|
||||||
|
refreshRequest: RefreshRequest(refreshToken: token),
|
||||||
|
);
|
||||||
await _accept(r.data!);
|
await _accept(r.data!);
|
||||||
return true;
|
return true;
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
if (e.response?.statusCode == 401) await _store.clear();
|
if (e.response?.statusCode == 401) await _wipeLocal();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _accept(TokenPair pair) async {
|
Future<void> _accept(TokenPair pair) async {
|
||||||
await _store.writeRefresh(pair.refreshToken);
|
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 {
|
Future<void> logout() async {
|
||||||
final refresh = await _store.readRefresh();
|
final refresh = await _store.readRefresh();
|
||||||
if (refresh != null) {
|
if (refresh != null) {
|
||||||
try {
|
try {
|
||||||
await _api.authLogout(refreshRequest: RefreshRequest(refreshToken: refresh));
|
await _api.authLogout(
|
||||||
|
refreshRequest: RefreshRequest(refreshToken: refresh),
|
||||||
|
);
|
||||||
} on DioException {
|
} on DioException {
|
||||||
// the server may already consider it revoked; local state wins
|
// the server may already consider it revoked; local state wins
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await _store.clear();
|
await _wipeLocal();
|
||||||
state = const AuthState(AuthStatus.signedOut);
|
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.
|
/// 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) {
|
return switch (e.type) {
|
||||||
DioExceptionType.connectionError ||
|
DioExceptionType.connectionError ||
|
||||||
DioExceptionType.connectionTimeout ||
|
DioExceptionType.connectionTimeout ||
|
||||||
DioExceptionType.receiveTimeout =>
|
DioExceptionType.receiveTimeout => 'Сервер недоступен',
|
||||||
'Сервер недоступен',
|
|
||||||
_ => e.message ?? 'Ошибка запроса',
|
_ => 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);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ class TokenStore {
|
|||||||
static const _refreshKey = 'refresh_token';
|
static const _refreshKey = 'refresh_token';
|
||||||
|
|
||||||
Future<String?> readRefresh() => _storage.read(key: _refreshKey);
|
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);
|
Future<void> clear() => _storage.delete(key: _refreshKey);
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-4
@@ -28,7 +28,10 @@ class CacheInterceptor extends Interceptor {
|
|||||||
};
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onResponse(Response<dynamic> response, ResponseInterceptorHandler handler) {
|
void onResponse(
|
||||||
|
Response<dynamic> response,
|
||||||
|
ResponseInterceptorHandler handler,
|
||||||
|
) {
|
||||||
if (response.requestOptions.method == 'GET' && response.statusCode == 200) {
|
if (response.requestOptions.method == 'GET' && response.statusCode == 200) {
|
||||||
// Never block the UI on the write: the caller already has its data.
|
// Never block the UI on the write: the caller already has its data.
|
||||||
unawaited(_db.put(requestKey(response.requestOptions), response.data));
|
unawaited(_db.put(requestKey(response.requestOptions), response.data));
|
||||||
@@ -37,8 +40,12 @@ class CacheInterceptor extends Interceptor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
|
Future<void> onError(
|
||||||
if (err.requestOptions.method != 'GET' || !_offlineTypes.contains(err.type)) {
|
DioException err,
|
||||||
|
ErrorInterceptorHandler handler,
|
||||||
|
) async {
|
||||||
|
if (err.requestOptions.method != 'GET' ||
|
||||||
|
!_offlineTypes.contains(err.type)) {
|
||||||
return handler.next(err);
|
return handler.next(err);
|
||||||
}
|
}
|
||||||
final cached = await _db.get(requestKey(err.requestOptions));
|
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
|
/// `path?sorted=query` — deliberately excludes the host, so switching the API
|
||||||
/// origin (dev vs. prod) does not orphan a device's cache.
|
/// origin (dev vs. prod) does not orphan a device's cache.
|
||||||
static String requestKey(RequestOptions options) {
|
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('&');
|
final query = params.map((e) => '${e.key}=${e.value}').join('&');
|
||||||
return '${options.path}?$query';
|
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
|
/// Wraps this response's data with the staleness [CacheInterceptor] recorded
|
||||||
/// in `extra['fetchedAt']`. A provider that wants offline support returns
|
/// in `extra['fetchedAt']`. A provider that wants offline support returns
|
||||||
/// `r.cached` instead of `r.data!`.
|
/// `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.
|
/// The oldest of several fetch times, or null if none of them is stale.
|
||||||
|
|||||||
+5
-1
@@ -38,7 +38,8 @@ class ResponseCacheDatabase extends _$ResponseCacheDatabase {
|
|||||||
|
|
||||||
/// Replaces whatever was cached for [requestKey] — one row per endpoint+params,
|
/// Replaces whatever was cached for [requestKey] — one row per endpoint+params,
|
||||||
/// never a history.
|
/// 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(
|
CachedResponsesCompanion.insert(
|
||||||
requestKey: requestKey,
|
requestKey: requestKey,
|
||||||
body: jsonEncode(body),
|
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
|
/// The body last stored for [requestKey], or null if this endpoint+params was
|
||||||
/// never fetched successfully on this device.
|
/// never fetched successfully on this device.
|
||||||
Future<CacheEntry?> get(String requestKey) async {
|
Future<CacheEntry?> get(String requestKey) async {
|
||||||
|
|||||||
+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/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`.
|
/// `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).
|
/// 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');
|
const fromEnv = String.fromEnvironment('API_BASE_URL');
|
||||||
if (fromEnv.isNotEmpty) return fromEnv;
|
if (fromEnv.isNotEmpty) return fromEnv;
|
||||||
if (kIsWeb) return Uri.base.origin;
|
if (kIsWeb) return Uri.base.origin;
|
||||||
return 'http://127.0.0.1:8000';
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../../core/auth/auth_controller.dart';
|
import '../../core/auth/auth_controller.dart';
|
||||||
import '../../core/config.dart';
|
import '../../core/config.dart';
|
||||||
|
import '../settings/api_base_url_dialog.dart';
|
||||||
|
|
||||||
class LoginPage extends ConsumerStatefulWidget {
|
class LoginPage extends ConsumerStatefulWidget {
|
||||||
const LoginPage({super.key});
|
const LoginPage({super.key});
|
||||||
@@ -55,9 +56,28 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text('fin-tracker', style: Theme.of(context).textTheme.headlineMedium),
|
Text(
|
||||||
|
'fin-tracker',
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(apiBaseUrl(), style: Theme.of(context).textTheme.bodySmall),
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
ref.watch(apiBaseUrlProvider),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit_outlined, size: 16),
|
||||||
|
tooltip: 'Изменить адрес',
|
||||||
|
onPressed: () => showApiBaseUrlDialog(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _email,
|
controller: _email,
|
||||||
@@ -71,18 +91,29 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
autofillHints: const [AutofillHints.password],
|
autofillHints: const [AutofillHints.password],
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
onSubmitted: (_) => _busy ? null : _submit(),
|
onSubmitted: (_) => _busy ? null : _submit(),
|
||||||
decoration: const InputDecoration(labelText: 'Пароль'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Пароль',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (_error != null) ...[
|
if (_error != null) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
Text(
|
||||||
|
_error!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: _busy ? null : _submit,
|
onPressed: _busy ? null : _submit,
|
||||||
child: _busy
|
child: _busy
|
||||||
? const SizedBox.square(
|
? const SizedBox.square(
|
||||||
dimension: 18, child: CircularProgressIndicator(strokeWidth: 2))
|
dimension: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
: const Text('Войти'),
|
: const Text('Войти'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../core/auth/switch_server.dart';
|
||||||
|
import '../../core/config.dart';
|
||||||
|
|
||||||
|
/// Asks for a new backend address and applies it. Shared by Настройки and the login screen
|
||||||
|
/// (a wrong address there means you can never reach Настройки).
|
||||||
|
Future<void> showApiBaseUrlDialog(BuildContext context) async {
|
||||||
|
final container = ProviderScope.containerOf(context);
|
||||||
|
final result = await showDialog<_Choice>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) =>
|
||||||
|
_ApiBaseUrlDialog(current: container.read(apiBaseUrlProvider)),
|
||||||
|
);
|
||||||
|
if (result == null) return;
|
||||||
|
await switchApiBaseUrl(container, result.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Choice {
|
||||||
|
const _Choice(this.url);
|
||||||
|
|
||||||
|
/// null = back to the default address.
|
||||||
|
final String? url;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ApiBaseUrlDialog extends StatefulWidget {
|
||||||
|
const _ApiBaseUrlDialog({required this.current});
|
||||||
|
final String current;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ApiBaseUrlDialog> createState() => _ApiBaseUrlDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ApiBaseUrlDialogState extends State<_ApiBaseUrlDialog> {
|
||||||
|
late final _controller = TextEditingController(text: widget.current);
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _save() {
|
||||||
|
final url = ApiBaseUrlController.normalize(_controller.text);
|
||||||
|
if (url == null) {
|
||||||
|
setState(() => _error = 'Нужен адрес вида https://fin.example.com');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.pop(context, _Choice(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Адрес API'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _controller,
|
||||||
|
autofocus: true,
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
decoration: InputDecoration(errorText: _error),
|
||||||
|
onSubmitted: (_) => _save(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Смена адреса завершит сеанс и очистит локальный кэш.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Отмена'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, const _Choice(null)),
|
||||||
|
child: const Text('По умолчанию'),
|
||||||
|
),
|
||||||
|
FilledButton(onPressed: _save, child: const Text('Сохранить')),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,47 +7,335 @@ import '../../core/auth/auth_controller.dart';
|
|||||||
import '../../core/config.dart';
|
import '../../core/config.dart';
|
||||||
import '../../core/theme/theme_controller.dart';
|
import '../../core/theme/theme_controller.dart';
|
||||||
import '../../core/widgets/async_value_view.dart';
|
import '../../core/widgets/async_value_view.dart';
|
||||||
|
import 'api_base_url_dialog.dart';
|
||||||
|
|
||||||
/// Настройки: API endpoint, signed-in account, theme and logout.
|
/// One entry of the left menu and the panel it opens.
|
||||||
class SettingsPage extends ConsumerWidget {
|
class _Section {
|
||||||
|
const _Section(this.title, this.build);
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final Widget Function(BuildContext context, WidgetRef ref) build;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tab along the top: a group of sections.
|
||||||
|
class _Group {
|
||||||
|
const _Group(this.title, this.icon, this.sections);
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final IconData icon;
|
||||||
|
final List<_Section> sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
final _groups = [
|
||||||
|
_Group('Аккаунт', Icons.person_outline, [
|
||||||
|
_Section('Приватные данные', _privateData),
|
||||||
|
_Section('Безопасность', _security),
|
||||||
|
]),
|
||||||
|
_Group('Отображение', Icons.palette_outlined, [_Section('Тема', _theme)]),
|
||||||
|
_Group('Сервер', Icons.dns_outlined, [
|
||||||
|
_Section('Адрес API', _apiAddress),
|
||||||
|
_Section('О приложении', _about),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Настройки, laid out like Snowball's account page: tabs along the top and, inside the tab,
|
||||||
|
/// a menu of its sections on the left. Below 720 px the menu becomes a row of chips above the
|
||||||
|
/// panel — a 240 px menu beside a form would leave the form nothing.
|
||||||
|
class SettingsPage extends ConsumerStatefulWidget {
|
||||||
const SettingsPage({super.key});
|
const SettingsPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<SettingsPage> createState() => _SettingsPageState();
|
||||||
final me = ref.watch(meProvider);
|
}
|
||||||
final themeMode = ref.watch(themeModeProvider);
|
|
||||||
|
|
||||||
return Scaffold(
|
class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||||
appBar: AppBar(title: const Text('Настройки')),
|
int _group = 0;
|
||||||
body: ListView(
|
int _section = 0;
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final group = _groups[_group];
|
||||||
|
final section =
|
||||||
|
group.sections[_section.clamp(0, group.sections.length - 1)];
|
||||||
|
final wide = MediaQuery.sizeOf(context).width >= 720;
|
||||||
|
|
||||||
|
final menu = wide
|
||||||
|
? SizedBox(
|
||||||
|
width: 240,
|
||||||
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const ListTile(
|
for (var i = 0; i < group.sections.length; i++)
|
||||||
leading: Icon(Icons.dns_outlined),
|
_MenuItem(
|
||||||
title: Text('Адрес API'),
|
title: group.sections[i].title,
|
||||||
|
selected: group.sections[i] == section,
|
||||||
|
onTap: () => setState(() => _section = i),
|
||||||
),
|
),
|
||||||
ListTile(
|
],
|
||||||
contentPadding: const EdgeInsets.only(left: 56, right: 16),
|
|
||||||
title: SelectableText(apiBaseUrl()),
|
|
||||||
),
|
),
|
||||||
const Divider(),
|
)
|
||||||
ListTile(
|
: Wrap(
|
||||||
leading: const Icon(Icons.account_circle_outlined),
|
spacing: 8,
|
||||||
title: const Text('Аккаунт'),
|
children: [
|
||||||
subtitle: AsyncValueView(
|
for (var i = 0; i < group.sections.length; i++)
|
||||||
value: me,
|
ChoiceChip(
|
||||||
data: (u) => Text(u.email),
|
label: Text(group.sections[i].title),
|
||||||
onRetry: () => ref.invalidate(meProvider),
|
selected: group.sections[i] == section,
|
||||||
|
onSelected: (_) => setState(() => _section = i),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
Card(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(color: scheme.outlineVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < _groups.length; i++)
|
||||||
|
_TopTab(
|
||||||
|
icon: _groups[i].icon,
|
||||||
|
title: _groups[i].title,
|
||||||
|
selected: i == _group,
|
||||||
|
onTap: () => setState(() {
|
||||||
|
_group = i;
|
||||||
|
_section = 0;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(),
|
|
||||||
const ListTile(
|
|
||||||
leading: Icon(Icons.palette_outlined),
|
|
||||||
title: Text('Тема'),
|
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.all(24),
|
||||||
child: SegmentedButton<ThemeMode>(
|
child: wide
|
||||||
|
? Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
menu,
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
Expanded(child: section.build(context, ref)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
menu,
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
section.build(context, ref),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TopTab extends StatelessWidget {
|
||||||
|
const _TopTab({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.selected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final bool selected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: selected ? scheme.primary : Colors.transparent,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: 20,
|
||||||
|
color: selected ? scheme.primary : scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: selected ? scheme.onSurface : scheme.onSurfaceVariant,
|
||||||
|
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MenuItem extends StatelessWidget {
|
||||||
|
const _MenuItem({
|
||||||
|
required this.title,
|
||||||
|
required this.selected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final bool selected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
|
child: Material(
|
||||||
|
color: selected ? scheme.surfaceContainerHigh : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: selected ? scheme.onSurface : scheme.onSurfaceVariant,
|
||||||
|
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- panels ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The muted banner at the top of a panel, like Snowball's «Это ваша конфиденциальная …».
|
||||||
|
Widget _note(BuildContext context, IconData icon, String text) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: scheme.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: scheme.onSurfaceVariant),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: Text(text)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _field(
|
||||||
|
BuildContext context,
|
||||||
|
String label,
|
||||||
|
String value, {
|
||||||
|
Widget? trailing,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label, style: Theme.of(context).textTheme.bodyMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
key: ValueKey('$label:$value'),
|
||||||
|
initialValue: value,
|
||||||
|
readOnly: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (trailing != null) ...[const SizedBox(width: 12), trailing],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _privateData(BuildContext context, WidgetRef ref) {
|
||||||
|
final me = ref.watch(meProvider);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_note(
|
||||||
|
context,
|
||||||
|
Icons.lock_outline,
|
||||||
|
'Это ваша конфиденциальная информация, она недоступна другим',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
AsyncValueView(
|
||||||
|
value: me,
|
||||||
|
onRetry: () => ref.invalidate(meProvider),
|
||||||
|
data: (u) => _field(context, 'Email', u.email),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _security(BuildContext context, WidgetRef ref) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_note(
|
||||||
|
context,
|
||||||
|
Icons.shield_outlined,
|
||||||
|
'Выход завершает сессию на этом устройстве и удаляет с него сохранённый токен и кэш данных',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
FilledButton.icon(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: scheme.error,
|
||||||
|
foregroundColor: scheme.onError,
|
||||||
|
),
|
||||||
|
onPressed: () => ref.read(authControllerProvider.notifier).logout(),
|
||||||
|
icon: const Icon(Icons.logout),
|
||||||
|
label: const Text('Выйти'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _theme(BuildContext context, WidgetRef ref) {
|
||||||
|
final themeMode = ref.watch(themeModeProvider);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Тема оформления', style: Theme.of(context).textTheme.bodyMedium),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SegmentedButton<ThemeMode>(
|
||||||
segments: const [
|
segments: const [
|
||||||
ButtonSegment(
|
ButtonSegment(
|
||||||
value: ThemeMode.system,
|
value: ThemeMode.system,
|
||||||
@@ -69,21 +357,22 @@ class SettingsPage extends ConsumerWidget {
|
|||||||
onSelectionChanged: (selection) =>
|
onSelectionChanged: (selection) =>
|
||||||
ref.read(themeModeProvider.notifier).setMode(selection.first),
|
ref.read(themeModeProvider.notifier).setMode(selection.first),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
const ListTile(
|
|
||||||
leading: Icon(Icons.info_outline),
|
|
||||||
title: Text('Версия приложения'),
|
|
||||||
subtitle: Text(appVersion),
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
ListTile(
|
|
||||||
leading: Icon(Icons.logout, color: Theme.of(context).colorScheme.error),
|
|
||||||
title: Text('Выйти', style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
|
||||||
onTap: () => ref.read(authControllerProvider.notifier).logout(),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _apiAddress(BuildContext context, WidgetRef ref) {
|
||||||
|
return _field(
|
||||||
|
context,
|
||||||
|
'Адрес API',
|
||||||
|
ref.watch(apiBaseUrlProvider),
|
||||||
|
trailing: FilledButton.tonalIcon(
|
||||||
|
onPressed: () => showApiBaseUrlDialog(context),
|
||||||
|
icon: const Icon(Icons.edit_outlined),
|
||||||
|
label: const Text('Изменить'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
Widget _about(BuildContext context, WidgetRef ref) =>
|
||||||
|
_field(context, 'Версия приложения', appVersion);
|
||||||
|
|||||||
+11
-2
@@ -2,8 +2,17 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
|
import 'core/config.dart';
|
||||||
|
|
||||||
void main() {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
runApp(const ProviderScope(child: FinTrackerApp()));
|
final storedApiBaseUrl = await loadStoredApiBaseUrl();
|
||||||
|
runApp(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
initialApiBaseUrlProvider.overrideWithValue(storedApiBaseUrl),
|
||||||
|
],
|
||||||
|
child: const FinTrackerApp(),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,11 +23,16 @@ class _ErrorCapture extends ErrorInterceptorHandler {
|
|||||||
void next(DioException error) => forwarded = error;
|
void next(DioException error) => forwarded = error;
|
||||||
}
|
}
|
||||||
|
|
||||||
RequestOptions _request(String method, String path, [Map<String, dynamic> query = const {}]) =>
|
RequestOptions _request(
|
||||||
RequestOptions(path: path, method: method, queryParameters: query);
|
String method,
|
||||||
|
String path, [
|
||||||
|
Map<String, dynamic> query = const {},
|
||||||
|
]) => RequestOptions(path: path, method: method, queryParameters: query);
|
||||||
|
|
||||||
DioException _offline(RequestOptions options) =>
|
DioException _offline(RequestOptions options) => DioException(
|
||||||
DioException(requestOptions: options, type: DioExceptionType.connectionError);
|
requestOptions: options,
|
||||||
|
type: DioExceptionType.connectionError,
|
||||||
|
);
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late ResponseCacheDatabase db;
|
late ResponseCacheDatabase db;
|
||||||
@@ -44,7 +49,11 @@ void main() {
|
|||||||
final options = _request('GET', '/networth/breakdown');
|
final options = _request('GET', '/networth/breakdown');
|
||||||
|
|
||||||
interceptor.onResponse(
|
interceptor.onResponse(
|
||||||
Response<dynamic>(requestOptions: options, statusCode: 200, data: {'totalRub': '100'}),
|
Response<dynamic>(
|
||||||
|
requestOptions: options,
|
||||||
|
statusCode: 200,
|
||||||
|
data: {'totalRub': '100'},
|
||||||
|
),
|
||||||
_ResponseCapture(),
|
_ResponseCapture(),
|
||||||
);
|
);
|
||||||
await pumpEventQueue(); // the write is fire-and-forget
|
await pumpEventQueue(); // the write is fire-and-forget
|
||||||
@@ -53,7 +62,9 @@ void main() {
|
|||||||
expect(cached?.body, {'totalRub': '100'});
|
expect(cached?.body, {'totalRub': '100'});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a connection error is answered from the cache, with fetchedAt set', () async {
|
test(
|
||||||
|
'a connection error is answered from the cache, with fetchedAt set',
|
||||||
|
() async {
|
||||||
final options = _request('GET', '/networth/breakdown');
|
final options = _request('GET', '/networth/breakdown');
|
||||||
await db.put(CacheInterceptor.requestKey(options), {'totalRub': '100'});
|
await db.put(CacheInterceptor.requestKey(options), {'totalRub': '100'});
|
||||||
|
|
||||||
@@ -63,7 +74,8 @@ void main() {
|
|||||||
expect(handler.resolvedResponse?.data, {'totalRub': '100'});
|
expect(handler.resolvedResponse?.data, {'totalRub': '100'});
|
||||||
expect(handler.resolvedResponse?.extra['fetchedAt'], isA<DateTime>());
|
expect(handler.resolvedResponse?.extra['fetchedAt'], isA<DateTime>());
|
||||||
expect(handler.forwarded, isNull);
|
expect(handler.forwarded, isNull);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('a connection error with nothing cached still fails', () async {
|
test('a connection error with nothing cached still fails', () async {
|
||||||
final options = _request('GET', '/networth/breakdown', {'never': 'asked'});
|
final options = _request('GET', '/networth/breakdown', {'never': 'asked'});
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:fintracker_app/core/auth/auth_controller.dart';
|
||||||
|
import 'package:fintracker_app/core/auth/switch_server.dart';
|
||||||
|
import 'package:fintracker_app/core/auth/token_store.dart';
|
||||||
|
import 'package:fintracker_app/core/cache/response_cache.dart';
|
||||||
|
import 'package:fintracker_app/core/cache/response_cache_provider.dart';
|
||||||
|
import 'package:fintracker_app/core/config.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// No stored refresh token, so `logout()` never touches the network.
|
||||||
|
class _NoTokenStore extends TokenStore {
|
||||||
|
bool cleared = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> readRefresh() async => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clear() async => cleared = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late ResponseCacheDatabase db;
|
||||||
|
late _NoTokenStore tokens;
|
||||||
|
|
||||||
|
ProviderContainer container({String? storedUrl}) {
|
||||||
|
final c = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
responseCacheDbProvider.overrideWithValue(db),
|
||||||
|
tokenStoreProvider.overrideWithValue(tokens),
|
||||||
|
initialApiBaseUrlProvider.overrideWithValue(storedUrl),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(c.dispose);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
db = ResponseCacheDatabase.forTesting(NativeDatabase.memory());
|
||||||
|
tokens = _NoTokenStore();
|
||||||
|
await db.put('/networth/breakdown', {'totalRub': '100'});
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() => db.close());
|
||||||
|
|
||||||
|
test('logout drops the token and every cached response', () async {
|
||||||
|
final c = container();
|
||||||
|
|
||||||
|
await c.read(authControllerProvider.notifier).logout();
|
||||||
|
|
||||||
|
expect(tokens.cleared, isTrue);
|
||||||
|
expect(await db.get('/networth/breakdown'), isNull);
|
||||||
|
expect(c.read(authControllerProvider).status, AuthStatus.signedOut);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('API base URL', () {
|
||||||
|
test('a saved address wins over the default', () {
|
||||||
|
expect(
|
||||||
|
container(storedUrl: 'https://fin.example.com')
|
||||||
|
.read(apiBaseUrlProvider),
|
||||||
|
'https://fin.example.com',
|
||||||
|
);
|
||||||
|
expect(container().read(apiBaseUrlProvider), defaultApiBaseUrl());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalize accepts http(s) origins and trims the trailing slash', () {
|
||||||
|
expect(
|
||||||
|
ApiBaseUrlController.normalize(' https://fin.example.com/ '),
|
||||||
|
'https://fin.example.com',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
ApiBaseUrlController.normalize('http://10.0.0.5:8000'),
|
||||||
|
'http://10.0.0.5:8000',
|
||||||
|
);
|
||||||
|
expect(ApiBaseUrlController.normalize('fin.example.com'), isNull);
|
||||||
|
expect(ApiBaseUrlController.normalize('ftp://fin.example.com'), isNull);
|
||||||
|
expect(ApiBaseUrlController.normalize(''), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'switching servers signs out, clears the cache and persists the address',
|
||||||
|
() async {
|
||||||
|
final c = container();
|
||||||
|
|
||||||
|
await switchApiBaseUrl(c, 'https://other.example.com');
|
||||||
|
|
||||||
|
expect(c.read(apiBaseUrlProvider), 'https://other.example.com');
|
||||||
|
expect(await loadStoredApiBaseUrl(), 'https://other.example.com');
|
||||||
|
expect(tokens.cleared, isTrue);
|
||||||
|
expect(await db.get('/networth/breakdown'), isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'saving the address already in use keeps the session and the cache',
|
||||||
|
() async {
|
||||||
|
final c = container(storedUrl: 'https://fin.example.com');
|
||||||
|
|
||||||
|
await switchApiBaseUrl(c, 'https://fin.example.com');
|
||||||
|
|
||||||
|
expect(tokens.cleared, isFalse);
|
||||||
|
expect(await db.get('/networth/breakdown'), isNotNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'null goes back to the default and forgets the saved address',
|
||||||
|
() async {
|
||||||
|
final c = container(storedUrl: 'https://fin.example.com');
|
||||||
|
|
||||||
|
await switchApiBaseUrl(c, null);
|
||||||
|
|
||||||
|
expect(c.read(apiBaseUrlProvider), defaultApiBaseUrl());
|
||||||
|
expect(await loadStoredApiBaseUrl(), isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import 'package:fintracker_api/fintracker_api.dart';
|
||||||
|
import 'package:fintracker_app/core/api/common_providers.dart';
|
||||||
|
import 'package:fintracker_app/features/settings/settings_page.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
Future<void> _pump(WidgetTester tester, double width) async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
tester.view.physicalSize = Size(width, 900);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
meProvider.overrideWith(
|
||||||
|
(ref) async => UserOut(email: 'ada@example.com', id: 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(home: Scaffold(body: SettingsPage())),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('tabs along the top, the tab\'s sections in a menu on the left', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _pump(tester, 1200);
|
||||||
|
|
||||||
|
for (final tab in ['Аккаунт', 'Отображение', 'Сервер']) {
|
||||||
|
expect(find.text(tab), findsOneWidget, reason: tab);
|
||||||
|
}
|
||||||
|
// the first tab opens on its first section
|
||||||
|
expect(find.text('Приватные данные'), findsOneWidget);
|
||||||
|
expect(find.text('Безопасность'), findsOneWidget);
|
||||||
|
expect(find.text('ada@example.com'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Безопасность'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Выйти'), findsOneWidget);
|
||||||
|
expect(find.text('ada@example.com'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('switching the tab resets to its first section', (tester) async {
|
||||||
|
await _pump(tester, 1200);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Безопасность'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Сервер'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Адрес API'), findsWidgets);
|
||||||
|
expect(find.text('О приложении'), findsOneWidget);
|
||||||
|
expect(find.text('Изменить'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Отображение'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Тёмная'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a narrow surface turns the menu into chips above the panel', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _pump(tester, 500);
|
||||||
|
|
||||||
|
expect(find.byType(ChoiceChip), findsNWidgets(2));
|
||||||
|
expect(find.text('ada@example.com'), findsOneWidget);
|
||||||
|
await tester.tap(find.widgetWithText(ChoiceChip, 'Безопасность'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Выйти'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -70,6 +70,20 @@
|
|||||||
на паттерне `AsyncValueView`/`EmptyState` переводятся по этому же рецепту, независимо друг от
|
на паттерне `AsyncValueView`/`EmptyState` переводятся по этому же рецепту, независимо друг от
|
||||||
друга (можно параллельно, в отдельных worktree).
|
друга (можно параллельно, в отдельных worktree).
|
||||||
|
|
||||||
|
## Жизненный цикл
|
||||||
|
|
||||||
|
Кэш — это вся финансовая картина в открытом sqlite/IndexedDB, поэтому он живёт не дольше сеанса:
|
||||||
|
`AuthController` чистит его вместе с refresh-токеном при `logout()` и когда сервер ответил 401 на
|
||||||
|
refresh. Смена адреса API (`switchApiBaseUrl`) идёт через `logout()`, так что данные старого
|
||||||
|
сервера не показываются новому. Ошибка очистки не блокирует выход. TTL и лимита размера у кэша
|
||||||
|
по-прежнему нет.
|
||||||
|
|
||||||
|
## Адрес API
|
||||||
|
|
||||||
|
`apiBaseUrlProvider`: сохранённый в `shared_preferences` адрес (читается в `main()` до `runApp`),
|
||||||
|
иначе `--dart-define=API_BASE_URL`, иначе origin страницы (web) / `http://127.0.0.1:8000`.
|
||||||
|
Меняется в Настройках и на экране входа.
|
||||||
|
|
||||||
## Веб: sqlite3.wasm + drift_worker.js
|
## Веб: sqlite3.wasm + drift_worker.js
|
||||||
|
|
||||||
Собранные ассеты лежат в `app/web/sqlite3.wasm` и `app/web/drift_worker.js`, закоммичены (как
|
Собранные ассеты лежат в `app/web/sqlite3.wasm` и `app/web/drift_worker.js`, закоммичены (как
|
||||||
|
|||||||
Reference in New Issue
Block a user