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
+30
View File
@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/theme/theme_controller.dart';
import 'router.dart';
class FinTrackerApp extends ConsumerWidget {
const FinTrackerApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(routerProvider);
final themeMode = ref.watch(themeModeProvider);
return MaterialApp.router(
title: 'fin-tracker',
routerConfig: router,
themeMode: themeMode,
theme: ThemeData(colorSchemeSeed: const Color(0xFF2E6F5E), useMaterial3: true),
darkTheme: ThemeData(
colorSchemeSeed: const Color(0xFF2E6F5E),
brightness: Brightness.dark,
useMaterial3: true,
),
locale: const Locale('ru'),
supportedLocales: const [Locale('ru'), Locale('en')],
localizationsDelegates: GlobalMaterialLocalizations.delegates,
);
}
}
+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);
}
}
@@ -0,0 +1,208 @@
import 'package:dio/dio.dart';
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_client.dart';
import '../../core/auth/auth_controller.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/money_text.dart';
import 'providers.dart';
const _roleOrder = [AccountRole.liquid, AccountRole.savings, AccountRole.investment, AccountRole.debt];
String _roleLabel(AccountRole role) => switch (role) {
AccountRole.liquid => 'Ликвидные',
AccountRole.savings => 'Сбережения',
AccountRole.investment => 'Инвестиции',
AccountRole.debt => 'Долги',
AccountRole.unknownDefaultOpenApi => 'Неизвестно',
};
String _kindLabel(AccountKind kind) => switch (kind) {
AccountKind.zmCash => 'Наличные',
AccountKind.zmCard => 'Карта',
AccountKind.zmChecking => 'Расчётный счёт',
AccountKind.zmDeposit => 'Вклад',
AccountKind.zmLoan => 'Кредит',
AccountKind.zmEmoney => 'Электронные деньги',
AccountKind.zmDebt => 'Долг',
AccountKind.broker => 'Брокерский счёт',
AccountKind.manualAsset => 'Актив вручную',
AccountKind.unknownDefaultOpenApi => 'Неизвестно',
};
/// Счета: every account grouped by role, with in-place role and
/// include-in-net-worth edits (`PATCH /accounts/{id}`). Archived accounts
/// collapse into their own section at the bottom regardless of role.
class AccountsPage extends ConsumerWidget {
const AccountsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final accounts = ref.watch(accountsProvider);
return Scaffold(
appBar: AppBar(title: const Text('Счета')),
body: RefreshIndicator(
onRefresh: () async => ref.invalidate(accountsProvider),
child: AsyncValueView(
value: accounts,
onRetry: () => ref.invalidate(accountsProvider),
data: (rows) {
if (rows.isEmpty) {
return ListView(
children: const [
EmptyState(
icon: Icons.account_balance_outlined,
message: 'Счетов ещё нет — нужна синхронизация.',
),
],
);
}
final active = rows.where((a) => !a.archived).toList();
final archived = rows.where((a) => a.archived).toList();
return ListView(
padding: const EdgeInsets.all(16),
children: [
for (final role in _roleOrder)
if (active.any((a) => a.role == role))
_RoleSection(
title: _roleLabel(role),
accounts: active.where((a) => a.role == role).toList(),
),
if (archived.isNotEmpty)
ExpansionTile(
title: Text('Архивные (${archived.length})'),
initiallyExpanded: false,
children: [for (final a in archived) _AccountTile(account: a)],
),
],
);
},
),
),
);
}
}
class _RoleSection extends StatelessWidget {
const _RoleSection({required this.title, required this.accounts});
final String title;
final List<AccountOut> accounts;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
),
for (final a in accounts) _AccountTile(account: a),
],
),
);
}
}
class _AccountTile extends ConsumerStatefulWidget {
const _AccountTile({required this.account});
final AccountOut account;
@override
ConsumerState<_AccountTile> createState() => _AccountTileState();
}
class _AccountTileState extends ConsumerState<_AccountTile> {
bool _saving = false;
Future<void> _patch(AccountPatch patch) async {
setState(() => _saving = true);
try {
await ref.read(apiProvider).getAccountsApi().accountsPatch(
accountId: widget.account.id,
accountPatch: patch,
);
ref.invalidate(accountsProvider);
} on DioException catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e))));
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final a = widget.account;
return Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(a.name, style: Theme.of(context).textTheme.titleSmall),
Text(
'${_kindLabel(a.kind)} · ${a.currency}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
MoneyText(
a.balance ?? '0',
currency: a.currency,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: DropdownButtonFormField<AccountRole>(
initialValue: a.role,
isDense: true,
decoration: const InputDecoration(labelText: 'Роль', isDense: true),
items: [
for (final r in _roleOrder)
DropdownMenuItem(value: r, child: Text(_roleLabel(r))),
],
onChanged: _saving ? null : (role) {
if (role != null) _patch(AccountPatch(role: role));
},
),
),
const SizedBox(width: 12),
Column(
children: [
const Text('В капитал', style: TextStyle(fontSize: 11)),
Switch(
value: a.includeInNetWorth,
onChanged: _saving
? null
: (v) => _patch(AccountPatch(includeInNetWorth: v)),
),
],
),
],
),
],
),
),
);
}
}
+16
View File
@@ -0,0 +1,16 @@
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_client.dart';
/// Every account, archived included — screens decide what to show.
final accountsProvider = FutureProvider.autoDispose<List<AccountOut>>((ref) async {
final r = await ref.watch(apiProvider).getAccountsApi().accountsList();
return r.data ?? const [];
});
/// `account_id -> name`, for screens that only carry the id (transactions, rules).
final accountNamesProvider = Provider.autoDispose<Map<int, String>>((ref) {
final accounts = ref.watch(accountsProvider).valueOrNull ?? const [];
return {for (final a in accounts) a.id: a.name};
});
@@ -0,0 +1,209 @@
import 'package:decimal/decimal.dart';
import 'package:fintracker_api/fintracker_api.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/chart_colors.dart';
import '../../core/utils/ru_date.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/money_text.dart';
import 'providers.dart';
const _incomeColor = ChartColors.income;
const _expenseColor = ChartColors.expense;
double _d(String s) => Decimal.parse(s).toDouble();
/// Потоки: 24 months of cashflow, a grouped bar chart on top and the full
/// breakdown as a scrollable table below. Tapping a row opens Категории for
/// that month.
class CashflowPage extends ConsumerWidget {
const CashflowPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final monthly = ref.watch(cashflowMonthly24Provider);
return Scaffold(
appBar: AppBar(title: const Text('Потоки')),
body: RefreshIndicator(
onRefresh: () async => ref.invalidate(cashflowMonthly24Provider),
child: AsyncValueView(
value: monthly,
onRetry: () => ref.invalidate(cashflowMonthly24Provider),
data: (rows) {
if (rows.isEmpty) {
return ListView(
children: const [
EmptyState(
icon: Icons.swap_horiz_outlined,
message: 'Данных о потоках ещё нет — нужна синхронизация.',
),
],
);
}
return ListView(
padding: const EdgeInsets.all(16),
children: [
const _Legend(),
const SizedBox(height: 8),
SizedBox(
height: 240,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
reverse: true,
child: SizedBox(
width: (rows.length * 56).toDouble().clamp(320, double.infinity),
child: _CashflowChart(rows: rows),
),
),
),
const SizedBox(height: 24),
_Table(rows: rows),
],
);
},
),
),
);
}
}
class _Legend extends StatelessWidget {
const _Legend();
@override
Widget build(BuildContext context) {
return Row(
children: const [
_LegendDot(color: _incomeColor, label: 'Доход'),
SizedBox(width: 16),
_LegendDot(color: _expenseColor, label: 'Расход'),
],
);
}
}
class _LegendDot extends StatelessWidget {
const _LegendDot({required this.color, required this.label});
final Color color;
final String label;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
const SizedBox(width: 6),
Text(label, style: Theme.of(context).textTheme.bodySmall),
],
);
}
}
class _CashflowChart extends StatelessWidget {
const _CashflowChart({required this.rows});
final List<CashFlowMonth> rows;
@override
Widget build(BuildContext context) {
final maxY = rows.fold<double>(
0,
(m, r) => [m, _d(r.incomeRub), _d(r.expenseRub)].reduce((a, b) => a > b ? a : b),
);
return BarChart(
BarChartData(
maxY: maxY * 1.1,
gridData: const FlGridData(drawVerticalLine: false),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
final i = value.toInt();
if (i < 0 || i >= rows.length) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(ruMonthYearShort(rows[i].month), style: const TextStyle(fontSize: 10)),
);
},
),
),
),
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIndex, rod, rodIndex) {
final label = rodIndex == 0 ? 'Доход' : 'Расход';
return BarTooltipItem(
'$label\n${MoneyText.format(rod.toY.toStringAsFixed(2), 'RUB')}',
const TextStyle(color: Colors.white, fontSize: 12),
);
},
),
),
barGroups: [
for (var i = 0; i < rows.length; i++)
BarChartGroupData(
x: i,
barRods: [
BarChartRodData(toY: _d(rows[i].incomeRub), color: _incomeColor, width: 8),
BarChartRodData(toY: _d(rows[i].expenseRub), color: _expenseColor, width: 8),
],
barsSpace: 2,
),
],
),
);
}
}
class _Table extends StatelessWidget {
const _Table({required this.rows});
final List<CashFlowMonth> rows;
@override
Widget build(BuildContext context) {
final headerStyle = Theme.of(context).textTheme.labelMedium;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
columns: [
DataColumn(label: Text('Месяц', style: headerStyle)),
DataColumn(label: Text('Доход', style: headerStyle), numeric: true),
DataColumn(label: Text('Расход', style: headerStyle), numeric: true),
DataColumn(label: Text('Базовые', style: headerStyle), numeric: true),
DataColumn(label: Text('Разовые', style: headerStyle), numeric: true),
DataColumn(label: Text('В сбережения', style: headerStyle), numeric: true),
DataColumn(label: Text('Норма сбер., %', style: headerStyle), numeric: true),
],
rows: [
for (final r in rows.reversed)
DataRow(
onSelectChanged: (_) => context.go('/categories?month=${monthKey(r.month)}'),
cells: [
DataCell(Text(ruMonthYearShort(r.month))),
DataCell(MoneyText(r.incomeRub, currency: 'RUB')),
DataCell(MoneyText(r.expenseRub, currency: 'RUB')),
DataCell(MoneyText(r.baselineRub, currency: 'RUB')),
DataCell(MoneyText(r.oneOffRub, currency: 'RUB')),
DataCell(MoneyText(r.savingsTransferRub, currency: 'RUB')),
DataCell(Text(r.savingsRate == null
? ''
: '${(_d(r.savingsRate!) * 100).toStringAsFixed(1)}%')),
],
),
],
),
);
}
}
+10
View File
@@ -0,0 +1,10 @@
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_client.dart';
/// The last 24 months, oldest first (as the API returns them).
final cashflowMonthly24Provider = FutureProvider.autoDispose<List<CashFlowMonth>>((ref) async {
final r = await ref.watch(apiProvider).getCashflowApi().cashflowMonthly(months: 24);
return r.data ?? const [];
});
@@ -0,0 +1,248 @@
import 'package:decimal/decimal.dart';
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/chart_colors.dart';
import '../../core/utils/ru_date.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/money_text.dart';
import 'providers.dart';
class _Group {
_Group(this.rootId, this.rootName);
final int? rootId;
final String rootName;
final List<SpendingRow> rows = [];
Decimal get total => rows.fold(Decimal.zero, (a, r) => a + Decimal.parse(r.amountRub));
}
List<_Group> _group(List<SpendingRow> rows) {
final byRoot = <int?, _Group>{};
for (final r in rows) {
final key = r.categoryId == null ? null : (r.rootCategoryId ?? r.categoryId);
final group = byRoot.putIfAbsent(
key,
() => _Group(key, key == null ? 'Без категории' : (r.rootCategoryName ?? r.categoryName ?? '')),
);
group.rows.add(r);
}
final groups = byRoot.values.toList()..sort((a, b) => b.total.compareTo(a.total));
return groups;
}
/// Категории: expenses of one month, root-grouped, largest first.
class CategoriesPage extends ConsumerStatefulWidget {
const CategoriesPage({this.initialMonth, super.key});
final String? initialMonth;
@override
ConsumerState<CategoriesPage> createState() => _CategoriesPageState();
}
class _CategoriesPageState extends ConsumerState<CategoriesPage> {
@override
void initState() {
super.initState();
final initial = widget.initialMonth;
if (initial != null) {
Future.microtask(() => ref.read(selectedSpendingMonthProvider.notifier).state = initial);
}
}
Future<void> _pickMonth() async {
final current = parseMonthKey(ref.read(selectedSpendingMonthProvider));
final picked = await showDatePicker(
context: context,
initialDate: current,
firstDate: DateTime.utc(2015),
lastDate: DateTime.now(),
helpText: 'Выберите месяц',
initialDatePickerMode: DatePickerMode.year,
);
if (picked != null) {
ref.read(selectedSpendingMonthProvider.notifier).state =
monthKey(DateTime(picked.year, picked.month));
}
}
void _shiftMonth(int delta) {
final current = parseMonthKey(ref.read(selectedSpendingMonthProvider));
final next = DateTime.utc(current.year, current.month + delta);
ref.read(selectedSpendingMonthProvider.notifier).state = monthKey(next);
}
@override
Widget build(BuildContext context) {
final month = ref.watch(selectedSpendingMonthProvider);
final spending = ref.watch(spendingProvider(month));
return Scaffold(
appBar: AppBar(title: const Text('Категории')),
body: RefreshIndicator(
onRefresh: () async => ref.invalidate(spendingProvider(month)),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: () => _shiftMonth(-1),
),
TextButton(
onPressed: _pickMonth,
child: Text(
ruMonthYear(parseMonthKey(month)),
style: Theme.of(context).textTheme.titleMedium,
),
),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: () => _shiftMonth(1),
),
],
),
const SizedBox(height: 8),
AsyncValueView(
value: spending,
onRetry: () => ref.invalidate(spendingProvider(month)),
data: (rows) {
if (rows.isEmpty) {
return const EmptyState(
icon: Icons.donut_small_outlined,
message: 'Данных за этот месяц нет — нужна синхронизация.',
);
}
final groups = _group(rows);
final total = groups.fold(Decimal.zero, (a, g) => a + g.total);
final maxTotal = groups.first.total;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Всего расходов', style: Theme.of(context).textTheme.bodyLarge),
MoneyText(
total.toString(),
currency: 'RUB',
style: Theme.of(context).textTheme.headlineSmall,
),
],
),
),
const Divider(),
for (final g in groups) _GroupTile(group: g, maxTotal: maxTotal),
],
);
},
),
],
),
),
);
}
}
class _GroupTile extends StatelessWidget {
const _GroupTile({required this.group, required this.maxTotal});
final _Group group;
final Decimal maxTotal;
bool get _flat =>
group.rows.length == 1 && (group.rootId == null || group.rows.first.categoryId == group.rootId);
@override
Widget build(BuildContext context) {
if (_flat) {
return _CategoryBar(
name: group.rootName,
amount: group.total,
maxAmount: maxTotal,
bold: true,
);
}
final children = [...group.rows]
..sort((a, b) => Decimal.parse(b.amountRub).compareTo(Decimal.parse(a.amountRub)));
return ExpansionTile(
tilePadding: EdgeInsets.zero,
title: _CategoryBar(name: group.rootName, amount: group.total, maxAmount: maxTotal, bold: true),
children: [
for (final r in children)
Padding(
padding: const EdgeInsets.only(left: 16),
child: _CategoryBar(
name: r.categoryId == group.rootId ? 'Без подкатегории' : (r.categoryName ?? ''),
amount: Decimal.parse(r.amountRub),
maxAmount: group.total,
bold: false,
),
),
],
);
}
}
class _CategoryBar extends StatelessWidget {
const _CategoryBar({
required this.name,
required this.amount,
required this.maxAmount,
required this.bold,
});
final String name;
final Decimal amount;
final Decimal maxAmount;
final bool bold;
@override
Widget build(BuildContext context) {
final ratio = maxAmount == Decimal.zero
? 0.0
: (amount / maxAmount).toDouble().clamp(0.0, 1.0);
final scheme = Theme.of(context).colorScheme;
final style = bold
? Theme.of(context).textTheme.bodyLarge
: Theme.of(context).textTheme.bodyMedium;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: Text(name, style: style)),
MoneyText(amount.toString(), currency: 'RUB', style: style),
],
),
const SizedBox(height: 4),
LayoutBuilder(
builder: (context, constraints) => ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Stack(
children: [
Container(height: 6, color: scheme.surfaceContainerHighest),
Container(
height: 6,
width: constraints.maxWidth * ratio,
color: bold ? ChartColors.expense : ChartColors.expense.withValues(alpha: 0.6),
),
],
),
),
),
],
),
);
}
}
@@ -0,0 +1,29 @@
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_client.dart';
import '../../core/utils/ru_date.dart';
/// Flat ZenMoney tag tree — the client nests it by `parent_id` where needed.
final categoriesListProvider = FutureProvider.autoDispose<List<CategoryOut>>((ref) async {
final r = await ref.watch(apiProvider).getCategoriesApi().categoriesList();
return r.data ?? const [];
});
/// `category_id -> name`, for screens that only carry the id.
final categoryNamesProvider = Provider.autoDispose<Map<int, String>>((ref) {
final categories = ref.watch(categoriesListProvider).valueOrNull ?? const [];
return {for (final c in categories) c.id: c.name};
});
/// Spending by category for one month (`YYYY-MM`); null means "the latest month".
final spendingProvider =
FutureProvider.autoDispose.family<List<SpendingRow>, String?>((ref, month) async {
final r = await ref.watch(apiProvider).getCashflowApi().cashflowSpending(month: month);
return r.data ?? const [];
});
/// The month currently selected on the Категории screen, `YYYY-MM`.
final selectedSpendingMonthProvider = StateProvider.autoDispose<String>(
(ref) => monthKey(DateTime.now()),
);
+478
View File
@@ -0,0 +1,478 @@
import 'package:dio/dio.dart';
import 'package:decimal/decimal.dart';
import 'package:fintracker_api/fintracker_api.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/auth/auth_controller.dart';
import '../../core/theme/chart_colors.dart';
import '../../core/utils/ru_date.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/money_text.dart';
import '../../core/api/api_client.dart';
import 'providers.dart';
double _d(String s) => Decimal.parse(s).toDouble();
Color _severityColor(String severity) {
final s = severity.toLowerCase();
if (s.contains('crit') || s.contains('err')) return const Color(0xFFD03B3B);
if (s.contains('warn')) return const Color(0xFFFAB219);
if (s.contains('info') || s.contains('low')) return const Color(0xFF0CA30C);
return const Color(0xFFEC835A);
}
/// Обзор: the dashboard landing page — net worth, this month's cashflow,
/// runway, a net worth line chart, a 12-month income/expense bar chart, and
/// a data-quality summary linking to the findings.
class HomePage extends ConsumerStatefulWidget {
const HomePage({super.key});
@override
ConsumerState<HomePage> createState() => _HomePageState();
}
class _HomePageState extends ConsumerState<HomePage> {
bool _refreshing = false;
Future<void> _refresh() async {
setState(() => _refreshing = true);
try {
await ref.read(apiProvider).getMetricsApi().metricsRefresh();
} on DioException catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e))));
} finally {
if (mounted) setState(() => _refreshing = false);
invalidateHomeProviders(ref);
}
}
void _showDataQuality(List<DataQualityRow> rows) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => SafeArea(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Качество данных', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
if (rows.isEmpty) const Text('Проблем не найдено.'),
for (final row in rows)
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
margin: const EdgeInsets.only(top: 4, right: 8),
width: 10,
height: 10,
decoration:
BoxDecoration(color: _severityColor(row.severity), shape: BoxShape.circle),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${row.checkName} (${row.count})',
style: Theme.of(context).textTheme.titleSmall),
Text(row.detail),
],
),
),
],
),
),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
final breakdown = ref.watch(netWorthBreakdownProvider);
final series = ref.watch(netWorthSeriesProvider);
final thisMonth = ref.watch(cashflowThisMonthProvider);
final last12 = ref.watch(cashflowLast12Provider);
final runway = ref.watch(runwayProvider);
final status = ref.watch(metricsStatusProvider);
final dataQuality = ref.watch(dataQualityProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Обзор'),
actions: [
IconButton(
tooltip: 'Пересчитать метрики',
icon: _refreshing
? const SizedBox(
width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.refresh),
onPressed: _refreshing ? null : _refresh,
),
],
),
body: RefreshIndicator(
onRefresh: () async => invalidateHomeProviders(ref),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Row(
children: [
Expanded(
child: AsyncValueView(
value: status,
onRetry: () => ref.invalidate(metricsStatusProvider),
data: (log) {
final at = log?.finishedAt ?? log?.startedAt;
final text = at == null
? 'Данные ещё не пересчитывались'
: 'Данные на ${ruDate(at.toLocal())} ${at.toLocal().hour.toString().padLeft(2, '0')}:${at.toLocal().minute.toString().padLeft(2, '0')}';
return Text(text, style: Theme.of(context).textTheme.bodySmall);
},
),
),
AsyncValueView(
value: dataQuality,
data: (rows) => ActionChip(
avatar: Icon(
rows.isEmpty ? Icons.check_circle_outline : Icons.warning_amber_outlined,
size: 18,
color: rows.isEmpty ? Colors.green : _severityColor(rows.first.severity),
),
label: Text(rows.isEmpty ? 'ок' : '${rows.length} замечаний'),
onPressed: rows.isEmpty ? null : () => _showDataQuality(rows),
),
),
],
),
const SizedBox(height: 16),
AsyncValueView(
value: breakdown,
onRetry: () => ref.invalidate(netWorthBreakdownProvider),
data: (b) => _NetWorthTiles(breakdown: b),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: AsyncValueView(
value: thisMonth,
onRetry: () => ref.invalidate(cashflowThisMonthProvider),
data: (m) => _MonthTiles(month: m),
),
),
],
),
const SizedBox(height: 12),
AsyncValueView(
value: runway,
onRetry: () => ref.invalidate(runwayProvider),
data: (r) => _RunwayTile(runway: r),
),
const SizedBox(height: 24),
LayoutBuilder(
builder: (context, constraints) {
final wide = constraints.maxWidth >= 900;
final netWorthChart = _ChartCard(
title: 'Капитал за 365 дней',
child: AsyncValueView(
value: series,
onRetry: () => ref.invalidate(netWorthSeriesProvider),
data: (rows) => rows.isEmpty
? const EmptyState(icon: Icons.show_chart, message: 'Пока нет данных.')
: _NetWorthChart(rows: rows),
),
);
final cashflowChart = _ChartCard(
title: 'Доход и расход, 12 месяцев',
child: AsyncValueView(
value: last12,
onRetry: () => ref.invalidate(cashflowLast12Provider),
data: (rows) => rows.isEmpty
? const EmptyState(icon: Icons.bar_chart, message: 'Пока нет данных.')
: _IncomeExpenseChart(rows: rows),
),
);
if (wide) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: netWorthChart),
const SizedBox(width: 16),
Expanded(child: cashflowChart),
],
);
}
return Column(
children: [netWorthChart, const SizedBox(height: 16), cashflowChart],
);
},
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: () => context.go('/sync'),
icon: const Icon(Icons.sync, size: 16),
label: const Text('Синхронизация'),
),
),
],
),
),
);
}
}
class _StatTile extends StatelessWidget {
const _StatTile({required this.label, required this.value});
final String label;
final Widget value;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 168,
child: Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 4),
DefaultTextStyle(style: Theme.of(context).textTheme.titleMedium!, child: value),
],
),
),
),
);
}
}
class _NetWorthTiles extends StatelessWidget {
const _NetWorthTiles({required this.breakdown});
final NetWorthBreakdown breakdown;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 12,
runSpacing: 12,
children: [
_StatTile(label: 'Капитал сегодня', value: MoneyText(breakdown.totalRub, currency: 'RUB')),
_StatTile(label: 'Ликвидные', value: MoneyText(breakdown.liquidRub, currency: 'RUB')),
_StatTile(label: 'Сбережения', value: MoneyText(breakdown.savingsRub, currency: 'RUB')),
_StatTile(label: 'Инвестиции', value: MoneyText(breakdown.investmentRub, currency: 'RUB')),
_StatTile(label: 'Долги', value: MoneyText(breakdown.debtRub, currency: 'RUB')),
],
);
}
}
class _MonthTiles extends StatelessWidget {
const _MonthTiles({required this.month});
final CashFlowMonth? month;
@override
Widget build(BuildContext context) {
if (month == null) {
return const EmptyState(icon: Icons.event_note_outlined, message: 'Данных за этот месяц нет.');
}
final rateText = month!.savingsRate == null ? '' : '${(_d(month!.savingsRate!) * 100).toStringAsFixed(1)}%';
return Wrap(
spacing: 12,
runSpacing: 12,
children: [
_StatTile(label: 'Доход в этом месяце', value: MoneyText(month!.incomeRub, currency: 'RUB')),
_StatTile(label: 'Расход в этом месяце', value: MoneyText(month!.expenseRub, currency: 'RUB')),
_StatTile(label: 'Норма сбережений', value: Text(rateText)),
],
);
}
}
class _RunwayTile extends StatelessWidget {
const _RunwayTile({required this.runway});
final RunwayOut runway;
@override
Widget build(BuildContext context) {
final text = runway.runwayMonths == null
? ''
: '${_d(runway.runwayMonths!).toStringAsFixed(1)} мес.';
return _StatTile(label: 'Запас хода (runway)', value: Text(text));
}
}
class _ChartCard extends StatelessWidget {
const _ChartCard({required this.title, required this.child});
final String title;
final Widget child;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 12),
SizedBox(height: 200, child: child),
],
),
),
);
}
}
class _NetWorthChart extends StatelessWidget {
const _NetWorthChart({required this.rows});
final List<NetWorthDay> rows;
@override
Widget build(BuildContext context) {
final spots = [
for (var i = 0; i < rows.length; i++) FlSpot(i.toDouble(), _d(rows[i].totalRub)),
];
return LineChart(
LineChartData(
gridData: const FlGridData(drawVerticalLine: false),
borderData: FlBorderData(show: false),
titlesData: const FlTitlesData(
topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (spots) => [
for (final s in spots)
LineTooltipItem(
MoneyText.format(s.y.toStringAsFixed(2), 'RUB'),
const TextStyle(color: Colors.white, fontSize: 12),
),
],
),
),
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: false,
barWidth: 2,
color: ChartColors.slot1Blue,
dotData: const FlDotData(show: false),
),
],
),
);
}
}
class _IncomeExpenseChart extends StatelessWidget {
const _IncomeExpenseChart({required this.rows});
final List<CashFlowMonth> rows;
@override
Widget build(BuildContext context) {
final maxY = rows.fold<double>(
0,
(m, r) => [m, _d(r.incomeRub), _d(r.expenseRub)].reduce((a, b) => a > b ? a : b),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: const [
_LegendDot(color: ChartColors.income, label: 'Доход'),
SizedBox(width: 16),
_LegendDot(color: ChartColors.expense, label: 'Расход'),
],
),
const SizedBox(height: 8),
Expanded(
child: BarChart(
BarChartData(
maxY: maxY * 1.1,
gridData: const FlGridData(drawVerticalLine: false),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
final i = value.toInt();
if (i < 0 || i >= rows.length) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 6),
child:
Text(ruMonthYearShort(rows[i].month), style: const TextStyle(fontSize: 9)),
);
},
),
),
),
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIndex, rod, rodIndex) {
final label = rodIndex == 0 ? 'Доход' : 'Расход';
return BarTooltipItem(
'$label\n${MoneyText.format(rod.toY.toStringAsFixed(2), 'RUB')}',
const TextStyle(color: Colors.white, fontSize: 12),
);
},
),
),
barGroups: [
for (var i = 0; i < rows.length; i++)
BarChartGroupData(
x: i,
barRods: [
BarChartRodData(toY: _d(rows[i].incomeRub), color: ChartColors.income, width: 6),
BarChartRodData(toY: _d(rows[i].expenseRub), color: ChartColors.expense, width: 6),
],
barsSpace: 2,
),
],
),
),
),
],
);
}
}
class _LegendDot extends StatelessWidget {
const _LegendDot({required this.color, required this.label});
final Color color;
final String label;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
const SizedBox(width: 6),
Text(label, style: Theme.of(context).textTheme.bodySmall),
],
);
}
}
+66
View File
@@ -0,0 +1,66 @@
import 'package:dio/dio.dart';
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_client.dart';
final netWorthBreakdownProvider = FutureProvider.autoDispose<NetWorthBreakdown>((ref) async {
final r = await ref.watch(apiProvider).getNetworthApi().networthBreakdown();
return r.data!;
});
/// Daily net worth for the last 365 days.
final netWorthSeriesProvider = FutureProvider.autoDispose<List<NetWorthDay>>((ref) async {
final now = DateTime.now();
final r = await ref.watch(apiProvider).getNetworthApi().networthSeries(
from: now.subtract(const Duration(days: 365)),
to: now,
);
return r.data ?? const [];
});
/// The current (partial) month's cashflow, or null before any data exists.
final cashflowThisMonthProvider = FutureProvider.autoDispose<CashFlowMonth?>((ref) async {
final r = await ref.watch(apiProvider).getCashflowApi().cashflowMonthly(months: 1);
final rows = r.data ?? const [];
return rows.isEmpty ? null : rows.last;
});
/// The last 12 months, for the income-vs-expense bar chart.
final cashflowLast12Provider = FutureProvider.autoDispose<List<CashFlowMonth>>((ref) async {
final r = await ref.watch(apiProvider).getCashflowApi().cashflowMonthly(months: 12);
return r.data ?? const [];
});
final runwayProvider = FutureProvider.autoDispose<RunwayOut>((ref) async {
final r = await ref.watch(apiProvider).getCashflowApi().cashflowRunway();
return r.data!;
});
/// When the metric tables were last rebuilt; null before the first refresh.
final metricsStatusProvider = FutureProvider.autoDispose<RefreshLogOut?>((ref) async {
try {
final r = await ref.watch(apiProvider).getMetricsApi().metricsStatus();
return r.data;
} on DioException catch (e) {
if (e.response?.statusCode == 404) return null;
rethrow;
}
});
final dataQualityProvider = FutureProvider.autoDispose<List<DataQualityRow>>((ref) async {
final r = await ref.watch(apiProvider).getMetricsApi().metricsDataQuality();
return r.data ?? const [];
});
/// Every dashboard provider, refreshed together after a manual
/// `POST /metrics/refresh` or a pull-to-refresh.
void invalidateHomeProviders(WidgetRef ref) {
ref.invalidate(netWorthBreakdownProvider);
ref.invalidate(netWorthSeriesProvider);
ref.invalidate(cashflowThisMonthProvider);
ref.invalidate(cashflowLast12Provider);
ref.invalidate(runwayProvider);
ref.invalidate(metricsStatusProvider);
ref.invalidate(dataQualityProvider);
}
+96
View File
@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/auth/auth_controller.dart';
import '../../core/config.dart';
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
final _email = TextEditingController();
final _password = TextEditingController();
String? _error;
bool _busy = false;
@override
void dispose() {
_email.dispose();
_password.dispose();
super.dispose();
}
Future<void> _submit() async {
setState(() {
_busy = true;
_error = null;
});
final error = await ref
.read(authControllerProvider.notifier)
.login(_email.text.trim(), _password.text);
if (!mounted) return;
setState(() {
_busy = false;
_error = error;
});
}
@override
Widget build(BuildContext context) {
final status = ref.watch(authControllerProvider).status;
return Scaffold(
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: Padding(
padding: const EdgeInsets.all(24),
child: status == AuthStatus.unknown
? const CircularProgressIndicator()
: AutofillGroup(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('fin-tracker', style: Theme.of(context).textTheme.headlineMedium),
const SizedBox(height: 4),
Text(apiBaseUrl(), style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 24),
TextField(
controller: _email,
autofillHints: const [AutofillHints.username],
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(labelText: 'Email'),
),
const SizedBox(height: 12),
TextField(
controller: _password,
autofillHints: const [AutofillHints.password],
obscureText: true,
onSubmitted: (_) => _busy ? null : _submit(),
decoration: const InputDecoration(labelText: 'Пароль'),
),
if (_error != null) ...[
const SizedBox(height: 12),
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))
: const Text('Войти'),
),
],
),
),
),
),
),
);
}
}
+20
View File
@@ -0,0 +1,20 @@
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_client.dart';
final rulesListProvider = FutureProvider.autoDispose<List<RuleOut>>((ref) async {
final r = await ref.watch(apiProvider).getRulesApi().rulesList();
return r.data ?? const [];
});
/// Enabled rules that matched nothing in the latest refresh.
final rulesStaleProvider = FutureProvider.autoDispose<List<RuleOut>>((ref) async {
final r = await ref.watch(apiProvider).getRulesApi().rulesStale();
return r.data ?? const [];
});
final staleRuleIdsProvider = Provider.autoDispose<Set<int>>((ref) {
final stale = ref.watch(rulesStaleProvider).valueOrNull ?? const [];
return {for (final r in stale) r.id};
});
+377
View File
@@ -0,0 +1,377 @@
import 'package:dio/dio.dart';
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_client.dart';
import '../../core/auth/auth_controller.dart';
import '../../core/utils/ru_date.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/empty_state.dart';
import 'providers.dart';
String ruleKindLabel(RuleKind k) => switch (k) {
RuleKind.savings => 'Сбережения',
RuleKind.oneOff => 'Разовая трата',
RuleKind.category => 'Категория',
RuleKind.payee => 'Плательщик',
RuleKind.brokerTarget => 'Целевой брокер',
RuleKind.ignore => 'Игнорировать',
RuleKind.unknownDefaultOpenApi => 'Неизвестно',
};
String ruleMatchTypeLabel(RuleMatchType t) => switch (t) {
RuleMatchType.id => 'ID транзакции',
RuleMatchType.payee => 'Плательщик',
RuleMatchType.comment => 'Комментарий',
RuleMatchType.category => 'Категория',
RuleMatchType.mcc => 'MCC',
RuleMatchType.account => 'Счёт',
RuleMatchType.unknownDefaultOpenApi => 'Неизвестно',
};
const _kinds = [
RuleKind.savings,
RuleKind.oneOff,
RuleKind.category,
RuleKind.payee,
RuleKind.brokerTarget,
RuleKind.ignore,
];
const _matchTypes = [
RuleMatchType.id,
RuleMatchType.payee,
RuleMatchType.comment,
RuleMatchType.category,
RuleMatchType.mcc,
RuleMatchType.account,
];
void _invalidateAll(WidgetRef ref) {
ref.invalidate(rulesListProvider);
ref.invalidate(rulesStaleProvider);
}
/// Правила: list of categorisation rules, an add/edit dialog, and a manual
/// "apply" trigger that re-runs the whole metric refresh.
class RulesPage extends ConsumerWidget {
const RulesPage({super.key});
Future<void> _apply(BuildContext context, WidgetRef ref) async {
final messenger = ScaffoldMessenger.of(context);
try {
final r = await ref.read(apiProvider).getRulesApi().rulesApply();
final ok = r.data?.error == null;
messenger.showSnackBar(SnackBar(
content: Text(ok ? 'Правила применены' : 'Ошибка применения: ${r.data?.error}'),
));
} on DioException catch (e) {
messenger.showSnackBar(SnackBar(content: Text(problemMessage(e))));
} finally {
_invalidateAll(ref);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final rules = ref.watch(rulesListProvider);
final staleIds = ref.watch(staleRuleIdsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Правила'),
actions: [
IconButton(
tooltip: 'Применить правила',
icon: const Icon(Icons.play_circle_outline),
onPressed: () => _apply(context, ref),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => showDialog(
context: context,
builder: (_) => _RuleDialog(onSaved: () => _invalidateAll(ref)),
),
child: const Icon(Icons.add),
),
body: RefreshIndicator(
onRefresh: () async => _invalidateAll(ref),
child: AsyncValueView(
value: rules,
onRetry: () => ref.invalidate(rulesListProvider),
data: (rows) {
if (rows.isEmpty) {
return ListView(
children: const [
EmptyState(icon: Icons.rule_folder_outlined, message: 'Правил ещё нет.'),
],
);
}
final sorted = [...rows]..sort((a, b) => a.priority.compareTo(b.priority));
return ListView(
padding: const EdgeInsets.all(16),
children: [
for (final rule in sorted)
_RuleTile(
rule: rule,
stale: staleIds.contains(rule.id),
onChanged: () => _invalidateAll(ref),
),
],
);
},
),
),
);
}
}
class _RuleTile extends ConsumerStatefulWidget {
const _RuleTile({required this.rule, required this.stale, required this.onChanged});
final RuleOut rule;
final bool stale;
final VoidCallback onChanged;
@override
ConsumerState<_RuleTile> createState() => _RuleTileState();
}
class _RuleTileState extends ConsumerState<_RuleTile> {
bool _busy = false;
Future<void> _toggle(bool enabled) async {
setState(() => _busy = true);
try {
await ref.read(apiProvider).getRulesApi().rulesPatch(
ruleId: widget.rule.id,
rulePatch: RulePatch(enabled: enabled),
);
widget.onChanged();
} on DioException catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e))));
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _delete() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Удалить правило?'),
content: Text('«${widget.rule.pattern}» — действие необратимо.'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Отмена')),
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Удалить')),
],
),
);
if (confirmed != true) return;
try {
await ref.read(apiProvider).getRulesApi().rulesDelete(ruleId: widget.rule.id);
widget.onChanged();
} on DioException catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e))));
}
}
@override
Widget build(BuildContext context) {
final rule = widget.rule;
return Card(
child: ListTile(
onTap: () => showDialog(
context: context,
builder: (_) => _RuleDialog(existing: rule, onSaved: widget.onChanged),
),
title: Row(
children: [
Text(ruleKindLabel(rule.kind)),
const SizedBox(width: 8),
Chip(
visualDensity: VisualDensity.compact,
label: Text(ruleMatchTypeLabel(rule.matchType)),
),
if (widget.stale) ...[
const SizedBox(width: 8),
Chip(
visualDensity: VisualDensity.compact,
label: const Text('устарело'),
backgroundColor: Colors.amber.withValues(alpha: 0.2),
),
],
],
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
[
'${rule.pattern}${rule.value ?? ''}',
'совпадений: ${rule.matchCount}',
if (rule.lastMatchedAt != null) 'последнее: ${ruDate(rule.lastMatchedAt!.toLocal())}',
].join(' · '),
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Switch(value: rule.enabled, onChanged: _busy ? null : _toggle),
IconButton(icon: const Icon(Icons.delete_outline), onPressed: _delete),
],
),
),
);
}
}
class _RuleDialog extends ConsumerStatefulWidget {
const _RuleDialog({this.existing, required this.onSaved});
final RuleOut? existing;
final VoidCallback onSaved;
@override
ConsumerState<_RuleDialog> createState() => _RuleDialogState();
}
class _RuleDialogState extends ConsumerState<_RuleDialog> {
final _formKey = GlobalKey<FormState>();
late RuleKind _kind;
late RuleMatchType _matchType;
late final TextEditingController _pattern;
late final TextEditingController _value;
late final TextEditingController _note;
late final TextEditingController _priority;
late bool _enabled;
bool _saving = false;
@override
void initState() {
super.initState();
final e = widget.existing;
_kind = e?.kind ?? RuleKind.category;
_matchType = e?.matchType ?? RuleMatchType.payee;
_pattern = TextEditingController(text: e?.pattern ?? '');
_value = TextEditingController(text: e?.value ?? '');
_note = TextEditingController(text: e?.note ?? '');
_priority = TextEditingController(text: (e?.priority ?? 100).toString());
_enabled = e?.enabled ?? true;
}
@override
void dispose() {
_pattern.dispose();
_value.dispose();
_note.dispose();
_priority.dispose();
super.dispose();
}
Future<void> _save() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() => _saving = true);
final priority = int.tryParse(_priority.text.trim()) ?? 100;
try {
final api = ref.read(apiProvider).getRulesApi();
if (widget.existing == null) {
await api.rulesCreate(
ruleCreate: RuleCreate(
kind: _kind,
matchType: _matchType,
pattern: _pattern.text.trim(),
value: _value.text.trim().isEmpty ? null : _value.text.trim(),
note: _note.text.trim().isEmpty ? null : _note.text.trim(),
priority: priority,
enabled: _enabled,
),
);
} else {
await api.rulesPatch(
ruleId: widget.existing!.id,
rulePatch: RulePatch(
kind: _kind,
matchType: _matchType,
pattern: _pattern.text.trim(),
value: _value.text.trim().isEmpty ? null : _value.text.trim(),
note: _note.text.trim().isEmpty ? null : _note.text.trim(),
priority: priority,
enabled: _enabled,
),
);
}
widget.onSaved();
if (mounted) Navigator.pop(context);
} on DioException catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e))));
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.existing == null ? 'Новое правило' : 'Правило'),
content: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<RuleKind>(
initialValue: _kind,
decoration: const InputDecoration(labelText: 'Тип правила'),
items: [for (final k in _kinds) DropdownMenuItem(value: k, child: Text(ruleKindLabel(k)))],
onChanged: (v) => setState(() => _kind = v!),
),
DropdownButtonFormField<RuleMatchType>(
initialValue: _matchType,
decoration: const InputDecoration(labelText: 'Совпадение по'),
items: [
for (final t in _matchTypes)
DropdownMenuItem(value: t, child: Text(ruleMatchTypeLabel(t))),
],
onChanged: (v) => setState(() => _matchType = v!),
),
TextFormField(
controller: _pattern,
decoration: const InputDecoration(labelText: 'Шаблон (pattern)'),
validator: (v) => (v == null || v.trim().isEmpty) ? 'Обязательное поле' : null,
),
TextFormField(
controller: _value,
decoration: const InputDecoration(labelText: 'Значение (value)'),
),
TextFormField(
controller: _note,
decoration: const InputDecoration(labelText: 'Заметка'),
),
TextFormField(
controller: _priority,
decoration: const InputDecoration(labelText: 'Приоритет'),
keyboardType: TextInputType.number,
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Включено'),
value: _enabled,
onChanged: (v) => setState(() => _enabled = v),
),
],
),
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Отмена')),
FilledButton(onPressed: _saving ? null : _save, child: const Text('Сохранить')),
],
);
}
}
@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/common_providers.dart';
import '../../core/app_info.dart';
import '../../core/auth/auth_controller.dart';
import '../../core/config.dart';
import '../../core/theme/theme_controller.dart';
import '../../core/widgets/async_value_view.dart';
/// Настройки: API endpoint, signed-in account, theme and logout.
class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final me = ref.watch(meProvider);
final themeMode = ref.watch(themeModeProvider);
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),
),
],
selected: {themeMode},
onSelectionChanged: (selection) =>
ref.read(themeModeProvider.notifier).setMode(selection.first),
),
),
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(),
),
],
),
);
}
}
+94
View File
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class _Destination {
const _Destination(this.path, this.icon, this.selectedIcon, this.label);
final String path;
final IconData icon;
final IconData selectedIcon;
final String label;
}
const _destinations = [
_Destination('/', Icons.dashboard_outlined, Icons.dashboard, 'Обзор'),
_Destination('/accounts', Icons.account_balance_outlined, Icons.account_balance, 'Счета'),
_Destination('/cashflow', Icons.swap_horiz_outlined, Icons.swap_horiz, 'Потоки'),
_Destination('/categories', Icons.category_outlined, Icons.category, 'Категории'),
_Destination(
'/transactions', Icons.receipt_long_outlined, Icons.receipt_long, 'Операции'),
_Destination('/rules', Icons.rule_folder_outlined, Icons.rule_folder, 'Правила'),
_Destination('/sync', Icons.sync_outlined, Icons.sync, 'Синк'),
_Destination('/settings', Icons.settings_outlined, Icons.settings, 'Настройки'),
];
/// Breakpoints per the plan: bottom bar under 600, collapsed rail 6001200,
/// extended rail at 1200 and above.
const _narrowBreakpoint = 600.0;
const _wideBreakpoint = 1200.0;
/// Adaptive navigation shell around the current route: a bottom
/// [NavigationBar] on narrow surfaces, a [NavigationRail] (collapsed or
/// extended) otherwise.
class AppShell extends StatelessWidget {
const AppShell({required this.location, required this.child, super.key});
final String location;
final Widget child;
int get _selectedIndex {
final i = _destinations.indexWhere((d) => d.path == location);
return i == -1 ? 0 : i;
}
void _onSelect(BuildContext context, int index) {
if (index != _selectedIndex) context.go(_destinations[index].path);
}
@override
Widget build(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
if (width < _narrowBreakpoint) {
return Scaffold(
body: SafeArea(child: child),
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedIndex,
onDestinationSelected: (i) => _onSelect(context, i),
destinations: [
for (final d in _destinations)
NavigationDestination(
icon: Icon(d.icon),
selectedIcon: Icon(d.selectedIcon),
label: d.label,
),
],
),
);
}
final extended = width >= _wideBreakpoint;
return Scaffold(
body: Row(
children: [
NavigationRail(
extended: extended,
minExtendedWidth: 220,
selectedIndex: _selectedIndex,
onDestinationSelected: (i) => _onSelect(context, i),
labelType: extended ? NavigationRailLabelType.none : NavigationRailLabelType.selected,
destinations: [
for (final d in _destinations)
NavigationRailDestination(
icon: Icon(d.icon),
selectedIcon: Icon(d.selectedIcon),
label: Text(d.label),
),
],
),
const VerticalDivider(width: 1),
Expanded(child: SafeArea(child: child)),
],
),
);
}
}
+276
View File
@@ -0,0 +1,276 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../core/api/api_client.dart';
import '../../core/auth/auth_controller.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/empty_state.dart';
final _dateFmt = DateFormat('dd.MM.yyyy HH:mm');
final syncStatusProvider = FutureProvider.autoDispose<List<SourceStatus>>((ref) async {
final r = await ref.watch(apiProvider).getSyncApi().syncStatus();
return r.data ?? const [];
});
final syncRunsProvider = FutureProvider.autoDispose<List<SyncRunOut>>((ref) async {
final r = await ref.watch(apiProvider).getSyncApi().syncRuns(limit: 20);
return r.data ?? const [];
});
const _pollInterval = Duration(seconds: 10);
/// Синк: source statuses with a manual trigger per source, and a log of the
/// last 20 runs. Polls `/sync/status` every 10 s while this page is mounted.
class SyncPage extends ConsumerStatefulWidget {
const SyncPage({super.key});
@override
ConsumerState<SyncPage> createState() => _SyncPageState();
}
class _SyncPageState extends ConsumerState<SyncPage> {
Timer? _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(_pollInterval, (_) {
if (!mounted) return;
ref.invalidate(syncStatusProvider);
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
Future<void> _refreshAll() async {
ref.invalidate(syncStatusProvider);
ref.invalidate(syncRunsProvider);
}
Future<void> _trigger(String source) async {
try {
await ref.read(apiProvider).getSyncApi().syncTrigger(source_: source);
} on DioException catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e))));
} finally {
ref.invalidate(syncStatusProvider);
ref.invalidate(syncRunsProvider);
}
}
@override
Widget build(BuildContext context) {
final status = ref.watch(syncStatusProvider);
final runs = ref.watch(syncRunsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Синк'),
actions: [
IconButton(
tooltip: 'Обновить',
icon: const Icon(Icons.refresh),
onPressed: _refreshAll,
),
],
),
body: RefreshIndicator(
onRefresh: _refreshAll,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Text('Источники', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
AsyncValueView(
value: status,
onRetry: () => ref.invalidate(syncStatusProvider),
data: (rows) => rows.isEmpty
? const EmptyState(
icon: Icons.cable_outlined,
message: 'Источники данных ещё не подключены (фаза 1: ZenMoney, ЦБ).',
)
: Column(
children: [for (final s in rows) _SourceCard(source: s, onTrigger: _trigger)],
),
),
const SizedBox(height: 24),
Text('Последние запуски', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
AsyncValueView(
value: runs,
onRetry: () => ref.invalidate(syncRunsProvider),
data: (rows) => rows.isEmpty
? const EmptyState(icon: Icons.history, message: 'Запусков ещё не было.')
: Column(children: [for (final r in rows) _RunTile(run: r)]),
),
],
),
),
);
}
}
class _SourceCard extends StatelessWidget {
const _SourceCard({required this.source, required this.onTrigger});
final SourceStatus source;
final Future<void> Function(String source) onTrigger;
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
title: Row(
children: [
Text(source.source_, style: Theme.of(context).textTheme.titleSmall),
const SizedBox(width: 8),
_statusChip(context, source.lastRunStatus),
if (source.queued) ...[
const SizedBox(width: 8),
const Chip(
visualDensity: VisualDensity.compact,
label: Text('в очереди'),
),
],
],
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4),
child: Text([
if (source.lastRunAt != null) 'запуск ${_dateFmt.format(source.lastRunAt!.toLocal())}',
if (source.cursor != null) 'курсор ${_shortCursor(source.cursor!)}',
].join(' · ')),
),
trailing: IconButton(
tooltip: 'Запустить синхронизацию',
icon: const Icon(Icons.sync),
onPressed: source.queued ? null : () => onTrigger(source.source_),
),
),
);
}
}
class _RunTile extends StatelessWidget {
const _RunTile({required this.run});
final SyncRunOut run;
@override
Widget build(BuildContext context) {
final duration = run.finishedAt?.difference(run.startedAt);
final subtitle = [
_dateFmt.format(run.startedAt.toLocal()),
duration != null ? _formatDuration(duration) : 'выполняется',
].join(' · ');
final counts = run.counts;
final content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (counts != null && counts.isNotEmpty)
Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (final e in counts.entries)
Chip(
visualDensity: VisualDensity.compact,
label: Text('${e.key}=${e.value}'),
),
],
),
],
);
if (run.error != null && run.error!.isNotEmpty) {
return Card(
child: ExpansionTile(
title: _runTitle(context),
subtitle: Text(subtitle),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
content,
const SizedBox(height: 8),
Text(run.error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
),
),
],
),
);
}
return Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_runTitle(context),
Padding(
padding: const EdgeInsets.only(top: 2, bottom: 6),
child: Text(subtitle, style: Theme.of(context).textTheme.bodySmall),
),
content,
],
),
),
);
}
Widget _runTitle(BuildContext context) {
return Row(
children: [
Text(run.source_, style: Theme.of(context).textTheme.titleSmall),
const SizedBox(width: 8),
_statusChip(context, run.status),
],
);
}
}
Widget _statusChip(BuildContext context, RunStatus? status) {
if (status == null) {
return const Chip(visualDensity: VisualDensity.compact, label: Text('нет данных'));
}
final scheme = Theme.of(context).colorScheme;
final (label, color) = switch (status) {
RunStatus.ok => ('ok', Colors.green),
RunStatus.error => ('error', scheme.error),
RunStatus.running => ('running', Colors.amber.shade700),
RunStatus.unknownDefaultOpenApi => ('неизвестно', scheme.outline),
};
return Chip(
visualDensity: VisualDensity.compact,
label: Text(label),
backgroundColor: color.withValues(alpha: 0.15),
labelStyle: TextStyle(color: color),
side: BorderSide(color: color.withValues(alpha: 0.4)),
);
}
String _shortCursor(String cursor) {
if (cursor.length <= 20) return cursor;
return '${cursor.substring(0, 10)}${cursor.substring(cursor.length - 6)}';
}
String _formatDuration(Duration d) {
if (d.inMinutes >= 1) return '${d.inMinutes} мин ${d.inSeconds % 60} с';
return '${d.inSeconds} с';
}
@@ -0,0 +1,160 @@
import 'package:dio/dio.dart';
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_client.dart';
class TransactionsFilter {
const TransactionsFilter({
this.q,
this.from,
this.to,
this.accountId,
this.categoryId,
this.flowType,
});
final String? q;
final DateTime? from;
final DateTime? to;
final int? accountId;
final int? categoryId;
final FlowType? flowType;
bool get isEmpty =>
q == null && from == null && to == null && accountId == null && categoryId == null && flowType == null;
TransactionsFilter copyWith({
String? Function()? q,
DateTime? Function()? from,
DateTime? Function()? to,
int? Function()? accountId,
int? Function()? categoryId,
FlowType? Function()? flowType,
}) {
return TransactionsFilter(
q: q != null ? q() : this.q,
from: from != null ? from() : this.from,
to: to != null ? to() : this.to,
accountId: accountId != null ? accountId() : this.accountId,
categoryId: categoryId != null ? categoryId() : this.categoryId,
flowType: flowType != null ? flowType() : this.flowType,
);
}
}
class TransactionsState {
const TransactionsState({
this.items = const [],
this.page = 1,
this.pageSize = 50,
this.total = 0,
this.loading = false,
this.loadingMore = false,
this.error,
});
final List<TransactionOut> items;
final int page;
final int pageSize;
final int total;
final bool loading;
final bool loadingMore;
final Object? error;
bool get hasMore => items.length < total;
TransactionsState copyWith({
List<TransactionOut>? items,
int? page,
int? pageSize,
int? total,
bool? loading,
bool? loadingMore,
Object? error,
bool clearError = false,
}) {
return TransactionsState(
items: items ?? this.items,
page: page ?? this.page,
pageSize: pageSize ?? this.pageSize,
total: total ?? this.total,
loading: loading ?? this.loading,
loadingMore: loadingMore ?? this.loadingMore,
error: clearError ? null : (error ?? this.error),
);
}
}
/// Filters + paginated results for Операции. `refresh()` restarts from page 1
/// (called on build and on every filter change); `loadMore()` appends the
/// next page for infinite scroll / the "ещё" button.
class TransactionsController extends Notifier<TransactionsState> {
TransactionsFilter filter = const TransactionsFilter();
@override
TransactionsState build() {
Future.microtask(refresh);
return const TransactionsState(loading: true);
}
Future<void> setFilter(TransactionsFilter Function(TransactionsFilter) update) {
filter = update(filter);
return refresh();
}
Future<void> refresh() async {
state = state.copyWith(loading: true, clearError: true);
try {
final r = await ref.read(apiProvider).getTransactionsApi().transactionsList(
from: filter.from,
to: filter.to,
accountId: filter.accountId,
categoryId: filter.categoryId,
flowType: filter.flowType,
q: (filter.q == null || filter.q!.isEmpty) ? null : filter.q,
page: 1,
pageSize: state.pageSize,
);
final page = r.data!;
state = TransactionsState(
items: page.items,
page: page.page,
pageSize: page.pageSize,
total: page.total,
);
} on DioException catch (e) {
state = state.copyWith(loading: false, error: e);
}
}
Future<void> loadMore() async {
if (state.loading || state.loadingMore || !state.hasMore) return;
state = state.copyWith(loadingMore: true, clearError: true);
try {
final next = state.page + 1;
final r = await ref.read(apiProvider).getTransactionsApi().transactionsList(
from: filter.from,
to: filter.to,
accountId: filter.accountId,
categoryId: filter.categoryId,
flowType: filter.flowType,
q: (filter.q == null || filter.q!.isEmpty) ? null : filter.q,
page: next,
pageSize: state.pageSize,
);
final page = r.data!;
state = state.copyWith(
items: [...state.items, ...page.items],
page: page.page,
total: page.total,
loadingMore: false,
);
} on DioException catch (e) {
state = state.copyWith(loadingMore: false, error: e);
}
}
}
final transactionsControllerProvider =
NotifierProvider<TransactionsController, TransactionsState>(TransactionsController.new);
@@ -0,0 +1,83 @@
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter/material.dart';
import '../../core/utils/ru_date.dart';
import '../../core/widgets/money_text.dart';
/// The amount/currency/RUB-equivalent a transaction is shown with, chosen by
/// its [FlowType]: the outgoing leg for expenses and transfers out, the
/// incoming leg for income.
({String amount, String currency, String? rub}) primaryAmount(TransactionOut t) {
switch (t.flowType) {
case FlowType.income:
return (amount: t.income, currency: t.incomeCurrency ?? 'RUB', rub: t.incomeRub);
case FlowType.expense:
return (amount: t.outcome, currency: t.outcomeCurrency ?? 'RUB', rub: t.outcomeRub);
case FlowType.internalTransfer:
case FlowType.savingsTransfer:
case FlowType.brokerExternalFlow:
case FlowType.other:
case FlowType.deleted:
case FlowType.unknownDefaultOpenApi:
if (t.outcome != '0' && t.outcomeCurrency != null) {
return (amount: t.outcome, currency: t.outcomeCurrency!, rub: t.outcomeRub);
}
return (amount: t.income, currency: t.incomeCurrency ?? 'RUB', rub: t.incomeRub);
}
}
(IconData, Color) flowTypeIcon(FlowType type, ColorScheme scheme) => switch (type) {
FlowType.income => (Icons.arrow_circle_down_outlined, Colors.green),
FlowType.expense => (Icons.arrow_circle_up_outlined, scheme.error),
FlowType.internalTransfer => (Icons.swap_horiz, scheme.primary),
FlowType.savingsTransfer => (Icons.savings_outlined, Colors.amber.shade800),
FlowType.brokerExternalFlow => (Icons.trending_up, Colors.deepPurple),
FlowType.other || FlowType.deleted || FlowType.unknownDefaultOpenApi => (
Icons.help_outline,
scheme.outline,
),
};
/// One row of the Операции list: date, payee, category, native amount with
/// its RUB equivalent when the currency differs, and a flow-type icon.
class TransactionRow extends StatelessWidget {
const TransactionRow({
required this.transaction,
required this.categoryName,
required this.onTap,
super.key,
});
final TransactionOut transaction;
final String? categoryName;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final t = transaction;
final scheme = Theme.of(context).colorScheme;
final (icon, color) = flowTypeIcon(t.flowType, scheme);
final amount = primaryAmount(t);
final showRub = amount.currency != 'RUB' && amount.rub != null;
final payee = t.payeeCanonical?.isNotEmpty == true ? t.payeeCanonical! : (t.payee ?? '');
return ListTile(
onTap: onTap,
leading: Icon(icon, color: color),
title: Text(payee),
subtitle: Text([ruDate(t.date), ?categoryName].join(' · ')),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
MoneyText(amount.amount, currency: amount.currency),
if (showRub)
Text(
MoneyText.format(amount.rub!, 'RUB'),
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: scheme.outline),
),
],
),
);
}
}
@@ -0,0 +1,347 @@
import 'dart:async';
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/utils/ru_date.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/money_text.dart';
import '../accounts/providers.dart';
import '../categories/providers.dart';
import 'providers.dart';
import 'transaction_row.dart';
const _flowTypes = [
FlowType.income,
FlowType.expense,
FlowType.internalTransfer,
FlowType.savingsTransfer,
];
String _flowTypeLabel(FlowType t) => switch (t) {
FlowType.income => 'Доход',
FlowType.expense => 'Расход',
FlowType.internalTransfer => 'Перевод',
FlowType.savingsTransfer => 'В сбережения',
FlowType.brokerExternalFlow => 'Брокер',
FlowType.other => 'Прочее',
FlowType.deleted => 'Удалено',
FlowType.unknownDefaultOpenApi => 'Неизвестно',
};
/// Операции: filtered, paginated transaction list with infinite scroll and a
/// detail bottom sheet per row.
class TransactionsPage extends ConsumerStatefulWidget {
const TransactionsPage({super.key});
@override
ConsumerState<TransactionsPage> createState() => _TransactionsPageState();
}
class _TransactionsPageState extends ConsumerState<TransactionsPage> {
final _searchController = TextEditingController();
final _scrollController = ScrollController();
Timer? _debounce;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_debounce?.cancel();
_searchController.dispose();
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels > _scrollController.position.maxScrollExtent - 200) {
ref.read(transactionsControllerProvider.notifier).loadMore();
}
}
void _onSearchChanged(String value) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 400), () {
ref.read(transactionsControllerProvider.notifier).setFilter(
(f) => f.copyWith(q: () => value.trim().isEmpty ? null : value.trim()),
);
});
}
Future<void> _pickDateRange() async {
final filter = ref.read(transactionsControllerProvider.notifier).filter;
final now = DateTime.now();
final picked = await showDateRangePicker(
context: context,
firstDate: DateTime.utc(2015),
lastDate: now,
initialDateRange: filter.from != null && filter.to != null
? DateTimeRange(start: filter.from!, end: filter.to!)
: null,
helpText: 'Период',
);
if (picked != null) {
ref.read(transactionsControllerProvider.notifier).setFilter(
(f) => f.copyWith(from: () => picked.start, to: () => picked.end),
);
}
}
void _clearDateRange() {
ref.read(transactionsControllerProvider.notifier).setFilter(
(f) => f.copyWith(from: () => null, to: () => null),
);
}
void _showDetail(TransactionOut t) {
final accountNames = ref.read(accountNamesProvider);
final categoryNames = ref.read(categoryNamesProvider);
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => _TransactionDetailSheet(
transaction: t,
accountNames: accountNames,
categoryNames: categoryNames,
),
);
}
@override
Widget build(BuildContext context) {
final state = ref.watch(transactionsControllerProvider);
final controllerFilter = ref.watch(transactionsControllerProvider.notifier).filter;
final categoryNames = ref.watch(categoryNamesProvider);
final accounts = ref.watch(accountsProvider).valueOrNull ?? const <AccountOut>[];
final categories = ref.watch(categoriesListProvider).valueOrNull ?? const <CategoryOut>[];
return Scaffold(
appBar: AppBar(title: const Text('Операции')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _searchController,
decoration: const InputDecoration(
prefixIcon: Icon(Icons.search),
hintText: 'Поиск по плательщику или комментарию',
isDense: true,
border: OutlineInputBorder(),
),
onChanged: _onSearchChanged,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
InputChip(
avatar: const Icon(Icons.date_range, size: 18),
label: Text(
controllerFilter.from != null && controllerFilter.to != null
? '${ruDate(controllerFilter.from!)} ${ruDate(controllerFilter.to!)}'
: 'Период',
),
onPressed: _pickDateRange,
onDeleted: controllerFilter.from != null ? _clearDateRange : null,
),
SizedBox(
width: 180,
child: DropdownButtonFormField<int?>(
initialValue: controllerFilter.accountId,
isDense: true,
decoration: const InputDecoration(labelText: 'Счёт', isDense: true),
items: [
const DropdownMenuItem(value: null, child: Text('Все счета')),
for (final a in accounts) DropdownMenuItem(value: a.id, child: Text(a.name)),
],
onChanged: (v) => ref.read(transactionsControllerProvider.notifier).setFilter(
(f) => f.copyWith(accountId: () => v),
),
),
),
SizedBox(
width: 180,
child: DropdownButtonFormField<int?>(
initialValue: controllerFilter.categoryId,
isDense: true,
decoration: const InputDecoration(labelText: 'Категория', isDense: true),
items: [
const DropdownMenuItem(value: null, child: Text('Все категории')),
for (final c in categories) DropdownMenuItem(value: c.id, child: Text(c.name)),
],
onChanged: (v) => ref.read(transactionsControllerProvider.notifier).setFilter(
(f) => f.copyWith(categoryId: () => v),
),
),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
ChoiceChip(
label: const Text('Все типы'),
selected: controllerFilter.flowType == null,
onSelected: (_) => ref.read(transactionsControllerProvider.notifier).setFilter(
(f) => f.copyWith(flowType: () => null),
),
),
for (final ft in _flowTypes)
ChoiceChip(
label: Text(_flowTypeLabel(ft)),
selected: controllerFilter.flowType == ft,
onSelected: (_) => ref.read(transactionsControllerProvider.notifier).setFilter(
(f) => f.copyWith(flowType: () => ft),
),
),
],
),
],
),
),
const Divider(height: 1),
Expanded(child: _buildList(state, categoryNames)),
],
),
);
}
Widget _buildList(TransactionsState state, Map<int, String> categoryNames) {
if (state.loading && state.items.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (state.error != null && state.items.isEmpty) {
return 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('${state.error}', textAlign: TextAlign.center),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: () => ref.read(transactionsControllerProvider.notifier).refresh(),
child: const Text('Повторить'),
),
],
),
),
);
}
if (state.items.isEmpty) {
return const EmptyState(
icon: Icons.receipt_long_outlined,
message: 'Операций не найдено — попробуйте изменить фильтры или выполните синхронизацию.',
);
}
return RefreshIndicator(
onRefresh: () => ref.read(transactionsControllerProvider.notifier).refresh(),
child: ListView.builder(
controller: _scrollController,
itemCount: state.items.length + 1,
itemBuilder: (context, index) {
if (index == state.items.length) {
if (!state.hasMore) return const SizedBox(height: 24);
return Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: state.loadingMore
? const CircularProgressIndicator()
: TextButton(
onPressed: () => ref.read(transactionsControllerProvider.notifier).loadMore(),
child: const Text('Ещё'),
),
),
);
}
final t = state.items[index];
return TransactionRow(
transaction: t,
categoryName: t.categoryId != null ? categoryNames[t.categoryId] : null,
onTap: () => _showDetail(t),
);
},
),
);
}
}
class _TransactionDetailSheet extends StatelessWidget {
const _TransactionDetailSheet({
required this.transaction,
required this.accountNames,
required this.categoryNames,
});
final TransactionOut transaction;
final Map<int, String> accountNames;
final Map<int, String> categoryNames;
@override
Widget build(BuildContext context) {
final t = transaction;
final rows = <(String, String)>[
('Дата', ruDate(t.date)),
('Плательщик', t.payee ?? ''),
if (t.payeeCanonical != null && t.payeeCanonical != t.payee) ('Канонический', t.payeeCanonical!),
('Комментарий', t.comment ?? ''),
('Категория', t.categoryId != null ? (categoryNames[t.categoryId] ?? '#${t.categoryId}') : 'Без категории'),
('Тип', _flowTypeLabel(t.flowType)),
if (t.outcome != '0')
('Списание', '${MoneyText.format(t.outcome, t.outcomeCurrency ?? 'RUB')}'
'${t.outcomeRub != null ? ' (${MoneyText.format(t.outcomeRub!, 'RUB')})' : ''}'),
if (t.income != '0')
('Зачисление', '${MoneyText.format(t.income, t.incomeCurrency ?? 'RUB')}'
'${t.incomeRub != null ? ' (${MoneyText.format(t.incomeRub!, 'RUB')})' : ''}'),
if (t.outcomeAccountId != null)
('Счёт списания', accountNames[t.outcomeAccountId] ?? '#${t.outcomeAccountId}'),
if (t.incomeAccountId != null)
('Счёт зачисления', accountNames[t.incomeAccountId] ?? '#${t.incomeAccountId}'),
if (t.mcc != null) ('MCC', '${t.mcc}'),
('Удержание (hold)', t.hold ? 'да' : 'нет'),
('Разовая трата', t.isOneOff ? 'да' : 'нет'),
if (t.tags.isNotEmpty) ('Теги', t.tags.map((id) => categoryNames[id] ?? '#$id').join(', ')),
if (t.tripId != null) ('Поездка', '#${t.tripId}'),
('Источник (id)', t.sourceId),
];
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Операция', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
for (final (label, value) in rows)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 140, child: Text(label, style: Theme.of(context).textTheme.bodySmall)),
Expanded(child: Text(value)),
],
),
),
],
),
),
);
}
}
+9
View File
@@ -0,0 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const ProviderScope(child: FinTrackerApp()));
}
+55
View File
@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'core/auth/auth_controller.dart';
import 'features/accounts/accounts_page.dart';
import 'features/cashflow/cashflow_page.dart';
import 'features/categories/categories_page.dart';
import 'features/home/home_page.dart';
import 'features/login/login_page.dart';
import 'features/rules/rules_page.dart';
import 'features/settings/settings_page.dart';
import 'features/shell/app_shell.dart';
import 'features/sync/sync_page.dart';
import 'features/transactions/transactions_page.dart';
final routerProvider = Provider<GoRouter>((ref) {
final auth = ValueNotifier<AuthState>(ref.read(authControllerProvider));
ref.listen(authControllerProvider, (_, next) => auth.value = next);
ref.onDispose(auth.dispose);
return GoRouter(
initialLocation: '/',
refreshListenable: auth,
redirect: (context, state) {
final status = auth.value.status;
final atLogin = state.matchedLocation == '/login';
return switch (status) {
AuthStatus.unknown => atLogin ? null : '/login',
AuthStatus.signedOut => atLogin ? null : '/login',
AuthStatus.signedIn => atLogin ? '/' : null,
};
},
routes: [
GoRoute(path: '/login', builder: (_, _) => const LoginPage()),
ShellRoute(
builder: (context, state, child) =>
AppShell(location: state.matchedLocation, child: child),
routes: [
GoRoute(path: '/', builder: (_, _) => const HomePage()),
GoRoute(path: '/accounts', builder: (_, _) => const AccountsPage()),
GoRoute(path: '/cashflow', builder: (_, _) => const CashflowPage()),
GoRoute(
path: '/categories',
builder: (_, state) => CategoriesPage(initialMonth: state.uri.queryParameters['month']),
),
GoRoute(path: '/transactions', builder: (_, _) => const TransactionsPage()),
GoRoute(path: '/rules', builder: (_, _) => const RulesPage()),
GoRoute(path: '/sync', builder: (_, _) => const SyncPage()),
GoRoute(path: '/settings', builder: (_, _) => const SettingsPage()),
],
),
],
);
});