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 '../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);
}
+39 -14
View File
@@ -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 ?? 'Ошибка запроса',
};
}
+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).
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
View File
@@ -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';
}
+2 -1
View File
@@ -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
View File
@@ -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
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_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;
}
}
}
+36 -5
View File
@@ -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('Сохранить')),
],
);
}
}
+357 -68
View File
@@ -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
View File
@@ -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(),
),
);
}
+26 -14
View File
@@ -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'});
+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);
});
}