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

Адрес бэкенда хранится в shared_preferences и читается до runApp; switchApiBaseUrl сбрасывает токены и кэш старого сервера. Выход стирает refresh-токен и sqlite-кэш ответов. Экран настроек переделан под разделы.
This commit is contained in:
Dmitry
2026-09-19 22:14:14 +03:00
parent bfe74ca47c
commit a559d6de3e
17 changed files with 887 additions and 135 deletions
+15 -16
View File
@@ -4,27 +4,21 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_controller.dart'; import '../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(
connectTimeout: const Duration(seconds: 10), baseUrl: ref.watch(apiBaseUrlProvider),
receiveTimeout: const Duration(seconds: 30), connectTimeout: const Duration(seconds: 10),
)); 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);
} }
+39 -14
View File
@@ -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(
connectTimeout: const Duration(seconds: 10), baseUrl: ref.watch(apiBaseUrlProvider),
receiveTimeout: const Duration(seconds: 20), 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> { 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 ?? 'Ошибка запроса',
}; };
} }
+17
View File
@@ -0,0 +1,17 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../config.dart';
import 'auth_controller.dart';
/// Points the app at [url] (null = the default). A different server means a different
/// account, so the session ends first — while the clients still talk to the OLD server, so
/// the refresh token is revoked there and never sent to the new one — and only then is the
/// address changed. Takes a container rather than a `WidgetRef`: signing out replaces the
/// calling screen, and a disposed widget's `ref` throws.
Future<void> switchApiBaseUrl(ProviderContainer container, String? url) async {
final next = url ?? defaultApiBaseUrl();
if (next != container.read(apiBaseUrlProvider)) {
await container.read(authControllerProvider.notifier).logout();
}
await container.read(apiBaseUrlProvider.notifier).save(url);
}
+3 -2
View File
@@ -4,12 +4,13 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// (single-user, TLS-only deployment; see plan §7 open question 1). /// (single-user, TLS-only deployment; see plan §7 open question 1).
class TokenStore { class TokenStore {
TokenStore([FlutterSecureStorage? storage]) TokenStore([FlutterSecureStorage? storage])
: _storage = storage ?? const FlutterSecureStorage(); : _storage = storage ?? const FlutterSecureStorage();
final FlutterSecureStorage _storage; final FlutterSecureStorage _storage;
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
View File
@@ -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';
} }
+2 -1
View File
@@ -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.
+11 -7
View File
@@ -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 {
@@ -63,9 +67,9 @@ class ResponseCacheDatabase extends _$ResponseCacheDatabase {
/// the compiled assets it loads there (regenerate with `just app-web-assets` /// the compiled assets it loads there (regenerate with `just app-web-assets`
/// whenever the `drift`/`sqlite3` package versions change). /// whenever the `drift`/`sqlite3` package versions change).
QueryExecutor _openConnection() => driftDatabase( QueryExecutor _openConnection() => driftDatabase(
name: 'response_cache', name: 'response_cache',
web: DriftWebOptions( web: DriftWebOptions(
sqlite3Wasm: Uri.parse('sqlite3.wasm'), sqlite3Wasm: Uri.parse('sqlite3.wasm'),
driftWorker: Uri.parse('drift_worker.js'), driftWorker: Uri.parse('drift_worker.js'),
), ),
); );
+11
View File
@@ -0,0 +1,11 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'response_cache.dart';
/// One sqlite-backed cache of the last successful GET per endpoint+params,
/// shared by every screen. See `docs/ai/offline-cache.md`.
final responseCacheDbProvider = Provider<ResponseCacheDatabase>((ref) {
final db = ResponseCacheDatabase();
ref.onDispose(db.close);
return db;
});
+49 -2
View File
@@ -1,11 +1,58 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/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;
}
}
}
+36 -5
View File
@@ -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('Сохранить')),
],
);
}
}
+357 -68
View File
@@ -7,83 +7,372 @@ 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),
children: [ @override
const ListTile( Widget build(BuildContext context) {
leading: Icon(Icons.dns_outlined), final scheme = Theme.of(context).colorScheme;
title: Text('Адрес API'), final group = _groups[_group];
), final section =
ListTile( group.sections[_section.clamp(0, group.sections.length - 1)];
contentPadding: const EdgeInsets.only(left: 56, right: 16), final wide = MediaQuery.sizeOf(context).width >= 720;
title: SelectableText(apiBaseUrl()),
), final menu = wide
const Divider(), ? SizedBox(
ListTile( width: 240,
leading: const Icon(Icons.account_circle_outlined), child: Column(
title: const Text('Аккаунт'), children: [
subtitle: AsyncValueView( for (var i = 0; i < group.sections.length; i++)
value: me, _MenuItem(
data: (u) => Text(u.email), title: group.sections[i].title,
onRetry: () => ref.invalidate(meProvider), selected: group.sections[i] == section,
), onTap: () => setState(() => _section = i),
), ),
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),
),
], ],
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( child: Row(
leading: Icon(Icons.info_outline), children: [
title: Text('Версия приложения'), Icon(
subtitle: Text(appVersion), icon,
), size: 20,
const Divider(), color: selected ? scheme.primary : scheme.onSurfaceVariant,
ListTile( ),
leading: Icon(Icons.logout, color: Theme.of(context).colorScheme.error), const SizedBox(width: 8),
title: Text('Выйти', style: TextStyle(color: Theme.of(context).colorScheme.error)), Text(
onTap: () => ref.read(authControllerProvider.notifier).logout(), 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
View File
@@ -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(),
),
);
} }
+26 -14
View File
@@ -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,17 +62,20 @@ 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(
final options = _request('GET', '/networth/breakdown'); 'a connection error is answered from the cache, with fetchedAt set',
await db.put(CacheInterceptor.requestKey(options), {'totalRub': '100'}); () async {
final options = _request('GET', '/networth/breakdown');
await db.put(CacheInterceptor.requestKey(options), {'totalRub': '100'});
final handler = _ErrorCapture(); final handler = _ErrorCapture();
await interceptor.onError(_offline(options), handler); await interceptor.onError(_offline(options), handler);
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'});
+120
View File
@@ -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);
},
);
});
}
+75
View File
@@ -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);
});
}
+14
View File
@@ -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`, закоммичены (как