Compare commits

...
10 Commits
Author SHA1 Message Date
Dmitry 0708e8e88b chore(just): рецепт dev, build-web не собирает ассеты drift
ci / backend (push) Canceled after 0s
ci / backend-image (push) Canceled after 0s
ci / app (push) Canceled after 0s
ci / app-build-linux (push) Canceled after 0s
ci / app-build-windows (push) Canceled after 0s
just dev поднимает БД, миграции, сборку web и API на :8000. build-web больше не зависит от app-web-assets (sqlite3.wasm и drift_worker.js закоммичены и пересобираются вручную), в docs/ai/offline-cache.md это отражено.
2026-09-20 10:50:40 +03:00
Dmitry a41a9ca198 style(app): текст в выборе скоупа по центру ячейки 2026-09-20 10:46:42 +03:00
Dmitry 521924b3a4 fix(app): перезагрузка страницы возвращает на открытый экран, а не на главную
Пока сессия восстанавливается, адрес запоминается в /login?from=…, после входа роутер возвращает на него. Принимаются только пути внутри приложения.
2026-09-20 10:46:40 +03:00
Dmitry 2645e56197 feat(app): календарь выплат сеткой по месяцам
Месячная сетка с дивидендами, купонами, амортизациями и погашениями по дням, выплаченными и ожидаемыми, с сводкой за месяц и списком выплат выбранного дня. Раскладка без прокрутки страницы: высота ячеек подстраивается под окно, на широком экране список справа. Прежний список остался вторым режимом.
2026-09-20 10:46:38 +03:00
Dmitry 6891028074 fix(app): подписи осей и легенда графика стоимости
Крайние подписи оси Y (414.6K поверх 400K) скрыты, числа в русской краткой форме, подписи оси X не обрезаются справа. Добавлена легенда: сплошная линия — стоимость, пунктир — вложено нетто.
2026-09-20 10:46:35 +03:00
Dmitry 617682351b feat(app): график цены — периоды, режимы, бенчмарки и мои сделки
Переключатель периода (7д…все и свой диапазон), режимы «Цена ₽ / ₽ + НКД / %», наложение бенчмарков в процентах (у ценового индекса пометка «без дивидендов»), изменение за период, заливка, подписи min/max, курсор с подсказкой и точки покупок и продаж. Ось X по датам, подписи осей без наложений.
2026-09-20 10:46:33 +03:00
Dmitry 6a15371960 fix(income): погашения облигаций попадают в календарь выплат
У облигаций из ленты операций не было ни даты погашения, ни номинала, а погашение строилось именно от них. moex_payouts дописывает оба поля из паспорта бумаги (только пустые). Аналитика берёт дату погашения из графика номинала, если поля нет, считает сумму по номиналу накануне, а последний шаг амортизации не учитывает дважды.
2026-09-20 10:46:27 +03:00
Dmitry d2df86ce33 feat(moex): история бумаг с листинга и цены индексов для бенчмарков
Окно бэкфилла начинается с history_from доски, а не с первой покупки: график цены показывает историю бумаги целиком. Для активных бенчмарков source=moex синк создаёт instrument и качает индекс с его доски (IMOEX и RGBITR на SNDX, MCFTR на RTSI). Миграция засевает IMOEX, MCFTR и RGBITR; тесты чистят таблицы перед каждым тестом, чтобы сид не попадал в первый из них.
2026-09-20 10:46:24 +03:00
Dmitry 05affeea29 docs: состояние проекта — расписание синков, порядок шагов метрик, отключённые счета, ручные события 2026-09-19 22:14:27 +03:00
Dmitry 322c60a359 style(app): остальные экраны под новый визуальный язык и форматирование
Доходы, ребалансировка, налоги, импорт, цели, здоровье данных, правила, транзакции, категории, cashflow, pending: новые виджеты и токены темы, dart format. Тесты подстроены под новые модели.
2026-09-19 22:14:27 +03:00
78 changed files with 4546 additions and 1262 deletions
+8 -5
View File
@@ -102,7 +102,7 @@ just revision "msg" # новая alembic-миграция из изме
Импорт CSV на живую базу ещё не запускался — это на живых данных не проверено, только на Импорт CSV на живую базу ещё не запускался — это на живых данных не проверено, только на
фикстурах. фикстурах.
Фаза 3 завершена и прогнана на живых отчётах (399 backend-тестов, 27 flutter): Фаза 3 завершена и прогнана на живых отчётах:
- `sources/reports/` — протокол `ReportParser` + три парсера: Сбер (HTML), ВТБ (xlsx), - `sources/reports/` — протокол `ReportParser` + три парсера: Сбер (HTML), ВТБ (xlsx),
универсальный CSV (экспорт Snowball). `registry.py` выбирает парсер по содержимому файла, универсальный CSV (экспорт Snowball). `registry.py` выбирает парсер по содержимому файла,
@@ -149,9 +149,9 @@ just revision "msg" # новая alembic-миграция из изме
source_id)`, так что строки обоих источников сосуществуют, и приоритет можно поменять без source_id)`, так что строки обоих источников сосуществуют, и приоритет можно поменять без
ресинка истории. Амортизация из MOEX идёт не в `corporate_action`, а в ресинка истории. Амортизация из MOEX идёт не в `corporate_action`, а в
`bond_nominal_schedule` — этим типом безраздельно владеет `ledger/corporate_actions.py`; `bond_nominal_schedule` — этим типом безраздельно владеет `ledger/corporate_actions.py`;
оба источника зарегистрированы (`tinvest_events`, `moex_payouts`), но не добавлены в оба источника зарегистрированы (`tinvest_events`, `moex_payouts`) и стоят в
`worker/jobs.default_schedule()` — как и сами `tinvest`/`moex`, они туда не входили и до `worker/jobs.default_schedule()` вместе с `tinvest`/`moex`; источники с `needs="tinvest_token"`
этой фазы, расписание синков за её рамками; не попадают в расписание, пока токен не задан;
- `analytics/income.py``metric_income_monthly` (факт, только confirmed) и - `analytics/income.py``metric_income_monthly` (факт, только confirmed) и
`metric_income_calendar` (прошлое и прогноз) с явным `basis` (`paid` / `announced` / `metric_income_calendar` (прошлое и прогноз) с явным `basis` (`paid` / `announced` /
`history`) на каждой строке — три источника числа никогда не смешиваются в одно; `history`) на каждой строке — три источника числа никогда не смешиваются в одно;
@@ -165,7 +165,10 @@ just revision "msg" # новая alembic-миграция из изме
- `analytics/benchmarks.py` — TWR индекса на сетке дат портфеля; `kind` (`price` vs - `analytics/benchmarks.py` — TWR индекса на сетке дат портфеля; `kind` (`price` vs
`total_return`) выставляется наружу, а не скрывается: сравнение с ценовым IMOEX без `total_return`) выставляется наружу, а не скрывается: сравнение с ценовым IMOEX без
дивидендов льстит портфелю на несколько % годовых, это осознанный выбор пользователя, дивидендов льстит портфелю на несколько % годовых, это осознанный выбор пользователя,
какой индекс сравнивать; какой индекс сравнивать. Цены индексов тянет `sources/moex` (`_ensure_benchmark_instruments`
создаёт `instrument` для активной строки `benchmark`; доска берётся из ISS — MCFTR на RTSI,
IMOEX и RGBITR на SNDX); миграция `f3a91c7d5e28` засевает IMOEX, MCFTR (оба `is_default`)
и RGBITR. История бумаг и индексов качается с листинга, а не с первой покупки;
- `analytics/goals.py` + `api/routers/goals.py` — прогресс цели и требуемый ежемесячный - `analytics/goals.py` + `api/routers/goals.py` — прогресс цели и требуемый ежемесячный
взнос по trailing XIRR; взнос по trailing XIRR;
- четыре новых шага в `register_steps`: `benchmarks` после `returns` (общая сетка дат), - четыре новых шага в `register_steps`: `benchmarks` после `returns` (общая сетка дат),
+18
View File
@@ -0,0 +1,18 @@
/// «12,3 тыс», «1,2 млн» — for axis labels and cells too narrow for a full amount.
///
/// fl_chart's own short form («414.6K») is English and rounds a step of 50 000 to the same
/// label twice; this one keeps the Russian suffixes and one decimal below a hundred.
String compactNumber(double value) {
final n = value.abs();
String scaled(double x, String suffix) {
final s = x >= 100
? x.round().toString()
: x.toStringAsFixed(1).replaceAll(RegExp(r'\.0$'), '');
return '${s.replaceAll('.', ',')} $suffix';
}
final sign = value < 0 ? '' : '';
if (n >= 1e6) return '$sign${scaled(n / 1e6, 'млн')}';
if (n >= 1e3) return '$sign${scaled(n / 1e3, 'тыс')}';
return '$sign${n.round()}';
}
+22
View File
@@ -42,6 +42,28 @@ String ruMonthYear(DateTime d) {
/// `'сен 2026'`, for compact chart/table labels. /// `'сен 2026'`, for compact chart/table labels.
String ruMonthYearShort(DateTime d) => '${_monthsShort[d.month - 1]} ${d.year}'; String ruMonthYearShort(DateTime d) => '${_monthsShort[d.month - 1]} ${d.year}';
const _monthsAbbr = [
'янв.',
'февр.',
'мар.',
'апр.',
'мая',
'июн.',
'июл.',
'авг.',
'сент.',
'окт.',
'нояб.',
'дек.',
];
/// `'20 сент. 25'`, the range line above a chart.
String ruDayMonthYearShort(DateTime d) =>
'${d.day} ${_monthsAbbr[d.month - 1]} ${(d.year % 100).toString().padLeft(2, '0')}';
/// `'16 сен'`, for chart labels over a span of weeks where the month alone repeats.
String ruDayMonthShort(DateTime d) => '${d.day} ${_monthsShort[d.month - 1]}';
/// `'2026-09'`, the `month` query parameter format the API expects. /// `'2026-09'`, the `month` query parameter format the API expects.
String monthKey(DateTime d) => String monthKey(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}'; '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}';
+3
View File
@@ -34,6 +34,9 @@ class ScopeSelector extends ConsumerWidget {
for (final s in rows) for (final s in rows)
DropdownMenuItem( DropdownMenuItem(
value: s.scope, value: s.scope,
// the button is as wide as the longest name; a shorter one would sit at its
// left edge otherwise
alignment: Alignment.center,
child: Text(s.name, overflow: TextOverflow.ellipsis), child: Text(s.name, overflow: TextOverflow.ellipsis),
), ),
], ],
+53 -14
View File
@@ -61,7 +61,10 @@ class CashflowPage extends ConsumerWidget {
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
reverse: true, reverse: true,
child: SizedBox( child: SizedBox(
width: (rows.length * 56).toDouble().clamp(320, double.infinity), width: (rows.length * 56).toDouble().clamp(
320,
double.infinity,
),
child: _CashflowChart(rows: rows), child: _CashflowChart(rows: rows),
), ),
), ),
@@ -102,7 +105,11 @@ class _LegendDot extends StatelessWidget {
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Container(width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 6), const SizedBox(width: 6),
Text(label, style: Theme.of(context).textTheme.bodySmall), Text(label, style: Theme.of(context).textTheme.bodySmall),
], ],
@@ -119,7 +126,11 @@ class _CashflowChart extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maxY = rows.fold<double>( final maxY = rows.fold<double>(
0, 0,
(m, r) => [m, _d(r.incomeRub), _d(r.expenseRub)].reduce((a, b) => a > b ? a : b), (m, r) => [
m,
_d(r.incomeRub),
_d(r.expenseRub),
].reduce((a, b) => a > b ? a : b),
); );
return BarChart( return BarChart(
BarChartData( BarChartData(
@@ -127,9 +138,15 @@ class _CashflowChart extends StatelessWidget {
gridData: const FlGridData(drawVerticalLine: false), gridData: const FlGridData(drawVerticalLine: false),
borderData: FlBorderData(show: false), borderData: FlBorderData(show: false),
titlesData: FlTitlesData( titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), topTitles: const AxisTitles(
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), sideTitles: SideTitles(showTitles: false),
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), ),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles( bottomTitles: AxisTitles(
sideTitles: SideTitles( sideTitles: SideTitles(
showTitles: true, showTitles: true,
@@ -138,7 +155,10 @@ class _CashflowChart extends StatelessWidget {
if (i < 0 || i >= rows.length) return const SizedBox.shrink(); if (i < 0 || i >= rows.length) return const SizedBox.shrink();
return Padding( return Padding(
padding: const EdgeInsets.only(top: 6), padding: const EdgeInsets.only(top: 6),
child: Text(ruMonthYearShort(rows[i].month), style: const TextStyle(fontSize: 10)), child: Text(
ruMonthYearShort(rows[i].month),
style: const TextStyle(fontSize: 10),
),
); );
}, },
), ),
@@ -160,8 +180,16 @@ class _CashflowChart extends StatelessWidget {
BarChartGroupData( BarChartGroupData(
x: i, x: i,
barRods: [ barRods: [
BarChartRodData(toY: _d(rows[i].incomeRub), color: _incomeColor, width: 8), BarChartRodData(
BarChartRodData(toY: _d(rows[i].expenseRub), color: _expenseColor, width: 8), toY: _d(rows[i].incomeRub),
color: _incomeColor,
width: 8,
),
BarChartRodData(
toY: _d(rows[i].expenseRub),
color: _expenseColor,
width: 8,
),
], ],
barsSpace: 2, barsSpace: 2,
), ),
@@ -188,13 +216,20 @@ class _Table extends StatelessWidget {
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),
DataColumn(label: Text('В сбережения', style: headerStyle), numeric: true), DataColumn(
DataColumn(label: Text('Норма сбер., %', style: headerStyle), numeric: true), label: Text('В сбережения', style: headerStyle),
numeric: true,
),
DataColumn(
label: Text('Норма сбер., %', style: headerStyle),
numeric: true,
),
], ],
rows: [ rows: [
for (final r in rows.reversed) for (final r in rows.reversed)
DataRow( DataRow(
onSelectChanged: (_) => context.go('/categories?month=${monthKey(r.month)}'), onSelectChanged: (_) =>
context.go('/categories?month=${monthKey(r.month)}'),
cells: [ cells: [
DataCell(Text(ruMonthYearShort(r.month))), DataCell(Text(ruMonthYearShort(r.month))),
DataCell(MoneyText(r.incomeRub, currency: 'RUB')), DataCell(MoneyText(r.incomeRub, currency: 'RUB')),
@@ -202,9 +237,13 @@ class _Table extends StatelessWidget {
DataCell(MoneyText(r.baselineRub, currency: 'RUB')), DataCell(MoneyText(r.baselineRub, currency: 'RUB')),
DataCell(MoneyText(r.oneOffRub, currency: 'RUB')), DataCell(MoneyText(r.oneOffRub, currency: 'RUB')),
DataCell(MoneyText(r.savingsTransferRub, currency: 'RUB')), DataCell(MoneyText(r.savingsTransferRub, currency: 'RUB')),
DataCell(Text(r.savingsRate == null DataCell(
Text(
r.savingsRate == null
? '' ? ''
: '${(_d(r.savingsRate!) * 100).toStringAsFixed(1)}%')), : '${(_d(r.savingsRate!) * 100).toStringAsFixed(1)}%',
),
),
], ],
), ),
], ],
+11 -4
View File
@@ -5,7 +5,14 @@ import '../../core/api/api_client.dart';
import '../../core/cache/cached.dart'; import '../../core/cache/cached.dart';
/// The last 24 months, oldest first (as the API returns them). See `docs/ai/offline-cache.md`. /// The last 24 months, oldest first (as the API returns them). See `docs/ai/offline-cache.md`.
final cashflowMonthly24Provider = FutureProvider.autoDispose<Cached<List<CashFlowMonth>>>((ref) async { final cashflowMonthly24Provider =
final r = await ref.watch(apiProvider).getCashflowApi().cashflowMonthly(months: 24); FutureProvider.autoDispose<Cached<List<CashFlowMonth>>>((ref) async {
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); final r = await ref
}); .watch(apiProvider)
.getCashflowApi()
.cashflowMonthly(months: 24);
return Cached(
r.data ?? const [],
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
});
@@ -18,20 +18,29 @@ class _Group {
final String rootName; final String rootName;
final List<SpendingRow> rows = []; final List<SpendingRow> rows = [];
Decimal get total => rows.fold(Decimal.zero, (a, r) => a + Decimal.parse(r.amountRub)); Decimal get total =>
rows.fold(Decimal.zero, (a, r) => a + Decimal.parse(r.amountRub));
} }
List<_Group> _group(List<SpendingRow> rows) { List<_Group> _group(List<SpendingRow> rows) {
final byRoot = <int?, _Group>{}; final byRoot = <int?, _Group>{};
for (final r in rows) { for (final r in rows) {
final key = r.categoryId == null ? null : (r.rootCategoryId ?? r.categoryId); final key = r.categoryId == null
? null
: (r.rootCategoryId ?? r.categoryId);
final group = byRoot.putIfAbsent( final group = byRoot.putIfAbsent(
key, key,
() => _Group(key, key == null ? 'Без категории' : (r.rootCategoryName ?? r.categoryName ?? '')), () => _Group(
key,
key == null
? 'Без категории'
: (r.rootCategoryName ?? r.categoryName ?? ''),
),
); );
group.rows.add(r); group.rows.add(r);
} }
final groups = byRoot.values.toList()..sort((a, b) => b.total.compareTo(a.total)); final groups = byRoot.values.toList()
..sort((a, b) => b.total.compareTo(a.total));
return groups; return groups;
} }
@@ -51,7 +60,9 @@ class _CategoriesPageState extends ConsumerState<CategoriesPage> {
super.initState(); super.initState();
final initial = widget.initialMonth; final initial = widget.initialMonth;
if (initial != null) { if (initial != null) {
Future.microtask(() => ref.read(selectedSpendingMonthProvider.notifier).state = initial); Future.microtask(
() => ref.read(selectedSpendingMonthProvider.notifier).state = initial,
);
} }
} }
@@ -66,8 +77,9 @@ class _CategoriesPageState extends ConsumerState<CategoriesPage> {
initialDatePickerMode: DatePickerMode.year, initialDatePickerMode: DatePickerMode.year,
); );
if (picked != null) { if (picked != null) {
ref.read(selectedSpendingMonthProvider.notifier).state = ref.read(selectedSpendingMonthProvider.notifier).state = monthKey(
monthKey(DateTime(picked.year, picked.month)); DateTime(picked.year, picked.month),
);
} }
} }
@@ -134,7 +146,10 @@ class _CategoriesPageState extends ConsumerState<CategoriesPage> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text('Всего расходов', style: Theme.of(context).textTheme.bodyLarge), Text(
'Всего расходов',
style: Theme.of(context).textTheme.bodyLarge,
),
MoneyText( MoneyText(
total.toString(), total.toString(),
currency: 'RUB', currency: 'RUB',
@@ -144,7 +159,8 @@ class _CategoriesPageState extends ConsumerState<CategoriesPage> {
), ),
), ),
const Divider(), const Divider(),
for (final g in groups) _GroupTile(group: g, maxTotal: maxTotal), for (final g in groups)
_GroupTile(group: g, maxTotal: maxTotal),
], ],
); );
}, },
@@ -163,7 +179,8 @@ class _GroupTile extends StatelessWidget {
final Decimal maxTotal; final Decimal maxTotal;
bool get _flat => bool get _flat =>
group.rows.length == 1 && (group.rootId == null || group.rows.first.categoryId == group.rootId); group.rows.length == 1 &&
(group.rootId == null || group.rows.first.categoryId == group.rootId);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -176,16 +193,26 @@ class _GroupTile extends StatelessWidget {
); );
} }
final children = [...group.rows] final children = [...group.rows]
..sort((a, b) => Decimal.parse(b.amountRub).compareTo(Decimal.parse(a.amountRub))); ..sort(
(a, b) =>
Decimal.parse(b.amountRub).compareTo(Decimal.parse(a.amountRub)),
);
return ExpansionTile( return ExpansionTile(
tilePadding: EdgeInsets.zero, tilePadding: EdgeInsets.zero,
title: _CategoryBar(name: group.rootName, amount: group.total, maxAmount: maxTotal, bold: true), title: _CategoryBar(
name: group.rootName,
amount: group.total,
maxAmount: maxTotal,
bold: true,
),
children: [ children: [
for (final r in children) for (final r in children)
Padding( Padding(
padding: const EdgeInsets.only(left: 16), padding: const EdgeInsets.only(left: 16),
child: _CategoryBar( child: _CategoryBar(
name: r.categoryId == group.rootId ? 'Без подкатегории' : (r.categoryName ?? ''), name: r.categoryId == group.rootId
? 'Без подкатегории'
: (r.categoryName ?? ''),
amount: Decimal.parse(r.amountRub), amount: Decimal.parse(r.amountRub),
maxAmount: group.total, maxAmount: group.total,
bold: false, bold: false,
@@ -240,7 +267,9 @@ class _CategoryBar extends StatelessWidget {
Container( Container(
height: 6, height: 6,
width: constraints.maxWidth * ratio, width: constraints.maxWidth * ratio,
color: bold ? ChartColors.expense : ChartColors.expense.withValues(alpha: 0.6), color: bold
? ChartColors.expense
: ChartColors.expense.withValues(alpha: 0.6),
), ),
], ],
), ),
+14 -6
View File
@@ -7,7 +7,9 @@ import '../../core/utils/ru_date.dart';
/// Flat ZenMoney tag tree — the client nests it by `parent_id` where needed. Shared with /// Flat ZenMoney tag tree — the client nests it by `parent_id` where needed. Shared with
/// Транзакции; not wrapped in `Cached` since it is a lookup, not a screen's own primary read. /// Транзакции; not wrapped in `Cached` since it is a lookup, not a screen's own primary read.
final categoriesListProvider = FutureProvider.autoDispose<List<CategoryOut>>((ref) async { final categoriesListProvider = FutureProvider.autoDispose<List<CategoryOut>>((
ref,
) async {
final r = await ref.watch(apiProvider).getCategoriesApi().categoriesList(); final r = await ref.watch(apiProvider).getCategoriesApi().categoriesList();
return r.data ?? const []; return r.data ?? const [];
}); });
@@ -20,11 +22,17 @@ final categoryNamesProvider = Provider.autoDispose<Map<int, String>>((ref) {
/// Spending by category for one month (`YYYY-MM`); null means "the latest month". See /// Spending by category for one month (`YYYY-MM`); null means "the latest month". See
/// `docs/ai/offline-cache.md`. /// `docs/ai/offline-cache.md`.
final spendingProvider = final spendingProvider = FutureProvider.autoDispose
FutureProvider.autoDispose.family<Cached<List<SpendingRow>>, String?>((ref, month) async { .family<Cached<List<SpendingRow>>, String?>((ref, month) async {
final r = await ref.watch(apiProvider).getCashflowApi().cashflowSpending(month: month); final r = await ref
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); .watch(apiProvider)
}); .getCashflowApi()
.cashflowSpending(month: month);
return Cached(
r.data ?? const [],
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
});
/// The month currently selected on the Категории screen, `YYYY-MM`. /// The month currently selected on the Категории screen, `YYYY-MM`.
final selectedSpendingMonthProvider = StateProvider.autoDispose<String>( final selectedSpendingMonthProvider = StateProvider.autoDispose<String>(
+4 -1
View File
@@ -142,7 +142,10 @@ class GoalsApi {
} }
Future<Goal> patch(int id, Map<String, dynamic> changes) async { Future<Goal> patch(int id, Map<String, dynamic> changes) async {
final r = await _dio.patch<Map<String, dynamic>>('$_base/$id', data: changes); final r = await _dio.patch<Map<String, dynamic>>(
'$_base/$id',
data: changes,
);
return Goal.fromJson(r.data ?? const {}); return Goal.fromJson(r.data ?? const {});
} }
+21 -7
View File
@@ -19,7 +19,8 @@ const goalBasisLabels = {
const goalBasisDescriptions = { const goalBasisDescriptions = {
'xirr': 'Прогноз построен на фактической доходности портфеля (XIRR).', 'xirr': 'Прогноз построен на фактической доходности портфеля (XIRR).',
'contribution': 'Прогноз построен на регулярных взносах, без учёта доходности.', 'contribution':
'Прогноз построен на регулярных взносах, без учёта доходности.',
'none': 'Данных для прогноза нет: ни доходности, ни истории взносов.', 'none': 'Данных для прогноза нет: ни доходности, ни истории взносов.',
}; };
@@ -58,12 +59,15 @@ class GoalCard extends ConsumerWidget {
Text( Text(
[ [
'цель ${MoneyText.format(goal.targetAmount, goal.currency)}', 'цель ${MoneyText.format(goal.targetAmount, goal.currency)}',
if (goal.targetDate != null) 'к ${ruDate(goal.targetDate!)}', if (goal.targetDate != null)
'к ${ruDate(goal.targetDate!)}',
if (goal.monthlyContribution != null) if (goal.monthlyContribution != null)
'взнос ${MoneyText.format(goal.monthlyContribution!, goal.currency)}/мес', 'взнос ${MoneyText.format(goal.monthlyContribution!, goal.currency)}/мес',
if (goal.archived) 'в архиве', if (goal.archived) 'в архиве',
].join(' · '), ].join(' · '),
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
), ),
], ],
), ),
@@ -86,7 +90,8 @@ class GoalCard extends ConsumerWidget {
AsyncValueView( AsyncValueView(
value: progress, value: progress,
onRetry: () => ref.invalidate(goalProgressProvider(goal.id)), onRetry: () => ref.invalidate(goalProgressProvider(goal.id)),
data: (cached) => GoalProgressView(goal: goal, progress: cached.data), data: (cached) =>
GoalProgressView(goal: goal, progress: cached.data),
), ),
], ],
), ),
@@ -98,7 +103,11 @@ class GoalCard extends ConsumerWidget {
/// The progress body, split out of [GoalCard] so it can be rendered (and tested) without a /// The progress body, split out of [GoalCard] so it can be rendered (and tested) without a
/// provider container. /// provider container.
class GoalProgressView extends StatelessWidget { class GoalProgressView extends StatelessWidget {
const GoalProgressView({required this.goal, required this.progress, super.key}); const GoalProgressView({
required this.goal,
required this.progress,
super.key,
});
final Goal goal; final Goal goal;
final GoalProgress progress; final GoalProgress progress;
@@ -150,7 +159,9 @@ class GoalProgressView extends StatelessWidget {
Icon( Icon(
unreachable ? Icons.trending_flat : Icons.flag_outlined, unreachable ? Icons.trending_flat : Icons.flag_outlined,
size: 18, size: 18,
color: unreachable ? theme.colorScheme.error : theme.colorScheme.primary, color: unreachable
? theme.colorScheme.error
: theme.colorScheme.primary,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
@@ -192,7 +203,10 @@ class GoalProgressView extends StatelessWidget {
), ),
), ),
if (progress.asOf != null) if (progress.asOf != null)
Text('на ${ruDate(progress.asOf!)}', style: theme.textTheme.bodySmall), Text(
'на ${ruDate(progress.asOf!)}',
style: theme.textTheme.bodySmall,
),
], ],
), ),
], ],
+34 -14
View File
@@ -20,9 +20,12 @@ class GoalEditDialog extends ConsumerStatefulWidget {
class _GoalEditDialogState extends ConsumerState<GoalEditDialog> { class _GoalEditDialogState extends ConsumerState<GoalEditDialog> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
late final _name = TextEditingController(text: widget.initial?.name ?? ''); late final _name = TextEditingController(text: widget.initial?.name ?? '');
late final _amount = TextEditingController(text: widget.initial?.targetAmount ?? ''); late final _amount = TextEditingController(
late final _contribution = text: widget.initial?.targetAmount ?? '',
TextEditingController(text: widget.initial?.monthlyContribution ?? ''); );
late final _contribution = TextEditingController(
text: widget.initial?.monthlyContribution ?? '',
);
late final _note = TextEditingController(text: widget.initial?.note ?? ''); late final _note = TextEditingController(text: widget.initial?.note ?? '');
late String _scope = widget.initial?.scope ?? 'all'; late String _scope = widget.initial?.scope ?? 'all';
late DateTime? _targetDate = widget.initial?.targetDate; late DateTime? _targetDate = widget.initial?.targetDate;
@@ -71,7 +74,8 @@ class _GoalEditDialogState extends ConsumerState<GoalEditDialog> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
initialValue: scopes.any((s) => s.scope == _scope) || _scope == 'all' initialValue:
scopes.any((s) => s.scope == _scope) || _scope == 'all'
? _scope ? _scope
: 'all', : 'all',
decoration: const InputDecoration(labelText: 'Что считаем'), decoration: const InputDecoration(labelText: 'Что считаем'),
@@ -86,14 +90,20 @@ class _GoalEditDialogState extends ConsumerState<GoalEditDialog> {
const SizedBox(height: 12), const SizedBox(height: 12),
TextFormField( TextFormField(
controller: _amount, controller: _amount,
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(
decoration: const InputDecoration(labelText: 'Целевая сумма, ₽'), decimal: true,
),
decoration: const InputDecoration(
labelText: 'Целевая сумма, ₽',
),
validator: _decimalValidator, validator: _decimalValidator,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextFormField( TextFormField(
controller: _contribution, controller: _contribution,
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Ежемесячный взнос, ₽', labelText: 'Ежемесячный взнос, ₽',
helperText: 'необязательно', helperText: 'необязательно',
@@ -104,20 +114,25 @@ class _GoalEditDialogState extends ConsumerState<GoalEditDialog> {
Row( Row(
children: [ children: [
Expanded( Expanded(
child: Text(_targetDate == null child: Text(
_targetDate == null
? 'Целевая дата не задана' ? 'Целевая дата не задана'
: 'Целевая дата: ${ruDate(_targetDate!)}'), : 'Целевая дата: ${ruDate(_targetDate!)}',
),
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
final now = DateTime.now(); final now = DateTime.now();
final picked = await showDatePicker( final picked = await showDatePicker(
context: context, context: context,
initialDate: _targetDate ?? DateTime(now.year + 3, now.month, now.day), initialDate:
_targetDate ??
DateTime(now.year + 3, now.month, now.day),
firstDate: DateTime(now.year - 1), firstDate: DateTime(now.year - 1),
lastDate: DateTime(now.year + 50), lastDate: DateTime(now.year + 50),
); );
if (picked != null) setState(() => _targetDate = picked); if (picked != null)
setState(() => _targetDate = picked);
}, },
child: const Text('Выбрать'), child: const Text('Выбрать'),
), ),
@@ -147,11 +162,15 @@ class _GoalEditDialogState extends ConsumerState<GoalEditDialog> {
), ),
), ),
actions: [ actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Отмена')), TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Отмена'),
),
FilledButton( FilledButton(
onPressed: () { onPressed: () {
if (!(_formKey.currentState?.validate() ?? false)) return; if (!(_formKey.currentState?.validate() ?? false)) return;
Navigator.of(context).pop(Goal( Navigator.of(context).pop(
Goal(
id: widget.initial?.id ?? 0, id: widget.initial?.id ?? 0,
name: _name.text.trim(), name: _name.text.trim(),
scope: _scope, scope: _scope,
@@ -161,7 +180,8 @@ class _GoalEditDialogState extends ConsumerState<GoalEditDialog> {
monthlyContribution: _decimal(_contribution.text), monthlyContribution: _decimal(_contribution.text),
note: _note.text.trim().isEmpty ? null : _note.text.trim(), note: _note.text.trim().isEmpty ? null : _note.text.trim(),
archived: _archived, archived: _archived,
)); ),
);
}, },
child: const Text('Сохранить'), child: const Text('Сохранить'),
), ),
+21 -7
View File
@@ -23,7 +23,8 @@ class _GoalsPageState extends ConsumerState<GoalsPage> {
bool _showArchived = false; bool _showArchived = false;
void _snack(String message) => void _snack(String message) =>
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
Future<void> _create() async { Future<void> _create() async {
final goal = await showDialog<Goal>( final goal = await showDialog<Goal>(
@@ -62,8 +63,14 @@ class _GoalsPageState extends ConsumerState<GoalsPage> {
title: const Text('Удалить цель?'), title: const Text('Удалить цель?'),
content: Text('«${goal.name}» будет удалена безвозвратно.'), content: Text('«${goal.name}» будет удалена безвозвратно.'),
actions: [ actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Отмена')), TextButton(
FilledButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Удалить')), onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Удалить'),
),
], ],
), ),
); );
@@ -82,10 +89,13 @@ class _GoalsPageState extends ConsumerState<GoalsPage> {
final goals = ref.watch(goalsProvider); final goals = ref.watch(goalsProvider);
// Every card's own progress fetch counts toward the one banner too — a fresh list with a // Every card's own progress fetch counts toward the one banner too — a fresh list with a
// stale progress card is still an offline dashboard, just not a visibly empty one. // stale progress card is still an offline dashboard, just not a visibly empty one.
final goalIds = [for (final g in goals.valueOrNull?.data ?? const <Goal>[]) g.id]; final goalIds = [
for (final g in goals.valueOrNull?.data ?? const <Goal>[]) g.id,
];
final stale = oldestFetch([ final stale = oldestFetch([
goals.valueOrNull?.fetchedAt, goals.valueOrNull?.fetchedAt,
for (final id in goalIds) ref.watch(goalProgressProvider(id)).valueOrNull?.fetchedAt, for (final id in goalIds)
ref.watch(goalProgressProvider(id)).valueOrNull?.fetchedAt,
]); ]);
return Scaffold( return Scaffold(
@@ -94,7 +104,9 @@ class _GoalsPageState extends ConsumerState<GoalsPage> {
actions: [ actions: [
IconButton( IconButton(
tooltip: _showArchived ? 'Скрыть архив' : 'Показать архив', tooltip: _showArchived ? 'Скрыть архив' : 'Показать архив',
icon: Icon(_showArchived ? Icons.inventory_2 : Icons.inventory_2_outlined), icon: Icon(
_showArchived ? Icons.inventory_2 : Icons.inventory_2_outlined,
),
onPressed: () => setState(() => _showArchived = !_showArchived), onPressed: () => setState(() => _showArchived = !_showArchived),
), ),
IconButton( IconButton(
@@ -116,7 +128,9 @@ class _GoalsPageState extends ConsumerState<GoalsPage> {
onRetry: () => ref.invalidate(goalsProvider), onRetry: () => ref.invalidate(goalsProvider),
data: (cached) { data: (cached) {
final all = cached.data; final all = cached.data;
final rows = _showArchived ? all : all.where((g) => !g.archived).toList(); final rows = _showArchived
? all
: all.where((g) => !g.archived).toList();
if (rows.isEmpty) { if (rows.isEmpty) {
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
+9 -6
View File
@@ -4,18 +4,21 @@ import '../../core/api/api_client.dart';
import '../../core/cache/cached.dart'; import '../../core/cache/cached.dart';
import 'data/goals_api.dart'; import 'data/goals_api.dart';
final goalsApiProvider = Provider<GoalsApi>((ref) => GoalsApi(ref.watch(apiProvider).dio)); final goalsApiProvider = Provider<GoalsApi>(
(ref) => GoalsApi(ref.watch(apiProvider).dio),
);
/// See `docs/ai/offline-cache.md`. /// See `docs/ai/offline-cache.md`.
final goalsProvider = final goalsProvider = FutureProvider.autoDispose<Cached<List<Goal>>>(
FutureProvider.autoDispose<Cached<List<Goal>>>((ref) => ref.watch(goalsApiProvider).list()); (ref) => ref.watch(goalsApiProvider).list(),
);
/// Progress is computed server-side and refetched per goal — the client never projects /// Progress is computed server-side and refetched per goal — the client never projects
/// anything itself. /// anything itself.
final goalProgressProvider = final goalProgressProvider = FutureProvider.autoDispose
FutureProvider.autoDispose.family<Cached<GoalProgress>, int>((ref, id) async { .family<Cached<GoalProgress>, int>((ref, id) async {
return ref.watch(goalsApiProvider).progress(id); return ref.watch(goalsApiProvider).progress(id);
}); });
/// After any create/patch/delete the list and every progress card are refetched: the /// After any create/patch/delete the list and every progress card are refetched: the
/// numbers are recomputed on the server, not patched in the app. /// numbers are recomputed on the server, not patched in the app.
@@ -52,14 +52,19 @@ class _DataQualityTile extends StatelessWidget {
margin: const EdgeInsets.only(top: 4, right: 10), margin: const EdgeInsets.only(top: 4, right: 10),
width: 10, width: 10,
height: 10, height: 10,
decoration: BoxDecoration(color: severityColor(row.severity), shape: BoxShape.circle), decoration: BoxDecoration(
color: severityColor(row.severity),
shape: BoxShape.circle,
),
), ),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('${row.checkName} (${row.count})', Text(
style: Theme.of(context).textTheme.titleSmall), '${row.checkName} (${row.count})',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 2), const SizedBox(height: 2),
Text(row.detail), Text(row.detail),
], ],
+46 -14
View File
@@ -16,12 +16,16 @@ import 'data_quality_list.dart';
final _dateFmt = DateFormat('dd.MM.yyyy HH:mm'); final _dateFmt = DateFormat('dd.MM.yyyy HH:mm');
final syncStatusProvider = FutureProvider.autoDispose<List<SourceStatus>>((ref) async { final syncStatusProvider = FutureProvider.autoDispose<List<SourceStatus>>((
ref,
) async {
final r = await ref.watch(apiProvider).getSyncApi().syncStatus(); final r = await ref.watch(apiProvider).getSyncApi().syncStatus();
return r.data ?? const []; return r.data ?? const [];
}); });
final syncRunsProvider = FutureProvider.autoDispose<List<SyncRunOut>>((ref) async { final syncRunsProvider = FutureProvider.autoDispose<List<SyncRunOut>>((
ref,
) async {
final r = await ref.watch(apiProvider).getSyncApi().syncRuns(limit: 20); final r = await ref.watch(apiProvider).getSyncApi().syncRuns(limit: 20);
return r.data ?? const []; return r.data ?? const [];
}); });
@@ -74,7 +78,8 @@ class _HealthPageState extends ConsumerState<HealthPage> {
await ref.read(apiProvider).getSyncApi().syncTrigger(source_: source); await ref.read(apiProvider).getSyncApi().syncTrigger(source_: source);
} on DioException catch (e) { } on DioException catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e)))); ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(problemMessage(e))));
} finally { } finally {
ref.invalidate(syncStatusProvider); ref.invalidate(syncStatusProvider);
ref.invalidate(syncRunsProvider); ref.invalidate(syncRunsProvider);
@@ -114,7 +119,10 @@ class _HealthPageState extends ConsumerState<HealthPage> {
body: TabBarView( body: TabBarView(
children: [ children: [
_SourcesTab(onRefresh: _refreshAll, onTrigger: _trigger), _SourcesTab(onRefresh: _refreshAll, onTrigger: _trigger),
_QualityTab(dataQuality: dataQuality, onRefresh: () async => ref.invalidate(dataQualityProvider)), _QualityTab(
dataQuality: dataQuality,
onRefresh: () async => ref.invalidate(dataQualityProvider),
),
], ],
), ),
), ),
@@ -149,17 +157,26 @@ class _SourcesTab extends ConsumerWidget {
message: 'Источники данных ещё не подключены (фаза 1: ZenMoney, ЦБ).', message: 'Источники данных ещё не подключены (фаза 1: ZenMoney, ЦБ).',
) )
: Column( : Column(
children: [for (final s in rows) _SourceCard(source: s, onTrigger: onTrigger)], children: [
for (final s in rows)
_SourceCard(source: s, onTrigger: onTrigger),
],
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text('Последние запуски', style: Theme.of(context).textTheme.titleMedium), Text(
'Последние запуски',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8), const SizedBox(height: 8),
AsyncValueView( AsyncValueView(
value: runs, value: runs,
onRetry: () => ref.invalidate(syncRunsProvider), onRetry: () => ref.invalidate(syncRunsProvider),
data: (rows) => rows.isEmpty data: (rows) => rows.isEmpty
? const EmptyState(icon: Icons.history, message: 'Запусков ещё не было.') ? const EmptyState(
icon: Icons.history,
message: 'Запусков ещё не было.',
)
: Column(children: [for (final r in rows) _RunTile(run: r)]), : Column(children: [for (final r in rows) _RunTile(run: r)]),
), ),
], ],
@@ -217,10 +234,14 @@ class _SourceCard extends StatelessWidget {
), ),
subtitle: Padding( subtitle: Padding(
padding: const EdgeInsets.only(top: 4), padding: const EdgeInsets.only(top: 4),
child: Text([ child: Text(
if (source.lastRunAt != null) 'запуск ${_dateFmt.format(source.lastRunAt!.toLocal())}', [
if (source.cursor != null) 'курсор ${_shortCursor(source.cursor!)}', if (source.lastRunAt != null)
].join(' · ')), 'запуск ${_dateFmt.format(source.lastRunAt!.toLocal())}',
if (source.cursor != null)
'курсор ${_shortCursor(source.cursor!)}',
].join(' · '),
),
), ),
trailing: IconButton( trailing: IconButton(
tooltip: 'Запустить синхронизацию', tooltip: 'Запустить синхронизацию',
@@ -277,7 +298,12 @@ class _RunTile extends StatelessWidget {
children: [ children: [
content, content,
const SizedBox(height: 8), const SizedBox(height: 8),
Text(run.error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), Text(
run.error!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
], ],
), ),
), ),
@@ -295,7 +321,10 @@ class _RunTile extends StatelessWidget {
_runTitle(context), _runTitle(context),
Padding( Padding(
padding: const EdgeInsets.only(top: 2, bottom: 6), padding: const EdgeInsets.only(top: 2, bottom: 6),
child: Text(subtitle, style: Theme.of(context).textTheme.bodySmall), child: Text(
subtitle,
style: Theme.of(context).textTheme.bodySmall,
),
), ),
content, content,
], ],
@@ -317,7 +346,10 @@ class _RunTile extends StatelessWidget {
Widget _statusChip(BuildContext context, RunStatus? status) { Widget _statusChip(BuildContext context, RunStatus? status) {
if (status == null) { if (status == null) {
return const Chip(visualDensity: VisualDensity.compact, label: Text('нет данных')); return const Chip(
visualDensity: VisualDensity.compact,
label: Text('нет данных'),
);
} }
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final (label, color) = switch (status) { final (label, color) = switch (status) {
+35 -17
View File
@@ -17,14 +17,20 @@ import '../../pending/data/pending_api.dart';
/// A candidate account for an import whose `account_id` the server could not resolve. /// A candidate account for an import whose `account_id` the server could not resolve.
class AccountSuggestion { class AccountSuggestion {
const AccountSuggestion({required this.id, required this.name, this.broker, this.sourceId}); const AccountSuggestion({
required this.id,
required this.name,
this.broker,
this.sourceId,
});
final int id; final int id;
final String name; final String name;
final String? broker; final String? broker;
final String? sourceId; final String? sourceId;
static AccountSuggestion fromJson(Map<String, dynamic> json) => AccountSuggestion( static AccountSuggestion fromJson(Map<String, dynamic> json) =>
AccountSuggestion(
id: asInt(json['id'])!, id: asInt(json['id'])!,
name: asString(json['name']) ?? '#${json['id']}', name: asString(json['name']) ?? '#${json['id']}',
broker: asString(json['broker']), broker: asString(json['broker']),
@@ -294,20 +300,24 @@ class ImportPreview {
accountExternalId: asString(json['account_external_id']), accountExternalId: asString(json['account_external_id']),
accountId: asInt(json['account_id']), accountId: asInt(json['account_id']),
accountName: asString(json['account_name']), accountName: asString(json['account_name']),
accountSuggestions: accountSuggestions: asList(json['account_suggestions'])
asList(json['account_suggestions']).map(AccountSuggestion.fromJson).toList(), .map(AccountSuggestion.fromJson)
.toList(),
periodFrom: asDate(json['period_from']), periodFrom: asDate(json['period_from']),
periodTo: asDate(json['period_to']), periodTo: asDate(json['period_to']),
uploadedAt: asDate(json['uploaded_at']), uploadedAt: asDate(json['uploaded_at']),
committedAt: asDate(json['committed_at']), committedAt: asDate(json['committed_at']),
counts: ImportCounts.fromJson(asMap(json['counts'])), counts: ImportCounts.fromJson(asMap(json['counts'])),
pendingInstruments: pendingInstruments: asList(json['pending_instruments'])
asList(json['pending_instruments']).map(PendingInstrument.fromJson).toList(), .map(PendingInstrument.fromJson)
.toList(),
reconciliation: Reconciliation.fromJson(asMap(json['reconciliation'])), reconciliation: Reconciliation.fromJson(asMap(json['reconciliation'])),
warnings: json['warnings'] is List warnings: json['warnings'] is List
? (json['warnings'] as List).map((e) => e.toString()).toList() ? (json['warnings'] as List).map((e) => e.toString()).toList()
: const [], : const [],
sampleEvents: asList(json['sample_events']).map(SampleEvent.fromJson).toList(), sampleEvents: asList(json['sample_events'])
.map(SampleEvent.fromJson)
.toList(),
); );
} }
@@ -366,14 +376,14 @@ class ImportsApi {
static const _base = '/api/v1/imports'; static const _base = '/api/v1/imports';
Future<List<ImportPreview>> list({int limit = 50, int offset = 0, String? status}) async { Future<List<ImportPreview>> list({
int limit = 50,
int offset = 0,
String? status,
}) async {
final r = await _dio.get<List<dynamic>>( final r = await _dio.get<List<dynamic>>(
_base, _base,
queryParameters: { queryParameters: {'limit': limit, 'offset': offset, 'status': ?status},
'limit': limit,
'offset': offset,
'status': ?status,
},
); );
return (r.data ?? const []) return (r.data ?? const [])
.map((e) => ImportPreview.fromJson(Map<String, dynamic>.from(e as Map))) .map((e) => ImportPreview.fromJson(Map<String, dynamic>.from(e as Map)))
@@ -385,7 +395,11 @@ class ImportsApi {
return ImportPreview.fromJson(r.data!); return ImportPreview.fromJson(r.data!);
} }
Future<ImportPreview> upload(PickedReport report, {int? accountId, String? parser}) async { Future<ImportPreview> upload(
PickedReport report, {
int? accountId,
String? parser,
}) async {
final bytes = report.bytes; final bytes = report.bytes;
final form = FormData.fromMap({ final form = FormData.fromMap({
'file': bytes != null 'file': bytes != null
@@ -404,11 +418,14 @@ class ImportsApi {
bool confirmDuplicates = false, bool confirmDuplicates = false,
bool dryRun = false, bool dryRun = false,
}) async { }) async {
final r = await _dio.post<Map<String, dynamic>>('$_base/$id/commit', data: { final r = await _dio.post<Map<String, dynamic>>(
'$_base/$id/commit',
data: {
'account_id': ?accountId, 'account_id': ?accountId,
'confirm_duplicates': confirmDuplicates, 'confirm_duplicates': confirmDuplicates,
'dry_run': dryRun, 'dry_run': dryRun,
}); },
);
return ImportResult.fromJson(r.data ?? const {}); return ImportResult.fromJson(r.data ?? const {});
} }
@@ -438,4 +455,5 @@ List<Map<String, dynamic>> asList(Object? v) => v is List
? v.whereType<Map>().map((e) => Map<String, dynamic>.from(e)).toList() ? v.whereType<Map>().map((e) => Map<String, dynamic>.from(e)).toList()
: const []; : const [];
Map<String, dynamic>? asMap(Object? v) => v is Map ? Map<String, dynamic>.from(v) : null; Map<String, dynamic>? asMap(Object? v) =>
v is Map ? Map<String, dynamic>.from(v) : null;
@@ -34,4 +34,6 @@ class FilePickerReportPicker implements ReportPicker {
} }
} }
final reportPickerProvider = Provider<ReportPicker>((ref) => const FilePickerReportPicker()); final reportPickerProvider = Provider<ReportPicker>(
(ref) => const FilePickerReportPicker(),
);
+128 -48
View File
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import '../../core/utils/ru_date.dart'; import '../../core/utils/ru_date.dart';
import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/async_value_view.dart';
import '../accounts/account_create_dialog.dart';
import '../portfolio/labels.dart' show eventKindLabels, formatQty; import '../portfolio/labels.dart' show eventKindLabels, formatQty;
import 'data/imports_api.dart'; import 'data/imports_api.dart';
import 'labels.dart'; import 'labels.dart';
@@ -31,14 +32,17 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
ImportResult? _result; ImportResult? _result;
void _snack(String message) => void _snack(String message) =>
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
Future<void> _commit(ImportPreview preview) async { Future<void> _commit(ImportPreview preview) async {
final accountId = preview.accountId ?? _accountChoice; final accountId = preview.accountId ?? _accountChoice;
if (accountId == null) return; if (accountId == null) return;
setState(() => _busy = true); setState(() => _busy = true);
try { try {
final result = await ref.read(importsApiProvider).commit( final result = await ref
.read(importsApiProvider)
.commit(
preview.id, preview.id,
accountId: preview.accountId == null ? accountId : null, accountId: preview.accountId == null ? accountId : null,
confirmDuplicates: _confirmDuplicates, confirmDuplicates: _confirmDuplicates,
@@ -49,8 +53,10 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
invalidateLedgerDependents(ref); invalidateLedgerDependents(ref);
ref.invalidate(importsListProvider); ref.invalidate(importsListProvider);
ref.invalidate(importPreviewProvider(preview.id)); ref.invalidate(importPreviewProvider(preview.id));
_snack('Импортировано: создано ${result.eventsCreated}, ' _snack(
'обновлено ${result.eventsUpdated}, пропущено ${result.eventsSkipped}'); 'Импортировано: создано ${result.eventsCreated}, '
'обновлено ${result.eventsUpdated}, пропущено ${result.eventsSkipped}',
);
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
_snack(importErrorMessage(e)); _snack(importErrorMessage(e));
@@ -64,13 +70,19 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Удалить импорт?'), title: const Text('Удалить импорт?'),
content: Text('Файл «${preview.filename}» и разобранные строки будут удалены. ' content: Text(
'События в леджере не создавались, так что удалять нечего.'), 'Файл «${preview.filename}» и разобранные строки будут удалены. '
'События в леджере не создавались, так что удалять нечего.',
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(false), child: const Text('Отмена')), onPressed: () => Navigator.of(context).pop(false),
child: const Text('Отмена'),
),
FilledButton( FilledButton(
onPressed: () => Navigator.of(context).pop(true), child: const Text('Удалить')), onPressed: () => Navigator.of(context).pop(true),
child: const Text('Удалить'),
),
], ],
), ),
); );
@@ -104,7 +116,8 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
IconButton( IconButton(
tooltip: 'Обновить', tooltip: 'Обновить',
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
onPressed: () => ref.invalidate(importPreviewProvider(widget.importId)), onPressed: () =>
ref.invalidate(importPreviewProvider(widget.importId)),
), ),
], ],
), ),
@@ -131,7 +144,8 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
icon: Icons.copy_all_outlined, icon: Icons.copy_all_outlined,
color: theme.colorScheme.secondary, color: theme.colorScheme.secondary,
title: 'Этот файл уже загружали', title: 'Этот файл уже загружали',
body: 'Показан ранее созданный импорт №${p.duplicateOfId}. ' body:
'Показан ранее созданный импорт №${p.duplicateOfId}. '
'Повторная загрузка не создаёт новых событий.', 'Повторная загрузка не создаёт новых событий.',
), ),
], ],
@@ -172,8 +186,10 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
children: [ children: [
Text('Строки отчёта', style: theme.textTheme.titleMedium), Text('Строки отчёта', style: theme.textTheme.titleMedium),
const SizedBox(height: 4), const SizedBox(height: 4),
Text('Первые ${p.sampleEvents.length} из ${p.counts.lines}', Text(
style: theme.textTheme.bodySmall), 'Первые ${p.sampleEvents.length} из ${p.counts.lines}',
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 8), const SizedBox(height: 8),
SampleEventsTable(events: p.sampleEvents), SampleEventsTable(events: p.sampleEvents),
], ],
@@ -217,13 +233,17 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
_kv( _kv(
'Счёт', 'Счёт',
p.accountName ?? p.accountName ??
(p.accountId != null ? '#${p.accountId}' : 'не определён по отчёту'), (p.accountId != null
? '#${p.accountId}'
: 'не определён по отчёту'),
), ),
if (p.accountExternalId != null) _kv('Счёт в отчёте', p.accountExternalId!), if (p.accountExternalId != null)
_kv('Счёт в отчёте', p.accountExternalId!),
if (p.parserName != null) if (p.parserName != null)
_kv('Парсер', '${p.parserName} v${p.parserVersion ?? '1'}'), _kv('Парсер', '${p.parserName} v${p.parserVersion ?? '1'}'),
if (p.sizeBytes != null) _kv('Размер', formatBytes(p.sizeBytes)), if (p.sizeBytes != null) _kv('Размер', formatBytes(p.sizeBytes)),
if (p.uploadedAt != null) _kv('Загружен', ruDate(p.uploadedAt!.toLocal())), if (p.uploadedAt != null)
_kv('Загружен', ruDate(p.uploadedAt!.toLocal())),
if (p.committedAt != null) if (p.committedAt != null)
_kv('Импортирован', ruDate(p.committedAt!.toLocal())), _kv('Импортирован', ruDate(p.committedAt!.toLocal())),
], ],
@@ -232,6 +252,22 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
); );
} }
Future<void> _createAccountFromReport(ImportPreview p) async {
final created = await showAccountCreateDialog(
context,
broker: brokerFromImportKey(p.broker),
sourceId: p.accountExternalId,
name: p.accountExternalId == null
? null
: '${brokerLabel(p.broker)} ${p.accountExternalId}',
);
if (created == null || !mounted) return;
// refetch first: the dropdown only accepts a value that is among the server's suggestions
ref.invalidate(importPreviewProvider(widget.importId));
await ref.read(importPreviewProvider(widget.importId).future);
if (mounted) setState(() => _accountChoice = created.id);
}
Widget _accountPicker(ImportPreview p) { Widget _accountPicker(ImportPreview p) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Card( return Card(
@@ -243,12 +279,18 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
children: [ children: [
Row( Row(
children: [ children: [
Icon(Icons.account_balance_outlined, color: theme.colorScheme.onErrorContainer), Icon(
Icons.account_balance_outlined,
color: theme.colorScheme.onErrorContainer,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text('Счёт не определён', child: Text(
style: theme.textTheme.titleMedium 'Счёт не определён',
?.copyWith(color: theme.colorScheme.onErrorContainer)), style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.onErrorContainer,
),
),
), ),
], ],
), ),
@@ -256,10 +298,17 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
Text( Text(
p.accountSuggestions.isEmpty p.accountSuggestions.isEmpty
? 'В отчёте номер счёта ${p.accountExternalId ?? ''}, но подходящего ' ? 'В отчёте номер счёта ${p.accountExternalId ?? ''}, но подходящего '
'счёта в базе нет. Заведите счёт, затем вернитесь сюда.' 'счёта в базе нет. Создайте счёт по данным из отчёта.'
: 'Выберите счёт, в который писать события. Без него импорт недоступен.', : 'Выберите счёт, в который писать события, или создайте новый. '
'Без счёта импорт недоступен.',
style: TextStyle(color: theme.colorScheme.onErrorContainer), style: TextStyle(color: theme.colorScheme.onErrorContainer),
), ),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: () => _createAccountFromReport(p),
icon: const Icon(Icons.add),
label: const Text('Создать счёт из отчёта'),
),
if (p.accountSuggestions.isNotEmpty) ...[ if (p.accountSuggestions.isNotEmpty) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
DropdownButtonFormField<int>( DropdownButtonFormField<int>(
@@ -274,11 +323,13 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
for (final s in p.accountSuggestions) for (final s in p.accountSuggestions)
DropdownMenuItem( DropdownMenuItem(
value: s.id, value: s.id,
child: Text([ child: Text(
[
s.name, s.name,
if (s.broker != null) brokerLabel(s.broker), if (s.broker != null) brokerLabel(s.broker),
if (s.sourceId != null) s.sourceId!, if (s.sourceId != null) s.sourceId!,
].join(' · ')), ].join(' · '),
),
), ),
], ],
onChanged: (v) => setState(() => _accountChoice = v), onChanged: (v) => setState(() => _accountChoice = v),
@@ -308,14 +359,25 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
_stat('Строк', c.lines), _stat('Строк', c.lines),
_stat('Событий', c.eventsTotal), _stat('Событий', c.eventsTotal),
_stat('Новых', c.eventsNew, color: Colors.green), _stat('Новых', c.eventsNew, color: Colors.green),
_stat('Дубликатов', c.eventsDuplicate, _stat(
color: c.eventsDuplicate > 0 ? theme.colorScheme.secondary : null, 'Дубликатов',
hint: 'Уже есть в леджере: будут обновлены, а не продублированы'), c.eventsDuplicate,
_stat('Shadow', c.eventsShadow, color: c.eventsDuplicate > 0
hint: 'Не первичный источник — в аналитику не идут'), ? theme.colorScheme.secondary
_stat('Ждут инструмента', c.eventsPending, : null,
hint: 'Уже есть в леджере: будут обновлены, а не продублированы',
),
_stat(
'Shadow',
c.eventsShadow,
hint: 'Не первичный источник — в аналитику не идут',
),
_stat(
'Ждут инструмента',
c.eventsPending,
color: c.eventsPending > 0 ? theme.colorScheme.error : null, color: c.eventsPending > 0 ? theme.colorScheme.error : null,
hint: 'Инструмент не распознан, события останутся в статусе pending'), hint: 'Инструмент не распознан, события останутся в статусе pending',
),
], ],
), ),
if (c.byKind.isNotEmpty) ...[ if (c.byKind.isNotEmpty) ...[
@@ -329,7 +391,9 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
for (final e in c.byKind.entries) for (final e in c.byKind.entries)
Chip( Chip(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
label: Text('${eventKindLabels[e.key] ?? e.key}: ${e.value}'), label: Text(
'${eventKindLabels[e.key] ?? e.key}: ${e.value}',
),
), ),
], ],
), ),
@@ -348,8 +412,10 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Нераспознанные инструменты (${p.pendingInstruments.length})', Text(
style: theme.textTheme.titleMedium), 'Нераспознанные инструменты (${p.pendingInstruments.length})',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'Сервер не угадывает инструмент. Пока вы не привяжете эти строки вручную, ' 'Сервер не угадывает инструмент. Пока вы не привяжете эти строки вручную, '
@@ -362,11 +428,14 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
dense: true, dense: true,
title: Text(pi.title), title: Text(pi.title),
subtitle: Text([ subtitle: Text(
[
if (pi.isin != null) 'ISIN ${pi.isin}', if (pi.isin != null) 'ISIN ${pi.isin}',
'встречается ${pi.occurrences}', 'встречается ${pi.occurrences}',
if (pi.sampleQuantity != null) 'кол-во ${formatQty(pi.sampleQuantity!)}', if (pi.sampleQuantity != null)
].join(' · ')), 'кол-во ${formatQty(pi.sampleQuantity!)}',
].join(' · '),
),
), ),
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
@@ -392,7 +461,10 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
children: [ children: [
Row( Row(
children: [ children: [
Icon(Icons.warning_amber_outlined, color: theme.colorScheme.tertiary), Icon(
Icons.warning_amber_outlined,
color: theme.colorScheme.tertiary,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text('Предупреждения', style: theme.textTheme.titleMedium), Text('Предупреждения', style: theme.textTheme.titleMedium),
], ],
@@ -434,7 +506,8 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
} }
Widget _actions(ImportPreview p, int? accountId) { Widget _actions(ImportPreview p, int? accountId) {
final canCommit = !p.isCommitted && !p.isFailed && accountId != null && !_busy; final canCommit =
!p.isCommitted && !p.isFailed && accountId != null && !_busy;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -444,8 +517,10 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
value: _confirmDuplicates, value: _confirmDuplicates,
onChanged: (v) => setState(() => _confirmDuplicates = v ?? false), onChanged: (v) => setState(() => _confirmDuplicates = v ?? false),
title: const Text('Обновлять дубликаты'), title: const Text('Обновлять дубликаты'),
subtitle: Text('Перезаписать ${p.counts.eventsDuplicate} событий, ' subtitle: Text(
'которые уже есть в леджере'), 'Перезаписать ${p.counts.eventsDuplicate} событий, '
'которые уже есть в леджере',
),
), ),
Wrap( Wrap(
spacing: 12, spacing: 12,
@@ -455,7 +530,10 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
onPressed: canCommit ? () => _commit(p) : null, onPressed: canCommit ? () => _commit(p) : null,
icon: _busy icon: _busy
? const SizedBox( ? const SizedBox(
width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.playlist_add_check), : const Icon(Icons.playlist_add_check),
label: Text(p.isCommitted ? 'Уже импортирован' : 'Импортировать'), label: Text(p.isCommitted ? 'Уже импортирован' : 'Импортировать'),
), ),
@@ -497,11 +575,11 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(title, Text(
style: Theme.of(context) title,
.textTheme style: Theme.of(context).textTheme.titleSmall
.titleSmall ?.copyWith(color: color),
?.copyWith(color: color)), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(body), Text(body),
], ],
@@ -525,8 +603,10 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(label, style: theme.textTheme.bodySmall), Text(label, style: theme.textTheme.bodySmall),
Text('$value', Text(
style: theme.textTheme.titleLarge?.copyWith(color: color)), '$value',
style: theme.textTheme.titleLarge?.copyWith(color: color),
),
], ],
), ),
); );
+33 -12
View File
@@ -35,7 +35,9 @@ class _ImportsPageState extends ConsumerState<ImportsPage> {
if (!mounted) return; if (!mounted) return;
ref.invalidate(importsListProvider); ref.invalidate(importsListProvider);
if (preview.duplicateOfId != null) { if (preview.duplicateOfId != null) {
_snack('Этот файл уже загружали — открыт существующий импорт №${preview.id}'); _snack(
'Этот файл уже загружали — открыт существующий импорт №${preview.id}',
);
} }
context.go('/imports/${preview.id}'); context.go('/imports/${preview.id}');
} catch (e) { } catch (e) {
@@ -47,7 +49,8 @@ class _ImportsPageState extends ConsumerState<ImportsPage> {
} }
void _snack(String message) => void _snack(String message) =>
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -72,7 +75,10 @@ class _ImportsPageState extends ConsumerState<ImportsPage> {
onPressed: _uploading ? null : _upload, onPressed: _uploading ? null : _upload,
icon: _uploading icon: _uploading
? const SizedBox( ? const SizedBox(
width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.upload_file), : const Icon(Icons.upload_file),
label: Text(_uploading ? 'Загрузка…' : 'Загрузить отчёт'), label: Text(_uploading ? 'Загрузка…' : 'Загрузить отчёт'),
), ),
@@ -91,7 +97,8 @@ class _ImportsPageState extends ConsumerState<ImportsPage> {
leading: const Icon(Icons.help_outline), leading: const Icon(Icons.help_outline),
title: Text('Нераспознанных инструментов: $pendingCount'), title: Text('Нераспознанных инструментов: $pendingCount'),
subtitle: const Text( subtitle: const Text(
'События по ним ждут в статусе pending и не попадают в аналитику.'), 'События по ним ждут в статусе pending и не попадают в аналитику.',
),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () => context.go('/instruments/pending'), onTap: () => context.go('/instruments/pending'),
), ),
@@ -106,11 +113,16 @@ class _ImportsPageState extends ConsumerState<ImportsPage> {
padding: EdgeInsets.only(top: 48), padding: EdgeInsets.only(top: 48),
child: EmptyState( child: EmptyState(
icon: Icons.upload_file_outlined, icon: Icons.upload_file_outlined,
message: 'Отчёты ещё не загружались.\n' message:
'Отчёты ещё не загружались.\n'
'Поддерживаются выгрузки Сбера и ВТБ (HTML, XLSX) и CSV.', 'Поддерживаются выгрузки Сбера и ВТБ (HTML, XLSX) и CSV.',
), ),
) )
: Column(children: [for (final row in rows) _ImportCard(item: row)]), : Column(
children: [
for (final row in rows) _ImportCard(item: row),
],
),
), ),
], ],
), ),
@@ -164,8 +176,12 @@ class _ImportCard extends StatelessWidget {
: 'период не определён'; : 'период не определён';
final subtitle = [ final subtitle = [
period, period,
item.accountName ?? (item.accountId != null ? 'счёт #${item.accountId}' : 'счёт не найден'), item.accountName ??
if (item.uploadedAt != null) 'загружен ${ruDate(item.uploadedAt!.toLocal())}', (item.accountId != null
? 'счёт #${item.accountId}'
: 'счёт не найден'),
if (item.uploadedAt != null)
'загружен ${ruDate(item.uploadedAt!.toLocal())}',
if (item.sizeBytes != null) formatBytes(item.sizeBytes), if (item.sizeBytes != null) formatBytes(item.sizeBytes),
].join(' · '); ].join(' · ');
@@ -174,7 +190,9 @@ class _ImportCard extends StatelessWidget {
onTap: () => context.go('/imports/${item.id}'), onTap: () => context.go('/imports/${item.id}'),
title: Row( title: Row(
children: [ children: [
Flexible(child: Text(item.filename, overflow: TextOverflow.ellipsis)), Flexible(
child: Text(item.filename, overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 8), const SizedBox(width: 8),
parseStatusChip(context, item.parseStatus), parseStatusChip(context, item.parseStatus),
], ],
@@ -198,9 +216,12 @@ class _ImportCard extends StatelessWidget {
if (item.isFailed && item.error != null) if (item.isFailed && item.error != null)
Padding( Padding(
padding: const EdgeInsets.only(top: 4), padding: const EdgeInsets.only(top: 4),
child: Text(item.error!, child: Text(
style: theme.textTheme.bodySmall item.error!,
?.copyWith(color: theme.colorScheme.error)), style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
), ),
], ],
), ),
+9 -5
View File
@@ -6,13 +6,17 @@ import '../home/providers.dart';
import '../portfolio/providers.dart'; import '../portfolio/providers.dart';
import 'data/imports_api.dart'; import 'data/imports_api.dart';
final importsApiProvider = Provider<ImportsApi>((ref) => ImportsApi(ref.watch(apiProvider).dio)); final importsApiProvider = Provider<ImportsApi>(
(ref) => ImportsApi(ref.watch(apiProvider).dio),
);
/// The list on `/imports`. Not auto-disposed by status: the filter is a separate provider so /// The list on `/imports`. Not auto-disposed by status: the filter is a separate provider so
/// changing it refetches without rebuilding the page state. /// changing it refetches without rebuilding the page state.
final importsStatusFilterProvider = StateProvider<String?>((ref) => null); final importsStatusFilterProvider = StateProvider<String?>((ref) => null);
final importsListProvider = FutureProvider.autoDispose<List<ImportPreview>>((ref) async { final importsListProvider = FutureProvider.autoDispose<List<ImportPreview>>((
ref,
) async {
final status = ref.watch(importsStatusFilterProvider); final status = ref.watch(importsStatusFilterProvider);
return ref.watch(importsApiProvider).list(status: status); return ref.watch(importsApiProvider).list(status: status);
}); });
@@ -20,10 +24,10 @@ final importsListProvider = FutureProvider.autoDispose<List<ImportPreview>>((ref
/// A single import's preview. For an uncommitted import the server recomputes counts and /// A single import's preview. For an uncommitted import the server recomputes counts and
/// reconciliation on every read, so this is deliberately re-fetched rather than cached from /// reconciliation on every read, so this is deliberately re-fetched rather than cached from
/// the list response. /// the list response.
final importPreviewProvider = final importPreviewProvider = FutureProvider.autoDispose
FutureProvider.autoDispose.family<ImportPreview, int>((ref, id) async { .family<ImportPreview, int>((ref, id) async {
return ref.watch(importsApiProvider).get(id); return ref.watch(importsApiProvider).get(id);
}); });
/// Everything whose numbers change once events land in (or move inside) the ledger: /// Everything whose numbers change once events land in (or move inside) the ledger:
/// portfolio, holdings, allocation, the value series, the dashboard and the event list. /// portfolio, holdings, allocation, the value series, the dashboard and the event list.
@@ -23,7 +23,10 @@ class ReconciliationCard extends StatelessWidget {
if (reconciliation.isEmpty) { if (reconciliation.isEmpty) {
return Card( return Card(
child: ListTile( child: ListTile(
leading: Icon(Icons.remove_circle_outline, color: theme.colorScheme.outline), leading: Icon(
Icons.remove_circle_outline,
color: theme.colorScheme.outline,
),
title: Text(title), title: Text(title),
subtitle: const Text('В отчёте нет остатков для сверки'), subtitle: const Text('В отчёте нет остатков для сверки'),
), ),
@@ -67,7 +70,12 @@ class ReconciliationCard extends StatelessWidget {
Text('Позиции', style: theme.textTheme.titleSmall), Text('Позиции', style: theme.textTheme.titleSmall),
const SizedBox(height: 4), const SizedBox(height: 4),
_ScrollableTable( _ScrollableTable(
columns: const ['Позиция', 'Из отчёта', 'Из леджера', 'Расхождение'], columns: const [
'Позиция',
'Из отчёта',
'Из леджера',
'Расхождение',
],
rows: [ rows: [
for (final p in reconciliation.positions) for (final p in reconciliation.positions)
_Row( _Row(
@@ -87,7 +95,12 @@ class ReconciliationCard extends StatelessWidget {
Text('Денежные остатки', style: theme.textTheme.titleSmall), Text('Денежные остатки', style: theme.textTheme.titleSmall),
const SizedBox(height: 4), const SizedBox(height: 4),
_ScrollableTable( _ScrollableTable(
columns: const ['Валюта', 'Из отчёта', 'Из леджера', 'Расхождение'], columns: const [
'Валюта',
'Из отчёта',
'Из леджера',
'Расхождение',
],
rows: [ rows: [
for (final c in reconciliation.cash) for (final c in reconciliation.cash)
_Row( _Row(
@@ -145,16 +158,20 @@ class _ScrollableTable extends StatelessWidget {
for (final r in rows) for (final r in rows)
DataRow( DataRow(
color: r.highlight color: r.highlight
? WidgetStatePropertyAll(theme.colorScheme.errorContainer.withValues(alpha: 0.4)) ? WidgetStatePropertyAll(
theme.colorScheme.errorContainer.withValues(alpha: 0.4),
)
: null, : null,
cells: [ cells: [
for (final cell in r.cells) for (final cell in r.cells)
DataCell(Text( DataCell(
Text(
cell, cell,
style: r.highlight style: r.highlight
? TextStyle(color: theme.colorScheme.error) ? TextStyle(color: theme.colorScheme.error)
: null, : null,
)), ),
),
], ],
), ),
], ],
@@ -37,11 +37,16 @@ class SampleEventsTable extends StatelessWidget {
DataRow( DataRow(
color: e.isDuplicate color: e.isDuplicate
? WidgetStatePropertyAll( ? WidgetStatePropertyAll(
theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.6)) theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.6,
),
)
: null, : null,
cells: [ cells: [
DataCell(Text('${e.lineNo}')), DataCell(Text('${e.lineNo}')),
DataCell(Text(e.tradeDate == null ? '' : ruDate(e.tradeDate!))), DataCell(
Text(e.tradeDate == null ? '' : ruDate(e.tradeDate!)),
),
DataCell(Text(eventKindLabels[e.kind] ?? e.kind)), DataCell(Text(eventKindLabels[e.kind] ?? e.kind)),
DataCell( DataCell(
Tooltip( Tooltip(
@@ -49,20 +54,26 @@ class SampleEventsTable extends StatelessWidget {
child: Text(e.instrumentName ?? e.instrumentKey ?? ''), child: Text(e.instrumentName ?? e.instrumentKey ?? ''),
), ),
), ),
DataCell(Text(e.quantity == null ? '' : formatQty(e.quantity!))), DataCell(
Text(e.quantity == null ? '' : formatQty(e.quantity!)),
),
DataCell(Text(_money(e.price, e.currency))), DataCell(Text(_money(e.price, e.currency))),
DataCell(Text(_money(e.amount, e.currency))), DataCell(Text(_money(e.amount, e.currency))),
DataCell(e.isDuplicate DataCell(
e.isDuplicate
? Tooltip( ? Tooltip(
message: 'Такое событие уже есть в леджере', message: 'Такое событие уже есть в леджере',
child: Chip( child: Chip(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
label: const Text('дубль'), label: const Text('дубль'),
backgroundColor: backgroundColor: theme
theme.colorScheme.secondaryContainer.withValues(alpha: 0.8), .colorScheme
.secondaryContainer
.withValues(alpha: 0.8),
), ),
) )
: const SizedBox.shrink()), : const SizedBox.shrink(),
),
], ],
), ),
], ],
@@ -71,5 +82,7 @@ class SampleEventsTable extends StatelessWidget {
} }
static String _money(String? value, String? currency) => static String _money(String? value, String? currency) =>
value == null || value.isEmpty ? '' : MoneyText.format(value, currency ?? 'RUB'); value == null || value.isEmpty
? ''
: MoneyText.format(value, currency ?? 'RUB');
} }
+595
View File
@@ -0,0 +1,595 @@
import 'package:decimal/decimal.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/utils/compact_number.dart';
import '../../core/utils/ru_date.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/money_text.dart';
import '../../core/widgets/section_card.dart';
import 'data/income_api.dart';
import 'entry_row.dart';
import 'labels.dart';
import 'providers.dart';
const _weekdays = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
/// Kinds in the order their glyphs are laid out inside a day cell.
const _kindOrder = ['dividend', 'coupon', 'amortization', 'repayment'];
/// When several payments of one kind share a day, the cell shows the least certain basis:
/// a guess must not be hidden behind a fact that happens to fall on the same day.
const _basisOrder = ['history', 'schedule', 'announced', 'paid'];
/// The month grid: every dividend, coupon, amortisation and redemption on the day it falls,
/// paid ones beside the expected ones. A day opens its payments beside or below the grid.
///
/// The screen itself does not scroll: the grid takes the height it is given, and only the
/// list of payments — the one part that can be any length — scrolls inside its own panel.
class IncomeMonthView extends ConsumerStatefulWidget {
const IncomeMonthView({super.key});
@override
ConsumerState<IncomeMonthView> createState() => _IncomeMonthViewState();
}
class _IncomeMonthViewState extends ConsumerState<IncomeMonthView> {
int? _selectedDay;
void _setMonth(DateTime month) {
ref.read(calendarMonthProvider.notifier).state = month;
setState(() => _selectedDay = null);
}
@override
Widget build(BuildContext context) {
final month = ref.watch(calendarMonthProvider);
final data = ref.watch(incomeMonthProvider(month));
final now = DateTime.now();
final isCurrent = month.year == now.year && month.month == now.month;
const dense = VisualDensity.compact;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
IconButton(
visualDensity: dense,
tooltip: 'Предыдущий месяц',
icon: const Icon(Icons.chevron_left),
onPressed: () =>
_setMonth(DateTime(month.year, month.month - 1)),
),
Expanded(
child: Text(
ruMonthYear(month),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
),
IconButton(
visualDensity: dense,
tooltip: 'Следующий месяц',
icon: const Icon(Icons.chevron_right),
onPressed: () =>
_setMonth(DateTime(month.year, month.month + 1)),
),
TextButton(
style: TextButton.styleFrom(visualDensity: dense),
onPressed: isCurrent
? null
: () => _setMonth(DateTime(now.year, now.month)),
child: const Text('Сегодня'),
),
],
),
Expanded(
child: AsyncValueView(
value: data,
onRetry: () => ref.invalidate(incomeMonthProvider(month)),
data: (cached) => _MonthBody(
month: month,
entries: cached.data.entries,
selectedDay: _selectedDay,
onSelect: (day) => setState(
() => _selectedDay = day == _selectedDay ? null : day,
),
onRefresh: () async =>
ref.invalidate(incomeMonthProvider(month)),
),
),
),
],
),
);
}
}
/// Width from which the payments sit beside the grid instead of under it.
const _wideFrom = 840.0;
/// Room the parts around the grid take, for sizing its rows from the height that is left.
const _weekdayRow = 24.0;
const _legendRow = 32.0;
/// On a narrow screen the legend wraps onto several lines.
const _legendRowNarrow = 64.0;
const _detailsMin = 150.0;
class _MonthBody extends StatelessWidget {
const _MonthBody({
required this.month,
required this.entries,
required this.selectedDay,
required this.onSelect,
required this.onRefresh,
});
final DateTime month;
final List<IncomeEntry> entries;
final int? selectedDay;
final ValueChanged<int> onSelect;
final Future<void> Function() onRefresh;
@override
Widget build(BuildContext context) {
final inMonth = [
for (final e in entries)
if (e.expectedDate != null &&
e.expectedDate!.year == month.year &&
e.expectedDate!.month == month.month)
e,
];
final byDay = <int, List<IncomeEntry>>{};
for (final e in inMonth) {
byDay.putIfAbsent(e.expectedDate!.day, () => []).add(e);
}
final shown = selectedDay == null
? inMonth
: byDay[selectedDay] ?? const <IncomeEntry>[];
final lead = DateTime(month.year, month.month).weekday - 1;
final days = DateTime(month.year, month.month + 1, 0).day;
final weeks = ((lead + days) / 7).ceil();
Widget grid(double cellHeight) => _Grid(
month: month,
byDay: byDay,
selectedDay: selectedDay,
onSelect: onSelect,
lead: lead,
days: days,
weeks: weeks,
cellHeight: cellHeight,
);
final legend = _Legend(entries: inMonth);
final details = _Details(
title: selectedDay == null
? 'Выплаты за месяц'
: ruDate(DateTime(month.year, month.month, selectedDay!)),
entries: shown,
onRefresh: onRefresh,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Summary(entries: inMonth),
const SizedBox(height: 8),
Expanded(
child: LayoutBuilder(
builder: (context, c) {
if (c.maxWidth >= _wideFrom) {
// grid on the left fills the height; payments on the right
final cell = ((c.maxHeight - _weekdayRow - _legendRow) / weeks)
.clamp(48.0, 104.0);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: Column(
children: [
grid(cell),
const SizedBox(height: 8),
legend,
],
),
),
const SizedBox(width: 16),
Expanded(flex: 2, child: details),
],
);
}
// narrow: rows shrink to leave the payments room underneath
final cell =
((c.maxHeight -
_weekdayRow -
_legendRowNarrow -
_detailsMin) /
weeks)
.clamp(40.0, 60.0);
return Column(
children: [
grid(cell),
const SizedBox(height: 8),
legend,
const SizedBox(height: 8),
Expanded(child: details),
],
);
},
),
),
],
);
}
}
/// The payments of the chosen day (or of the month), scrolling inside their own panel.
class _Details extends StatelessWidget {
const _Details({
required this.title,
required this.entries,
required this.onRefresh,
});
final String title;
final List<IncomeEntry> entries;
final Future<void> Function() onRefresh;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return RefreshIndicator(
onRefresh: onRefresh,
child: ListView(
padding: EdgeInsets.zero,
children: [
SectionCard(
title: title,
child: entries.isEmpty
? Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'Выплат нет.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.hintColor,
),
),
)
: Column(
children: [
for (final e in entries) IncomeEntryRow(entry: e),
],
),
),
],
),
);
}
}
class _Sum {
const _Sum(this.total, this.unconverted);
final Decimal total;
/// Payments with no rouble figure (no FX rate for the day): left out of [total], not zeroed.
final int unconverted;
}
_Sum _sum(Iterable<IncomeEntry> entries) {
var total = Decimal.zero;
var unconverted = 0;
for (final e in entries) {
final v = Decimal.tryParse(e.amountRub ?? '');
if (v == null) {
unconverted++;
} else {
total += v;
}
}
return _Sum(total, unconverted);
}
/// One line: what came in, what is still expected. What qualifies the numbers — a share that
/// is only an extrapolation, payments with no rouble figure — sits behind the info icon.
class _Summary extends StatelessWidget {
const _Summary({required this.entries});
final List<IncomeEntry> entries;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final paid = _sum(entries.where((e) => e.basis == 'paid'));
final expected = _sum(entries.where((e) => e.basis != 'paid'));
final guess = _sum(entries.where((e) => e.basis == 'history')).total;
final unconverted = paid.unconverted + expected.unconverted;
Widget stat(String label, Color color, Decimal value) => Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$label ',
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
),
MoneyText(
value.toString(),
currency: 'RUB',
style: theme.textTheme.titleSmall?.copyWith(color: color),
),
],
);
final notes = [
if (guess > Decimal.zero)
'Из ожидаемого ${MoneyText.format(guess.toString(), 'RUB')}'
'экстраполяция по истории: этих выплат может не быть.',
if (unconverted > 0)
'У $unconverted выплат нет курса на дату — в суммы они не вошли.',
];
return Wrap(
spacing: 20,
runSpacing: 2,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
stat('Получено', basisColor('paid'), paid.total),
stat('Ожидается', basisColor('schedule'), expected.total),
if (notes.isNotEmpty)
Tooltip(
message: notes.join('\n'),
triggerMode: TooltipTriggerMode.tap,
showDuration: const Duration(seconds: 6),
child: Icon(
Icons.info_outline,
size: 18,
color: basisColor('history'),
),
),
],
);
}
}
class _Grid extends StatelessWidget {
const _Grid({
required this.month,
required this.byDay,
required this.selectedDay,
required this.onSelect,
required this.lead,
required this.days,
required this.weeks,
required this.cellHeight,
});
final DateTime month;
final Map<int, List<IncomeEntry>> byDay;
final int? selectedDay;
final ValueChanged<int> onSelect;
final int lead;
final int days;
final int weeks;
final double cellHeight;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final now = DateTime.now();
return Column(
children: [
SizedBox(
height: _weekdayRow,
child: Row(
children: [
for (final w in _weekdays)
Expanded(
child: Center(
child: Text(
w,
style: theme.textTheme.labelMedium?.copyWith(
color: theme.hintColor,
),
),
),
),
],
),
),
for (var w = 0; w < weeks; w++)
Row(
children: [
for (var c = 0; c < 7; c++)
Expanded(
child: Builder(
builder: (_) {
final day = w * 7 + c - lead + 1;
if (day < 1 || day > days) {
return SizedBox(height: cellHeight);
}
return _DayCell(
day: day,
height: cellHeight,
entries: byDay[day] ?? const [],
isToday:
month.year == now.year &&
month.month == now.month &&
day == now.day,
selected: day == selectedDay,
onTap: () => onSelect(day),
);
},
),
),
],
),
],
);
}
}
class _DayCell extends StatelessWidget {
const _DayCell({
required this.day,
required this.height,
required this.entries,
required this.isToday,
required this.selected,
required this.onTap,
});
final int day;
final double height;
final List<IncomeEntry> entries;
final bool isToday;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final primary = theme.colorScheme.primary;
final kinds = [
for (final k in _kindOrder)
if (entries.any((e) => e.kind == k)) k,
// a kind the server invents later still gets a glyph rather than vanishing
for (final k in {for (final e in entries) e.kind})
if (!_kindOrder.contains(k)) k,
];
final sum = _sum(entries);
final hasAmount = entries.isNotEmpty && sum.unconverted < entries.length;
return LayoutBuilder(
builder: (context, c) {
final narrow = c.maxWidth < 72;
// a wide but short cell keeps the day and the glyphs on one line
final oneLine = !narrow && height < 64;
final showAmount = !narrow && height >= 64 && hasAmount;
final iconSize = narrow || height < 64 ? 12.0 : 15.0;
final dayText = Text(
'$day',
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: isToday ? FontWeight.w700 : null,
color: isToday ? primary : null,
),
);
final glyphs = Wrap(
spacing: 2,
runSpacing: 2,
alignment: oneLine ? WrapAlignment.end : WrapAlignment.start,
children: [
for (final k in kinds)
Icon(
incomeKindIcon(k),
size: iconSize,
color: basisColor(
_leastCertain(entries.where((e) => e.kind == k)),
),
),
],
);
return Padding(
padding: const EdgeInsets.all(1.5),
child: Material(
color: selected
? primary.withValues(alpha: 0.16)
: entries.isEmpty
? Colors.transparent
: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.5,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: isToday
? BorderSide(color: primary)
: selected
? BorderSide(color: primary.withValues(alpha: 0.5))
: BorderSide(
color: theme.dividerColor.withValues(alpha: 0.4),
),
),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: onTap,
child: SizedBox(
height: height - 3,
child: Padding(
padding: EdgeInsets.all(narrow ? 4 : 6),
child: oneLine
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [dayText, const Spacer(), glyphs],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
dayText,
glyphs,
if (showAmount)
Text(
'${sum.unconverted > 0 ? '' : ''}${compactNumber(sum.total.toDouble())}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall,
),
],
),
),
),
),
),
);
},
);
}
}
String _leastCertain(Iterable<IncomeEntry> entries) {
final bases = entries.map((e) => e.basis).toSet();
for (final b in _basisOrder) {
if (bases.contains(b)) return b;
}
return bases.first;
}
/// What the glyphs and colours mean, and only for what is on this month's grid.
class _Legend extends StatelessWidget {
const _Legend({required this.entries});
final List<IncomeEntry> entries;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bases = {for (final e in entries) e.basis};
return Wrap(
spacing: 16,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
for (final k in _kindOrder)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(incomeKindIcon(k), size: 15, color: theme.hintColor),
const SizedBox(width: 4),
Text(incomeKindLabel(k), style: theme.textTheme.bodySmall),
],
),
for (final b in _basisOrder.reversed)
if (bases.contains(b)) BasisChip(basis: b),
],
);
}
}
+71 -66
View File
@@ -1,24 +1,65 @@
import 'package:decimal/decimal.dart'; import 'package:decimal/decimal.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/utils/ru_date.dart'; import '../../core/utils/ru_date.dart';
import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/empty_state.dart'; import '../../core/widgets/empty_state.dart';
import '../../core/widgets/money_text.dart'; import '../../core/widgets/money_text.dart';
import '../../core/widgets/section_card.dart'; import '../../core/widgets/section_card.dart';
import '../portfolio/labels.dart' show formatQty; import 'calendar_grid.dart';
import 'data/income_api.dart'; import 'data/income_api.dart';
import 'entry_row.dart';
import 'labels.dart'; import 'labels.dart';
import 'providers.dart'; import 'providers.dart';
/// Календарь: every expected payment, month by month, each row carrying its [BasisChip]. /// Календарь: the payments as a month grid (default) or as a list running forward.
class IncomeCalendarTab extends ConsumerWidget {
const IncomeCalendarTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final view = ref.watch(calendarViewProvider);
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: SegmentedButton<CalendarView>(
showSelectedIcon: false,
style: const ButtonStyle(visualDensity: VisualDensity.compact),
segments: const [
ButtonSegment(
value: CalendarView.month,
icon: Icon(Icons.calendar_month_outlined),
label: Text('Календарь'),
),
ButtonSegment(
value: CalendarView.list,
icon: Icon(Icons.view_list_outlined),
label: Text('Список'),
),
],
selected: {view},
onSelectionChanged: (s) =>
ref.read(calendarViewProvider.notifier).state = s.first,
),
),
Expanded(
child: view == CalendarView.month
? const IncomeMonthView()
: const _IncomeList(),
),
],
);
}
}
/// Every expected payment, month by month, each row carrying its [BasisChip].
/// ///
/// The total is deliberately paired with the by-basis split: adding «объявлено» and «по /// The total is deliberately paired with the by-basis split: adding «объявлено» and «по
/// истории» into one number turns a guess into a promise. /// истории» into one number turns a guess into a promise.
class IncomeCalendarTab extends ConsumerWidget { class _IncomeList extends ConsumerWidget {
const IncomeCalendarTab({super.key}); const _IncomeList();
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -43,7 +84,8 @@ class IncomeCalendarTab extends ConsumerWidget {
padding: EdgeInsets.only(top: 48), padding: EdgeInsets.only(top: 48),
child: EmptyState( child: EmptyState(
icon: Icons.event_available_outlined, icon: Icons.event_available_outlined,
message: 'Ожидаемых выплат в этом окне нет.\n' message:
'Ожидаемых выплат в этом окне нет.\n'
'Либо по бумагам нет объявленных выплат и истории, либо портфель пуст.', 'Либо по бумагам нет объявленных выплат и истории, либо портфель пуст.',
), ),
) )
@@ -77,13 +119,15 @@ class _CalendarControls extends ConsumerWidget {
ChoiceChip( ChoiceChip(
label: Text('$m мес'), label: Text('$m мес'),
selected: months == m, selected: months == m,
onSelected: (_) => ref.read(calendarMonthsProvider.notifier).state = m, onSelected: (_) =>
ref.read(calendarMonthsProvider.notifier).state = m,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
FilterChip( FilterChip(
label: const Text('Показать выплаченные'), label: const Text('Показать выплаченные'),
selected: includePaid, selected: includePaid,
onSelected: (v) => ref.read(calendarIncludePaidProvider.notifier).state = v, onSelected: (v) =>
ref.read(calendarIncludePaidProvider.notifier).state = v,
), ),
], ],
); );
@@ -130,7 +174,11 @@ class _Totals extends StatelessWidget {
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon(Icons.info_outline, size: 16, color: basisColor('history')), Icon(
Icons.info_outline,
size: 16,
color: basisColor('history'),
),
const SizedBox(width: 6), const SizedBox(width: 6),
Expanded( Expanded(
child: Text( child: Text(
@@ -164,7 +212,11 @@ class _BasisTotal extends StatelessWidget {
children: [ children: [
BasisChip(basis: basis), BasisChip(basis: basis),
const SizedBox(height: 4), const SizedBox(height: 4),
MoneyText(amount, currency: 'RUB', style: Theme.of(context).textTheme.titleSmall), MoneyText(
amount,
currency: 'RUB',
style: Theme.of(context).textTheme.titleSmall,
),
], ],
), ),
); );
@@ -196,60 +248,8 @@ class _MonthCard extends StatelessWidget {
currency: 'RUB', currency: 'RUB',
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
child: Column(children: [for (final e in entries) _EntryRow(entry: e)]), child: Column(
); children: [for (final e in entries) IncomeEntryRow(entry: e)],
}
}
class _EntryRow extends StatelessWidget {
const _EntryRow({required this.entry});
final IncomeEntry entry;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final details = [
incomeKindLabel(entry.kind),
if (entry.qty != null && entry.perUnit != null)
'${formatQty(entry.qty!)} × ${MoneyText.format(entry.perUnit!, entry.currency)}',
if (entry.recordDate != null) 'отсечка ${ruDate(entry.recordDate!)}',
if (entry.taxWithheld != null)
'налог ${MoneyText.format(entry.taxWithheld!, entry.currency)}',
].join(' · ');
return ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
onTap: entry.instrumentId == null
? null
: () => context.push('/portfolio/instrument/${entry.instrumentId}'),
title: Row(
children: [
Flexible(child: Text(entry.title, overflow: TextOverflow.ellipsis)),
const SizedBox(width: 8),
BasisChip(basis: entry.basis),
],
),
subtitle: Text(
'${entry.expectedDate == null ? 'дата неизвестна' : ruDate(entry.expectedDate!)} · $details',
style: theme.textTheme.bodySmall,
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (entry.amount != null)
MoneyText(entry.amount!, currency: entry.currency, style: theme.textTheme.titleSmall),
// no FX rate for the date ⇒ no rouble figure. An em dash, never 0 ₽.
if (entry.currency != 'RUB')
Text(
entry.amountRub == null
? '— ₽ (нет курса)'
: MoneyText.format(entry.amountRub!, 'RUB'),
style: theme.textTheme.bodySmall,
),
],
), ),
); );
} }
@@ -257,7 +257,9 @@ class _EntryRow extends StatelessWidget {
/// Groups entries by month, preserving the server's order inside each month. Entries with /// Groups entries by month, preserving the server's order inside each month. Entries with
/// no date go last, under their own heading — they are still expected money. /// no date go last, under their own heading — they are still expected money.
List<MapEntry<DateTime, List<IncomeEntry>>> _groupByMonth(List<IncomeEntry> entries) { List<MapEntry<DateTime, List<IncomeEntry>>> _groupByMonth(
List<IncomeEntry> entries,
) {
final groups = <DateTime, List<IncomeEntry>>{}; final groups = <DateTime, List<IncomeEntry>>{};
for (final e in entries) { for (final e in entries) {
final d = e.expectedDate; final d = e.expectedDate;
@@ -272,5 +274,8 @@ List<MapEntry<DateTime, List<IncomeEntry>>> _groupByMonth(List<IncomeEntry> entr
List<String> _orderedBases(Iterable<String> bases) { List<String> _orderedBases(Iterable<String> bases) {
const order = ['schedule', 'announced', 'history', 'paid']; const order = ['schedule', 'announced', 'history', 'paid'];
final set = bases.toSet(); final set = bases.toSet();
return [...order.where(set.contains), ...set.where((b) => !order.contains(b))]; return [
...order.where(set.contains),
...set.where((b) => !order.contains(b)),
];
} }
+27 -12
View File
@@ -56,7 +56,8 @@ class IncomeEntry {
final String basis; final String basis;
final String? taxWithheld; final String? taxWithheld;
String get title => ticker ?? name ?? (instrumentId == null ? '' : '#$instrumentId'); String get title =>
ticker ?? name ?? (instrumentId == null ? '' : '#$instrumentId');
static IncomeEntry fromJson(Map<String, dynamic> json) => IncomeEntry( static IncomeEntry fromJson(Map<String, dynamic> json) => IncomeEntry(
instrumentId: asInt(json['instrument_id']), instrumentId: asInt(json['instrument_id']),
@@ -118,7 +119,8 @@ class IncomeHistoryRow {
final String? taxWithheld; final String? taxWithheld;
final int paymentCount; final int paymentCount;
static IncomeHistoryRow fromJson(Map<String, dynamic> json) => IncomeHistoryRow( static IncomeHistoryRow fromJson(Map<String, dynamic> json) =>
IncomeHistoryRow(
month: asDate(json['month']), month: asDate(json['month']),
kind: asString(json['kind']) ?? 'other', kind: asString(json['kind']) ?? 'other',
currency: asString(json['currency']) ?? 'RUB', currency: asString(json['currency']) ?? 'RUB',
@@ -151,7 +153,11 @@ class IncomeHistory {
} }
class ForecastMonth { class ForecastMonth {
const ForecastMonth({required this.amountRub, this.month, this.byBasis = const {}}); const ForecastMonth({
required this.amountRub,
this.month,
this.byBasis = const {},
});
final DateTime? month; final DateTime? month;
final String amountRub; final String amountRub;
@@ -214,12 +220,15 @@ class IncomeApi {
DateTime? dateTo, DateTime? dateTo,
bool includePaid = false, bool includePaid = false,
}) async { }) async {
final r = await _dio.get<Map<String, dynamic>>('$_base/calendar', queryParameters: { final r = await _dio.get<Map<String, dynamic>>(
'$_base/calendar',
queryParameters: {
'scope': scope, 'scope': scope,
'date_from': ?_isoDate(dateFrom), 'date_from': ?_isoDate(dateFrom),
'date_to': ?_isoDate(dateTo), 'date_to': ?_isoDate(dateTo),
'include_paid': includePaid, 'include_paid': includePaid,
}); },
);
return Cached( return Cached(
IncomeCalendar.fromJson(r.data ?? const {}), IncomeCalendar.fromJson(r.data ?? const {}),
fetchedAt: r.extra['fetchedAt'] as DateTime?, fetchedAt: r.extra['fetchedAt'] as DateTime?,
@@ -233,24 +242,30 @@ class IncomeApi {
DateTime? dateTo, DateTime? dateTo,
String? kind, String? kind,
}) async { }) async {
final r = await _dio.get<Map<String, dynamic>>('$_base/history', queryParameters: { final r = await _dio.get<Map<String, dynamic>>(
'$_base/history',
queryParameters: {
'scope': scope, 'scope': scope,
'group': group, 'group': group,
'date_from': ?_isoDate(dateFrom), 'date_from': ?_isoDate(dateFrom),
'date_to': ?_isoDate(dateTo), 'date_to': ?_isoDate(dateTo),
'kind': ?kind, 'kind': ?kind,
}); },
);
return Cached( return Cached(
IncomeHistory.fromJson(r.data ?? const {}), IncomeHistory.fromJson(r.data ?? const {}),
fetchedAt: r.extra['fetchedAt'] as DateTime?, fetchedAt: r.extra['fetchedAt'] as DateTime?,
); );
} }
Future<Cached<IncomeForecast>> forecast({String scope = 'all', int months = 12}) async { Future<Cached<IncomeForecast>> forecast({
final r = await _dio.get<Map<String, dynamic>>('$_base/forecast', queryParameters: { String scope = 'all',
'scope': scope, int months = 12,
'months': months, }) async {
}); final r = await _dio.get<Map<String, dynamic>>(
'$_base/forecast',
queryParameters: {'scope': scope, 'months': months},
);
return Cached( return Cached(
IncomeForecast.fromJson(r.data ?? const {}), IncomeForecast.fromJson(r.data ?? const {}),
fetchedAt: r.extra['fetchedAt'] as DateTime?, fetchedAt: r.extra['fetchedAt'] as DateTime?,
+73
View File
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../core/utils/ru_date.dart';
import '../../core/widgets/money_text.dart';
import '../portfolio/labels.dart' show formatQty;
import 'data/income_api.dart';
import 'labels.dart';
/// One payment as a list row: what, when, on which basis, and how much. The basis chip is on
/// every row on purpose — it is what tells a declared payment from an extrapolated one.
class IncomeEntryRow extends StatelessWidget {
const IncomeEntryRow({required this.entry, super.key});
final IncomeEntry entry;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final details = [
incomeKindLabel(entry.kind),
if (entry.qty != null && entry.perUnit != null)
'${formatQty(entry.qty!)} × ${MoneyText.format(entry.perUnit!, entry.currency)}',
if (entry.recordDate != null) 'отсечка ${ruDate(entry.recordDate!)}',
if (entry.taxWithheld != null)
'налог ${MoneyText.format(entry.taxWithheld!, entry.currency)}',
].join(' · ');
return ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
onTap: entry.instrumentId == null
? null
: () => context.push('/portfolio/instrument/${entry.instrumentId}'),
leading: Icon(
incomeKindIcon(entry.kind),
color: basisColor(entry.basis),
size: 20,
),
title: Row(
children: [
Flexible(child: Text(entry.title, overflow: TextOverflow.ellipsis)),
const SizedBox(width: 8),
BasisChip(basis: entry.basis),
],
),
subtitle: Text(
'${entry.expectedDate == null ? 'дата неизвестна' : ruDate(entry.expectedDate!)} · $details',
style: theme.textTheme.bodySmall,
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (entry.amount != null)
MoneyText(
entry.amount!,
currency: entry.currency,
style: theme.textTheme.titleSmall,
),
// no FX rate for the date ⇒ no rouble figure. An em dash, never 0 ₽.
if (entry.currency != 'RUB')
Text(
entry.amountRub == null
? '— ₽ (нет курса)'
: MoneyText.format(entry.amountRub!, 'RUB'),
style: theme.textTheme.bodySmall,
),
],
),
);
}
}
+38 -13
View File
@@ -50,7 +50,9 @@ class IncomeForecastTab extends ConsumerWidget {
StatTile( StatTile(
label: 'Доходность к стоимости', label: 'Доходность к стоимости',
// null means "нет оценки стоимости" and must not read as 0 % // null means "нет оценки стоимости" and must not read as 0 %
value: Text(formatPercent(data.annualYieldOnValue, signed: false)), value: Text(
formatPercent(data.annualYieldOnValue, signed: false),
),
note: data.annualYieldOnValue == null note: data.annualYieldOnValue == null
? 'нет оценки текущей стоимости' ? 'нет оценки текущей стоимости'
: 'ожидаемый доход / стоимость портфеля', : 'ожидаемый доход / стоимость портфеля',
@@ -58,7 +60,10 @@ class IncomeForecastTab extends ConsumerWidget {
for (final b in bases) for (final b in bases)
StatTile( StatTile(
label: 'Основание: ${basisLabel(b)}', label: 'Основание: ${basisLabel(b)}',
value: MoneyText(_basisTotal(data, b).toString(), currency: 'RUB'), value: MoneyText(
_basisTotal(data, b).toString(),
currency: 'RUB',
),
note: basisDescription(b), note: basisDescription(b),
width: 220, width: 220,
), ),
@@ -91,8 +96,14 @@ class IncomeForecastTab extends ConsumerWidget {
child: SingleChildScrollView( child: SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: SizedBox( child: SizedBox(
width: (data.months.length * 52).toDouble().clamp(320, double.infinity), width: (data.months.length * 52).toDouble().clamp(
child: _ForecastChart(months: data.months, bases: bases), 320,
double.infinity,
),
child: _ForecastChart(
months: data.months,
bases: bases,
),
), ),
), ),
), ),
@@ -130,7 +141,8 @@ class _HorizonChips extends ConsumerWidget {
ChoiceChip( ChoiceChip(
label: Text('$m мес'), label: Text('$m мес'),
selected: months == m, selected: months == m,
onSelected: (_) => ref.read(forecastMonthsProvider.notifier).state = m, onSelected: (_) =>
ref.read(forecastMonthsProvider.notifier).state = m,
), ),
], ],
); );
@@ -191,10 +203,16 @@ class _BasisLegend extends StatelessWidget {
Container( Container(
width: 10, width: 10,
height: 10, height: 10,
decoration: BoxDecoration(color: basisColor(b), shape: BoxShape.circle), decoration: BoxDecoration(
color: basisColor(b),
shape: BoxShape.circle,
),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Text(basisLabel(b), style: Theme.of(context).textTheme.bodySmall), Text(
basisLabel(b),
style: Theme.of(context).textTheme.bodySmall,
),
], ],
), ),
), ),
@@ -235,7 +253,8 @@ class _ForecastChart extends StatelessWidget {
getTitlesWidget: (value, meta) { getTitlesWidget: (value, meta) {
final i = value.round(); final i = value.round();
if (i < 0 || i >= months.length) return const SizedBox.shrink(); if (i < 0 || i >= months.length) return const SizedBox.shrink();
if (months.length > 14 && i.isOdd) return const SizedBox.shrink(); if (months.length > 14 && i.isOdd)
return const SizedBox.shrink();
final m = months[i].month; final m = months[i].month;
return Text( return Text(
m == null ? '' : ruMonthYearShort(m), m == null ? '' : ruMonthYearShort(m),
@@ -251,7 +270,8 @@ class _ForecastChart extends StatelessWidget {
final m = months[group.x]; final m = months[group.x];
final parts = [ final parts = [
for (final b in bases) for (final b in bases)
if ((Decimal.tryParse(m.byBasis[b] ?? '0') ?? Decimal.zero) > Decimal.zero) if ((Decimal.tryParse(m.byBasis[b] ?? '0') ?? Decimal.zero) >
Decimal.zero)
'${basisLabel(b)}: ${MoneyText.format(m.byBasis[b]!, 'RUB')}', '${basisLabel(b)}: ${MoneyText.format(m.byBasis[b]!, 'RUB')}',
]; ];
return BarTooltipItem( return BarTooltipItem(
@@ -277,7 +297,8 @@ class _ForecastChart extends StatelessWidget {
final stack = <BarChartRodStackItem>[]; final stack = <BarChartRodStackItem>[];
var from = 0.0; var from = 0.0;
for (final b in bases) { for (final b in bases) {
final v = (Decimal.tryParse(m.byBasis[b] ?? '0') ?? Decimal.zero).toDouble(); final v = (Decimal.tryParse(m.byBasis[b] ?? '0') ?? Decimal.zero)
.toDouble();
if (v <= 0) continue; if (v <= 0) continue;
stack.add(BarChartRodStackItem(from, from + v, basisColor(b))); stack.add(BarChartRodStackItem(from, from + v, basisColor(b)));
from += v; from += v;
@@ -321,14 +342,18 @@ class _ForecastTable extends StatelessWidget {
for (final m in months) for (final m in months)
DataRow( DataRow(
cells: [ cells: [
DataCell(Text(m.month == null ? '' : ruMonthYearShort(m.month!))), DataCell(
Text(m.month == null ? '' : ruMonthYearShort(m.month!)),
),
for (final b in bases) for (final b in bases)
DataCell(MoneyText(m.byBasis[b] ?? '0', currency: 'RUB')), DataCell(MoneyText(m.byBasis[b] ?? '0', currency: 'RUB')),
DataCell(MoneyText( DataCell(
MoneyText(
m.amountRub, m.amountRub,
currency: 'RUB', currency: 'RUB',
style: Theme.of(context).textTheme.titleSmall, style: Theme.of(context).textTheme.titleSmall,
)), ),
),
], ],
), ),
], ],
+33 -12
View File
@@ -77,7 +77,10 @@ class IncomeHistoryTab extends ConsumerWidget {
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
reverse: true, reverse: true,
child: SizedBox( child: SizedBox(
width: (months.length * 44).toDouble().clamp(320, double.infinity), width: (months.length * 44).toDouble().clamp(
320,
double.infinity,
),
child: _HistoryChart(months: months), child: _HistoryChart(months: months),
), ),
), ),
@@ -109,7 +112,8 @@ class _PeriodChips extends ConsumerWidget {
ChoiceChip( ChoiceChip(
label: Text(m >= 120 ? 'Всё время' : '$m мес'), label: Text(m >= 120 ? 'Всё время' : '$m мес'),
selected: months == m, selected: months == m,
onSelected: (_) => ref.read(historyMonthsProvider.notifier).state = m, onSelected: (_) =>
ref.read(historyMonthsProvider.notifier).state = m,
), ),
], ],
); );
@@ -130,7 +134,9 @@ List<_MonthTotal> _byMonth(List<IncomeHistoryRow> rows) {
final m = r.month; final m = r.month;
if (m == null) continue; if (m == null) continue;
final key = DateTime.utc(m.year, m.month); final key = DateTime.utc(m.year, m.month);
sums[key] = (sums[key] ?? Decimal.zero) + (Decimal.tryParse(r.amountRub ?? '') ?? Decimal.zero); sums[key] =
(sums[key] ?? Decimal.zero) +
(Decimal.tryParse(r.amountRub ?? '') ?? Decimal.zero);
} }
final keys = sums.keys.toList()..sort(); final keys = sums.keys.toList()..sort();
return [for (final k in keys) _MonthTotal(k, sums[k]!)]; return [for (final k in keys) _MonthTotal(k, sums[k]!)];
@@ -166,8 +172,12 @@ class _HistoryChart extends StatelessWidget {
getTitlesWidget: (value, meta) { getTitlesWidget: (value, meta) {
final i = value.round(); final i = value.round();
if (i < 0 || i >= months.length) return const SizedBox.shrink(); if (i < 0 || i >= months.length) return const SizedBox.shrink();
if (months.length > 14 && i % 3 != 0) return const SizedBox.shrink(); if (months.length > 14 && i % 3 != 0)
return Text(ruMonthYearShort(months[i].month), style: theme.textTheme.bodySmall); return const SizedBox.shrink();
return Text(
ruMonthYearShort(months[i].month),
style: theme.textTheme.bodySmall,
);
}, },
), ),
), ),
@@ -190,7 +200,9 @@ class _HistoryChart extends StatelessWidget {
toY: months[i].amountRub.toDouble(), toY: months[i].amountRub.toDouble(),
color: basisColor('paid'), color: basisColor('paid'),
width: 12, width: 12,
borderRadius: const BorderRadius.vertical(top: Radius.circular(2)), borderRadius: const BorderRadius.vertical(
top: Radius.circular(2),
),
), ),
], ],
), ),
@@ -216,7 +228,10 @@ class _HistoryTable extends StatelessWidget {
DataColumn(label: Text('Тип', style: headerStyle)), DataColumn(label: Text('Тип', style: headerStyle)),
DataColumn(label: Text('Валюта', style: headerStyle)), 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),
DataColumn(label: Text('Выплат', style: headerStyle), numeric: true), DataColumn(label: Text('Выплат', style: headerStyle), numeric: true),
], ],
@@ -224,16 +239,22 @@ class _HistoryTable extends StatelessWidget {
for (final r in rows) for (final r in rows)
DataRow( DataRow(
cells: [ cells: [
DataCell(Text(r.month == null ? '' : ruMonthYearShort(r.month!))), DataCell(
Text(r.month == null ? '' : ruMonthYearShort(r.month!)),
),
DataCell(Text(incomeKindLabel(r.kind))), DataCell(Text(incomeKindLabel(r.kind))),
DataCell(Text(r.currency)), DataCell(Text(r.currency)),
DataCell(MoneyText(r.amount, currency: r.currency)), DataCell(MoneyText(r.amount, currency: r.currency)),
DataCell(r.amountRub == null DataCell(
r.amountRub == null
? const Text('') ? const Text('')
: MoneyText(r.amountRub!, currency: 'RUB')), : MoneyText(r.amountRub!, currency: 'RUB'),
DataCell(r.taxWithheld == null ),
DataCell(
r.taxWithheld == null
? const Text('') ? const Text('')
: MoneyText(r.taxWithheld!, currency: r.currency)), : MoneyText(r.taxWithheld!, currency: r.currency),
),
DataCell(Text('${r.paymentCount}')), DataCell(Text('${r.paymentCount}')),
], ],
), ),
+10 -2
View File
@@ -40,7 +40,11 @@ class IncomePage extends ConsumerWidget {
), ),
], ],
bottom: const TabBar( bottom: const TabBar(
tabs: [Tab(text: 'Календарь'), Tab(text: 'История'), Tab(text: 'Прогноз')], tabs: [
Tab(text: 'Календарь'),
Tab(text: 'История'),
Tab(text: 'Прогноз'),
],
), ),
), ),
body: Column( body: Column(
@@ -52,7 +56,11 @@ class IncomePage extends ConsumerWidget {
), ),
const Expanded( const Expanded(
child: TabBarView( child: TabBarView(
children: [IncomeCalendarTab(), IncomeHistoryTab(), IncomeForecastTab()], children: [
IncomeCalendarTab(),
IncomeHistoryTab(),
IncomeForecastTab(),
],
), ),
), ),
], ],
+17 -3
View File
@@ -13,6 +13,16 @@ const incomeKindLabels = {
String incomeKindLabel(String kind) => incomeKindLabels[kind] ?? kind; String incomeKindLabel(String kind) => incomeKindLabels[kind] ?? kind;
/// One glyph per kind, so a day cell on the calendar says what is paid without a word. The
/// colour of the glyph is left to the caller: on the calendar it is the basis, not the kind.
IconData incomeKindIcon(String kind) => switch (kind) {
'dividend' => Icons.paid_outlined,
'coupon' => Icons.receipt_long_outlined,
'amortization' => Icons.trending_down,
'repayment' => Icons.flag_outlined,
_ => Icons.circle_outlined,
};
const basisLabels = { const basisLabels = {
'schedule': 'по графику', 'schedule': 'по графику',
'announced': 'объявлено', 'announced': 'объявлено',
@@ -26,7 +36,8 @@ const basisLabels = {
const basisDescriptions = { const basisDescriptions = {
'schedule': 'Арифметика по опубликованному графику выплат эмитента.', 'schedule': 'Арифметика по опубликованному графику выплат эмитента.',
'announced': 'Объявленный эмитентом факт: размер и дата известны.', 'announced': 'Объявленный эмитентом факт: размер и дата известны.',
'history': 'Экстраполяция по выплатам за последние 24 мес — может ошибаться ' 'history':
'Экстраполяция по выплатам за последние 24 мес — может ошибаться '
'на любую величину, в том числе выплаты может не быть вовсе.', 'на любую величину, в том числе выплаты может не быть вовсе.',
'paid': 'Уже получено.', 'paid': 'Уже получено.',
}; };
@@ -43,7 +54,7 @@ Color basisColor(String basis) => switch (basis) {
'history' => ChartColors.slot4Yellow, 'history' => ChartColors.slot4Yellow,
'paid' => ChartColors.slot5Magenta, 'paid' => ChartColors.slot5Magenta,
_ => ChartColors.slot2Orange, _ => ChartColors.slot2Orange,
}; };
/// The basis chip that has to sit on every calendar and forecast row. /// The basis chip that has to sit on every calendar and forecast row.
class BasisChip extends StatelessWidget { class BasisChip extends StatelessWidget {
@@ -58,7 +69,10 @@ class BasisChip extends StatelessWidget {
return Tooltip( return Tooltip(
message: basisDescription(basis), message: basisDescription(basis),
child: Container( child: Container(
padding: EdgeInsets.symmetric(horizontal: dense ? 6 : 10, vertical: dense ? 1 : 4), padding: EdgeInsets.symmetric(
horizontal: dense ? 6 : 10,
vertical: dense ? 1 : 4,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withValues(alpha: 0.14), color: color.withValues(alpha: 0.14),
border: Border.all(color: color.withValues(alpha: 0.5)), border: Border.all(color: color.withValues(alpha: 0.5)),
+47 -8
View File
@@ -5,7 +5,9 @@ import '../../core/cache/cached.dart';
import '../portfolio/providers.dart' show scopeProvider; import '../portfolio/providers.dart' show scopeProvider;
import 'data/income_api.dart'; import 'data/income_api.dart';
final incomeApiProvider = Provider<IncomeApi>((ref) => IncomeApi(ref.watch(apiProvider).dio)); final incomeApiProvider = Provider<IncomeApi>(
(ref) => IncomeApi(ref.watch(apiProvider).dio),
);
/// How far the calendar looks ahead, in months. 12 is the contract default. /// How far the calendar looks ahead, in months. 12 is the contract default.
final calendarMonthsProvider = StateProvider<int>((ref) => 12); final calendarMonthsProvider = StateProvider<int>((ref) => 12);
@@ -14,44 +16,81 @@ final calendarIncludePaidProvider = StateProvider<bool>((ref) => false);
/// Доходы shares the portfolio-wide [scopeProvider]: switching the scope on Портфель must /// Доходы shares the portfolio-wide [scopeProvider]: switching the scope on Портфель must
/// not leave the income calendar showing a different portfolio. See `docs/ai/offline-cache.md`. /// not leave the income calendar showing a different portfolio. See `docs/ai/offline-cache.md`.
final incomeCalendarProvider = FutureProvider.autoDispose<Cached<IncomeCalendar>>((ref) async { final incomeCalendarProvider =
FutureProvider.autoDispose<Cached<IncomeCalendar>>((ref) async {
final scope = ref.watch(scopeProvider); final scope = ref.watch(scopeProvider);
final months = ref.watch(calendarMonthsProvider); final months = ref.watch(calendarMonthsProvider);
final includePaid = ref.watch(calendarIncludePaidProvider); final includePaid = ref.watch(calendarIncludePaidProvider);
final now = DateTime.now(); final now = DateTime.now();
return ref.watch(incomeApiProvider).calendar( return ref
.watch(incomeApiProvider)
.calendar(
scope: scope, scope: scope,
dateFrom: DateTime(now.year, now.month, now.day), dateFrom: DateTime(now.year, now.month, now.day),
dateTo: DateTime(now.year, now.month + months, now.day), dateTo: DateTime(now.year, now.month + months, now.day),
includePaid: includePaid, includePaid: includePaid,
); );
});
enum CalendarView { month, list }
/// The calendar opens as a month grid; the list of the coming payments is the other view.
final calendarViewProvider = StateProvider<CalendarView>(
(ref) => CalendarView.month,
);
/// The month the grid shows, as the first day of it.
final calendarMonthProvider = StateProvider<DateTime>((ref) {
final now = DateTime.now();
return DateTime(now.year, now.month);
}); });
/// Every payment of one month, the paid ones included: a calendar of a month is the record of
/// it as well as the plan. Keyed by the first day of the month.
final incomeMonthProvider = FutureProvider.autoDispose
.family<Cached<IncomeCalendar>, DateTime>((ref, month) async {
final scope = ref.watch(scopeProvider);
return ref
.watch(incomeApiProvider)
.calendar(
scope: scope,
dateFrom: DateTime(month.year, month.month, 1),
dateTo: DateTime(month.year, month.month + 1, 0),
includePaid: true,
);
});
/// How far the history goes back, in months. /// How far the history goes back, in months.
final historyMonthsProvider = StateProvider<int>((ref) => 24); final historyMonthsProvider = StateProvider<int>((ref) => 24);
final incomeHistoryProvider = FutureProvider.autoDispose<Cached<IncomeHistory>>((ref) async { final incomeHistoryProvider = FutureProvider.autoDispose<Cached<IncomeHistory>>(
(ref) async {
final scope = ref.watch(scopeProvider); final scope = ref.watch(scopeProvider);
final months = ref.watch(historyMonthsProvider); final months = ref.watch(historyMonthsProvider);
final now = DateTime.now(); final now = DateTime.now();
return ref.watch(incomeApiProvider).history( return ref
.watch(incomeApiProvider)
.history(
scope: scope, scope: scope,
dateFrom: DateTime(now.year, now.month - months + 1, 1), dateFrom: DateTime(now.year, now.month - months + 1, 1),
dateTo: DateTime(now.year, now.month + 1, 0), dateTo: DateTime(now.year, now.month + 1, 0),
); );
}); },
);
final forecastMonthsProvider = StateProvider<int>((ref) => 12); final forecastMonthsProvider = StateProvider<int>((ref) => 12);
final incomeForecastProvider = FutureProvider.autoDispose<Cached<IncomeForecast>>((ref) async { final incomeForecastProvider =
FutureProvider.autoDispose<Cached<IncomeForecast>>((ref) async {
final scope = ref.watch(scopeProvider); final scope = ref.watch(scopeProvider);
return ref return ref
.watch(incomeApiProvider) .watch(incomeApiProvider)
.forecast(scope: scope, months: ref.watch(forecastMonthsProvider)); .forecast(scope: scope, months: ref.watch(forecastMonthsProvider));
}); });
void invalidateIncomeProviders(WidgetRef ref) { void invalidateIncomeProviders(WidgetRef ref) {
ref.invalidate(incomeCalendarProvider); ref.invalidate(incomeCalendarProvider);
ref.invalidate(incomeMonthProvider);
ref.invalidate(incomeHistoryProvider); ref.invalidate(incomeHistoryProvider);
ref.invalidate(incomeForecastProvider); ref.invalidate(incomeForecastProvider);
} }
+21 -8
View File
@@ -60,7 +60,8 @@ class PendingInstrument {
return isin ?? sourceKey; return isin ?? sourceKey;
} }
static PendingInstrument fromJson(Map<String, dynamic> json) => PendingInstrument( static PendingInstrument fromJson(Map<String, dynamic> json) =>
PendingInstrument(
id: asInt(json['id'])!, id: asInt(json['id'])!,
source: asString(json['source']) ?? '', source: asString(json['source']) ?? '',
sourceKey: asString(json['source_key']) ?? '', sourceKey: asString(json['source_key']) ?? '',
@@ -98,7 +99,8 @@ class PendingResolveResult {
final bool aliasCreated; final bool aliasCreated;
final bool metricsRefreshed; final bool metricsRefreshed;
static PendingResolveResult fromJson(Map<String, dynamic> json) => PendingResolveResult( static PendingResolveResult fromJson(Map<String, dynamic> json) =>
PendingResolveResult(
id: asInt(json['id']) ?? 0, id: asInt(json['id']) ?? 0,
status: asString(json['status']) ?? 'resolved', status: asString(json['status']) ?? 'resolved',
instrumentId: asInt(json['instrument_id']), instrumentId: asInt(json['instrument_id']),
@@ -172,7 +174,10 @@ class PendingApi {
queryParameters: {'status': status, 'limit': limit, 'offset': offset}, queryParameters: {'status': status, 'limit': limit, 'offset': offset},
); );
return (r.data ?? const []) return (r.data ?? const [])
.map((e) => PendingInstrument.fromJson(Map<String, dynamic>.from(e as Map))) .map(
(e) =>
PendingInstrument.fromJson(Map<String, dynamic>.from(e as Map)),
)
.toList(); .toList();
} }
@@ -182,10 +187,17 @@ class PendingApi {
Future<PendingResolveResult> create(int id, NewInstrument instrument) => Future<PendingResolveResult> create(int id, NewInstrument instrument) =>
_resolve(id, {'action': 'create', 'instrument': instrument.toJson()}); _resolve(id, {'action': 'create', 'instrument': instrument.toJson()});
Future<PendingResolveResult> ignore(int id) => _resolve(id, {'action': 'ignore'}); Future<PendingResolveResult> ignore(int id) =>
_resolve(id, {'action': 'ignore'});
Future<PendingResolveResult> _resolve(int id, Map<String, dynamic> body) async { Future<PendingResolveResult> _resolve(
final r = await _dio.post<Map<String, dynamic>>('$_base/$id/resolve', data: body); int id,
Map<String, dynamic> body,
) async {
final r = await _dio.post<Map<String, dynamic>>(
'$_base/$id/resolve',
data: body,
);
return PendingResolveResult.fromJson(r.data ?? const {}); return PendingResolveResult.fromJson(r.data ?? const {});
} }
} }
@@ -196,14 +208,15 @@ class PendingApi {
// not convert to `double` anywhere — a numeric JSON value (should one ever appear) is kept // not convert to `double` anywhere — a numeric JSON value (should one ever appear) is kept
// as its lossless string form and parsed into `Decimal` at the point of display. // as its lossless string form and parsed into `Decimal` at the point of display.
String? asString(Object? v) => v == null ? null : (v is String ? v : v.toString()); String? asString(Object? v) =>
v == null ? null : (v is String ? v : v.toString());
int? asInt(Object? v) => switch (v) { int? asInt(Object? v) => switch (v) {
null => null, null => null,
final int i => i, final int i => i,
final String s => int.tryParse(s), final String s => int.tryParse(s),
_ => null, _ => null,
}; };
DateTime? asDate(Object? v) { DateTime? asDate(Object? v) {
final s = asString(v); final s = asString(v);
+48 -18
View File
@@ -18,16 +18,22 @@ class PendingInstrumentsPage extends ConsumerStatefulWidget {
const PendingInstrumentsPage({super.key}); const PendingInstrumentsPage({super.key});
@override @override
ConsumerState<PendingInstrumentsPage> createState() => _PendingInstrumentsPageState(); ConsumerState<PendingInstrumentsPage> createState() =>
_PendingInstrumentsPageState();
} }
class _PendingInstrumentsPageState extends ConsumerState<PendingInstrumentsPage> { class _PendingInstrumentsPageState
extends ConsumerState<PendingInstrumentsPage> {
int? _busyId; int? _busyId;
void _snack(String message) => void _snack(String message) =>
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
Future<void> _run(int id, Future<PendingResolveResult> Function() action) async { Future<void> _run(
int id,
Future<PendingResolveResult> Function() action,
) async {
setState(() => _busyId = id); setState(() => _busyId = id);
try { try {
final result = await action(); final result = await action();
@@ -36,10 +42,12 @@ class _PendingInstrumentsPageState extends ConsumerState<PendingInstrumentsPage>
ref.invalidate(pendingCountProvider); ref.invalidate(pendingCountProvider);
// Events moved out of `pending`, so holdings, allocation and the event list changed. // Events moved out of `pending`, so holdings, allocation and the event list changed.
invalidateLedgerDependents(ref); invalidateLedgerDependents(ref);
_snack(result.status == 'ignored' _snack(
result.status == 'ignored'
? 'Строка помечена как «не инструмент»' ? 'Строка помечена как «не инструмент»'
: 'Привязано событий: ${result.eventsBound}' : 'Привязано событий: ${result.eventsBound}'
'${result.aliasCreated ? ', добавлен алиас' : ''}'); '${result.aliasCreated ? ', добавлен алиас' : ''}',
);
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
_snack(importErrorMessage(e)); _snack(importErrorMessage(e));
@@ -56,7 +64,10 @@ class _PendingInstrumentsPageState extends ConsumerState<PendingInstrumentsPage>
), ),
); );
if (instrumentId == null || !mounted) return; if (instrumentId == null || !mounted) return;
await _run(p.id, () => ref.read(pendingApiProvider).link(p.id, instrumentId)); await _run(
p.id,
() => ref.read(pendingApiProvider).link(p.id, instrumentId),
);
} }
Future<void> _create(PendingInstrument p) async { Future<void> _create(PendingInstrument p) async {
@@ -65,7 +76,10 @@ class _PendingInstrumentsPageState extends ConsumerState<PendingInstrumentsPage>
builder: (_) => CreateInstrumentDialog(pending: p), builder: (_) => CreateInstrumentDialog(pending: p),
); );
if (instrument == null || !mounted) return; if (instrument == null || !mounted) return;
await _run(p.id, () => ref.read(pendingApiProvider).create(p.id, instrument)); await _run(
p.id,
() => ref.read(pendingApiProvider).create(p.id, instrument),
);
} }
Future<void> _ignore(PendingInstrument p) async { Future<void> _ignore(PendingInstrument p) async {
@@ -73,14 +87,19 @@ class _PendingInstrumentsPageState extends ConsumerState<PendingInstrumentsPage>
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Игнорировать строку?'), title: const Text('Игнорировать строку?'),
content: Text('«${p.title}» больше не будет предлагаться к резолву. ' content: Text(
'Её события останутся без инструмента.'), '«${p.title}» больше не будет предлагаться к резолву. '
'Её события останутся без инструмента.',
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(false), child: const Text('Отмена')), onPressed: () => Navigator.of(context).pop(false),
child: const Text('Отмена'),
),
FilledButton( FilledButton(
onPressed: () => Navigator.of(context).pop(true), onPressed: () => Navigator.of(context).pop(true),
child: const Text('Игнорировать')), child: const Text('Игнорировать'),
),
], ],
), ),
); );
@@ -164,7 +183,8 @@ class _StatusFilter extends ConsumerWidget {
ChoiceChip( ChoiceChip(
label: Text(e.value), label: Text(e.value),
selected: current == e.key, selected: current == e.key,
onSelected: (_) => ref.read(pendingStatusFilterProvider.notifier).state = e.key, onSelected: (_) =>
ref.read(pendingStatusFilterProvider.notifier).state = e.key,
), ),
], ],
); );
@@ -197,7 +217,8 @@ class _PendingCard extends StatelessWidget {
if (p.ticker != null) 'тикер ${p.ticker}', if (p.ticker != null) 'тикер ${p.ticker}',
if (p.board != null) 'доска ${p.board}', if (p.board != null) 'доска ${p.board}',
if (p.currency != null) p.currency!, if (p.currency != null) p.currency!,
if (p.assetClassHint != null) 'в отчёте: ${assetClassLabel(p.assetClassHint)}', if (p.assetClassHint != null)
'в отчёте: ${assetClassLabel(p.assetClassHint)}',
]; ];
final sample = <String>[ final sample = <String>[
'встречается ${p.occurrences} раз', 'встречается ${p.occurrences} раз',
@@ -214,18 +235,25 @@ class _PendingCard extends StatelessWidget {
children: [ children: [
Row( Row(
children: [ children: [
Expanded(child: Text(p.title, style: theme.textTheme.titleMedium)), Expanded(
child: Text(p.title, style: theme.textTheme.titleMedium),
),
if (!open) if (!open)
Chip( Chip(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
label: Text(p.status == 'ignored' ? 'игнорируется' : 'привязан'), label: Text(
p.status == 'ignored' ? 'игнорируется' : 'привязан',
),
), ),
], ],
), ),
if (facts.isNotEmpty) if (facts.isNotEmpty)
Padding( Padding(
padding: const EdgeInsets.only(top: 4), padding: const EdgeInsets.only(top: 4),
child: Text(facts.join(' · '), style: theme.textTheme.bodySmall), child: Text(
facts.join(' · '),
style: theme.textTheme.bodySmall,
),
), ),
Padding( Padding(
padding: const EdgeInsets.only(top: 2), padding: const EdgeInsets.only(top: 2),
@@ -236,7 +264,9 @@ class _PendingCard extends StatelessWidget {
child: Text( child: Text(
'источник ${p.source} · ключ ${p.sourceKey}' 'источник ${p.source} · ключ ${p.sourceKey}'
'${p.firstSeenFileId != null ? ' · файл №${p.firstSeenFileId}' : ''}', '${p.firstSeenFileId != null ? ' · файл №${p.firstSeenFileId}' : ''}',
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.outline), style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.outline,
),
), ),
), ),
if (open) ...[ if (open) ...[
+7 -5
View File
@@ -4,7 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_client.dart'; import '../../core/api/api_client.dart';
import 'data/pending_api.dart'; import 'data/pending_api.dart';
final pendingApiProvider = Provider<PendingApi>((ref) => PendingApi(ref.watch(apiProvider).dio)); final pendingApiProvider = Provider<PendingApi>(
(ref) => PendingApi(ref.watch(apiProvider).dio),
);
/// `pending | resolved | ignored | all` — the filter of the resolve screen. /// `pending | resolved | ignored | all` — the filter of the resolve screen.
final pendingStatusFilterProvider = StateProvider<String>((ref) => 'pending'); final pendingStatusFilterProvider = StateProvider<String>((ref) => 'pending');
@@ -13,7 +15,7 @@ final pendingInstrumentsProvider =
FutureProvider.autoDispose<List<PendingInstrument>>((ref) async { FutureProvider.autoDispose<List<PendingInstrument>>((ref) async {
final status = ref.watch(pendingStatusFilterProvider); final status = ref.watch(pendingStatusFilterProvider);
return ref.watch(pendingApiProvider).list(status: status); return ref.watch(pendingApiProvider).list(status: status);
}); });
/// How many rows still await a decision — shown as a badge next to the import screen's link. /// How many rows still await a decision — shown as a badge next to the import screen's link.
final pendingCountProvider = FutureProvider.autoDispose<int>((ref) async { final pendingCountProvider = FutureProvider.autoDispose<int>((ref) async {
@@ -23,12 +25,12 @@ final pendingCountProvider = FutureProvider.autoDispose<int>((ref) async {
/// Instrument search for the "link to an existing instrument" dialog. This one endpoint is /// Instrument search for the "link to an existing instrument" dialog. This one endpoint is
/// already in the generated client, so it goes through it rather than raw Dio. /// already in the generated client, so it goes through it rather than raw Dio.
final instrumentSearchProvider = final instrumentSearchProvider = FutureProvider.autoDispose
FutureProvider.autoDispose.family<List<InstrumentOut>, String>((ref, query) async { .family<List<InstrumentOut>, String>((ref, query) async {
if (query.trim().length < 2) return const []; if (query.trim().length < 2) return const [];
final r = await ref final r = await ref
.watch(apiProvider) .watch(apiProvider)
.getInstrumentsApi() .getInstrumentsApi()
.instrumentsList(q: query.trim(), limit: 25); .instrumentsList(q: query.trim(), limit: 25);
return r.data ?? const []; return r.data ?? const [];
}); });
@@ -21,12 +21,15 @@ class _CreateInstrumentDialogState extends State<CreateInstrumentDialog> {
late final _ticker = TextEditingController(text: widget.pending.ticker ?? ''); late final _ticker = TextEditingController(text: widget.pending.ticker ?? '');
late final _board = TextEditingController(text: widget.pending.board ?? ''); late final _board = TextEditingController(text: widget.pending.board ?? '');
late final _name = TextEditingController(text: widget.pending.name ?? ''); late final _name = TextEditingController(text: widget.pending.name ?? '');
late final _currency = TextEditingController(text: widget.pending.currency ?? 'RUB'); late final _currency = TextEditingController(
text: widget.pending.currency ?? 'RUB',
);
late final _lot = TextEditingController(text: '1'); late final _lot = TextEditingController(text: '1');
/// `asset_class_hint` is what the report's own section said (e.g. the «Фонды» table), so /// `asset_class_hint` is what the report's own section said (e.g. the «Фонды» table), so
/// it is offered as the initial value of a control the user must still look at. /// it is offered as the initial value of a control the user must still look at.
late String _assetClass = assetClassKeys.contains(widget.pending.assetClassHint) late String _assetClass =
assetClassKeys.contains(widget.pending.assetClassHint)
? widget.pending.assetClassHint! ? widget.pending.assetClassHint!
: 'share'; : 'share';
@@ -43,7 +46,8 @@ class _CreateInstrumentDialogState extends State<CreateInstrumentDialog> {
void _submit() { void _submit() {
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
Navigator.of(context).pop(NewInstrument( Navigator.of(context).pop(
NewInstrument(
assetClass: _assetClass, assetClass: _assetClass,
name: _name.text.trim(), name: _name.text.trim(),
currency: _currency.text.trim().toUpperCase(), currency: _currency.text.trim().toUpperCase(),
@@ -51,7 +55,8 @@ class _CreateInstrumentDialogState extends State<CreateInstrumentDialog> {
ticker: _ticker.text.trim(), ticker: _ticker.text.trim(),
board: _board.text.trim(), board: _board.text.trim(),
lot: int.tryParse(_lot.text.trim()), lot: int.tryParse(_lot.text.trim()),
)); ),
);
} }
@override @override
@@ -72,27 +77,44 @@ class _CreateInstrumentDialogState extends State<CreateInstrumentDialog> {
decoration: const InputDecoration(labelText: 'Класс актива'), decoration: const InputDecoration(labelText: 'Класс актива'),
items: [ items: [
for (final key in assetClassKeys) for (final key in assetClassKeys)
DropdownMenuItem(value: key, child: Text('${assetClassLabel(key)} ($key)')), DropdownMenuItem(
value: key,
child: Text('${assetClassLabel(key)} ($key)'),
),
], ],
onChanged: (v) => setState(() => _assetClass = v ?? _assetClass), onChanged: (v) =>
setState(() => _assetClass = v ?? _assetClass),
), ),
_field(_name, 'Название', required: true), _field(_name, 'Название', required: true),
_field(_isin, 'ISIN'), _field(_isin, 'ISIN'),
_field(_ticker, 'Тикер'), _field(_ticker, 'Тикер'),
_field(_board, 'Доска (TQBR, TQTF…)'), _field(_board, 'Доска (TQBR, TQTF…)'),
_field(_currency, 'Валюта', required: true), _field(_currency, 'Валюта', required: true),
_field(_lot, 'Лот', keyboard: TextInputType.number, validator: (v) { _field(
_lot,
'Лот',
keyboard: TextInputType.number,
validator: (v) {
if (v == null || v.trim().isEmpty) return null; if (v == null || v.trim().isEmpty) return null;
return int.tryParse(v.trim()) == null ? 'Целое число' : null; return int.tryParse(v.trim()) == null
}), ? 'Целое число'
: null;
},
),
], ],
), ),
), ),
), ),
), ),
actions: [ actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Отмена')), TextButton(
FilledButton(onPressed: _submit, child: const Text('Создать и привязать')), onPressed: () => Navigator.of(context).pop(),
child: const Text('Отмена'),
),
FilledButton(
onPressed: _submit,
child: const Text('Создать и привязать'),
),
], ],
); );
} }
@@ -110,9 +132,12 @@ class _CreateInstrumentDialogState extends State<CreateInstrumentDialog> {
controller: controller, controller: controller,
keyboardType: keyboard, keyboardType: keyboard,
decoration: InputDecoration(labelText: label, isDense: true), decoration: InputDecoration(labelText: label, isDense: true),
validator: validator ?? validator:
validator ??
(required (required
? (v) => (v == null || v.trim().isEmpty) ? 'Обязательное поле' : null ? (v) => (v == null || v.trim().isEmpty)
? 'Обязательное поле'
: null
: null), : null),
), ),
); );
@@ -18,12 +18,14 @@ class LinkInstrumentDialog extends ConsumerStatefulWidget {
final String initialQuery; final String initialQuery;
@override @override
ConsumerState<LinkInstrumentDialog> createState() => _LinkInstrumentDialogState(); ConsumerState<LinkInstrumentDialog> createState() =>
_LinkInstrumentDialogState();
} }
class _LinkInstrumentDialogState extends ConsumerState<LinkInstrumentDialog> { class _LinkInstrumentDialogState extends ConsumerState<LinkInstrumentDialog> {
late final TextEditingController _controller = late final TextEditingController _controller = TextEditingController(
TextEditingController(text: widget.initialQuery); text: widget.initialQuery,
);
String _query = ''; String _query = '';
Timer? _debounce; Timer? _debounce;
@@ -75,7 +77,9 @@ class _LinkInstrumentDialogState extends ConsumerState<LinkInstrumentDialog> {
error: (e, _) => Center(child: Text('$e')), error: (e, _) => Center(child: Text('$e')),
data: (rows) { data: (rows) {
if (_query.trim().length < 2) { if (_query.trim().length < 2) {
return const Center(child: Text('Введите минимум 2 символа')); return const Center(
child: Text('Введите минимум 2 символа'),
);
} }
if (rows.isEmpty) { if (rows.isEmpty) {
return const Center(child: Text('Ничего не найдено')); return const Center(child: Text('Ничего не найдено'));
@@ -108,10 +112,12 @@ class _LinkInstrumentDialogState extends ConsumerState<LinkInstrumentDialog> {
].join(' · '); ].join(' · ');
return ListTile( return ListTile(
dense: true, dense: true,
title: Text([ title: Text(
[
if (instrument.ticker != null) instrument.ticker!, if (instrument.ticker != null) instrument.ticker!,
instrument.name, instrument.name,
].join(' · ')), ].join(' · '),
),
subtitle: Text(subtitle), subtitle: Text(subtitle),
onTap: () => Navigator.of(context).pop(instrument.id), onTap: () => Navigator.of(context).pop(instrument.id),
); );
+74 -7
View File
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/chart_colors.dart'; import '../../core/theme/chart_colors.dart';
import '../../core/utils/compact_number.dart';
import '../../core/utils/ru_date.dart'; import '../../core/utils/ru_date.dart';
import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/empty_state.dart'; import '../../core/widgets/empty_state.dart';
@@ -249,7 +250,8 @@ class _ValueChart extends StatelessWidget {
spots.add(FlSpot(i.toDouble(), _d(rows[i].totalRub))); spots.add(FlSpot(i.toDouble(), _d(rows[i].totalRub)));
invested.add(FlSpot(i.toDouble(), _d(rows[i].investedNetRub))); invested.add(FlSpot(i.toDouble(), _d(rows[i].investedNetRub)));
} }
return SizedBox( const names = ['Стоимость', 'Вложено (нетто)'];
final chart = SizedBox(
height: 220, height: 220,
child: LineChart( child: LineChart(
LineChartData( LineChartData(
@@ -258,20 +260,47 @@ class _ValueChart extends StatelessWidget {
titlesData: FlTitlesData( titlesData: FlTitlesData(
topTitles: const AxisTitles(), topTitles: const AxisTitles(),
rightTitles: const AxisTitles(), rightTitles: const AxisTitles(),
leftTitles: const AxisTitles( leftTitles: AxisTitles(
sideTitles: SideTitles(showTitles: true, reservedSize: 56), sideTitles: SideTitles(
showTitles: true,
reservedSize: 56,
getTitlesWidget: (value, meta) {
// the axis ends sit on the data's min/max and land on top of the round
// labels next to them (414,6 тыс over 400 тыс)
if (value == meta.min || value == meta.max) {
return const SizedBox.shrink();
}
return SideTitleWidget(
axisSide: meta.axisSide,
child: Text(
compactNumber(value),
style: theme.textTheme.bodySmall,
),
);
},
),
), ),
bottomTitles: AxisTitles( bottomTitles: AxisTitles(
sideTitles: SideTitles( sideTitles: SideTitles(
showTitles: true, showTitles: true,
reservedSize: 28, reservedSize: 28,
interval: (rows.length / 4).clamp(1, double.infinity), // ticks at the quarters of the span, so the last one is the last day
interval: ((rows.length - 1) / 4).clamp(1.0, double.infinity),
getTitlesWidget: (value, meta) { getTitlesWidget: (value, meta) {
// float steps can land a label a hair before the last one; keep only one
if (value != meta.max &&
meta.max - value < meta.appliedInterval * 0.1) {
return const SizedBox.shrink();
}
final i = value.round(); final i = value.round();
if (i < 0 || i >= rows.length) return const SizedBox.shrink(); if (i < 0 || i >= rows.length) return const SizedBox.shrink();
return Text( return SideTitleWidget(
axisSide: meta.axisSide,
fitInside: SideTitleFitInsideData.fromTitleMeta(meta),
child: Text(
ruMonthYearShort(rows[i].d), ruMonthYearShort(rows[i].d),
style: theme.textTheme.bodySmall, style: theme.textTheme.bodySmall,
),
); );
}, },
), ),
@@ -297,9 +326,10 @@ class _ValueChart extends StatelessWidget {
lineTouchData: LineTouchData( lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData( touchTooltipData: LineTouchTooltipData(
getTooltipItems: (touched) => [ getTooltipItems: (touched) => [
for (final t in touched) for (final (i, t) in touched.indexed)
LineTooltipItem( LineTooltipItem(
'${ruDate(rows[t.x.round()].d)}\n' '${i == 0 ? '${ruDate(rows[t.x.round()].d)}\n' : ''}'
'${names[t.barIndex]}: '
'${MoneyText.format(t.y.toStringAsFixed(2), 'RUB')}', '${MoneyText.format(t.y.toStringAsFixed(2), 'RUB')}',
theme.textTheme.bodySmall ?? const TextStyle(), theme.textTheme.bodySmall ?? const TextStyle(),
), ),
@@ -309,6 +339,43 @@ class _ValueChart extends StatelessWidget {
), ),
), ),
); );
// the dashed line has no other explanation: say what each of the two is
Widget key(Color color, String label, {bool dashed = false}) => Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 18,
child: dashed
? Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
for (var i = 0; i < 3; i++)
Container(width: 4, height: 2, color: color),
],
)
: Container(height: 2, color: color),
),
const SizedBox(width: 6),
Text(label, style: theme.textTheme.bodySmall),
],
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
chart,
const SizedBox(height: 8),
Wrap(
spacing: 16,
runSpacing: 4,
children: [
key(ChartColors.slot1Blue, names[0]),
key(ChartColors.slot4Yellow, names[1], dashed: true),
],
),
],
);
} }
} }
+16 -62
View File
@@ -1,13 +1,11 @@
import 'package:decimal/decimal.dart'; import 'package:decimal/decimal.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:fintracker_api/fintracker_api.dart'; import 'package:fintracker_api/fintracker_api.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_client.dart'; import '../../core/api/api_client.dart';
import '../../core/auth/auth_controller.dart'; import '../../core/auth/auth_controller.dart';
import '../../core/theme/chart_colors.dart';
import '../../core/utils/ru_date.dart'; import '../../core/utils/ru_date.dart';
import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/asset_icon.dart'; import '../../core/widgets/asset_icon.dart';
@@ -17,6 +15,7 @@ import '../../core/widgets/stale_banner.dart';
import '../../core/widgets/help_tip.dart'; import '../../core/widgets/help_tip.dart';
import 'instrument_edit_dialog.dart'; import 'instrument_edit_dialog.dart';
import 'labels.dart'; import 'labels.dart';
import 'price_history.dart';
import 'providers.dart'; import 'providers.dart';
double _d(String s) => Decimal.parse(s).toDouble(); double _d(String s) => Decimal.parse(s).toDouble();
@@ -87,14 +86,24 @@ class InstrumentPage extends ConsumerWidget {
StaleBanner(fetchedAt: cached.fetchedAt!), StaleBanner(fetchedAt: cached.fetchedAt!),
_Header(instrument: d.instrument, holding: d.holding), _Header(instrument: d.instrument, holding: d.holding),
const SizedBox(height: 16), const SizedBox(height: 16),
_Section( if (d.prices.isEmpty)
title: 'Цена', const _Section(
child: d.prices.isEmpty title: 'История цены',
? const EmptyState( child: EmptyState(
icon: Icons.show_chart, icon: Icons.show_chart,
message: 'Цен нет — стоимость позиции неизвестна.', message: 'Цен нет — стоимость позиции неизвестна.',
),
) )
: _PriceChart(prices: d.prices), else
PriceHistory(
instrumentId: instrumentId,
label: d.instrument.ticker ?? d.instrument.name,
isBond: d.instrument.assetClass == 'bond',
trades: [
for (final e in d.events)
if (e.kind == EventKind.buy || e.kind == EventKind.sell)
e.tradeDate,
],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_Section( _Section(
@@ -287,61 +296,6 @@ class _Fact extends StatelessWidget {
} }
} }
class _PriceChart extends StatelessWidget {
const _PriceChart({required this.prices});
final List<PricePoint> prices;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox(
height: 200,
child: LineChart(
LineChartData(
gridData: const FlGridData(show: true, drawVerticalLine: false),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
topTitles: const AxisTitles(),
rightTitles: const AxisTitles(),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: true, reservedSize: 52),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
interval: (prices.length / 4).clamp(1, double.infinity),
getTitlesWidget: (value, meta) {
final i = value.round();
if (i < 0 || i >= prices.length)
return const SizedBox.shrink();
return Text(
ruMonthYearShort(prices[i].d),
style: theme.textTheme.bodySmall,
);
},
),
),
),
lineBarsData: [
LineChartBarData(
spots: [
for (var i = 0; i < prices.length; i++)
FlSpot(i.toDouble(), _d(prices[i].close)),
],
isCurved: false,
color: ChartColors.slot1Blue,
barWidth: 2,
dotData: FlDotData(show: prices.length < 2),
),
],
),
),
);
}
}
class _LotsTable extends StatelessWidget { class _LotsTable extends StatelessWidget {
const _LotsTable({required this.lots}); const _LotsTable({required this.lots});
@@ -0,0 +1,946 @@
import 'dart:math' as math;
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:intl/intl.dart';
import '../../core/api/api_client.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';
/// The periods offered above the chart. `all` has no start: the whole history there is.
enum PricePeriod {
week(''),
month(''),
quarter(''),
halfYear(''),
ytd('YTD'),
year('1г'),
fiveYears(''),
all('все');
const PricePeriod(this.label);
final String label;
DateTime? start(DateTime today) => switch (this) {
week => today.subtract(const Duration(days: 7)),
month => DateTime(today.year, today.month - 1, today.day),
quarter => DateTime(today.year, today.month - 3, today.day),
halfYear => DateTime(today.year, today.month - 6, today.day),
ytd => DateTime(today.year),
year => DateTime(today.year - 1, today.month, today.day),
fiveYears => DateTime(today.year - 5, today.month, today.day),
all => null,
};
}
/// What the chart plots.
enum PriceMode {
rub('Цена (₽)'),
rubAccrued('Цена (₽ + НКД)'),
percent('Цена (%)');
const PriceMode(this.label);
final String label;
}
/// «Мои сделки на графике»: kept for the whole app, not per instrument — it is a taste about
/// the chart, not a fact about a paper.
final showTradesProvider = StateProvider<bool>((ref) => true);
/// Which slice of an instrument's price history to load: from a date, or all of it.
typedef PriceQuery = ({int instrumentId, DateTime? from});
/// The price series of one instrument. There is no prices-only endpoint, so this reads the
/// instrument card with `prices_from` and keeps the prices — for an index the lots and events
/// in the same response are empty.
final priceSeriesProvider = FutureProvider.autoDispose
.family<List<PricePoint>, PriceQuery>((ref, q) async {
final r = await ref
.watch(apiProvider)
.getInstrumentsApi()
.instrumentsGet(
instrumentId: q.instrumentId,
pricesFrom: q.from ?? DateTime(1990),
);
return r.data?.prices ?? const [];
});
/// The benchmarks that can be drawn: active, and already synced into an instrument.
final chartBenchmarksProvider = FutureProvider.autoDispose<List<BenchmarkOut>>((
ref,
) async {
final r = await ref.watch(apiProvider).getBenchmarksApi().benchmarksList();
return [
for (final b in r.data ?? const <BenchmarkOut>[])
if (b.isActive && b.instrumentId != null) b,
];
});
// Chart x is whole days since the epoch: the instrument and its benchmarks trade on
// different days, so an index into one list would not line them up.
const _msPerDay = Duration.millisecondsPerDay;
int _day(DateTime d) =>
DateTime.utc(d.year, d.month, d.day).millisecondsSinceEpoch ~/ _msPerDay;
DateTime _fromDay(num day) =>
DateTime.fromMillisecondsSinceEpoch(day.round() * _msPerDay, isUtc: true);
class _Line {
const _Line({
required this.label,
required this.color,
required this.spots,
this.note,
});
final String label;
final Color color;
final List<FlSpot> spots;
/// Marks what the reader has to know about the series, e.g. a price index without dividends.
final String? note;
}
/// Everything the chart and the lines above it are drawn from.
class _Model {
const _Model({
required this.lines,
required this.percent,
required this.currency,
required this.first,
required this.last,
required this.tradeXs,
});
final List<_Line> lines;
final bool percent;
final String currency;
final DateTime first;
final DateTime last;
/// Days of the chart the user traded on, snapped onto a day the paper had a price.
final Set<double> tradeXs;
}
/// A price index throws its dividends away, so a holder's own return is flattered next to it;
/// the legend says so rather than leaving the comparison to look like-for-like.
String? _benchmarkNote(BenchmarkOut b) =>
b.kind == 'price' ? 'без дивидендов' : null;
String _money(double v, String currency) =>
MoneyText.format(v.toStringAsFixed(6), currency);
String _percent(double v, {int digits = 2}) {
final n = NumberFormat.decimalPatternDigits(
locale: 'ru_RU',
decimalDigits: digits,
).format(v);
return '${v > 0 ? '+' : ''}$n %';
}
Color _signColor(double v) => v < 0 ? ChartColors.loss : ChartColors.gain;
/// The chart block of the instrument card: benchmarks to lay over it, then the card with the
/// mode, the period switch, the change over the period and the chart itself.
///
/// With no benchmark the axis is the price; with one the axis turns into the change since the
/// start of the period, the only scale an instrument and an index share.
class PriceHistory extends ConsumerStatefulWidget {
const PriceHistory({
required this.instrumentId,
required this.label,
this.isBond = false,
this.trades = const [],
super.key,
});
final int instrumentId;
final String label;
/// Bonds are quoted without the accrued coupon; only they get «Цена (₽ + НКД)».
final bool isBond;
/// Days the user bought or sold the paper, for the dots on the line.
final List<DateTime> trades;
@override
ConsumerState<PriceHistory> createState() => _PriceHistoryState();
}
class _PriceHistoryState extends ConsumerState<PriceHistory> {
PricePeriod _period = PricePeriod.year;
DateTimeRange? _custom;
PriceMode _mode = PriceMode.rub;
final Set<int> _benchmarkIds = {};
Future<void> _pickRange() async {
final now = DateTime.now();
final picked = await showDateRangePicker(
context: context,
firstDate: DateTime(2000),
lastDate: now,
initialDateRange: _custom,
);
if (picked != null) setState(() => _custom = picked);
}
void _toggleBenchmark(int id, bool on) => setState(() {
if (on) {
_benchmarkIds.add(id);
// an index cannot be laid over a price in roubles: the comparison is in percent
_mode = PriceMode.percent;
} else {
_benchmarkIds.remove(id);
}
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final from = _custom?.start ?? _period.start(today);
final to = _custom?.end;
final benchmarks = ref.watch(chartBenchmarksProvider);
final available = benchmarks.valueOrNull ?? const <BenchmarkOut>[];
final selected = [
for (final b in available)
if (_benchmarkIds.contains(b.id)) b,
];
final showTrades = ref.watch(showTradesProvider);
final series = ref.watch(
priceSeriesProvider((instrumentId: widget.instrumentId, from: from)),
);
final model = series.valueOrNull == null
? null
: _buildModel(series.valueOrNull!, selected, from, to);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: available.isNotEmpty
? _BenchmarkBar(
available: available,
selected: _benchmarkIds,
onChanged: _toggleBenchmark,
)
: Text(
benchmarks.hasValue
// a benchmark is listed only once the MOEX sync has stored its history
? 'Бенчмарков пока нет: они появятся после синка MOEX.'
: 'Бенчмарки',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
),
),
),
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Header(
mode: _mode,
modes: [
PriceMode.rub,
if (widget.isBond) PriceMode.rubAccrued,
PriceMode.percent,
],
onMode: (m) => setState(() => _mode = m),
showTrades: showTrades,
onShowTrades: (v) =>
ref.read(showTradesProvider.notifier).state = v,
),
const SizedBox(height: 12),
Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
runSpacing: 4,
children: [
_PeriodBar(
period: _custom == null ? _period : null,
customActive: _custom != null,
onPeriod: (p) => setState(() {
_period = p;
_custom = null;
}),
onCustom: _pickRange,
),
if (model != null) _Summary(model: model),
],
),
const SizedBox(height: 12),
AsyncValueView(
value: series,
onRetry: () => ref.invalidate(priceSeriesProvider),
data: (_) {
final m = model;
if (m == null) {
return const EmptyState(
icon: Icons.show_chart,
message: 'За выбранный период цен нет.',
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 300,
child: _HistoryChart(
model: m,
showTrades: showTrades,
),
),
const SizedBox(height: 8),
_Legend(
lines: m.lines,
hint: selected.isNotEmpty && !m.percent
? 'Бенчмарки сравниваются в режиме «Цена (%)».'
: null,
),
],
);
},
),
],
),
),
),
],
);
}
_Model? _buildModel(
List<PricePoint> prices,
List<BenchmarkOut> selected,
DateTime? from,
DateTime? to,
) {
final points = _within(prices, to);
if (points.isEmpty) return null;
final percent = _mode == PriceMode.percent;
final startDay = _day(points.first.d);
final lines = <_Line>[];
if (percent) {
lines.add(
_Line(
label: widget.label,
color: ChartColors.slot1Blue,
spots: _percentSpots(points, startDay, null),
),
);
// one earlier quote than the window: the base an index starts from on a day it was shut
final benchFrom = from?.subtract(const Duration(days: 10));
const palette = [
ChartColors.slot2Orange,
ChartColors.slot3Aqua,
ChartColors.slot4Yellow,
ChartColors.slot5Magenta,
];
for (final (i, b) in selected.indexed) {
final async = ref.watch(
priceSeriesProvider((instrumentId: b.instrumentId!, from: benchFrom)),
);
final loaded = async.valueOrNull;
// still loading, or failed: no line rather than a wrong one
if (loaded == null) continue;
lines.add(
_Line(
label: b.code,
color: palette[i % palette.length],
spots: _percentSpots(_within(loaded, to), startDay, startDay),
note: _benchmarkNote(b),
),
);
}
} else {
final withAccrued = _mode == PriceMode.rubAccrued;
lines.add(
_Line(
label: widget.label,
color: ChartColors.slot1Blue,
spots: [
for (final p in points)
FlSpot(
_day(p.d).toDouble(),
double.parse(p.close) +
(withAccrued ? double.parse(p.accruedInterest ?? '0') : 0),
),
],
),
);
}
return _Model(
lines: lines,
percent: percent,
currency: points.first.currency,
first: points.first.d,
last: points.last.d,
tradeXs: _snapTrades(widget.trades, lines.first.spots),
);
}
}
List<PricePoint> _within(List<PricePoint> prices, DateTime? to) => to == null
? prices
: [
for (final p in prices)
if (_day(p.d) <= _day(to)) p,
];
/// A trade happens on a day, but the line only has a point on trading days that have a
/// price: the dot goes on the last point at or before the trade, and only inside the window.
Set<double> _snapTrades(List<DateTime> trades, List<FlSpot> spots) {
if (spots.isEmpty) return const {};
final xs = [for (final s in spots) s.x];
final out = <double>{};
for (final t in trades) {
final day = _day(t).toDouble();
if (day < xs.first || day > xs.last) continue;
out.add(xs.lastWhere((x) => x <= day));
}
return out;
}
/// Change since [startDay], in percent. The base is the last quote on or before the start
/// (the first one after it when there is none), so an index that was shut on the start day
/// still begins at zero on the same date as the instrument.
List<FlSpot> _percentSpots(
List<PricePoint> prices,
int startDay,
int? baseOnOrBefore,
) {
double? base;
if (baseOnOrBefore != null) {
for (final p in prices) {
if (_day(p.d) <= baseOnOrBefore) base = double.parse(p.close);
}
}
final inWindow = [
for (final p in prices)
if (_day(p.d) >= startDay) p,
];
base ??= inWindow.isEmpty ? null : double.parse(inWindow.first.close);
if (base == null || base == 0) return const [];
return [
for (final p in inWindow)
FlSpot(_day(p.d).toDouble(), (double.parse(p.close) / base - 1) * 100),
];
}
/// «История цены», the mode of the chart and the «⋯» menu.
class _Header extends StatelessWidget {
const _Header({
required this.mode,
required this.modes,
required this.onMode,
required this.showTrades,
required this.onShowTrades,
});
final PriceMode mode;
final List<PriceMode> modes;
final ValueChanged<PriceMode> onMode;
final bool showTrades;
final ValueChanged<bool> onShowTrades;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
Expanded(
child: Text('История цены', style: theme.textTheme.titleMedium),
),
DropdownButtonHideUnderline(
child: DropdownButton<PriceMode>(
value: modes.contains(mode) ? mode : modes.first,
borderRadius: BorderRadius.circular(8),
style: theme.textTheme.bodyMedium,
items: [
for (final m in modes)
DropdownMenuItem(value: m, child: Text(m.label)),
],
onChanged: (m) {
if (m != null) onMode(m);
},
),
),
PopupMenuButton<bool>(
tooltip: 'Настройки графика',
icon: const Icon(Icons.more_horiz),
onSelected: onShowTrades,
itemBuilder: (_) => [
CheckedPopupMenuItem<bool>(
value: !showTrades,
checked: showTrades,
child: const Text('Мои сделки на графике'),
),
],
),
],
);
}
}
class _PeriodBar extends StatelessWidget {
const _PeriodBar({
required this.period,
required this.customActive,
required this.onPeriod,
required this.onCustom,
});
final PricePeriod? period;
final bool customActive;
final ValueChanged<PricePeriod> onPeriod;
final VoidCallback onCustom;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
Color? colorFor(bool active) => active ? theme.colorScheme.primary : null;
return Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
for (final p in PricePeriod.values)
TextButton(
onPressed: () => onPeriod(p),
style: TextButton.styleFrom(
minimumSize: const Size(40, 36),
padding: const EdgeInsets.symmetric(horizontal: 10),
foregroundColor:
colorFor(p == period) ?? theme.textTheme.bodyMedium?.color,
),
child: Text(p.label),
),
IconButton(
tooltip: 'Свой период',
onPressed: onCustom,
color: colorFor(customActive),
icon: const Icon(Icons.calendar_month_outlined),
),
],
);
}
}
/// «20 сент. 25 - 20 сент. 26 ● +48,90 ₽ (▲ 5,23 %)»: the change over what the chart shows.
class _Summary extends StatelessWidget {
const _Summary({required this.model});
final _Model model;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hint = theme.textTheme.bodySmall?.copyWith(color: theme.hintColor);
Widget item(_Line l) {
final first = l.spots.first.y;
final last = l.spots.last.y;
final delta = last - first;
// in roubles the change is a sum and a share of the start; in percent it is the value
final text = model.percent
? _percent(last)
: '${delta > 0 ? '+' : ''}${_money(delta, model.currency)} '
'(${delta < 0 ? '' : ''} ${_percent(first == 0 ? 0 : delta / first * 100).replaceFirst('+', '')})';
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: l.color, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(
text,
style: theme.textTheme.bodySmall?.copyWith(
color: _signColor(model.percent ? last : delta),
),
),
],
);
}
return Wrap(
spacing: 12,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
'${ruDayMonthYearShort(model.first)} - ${ruDayMonthYearShort(model.last)}',
style: hint,
),
for (final l in model.lines)
if (l.spots.isNotEmpty) item(l),
],
);
}
}
/// «Бенчмарки [IMOEX ×] Выбрать» — the card above the chart.
class _BenchmarkBar extends StatelessWidget {
const _BenchmarkBar({
required this.available,
required this.selected,
required this.onChanged,
});
final List<BenchmarkOut> available;
final Set<int> selected;
final void Function(int id, bool on) onChanged;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Wrap(
spacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text('Бенчмарки', style: theme.textTheme.bodyLarge),
for (final b in available)
if (selected.contains(b.id))
InputChip(
label: Text(b.code),
onDeleted: () => onChanged(b.id, false),
),
PopupMenuButton<int>(
tooltip: 'Выбрать бенчмарки',
onSelected: (id) => onChanged(id, !selected.contains(id)),
itemBuilder: (_) => [
for (final b in available)
CheckedPopupMenuItem<int>(
value: b.id,
checked: selected.contains(b.id),
child: Text(
_benchmarkNote(b) == null
? '${b.code} · ${b.name}'
: '${b.code} · ${b.name} (${_benchmarkNote(b)})',
),
),
],
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(
'Выбрать',
style: TextStyle(color: theme.colorScheme.primary),
),
),
),
],
);
}
}
/// What the colours are, and what to know about a series (a price index has no dividends,
/// an index may have nothing for the period). Silent for a lone line: the header says it.
class _Legend extends StatelessWidget {
const _Legend({required this.lines, this.hint});
final List<_Line> lines;
final String? hint;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final noted = lines.length > 1 || lines.any((l) => l.spots.isEmpty);
return Wrap(
spacing: 16,
runSpacing: 4,
children: [
if (noted)
for (final l in lines)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: l.color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(
[
l.label,
if (l.note != null) '(${l.note})',
if (l.spots.isEmpty) '— нет данных за период',
].join(' '),
style: theme.textTheme.bodySmall,
),
],
),
if (hint != null)
Text(
hint!,
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
),
],
);
}
}
class _HistoryChart extends StatelessWidget {
const _HistoryChart({required this.model, required this.showTrades});
final _Model model;
final bool showTrades;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final percent = model.percent;
final currency = model.currency;
// fl_chart has no use for a series without points; the legend still names it
final drawn = [
for (final l in model.lines)
if (l.spots.isNotEmpty) l,
];
final main = drawn.first;
final xs = [
for (final l in drawn)
for (final s in l.spots) s.x,
];
final ys = [
for (final l in drawn)
for (final s in l.spots) s.y,
];
var minX = xs.reduce(math.min);
var maxX = xs.reduce(math.max);
// a single quote has no width to draw a line across
if (minX == maxX) {
minX -= 1;
maxX += 1;
}
final span = maxX - minX;
// a few weeks of dates repeat the same month on every label — show the day then
final shortSpan = span <= 180;
// room above and below the data for the min/max captions and the line's own stroke
final lo = ys.reduce(math.min);
final hi = ys.reduce(math.max);
final pad = (hi - lo) > 0 ? (hi - lo) * 0.12 : (hi.abs() * 0.01 + 1);
final minY = lo - pad;
final maxY = hi + pad;
final mainYs = [for (final s in main.spots) s.y];
final mainLo = mainYs.reduce(math.min);
final mainHi = mainYs.reduce(math.max);
String fmt(double v) => percent ? _percent(v) : _money(v, currency);
final captionStyle = theme.textTheme.labelSmall?.copyWith(
color: theme.hintColor,
);
final faint = theme.hintColor.withValues(alpha: 0.35);
HorizontalLine extreme(double y, String prefix, Alignment at) =>
HorizontalLine(
y: y,
color: faint,
strokeWidth: 1,
label: HorizontalLineLabel(
show: true,
alignment: at,
style: captionStyle,
labelResolver: (_) => '$prefix: ${fmt(y)}',
),
);
return LineChart(
LineChartData(
minX: minX,
maxX: maxX,
minY: minY,
maxY: maxY,
gridData: FlGridData(
drawVerticalLine: false,
getDrawingHorizontalLine: (_) => FlLine(
color: theme.dividerColor.withValues(alpha: 0.25),
strokeWidth: 1,
),
),
borderData: FlBorderData(show: false),
extraLinesData: ExtraLinesData(
horizontalLines: [
if (percent)
HorizontalLine(
y: 0,
color: theme.hintColor,
strokeWidth: 1,
dashArray: [4, 4],
),
if (mainHi != mainLo) ...[
extreme(mainHi, 'max', Alignment.topRight),
extreme(mainLo, 'min', Alignment.bottomRight),
],
],
),
lineTouchData: LineTouchData(
getTouchedSpotIndicator: (bar, indexes) => [
for (final _ in indexes)
TouchedSpotIndicatorData(
FlLine(
color: theme.hintColor,
strokeWidth: 1,
dashArray: [4, 4],
),
FlDotData(
getDotPainter: (_, _, _, _) => FlDotCirclePainter(
radius: 4,
color: bar.color ?? ChartColors.slot1Blue,
strokeWidth: 2,
strokeColor: Colors.white,
),
),
),
],
touchTooltipData: LineTouchTooltipData(
tooltipRoundedRadius: 8,
tooltipPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
getTooltipColor: (_) => theme.colorScheme.surfaceContainerHighest,
getTooltipItems: (touched) => [
for (final (i, s) in touched.indexed)
LineTooltipItem(
i == 0 ? '${ruDayMonthYearShort(_fromDay(s.x))}\n' : '',
theme.textTheme.bodySmall!.copyWith(color: theme.hintColor),
children: [
TextSpan(
text: '',
style: TextStyle(color: drawn[s.barIndex].color),
),
TextSpan(
text: '${drawn[s.barIndex].label}: ',
style: TextStyle(color: theme.hintColor),
),
TextSpan(
text: fmt(s.y),
style: TextStyle(
color: theme.colorScheme.onSurface,
fontWeight: FontWeight.w700,
),
),
],
),
],
),
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(),
rightTitles: const AxisTitles(),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: percent ? 64 : 56,
getTitlesWidget: (value, meta) {
// the axis ends sit on the padded range and collide with their neighbours
if (value == meta.min || value == meta.max) {
return const SizedBox.shrink();
}
final digits = meta.appliedInterval >= 1 ? 0 : 2;
return SideTitleWidget(
axisSide: meta.axisSide,
child: Text(
percent
? _percent(value, digits: digits)
: NumberFormat.decimalPatternDigits(
locale: 'ru_RU',
decimalDigits: digits,
).format(value),
style: theme.textTheme.bodySmall,
),
);
},
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
interval: math.max(1, span / 4),
getTitlesWidget: (value, meta) {
// float steps can land a label a hair before the last one; keep only one
if (value != meta.max &&
meta.max - value < meta.appliedInterval * 0.1) {
return const SizedBox.shrink();
}
final d = _fromDay(value);
return SideTitleWidget(
axisSide: meta.axisSide,
fitInside: SideTitleFitInsideData.fromTitleMeta(meta),
child: Text(
shortSpan ? ruDayMonthShort(d) : ruMonthYearShort(d),
style: theme.textTheme.bodySmall,
),
);
},
),
),
),
lineBarsData: [
for (final (i, l) in drawn.indexed)
LineChartBarData(
spots: l.spots,
isCurved: false,
color: l.color,
barWidth: 2,
// the instrument's own line carries the fill and the trades; an index is a ruler
belowBarData: i == 0
? BarAreaData(
show: true,
cutOffY: percent ? 0 : minY,
applyCutOffY: true,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
l.color.withValues(alpha: 0.32),
l.color.withValues(alpha: 0.02),
],
),
)
: BarAreaData(show: false),
dotData: FlDotData(
show: l.spots.length < 2 || (i == 0 && showTrades),
checkToShowDot: (spot, _) =>
l.spots.length < 2 ||
(i == 0 && model.tradeXs.contains(spot.x)),
getDotPainter: (_, _, _, _) => FlDotCirclePainter(
radius: 4,
color: Colors.white,
strokeWidth: 2,
strokeColor: l.color,
),
),
),
],
),
);
}
}
@@ -34,8 +34,12 @@ class TargetWeight {
final String? band; final String? band;
final String? note; final String? note;
TargetWeight copyWith({String? bucket, String? targetWeight, String? band, String? note}) => TargetWeight copyWith({
TargetWeight( String? bucket,
String? targetWeight,
String? band,
String? note,
}) => TargetWeight(
bucket: bucket ?? this.bucket, bucket: bucket ?? this.bucket,
targetWeight: targetWeight ?? this.targetWeight, targetWeight: targetWeight ?? this.targetWeight,
band: band ?? this.band, band: band ?? this.band,
@@ -58,7 +62,11 @@ class TargetWeight {
} }
class TargetSet { class TargetSet {
const TargetSet({required this.dimension, this.targets = const [], this.weightsSum}); const TargetSet({
required this.dimension,
this.targets = const [],
this.weightsSum,
});
final String dimension; final String dimension;
final List<TargetWeight> targets; final List<TargetWeight> targets;
@@ -72,7 +80,8 @@ class TargetSet {
Decimal get localSum => sumDecimals(targets.map((t) => t.targetWeight)); Decimal get localSum => sumDecimals(targets.map((t) => t.targetWeight));
/// The contract's tolerance: the sum must be 1 within 0.0001. /// The contract's tolerance: the sum must be 1 within 0.0001.
bool get sumIsValid => (localSum - Decimal.one).abs() <= Decimal.parse('0.0001'); bool get sumIsValid =>
(localSum - Decimal.one).abs() <= Decimal.parse('0.0001');
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'dimension': dimension, 'dimension': dimension,
@@ -119,7 +128,8 @@ class RebalanceTrade {
/// tell an underweight recommendation from a wrong one. /// tell an underweight recommendation from a wrong one.
final bool blockedByCash; final bool blockedByCash;
String get title => ticker ?? name ?? (instrumentId == null ? '' : '#$instrumentId'); String get title =>
ticker ?? name ?? (instrumentId == null ? '' : '#$instrumentId');
static RebalanceTrade fromJson(Map<String, dynamic> json) => RebalanceTrade( static RebalanceTrade fromJson(Map<String, dynamic> json) => RebalanceTrade(
instrumentId: asInt(json['instrument_id']), instrumentId: asInt(json['instrument_id']),
@@ -191,7 +201,8 @@ class RebalancePlan {
final List<RebalanceBucket> buckets; final List<RebalanceBucket> buckets;
final List<String> warnings; final List<String> warnings;
bool get everythingWithinBand => buckets.isNotEmpty && buckets.every((b) => b.withinBand); bool get everythingWithinBand =>
buckets.isNotEmpty && buckets.every((b) => b.withinBand);
static RebalancePlan fromJson(Map<String, dynamic> json) => RebalancePlan( static RebalancePlan fromJson(Map<String, dynamic> json) => RebalancePlan(
portfolioId: asInt(json['portfolio_id']) ?? 0, portfolioId: asInt(json['portfolio_id']) ?? 0,
@@ -217,12 +228,18 @@ class RebalanceApi {
/// not — revisit once the route is in the spec. /// not — revisit once the route is in the spec.
/// See `docs/ai/offline-cache.md`: this hand-written client returns `Cached<T>` directly /// See `docs/ai/offline-cache.md`: this hand-written client returns `Cached<T>` directly
/// (there is no generated `Response<T>` for the screen to unwrap `r.cached` from). /// (there is no generated `Response<T>` for the screen to unwrap `r.cached` from).
Future<Cached<TargetSet>> getTargets(int portfolioId, {String dimension = 'asset_class'}) async { Future<Cached<TargetSet>> getTargets(
int portfolioId, {
String dimension = 'asset_class',
}) async {
final r = await _dio.get<Map<String, dynamic>>( final r = await _dio.get<Map<String, dynamic>>(
'$_base/$portfolioId/targets', '$_base/$portfolioId/targets',
queryParameters: {'dimension': dimension}, queryParameters: {'dimension': dimension},
); );
return Cached(TargetSet.fromJson(r.data ?? const {}), fetchedAt: r.extra['fetchedAt'] as DateTime?); return Cached(
TargetSet.fromJson(r.data ?? const {}),
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
} }
/// Full replacement of one dimension — partial updates are not supported by the contract. /// Full replacement of one dimension — partial updates are not supported by the contract.
@@ -241,7 +258,10 @@ class RebalanceApi {
}) async { }) async {
final r = await _dio.get<Map<String, dynamic>>( final r = await _dio.get<Map<String, dynamic>>(
'$_base/$portfolioId/rebalance', '$_base/$portfolioId/rebalance',
queryParameters: {'dimension': dimension, 'cash_available': ?cashAvailable}, queryParameters: {
'dimension': dimension,
'cash_available': ?cashAvailable,
},
); );
return Cached( return Cached(
RebalancePlan.fromJson(r.data ?? const {}), RebalancePlan.fromJson(r.data ?? const {}),
+3 -1
View File
@@ -10,5 +10,7 @@ String bucketLabelForKey(String dimension, String bucket) {
if (bucket == 'cash') return 'Денежные средства'; if (bucket == 'cash') return 'Денежные средства';
if (bucket == 'unknown') return 'Не указано'; if (bucket == 'unknown') return 'Не указано';
if (bucket.isEmpty) return ''; if (bucket.isEmpty) return '';
return dimension == 'asset_class' ? (assetClassLabels[bucket] ?? bucket) : bucket; return dimension == 'asset_class'
? (assetClassLabels[bucket] ?? bucket)
: bucket;
} }
+29 -10
View File
@@ -62,7 +62,9 @@ class RebalancePlanTab extends ConsumerWidget {
child: const ListTile( child: const ListTile(
leading: Icon(Icons.check_circle_outline), leading: Icon(Icons.check_circle_outline),
title: Text('Все группы внутри коридора'), title: Text('Все группы внутри коридора'),
subtitle: Text('Действий не требуется — отклонения меньше заданного допуска.'), subtitle: Text(
'Действий не требуется — отклонения меньше заданного допуска.',
),
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -104,7 +106,9 @@ class _Header extends ConsumerWidget {
: MoneyText(plan.totalValueRub!, currency: 'RUB'), : MoneyText(plan.totalValueRub!, currency: 'RUB'),
), ),
_Figure( _Figure(
label: whatIf == null ? 'Доступно денег' : 'Доступно денег (what-if)', label: whatIf == null
? 'Доступно денег'
: 'Доступно денег (what-if)',
child: plan.cashAvailableRub == null child: plan.cashAvailableRub == null
? const Text('') ? const Text('')
: MoneyText(plan.cashAvailableRub!, currency: 'RUB'), : MoneyText(plan.cashAvailableRub!, currency: 'RUB'),
@@ -149,8 +153,9 @@ class _WhatIfCashField extends ConsumerStatefulWidget {
} }
class _WhatIfCashFieldState extends ConsumerState<_WhatIfCashField> { class _WhatIfCashFieldState extends ConsumerState<_WhatIfCashField> {
late final TextEditingController _controller = late final TextEditingController _controller = TextEditingController(
TextEditingController(text: widget.current ?? ''); text: widget.current ?? '',
);
@override @override
void dispose() { void dispose() {
@@ -159,7 +164,10 @@ class _WhatIfCashFieldState extends ConsumerState<_WhatIfCashField> {
} }
void _apply() { void _apply() {
final text = _controller.text.trim().replaceAll(',', '.').replaceAll(' ', ''); final text = _controller.text
.trim()
.replaceAll(',', '.')
.replaceAll(' ', '');
ref.read(whatIfCashProvider.notifier).state = text.isEmpty ? null : text; ref.read(whatIfCashProvider.notifier).state = text.isEmpty ? null : text;
} }
@@ -228,7 +236,8 @@ class _BucketCard extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
return SectionCard( return SectionCard(
title: bucketLabelForKey(dimension, bucket.bucket), title: bucketLabelForKey(dimension, bucket.bucket),
subtitle: 'сейчас ${formatShareAsPercent(bucket.currentWeight)} · ' subtitle:
'сейчас ${formatShareAsPercent(bucket.currentWeight)} · '
'цель ${formatShareAsPercent(bucket.targetWeight)} · ' 'цель ${formatShareAsPercent(bucket.targetWeight)} · '
'отклонение ${formatShareAsPercent(bucket.drift, signed: true)}', 'отклонение ${formatShareAsPercent(bucket.drift, signed: true)}',
trailing: bucket.withinBand trailing: bucket.withinBand
@@ -238,7 +247,9 @@ class _BucketCard extends StatelessWidget {
: MoneyText( : MoneyText(
bucket.deltaValueRub!, bucket.deltaValueRub!,
currency: 'RUB', currency: 'RUB',
style: TextStyle(color: signColor(context, bucket.deltaValueRub)), style: TextStyle(
color: signColor(context, bucket.deltaValueRub),
),
)), )),
child: bucket.withinBand child: bucket.withinBand
? Text( ? Text(
@@ -251,7 +262,9 @@ class _BucketCard extends StatelessWidget {
'вероятно, у подходящих бумаг нет цены — см. предупреждения выше.', 'вероятно, у подходящих бумаг нет цены — см. предупреждения выше.',
style: theme.textTheme.bodySmall, style: theme.textTheme.bodySmall,
) )
: Column(children: [for (final t in bucket.trades) TradeRow(trade: t)]), : Column(
children: [for (final t in bucket.trades) TradeRow(trade: t)],
),
); );
} }
} }
@@ -301,7 +314,9 @@ class TradeRow extends StatelessWidget {
: () => context.push('/portfolio/instrument/${trade.instrumentId}'), : () => context.push('/portfolio/instrument/${trade.instrumentId}'),
leading: Icon( leading: Icon(
isBuy ? Icons.add_circle_outline : Icons.remove_circle_outline, isBuy ? Icons.add_circle_outline : Icons.remove_circle_outline,
color: isBuy ? Theme.of(context).colorScheme.primary : theme.colorScheme.error, color: isBuy
? Theme.of(context).colorScheme.primary
: theme.colorScheme.error,
), ),
title: Row( title: Row(
children: [ children: [
@@ -320,7 +335,11 @@ class TradeRow extends StatelessWidget {
), ),
trailing: noQty || trade.amountRub == null trailing: noQty || trade.amountRub == null
? const Text('') ? const Text('')
: MoneyText(trade.amountRub!, currency: 'RUB', style: theme.textTheme.titleSmall), : MoneyText(
trade.amountRub!,
currency: 'RUB',
style: theme.textTheme.titleSmall,
),
); );
} }
} }
+15 -7
View File
@@ -5,8 +5,9 @@ import '../../core/cache/cached.dart';
import '../portfolio/providers.dart' show scopeProvider, scopesProvider; import '../portfolio/providers.dart' show scopeProvider, scopesProvider;
import 'data/rebalance_api.dart'; import 'data/rebalance_api.dart';
final rebalanceApiProvider = final rebalanceApiProvider = Provider<RebalanceApi>(
Provider<RebalanceApi>((ref) => RebalanceApi(ref.watch(apiProvider).dio)); (ref) => RebalanceApi(ref.watch(apiProvider).dio),
);
/// A portfolio the user can rebalance, derived from the scope list the analytics API /// A portfolio the user can rebalance, derived from the scope list the analytics API
/// already publishes (`portfolio:<id>`): there is no separate portfolios endpoint, and /// already publishes (`portfolio:<id>`): there is no separate portfolios endpoint, and
@@ -17,7 +18,9 @@ class PortfolioRef {
final String name; final String name;
} }
final portfoliosProvider = FutureProvider.autoDispose<List<PortfolioRef>>((ref) async { final portfoliosProvider = FutureProvider.autoDispose<List<PortfolioRef>>((
ref,
) async {
final scopes = await ref.watch(scopesProvider.future); final scopes = await ref.watch(scopesProvider.future);
return [ return [
for (final s in scopes) for (final s in scopes)
@@ -41,20 +44,25 @@ final whatIfCashProvider = StateProvider<String?>((ref) => null);
/// See `docs/ai/offline-cache.md`. No portfolio selected yet is a live, empty answer — not a /// See `docs/ai/offline-cache.md`. No portfolio selected yet is a live, empty answer — not a
/// stale one — so it is wrapped with `fetchedAt: null` rather than left unwrapped. /// stale one — so it is wrapped with `fetchedAt: null` rather than left unwrapped.
final targetsProvider = FutureProvider.autoDispose<Cached<TargetSet>>((ref) async { final targetsProvider = FutureProvider.autoDispose<Cached<TargetSet>>((
ref,
) async {
final id = ref.watch(selectedPortfolioProvider); final id = ref.watch(selectedPortfolioProvider);
final dimension = ref.watch(targetDimensionProvider); final dimension = ref.watch(targetDimensionProvider);
if (id == null) return Cached(TargetSet(dimension: dimension)); if (id == null) return Cached(TargetSet(dimension: dimension));
return ref.watch(rebalanceApiProvider).getTargets(id, dimension: dimension); return ref.watch(rebalanceApiProvider).getTargets(id, dimension: dimension);
}); });
final rebalancePlanProvider = FutureProvider.autoDispose<Cached<RebalancePlan?>>((ref) async { final rebalancePlanProvider =
FutureProvider.autoDispose<Cached<RebalancePlan?>>((ref) async {
final id = ref.watch(selectedPortfolioProvider); final id = ref.watch(selectedPortfolioProvider);
final dimension = ref.watch(targetDimensionProvider); final dimension = ref.watch(targetDimensionProvider);
final cash = ref.watch(whatIfCashProvider); final cash = ref.watch(whatIfCashProvider);
if (id == null) return const Cached(null); if (id == null) return const Cached(null);
return ref.watch(rebalanceApiProvider).plan(id, dimension: dimension, cashAvailable: cash); return ref
}); .watch(rebalanceApiProvider)
.plan(id, dimension: dimension, cashAvailable: cash);
});
/// After a successful PUT the numbers are recomputed on the server, so both sides of the /// After a successful PUT the numbers are recomputed on the server, so both sides of the
/// screen are refetched rather than patched in place. /// screen are refetched rather than patched in place.
+49 -10
View File
@@ -1,11 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/cache/cached.dart'; import '../../core/cache/cached.dart';
import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/empty_state.dart'; import '../../core/widgets/empty_state.dart';
import '../../core/widgets/stale_banner.dart'; import '../../core/widgets/stale_banner.dart';
import '../portfolio/labels.dart' show dimensionLabels; import '../portfolio/labels.dart' show dimensionLabels;
import '../portfolios/providers.dart' show portfolioListProvider;
import 'data/rebalance_api.dart'; import 'data/rebalance_api.dart';
import 'plan_tab.dart'; import 'plan_tab.dart';
import 'providers.dart'; import 'providers.dart';
@@ -45,7 +47,10 @@ class RebalancePage extends ConsumerWidget {
), ),
], ],
bottom: const TabBar( bottom: const TabBar(
tabs: [Tab(text: 'Целевые веса'), Tab(text: 'Рекомендации')], tabs: [
Tab(text: 'Целевые веса'),
Tab(text: 'Рекомендации'),
],
), ),
), ),
body: Column( body: Column(
@@ -61,21 +66,48 @@ class RebalancePage extends ConsumerWidget {
onRetry: () => ref.invalidate(portfoliosProvider), onRetry: () => ref.invalidate(portfoliosProvider),
data: (list) { data: (list) {
if (list.isEmpty) { if (list.isEmpty) {
return const EmptyState( // A portfolio can exist yet be absent here: this list comes from the
// analytics scopes, which only cover accounts that have ledger events.
final created =
ref
.watch(portfolioListProvider)
.valueOrNull
?.isNotEmpty ??
false;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
EmptyState(
icon: Icons.pie_chart_outline, icon: Icons.pie_chart_outline,
message: 'Портфелей пока нет.\n' message: created
'Ребалансировка считается по портфелю: заведите его в настройках счетов.', ? 'Портфели есть, но в них нет счетов с бумагами.\n'
'Добавьте брокерские счета: у счетов ZenMoney нет сделок, '
'считать по ним нечего.'
: 'Портфелей пока нет.\n'
'Ребалансировка считается по портфелю — набору счетов.',
),
FilledButton.icon(
onPressed: () => context.go('/portfolios'),
icon: Icon(created ? Icons.edit_outlined : Icons.add),
label: Text(
created ? 'Изменить портфели' : 'Создать портфель',
),
),
],
); );
} }
final selected = ref.watch(selectedPortfolioProvider); final selected = ref.watch(selectedPortfolioProvider);
if (selected == null || !list.any((p) => p.id == selected)) { if (selected == null || !list.any((p) => p.id == selected)) {
// pick the first portfolio once, after the list is known // pick the first portfolio once, after the list is known
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(selectedPortfolioProvider.notifier).state = list.first.id; ref.read(selectedPortfolioProvider.notifier).state =
list.first.id;
}); });
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
return const TabBarView(children: [TargetsTab(), RebalancePlanTab()]); return const TabBarView(
children: [TargetsTab(), RebalancePlanTab()],
);
}, },
), ),
), ),
@@ -91,16 +123,23 @@ class _PortfolioSelector extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final portfolios = ref.watch(portfoliosProvider).valueOrNull ?? const <PortfolioRef>[]; final portfolios =
ref.watch(portfoliosProvider).valueOrNull ?? const <PortfolioRef>[];
final selected = ref.watch(selectedPortfolioProvider); final selected = ref.watch(selectedPortfolioProvider);
if (portfolios.length < 2 || selected == null) return const SizedBox.shrink(); if (portfolios.length < 2 || selected == null)
return const SizedBox.shrink();
return DropdownButtonHideUnderline( return DropdownButtonHideUnderline(
child: DropdownButton<int>( child: DropdownButton<int>(
value: portfolios.any((p) => p.id == selected) ? selected : portfolios.first.id, value: portfolios.any((p) => p.id == selected)
? selected
: portfolios.first.id,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
items: [ items: [
for (final p in portfolios) for (final p in portfolios)
DropdownMenuItem(value: p.id, child: Text(p.name, overflow: TextOverflow.ellipsis)), DropdownMenuItem(
value: p.id,
child: Text(p.name, overflow: TextOverflow.ellipsis),
),
], ],
onChanged: (v) { onChanged: (v) {
if (v != null) ref.read(selectedPortfolioProvider.notifier).state = v; if (v != null) ref.read(selectedPortfolioProvider.notifier).state = v;
+84 -30
View File
@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/utils/json.dart'; import '../../core/utils/json.dart';
import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/section_card.dart'; import '../../core/widgets/section_card.dart';
import '../../core/widgets/help_tip.dart';
import '../portfolio/labels.dart' show assetClassLabels; import '../portfolio/labels.dart' show assetClassLabels;
import 'data/rebalance_api.dart'; import 'data/rebalance_api.dart';
import 'labels.dart'; import 'labels.dart';
@@ -38,7 +39,8 @@ class _TargetsTabState extends ConsumerState<TargetsTab> {
_dirty = false; _dirty = false;
} }
Decimal get _sum => sumDecimals((_draft ?? const []).map((t) => t.targetWeight)); Decimal get _sum =>
sumDecimals((_draft ?? const []).map((t) => t.targetWeight));
bool get _sumIsValid => (_sum - Decimal.one).abs() <= Decimal.parse('0.0001'); bool get _sumIsValid => (_sum - Decimal.one).abs() <= Decimal.parse('0.0001');
@@ -52,13 +54,17 @@ class _TargetsTabState extends ConsumerState<TargetsTab> {
try { try {
await ref await ref
.read(rebalanceApiProvider) .read(rebalanceApiProvider)
.putTargets(portfolioId, TargetSet(dimension: dimension, targets: draft)); .putTargets(
portfolioId,
TargetSet(dimension: dimension, targets: draft),
);
if (!mounted) return; if (!mounted) return;
_dirty = false; _dirty = false;
_seededFor = null; // refetch reseeds the draft from the saved set _seededFor = null; // refetch reseeds the draft from the saved set
invalidateRebalanceProviders(ref); invalidateRebalanceProviders(ref);
ScaffoldMessenger.of(context) ScaffoldMessenger.of(
.showSnackBar(const SnackBar(content: Text('Целевые веса сохранены'))); context,
).showSnackBar(const SnackBar(content: Text('Целевые веса сохранены')));
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context) ScaffoldMessenger.of(context)
@@ -84,11 +90,16 @@ class _TargetsTabState extends ConsumerState<TargetsTab> {
return ListView( return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 88), padding: const EdgeInsets.fromLTRB(16, 16, 16, 88),
children: [ children: [
_SumBanner(sum: _sum, valid: _sumIsValid, serverSum: set.weightsSum), _SumBanner(
sum: _sum,
valid: _sumIsValid,
serverSum: set.weightsSum,
),
const SizedBox(height: 12), const SizedBox(height: 12),
SectionCard( SectionCard(
title: 'Целевые веса', title: 'Целевые веса',
subtitle: 'Вес — доля портфеля; допуск (band) — ширина коридора, ' subtitle:
'Вес — доля портфеля; допуск (band) — ширина коридора, '
'внутри которого сделки не предлагаются.', 'внутри которого сделки не предлагаются.',
child: Column( child: Column(
children: [ children: [
@@ -118,7 +129,11 @@ class _TargetsTabState extends ConsumerState<TargetsTab> {
onPressed: () => setState(() { onPressed: () => setState(() {
_draft = [ _draft = [
...draft, ...draft,
const TargetWeight(bucket: '', targetWeight: '0', band: '0.05'), const TargetWeight(
bucket: '',
targetWeight: '0',
band: '0.05',
),
]; ];
_dirty = true; _dirty = true;
}), }),
@@ -133,12 +148,18 @@ class _TargetsTabState extends ConsumerState<TargetsTab> {
Row( Row(
children: [ children: [
FilledButton.icon( FilledButton.icon(
onPressed: _saving || !_sumIsValid || draft.any((t) => t.bucket.isEmpty) onPressed:
_saving ||
!_sumIsValid ||
draft.any((t) => t.bucket.isEmpty)
? null ? null
: _save, : _save,
icon: _saving icon: _saving
? const SizedBox( ? const SizedBox(
width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined), : const Icon(Icons.save_outlined),
label: const Text('Сохранить'), label: const Text('Сохранить'),
), ),
@@ -224,12 +245,15 @@ class _TargetRow extends StatefulWidget {
} }
class _TargetRowState extends State<_TargetRow> { class _TargetRowState extends State<_TargetRow> {
late final TextEditingController _weight = late final TextEditingController _weight = TextEditingController(
TextEditingController(text: shareToPercentText(widget.target.targetWeight)); text: shareToPercentText(widget.target.targetWeight),
late final TextEditingController _band = );
TextEditingController(text: shareToPercentText(widget.target.band)); late final TextEditingController _band = TextEditingController(
late final TextEditingController _bucket = text: shareToPercentText(widget.target.band),
TextEditingController(text: widget.target.bucket); );
late final TextEditingController _bucket = TextEditingController(
text: widget.target.bucket,
);
@override @override
void dispose() { void dispose() {
@@ -257,20 +281,32 @@ class _TargetRowState extends State<_TargetRow> {
child: knownBuckets.isEmpty child: knownBuckets.isEmpty
? TextField( ? TextField(
controller: _bucket, controller: _bucket,
decoration: const InputDecoration(labelText: 'Группа', isDense: true), decoration: const InputDecoration(
onChanged: (v) => widget.onChanged(widget.target.copyWith(bucket: v)), labelText: 'Группа',
isDense: true,
),
onChanged: (v) =>
widget.onChanged(widget.target.copyWith(bucket: v)),
) )
: DropdownButtonFormField<String>( : DropdownButtonFormField<String>(
initialValue: initialValue: knownBuckets.contains(widget.target.bucket)
knownBuckets.contains(widget.target.bucket) ? widget.target.bucket : null, ? widget.target.bucket
: null,
isExpanded: true, isExpanded: true,
decoration: const InputDecoration(labelText: 'Группа', isDense: true), decoration: const InputDecoration(
labelText: 'Группа',
isDense: true,
),
items: [ items: [
for (final b in knownBuckets) for (final b in knownBuckets)
DropdownMenuItem(value: b, child: Text(bucketLabelForKey('asset_class', b))), DropdownMenuItem(
value: b,
child: Text(bucketLabelForKey('asset_class', b)),
),
], ],
onChanged: (v) => onChanged: (v) => widget.onChanged(
widget.onChanged(widget.target.copyWith(bucket: v ?? '')), widget.target.copyWith(bucket: v ?? ''),
),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -278,11 +314,18 @@ class _TargetRowState extends State<_TargetRow> {
flex: 2, flex: 2,
child: TextField( child: TextField(
controller: _weight, controller: _weight,
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(
decoration: const InputDecoration(labelText: 'Вес, %', isDense: true), decimal: true,
),
decoration: const InputDecoration(
labelText: 'Вес, %',
isDense: true,
),
onChanged: (v) { onChanged: (v) {
final share = percentTextToShare(v); final share = percentTextToShare(v);
widget.onChanged(widget.target.copyWith(targetWeight: share ?? '0')); widget.onChanged(
widget.target.copyWith(targetWeight: share ?? '0'),
);
}, },
), ),
), ),
@@ -291,10 +334,21 @@ class _TargetRowState extends State<_TargetRow> {
flex: 2, flex: 2,
child: TextField( child: TextField(
controller: _band, controller: _band,
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(
decoration: const InputDecoration(labelText: 'Допуск, %', isDense: true), decimal: true,
onChanged: (v) => ),
widget.onChanged(widget.target.copyWith(band: percentTextToShare(v))), decoration: const InputDecoration(
labelText: 'Допуск, %',
isDense: true,
suffixIcon: HelpTip(
'Допуск (band) — ширина коридора вокруг целевого веса: пока фактическая доля '
'внутри него, сделки не предлагаются. ±5 % значит «не трогать, пока доля в '
'пределах 5 процентных пунктов от цели».',
),
),
onChanged: (v) => widget.onChanged(
widget.target.copyWith(band: percentTextToShare(v)),
),
), ),
), ),
IconButton( IconButton(
+5 -1
View File
@@ -26,7 +26,11 @@ String? percentTextToShare(String text) {
/// `"0.032"` → `"3,20 %"`, signed. Kept local to the rebalance screen because drift is a /// `"0.032"` → `"3,20 %"`, signed. Kept local to the rebalance screen because drift is a
/// share, not a return, and the portfolio helper would sign it the same way by accident. /// share, not a return, and the portfolio helper would sign it the same way by accident.
String formatShareAsPercent(String? share, {bool signed = false, int digits = 2}) { String formatShareAsPercent(
String? share, {
bool signed = false,
int digits = 2,
}) {
final d = share == null ? null : Decimal.tryParse(share); final d = share == null ? null : Decimal.tryParse(share);
if (d == null) return ''; if (d == null) return '';
final p = (d * _hundred).toDouble(); final p = (d * _hundred).toDouble();
+14 -4
View File
@@ -5,17 +5,27 @@ import '../../core/api/api_client.dart';
import '../../core/cache/cached.dart'; import '../../core/cache/cached.dart';
/// See `docs/ai/offline-cache.md`. /// See `docs/ai/offline-cache.md`.
final rulesListProvider = FutureProvider.autoDispose<Cached<List<RuleOut>>>((ref) async { final rulesListProvider = FutureProvider.autoDispose<Cached<List<RuleOut>>>((
ref,
) async {
final r = await ref.watch(apiProvider).getRulesApi().rulesList(); final r = await ref.watch(apiProvider).getRulesApi().rulesList();
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); return Cached(
r.data ?? const [],
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
}); });
/// Enabled rules that matched nothing in the latest refresh — «устарело» here is a business /// Enabled rules that matched nothing in the latest refresh — «устарело» here is a business
/// concept (the rule looks dead), unrelated to the offline-cache staleness this file also /// concept (the rule looks dead), unrelated to the offline-cache staleness this file also
/// tracks; both happen to be named "stale" in their own domains. /// tracks; both happen to be named "stale" in their own domains.
final rulesStaleProvider = FutureProvider.autoDispose<Cached<List<RuleOut>>>((ref) async { final rulesStaleProvider = FutureProvider.autoDispose<Cached<List<RuleOut>>>((
ref,
) async {
final r = await ref.watch(apiProvider).getRulesApi().rulesStale(); final r = await ref.watch(apiProvider).getRulesApi().rulesStale();
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); return Cached(
r.data ?? const [],
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
}); });
final staleRuleIdsProvider = Provider.autoDispose<Set<int>>((ref) { final staleRuleIdsProvider = Provider.autoDispose<Set<int>>((ref) {
+75 -25
View File
@@ -20,7 +20,7 @@ String ruleKindLabel(RuleKind k) => switch (k) {
RuleKind.brokerTarget => 'Целевой брокер', RuleKind.brokerTarget => 'Целевой брокер',
RuleKind.ignore => 'Игнорировать', RuleKind.ignore => 'Игнорировать',
RuleKind.unknownDefaultOpenApi => 'Неизвестно', RuleKind.unknownDefaultOpenApi => 'Неизвестно',
}; };
String ruleMatchTypeLabel(RuleMatchType t) => switch (t) { String ruleMatchTypeLabel(RuleMatchType t) => switch (t) {
RuleMatchType.id => 'ID транзакции', RuleMatchType.id => 'ID транзакции',
@@ -30,7 +30,7 @@ String ruleMatchTypeLabel(RuleMatchType t) => switch (t) {
RuleMatchType.mcc => 'MCC', RuleMatchType.mcc => 'MCC',
RuleMatchType.account => 'Счёт', RuleMatchType.account => 'Счёт',
RuleMatchType.unknownDefaultOpenApi => 'Неизвестно', RuleMatchType.unknownDefaultOpenApi => 'Неизвестно',
}; };
const _kinds = [ const _kinds = [
RuleKind.savings, RuleKind.savings,
@@ -65,9 +65,13 @@ class RulesPage extends ConsumerWidget {
try { try {
final r = await ref.read(apiProvider).getRulesApi().rulesApply(); final r = await ref.read(apiProvider).getRulesApi().rulesApply();
final ok = r.data?.error == null; final ok = r.data?.error == null;
messenger.showSnackBar(SnackBar( messenger.showSnackBar(
content: Text(ok ? 'Правила применены' : 'Ошибка применения: ${r.data?.error}'), SnackBar(
)); content: Text(
ok ? 'Правила применены' : 'Ошибка применения: ${r.data?.error}',
),
),
);
} on DioException catch (e) { } on DioException catch (e) {
messenger.showSnackBar(SnackBar(content: Text(problemMessage(e)))); messenger.showSnackBar(SnackBar(content: Text(problemMessage(e))));
} finally { } finally {
@@ -113,11 +117,15 @@ class RulesPage extends ConsumerWidget {
return ListView( return ListView(
children: [ children: [
if (stale != null) StaleBanner(fetchedAt: stale), if (stale != null) StaleBanner(fetchedAt: stale),
const EmptyState(icon: Icons.rule_folder_outlined, message: 'Правил ещё нет.'), const EmptyState(
icon: Icons.rule_folder_outlined,
message: 'Правил ещё нет.',
),
], ],
); );
} }
final sorted = [...rows]..sort((a, b) => a.priority.compareTo(b.priority)); final sorted = [...rows]
..sort((a, b) => a.priority.compareTo(b.priority));
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
@@ -138,7 +146,11 @@ class RulesPage extends ConsumerWidget {
} }
class _RuleTile extends ConsumerStatefulWidget { class _RuleTile extends ConsumerStatefulWidget {
const _RuleTile({required this.rule, required this.stale, required this.onChanged}); const _RuleTile({
required this.rule,
required this.stale,
required this.onChanged,
});
final RuleOut rule; final RuleOut rule;
final bool stale; final bool stale;
@@ -154,14 +166,18 @@ class _RuleTileState extends ConsumerState<_RuleTile> {
Future<void> _toggle(bool enabled) async { Future<void> _toggle(bool enabled) async {
setState(() => _busy = true); setState(() => _busy = true);
try { try {
await ref.read(apiProvider).getRulesApi().rulesPatch( await ref
.read(apiProvider)
.getRulesApi()
.rulesPatch(
ruleId: widget.rule.id, ruleId: widget.rule.id,
rulePatch: RulePatch(enabled: enabled), rulePatch: RulePatch(enabled: enabled),
); );
widget.onChanged(); widget.onChanged();
} on DioException catch (e) { } on DioException catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e)))); ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(problemMessage(e))));
} finally { } finally {
if (mounted) setState(() => _busy = false); if (mounted) setState(() => _busy = false);
} }
@@ -174,18 +190,28 @@ class _RuleTileState extends ConsumerState<_RuleTile> {
title: const Text('Удалить правило?'), title: const Text('Удалить правило?'),
content: Text('«${widget.rule.pattern}» — действие необратимо.'), content: Text('«${widget.rule.pattern}» — действие необратимо.'),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Отмена')), TextButton(
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Удалить')), onPressed: () => Navigator.pop(context, false),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Удалить'),
),
], ],
), ),
); );
if (confirmed != true) return; if (confirmed != true) return;
try { try {
await ref.read(apiProvider).getRulesApi().rulesDelete(ruleId: widget.rule.id); await ref
.read(apiProvider)
.getRulesApi()
.rulesDelete(ruleId: widget.rule.id);
widget.onChanged(); widget.onChanged();
} on DioException catch (e) { } on DioException catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e)))); ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(problemMessage(e))));
} }
} }
@@ -196,7 +222,8 @@ class _RuleTileState extends ConsumerState<_RuleTile> {
child: ListTile( child: ListTile(
onTap: () => showDialog( onTap: () => showDialog(
context: context, context: context,
builder: (_) => _RuleDialog(existing: rule, onSaved: widget.onChanged), builder: (_) =>
_RuleDialog(existing: rule, onSaved: widget.onChanged),
), ),
title: Row( title: Row(
children: [ children: [
@@ -222,7 +249,8 @@ class _RuleTileState extends ConsumerState<_RuleTile> {
[ [
'${rule.pattern}${rule.value ?? ''}', '${rule.pattern}${rule.value ?? ''}',
'совпадений: ${rule.matchCount}', 'совпадений: ${rule.matchCount}',
if (rule.lastMatchedAt != null) 'последнее: ${ruDate(rule.lastMatchedAt!.toLocal())}', if (rule.lastMatchedAt != null)
'последнее: ${ruDate(rule.lastMatchedAt!.toLocal())}',
].join(' · '), ].join(' · '),
), ),
), ),
@@ -230,7 +258,10 @@ class _RuleTileState extends ConsumerState<_RuleTile> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Switch(value: rule.enabled, onChanged: _busy ? null : _toggle), Switch(value: rule.enabled, onChanged: _busy ? null : _toggle),
IconButton(icon: const Icon(Icons.delete_outline), onPressed: _delete), IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: _delete,
),
], ],
), ),
), ),
@@ -317,7 +348,8 @@ class _RuleDialogState extends ConsumerState<_RuleDialog> {
if (mounted) Navigator.pop(context); if (mounted) Navigator.pop(context);
} on DioException catch (e) { } on DioException catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e)))); ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(problemMessage(e))));
} finally { } finally {
if (mounted) setState(() => _saving = false); if (mounted) setState(() => _saving = false);
} }
@@ -337,7 +369,10 @@ class _RuleDialogState extends ConsumerState<_RuleDialog> {
DropdownButtonFormField<RuleKind>( DropdownButtonFormField<RuleKind>(
initialValue: _kind, initialValue: _kind,
decoration: const InputDecoration(labelText: 'Тип правила'), decoration: const InputDecoration(labelText: 'Тип правила'),
items: [for (final k in _kinds) DropdownMenuItem(value: k, child: Text(ruleKindLabel(k)))], items: [
for (final k in _kinds)
DropdownMenuItem(value: k, child: Text(ruleKindLabel(k))),
],
onChanged: (v) => setState(() => _kind = v!), onChanged: (v) => setState(() => _kind = v!),
), ),
DropdownButtonFormField<RuleMatchType>( DropdownButtonFormField<RuleMatchType>(
@@ -345,18 +380,27 @@ class _RuleDialogState extends ConsumerState<_RuleDialog> {
decoration: const InputDecoration(labelText: 'Совпадение по'), decoration: const InputDecoration(labelText: 'Совпадение по'),
items: [ items: [
for (final t in _matchTypes) for (final t in _matchTypes)
DropdownMenuItem(value: t, child: Text(ruleMatchTypeLabel(t))), DropdownMenuItem(
value: t,
child: Text(ruleMatchTypeLabel(t)),
),
], ],
onChanged: (v) => setState(() => _matchType = v!), onChanged: (v) => setState(() => _matchType = v!),
), ),
TextFormField( TextFormField(
controller: _pattern, controller: _pattern,
decoration: const InputDecoration(labelText: 'Шаблон (pattern)'), decoration: const InputDecoration(
validator: (v) => (v == null || v.trim().isEmpty) ? 'Обязательное поле' : null, labelText: 'Шаблон (pattern)',
),
validator: (v) => (v == null || v.trim().isEmpty)
? 'Обязательное поле'
: null,
), ),
TextFormField( TextFormField(
controller: _value, controller: _value,
decoration: const InputDecoration(labelText: 'Значение (value)'), decoration: const InputDecoration(
labelText: 'Значение (value)',
),
), ),
TextFormField( TextFormField(
controller: _note, controller: _note,
@@ -378,8 +422,14 @@ class _RuleDialogState extends ConsumerState<_RuleDialog> {
), ),
), ),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Отмена')), TextButton(
FilledButton(onPressed: _saving ? null : _save, child: const Text('Сохранить')), onPressed: () => Navigator.pop(context),
child: const Text('Отмена'),
),
FilledButton(
onPressed: _saving ? null : _save,
child: const Text('Сохранить'),
),
], ],
); );
} }
+18 -6
View File
@@ -41,7 +41,8 @@ class TaxRow {
final String? taxableBaseRub; final String? taxableBaseRub;
final String? estimatedTaxRub; final String? estimatedTaxRub;
String get title => accountName ?? (accountId == null ? 'Итого' : 'Счёт #$accountId'); String get title =>
accountName ?? (accountId == null ? 'Итого' : 'Счёт #$accountId');
static TaxRow fromJson(Map<String, dynamic> json) => TaxRow( static TaxRow fromJson(Map<String, dynamic> json) => TaxRow(
accountId: asInt(json['account_id']), accountId: asInt(json['account_id']),
@@ -83,7 +84,9 @@ class TaxSummary {
final totals = asObject(json['totals']); final totals = asObject(json['totals']);
return TaxSummary( return TaxSummary(
year: asInt(json['year']) ?? DateTime.now().year, year: asInt(json['year']) ?? DateTime.now().year,
estimated: json.containsKey('estimated') ? asBool(json['estimated']) : true, estimated: json.containsKey('estimated')
? asBool(json['estimated'])
: true,
taxRate: asString(json['tax_rate']), taxRate: asString(json['tax_rate']),
accounts: asObjects(json['accounts']).map(TaxRow.fromJson).toList(), accounts: asObjects(json['accounts']).map(TaxRow.fromJson).toList(),
totals: totals == null ? null : TaxRow.fromJson(totals), totals: totals == null ? null : TaxRow.fromJson(totals),
@@ -128,7 +131,8 @@ class TaxLot {
/// horizon at which a person can still decide to wait. /// horizon at which a person can still decide to wait.
bool get nearLdv => !ldvEligible && daysToLdv != null && daysToLdv! <= 183; bool get nearLdv => !ldvEligible && daysToLdv != null && daysToLdv! <= 183;
String get title => ticker ?? (instrumentId == null ? '#$lotId' : '#$instrumentId'); String get title =>
ticker ?? (instrumentId == null ? '#$lotId' : '#$instrumentId');
static TaxLot fromJson(Map<String, dynamic> json) => TaxLot( static TaxLot fromJson(Map<String, dynamic> json) => TaxLot(
lotId: asInt(json['lot_id']) ?? 0, lotId: asInt(json['lot_id']) ?? 0,
@@ -156,12 +160,18 @@ class TaxApi {
/// See `docs/ai/offline-cache.md`: this hand-written client returns `Cached<T>` directly /// See `docs/ai/offline-cache.md`: this hand-written client returns `Cached<T>` directly
/// (there is no generated `Response<T>` for the screen to unwrap `r.cached` from). /// (there is no generated `Response<T>` for the screen to unwrap `r.cached` from).
Future<Cached<TaxSummary>> summary({required int year, int? accountId}) async { Future<Cached<TaxSummary>> summary({
required int year,
int? accountId,
}) async {
final r = await _dio.get<Map<String, dynamic>>( final r = await _dio.get<Map<String, dynamic>>(
_base, _base,
queryParameters: {'year': year, 'account_id': ?accountId}, queryParameters: {'year': year, 'account_id': ?accountId},
); );
return Cached(TaxSummary.fromJson(r.data ?? const {}), fetchedAt: r.extra['fetchedAt'] as DateTime?); return Cached(
TaxSummary.fromJson(r.data ?? const {}),
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
} }
Future<Cached<List<TaxLot>>> lots({required int year, int? accountId}) async { Future<Cached<List<TaxLot>>> lots({required int year, int? accountId}) async {
@@ -169,7 +179,9 @@ class TaxApi {
'$_base/lots', '$_base/lots',
queryParameters: {'year': year, 'account_id': ?accountId}, queryParameters: {'year': year, 'account_id': ?accountId},
); );
final lots = asObjects((r.data ?? const {})['lots']).map(TaxLot.fromJson).toList(); final lots = asObjects((r.data ?? const {})['lots'])
.map(TaxLot.fromJson)
.toList();
return Cached(lots, fetchedAt: r.extra['fetchedAt'] as DateTime?); return Cached(lots, fetchedAt: r.extra['fetchedAt'] as DateTime?);
} }
} }
+113 -33
View File
@@ -1,4 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/widgets/help_tip.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -67,9 +70,12 @@ class _TaxLotsTabState extends ConsumerState<TaxLotsTab> {
color: Theme.of(context).colorScheme.tertiaryContainer, color: Theme.of(context).colorScheme.tertiaryContainer,
child: ListTile( child: ListTile(
leading: const Icon(Icons.schedule), leading: const Icon(Icons.schedule),
title: Text('До ЛДВ меньше полугода: ${near.length} лот(ов)'), title: Text(
'До ЛДВ меньше полугода: ${near.length} лот(ов)',
),
subtitle: const Text( subtitle: const Text(
'Продажа до этой даты облагается налогом на весь прирост.'), 'Продажа до этой даты облагается налогом на весь прирост.',
),
trailing: FilterChip( trailing: FilterChip(
label: const Text('только они'), label: const Text('только они'),
selected: _onlyNearLdv, selected: _onlyNearLdv,
@@ -80,7 +86,8 @@ class _TaxLotsTabState extends ConsumerState<TaxLotsTab> {
const SizedBox(height: 12), const SizedBox(height: 12),
SectionCard( SectionCard(
title: 'Открытые лоты', title: 'Открытые лоты',
subtitle: 'налог при продаже сегодня — оценка по ставке из сводки', subtitle:
'налог при продаже сегодня — оценка по ставке из сводки',
child: _LotsTable(rows: rows), child: _LotsTable(rows: rows),
), ),
], ],
@@ -104,71 +111,144 @@ class _LotsTable extends StatelessWidget {
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: DataTable( child: DataTable(
columns: [ columns: [
DataColumn(label: Text('Бумага', style: headerStyle)), DataColumn(label: TermLabel('Бумага', style: headerStyle)),
DataColumn(label: Text('Куплен', style: headerStyle)), DataColumn(label: TermLabel('Куплен', style: headerStyle)),
DataColumn(label: Text('Кол-во', style: headerStyle), numeric: true), DataColumn(
DataColumn(label: Text('Стоимость', style: headerStyle), numeric: true), label: TermLabel(
DataColumn(label: Text('Рынок', style: headerStyle), numeric: true), 'Кол-во',
DataColumn(label: Text('Нереализ.', style: headerStyle), numeric: true), style: headerStyle,
DataColumn(label: Text('Дата ЛДВ', style: headerStyle)), alignment: MainAxisAlignment.end,
DataColumn(label: Text('Дней до ЛДВ', style: headerStyle), numeric: true), ),
DataColumn(label: Text('Налог при продаже', style: headerStyle), numeric: true), numeric: true,
),
DataColumn(
label: TermLabel(
'Стоимость',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'Рынок',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'Нереализ.',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(label: TermLabel('Дата ЛДВ', style: headerStyle)),
DataColumn(
label: TermLabel(
'Дней до ЛДВ',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'Налог при продаже',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
], ],
rows: [ rows: [
for (final l in rows) for (final l in rows)
DataRow( DataRow(
color: l.nearLdv color: l.nearLdv
? WidgetStatePropertyAll( ? WidgetStatePropertyAll(
theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5)) theme.colorScheme.tertiaryContainer.withValues(
alpha: 0.5,
),
)
: null, : null,
onSelectChanged: l.instrumentId == null onSelectChanged: l.instrumentId == null
? null ? null
: (_) => context.push('/portfolio/instrument/${l.instrumentId}'), : (_) =>
context.push('/portfolio/instrument/${l.instrumentId}'),
cells: [ cells: [
DataCell(Row( DataCell(
Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text(l.title), Text(l.title),
if (l.nearLdv) ...[ if (l.nearLdv) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
Tooltip( Tooltip(
message: 'До ЛДВ осталось ${l.daysToLdv} дн. — ' message:
'До ЛДВ осталось ${l.daysToLdv} дн. — '
'продажа сейчас облагается налогом полностью', 'продажа сейчас облагается налогом полностью',
child: Icon(Icons.schedule, size: 16, color: theme.colorScheme.error), child: Icon(
Icons.schedule,
size: 16,
color: theme.colorScheme.error,
),
), ),
], ],
], ],
)), ),
),
DataCell(Text(l.openDate == null ? '' : ruDate(l.openDate!))), DataCell(Text(l.openDate == null ? '' : ruDate(l.openDate!))),
DataCell(Text(l.qtyRemaining == null ? '' : formatQty(l.qtyRemaining!))), DataCell(
DataCell(l.costRub == null Text(
l.qtyRemaining == null ? '' : formatQty(l.qtyRemaining!),
),
),
DataCell(
l.costRub == null
? const Text('') ? const Text('')
: MoneyText(l.costRub!, currency: 'RUB')), : MoneyText(l.costRub!, currency: 'RUB'),
DataCell(l.marketValueRub == null ),
DataCell(
l.marketValueRub == null
? const Text('') ? const Text('')
: MoneyText(l.marketValueRub!, currency: 'RUB')), : MoneyText(l.marketValueRub!, currency: 'RUB'),
DataCell(l.unrealizedGainRub == null ),
DataCell(
l.unrealizedGainRub == null
? const Text('') ? const Text('')
: MoneyText( : MoneyText(
l.unrealizedGainRub!, l.unrealizedGainRub!,
currency: 'RUB', currency: 'RUB',
style: TextStyle(color: signColor(context, l.unrealizedGainRub)), style: TextStyle(
)), color: signColor(context, l.unrealizedGainRub),
DataCell(l.ldvEligible ),
),
),
DataCell(
l.ldvEligible
? const Text('уже действует') ? const Text('уже действует')
: Text(l.ldvDate == null ? '' : ruDate(l.ldvDate!))), : Text(l.ldvDate == null ? '' : ruDate(l.ldvDate!)),
DataCell(l.ldvEligible ),
DataCell(
l.ldvEligible
? const Text('') ? const Text('')
: Text( : Text(
l.daysToLdv == null ? '' : '${l.daysToLdv}', l.daysToLdv == null ? '' : '${l.daysToLdv}',
style: l.nearLdv style: l.nearLdv
? TextStyle( ? TextStyle(
color: theme.colorScheme.error, fontWeight: FontWeight.w600) color: theme.colorScheme.error,
fontWeight: FontWeight.w600,
)
: null, : null,
)), ),
DataCell(l.taxIfSoldNowRub == null ),
DataCell(
l.taxIfSoldNowRub == null
? const Text('') ? const Text('')
: MoneyText(l.taxIfSoldNowRub!, currency: 'RUB')), : MoneyText(l.taxIfSoldNowRub!, currency: 'RUB'),
),
], ],
), ),
], ],
+15 -5
View File
@@ -4,7 +4,9 @@ import '../../core/api/api_client.dart';
import '../../core/cache/cached.dart'; import '../../core/cache/cached.dart';
import 'data/tax_api.dart'; import 'data/tax_api.dart';
final taxApiProvider = Provider<TaxApi>((ref) => TaxApi(ref.watch(apiProvider).dio)); final taxApiProvider = Provider<TaxApi>(
(ref) => TaxApi(ref.watch(apiProvider).dio),
);
final taxYearProvider = StateProvider<int>((ref) => DateTime.now().year); final taxYearProvider = StateProvider<int>((ref) => DateTime.now().year);
@@ -12,15 +14,23 @@ final taxYearProvider = StateProvider<int>((ref) => DateTime.now().year);
final taxAccountProvider = StateProvider<int?>((ref) => null); final taxAccountProvider = StateProvider<int?>((ref) => null);
/// See `docs/ai/offline-cache.md`. /// See `docs/ai/offline-cache.md`.
final taxSummaryProvider = FutureProvider.autoDispose<Cached<TaxSummary>>((ref) async { final taxSummaryProvider = FutureProvider.autoDispose<Cached<TaxSummary>>((
return ref.watch(taxApiProvider).summary( ref,
) async {
return ref
.watch(taxApiProvider)
.summary(
year: ref.watch(taxYearProvider), year: ref.watch(taxYearProvider),
accountId: ref.watch(taxAccountProvider), accountId: ref.watch(taxAccountProvider),
); );
}); });
final taxLotsProvider = FutureProvider.autoDispose<Cached<List<TaxLot>>>((ref) async { final taxLotsProvider = FutureProvider.autoDispose<Cached<List<TaxLot>>>((
return ref.watch(taxApiProvider).lots( ref,
) async {
return ref
.watch(taxApiProvider)
.lots(
year: ref.watch(taxYearProvider), year: ref.watch(taxYearProvider),
accountId: ref.watch(taxAccountProvider), accountId: ref.watch(taxAccountProvider),
); );
+81 -12
View File
@@ -1,4 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/widgets/help_tip.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/async_value_view.dart';
@@ -30,7 +33,10 @@ class TaxSummaryTab extends ConsumerWidget {
children: [ children: [
Row( Row(
children: [ children: [
Text('${data.year} год', style: Theme.of(context).textTheme.titleLarge), Text(
'${data.year} год',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(width: 12), const SizedBox(width: 12),
if (data.estimated) if (data.estimated)
const Chip( const Chip(
@@ -40,7 +46,9 @@ class TaxSummaryTab extends ConsumerWidget {
), ),
const Spacer(), const Spacer(),
if (data.taxRate != null) if (data.taxRate != null)
Text('ставка ${formatPercent(data.taxRate, signed: false)}'), Text(
'ставка ${formatPercent(data.taxRate, signed: false)}',
),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -124,15 +132,72 @@ class _AccountsTable extends StatelessWidget {
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: DataTable( child: DataTable(
columns: [ columns: [
DataColumn(label: Text('Счёт', style: headerStyle)), DataColumn(label: TermLabel('Счёт', style: headerStyle)),
DataColumn(label: Text('Дивиденды', style: headerStyle), numeric: true), DataColumn(
DataColumn(label: Text('Купоны', style: headerStyle), numeric: true), label: TermLabel(
DataColumn(label: Text('Удержано', style: headerStyle), numeric: true), 'Дивиденды',
DataColumn(label: Text('Прибыль', style: headerStyle), numeric: true), style: headerStyle,
DataColumn(label: Text('Убыток', style: headerStyle), numeric: true), alignment: MainAxisAlignment.end,
DataColumn(label: Text('ЛДВ', style: headerStyle), numeric: true), ),
DataColumn(label: Text('База', style: headerStyle), numeric: true), numeric: true,
DataColumn(label: Text('Налог (оценка)', style: headerStyle), numeric: true), ),
DataColumn(
label: TermLabel(
'Купоны',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'Удержано',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'Прибыль',
hint: 'Сумма положительных результатов по проданным бумагам за год (до вычета убытков).',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'Убыток',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'ЛДВ',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'База',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'Налог (оценка)',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
], ],
rows: [ rows: [
for (final r in rows) _row(context, r, bold: false), for (final r in rows) _row(context, r, bold: false),
@@ -149,7 +214,11 @@ class _AccountsTable extends StatelessWidget {
: MoneyText( : MoneyText(
v, v,
currency: 'RUB', currency: 'RUB',
style: signed ? (style ?? const TextStyle()).copyWith(color: signColor(context, v)) : style, style: signed
? (style ?? const TextStyle()).copyWith(
color: signColor(context, v),
)
: style,
); );
return DataRow( return DataRow(
+8 -6
View File
@@ -37,7 +37,10 @@ class TaxPage extends ConsumerWidget {
), ),
], ],
bottom: const TabBar( bottom: const TabBar(
tabs: [Tab(text: 'Сводка за год'), Tab(text: 'Лоты и ЛДВ')], tabs: [
Tab(text: 'Сводка за год'),
Tab(text: 'Лоты и ЛДВ'),
],
), ),
), ),
body: Column( body: Column(
@@ -48,7 +51,9 @@ class TaxPage extends ConsumerWidget {
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: StaleBanner(fetchedAt: stale), child: StaleBanner(fetchedAt: stale),
), ),
const Expanded(child: TabBarView(children: [TaxSummaryTab(), TaxLotsTab()])), const Expanded(
child: TabBarView(children: [TaxSummaryTab(), TaxLotsTab()]),
),
], ],
), ),
), ),
@@ -75,10 +80,7 @@ class EstimateBanner extends ConsumerWidget {
const Icon(Icons.info_outline, size: 18), const Icon(Icons.info_outline, size: 18),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(text, style: Theme.of(context).textTheme.bodySmall),
text,
style: Theme.of(context).textTheme.bodySmall,
),
), ),
], ],
), ),
+20 -5
View File
@@ -22,7 +22,12 @@ class TransactionsFilter {
final FlowType? flowType; final FlowType? flowType;
bool get isEmpty => bool get isEmpty =>
q == null && from == null && to == null && accountId == null && categoryId == null && flowType == null; q == null &&
from == null &&
to == null &&
accountId == null &&
categoryId == null &&
flowType == null;
TransactionsFilter copyWith({ TransactionsFilter copyWith({
String? Function()? q, String? Function()? q,
@@ -98,7 +103,9 @@ class TransactionsController extends Notifier<TransactionsState> {
return const TransactionsState(loading: true); return const TransactionsState(loading: true);
} }
Future<void> setFilter(TransactionsFilter Function(TransactionsFilter) update) { Future<void> setFilter(
TransactionsFilter Function(TransactionsFilter) update,
) {
filter = update(filter); filter = update(filter);
return refresh(); return refresh();
} }
@@ -106,7 +113,10 @@ class TransactionsController extends Notifier<TransactionsState> {
Future<void> refresh() async { Future<void> refresh() async {
state = state.copyWith(loading: true, clearError: true); state = state.copyWith(loading: true, clearError: true);
try { try {
final r = await ref.read(apiProvider).getTransactionsApi().transactionsList( final r = await ref
.read(apiProvider)
.getTransactionsApi()
.transactionsList(
from: filter.from, from: filter.from,
to: filter.to, to: filter.to,
accountId: filter.accountId, accountId: filter.accountId,
@@ -133,7 +143,10 @@ class TransactionsController extends Notifier<TransactionsState> {
state = state.copyWith(loadingMore: true, clearError: true); state = state.copyWith(loadingMore: true, clearError: true);
try { try {
final next = state.page + 1; final next = state.page + 1;
final r = await ref.read(apiProvider).getTransactionsApi().transactionsList( final r = await ref
.read(apiProvider)
.getTransactionsApi()
.transactionsList(
from: filter.from, from: filter.from,
to: filter.to, to: filter.to,
accountId: filter.accountId, accountId: filter.accountId,
@@ -157,4 +170,6 @@ class TransactionsController extends Notifier<TransactionsState> {
} }
final transactionsControllerProvider = final transactionsControllerProvider =
NotifierProvider<TransactionsController, TransactionsState>(TransactionsController.new); NotifierProvider<TransactionsController, TransactionsState>(
TransactionsController.new,
);
@@ -7,12 +7,22 @@ import '../../core/widgets/money_text.dart';
/// The amount/currency/RUB-equivalent a transaction is shown with, chosen by /// The amount/currency/RUB-equivalent a transaction is shown with, chosen by
/// its [FlowType]: the outgoing leg for expenses and transfers out, the /// its [FlowType]: the outgoing leg for expenses and transfers out, the
/// incoming leg for income. /// incoming leg for income.
({String amount, String currency, String? rub}) primaryAmount(TransactionOut t) { ({String amount, String currency, String? rub}) primaryAmount(
TransactionOut t,
) {
switch (t.flowType) { switch (t.flowType) {
case FlowType.income: case FlowType.income:
return (amount: t.income, currency: t.incomeCurrency ?? 'RUB', rub: t.incomeRub); return (
amount: t.income,
currency: t.incomeCurrency ?? 'RUB',
rub: t.incomeRub,
);
case FlowType.expense: case FlowType.expense:
return (amount: t.outcome, currency: t.outcomeCurrency ?? 'RUB', rub: t.outcomeRub); return (
amount: t.outcome,
currency: t.outcomeCurrency ?? 'RUB',
rub: t.outcomeRub,
);
case FlowType.internalTransfer: case FlowType.internalTransfer:
case FlowType.savingsTransfer: case FlowType.savingsTransfer:
case FlowType.brokerExternalFlow: case FlowType.brokerExternalFlow:
@@ -20,22 +30,33 @@ import '../../core/widgets/money_text.dart';
case FlowType.deleted: case FlowType.deleted:
case FlowType.unknownDefaultOpenApi: case FlowType.unknownDefaultOpenApi:
if (t.outcome != '0' && t.outcomeCurrency != null) { if (t.outcome != '0' && t.outcomeCurrency != null) {
return (amount: t.outcome, currency: t.outcomeCurrency!, rub: t.outcomeRub); return (
amount: t.outcome,
currency: t.outcomeCurrency!,
rub: t.outcomeRub,
);
} }
return (amount: t.income, currency: t.incomeCurrency ?? 'RUB', rub: t.incomeRub); return (
amount: t.income,
currency: t.incomeCurrency ?? 'RUB',
rub: t.incomeRub,
);
} }
} }
(IconData, Color) flowTypeIcon(FlowType type, ColorScheme scheme) => switch (type) { (IconData, Color) flowTypeIcon(FlowType type, ColorScheme scheme) =>
switch (type) {
FlowType.income => (Icons.arrow_circle_down_outlined, Colors.green), FlowType.income => (Icons.arrow_circle_down_outlined, Colors.green),
FlowType.expense => (Icons.arrow_circle_up_outlined, scheme.error), FlowType.expense => (Icons.arrow_circle_up_outlined, scheme.error),
FlowType.internalTransfer => (Icons.swap_horiz, scheme.primary), FlowType.internalTransfer => (Icons.swap_horiz, scheme.primary),
FlowType.savingsTransfer => (Icons.savings_outlined, Colors.amber.shade800), FlowType.savingsTransfer => (
FlowType.brokerExternalFlow => (Icons.trending_up, Colors.deepPurple), Icons.savings_outlined,
FlowType.other || FlowType.deleted || FlowType.unknownDefaultOpenApi => ( Colors.amber.shade800,
Icons.help_outline,
scheme.outline,
), ),
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 /// One row of the Операции list: date, payee, category, native amount with
@@ -59,7 +80,9 @@ class TransactionRow extends StatelessWidget {
final (icon, color) = flowTypeIcon(t.flowType, scheme); final (icon, color) = flowTypeIcon(t.flowType, scheme);
final amount = primaryAmount(t); final amount = primaryAmount(t);
final showRub = amount.currency != 'RUB' && amount.rub != null; final showRub = amount.currency != 'RUB' && amount.rub != null;
final payee = t.payeeCanonical?.isNotEmpty == true ? t.payeeCanonical! : (t.payee ?? ''); final payee = t.payeeCanonical?.isNotEmpty == true
? t.payeeCanonical!
: (t.payee ?? '');
return ListTile( return ListTile(
onTap: onTap, onTap: onTap,
@@ -74,7 +97,8 @@ class TransactionRow extends StatelessWidget {
if (showRub) if (showRub)
Text( Text(
MoneyText.format(amount.rub!, 'RUB'), MoneyText.format(amount.rub!, 'RUB'),
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: scheme.outline), style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: scheme.outline),
), ),
], ],
), ),
@@ -28,7 +28,7 @@ String _flowTypeLabel(FlowType t) => switch (t) {
FlowType.other => 'Прочее', FlowType.other => 'Прочее',
FlowType.deleted => 'Удалено', FlowType.deleted => 'Удалено',
FlowType.unknownDefaultOpenApi => 'Неизвестно', FlowType.unknownDefaultOpenApi => 'Неизвестно',
}; };
/// Операции: filtered, paginated transaction list with infinite scroll and a /// Операции: filtered, paginated transaction list with infinite scroll and a
/// detail bottom sheet per row. /// detail bottom sheet per row.
@@ -60,7 +60,8 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
} }
void _onScroll() { void _onScroll() {
if (_scrollController.position.pixels > _scrollController.position.maxScrollExtent - 200) { if (_scrollController.position.pixels >
_scrollController.position.maxScrollExtent - 200) {
ref.read(transactionsControllerProvider.notifier).loadMore(); ref.read(transactionsControllerProvider.notifier).loadMore();
} }
} }
@@ -68,8 +69,11 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
void _onSearchChanged(String value) { void _onSearchChanged(String value) {
_debounce?.cancel(); _debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 400), () { _debounce = Timer(const Duration(milliseconds: 400), () {
ref.read(transactionsControllerProvider.notifier).setFilter( ref
(f) => f.copyWith(q: () => value.trim().isEmpty ? null : value.trim()), .read(transactionsControllerProvider.notifier)
.setFilter(
(f) =>
f.copyWith(q: () => value.trim().isEmpty ? null : value.trim()),
); );
}); });
} }
@@ -87,16 +91,18 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
helpText: 'Период', helpText: 'Период',
); );
if (picked != null) { if (picked != null) {
ref.read(transactionsControllerProvider.notifier).setFilter( ref
.read(transactionsControllerProvider.notifier)
.setFilter(
(f) => f.copyWith(from: () => picked.start, to: () => picked.end), (f) => f.copyWith(from: () => picked.start, to: () => picked.end),
); );
} }
} }
void _clearDateRange() { void _clearDateRange() {
ref.read(transactionsControllerProvider.notifier).setFilter( ref
(f) => f.copyWith(from: () => null, to: () => null), .read(transactionsControllerProvider.notifier)
); .setFilter((f) => f.copyWith(from: () => null, to: () => null));
} }
void _showDetail(TransactionOut t) { void _showDetail(TransactionOut t) {
@@ -116,10 +122,14 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final state = ref.watch(transactionsControllerProvider); final state = ref.watch(transactionsControllerProvider);
final controllerFilter = ref.watch(transactionsControllerProvider.notifier).filter; final controllerFilter = ref
.watch(transactionsControllerProvider.notifier)
.filter;
final categoryNames = ref.watch(categoryNamesProvider); final categoryNames = ref.watch(categoryNamesProvider);
final accounts = ref.watch(accountsProvider).valueOrNull?.data ?? const <AccountOut>[]; final accounts =
final categories = ref.watch(categoriesListProvider).valueOrNull ?? const <CategoryOut>[]; ref.watch(accountsProvider).valueOrNull?.data ?? const <AccountOut>[];
final categories =
ref.watch(categoriesListProvider).valueOrNull ?? const <CategoryOut>[];
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Операции')), appBar: AppBar(title: const Text('Операции')),
@@ -149,26 +159,36 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
InputChip( InputChip(
avatar: const Icon(Icons.date_range, size: 18), avatar: const Icon(Icons.date_range, size: 18),
label: Text( label: Text(
controllerFilter.from != null && controllerFilter.to != null controllerFilter.from != null &&
controllerFilter.to != null
? '${ruDate(controllerFilter.from!)} ${ruDate(controllerFilter.to!)}' ? '${ruDate(controllerFilter.from!)} ${ruDate(controllerFilter.to!)}'
: 'Период', : 'Период',
), ),
onPressed: _pickDateRange, onPressed: _pickDateRange,
onDeleted: controllerFilter.from != null ? _clearDateRange : null, onDeleted: controllerFilter.from != null
? _clearDateRange
: null,
), ),
SizedBox( SizedBox(
width: 180, width: 180,
child: DropdownButtonFormField<int?>( child: DropdownButtonFormField<int?>(
initialValue: controllerFilter.accountId, initialValue: controllerFilter.accountId,
isDense: true, isDense: true,
decoration: const InputDecoration(labelText: 'Счёт', isDense: true), decoration: const InputDecoration(
items: [ labelText: 'Счёт',
const DropdownMenuItem(value: null, child: Text('Все счета')), isDense: true,
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),
), ),
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( SizedBox(
@@ -176,14 +196,21 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
child: DropdownButtonFormField<int?>( child: DropdownButtonFormField<int?>(
initialValue: controllerFilter.categoryId, initialValue: controllerFilter.categoryId,
isDense: true, isDense: true,
decoration: const InputDecoration(labelText: 'Категория', isDense: true), decoration: const InputDecoration(
items: [ labelText: 'Категория',
const DropdownMenuItem(value: null, child: Text('Все категории')), isDense: true,
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),
), ),
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)),
), ),
), ),
], ],
@@ -195,17 +222,17 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
ChoiceChip( ChoiceChip(
label: const Text('Все типы'), label: const Text('Все типы'),
selected: controllerFilter.flowType == null, selected: controllerFilter.flowType == null,
onSelected: (_) => ref.read(transactionsControllerProvider.notifier).setFilter( onSelected: (_) => ref
(f) => f.copyWith(flowType: () => null), .read(transactionsControllerProvider.notifier)
), .setFilter((f) => f.copyWith(flowType: () => null)),
), ),
for (final ft in _flowTypes) for (final ft in _flowTypes)
ChoiceChip( ChoiceChip(
label: Text(_flowTypeLabel(ft)), label: Text(_flowTypeLabel(ft)),
selected: controllerFilter.flowType == ft, selected: controllerFilter.flowType == ft,
onSelected: (_) => ref.read(transactionsControllerProvider.notifier).setFilter( onSelected: (_) => ref
(f) => f.copyWith(flowType: () => ft), .read(transactionsControllerProvider.notifier)
), .setFilter((f) => f.copyWith(flowType: () => ft)),
), ),
], ],
), ),
@@ -230,12 +257,17 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error, size: 32), Icon(
Icons.error_outline,
color: Theme.of(context).colorScheme.error,
size: 32,
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text('${state.error}', textAlign: TextAlign.center), Text('${state.error}', textAlign: TextAlign.center),
const SizedBox(height: 12), const SizedBox(height: 12),
FilledButton.tonal( FilledButton.tonal(
onPressed: () => ref.read(transactionsControllerProvider.notifier).refresh(), onPressed: () =>
ref.read(transactionsControllerProvider.notifier).refresh(),
child: const Text('Повторить'), child: const Text('Повторить'),
), ),
], ],
@@ -250,7 +282,8 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
); );
} }
return RefreshIndicator( return RefreshIndicator(
onRefresh: () => ref.read(transactionsControllerProvider.notifier).refresh(), onRefresh: () =>
ref.read(transactionsControllerProvider.notifier).refresh(),
child: ListView.builder( child: ListView.builder(
controller: _scrollController, controller: _scrollController,
itemCount: state.items.length + 1, itemCount: state.items.length + 1,
@@ -263,7 +296,9 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
child: state.loadingMore child: state.loadingMore
? const CircularProgressIndicator() ? const CircularProgressIndicator()
: TextButton( : TextButton(
onPressed: () => ref.read(transactionsControllerProvider.notifier).loadMore(), onPressed: () => ref
.read(transactionsControllerProvider.notifier)
.loadMore(),
child: const Text('Ещё'), child: const Text('Ещё'),
), ),
), ),
@@ -272,7 +307,9 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
final t = state.items[index]; final t = state.items[index];
return TransactionRow( return TransactionRow(
transaction: t, transaction: t,
categoryName: t.categoryId != null ? categoryNames[t.categoryId] : null, categoryName: t.categoryId != null
? categoryNames[t.categoryId]
: null,
onTap: () => _showDetail(t), onTap: () => _showDetail(t),
); );
}, },
@@ -298,24 +335,43 @@ class _TransactionDetailSheet extends StatelessWidget {
final rows = <(String, String)>[ final rows = <(String, String)>[
('Дата', ruDate(t.date)), ('Дата', ruDate(t.date)),
('Плательщик', t.payee ?? ''), ('Плательщик', t.payee ?? ''),
if (t.payeeCanonical != null && t.payeeCanonical != t.payee) ('Канонический', t.payeeCanonical!), if (t.payeeCanonical != null && t.payeeCanonical != t.payee)
('Канонический', t.payeeCanonical!),
('Комментарий', t.comment ?? ''), ('Комментарий', t.comment ?? ''),
('Категория', t.categoryId != null ? (categoryNames[t.categoryId] ?? '#${t.categoryId}') : 'Без категории'), (
'Категория',
t.categoryId != null
? (categoryNames[t.categoryId] ?? '#${t.categoryId}')
: 'Без категории',
),
('Тип', _flowTypeLabel(t.flowType)), ('Тип', _flowTypeLabel(t.flowType)),
if (t.outcome != '0') if (t.outcome != '0')
('Списание', '${MoneyText.format(t.outcome, t.outcomeCurrency ?? 'RUB')}' (
'${t.outcomeRub != null ? ' (${MoneyText.format(t.outcomeRub!, 'RUB')})' : ''}'), 'Списание',
'${MoneyText.format(t.outcome, t.outcomeCurrency ?? 'RUB')}'
'${t.outcomeRub != null ? ' (${MoneyText.format(t.outcomeRub!, 'RUB')})' : ''}',
),
if (t.income != '0') if (t.income != '0')
('Зачисление', '${MoneyText.format(t.income, t.incomeCurrency ?? 'RUB')}' (
'${t.incomeRub != null ? ' (${MoneyText.format(t.incomeRub!, 'RUB')})' : ''}'), 'Зачисление',
'${MoneyText.format(t.income, t.incomeCurrency ?? 'RUB')}'
'${t.incomeRub != null ? ' (${MoneyText.format(t.incomeRub!, 'RUB')})' : ''}',
),
if (t.outcomeAccountId != null) if (t.outcomeAccountId != null)
('Счёт списания', accountNames[t.outcomeAccountId] ?? '#${t.outcomeAccountId}'), (
'Счёт списания',
accountNames[t.outcomeAccountId] ?? '#${t.outcomeAccountId}',
),
if (t.incomeAccountId != null) if (t.incomeAccountId != null)
('Счёт зачисления', accountNames[t.incomeAccountId] ?? '#${t.incomeAccountId}'), (
'Счёт зачисления',
accountNames[t.incomeAccountId] ?? '#${t.incomeAccountId}',
),
if (t.mcc != null) ('MCC', '${t.mcc}'), if (t.mcc != null) ('MCC', '${t.mcc}'),
('Удержание (hold)', t.hold ? 'да' : 'нет'), ('Удержание (hold)', t.hold ? 'да' : 'нет'),
('Разовая трата', t.isOneOff ? 'да' : 'нет'), ('Разовая трата', t.isOneOff ? 'да' : 'нет'),
if (t.tags.isNotEmpty) ('Теги', t.tags.map((id) => categoryNames[id] ?? '#$id').join(', ')), if (t.tags.isNotEmpty)
('Теги', t.tags.map((id) => categoryNames[id] ?? '#$id').join(', ')),
if (t.tripId != null) ('Поездка', '#${t.tripId}'), if (t.tripId != null) ('Поездка', '#${t.tripId}'),
('Источник (id)', t.sourceId), ('Источник (id)', t.sourceId),
]; ];
@@ -334,7 +390,13 @@ class _TransactionDetailSheet extends StatelessWidget {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SizedBox(width: 140, child: Text(label, style: Theme.of(context).textTheme.bodySmall)), SizedBox(
width: 140,
child: Text(
label,
style: Theme.of(context).textTheme.bodySmall,
),
),
Expanded(child: Text(value)), Expanded(child: Text(value)),
], ],
), ),
+18 -2
View File
@@ -26,6 +26,12 @@ import 'features/shell/app_shell.dart';
import 'features/tax/tax_page.dart'; import 'features/tax/tax_page.dart';
import 'features/transactions/transactions_page.dart'; import 'features/transactions/transactions_page.dart';
/// Where to go after signing in: the page the user was on, if `from` is an in-app path.
String _returnTo(String? from) {
final inApp = from != null && from.startsWith('/') && !from.startsWith('//');
return inApp && !from.startsWith('/login') ? from : '/';
}
final routerProvider = Provider<GoRouter>((ref) { final routerProvider = Provider<GoRouter>((ref) {
final auth = ValueNotifier<AuthState>(ref.read(authControllerProvider)); final auth = ValueNotifier<AuthState>(ref.read(authControllerProvider));
ref.listen(authControllerProvider, (_, next) => auth.value = next); ref.listen(authControllerProvider, (_, next) => auth.value = next);
@@ -38,9 +44,19 @@ final routerProvider = Provider<GoRouter>((ref) {
final status = auth.value.status; final status = auth.value.status;
final atLogin = state.matchedLocation == '/login'; final atLogin = state.matchedLocation == '/login';
return switch (status) { return switch (status) {
AuthStatus.unknown => atLogin ? null : '/login', // A reload starts here while the stored session is still being restored: the address
// is kept in `from` so signing back in lands on it instead of on the home page. A
// deliberate sign-out is `signedOut` from the start and carries no `from`.
AuthStatus.unknown =>
atLogin
? null
: Uri(
path: '/login',
queryParameters: {'from': state.uri.toString()},
).toString(),
AuthStatus.signedOut => atLogin ? null : '/login', AuthStatus.signedOut => atLogin ? null : '/login',
AuthStatus.signedIn => atLogin ? '/' : null, AuthStatus.signedIn =>
atLogin ? _returnTo(state.uri.queryParameters['from']) : null,
}; };
}, },
routes: [ routes: [
+5 -1
View File
@@ -33,6 +33,10 @@ void main() {
test('non-date parameters are passed through untouched', () { test('non-date parameters are passed through untouched', () {
final out = run({'page': 2, 'q': 'кофе', 'include_deleted': false}); final out = run({'page': 2, 'q': 'кофе', 'include_deleted': false});
expect(out.queryParameters, {'page': 2, 'q': 'кофе', 'include_deleted': false}); expect(out.queryParameters, {
'page': 2,
'q': 'кофе',
'include_deleted': false,
});
}); });
} }
+28 -8
View File
@@ -28,6 +28,7 @@ EventOut event({
kind: kind, kind: kind,
price: '100', price: '100',
priceCurrency: currency, priceCurrency: currency,
source_: 'tinvest',
quantity: quantity, quantity: quantity,
status: status, status: status,
tax: null, tax: null,
@@ -41,10 +42,12 @@ Widget wrap(EventOut e) => MaterialApp(
home: Scaffold( home: Scaffold(
body: EventRow(event: e, accountName: 'T-Invest ИИС', onTap: () {}), body: EventRow(event: e, accountName: 'T-Invest ИИС', onTap: () {}),
), ),
); );
void main() { void main() {
testWidgets('shows kind, ticker, account and the native amount', (tester) async { testWidgets('shows kind, ticker, account and the native amount', (
tester,
) async {
await tester.pumpWidget(wrap(event())); await tester.pumpWidget(wrap(event()));
expect(find.text('Покупка · SBER'), findsOneWidget); expect(find.text('Покупка · SBER'), findsOneWidget);
@@ -53,15 +56,23 @@ void main() {
expect(find.text(MoneyText.format('-10000', 'RUB')), findsOneWidget); expect(find.text(MoneyText.format('-10000', 'RUB')), findsOneWidget);
}); });
testWidgets('adds the RUB equivalent only for a foreign currency', (tester) async { testWidgets('adds the RUB equivalent only for a foreign currency', (
await tester.pumpWidget(wrap(event(currency: 'USD', amount: '-100', amountRub: '-9500'))); tester,
) async {
await tester.pumpWidget(
wrap(event(currency: 'USD', amount: '-100', amountRub: '-9500')),
);
expect(find.text(MoneyText.format('-100', 'USD')), findsOneWidget); expect(find.text(MoneyText.format('-100', 'USD')), findsOneWidget);
expect(find.text(MoneyText.format('-9500', 'RUB')), findsOneWidget); expect(find.text(MoneyText.format('-9500', 'RUB')), findsOneWidget);
}); });
testWidgets('leaves the RUB line out when no rate was available', (tester) async { testWidgets('leaves the RUB line out when no rate was available', (
await tester.pumpWidget(wrap(event(currency: 'USD', amount: '-100', amountRub: null))); tester,
) async {
await tester.pumpWidget(
wrap(event(currency: 'USD', amount: '-100', amountRub: null)),
);
expect(find.text(MoneyText.format('-100', 'USD')), findsOneWidget); expect(find.text(MoneyText.format('-100', 'USD')), findsOneWidget);
expect(find.textContaining(''), findsNothing); expect(find.textContaining(''), findsNothing);
@@ -73,9 +84,18 @@ void main() {
expect(find.textContaining('Ожидает'), findsOneWidget); expect(find.textContaining('Ожидает'), findsOneWidget);
}); });
testWidgets('drops the separator for an event with no instrument', (tester) async { testWidgets('drops the separator for an event with no instrument', (
tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
wrap(event(kind: EventKind.deposit, ticker: null, quantity: null, externalFlow: true)), wrap(
event(
kind: EventKind.deposit,
ticker: null,
quantity: null,
externalFlow: true,
),
),
); );
expect(find.text('Пополнение'), findsOneWidget); expect(find.text('Пополнение'), findsOneWidget);
+13 -6
View File
@@ -1,7 +1,8 @@
import 'package:fintracker_api/fintracker_api.dart'; import 'package:fintracker_api/fintracker_api.dart';
import 'package:fintracker_app/core/cache/cached.dart'; import 'package:fintracker_app/core/cache/cached.dart';
import 'package:fintracker_app/features/health/health_page.dart'; import 'package:fintracker_app/features/health/health_page.dart';
import 'package:fintracker_app/features/home/providers.dart' show dataQualityProvider; import 'package:fintracker_app/features/home/providers.dart'
show dataQualityProvider;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -13,7 +14,7 @@ SourceStatus _source(String name) => SourceStatus(
lastSuccessAt: DateTime(2026, 9, 18, 10), lastSuccessAt: DateTime(2026, 9, 18, 10),
cursor: null, cursor: null,
queued: false, queued: false,
); );
DataQualityRow _issue() => DataQualityRow( DataQualityRow _issue() => DataQualityRow(
id: 1, id: 1,
@@ -23,10 +24,12 @@ DataQualityRow _issue() => DataQualityRow(
count: 3, count: 3,
detail: 'Курс не найден для 3 операций.', detail: 'Курс не найден для 3 операций.',
computedAt: DateTime(2026, 9, 18, 10), computedAt: DateTime(2026, 9, 18, 10),
); );
void main() { void main() {
testWidgets('shows sources on the first tab and findings on the second', (tester) async { testWidgets('shows sources on the first tab and findings on the second', (
tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
ProviderScope( ProviderScope(
overrides: [ overrides: [
@@ -50,7 +53,9 @@ void main() {
expect(find.text('Курс не найден для 3 операций.'), findsOneWidget); expect(find.text('Курс не найден для 3 операций.'), findsOneWidget);
}); });
testWidgets('opens directly on the findings tab when initialTab is 1', (tester) async { testWidgets('opens directly on the findings tab when initialTab is 1', (
tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
ProviderScope( ProviderScope(
overrides: [ overrides: [
@@ -72,7 +77,9 @@ void main() {
overrides: [ overrides: [
syncStatusProvider.overrideWith((ref) async => const []), syncStatusProvider.overrideWith((ref) async => const []),
syncRunsProvider.overrideWith((ref) async => const []), syncRunsProvider.overrideWith((ref) async => const []),
dataQualityProvider.overrideWith((ref) => const Cached(<DataQualityRow>[])), dataQualityProvider.overrideWith(
(ref) => const Cached(<DataQualityRow>[]),
),
], ],
child: const MaterialApp(home: HealthPage(initialTab: 1)), child: const MaterialApp(home: HealthPage(initialTab: 1)),
), ),
+14 -5
View File
@@ -145,15 +145,19 @@ void main() {
final json = Map<String, dynamic>.from(_previewJson)..['account_id'] = null; final json = Map<String, dynamic>.from(_previewJson)..['account_id'] = null;
expect(ImportPreview.fromJson(json).canCommit, isFalse); expect(ImportPreview.fromJson(json).canCommit, isFalse);
final failed = Map<String, dynamic>.from(_previewJson)..['parse_status'] = 'failed'; final failed = Map<String, dynamic>.from(_previewJson)
..['parse_status'] = 'failed';
expect(ImportPreview.fromJson(failed).canCommit, isFalse); expect(ImportPreview.fromJson(failed).canCommit, isFalse);
expect(ImportPreview.fromJson(failed).canDelete, isTrue); expect(ImportPreview.fromJson(failed).canDelete, isTrue);
final committed = Map<String, dynamic>.from(_previewJson)..['parse_status'] = 'committed'; final committed = Map<String, dynamic>.from(_previewJson)
..['parse_status'] = 'committed';
expect(ImportPreview.fromJson(committed).canDelete, isFalse); expect(ImportPreview.fromJson(committed).canDelete, isFalse);
}); });
test('parses PendingResolveResult and builds the create body as plain strings', () { test(
'parses PendingResolveResult and builds the create body as plain strings',
() {
final r = PendingResolveResult.fromJson(const { final r = PendingResolveResult.fromJson(const {
'id': 4, 'id': 4,
'status': 'resolved', 'status': 'resolved',
@@ -174,7 +178,12 @@ void main() {
lot: 1, lot: 1,
).toJson(); ).toJson();
expect(body['asset_class'], 'index'); expect(body['asset_class'], 'index');
expect(body.containsKey('isin'), isFalse, reason: 'empty optionals are omitted'); expect(
body.containsKey('isin'),
isFalse,
reason: 'empty optionals are omitted',
);
expect(body['ticker'], 'IMOEX'); expect(body['ticker'], 'IMOEX');
}); },
);
} }
+64 -21
View File
@@ -33,7 +33,11 @@ void main() {
'tax_withheld': null, 'tax_withheld': null,
}, },
], ],
'by_basis': {'schedule': '8100.00', 'announced': '4130.50', 'history': '2000.00'}, 'by_basis': {
'schedule': '8100.00',
'announced': '4130.50',
'history': '2000.00',
},
}; };
test('parses the calendar, keeping every amount as a string', () { test('parses the calendar, keeping every amount as a string', () {
@@ -44,13 +48,20 @@ void main() {
expect(c.entries.single.basis, 'announced'); expect(c.entries.single.basis, 'announced');
expect(c.entries.single.ticker, 'SBER'); expect(c.entries.single.ticker, 'SBER');
expect(c.entries.single.taxWithheld, isNull); expect(c.entries.single.taxWithheld, isNull);
expect(c.entries.single.perUnit, '34.84', reason: 'never parsed through double'); expect(
c.entries.single.perUnit,
'34.84',
reason: 'never parsed through double',
);
}); });
test('a null amount_rub stays null — no FX rate is not zero', () { test('a null amount_rub stays null — no FX rate is not zero', () {
final json = Map<String, dynamic>.from(calendarJson); final json = Map<String, dynamic>.from(calendarJson);
json['entries'] = [ json['entries'] = [
{...(calendarJson['entries'] as List).first as Map<String, dynamic>, 'amount_rub': null}, {
...(calendarJson['entries'] as List).first as Map<String, dynamic>,
'amount_rub': null,
},
]; ];
expect(IncomeCalendar.fromJson(json).entries.single.amountRub, isNull); expect(IncomeCalendar.fromJson(json).entries.single.amountRub, isNull);
}); });
@@ -82,18 +93,28 @@ void main() {
{ {
'month': '2026-10-01', 'month': '2026-10-01',
'amount_rub': '1830.20', 'amount_rub': '1830.20',
'by_basis': {'schedule': '1133.40', 'announced': '696.80', 'history': '0'}, 'by_basis': {
'schedule': '1133.40',
'announced': '696.80',
'history': '0',
},
}, },
], ],
'total_rub': '21960.00', 'total_rub': '21960.00',
'annual_yield_on_value': '0.081', 'annual_yield_on_value': '0.081',
'warnings': ['у 3 инструментов нет истории выплат — в прогноз не вошли'], 'warnings': [
'у 3 инструментов нет истории выплат — в прогноз не вошли',
],
}); });
expect(f.months.single.byBasis['schedule'], '1133.40'); expect(f.months.single.byBasis['schedule'], '1133.40');
expect(f.totalRub, '21960.00'); expect(f.totalRub, '21960.00');
expect(f.annualYieldOnValue, '0.081'); expect(f.annualYieldOnValue, '0.081');
expect(f.warnings, hasLength(1)); expect(f.warnings, hasLength(1));
expect(f.bases, ['schedule', 'announced', 'history'], reason: 'contract order'); expect(f.bases, [
'schedule',
'announced',
'history',
], reason: 'contract order');
final noYield = IncomeForecast.fromJson(const { final noYield = IncomeForecast.fromJson(const {
'months': <dynamic>[], 'months': <dynamic>[],
@@ -110,7 +131,12 @@ void main() {
'dimension': 'asset_class', 'dimension': 'asset_class',
'weights_sum': '1.00', 'weights_sum': '1.00',
'targets': [ 'targets': [
{'bucket': 'share', 'target_weight': '0.60', 'band': '0.05', 'note': null}, {
'bucket': 'share',
'target_weight': '0.60',
'band': '0.05',
'note': null,
},
{'bucket': 'bond', 'target_weight': '0.30', 'band': '0.05'}, {'bucket': 'bond', 'target_weight': '0.30', 'band': '0.05'},
{'bucket': 'cash', 'target_weight': '0.10', 'band': '0.02'}, {'bucket': 'cash', 'target_weight': '0.10', 'band': '0.02'},
], ],
@@ -121,22 +147,29 @@ void main() {
expect(set.targets.first.band, '0.05'); expect(set.targets.first.band, '0.05');
// 0.1 + 0.2 + 0.7 is exactly 1 in Decimal and would not be in double // 0.1 + 0.2 + 0.7 is exactly 1 in Decimal and would not be in double
final tenths = TargetSet(dimension: 'asset_class', targets: const [ final tenths = TargetSet(
dimension: 'asset_class',
targets: const [
TargetWeight(bucket: 'a', targetWeight: '0.1'), TargetWeight(bucket: 'a', targetWeight: '0.1'),
TargetWeight(bucket: 'b', targetWeight: '0.2'), TargetWeight(bucket: 'b', targetWeight: '0.2'),
TargetWeight(bucket: 'c', targetWeight: '0.7'), TargetWeight(bucket: 'c', targetWeight: '0.7'),
]); ],
);
expect(tenths.sumIsValid, isTrue); expect(tenths.sumIsValid, isTrue);
final short = TargetSet(dimension: 'asset_class', targets: const [ final short = TargetSet(
TargetWeight(bucket: 'a', targetWeight: '0.9'), dimension: 'asset_class',
]); targets: const [TargetWeight(bucket: 'a', targetWeight: '0.9')],
);
expect(short.sumIsValid, isFalse); expect(short.sumIsValid, isFalse);
}); });
test('the PUT body omits empty optionals and keeps shares as strings', () { test('the PUT body omits empty optionals and keeps shares as strings', () {
final body = const TargetWeight(bucket: 'bond', targetWeight: '0.30', band: '0.05') final body = const TargetWeight(
.toJson(); bucket: 'bond',
targetWeight: '0.30',
band: '0.05',
).toJson();
expect(body['target_weight'], '0.30'); expect(body['target_weight'], '0.30');
expect(body.containsKey('note'), isFalse); expect(body.containsKey('note'), isFalse);
}); });
@@ -151,7 +184,9 @@ void main() {
expect(formatShareAsPercent(null), ''); expect(formatShareAsPercent(null), '');
}); });
test('parses the rebalance plan, including within_band and blocked_by_cash', () { test(
'parses the rebalance plan, including within_band and blocked_by_cash',
() {
final plan = RebalancePlan.fromJson(const { final plan = RebalancePlan.fromJson(const {
'portfolio_id': 1, 'portfolio_id': 1,
'dimension': 'asset_class', 'dimension': 'asset_class',
@@ -195,7 +230,8 @@ void main() {
expect(trade.lot, 10); expect(trade.lot, 10);
expect(trade.blockedByCash, isFalse); expect(trade.blockedByCash, isFalse);
expect(plan.warnings, hasLength(1)); expect(plan.warnings, hasLength(1));
}); },
);
test('suggested_qty stays null when there is no price', () { test('suggested_qty stays null when there is no price', () {
final trade = RebalanceTrade.fromJson(const { final trade = RebalanceTrade.fromJson(const {
@@ -214,7 +250,9 @@ void main() {
}); });
group('benchmarks', () { group('benchmarks', () {
test('parses a period row with excess, kind and days_skipped on both sides', () { test(
'parses a period row with excess, kind and days_skipped on both sides',
() {
final rows = [ final rows = [
for (final r in const [ for (final r in const [
{ {
@@ -253,10 +291,14 @@ void main() {
expect(row.portfolioDaysSkipped, 3); expect(row.portfolioDaysSkipped, 3);
expect(row.hasSkippedDays, isTrue); expect(row.hasSkippedDays, isTrue);
expect(row.benchmarks.first.isPriceIndex, isFalse); expect(row.benchmarks.first.isPriceIndex, isFalse);
expect(row.benchmarks.last.isPriceIndex, isTrue, expect(
reason: 'a price index must be markable'); row.benchmarks.last.isPriceIndex,
isTrue,
reason: 'a price index must be markable',
);
expect(row.benchmarks.first.excess, '0.063'); expect(row.benchmarks.first.excess, '0.063');
}); },
);
}); });
group('goals', () { group('goals', () {
@@ -346,7 +388,8 @@ void main() {
'dividends_gross_rub': '12400.00', 'dividends_gross_rub': '12400.00',
'estimated_tax_rub': '1963.00', 'estimated_tax_rub': '1963.00',
}, },
'disclaimer': 'Оценка. Налоговый агент — брокер; сверяйтесь с его справкой.', 'disclaimer':
'Оценка. Налоговый агент — брокер; сверяйтесь с его справкой.',
}); });
expect(s.year, 2026); expect(s.year, 2026);
+3 -1
View File
@@ -7,7 +7,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
testWidgets('the add-rule dialog requires a non-empty pattern', (tester) async { testWidgets('the add-rule dialog requires a non-empty pattern', (
tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
ProviderScope( ProviderScope(
overrides: [ overrides: [
+4 -2
View File
@@ -28,10 +28,12 @@ TransactionOut _usdExpense() => TransactionOut(
tags: const [], tags: const [],
tripId: null, tripId: null,
ts: DateTime(2026, 9, 10), ts: DateTime(2026, 9, 10),
); );
void main() { void main() {
testWidgets('shows a USD amount with its RUB equivalent as secondary text', (tester) async { testWidgets('shows a USD amount with its RUB equivalent as secondary text', (
tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: Scaffold( home: Scaffold(
+4 -1
View File
@@ -3,7 +3,10 @@ import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
test('AuthState.signedIn reflects status', () { test('AuthState.signedIn reflects status', () {
expect(const AuthState(AuthStatus.signedIn, accessToken: 't').signedIn, isTrue); expect(
const AuthState(AuthStatus.signedIn, accessToken: 't').signedIn,
isTrue,
);
expect(const AuthState(AuthStatus.signedOut).signedIn, isFalse); expect(const AuthState(AuthStatus.signedOut).signedIn, isFalse);
}); });
} }
@@ -0,0 +1,49 @@
"""benchmark: the MOEX indices the portfolio is compared against
The list is data, but there is no screen to add to it, and an empty `benchmark` table leaves
`/analytics/benchmarks` and the overlay on the price chart with nothing to draw. IMOEX and
MCFTR are the pair the comparison is built around (a price index next to its dividend-
reinvested twin, so the gap between them is visible); RGBITR is there for a bond-heavy
portfolio and is not shown unasked. A row that is already there is left as the user set it.
Revision ID: f3a91c7d5e28
Revises: e8b21f6a90c3
Create Date: 2026-09-20 18:00:00.000000
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "f3a91c7d5e28"
down_revision: str | None = "e8b21f6a90c3"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
BENCHMARKS = [
("IMOEX", "Индекс МосБиржи", "price", True),
("MCFTR", "Индекс МосБиржи полной доходности «брутто»", "total_return", True),
(
"RGBITR",
"Индекс МосБиржи государственных облигаций (полной доходности)",
"total_return",
False,
),
]
def upgrade() -> None:
insert = sa.text(
"INSERT INTO benchmark (code, name, kind, source, currency, is_default, is_active) "
"VALUES (:code, :name, CAST(:kind AS benchmark_kind), 'moex', 'RUB', :is_default, true) "
"ON CONFLICT (code) DO NOTHING"
)
for code, name, kind, is_default in BENCHMARKS:
op.execute(insert.bindparams(code=code, name=name, kind=kind, is_default=is_default))
def downgrade() -> None:
op.execute(sa.text("DELETE FROM benchmark WHERE code IN ('IMOEX', 'MCFTR', 'RGBITR')"))
+22 -3
View File
@@ -190,6 +190,17 @@ def nominal_at(schedule: Sequence[tuple[date, Decimal]], d: date) -> Decimal | N
return current return current
def final_redemption_date(schedule: Sequence[tuple[date, Decimal]]) -> date | None:
"""The day the published schedule takes the nominal to zero, if it does.
A bond's amortisation plan ends with the redemption itself, and each row records what the
nominal *becomes* so the last row is zero exactly on the maturity date.
"""
if schedule and schedule[-1][1] == ZERO:
return schedule[-1][0]
return None
def coupon_per_unit( def coupon_per_unit(
declared: Decimal, base_nominal: Decimal | None, nominal_on_date: Decimal | None declared: Decimal, base_nominal: Decimal | None, nominal_on_date: Decimal | None
) -> Decimal: ) -> Decimal:
@@ -366,7 +377,9 @@ def bond_entries(
previous: Decimal | None = None previous: Decimal | None = None
for effective, value in schedule: for effective, value in schedule:
if previous is not None and start <= effective <= end and value < previous: # the step that takes the nominal to zero is the redemption, built below: counted here
# as well it would put the last payment on the calendar twice
if previous is not None and start <= effective <= end and 0 < value < previous:
step = previous - value step = previous - value
out.append( out.append(
Entry( Entry(
@@ -383,9 +396,15 @@ def bond_entries(
) )
previous = value previous = value
maturity = facts.maturity_date # the passport date when the instrument has one; otherwise the day the published schedule
# runs the nominal down to zero, which is the same day by construction
maturity = facts.maturity_date or final_redemption_date(schedule)
if maturity is not None and start <= maturity <= end: if maturity is not None and start <= maturity <= end:
par = nominal_at(schedule, maturity) or facts.nominal # what is repaid at maturity is the nominal still standing the day before: the schedule
# entry for the maturity date itself says what is left AFTER it, i.e. nothing
par = nominal_at(schedule, maturity - timedelta(days=1))
if par is None:
par = facts.nominal
if par is not None: if par is not None:
out.append( out.append(
Entry( Entry(
@@ -111,6 +111,15 @@ class AmortisationRow:
currency: str | None currency: str | None
@dataclass(frozen=True)
class BondDescription:
maturity_date: date | None
face_value: Decimal | None
"""The nominal in force today (`FACEVALUE`), not the issue par: it is what a percent quote
is a percent of."""
face_unit: str | None
def new_http_client() -> httpx.AsyncClient: def new_http_client() -> httpx.AsyncClient:
# trust_env=False: see NETWORK NOTE above # trust_env=False: see NETWORK NOTE above
return httpx.AsyncClient(trust_env=False, timeout=TIMEOUT) return httpx.AsyncClient(trust_env=False, timeout=TIMEOUT)
@@ -222,6 +231,16 @@ class MoexClient:
] ]
return coupons, amortisations return coupons, amortisations
async def bond_description(self, secid: str) -> BondDescription:
"""The bond's passport: when it matures and what its nominal is today."""
payload = await self._get(f"/securities/{secid}.json", **{"iss.only": "description"})
fields = {str(row.get("name")): row.get("value") for row in _rows(payload, "description")}
return BondDescription(
maturity_date=_date(fields.get("MATDATE")),
face_value=_decimal(fields.get("FACEVALUE")),
face_unit=_currency(fields.get("FACEUNIT")),
)
def _rows(payload: dict[str, Any], block: str) -> list[dict[str, Any]]: def _rows(payload: dict[str, Any], block: str) -> list[dict[str, Any]]:
"""Turn ISS's {columns, data} block into dicts, so fields are read by name.""" """Turn ISS's {columns, data} block into dicts, so fields are read by name."""
+56 -4
View File
@@ -16,6 +16,9 @@ What it writes, and what it deliberately does not:
* amortisations -> `bond_nominal_schedule(source='moex')`, and NOT * amortisations -> `bond_nominal_schedule(source='moex')`, and NOT
`corporate_action(kind=amortization)`: that kind belongs to `ledger/corporate_actions.py`, `corporate_action(kind=amortization)`: that kind belongs to `ledger/corporate_actions.py`,
whose prune deletes every row in it the ledger does not imply. whose prune deletes every row in it the ledger does not imply.
* a bond's passport (`/securities/{secid}.json`, `description`) -> `instrument.maturity_date`
and `nominal`, only where they are empty: the operations feed leaves both blank, and the
redemption on the income calendar cannot be built without them.
**The amortisation plan is read as a run-out, not as a column.** ISS states `value` (repaid **The amortisation plan is read as a run-out, not as a column.** ISS states `value` (repaid
per bond) and `facevalue` per row, but which side of the payment `facevalue` stands on is not per bond) and `facevalue` per row, but which side of the payment `facevalue` stands on is not
@@ -38,7 +41,7 @@ from datetime import date
from decimal import Decimal from decimal import Decimal
import httpx import httpx
from sqlalchemy import select from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from fintracker.analytics import today_local from fintracker.analytics import today_local
@@ -100,6 +103,8 @@ class Target:
secid: str secid: str
asset_class: AssetClass asset_class: AssetClass
currency: str currency: str
maturity_date: date | None = None
nominal: Decimal | None = None
async def fetch_dividends(client: httpx.AsyncClient, secid: str) -> list[MoexDividendRow]: async def fetch_dividends(client: httpx.AsyncClient, secid: str) -> list[MoexDividendRow]:
@@ -220,7 +225,14 @@ class MoexPayoutsSource:
log.info("moex_payouts: no priceable instruments in the ledger yet") log.info("moex_payouts: no priceable instruments in the ledger yet")
return SyncResult(cursor_after=today.isoformat(), counts={"payouts": 0}, changed=False) return SyncResult(cursor_after=today.isoformat(), counts={"payouts": 0}, changed=False)
counts = {"instruments": 0, "coupons": 0, "dividends": 0, "payouts": 0, "nominals": 0} counts = {
"instruments": 0,
"coupons": 0,
"dividends": 0,
"payouts": 0,
"nominals": 0,
"bond_facts": 0,
}
warnings: list[str] = [] warnings: list[str] = []
payouts: list[PayoutRow] = [] payouts: list[PayoutRow] = []
nominals: list[NominalPoint] = [] nominals: list[NominalPoint] = []
@@ -231,6 +243,7 @@ class MoexPayoutsSource:
if target.asset_class in BOND_CLASSES: if target.asset_class in BOND_CLASSES:
rows = await self._bond(moex, target, today, payouts, nominals, warnings) rows = await self._bond(moex, target, today, payouts, nominals, warnings)
counts["coupons"] += rows counts["coupons"] += rows
counts["bond_facts"] += await self._bond_facts(session, moex, target, warnings)
else: else:
counts["dividends"] += await self._dividends( counts["dividends"] += await self._dividends(
http, target, today, payouts, warnings http, target, today, payouts, warnings
@@ -250,9 +263,44 @@ class MoexPayoutsSource:
cursor_after=today.isoformat(), cursor_after=today.isoformat(),
counts=counts, counts=counts,
warnings=warnings, warnings=warnings,
changed=bool(counts["payouts"] or counts["nominals"]), changed=bool(counts["payouts"] or counts["nominals"] or counts["bond_facts"]),
) )
async def _bond_facts(
self, session: AsyncSession, moex: MoexClient, target: Target, warnings: list[str]
) -> int:
"""Fill the maturity date and the nominal a bond is missing, from its MOEX passport.
Neither is on the instrument when it came from the operations feed (T-Invest's
`GetInstrumentBy` states neither), and the redemption on the income calendar is built
from exactly these two. A value already there is never overwritten: it may have been
corrected by hand. Returns the number of fields written.
"""
if target.maturity_date is not None and target.nominal is not None:
return 0
description = None
for secid in _secid_candidates(target.secid):
try:
description = await moex.bond_description(secid)
except (MoexError, httpx.HTTPError) as err:
warnings.append(f"{secid}: {err}")
continue
break
if description is None:
return 0
values: dict[str, object] = {}
if target.maturity_date is None and description.maturity_date is not None:
values["maturity_date"] = description.maturity_date
if target.nominal is None and description.face_value is not None:
values["nominal"] = description.face_value
values["nominal_currency"] = description.face_unit or target.currency
if values:
await session.execute(
update(Instrument).where(Instrument.id == target.instrument_id).values(**values)
)
return len(values)
async def _bond( async def _bond(
self, self,
moex: MoexClient, moex: MoexClient,
@@ -320,6 +368,8 @@ async def load_targets(session: AsyncSession) -> list[Target]:
Instrument.ticker, Instrument.ticker,
Instrument.asset_class, Instrument.asset_class,
Instrument.currency, Instrument.currency,
Instrument.maturity_date,
Instrument.nominal,
) )
.join(Event, Event.instrument_id == Instrument.id) .join(Event, Event.instrument_id == Instrument.id)
.where( .where(
@@ -327,7 +377,7 @@ async def load_targets(session: AsyncSession) -> list[Target]:
Instrument.ticker.is_not(None), Instrument.ticker.is_not(None),
Instrument.asset_class.in_(BOND_CLASSES | DIVIDEND_CLASSES), Instrument.asset_class.in_(BOND_CLASSES | DIVIDEND_CLASSES),
) )
.group_by(Instrument.id, Instrument.ticker, Instrument.asset_class, Instrument.currency) .group_by(Instrument.id)
) )
).all() ).all()
return [ return [
@@ -336,6 +386,8 @@ async def load_targets(session: AsyncSession) -> list[Target]:
secid=r.ticker, secid=r.ticker,
asset_class=r.asset_class, asset_class=r.asset_class,
currency=r.currency, currency=r.currency,
maturity_date=r.maturity_date,
nominal=r.nominal,
) )
for r in rows for r in rows
] ]
+121 -11
View File
@@ -1,8 +1,12 @@
"""The `moex` source: fill `price_daily` / `price_last` for papers the portfolio holds. """The `moex` source: fill `price_daily` / `price_last` for papers the portfolio holds.
Scope is derived from the ledger, not configured: only instruments that appear in `event` Scope is derived from the ledger, not configured: only instruments that appear in `event`
are priced, and each is fetched from the first day it was held rather than from its are priced, plus the indices behind the active MOEX benchmarks (`_ensure_benchmark_instruments`
listing pricing a paper for years before it was bought would be thousands of useless rows. gives each one an `instrument`; `analytics/benchmarks.py` and the chart overlay read them
from `price_daily` like any other paper). Each is fetched from its listing on the board it
is priced from (`history_start`), not from the day it was first held: the chart on the
instrument card shows the paper's own history, and the analytics never read a price from
before the first purchase, so the older rows cost only disk.
Each instrument's board is resolved from `/securities/{secid}.json` and cached in Each instrument's board is resolved from `/securities/{secid}.json` and cached in
`instrument.board`/`exchange`. A cached board is only kept while it is still trading, and the `instrument.board`/`exchange`. A cached board is only kept while it is still trading, and the
@@ -17,10 +21,10 @@ the past never closed — which is what left TWR skipping days for want of a pri
window comes from the instrument's own state instead: window comes from the instrument's own state instead:
* `price_coverage.history_from` the earliest date we have already *asked* ISS for. While * `price_coverage.history_from` the earliest date we have already *asked* ISS for. While
it is later than the day the paper was first held (or missing), the run does a full sweep it is later than the start of the paper's history (`history_start`) or missing, the run
from that day; once recorded, the paper falls back to the incremental window. Asking is does a full sweep from that day; once recorded, the paper falls back to the incremental
what gets remembered, not receiving: a stretch the exchange has nothing for would window. Asking is what gets remembered, not receiving: a stretch the exchange has nothing
otherwise be re-requested on every single run, forever. for would otherwise be re-requested on every single run, forever.
* `max(price_daily.d)` the newest day stored. The incremental window starts a few days * `max(price_daily.d)` the newest day stored. The incremental window starts a few days
before it, because ISS revises a session's settlement price after the close, and because before it, because ISS revises a session's settlement price after the close, and because
anchoring on the instrument's own data (rather than on the run date) makes a run that anchoring on the instrument's own data (rather than on the run date) makes a run that
@@ -31,7 +35,7 @@ from __future__ import annotations
import logging import logging
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import dataclass from dataclasses import dataclass, replace
from datetime import UTC, date, datetime, timedelta from datetime import UTC, date, datetime, timedelta
from decimal import Decimal from decimal import Decimal
@@ -40,7 +44,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from fintracker.analytics import today_local from fintracker.analytics import today_local
from fintracker.models import AssetClass, Event, EventStatus, Instrument from fintracker.models import AssetClass, Benchmark, Event, EventStatus, Instrument
from fintracker.models.pricing import PriceCoverage, PriceDaily, PriceLast from fintracker.models.pricing import PriceCoverage, PriceDaily, PriceLast
from fintracker.sources.base import SyncContext, SyncResult from fintracker.sources.base import SyncContext, SyncResult
from fintracker.sources.moex.client import BoardInfo, Candle, MoexClient, MoexError from fintracker.sources.moex.client import BoardInfo, Candle, MoexClient, MoexError
@@ -59,7 +63,17 @@ calendar artefact rather than a paper we failed to download.
CHUNK = 500 CHUNK = 500
#: Only these can be priced on MOEX; currencies come from the CBR and custom holdings by hand. #: Only these can be priced on MOEX; currencies come from the CBR and custom holdings by hand.
PRICEABLE = {AssetClass.share, AssetClass.bond, AssetClass.etf, AssetClass.fund} PRICEABLE = {
AssetClass.share,
AssetClass.bond,
AssetClass.etf,
AssetClass.fund,
AssetClass.market_index,
}
BENCHMARK_SINCE = date(1990, 1, 1)
"""An index has no first purchase: this stands in for it, and `history_start` pulls it forward
to the day the index began (IMOEX: 1997-09-22)."""
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -73,6 +87,8 @@ class Target:
board: str | None board: str | None
exchange: str | None exchange: str | None
asset_class: AssetClass asset_class: AssetClass
is_benchmark: bool = False
"""Priced for a comparison, not because the portfolio holds it."""
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -89,6 +105,23 @@ class Coverage:
"""Breaks longer than GAP_DAYS between two stored days, as (last before, first after).""" """Breaks longer than GAP_DAYS between two stored days, as (last before, first after)."""
def history_start(first_held: date, board: BoardInfo) -> date:
"""The day to start a paper's history from: its listing on the board it is priced from.
The chart on the instrument card shows the paper's own price history, not just the stretch
it was held a chart that begins on the day of the first purchase says nothing about
where the paper stood when it was bought. The board's `history_from` is the earliest day
ISS has anything for, so it bounds the sweep without guessing a floor.
A board that started *after* the first purchase (the T-Bank funds that moved from TQTF to
TQBR in June 2026) does not go back that far: the day held stays the start, and
`history_legs` asks the board it traded on before the move for the rest.
"""
if board.history_from is None:
return first_held
return min(first_held, board.history_from)
def needs_backfill(target: Target, coverage: Coverage) -> bool: def needs_backfill(target: Target, coverage: Coverage) -> bool:
"""True while the paper's history has never been asked for from the day it was held.""" """True while the paper's history has never been asked for from the day it was held."""
return coverage.history_from is None or coverage.history_from > target.since return coverage.history_from is None or coverage.history_from > target.since
@@ -166,6 +199,7 @@ class MoexSource:
async def sync(self, ctx: SyncContext) -> SyncResult: async def sync(self, ctx: SyncContext) -> SyncResult:
session = ctx.session session = ctx.session
today = today_local() today = today_local()
await _ensure_benchmark_instruments(session)
targets = await _targets(session) targets = await _targets(session)
if not targets: if not targets:
log.info("moex: no priceable instruments in the ledger yet") log.info("moex: no priceable instruments in the ledger yet")
@@ -183,6 +217,8 @@ class MoexSource:
continue continue
board, boards = resolved board, boards = resolved
coverage = coverages.get(target.instrument_id, Coverage()) coverage = coverages.get(target.instrument_id, Coverage())
first_held = target.since
target = replace(target, since=history_start(first_held, board))
backfill = needs_backfill(target, coverage) backfill = needs_backfill(target, coverage)
since, until = fetch_window(target, coverage, today) since, until = fetch_window(target, coverage, today)
candles: list[Candle] = [] candles: list[Candle] = []
@@ -207,14 +243,21 @@ class MoexSource:
log.info("moex: %s backfilled from %s", target.secid, since) log.info("moex: %s backfilled from %s", target.secid, since)
else: else:
# a sweep closes these itself; reporting them otherwise keeps a paper that # a sweep closes these itself; reporting them otherwise keeps a paper that
# stopped trading from looking like a failed download, and vice versa # stopped trading from looking like a failed download, and vice versa.
# Only breaks in the stretch the paper was held: the older history is there
# for the chart, and a halt years before the purchase is not ours to fix.
# An index was never held — the comparison reports its own `days_skipped`.
if not target.is_benchmark:
warnings += [ warnings += [
f"{target.secid}: разрыв в истории {a.isoformat()}..{b.isoformat()}" f"{target.secid}: разрыв в истории {a.isoformat()}..{b.isoformat()}"
for a, b in coverage.gaps for a, b in coverage.gaps
if a >= first_held
] ]
# remembered even when ISS returned nothing: we asked, and asking is the state # remembered even when ISS returned nothing: we asked, and asking is the state
await _store_coverage(session, target, since) await _store_coverage(session, target, since)
if target.is_benchmark:
continue # an index has no live quote to hold: only its daily close is read
last = await moex.last_price( last = await moex.last_price(
board.secid, engine=board.engine, market=board.market, board=board.board board.secid, engine=board.engine, market=board.market, board=board.board
) )
@@ -264,7 +307,7 @@ async def _targets(session: AsyncSession) -> list[Target]:
) )
) )
).all() ).all()
return [ targets = [
Target( Target(
instrument_id=r.id, instrument_id=r.id,
secid=r.ticker, secid=r.ticker,
@@ -278,6 +321,73 @@ async def _targets(session: AsyncSession) -> list[Target]:
if r.asset_class in PRICEABLE and r.first_held if r.asset_class in PRICEABLE and r.first_held
] ]
held = {t.instrument_id for t in targets}
indices = (
await session.execute(
select(Instrument)
.join(Benchmark, Benchmark.instrument_id == Instrument.id)
.where(Benchmark.source == SOURCE, Benchmark.is_active, Instrument.ticker.is_not(None))
)
).scalars()
return targets + [
Target(
instrument_id=i.id,
secid=i.ticker or "",
since=BENCHMARK_SINCE,
nominal=None,
board=i.board,
exchange=i.exchange,
asset_class=i.asset_class,
is_benchmark=True,
)
for i in indices
if i.id not in held
]
async def _ensure_benchmark_instruments(session: AsyncSession) -> None:
"""Give every active MOEX benchmark the `instrument` its history is stored under.
The benchmark list is the user's data, and a row added through the API has no instrument
yet (`instrument_id` is NULL until the index is first synced). The board is left empty:
indices do not share one IMOEX and RGBITR are on SNDX, MCFTR is on RTSI so `_board_for`
resolves it from ISS like it does for any paper.
"""
pending = (
(
await session.execute(
select(Benchmark).where(
Benchmark.source == SOURCE,
Benchmark.is_active,
Benchmark.instrument_id.is_(None),
)
)
)
.scalars()
.all()
)
for benchmark in pending:
instrument = (
await session.execute(
select(Instrument).where(
Instrument.ticker == benchmark.code,
Instrument.asset_class == AssetClass.market_index,
)
)
).scalar_one_or_none()
if instrument is None:
instrument = Instrument(
asset_class=AssetClass.market_index,
ticker=benchmark.code,
name=benchmark.name,
currency=benchmark.currency,
)
session.add(instrument)
await session.flush()
benchmark.instrument_id = instrument.id
if pending:
await session.flush()
async def _coverage(session: AsyncSession) -> dict[int, Coverage]: async def _coverage(session: AsyncSession) -> dict[int, Coverage]:
"""Stored span, remembered backfill depth and long breaks, for every instrument at once.""" """Stored span, remembered backfill depth and long breaks, for every instrument at once."""
+35
View File
@@ -117,6 +117,41 @@ def test_bond_entries_cover_coupon_amortisation_and_redemption():
assert all(e.basis is IncomeBasis.schedule for e in entries) assert all(e.basis is IncomeBasis.schedule for e in entries)
def test_a_bullet_bond_is_redeemed_on_the_day_its_schedule_reaches_zero():
"""The OFZ 26226 case: no `maturity_date` on the instrument, one schedule row — zero."""
facts = BondFacts(
nominal=D(1000),
nominal_schedule=((date(2026, 10, 7), D(0)),),
maturity_date=None,
currency="RUB",
)
entries = bond_entries(1, facts, [], D(5), start=date(2026, 9, 20), end=date(2027, 9, 20))
assert [(e.kind, e.expected_date, e.per_unit, e.amount) for e in entries] == [
("repayment", date(2026, 10, 7), D(1000), D(5000))
]
def test_the_last_amortisation_is_the_redemption_and_is_not_counted_twice():
facts = BondFacts(
nominal=D(1000),
nominal_schedule=(
(date(2026, 10, 1), D(500)),
(date(2027, 1, 1), D(200)),
(date(2027, 4, 1), D(0)),
),
maturity_date=None,
currency="RUB",
)
entries = bond_entries(1, facts, [], D(10), start=date(2026, 9, 20), end=date(2027, 9, 20))
by_kind = [(e.kind, e.expected_date, e.amount) for e in entries]
# the first row of a plan has no earlier nominal to step down from, so it is not a step;
# the step to 200 is one, and the final 200 is repaid once — as the redemption
assert by_kind == [
("amortization", date(2027, 1, 1), D(3000)),
("repayment", date(2027, 4, 1), D(2000)),
]
def test_an_announced_payout_displaces_the_projection_of_the_same_payment(): def test_an_announced_payout_displaces_the_projection_of_the_same_payment():
announced = [ announced = [
Entry( Entry(
+3
View File
@@ -56,6 +56,9 @@ async def app(migrated: str):
from fintracker.db import reset_engine from fintracker.db import reset_engine
auth_router._login_limiter = None # fresh rate limiter per test auth_router._login_limiter = None # fresh rate limiter per test
# rows a migration seeds (the default benchmarks) are in the first test's database only,
# since the tables are emptied after each test — start every test from the same blank one
await _truncate_all()
application = app_module.create_app() application = app_module.create_app()
yield application yield application
await _truncate_all() await _truncate_all()
@@ -286,6 +286,20 @@ def mock_iss(mock_http) -> None:
), ),
) )
) )
mock_http.get(url__startswith=f"{ISS}/securities/RU000A.json").mock(
return_value=httpx.Response(
200,
json=block(
"description",
["name", "title", "value"],
[
["MATDATE", "Дата погашения", "2028-05-05"],
["FACEVALUE", "Номинальная стоимость", "750"],
["FACEUNIT", "Валюта номинала", "SUR"],
],
),
)
)
mock_http.get(url__startswith=f"{ISS}/securities/SBER/dividends").mock( mock_http.get(url__startswith=f"{ISS}/securities/SBER/dividends").mock(
return_value=httpx.Response( return_value=httpx.Response(
200, 200,
@@ -298,6 +312,37 @@ def mock_iss(mock_http) -> None:
) )
async def test_a_bond_run_fills_the_maturity_and_nominal_the_instrument_lacks(
app, mock_http, run_sync
):
"""The redemption on the income calendar is built from these two fields."""
instrument_id = await seed(AssetClass.bond, "RU000A")
mock_iss(mock_http)
await run_sync(MoexPayoutsSource(), settings=Settings())
(instrument,) = await rows_of(Instrument, id=instrument_id)
assert instrument.maturity_date == date(2028, 5, 5)
assert instrument.nominal == D(750)
assert instrument.nominal_currency == "RUB"
async def test_a_value_already_on_the_instrument_is_not_overwritten(app, mock_http, run_sync):
instrument_id = await seed(AssetClass.bond, "RU000A")
async with get_sessionmaker()() as session:
instrument = await session.get(Instrument, instrument_id)
assert instrument is not None
instrument.nominal = D(1000)
await session.commit()
mock_iss(mock_http)
await run_sync(MoexPayoutsSource(), settings=Settings())
(instrument,) = await rows_of(Instrument, id=instrument_id)
assert instrument.nominal == D(1000) # what was there stays, whatever MOEX says today
assert instrument.maturity_date == date(2028, 5, 5) # only the missing field is filled
async def test_a_bond_run_writes_coupons_and_the_nominal_schedule(app, mock_http, run_sync): async def test_a_bond_run_writes_coupons_and_the_nominal_schedule(app, mock_http, run_sync):
instrument_id = await seed(AssetClass.bond, "RU000A") instrument_id = await seed(AssetClass.bond, "RU000A")
mock_iss(mock_http) mock_iss(mock_http)
+136 -7
View File
@@ -18,7 +18,15 @@ from factories import make_account, make_event, make_instrument, make_price
from fintracker.analytics import today_local from fintracker.analytics import today_local
from fintracker.config import Settings from fintracker.config import Settings
from fintracker.db import get_sessionmaker from fintracker.db import get_sessionmaker
from fintracker.models import EventKind, PriceCoverage, PriceDaily from fintracker.models import (
AssetClass,
Benchmark,
BenchmarkKind,
EventKind,
Instrument,
PriceCoverage,
PriceDaily,
)
from fintracker.sources.moex.client import BoardInfo from fintracker.sources.moex.client import BoardInfo
from fintracker.sources.moex.sync import ( from fintracker.sources.moex.sync import (
OVERLAP_DAYS, OVERLAP_DAYS,
@@ -28,11 +36,14 @@ from fintracker.sources.moex.sync import (
choose_board, choose_board,
fetch_window, fetch_window,
history_legs, history_legs,
history_start,
needs_backfill, needs_backfill,
) )
ISS = "https://iss.moex.com/iss" ISS = "https://iss.moex.com/iss"
TODAY = today_local() TODAY = today_local()
LISTING = date(2013, 3, 25)
"""When the mocked TQBR started: the day a backfill now reaches back to."""
def monday_back(days: int) -> date: def monday_back(days: int) -> date:
@@ -198,8 +209,27 @@ def test_a_paper_without_any_price_is_asked_from_the_day_it_was_held():
assert fetch_window(target(since), Coverage(), TODAY) == (since, TODAY) assert fetch_window(target(since), Coverage(), TODAY) == (since, TODAY)
async def test_a_hole_in_the_past_is_pulled_back_to_the_first_held_day(app, mock_http, run_sync): def test_history_starts_at_the_listing_when_that_is_before_the_first_purchase():
"""The TBRU@ case: held since spring, priced only from the day the source first saw it.""" whole = board("TQBR", primary=True, since="2013-03-25", till="2026-09-17")
assert history_start(date(2025, 7, 8), whole) == date(2013, 3, 25)
def test_history_starts_at_the_first_purchase_when_the_board_began_later():
"""A board the paper moved to has no history before the move — the old board supplies it."""
assert history_start(date(2025, 7, 8), MOVED_TO) == date(2025, 7, 8)
def test_history_starts_at_the_first_purchase_when_the_board_publishes_no_range():
unknown = board("TQBR", primary=True, since=None, till=None)
assert history_start(date(2025, 7, 8), unknown) == date(2025, 7, 8)
async def test_a_hole_in_the_past_is_pulled_back_to_the_listing(app, mock_http, run_sync):
"""The TBRU@ case: held since spring, priced only from the day the source first saw it.
The sweep goes back past the first purchase to the listing, so the chart on the
instrument card has the paper's own history rather than only the stretch it was held.
"""
first_held = monday_back(60) first_held = monday_back(60)
recent = [TODAY - timedelta(days=n) for n in (3, 2, 1)] recent = [TODAY - timedelta(days=n) for n in (3, 2, 1)]
instrument_id = await seed(first_held=first_held, priced=recent, asked_from=recent[0]) instrument_id = await seed(first_held=first_held, priced=recent, asked_from=recent[0])
@@ -207,9 +237,9 @@ async def test_a_hole_in_the_past_is_pulled_back_to_the_first_held_day(app, mock
history = mock_iss(mock_http) history = mock_iss(mock_http)
result = await run_sync(MoexSource(), settings=settings()) result = await run_sync(MoexSource(), settings=settings())
assert windows(history) == [(first_held, TODAY)] assert windows(history) == [(LISTING, TODAY)]
count, low, high = await stored_days(instrument_id) count, low, high = await stored_days(instrument_id)
assert low == first_held assert low == LISTING
assert high >= recent[-1] assert high >= recent[-1]
assert count > len(recent) assert count > len(recent)
assert result.counts["backfilled"] == 1 assert result.counts["backfilled"] == 1
@@ -223,7 +253,7 @@ async def test_a_complete_history_only_re_reads_the_overlap_window(app, mock_htt
for n in range(61) for n in range(61)
if (first_held + timedelta(days=n)).isoweekday() < 6 if (first_held + timedelta(days=n)).isoweekday() < 6
] ]
await seed(first_held=first_held, priced=priced, asked_from=first_held) await seed(first_held=first_held, priced=priced, asked_from=LISTING)
history = mock_iss(mock_http) history = mock_iss(mock_http)
result = await run_sync(MoexSource(), settings=settings()) result = await run_sync(MoexSource(), settings=settings())
@@ -256,7 +286,7 @@ async def test_a_long_break_inside_a_settled_history_is_reported(app, mock_http,
so the gap is surfaced as a warning instead of being silently re-fetched every run.""" so the gap is surfaced as a warning instead of being silently re-fetched every run."""
first_held = monday_back(120) first_held = monday_back(120)
priced = [first_held, first_held + timedelta(days=1), TODAY - timedelta(days=1)] priced = [first_held, first_held + timedelta(days=1), TODAY - timedelta(days=1)]
await seed(first_held=first_held, priced=priced, asked_from=first_held) await seed(first_held=first_held, priced=priced, asked_from=LISTING)
mock_iss(mock_http) mock_iss(mock_http)
result = await run_sync(MoexSource(), settings=settings()) result = await run_sync(MoexSource(), settings=settings())
@@ -266,6 +296,22 @@ async def test_a_long_break_inside_a_settled_history_is_reported(app, mock_http,
] ]
async def test_a_break_before_the_first_purchase_is_not_reported(app, mock_http, run_sync):
"""The older history exists for the chart; a halt years before the purchase is not ours."""
first_held = monday_back(60)
held = [
first_held + timedelta(days=n)
for n in range(61)
if (first_held + timedelta(days=n)).isoweekday() < 6
]
await seed(first_held=first_held, priced=[LISTING, *held], asked_from=LISTING)
mock_iss(mock_http)
result = await run_sync(MoexSource(), settings=settings())
assert result.warnings == []
def board(name: str, *, primary: bool, since: str | None, till: str | None) -> BoardInfo: def board(name: str, *, primary: bool, since: str | None, till: str | None) -> BoardInfo:
return BoardInfo( return BoardInfo(
secid="TBRU", secid="TBRU",
@@ -343,3 +389,86 @@ async def test_a_backfill_spans_both_sides_of_a_board_move(app, mock_http, run_s
assert low == first_held # the stretch on the old board is stored under the same instrument assert low == first_held # the stretch on the old board is stored under the same instrument
assert high >= TODAY - timedelta(days=2) # ... and the new board carries it to today assert high >= TODAY - timedelta(days=2) # ... and the new board carries it to today
assert count > 60 assert count > 60
async def add_benchmark(code: str, *, source: str = "moex", active: bool = True) -> int:
async with get_sessionmaker()() as session:
benchmark = Benchmark(
code=code,
name=f"Индекс {code}",
kind=BenchmarkKind.total_return,
source=source,
currency="RUB",
is_default=False,
is_active=active,
)
session.add(benchmark)
await session.commit()
return benchmark.id
async def benchmark_instrument(benchmark_id: int) -> Instrument | None:
async with get_sessionmaker()() as session:
benchmark = await session.get(Benchmark, benchmark_id)
assert benchmark is not None
if benchmark.instrument_id is None:
return None
return await session.get(Instrument, benchmark.instrument_id)
async def test_an_active_benchmark_gets_an_instrument_and_its_whole_history(
app, mock_http, run_sync
):
"""MCFTR is on RTSI, not SNDX with the other indices — the board comes from ISS."""
benchmark_id = await add_benchmark("MCFTR")
listed = date(2003, 2, 26)
history = mock_iss(
mock_http,
boards=[["MCFTR", "RTSI", "index", "stock", 1, listed.isoformat(), TODAY.isoformat()]],
)
result = await run_sync(MoexSource(), settings=settings())
instrument = await benchmark_instrument(benchmark_id)
assert instrument is not None
assert instrument.asset_class == AssetClass.market_index
assert (instrument.ticker, instrument.board, instrument.exchange) == (
"MCFTR",
"RTSI",
"index",
)
assert legs(history) == [("RTSI", listed, TODAY)]
count, low, high = await stored_days(instrument.id)
assert low == listed
assert high >= TODAY - timedelta(days=3)
assert count > 1000
assert result.counts["last"] == 0 # an index has no live quote to keep
assert result.warnings == []
async def test_a_second_run_does_not_duplicate_the_benchmark_instrument(app, mock_http, run_sync):
benchmark_id = await add_benchmark("IMOEX")
mock_iss(
mock_http,
boards=[["IMOEX", "SNDX", "index", "stock", 1, "1997-09-22", TODAY.isoformat()]],
)
await run_sync(MoexSource(), settings=settings())
first = await benchmark_instrument(benchmark_id)
second_run = await run_sync(MoexSource(), settings=settings())
second = await benchmark_instrument(benchmark_id)
assert first is not None and second is not None
assert first.id == second.id
assert second_run.counts["backfilled"] == 0
async def test_an_inactive_or_manual_benchmark_is_left_alone(app, mock_http, run_sync):
inactive = await add_benchmark("RGBITR", active=False)
manual = await add_benchmark("SPX", source="manual")
result = await run_sync(MoexSource(), settings=settings()) # no ISS route: any call fails
assert await benchmark_instrument(inactive) is None
assert await benchmark_instrument(manual) is None
assert result.counts == {"prices": 0}
+3 -3
View File
@@ -20,7 +20,7 @@ sources/* ──sync──▶ raw_* (JSONB, идемпотентно) ──map
worker (APScheduler, advisory locks) ▼ worker (APScheduler, advisory locks) ▼
ledger (лоты FIFO, дедуп, матчинг ZM↔брокер) ledger (лоты FIFO, дедуп, матчинг ZM↔брокер)
analytics (polars, pyxirr) ──▶ metric_* ──▶ api ──▶ Flutter analytics (pyxirr) ─────────▶ metric_* ──▶ api ──▶ Flutter
``` ```
- `backend/src/fintracker/sources/` — по одному пакету на источник, контракт в `base.py`. - `backend/src/fintracker/sources/` — по одному пакету на источник, контракт в `base.py`.
@@ -37,5 +37,5 @@ worker (APScheduler, advisory locks) ▼
- [links.md](links.md) — внешние API и референсы. - [links.md](links.md) — внешние API и референсы.
- [offline-cache.md](offline-cache.md) — офлайн-кэш Flutter-клиента: контракт `Cached<T>`, - [offline-cache.md](offline-cache.md) — офлайн-кэш Flutter-клиента: контракт `Cached<T>`,
`CacheInterceptor`, баннер «данные на …» (фаза 5). `CacheInterceptor`, баннер «данные на …» (фаза 5).
- [design-system.md](design-system.md) — визуальный язык Flutter-клиента: токены темы, - [design-system.md](design-system.md) — визуальный язык Flutter-клиента по образцу Snowball: токены
`SectionHeader`/`TileCarousel`, группированный `NavSidebar` (обкатано на Обзоре). темы, верхняя панель навигации, сетка карточек на Обзоре, таблица активов, вкладки Аналитики.
+31 -10
View File
@@ -1,7 +1,7 @@
# Архитектура # Архитектура
Полный проект с обоснованиями — `docs/ai/plan.md`. Здесь — то, что нужно держать в голове Полный проект с обоснованиями — `docs/ai/plan.md`. Здесь — то, что нужно держать в голове
при изменении кода. Таблицы, которых ещё нет в `models/`, помечены *(план)*. при изменении кода. Всё описанное ниже реализовано; чего в коде нет, здесь не упоминается.
## Домен ## Домен
@@ -10,15 +10,23 @@
`source` + `source_id` уникальны. `mirror_of_account_id` помечает ZM-счёт, который лишь `source` + `source_id` уникальны. `mirror_of_account_id` помечает ZM-счёт, который лишь
зеркалит брокерский (исключается из net worth). `primary_event_source` — чей леджер зеркалит брокерский (исключается из net worth). `primary_event_source` — чей леджер
для этого счёта истина; события других источников становятся `shadow`. для этого счёта истина; события других источников становятся `shadow`.
- `account.disabled` — переключатель пользователя («Активен» на экране Счета); синки его не пишут.
Отключённый счёт выпадает из скоупов (`valuation.account_scopes`: all, account, portfolio), из
капитала ZM-счетов и из подсказок импорта; события и транзакции остаются в леджере.
- Ручные события: `POST /events` (`source = manual`, всегда `confirmed`, знаки выводятся из вида
события) и `DELETE /events/{id}` — только для `manual`; брокерские события синк вернёт. Виды:
buy, sell, transfer_in/out, dividend, coupon, interest, deposit, withdrawal, commission, tax,
tax_refund. Комиссия входит в `amount`. После записи нужен `POST /metrics/refresh` (лоты).
- `portfolio``account` m:n через `portfolio_account`; составной портфель = все счета. - `portfolio``account` m:n через `portfolio_account`; составной портфель = все счета.
- `account_link` — явная карта «ZM-счёт → брокерский счёт» для матчинга переводов. - `account_link` — явная карта «ZM-счёт → брокерский счёт» для матчинга переводов.
### Инструменты (`models/instruments.py`) ### Инструменты (`models/instruments.py`)
- `instrument` с частичными UNIQUE по `isin`, `figi`, `tinvest_uid`, `(ticker, board)`. - `instrument` с частичными UNIQUE по `isin`, `figi`, `tinvest_uid`, `(ticker, board)`.
Резолв: ISIN → FIGI → tinvest_uid → (ticker, board) → `instrument_alias`. Резолв: ISIN → FIGI → tinvest_uid → (ticker, board) → `instrument_alias`.
- Нераспознанные из отчётов → `pending_instrument` *(план)*, подтверждает пользователь. - Нераспознанные из отчётов → `pending_instrument`, подтверждает пользователь
(`/instruments/pending`).
### Леджер *(план, фаза 2)* ### Леджер (`models/ledger.py`)
- `event` — единая таблица событий всех брокерских источников: buy/sell/dividend/coupon/ - `event` — единая таблица событий всех брокерских источников: buy/sell/dividend/coupon/
tax/commission/deposit/withdrawal/transfer_in/out/split/amortization/repayment/fx_exchange. tax/commission/deposit/withdrawal/transfer_in/out/split/amortization/repayment/fx_exchange.
`quantity` знаковый, `amount` — знаковый денежный эффект на счёт, `dedupe_key` UNIQUE, `quantity` знаковый, `amount` — знаковый денежный эффект на счёт, `dedupe_key` UNIQUE,
@@ -35,8 +43,12 @@
Реализованы: `zenmoney` (diff-курсор, два режима auth: статический токен или OAuth-ротация Реализованы: `zenmoney` (diff-курсор, два режима auth: статический токен или OAuth-ротация
через `source_credential`; маппер всегда пересобирает core из полных `raw_*`), `cbr` через `source_credential`; маппер всегда пересобирает core из полных `raw_*`), `cbr`
(валюты берутся из счетов и транзакций, `trust_env=False` — мимо прокси). Расписание — (валюты берутся из счетов и транзакций, `trust_env=False` — мимо прокси), `tinvest`
`worker/jobs.py`. (gRPC: счета, операции, инструменты, снапшоты для сверки) и `tinvest_events` (дивиденды и
купоны), `moex` (котировки по бумагам из леджера) и `moex_payouts` (выплаты ISS). Расписание —
`worker/jobs.py`. Отчёты брокеров (`sources/reports/`: Сбер HTML, ВТБ xlsx, универсальный
CSV) — не синк, а загрузка через `/imports`; `ledger/report_import.py` показывает превью и
пишет события только на commit.
Контракт `sources/base.py`: `Source.sync(SyncContext) -> SyncResult`. Источник пишет Контракт `sources/base.py`: `Source.sync(SyncContext) -> SyncResult`. Источник пишет
`raw_*` идемпотентно и возвращает новый курсор; всё остальное (lock, журнал, курсор, `raw_*` идемпотентно и возвращает новый курсор; всё остальное (lock, журнал, курсор,
@@ -45,15 +57,24 @@
## Worker (`worker/`) ## Worker (`worker/`)
Отдельный процесс: APScheduler по расписанию из `worker/jobs.default_schedule()` + Отдельный процесс: APScheduler по расписанию из `worker/jobs.default_schedule()` +
опрос `sync_job` каждые 5 с. На источник — advisory lock `sync:<name>`, поэтому ручной и опрос `sync_job` каждые 5 с. На источник — advisory lock `sync:<name>`, поэтому ручной и
плановый запуски не пересекаются. После синка, изменившего данные, — refresh метрик плановый запуски не пересекаются. После синка, изменившего данные, — `refresh_all`
*(план)*. (ошибка пересчёта помечает сам синк как error). Ручной пересчёт метрик тоже идёт через
`sync_job` (см. ниже).
## Аналитика (`analytics/`, `pricing/{fx,prices}.py`, `metrics/refresh.py`) ## Аналитика (`analytics/`, `pricing/{fx,prices}.py`, `metrics/refresh.py`)
Шаги регистрируются в `analytics/__init__.py` и выполняются `refresh_all` по порядку: Шаги регистрируются в `analytics/__init__.py` и выполняются `refresh_all` по порядку:
`fx → classify → lots → valuation → returns → allocation → networth → cashflow → spending `fx → classify → matching → corpactionslots → valuation → returns → benchmarks
runway → quality` (фаза 4 вставит income и rebalance после allocation). Запуск: allocation → rebalance → cashflow_broker → income → tax → networth → cashflow → spending →
runway → shadow_dedupe → report_reconcile → quality`. Порядок объяснён комментариями в
`register_steps` — например, `matching` обязан идти после `classify`, а `quality` — последним,
потому что забирает находки остальных шагов. Запуск:
worker после синка с `changed=True`, `fintracker metrics refresh`, `POST /metrics/refresh`, worker после синка с `changed=True`, `fintracker metrics refresh`, `POST /metrics/refresh`,
`POST /rules/apply`. Каждая `metric_*` таблица пересобирается целиком. Net worth считается `POST /rules/apply`. `POST /metrics/refresh` только ставит задачу в `sync_job` (`source =
METRICS_JOB`) и отвечает 202 — пересчёт делает worker; клиент опрашивает `GET /metrics/status`,
пока `refreshing` не станет false. Шаги коммитятся по одному: при падении `metric_refresh_log`
хранит `failed_step` и `step_timings`, а `/metrics/status` отдаёт `consistent: false`, пока
следующий полный пересчёт не пройдёт. (`/rules/apply` и коммит импорта по-прежнему считают
синхронно.) Каждая `metric_*` таблица пересобирается целиком. Net worth считается
от текущего `account.balance` назад по транзакциям; конвертация — `FxTable` по дате от текущего `account.balance` назад по транзакциям; конвертация — `FxTable` по дате
операции, цены — `PriceTable` (протяжка вперёд, `STALE_AFTER_DAYS = 10`, назад не тянем). операции, цены — `PriceTable` (протяжка вперёд, `STALE_AFTER_DAYS = 10`, назад не тянем).
+1 -1
View File
@@ -91,7 +91,7 @@ refresh. Смена адреса API (`switchApiBaseUrl`) идёт через `l
версии пакетов `drift`/`sqlite3` — recipe качает `sqlite3.wasm` с GitHub-релиза пакета версии пакетов `drift`/`sqlite3` — recipe качает `sqlite3.wasm` с GitHub-релиза пакета
`sqlite3.dart`, версия релиза берётся из `flutter pub deps --json`, и компилирует `sqlite3.dart`, версия релиза берётся из `flutter pub deps --json`, и компилирует
`drift_worker.js` из исходника `package:drift/web/drift_worker.dart` через `dart compile js`. `drift_worker.js` из исходника `package:drift/web/drift_worker.dart` через `dart compile js`.
`just build-web` вызывает это автоматически. `just build-web` этот рецепт не вызывает — запускать вручную.
## Что не проверено ## Что не проверено
+4 -1
View File
@@ -20,9 +20,12 @@ api:
web: web:
cd {{backend}} && DATABASE_URL={{db_url}} ALLOW_DEV_SECRET=1 WEB_DIR=../app/build/web uv run fintracker serve --port 8000 cd {{backend}} && DATABASE_URL={{db_url}} ALLOW_DEV_SECRET=1 WEB_DIR=../app/build/web uv run fintracker serve --port 8000
# db + migrations + Flutter web build + API and web on :8000 (needs `nix develop .#app`)
dev: db-start migrate build-web web
# --- flutter --- # --- flutter ---
build-web: app-web-assets build-web:
cd app && flutter pub get && flutter build web --release cd app && flutter pub get && flutter build web --release
# fetch sqlite3.wasm + compile drift_worker.js into app/web/ (offline cache, drift/wasm+OPFS — # fetch sqlite3.wasm + compile drift_worker.js into app/web/ (offline cache, drift/wasm+OPFS —