From a559d6de3ec690cb76d9909a405cfddb6185426e Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sat, 19 Sep 2026 22:14:14 +0300 Subject: [PATCH] =?UTF-8?q?feat(app):=20=D1=81=D0=BC=D0=B5=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0=20API=20=D0=B8=20?= =?UTF-8?q?=D0=BE=D1=87=D0=B8=D1=81=D1=82=D0=BA=D0=B0=20=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D1=83=D1=81=D1=82=D1=80=D0=BE=D0=B9=D1=81?= =?UTF-8?q?=D1=82=D0=B2=D0=B0=20=D0=BF=D1=80=D0=B8=20=D0=B2=D1=8B=D1=85?= =?UTF-8?q?=D0=BE=D0=B4=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Адрес бэкенда хранится в shared_preferences и читается до runApp; switchApiBaseUrl сбрасывает токены и кэш старого сервера. Выход стирает refresh-токен и sqlite-кэш ответов. Экран настроек переделан под разделы. --- app/lib/core/api/api_client.dart | 31 +- app/lib/core/auth/auth_controller.dart | 53 ++- app/lib/core/auth/switch_server.dart | 17 + app/lib/core/auth/token_store.dart | 5 +- app/lib/core/cache/cache_interceptor.dart | 16 +- app/lib/core/cache/cached.dart | 3 +- app/lib/core/cache/response_cache.dart | 18 +- .../core/cache/response_cache_provider.dart | 11 + app/lib/core/config.dart | 51 ++- app/lib/features/login/login_page.dart | 41 +- .../settings/api_base_url_dialog.dart | 89 ++++ app/lib/features/settings/settings_page.dart | 425 +++++++++++++++--- app/lib/main.dart | 13 +- app/test/cache_interceptor_test.dart | 40 +- app/test/session_wipe_test.dart | 120 +++++ app/test/settings_page_test.dart | 75 ++++ docs/ai/offline-cache.md | 14 + 17 files changed, 887 insertions(+), 135 deletions(-) create mode 100644 app/lib/core/auth/switch_server.dart create mode 100644 app/lib/core/cache/response_cache_provider.dart create mode 100644 app/lib/features/settings/api_base_url_dialog.dart create mode 100644 app/test/session_wipe_test.dart create mode 100644 app/test/settings_page_test.dart diff --git a/app/lib/core/api/api_client.dart b/app/lib/core/api/api_client.dart index a0d0e00..1da817e 100644 --- a/app/lib/core/api/api_client.dart +++ b/app/lib/core/api/api_client.dart @@ -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((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((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 onError(DioException err, ErrorInterceptorHandler handler) async { + Future 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); } diff --git a/app/lib/core/auth/auth_controller.dart b/app/lib/core/auth/auth_controller.dart index 72fa907..4d71f79 100644 --- a/app/lib/core/auth/auth_controller.dart +++ b/app/lib/core/auth/auth_controller.dart @@ -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()); /// A bare Dio for auth calls: no auth interceptor, so a refresh can never recurse. final authDioProvider = Provider( - (_) => 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.new); +final authControllerProvider = NotifierProvider( + AuthController.new, +); class AuthController extends Notifier { @override @@ -51,7 +56,9 @@ class AuthController extends Notifier { Future 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 { 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 _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 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 _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 ?? 'Ошибка запроса', }; } diff --git a/app/lib/core/auth/switch_server.dart b/app/lib/core/auth/switch_server.dart new file mode 100644 index 0000000..1608f36 --- /dev/null +++ b/app/lib/core/auth/switch_server.dart @@ -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 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); +} diff --git a/app/lib/core/auth/token_store.dart b/app/lib/core/auth/token_store.dart index 1d86ee0..31b747d 100644 --- a/app/lib/core/auth/token_store.dart +++ b/app/lib/core/auth/token_store.dart @@ -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 readRefresh() => _storage.read(key: _refreshKey); - Future writeRefresh(String token) => _storage.write(key: _refreshKey, value: token); + Future writeRefresh(String token) => + _storage.write(key: _refreshKey, value: token); Future clear() => _storage.delete(key: _refreshKey); } diff --git a/app/lib/core/cache/cache_interceptor.dart b/app/lib/core/cache/cache_interceptor.dart index 81c93d2..62e92fc 100644 --- a/app/lib/core/cache/cache_interceptor.dart +++ b/app/lib/core/cache/cache_interceptor.dart @@ -28,7 +28,10 @@ class CacheInterceptor extends Interceptor { }; @override - void onResponse(Response response, ResponseInterceptorHandler handler) { + void onResponse( + Response 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 onError(DioException err, ErrorInterceptorHandler handler) async { - if (err.requestOptions.method != 'GET' || !_offlineTypes.contains(err.type)) { + Future 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'; } diff --git a/app/lib/core/cache/cached.dart b/app/lib/core/cache/cached.dart index 4622a79..5a4b2fa 100644 --- a/app/lib/core/cache/cached.dart +++ b/app/lib/core/cache/cached.dart @@ -15,7 +15,8 @@ extension CachedResponseX on Response { /// 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 get cached => Cached(data as T, fetchedAt: extra['fetchedAt'] as DateTime?); + Cached 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. diff --git a/app/lib/core/cache/response_cache.dart b/app/lib/core/cache/response_cache.dart index 95e04d3..65ac6e3 100644 --- a/app/lib/core/cache/response_cache.dart +++ b/app/lib/core/cache/response_cache.dart @@ -38,7 +38,8 @@ class ResponseCacheDatabase extends _$ResponseCacheDatabase { /// Replaces whatever was cached for [requestKey] — one row per endpoint+params, /// never a history. - Future put(String requestKey, Object? body) => into(cachedResponses).insertOnConflictUpdate( + Future 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 clear() => delete(cachedResponses).go(); + /// The body last stored for [requestKey], or null if this endpoint+params was /// never fetched successfully on this device. Future 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'), + ), +); diff --git a/app/lib/core/cache/response_cache_provider.dart b/app/lib/core/cache/response_cache_provider.dart new file mode 100644 index 0000000..0be7d8b --- /dev/null +++ b/app/lib/core/cache/response_cache_provider.dart @@ -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((ref) { + final db = ResponseCacheDatabase(); + ref.onDispose(db.close); + return db; +}); diff --git a/app/lib/core/config.dart b/app/lib/core/config.dart index 73f03c1..3d898a5 100644 --- a/app/lib/core/config.dart +++ b/app/lib/core/config.dart @@ -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 loadStoredApiBaseUrl() async => + (await SharedPreferences.getInstance()).getString(_prefsKey); + +/// Overridden in `main()` with [loadStoredApiBaseUrl]; null (tests, nothing saved) means default. +final initialApiBaseUrlProvider = Provider((_) => 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.new, +); + +class ApiBaseUrlController extends Notifier { + @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 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; + } + } +} diff --git a/app/lib/features/login/login_page.dart b/app/lib/features/login/login_page.dart index 7496d80..eb95f12 100644 --- a/app/lib/features/login/login_page.dart +++ b/app/lib/features/login/login_page.dart @@ -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 { 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 { 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('Войти'), ), ], diff --git a/app/lib/features/settings/api_base_url_dialog.dart b/app/lib/features/settings/api_base_url_dialog.dart new file mode 100644 index 0000000..7586cbd --- /dev/null +++ b/app/lib/features/settings/api_base_url_dialog.dart @@ -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 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('Сохранить')), + ], + ); + } +} diff --git a/app/lib/features/settings/settings_page.dart b/app/lib/features/settings/settings_page.dart index 4fbb35e..9a663d8 100644 --- a/app/lib/features/settings/settings_page.dart +++ b/app/lib/features/settings/settings_page.dart @@ -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 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( - 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 { + 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( + 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); diff --git a/app/lib/main.dart b/app/lib/main.dart index 51e6021..401e24c 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -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 main() async { WidgetsFlutterBinding.ensureInitialized(); - runApp(const ProviderScope(child: FinTrackerApp())); + final storedApiBaseUrl = await loadStoredApiBaseUrl(); + runApp( + ProviderScope( + overrides: [ + initialApiBaseUrlProvider.overrideWithValue(storedApiBaseUrl), + ], + child: const FinTrackerApp(), + ), + ); } diff --git a/app/test/cache_interceptor_test.dart b/app/test/cache_interceptor_test.dart index f350855..1ef125b 100644 --- a/app/test/cache_interceptor_test.dart +++ b/app/test/cache_interceptor_test.dart @@ -23,11 +23,16 @@ class _ErrorCapture extends ErrorInterceptorHandler { void next(DioException error) => forwarded = error; } -RequestOptions _request(String method, String path, [Map query = const {}]) => - RequestOptions(path: path, method: method, queryParameters: query); +RequestOptions _request( + String method, + String path, [ + Map 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(requestOptions: options, statusCode: 200, data: {'totalRub': '100'}), + Response( + 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()); - expect(handler.forwarded, isNull); - }); + expect(handler.resolvedResponse?.data, {'totalRub': '100'}); + expect(handler.resolvedResponse?.extra['fetchedAt'], isA()); + expect(handler.forwarded, isNull); + }, + ); test('a connection error with nothing cached still fails', () async { final options = _request('GET', '/networth/breakdown', {'never': 'asked'}); diff --git a/app/test/session_wipe_test.dart b/app/test/session_wipe_test.dart new file mode 100644 index 0000000..87cb543 --- /dev/null +++ b/app/test/session_wipe_test.dart @@ -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 readRefresh() async => null; + + @override + Future 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); + }, + ); + }); +} diff --git a/app/test/settings_page_test.dart b/app/test/settings_page_test.dart new file mode 100644 index 0000000..2c78093 --- /dev/null +++ b/app/test/settings_page_test.dart @@ -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 _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); + }); +} diff --git a/docs/ai/offline-cache.md b/docs/ai/offline-cache.md index 6f67c15..32f00ca 100644 --- a/docs/ai/offline-cache.md +++ b/docs/ai/offline-cache.md @@ -70,6 +70,20 @@ на паттерне `AsyncValueView`/`EmptyState` переводятся по этому же рецепту, независимо друг от друга (можно параллельно, в отдельных 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 Собранные ассеты лежат в `app/web/sqlite3.wasm` и `app/web/drift_worker.js`, закоммичены (как