Compare commits
10
Commits
62d36aa3e8
...
0708e8e88b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0708e8e88b | ||
|
|
a41a9ca198 | ||
|
|
521924b3a4 | ||
|
|
2645e56197 | ||
|
|
6891028074 | ||
|
|
617682351b | ||
|
|
6a15371960 | ||
|
|
d2df86ce33 | ||
|
|
05affeea29 | ||
|
|
322c60a359 |
@@ -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` (общая сетка дат),
|
||||||
|
|||||||
@@ -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()}';
|
||||||
|
}
|
||||||
@@ -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')}';
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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)}%',
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,10 +22,16 @@ 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`.
|
||||||
|
|||||||
@@ -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 {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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('Сохранить'),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -4,16 +4,19 @@ 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,8 +24,8 @@ 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
)),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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}')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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': 'Уже получено.',
|
||||||
};
|
};
|
||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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,12 +16,15 @@ 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),
|
||||||
@@ -27,23 +32,56 @@ final incomeCalendarProvider = FutureProvider.autoDispose<Cached<IncomeCalendar>
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -52,6 +90,7 @@ final incomeForecastProvider = FutureProvider.autoDispose<Cached<IncomeForecast>
|
|||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +208,8 @@ 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,
|
||||||
|
|||||||
@@ -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) ...[
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -23,8 +25,8 @@ 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)
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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('7д'),
|
||||||
|
month('1м'),
|
||||||
|
quarter('3м'),
|
||||||
|
halfYear('6м'),
|
||||||
|
ytd('YTD'),
|
||||||
|
year('1г'),
|
||||||
|
fiveYears('5л'),
|
||||||
|
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 {}),
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,19 +44,24 @@ 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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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('Сохранить'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
@@ -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: [
|
||||||
|
|||||||
@@ -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,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,
|
||||||
@@ -44,7 +45,9 @@ Widget wrap(EventOut e) => MaterialApp(
|
|||||||
);
|
);
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -26,7 +27,9 @@ DataQualityRow _issue() => DataQualityRow(
|
|||||||
);
|
);
|
||||||
|
|
||||||
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)),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ TransactionOut _usdExpense() => TransactionOut(
|
|||||||
);
|
);
|
||||||
|
|
||||||
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(
|
||||||
|
|||||||
@@ -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')"))
|
||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 → corpactions → lots → 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`, назад не тянем).
|
||||||
|
|
||||||
|
|||||||
@@ -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` этот рецепт не вызывает — запускать вручную.
|
||||||
|
|
||||||
## Что не проверено
|
## Что не проверено
|
||||||
|
|
||||||
|
|||||||
@@ -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 —
|
||||||
|
|||||||
Reference in New Issue
Block a user