feat(app): Flutter-клиент — логин, дашборд, потоки, категории, транзакции, правила

Riverpod + go_router с auth-guard, NavigationRail на широком экране и bottom bar
на узком. Токены в flutter_secure_storage, на web access живёт в памяти.
Интерцептор подставляет токен и делает ровно один refresh на 401.

Деньги приходят строками и форматируются через Decimal: парсить их в double
значило бы терять копейки ровно там, где бэкенд их бережёт.
This commit is contained in:
Dmitry
2026-09-18 13:44:09 +03:00
parent b9c12fa1a1
commit ce0966fca2
99 changed files with 6284 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
import 'package:dio/dio.dart';
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_controller.dart';
import '../config.dart';
/// Authenticated client: bearer header on every call, one transparent refresh on 401.
final apiProvider = Provider<FintrackerApi>((ref) {
final dio = Dio(BaseOptions(
baseUrl: apiBaseUrl(),
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
));
dio.interceptors.add(DateQueryInterceptor());
dio.interceptors.add(_AuthInterceptor(ref));
return FintrackerApi(dio: dio, interceptors: const []);
});
/// Query dates as `YYYY-MM-DD`, which is what `format: date` in the spec means.
///
/// openapi-generator types a `format: date` query parameter as `DateTime` and hands it to
/// Dio unconverted, so the wire value becomes `DateTime.toString()` —
/// `2026-09-18 10:31:29.084`, which FastAPI rejects with 422. Every date-typed query
/// parameter in the spec is a `date` (none is a `date-time`), so truncating here is exactly
/// the intended encoding rather than a lossy guess. Lives in the app, not in the generated
/// package, because `just gen-client` overwrites the latter.
class DateQueryInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
options.queryParameters = {
for (final e in options.queryParameters.entries)
e.key: e.value is DateTime ? _isoDate(e.value as DateTime) : e.value,
};
handler.next(options);
}
static String _isoDate(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-'
'${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}';
}
class _AuthInterceptor extends QueuedInterceptor {
_AuthInterceptor(this._ref);
final Ref _ref;
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final token = _ref.read(authControllerProvider).accessToken;
if (token != null) options.headers['Authorization'] = 'Bearer $token';
handler.next(options);
}
@override
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);
final auth = _ref.read(authControllerProvider.notifier);
if (!await auth.refreshSession()) {
await auth.logout();
return handler.next(err);
}
final token = _ref.read(authControllerProvider).accessToken;
final opts = err.requestOptions
..headers['Authorization'] = 'Bearer $token'
..extra['retried'] = true;
try {
handler.resolve(await Dio(BaseOptions(baseUrl: opts.baseUrl)).fetch(opts));
} on DioException catch (e) {
handler.next(e);
}
}
}
+16
View File
@@ -0,0 +1,16 @@
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'api_client.dart';
/// The signed-in user, used by Обзор and Настройки.
final meProvider = FutureProvider<UserOut>((ref) async {
final r = await ref.watch(apiProvider).getAuthApi().authMe();
return r.data!;
});
/// Backend health, used by Обзор.
final healthProvider = FutureProvider<Health>((ref) async {
final r = await ref.watch(apiProvider).getHealthApi().healthCheck();
return r.data!;
});
+4
View File
@@ -0,0 +1,4 @@
/// Mirrors the `version:` line in `pubspec.yaml`. Flutter has no runtime
/// pubspec reader without an extra dependency (`package_info_plus`), so this
/// is a hand-maintained literal for now — bump it alongside pubspec.yaml.
const appVersion = '0.1.0+1';
+109
View File
@@ -0,0 +1,109 @@
import 'package:dio/dio.dart';
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../config.dart';
import 'token_store.dart';
enum AuthStatus { unknown, signedOut, signedIn }
class AuthState {
const AuthState(this.status, {this.accessToken, this.email});
final AuthStatus status;
final String? accessToken;
final String? email;
bool get signedIn => status == AuthStatus.signedIn;
}
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),
)),
);
final authControllerProvider = NotifierProvider<AuthController, AuthState>(AuthController.new);
class AuthController extends Notifier<AuthState> {
@override
AuthState build() {
Future.microtask(_restore);
return const AuthState(AuthStatus.unknown);
}
AuthApi get _api => AuthApi(ref.read(authDioProvider));
TokenStore get _store => ref.read(tokenStoreProvider);
Future<void> _restore() async {
final refresh = await _store.readRefresh();
if (refresh == null) {
state = const AuthState(AuthStatus.signedOut);
return;
}
final ok = await refreshSession(refresh);
if (!ok) state = const AuthState(AuthStatus.signedOut);
}
Future<String?> login(String email, String password) async {
try {
final r = await _api.authLogin(loginRequest: LoginRequest(email: email, password: password));
await _accept(r.data!);
return null;
} on DioException catch (e) {
return problemMessage(e);
}
}
/// Exchange a refresh token for a new pair. Returns false when it is no longer valid.
Future<bool> refreshSession([String? refresh]) async {
final token = refresh ?? await _store.readRefresh();
if (token == null) return false;
try {
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();
return false;
}
}
Future<void> _accept(TokenPair pair) async {
await _store.writeRefresh(pair.refreshToken);
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));
} on DioException {
// the server may already consider it revoked; local state wins
}
}
await _store.clear();
state = const AuthState(AuthStatus.signedOut);
}
}
/// Human-readable text from an RFC 7807 body, falling back to the transport error.
String problemMessage(DioException e) {
final data = e.response?.data;
if (data is Map) {
final detail = data['detail'] ?? data['title'];
if (detail is String && detail.isNotEmpty) return detail;
}
return switch (e.type) {
DioExceptionType.connectionError ||
DioExceptionType.connectionTimeout ||
DioExceptionType.receiveTimeout =>
'Сервер недоступен',
_ => e.message ?? 'Ошибка запроса',
};
}
+15
View File
@@ -0,0 +1,15 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Refresh token at rest: Keystore / libsecret / DPAPI; on web — localStorage
/// (single-user, TLS-only deployment; see plan §7 open question 1).
class TokenStore {
TokenStore([FlutterSecureStorage? storage])
: _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> clear() => _storage.delete(key: _refreshKey);
}
+11
View File
@@ -0,0 +1,11 @@
import 'package:flutter/foundation.dart';
/// Backend origin. 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() {
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';
}
+21
View File
@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
/// Fixed categorical order from the design system's palette (dataviz skill):
/// slot 1 blue, slot 2 orange, slot 3 aqua, slot 4 yellow, slot 5 magenta.
/// Assigned to entities in this fixed order — never cycled, never by rank.
class ChartColors {
const ChartColors._();
static const slot1Blue = Color(0xFF2A78D6);
static const slot2Orange = Color(0xFFEB6834);
static const slot3Aqua = Color(0xFF1BAF7A);
static const slot4Yellow = Color(0xFFEDA100);
static const slot5Magenta = Color(0xFFE87BA4);
/// This app's fixed assignment for cashflow charts.
static const income = slot1Blue;
static const expense = slot2Orange;
static const baseline = slot3Aqua;
static const oneOff = slot4Yellow;
static const savingsTransfer = slot5Magenta;
}
+28
View File
@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _prefsKey = 'theme_mode';
final themeModeProvider = NotifierProvider<ThemeModeController, ThemeMode>(ThemeModeController.new);
/// Persists the chosen [ThemeMode] to this device via `shared_preferences`.
class ThemeModeController extends Notifier<ThemeMode> {
@override
ThemeMode build() {
Future.microtask(_restore);
return ThemeMode.system;
}
Future<void> _restore() async {
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString(_prefsKey);
state = ThemeMode.values.asNameMap()[saved] ?? ThemeMode.system;
}
Future<void> setMode(ThemeMode mode) async {
state = mode;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsKey, mode.name);
}
}
+56
View File
@@ -0,0 +1,56 @@
/// Russian month/date helpers that avoid needing `initializeDateFormatting`
/// (which `intl`'s locale-aware `DateFormat` symbols require) for the small
/// set of formats this app needs.
library;
const _monthsNominative = [
'январь',
'февраль',
'март',
'апрель',
'май',
'июнь',
'июль',
'август',
'сентябрь',
'октябрь',
'ноябрь',
'декабрь',
];
const _monthsShort = [
'янв',
'фев',
'мар',
'апр',
'май',
'июн',
'июл',
'авг',
'сен',
'окт',
'ноя',
'дек',
];
/// `'сентябрь 2026'`.
String ruMonthYear(DateTime d) {
final name = _monthsNominative[d.month - 1];
return '${name[0].toUpperCase()}${name.substring(1)} ${d.year}';
}
/// `'сен 2026'`, for compact chart/table labels.
String ruMonthYearShort(DateTime d) => '${_monthsShort[d.month - 1]} ${d.year}';
/// `'2026-09'`, the `month` query parameter format the API expects.
String monthKey(DateTime d) => '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}';
/// Parses a `'YYYY-MM'` key back into a [DateTime] (first of month, UTC).
DateTime parseMonthKey(String key) {
final parts = key.split('-');
return DateTime.utc(int.parse(parts[0]), int.parse(parts[1]));
}
/// `'12.09.2026'`.
String ruDate(DateTime d) =>
'${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
@@ -0,0 +1,49 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_controller.dart';
/// Renders an [AsyncValue]: the data on success, a spinner while loading, and
/// a retry-capable error view otherwise. Keeps the loading/error boilerplate
/// out of every screen that watches a provider.
class AsyncValueView<T> extends StatelessWidget {
const AsyncValueView({required this.value, required this.data, this.onRetry, super.key});
final AsyncValue<T> value;
final Widget Function(T data) data;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
return value.when(
data: data,
loading: () => const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: CircularProgressIndicator(),
),
),
error: (error, _) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error, size: 32),
const SizedBox(height: 8),
Text(_message(error), textAlign: TextAlign.center),
if (onRetry != null) ...[
const SizedBox(height: 12),
FilledButton.tonal(onPressed: onRetry, child: const Text('Повторить')),
],
],
),
),
),
);
}
static String _message(Object error) =>
error is DioException ? problemMessage(error) : error.toString();
}
+30
View File
@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
/// A centered placeholder for a screen or list section with nothing to show yet.
class EmptyState extends StatelessWidget {
const EmptyState({required this.message, this.icon = Icons.inbox_outlined, super.key});
final String message;
final IconData icon;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 48, color: Theme.of(context).colorScheme.outline),
const SizedBox(height: 12),
Text(
message,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
);
}
}
+34
View File
@@ -0,0 +1,34 @@
import 'package:decimal/decimal.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
/// Currency symbols for the instruments we expect to see. Falls back to the
/// ISO code itself (e.g. 'USDT') when we don't have a nicer glyph.
const _currencySymbols = {
'RUB': '',
'USD': '\$',
'EUR': '',
};
/// Formats a decimal-string amount (as the API sends it, to avoid double
/// rounding errors) with `intl`'s ru_RU rules and a currency symbol.
class MoneyText extends StatelessWidget {
const MoneyText(this.amount, {required this.currency, super.key, this.style});
/// The amount as a string, e.g. `'1234.5'` — never a [double].
final String amount;
final String currency;
final TextStyle? style;
static String format(String amount, String currency) {
final value = Decimal.parse(amount).toDouble();
final symbol = _currencySymbols[currency] ?? currency;
final formatter = NumberFormat.currency(locale: 'ru_RU', symbol: symbol, decimalDigits: 2);
return formatter.format(value);
}
@override
Widget build(BuildContext context) {
return Text(format(amount, currency), style: style);
}
}