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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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!;
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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 ?? 'Ошибка запроса',
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user