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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/auth/auth_controller.dart';
|
||||
import '../../core/config.dart';
|
||||
import '../settings/api_base_url_dialog.dart';
|
||||
|
||||
class LoginPage extends ConsumerStatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
@@ -55,9 +56,28 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('fin-tracker', style: Theme.of(context).textTheme.headlineMedium),
|
||||
Text(
|
||||
'fin-tracker',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
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),
|
||||
TextField(
|
||||
controller: _email,
|
||||
@@ -71,18 +91,29 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
autofillHints: const [AutofillHints.password],
|
||||
obscureText: true,
|
||||
onSubmitted: (_) => _busy ? null : _submit(),
|
||||
decoration: const InputDecoration(labelText: 'Пароль'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Пароль',
|
||||
),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
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),
|
||||
FilledButton(
|
||||
onPressed: _busy ? null : _submit,
|
||||
child: _busy
|
||||
? const SizedBox.square(
|
||||
dimension: 18, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
dimension: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: 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,83 +7,372 @@ import '../../core/auth/auth_controller.dart';
|
||||
import '../../core/config.dart';
|
||||
import '../../core/theme/theme_controller.dart';
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
import 'api_base_url_dialog.dart';
|
||||
|
||||
/// Настройки: API endpoint, signed-in account, theme and logout.
|
||||
class SettingsPage extends ConsumerWidget {
|
||||
/// One entry of the left menu and the panel it opens.
|
||||
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});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final me = ref.watch(meProvider);
|
||||
final themeMode = ref.watch(themeModeProvider);
|
||||
ConsumerState<SettingsPage> createState() => _SettingsPageState();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Настройки')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
children: [
|
||||
const ListTile(
|
||||
leading: Icon(Icons.dns_outlined),
|
||||
title: Text('Адрес API'),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.only(left: 56, right: 16),
|
||||
title: SelectableText(apiBaseUrl()),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.account_circle_outlined),
|
||||
title: const Text('Аккаунт'),
|
||||
subtitle: AsyncValueView(
|
||||
value: me,
|
||||
data: (u) => Text(u.email),
|
||||
onRetry: () => ref.invalidate(meProvider),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
const ListTile(
|
||||
leading: Icon(Icons.palette_outlined),
|
||||
title: Text('Тема'),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SegmentedButton<ThemeMode>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: ThemeMode.system,
|
||||
label: Text('Системная'),
|
||||
icon: Icon(Icons.brightness_auto_outlined),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ThemeMode.light,
|
||||
label: Text('Светлая'),
|
||||
icon: Icon(Icons.light_mode_outlined),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ThemeMode.dark,
|
||||
label: Text('Тёмная'),
|
||||
icon: Icon(Icons.dark_mode_outlined),
|
||||
),
|
||||
class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
int _group = 0;
|
||||
int _section = 0;
|
||||
|
||||
@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: [
|
||||
for (var i = 0; i < group.sections.length; i++)
|
||||
_MenuItem(
|
||||
title: group.sections[i].title,
|
||||
selected: group.sections[i] == section,
|
||||
onTap: () => setState(() => _section = i),
|
||||
),
|
||||
],
|
||||
selected: {themeMode},
|
||||
onSelectionChanged: (selection) =>
|
||||
ref.read(themeModeProvider.notifier).setMode(selection.first),
|
||||
),
|
||||
)
|
||||
: Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (var i = 0; i < group.sections.length; i++)
|
||||
ChoiceChip(
|
||||
label: Text(group.sections[i].title),
|
||||
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;
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
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,
|
||||
),
|
||||
),
|
||||
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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
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 [
|
||||
ButtonSegment(
|
||||
value: ThemeMode.system,
|
||||
label: Text('Системная'),
|
||||
icon: Icon(Icons.brightness_auto_outlined),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ThemeMode.light,
|
||||
label: Text('Светлая'),
|
||||
icon: Icon(Icons.light_mode_outlined),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ThemeMode.dark,
|
||||
label: Text('Тёмная'),
|
||||
icon: Icon(Icons.dark_mode_outlined),
|
||||
),
|
||||
],
|
||||
selected: {themeMode},
|
||||
onSelectionChanged: (selection) =>
|
||||
ref.read(themeModeProvider.notifier).setMode(selection.first),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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 'app.dart';
|
||||
import 'core/config.dart';
|
||||
|
||||
void main() {
|
||||
Future<void> main() async {
|
||||
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;
|
||||
}
|
||||
|
||||
RequestOptions _request(String method, String path, [Map<String, dynamic> query = const {}]) =>
|
||||
RequestOptions(path: path, method: method, queryParameters: query);
|
||||
RequestOptions _request(
|
||||
String method,
|
||||
String path, [
|
||||
Map<String, dynamic> query = const {},
|
||||
]) => RequestOptions(path: path, method: method, queryParameters: query);
|
||||
|
||||
DioException _offline(RequestOptions options) =>
|
||||
DioException(requestOptions: options, type: DioExceptionType.connectionError);
|
||||
DioException _offline(RequestOptions options) => DioException(
|
||||
requestOptions: options,
|
||||
type: DioExceptionType.connectionError,
|
||||
);
|
||||
|
||||
void main() {
|
||||
late ResponseCacheDatabase db;
|
||||
@@ -44,7 +49,11 @@ void main() {
|
||||
final options = _request('GET', '/networth/breakdown');
|
||||
|
||||
interceptor.onResponse(
|
||||
Response<dynamic>(requestOptions: options, statusCode: 200, data: {'totalRub': '100'}),
|
||||
Response<dynamic>(
|
||||
requestOptions: options,
|
||||
statusCode: 200,
|
||||
data: {'totalRub': '100'},
|
||||
),
|
||||
_ResponseCapture(),
|
||||
);
|
||||
await pumpEventQueue(); // the write is fire-and-forget
|
||||
@@ -53,17 +62,20 @@ void main() {
|
||||
expect(cached?.body, {'totalRub': '100'});
|
||||
});
|
||||
|
||||
test('a connection error is answered from the cache, with fetchedAt set', () async {
|
||||
final options = _request('GET', '/networth/breakdown');
|
||||
await db.put(CacheInterceptor.requestKey(options), {'totalRub': '100'});
|
||||
test(
|
||||
'a connection error is answered from the cache, with fetchedAt set',
|
||||
() async {
|
||||
final options = _request('GET', '/networth/breakdown');
|
||||
await db.put(CacheInterceptor.requestKey(options), {'totalRub': '100'});
|
||||
|
||||
final handler = _ErrorCapture();
|
||||
await interceptor.onError(_offline(options), handler);
|
||||
final handler = _ErrorCapture();
|
||||
await interceptor.onError(_offline(options), handler);
|
||||
|
||||
expect(handler.resolvedResponse?.data, {'totalRub': '100'});
|
||||
expect(handler.resolvedResponse?.extra['fetchedAt'], isA<DateTime>());
|
||||
expect(handler.forwarded, isNull);
|
||||
});
|
||||
expect(handler.resolvedResponse?.data, {'totalRub': '100'});
|
||||
expect(handler.resolvedResponse?.extra['fetchedAt'], isA<DateTime>());
|
||||
expect(handler.forwarded, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('a connection error with nothing cached still fails', () async {
|
||||
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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user