feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события
Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
This commit is contained in:
@@ -75,21 +75,24 @@ class _DimensionCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final onlyUnknown = buckets.every((b) => b.bucket == 'unknown' || b.bucket == 'cash');
|
||||
final onlyUnknown = buckets.every(
|
||||
(b) => b.bucket == 'unknown' || b.bucket == 'cash',
|
||||
);
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(dimensionLabel(dimension), style: Theme.of(context).textTheme.titleMedium),
|
||||
Text(
|
||||
dimensionLabel(dimension),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
if (onlyUnknown) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Атрибут не заполнен у инструментов — разрез пустой, а не нулевой.',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: Theme.of(context).hintColor),
|
||||
),
|
||||
],
|
||||
@@ -111,7 +114,9 @@ class _DimensionCard extends StatelessWidget {
|
||||
],
|
||||
);
|
||||
}
|
||||
return Column(children: [donut, const SizedBox(height: 12), legend]);
|
||||
return Column(
|
||||
children: [donut, const SizedBox(height: 12), legend],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -133,7 +138,10 @@ class _Donut extends StatelessWidget {
|
||||
// and the legend still lists it with its real value
|
||||
final positive = buckets.where((b) => _d(b.valueRub) > 0).toList();
|
||||
if (positive.isEmpty) {
|
||||
return const EmptyState(icon: Icons.donut_large_outlined, message: 'Нечего показать.');
|
||||
return const EmptyState(
|
||||
icon: Icons.donut_large_outlined,
|
||||
message: 'Нечего показать.',
|
||||
);
|
||||
}
|
||||
return PieChart(
|
||||
PieChartData(
|
||||
@@ -194,10 +202,18 @@ class _Legend extends StatelessWidget {
|
||||
if (buckets[i].holdingCount > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Text('${buckets[i].holdingCount}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor)),
|
||||
child: Text(
|
||||
'${buckets[i].holdingCount}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
MoneyText(buckets[i].valueRub, currency: 'RUB', style: theme.textTheme.bodyMedium),
|
||||
MoneyText(
|
||||
buckets[i].valueRub,
|
||||
currency: 'RUB',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 56,
|
||||
|
||||
@@ -10,16 +10,18 @@ import 'data/benchmarks_api.dart';
|
||||
import 'labels.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
final benchmarksApiProvider =
|
||||
Provider<BenchmarksApi>((ref) => BenchmarksApi(ref.watch(apiProvider).dio));
|
||||
final benchmarksApiProvider = Provider<BenchmarksApi>(
|
||||
(ref) => BenchmarksApi(ref.watch(apiProvider).dio),
|
||||
);
|
||||
|
||||
/// Benchmark comparison for the current scope. Part of Портфель, not a screen of its own:
|
||||
/// «на сколько я обогнал индекс» is a property of the portfolio, not a separate subject. See
|
||||
/// `docs/ai/offline-cache.md`.
|
||||
final benchmarkRowsProvider = FutureProvider.autoDispose<Cached<List<BenchmarkRow>>>((ref) async {
|
||||
final scope = ref.watch(scopeProvider);
|
||||
return ref.watch(benchmarksApiProvider).compare(scope: scope);
|
||||
});
|
||||
final benchmarkRowsProvider =
|
||||
FutureProvider.autoDispose<Cached<List<BenchmarkRow>>>((ref) async {
|
||||
final scope = ref.watch(scopeProvider);
|
||||
return ref.watch(benchmarksApiProvider).compare(scope: scope);
|
||||
});
|
||||
|
||||
/// The comparison block on Портфель.
|
||||
///
|
||||
@@ -73,13 +75,16 @@ class _PeriodBlock extends StatelessWidget {
|
||||
if (row.dateFrom != null && row.dateTo != null)
|
||||
Text(
|
||||
'${ruDate(row.dateFrom!)} – ${ruDate(row.dateTo!)}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'портфель ${formatPercent(row.portfolioTwr)}',
|
||||
style: theme.textTheme.titleSmall
|
||||
?.copyWith(color: signColor(context, row.portfolioTwr)),
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: signColor(context, row.portfolioTwr),
|
||||
),
|
||||
),
|
||||
if (row.portfolioDaysSkipped > 0) ...[
|
||||
const SizedBox(width: 6),
|
||||
@@ -89,13 +94,14 @@ class _PeriodBlock extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
for (final b in row.benchmarks) _BenchmarkRowView(result: b),
|
||||
if (row.hasSkippedDays)
|
||||
if (row.benchmarksSkipDays)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
'Сетка дат не полностью совпадает: часть дней пропущена, '
|
||||
'сравнение не строго like-for-like.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error),
|
||||
'У индекса нет котировок в часть дней окна — сравнение не строго день в день.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 20),
|
||||
@@ -131,8 +137,9 @@ class _BenchmarkRowView extends StatelessWidget {
|
||||
child: Text(
|
||||
formatPercent(result.excess),
|
||||
textAlign: TextAlign.right,
|
||||
style: theme.textTheme.bodyMedium
|
||||
?.copyWith(color: signColor(context, result.excess)),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: signColor(context, result.excess),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -148,7 +155,8 @@ class _PriceIndexChip extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Tooltip(
|
||||
message: 'Ценовой индекс: не учитывает дивиденды и систематически занижает '
|
||||
message:
|
||||
'Ценовой индекс: не учитывает дивиденды и систематически занижает '
|
||||
'результат держателя. Сравнение с ним — нижняя граница, а не эталон.',
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
@@ -156,7 +164,10 @@ class _PriceIndexChip extends StatelessWidget {
|
||||
color: scheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text('ценовой индекс', style: Theme.of(context).textTheme.labelSmall),
|
||||
child: Text(
|
||||
'ценовой индекс',
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -171,7 +182,8 @@ class _SkippedChip extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: 'В расчёте $side пропущено $days дн. — в эти дни не было цены',
|
||||
message:
|
||||
'В расчёте $side не учтено $days дн.: в эти дни у части позиций не было цены',
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
||||
@@ -42,14 +42,14 @@ class BenchmarkResult {
|
||||
bool get isPriceIndex => kind == 'price';
|
||||
|
||||
static BenchmarkResult fromJson(Map<String, dynamic> json) => BenchmarkResult(
|
||||
benchmarkId: asInt(json['benchmark_id']) ?? 0,
|
||||
code: asString(json['code']) ?? '—',
|
||||
kind: asString(json['kind']) ?? 'total_return',
|
||||
twr: asString(json['twr']),
|
||||
twrAnnualized: asString(json['twr_annualized']),
|
||||
daysSkipped: asInt(json['days_skipped']) ?? 0,
|
||||
excess: asString(json['excess']),
|
||||
);
|
||||
benchmarkId: asInt(json['benchmark_id']) ?? 0,
|
||||
code: asString(json['code']) ?? '—',
|
||||
kind: asString(json['kind']) ?? 'total_return',
|
||||
twr: asString(json['twr']),
|
||||
twrAnnualized: asString(json['twr_annualized']),
|
||||
daysSkipped: asInt(json['days_skipped']) ?? 0,
|
||||
excess: asString(json['excess']),
|
||||
);
|
||||
}
|
||||
|
||||
class BenchmarkRow {
|
||||
@@ -74,18 +74,22 @@ class BenchmarkRow {
|
||||
final int portfolioDaysSkipped;
|
||||
final List<BenchmarkResult> benchmarks;
|
||||
|
||||
bool get hasSkippedDays =>
|
||||
portfolioDaysSkipped > 0 || benchmarks.any((b) => b.daysSkipped > 0);
|
||||
bool get hasSkippedDays => portfolioDaysSkipped > 0 || benchmarksSkipDays;
|
||||
|
||||
/// Only the index side: the portfolio's own gaps are already marked next to its number.
|
||||
bool get benchmarksSkipDays => benchmarks.any((b) => b.daysSkipped > 0);
|
||||
|
||||
static BenchmarkRow fromJson(Map<String, dynamic> json) => BenchmarkRow(
|
||||
period: asString(json['period']) ?? 'all',
|
||||
dateFrom: asDate(json['date_from']),
|
||||
dateTo: asDate(json['date_to']),
|
||||
portfolioTwr: asString(json['portfolio_twr']),
|
||||
portfolioTwrAnnualized: asString(json['portfolio_twr_annualized']),
|
||||
portfolioDaysSkipped: asInt(json['portfolio_days_skipped']) ?? 0,
|
||||
benchmarks: asObjects(json['benchmarks']).map(BenchmarkResult.fromJson).toList(),
|
||||
);
|
||||
period: asString(json['period']) ?? 'all',
|
||||
dateFrom: asDate(json['date_from']),
|
||||
dateTo: asDate(json['date_to']),
|
||||
portfolioTwr: asString(json['portfolio_twr']),
|
||||
portfolioTwrAnnualized: asString(json['portfolio_twr_annualized']),
|
||||
portfolioDaysSkipped: asInt(json['portfolio_days_skipped']) ?? 0,
|
||||
benchmarks: asObjects(json['benchmarks'])
|
||||
.map(BenchmarkResult.fromJson)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
class BenchmarksApi {
|
||||
@@ -104,7 +108,9 @@ class BenchmarksApi {
|
||||
// `period` is repeatable; Dio serialises a list as repeated query parameters.
|
||||
queryParameters: {'scope': scope, 'period': periods},
|
||||
);
|
||||
final rows = asObjects((r.data ?? const {})['rows']).map(BenchmarkRow.fromJson).toList();
|
||||
final rows = asObjects((r.data ?? const {})['rows'])
|
||||
.map(BenchmarkRow.fromJson)
|
||||
.toList();
|
||||
return Cached(rows, fetchedAt: r.extra['fetchedAt'] as DateTime?);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,22 +3,45 @@ import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/theme/chart_colors.dart';
|
||||
import '../../core/utils/ru_date.dart';
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../core/widgets/money_text.dart';
|
||||
import '../../core/widgets/help_tip.dart';
|
||||
import 'benchmarks_card.dart';
|
||||
import 'holdings_table.dart';
|
||||
import 'labels.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
double _d(String s) => Decimal.parse(s).toDouble();
|
||||
|
||||
/// Which parts of the screen a [HoldingsTab] draws. The same providers feed all of them, so
|
||||
/// the numbers on Портфель and on Аналитика → Общее can never disagree.
|
||||
enum HoldingsSections {
|
||||
/// Everything, top to bottom.
|
||||
all,
|
||||
|
||||
/// Only the assets table — the Портфель screen.
|
||||
table,
|
||||
|
||||
/// The charts and returns, without the table — Аналитика → Общее.
|
||||
overview,
|
||||
}
|
||||
|
||||
/// Позиции: what the portfolio holds, what it is worth, and what it earned.
|
||||
class HoldingsTab extends ConsumerWidget {
|
||||
const HoldingsTab({super.key});
|
||||
const HoldingsTab({
|
||||
this.sections = HoldingsSections.all,
|
||||
this.header,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final HoldingsSections sections;
|
||||
|
||||
/// Drawn above everything else, inside the same scrolling list.
|
||||
final Widget? header;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -26,58 +49,73 @@ class HoldingsTab extends ConsumerWidget {
|
||||
final holdings = ref.watch(holdingsProvider);
|
||||
final series = ref.watch(valueSeriesProvider);
|
||||
final returns = ref.watch(portfolioReturnsProvider);
|
||||
final all = sections == HoldingsSections.all;
|
||||
final withTable = all || sections == HoldingsSections.table;
|
||||
final withCharts = all || sections == HoldingsSections.overview;
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => invalidatePortfolioProviders(ref),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
AsyncValueView(
|
||||
value: summary,
|
||||
onRetry: () => ref.invalidate(portfolioSummaryProvider),
|
||||
data: (cached) => _SummaryTiles(summary: cached.data),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_Card(
|
||||
title: 'Стоимость за 365 дней',
|
||||
child: AsyncValueView(
|
||||
value: series,
|
||||
onRetry: () => ref.invalidate(valueSeriesProvider),
|
||||
data: (cached) => cached.data.isEmpty
|
||||
? const EmptyState(icon: Icons.show_chart, message: 'Пока нет данных.')
|
||||
: _ValueChart(rows: cached.data),
|
||||
if (header != null) ...[header!, const SizedBox(height: 16)],
|
||||
if (all) ...[
|
||||
AsyncValueView(
|
||||
value: summary,
|
||||
onRetry: () => ref.invalidate(portfolioSummaryProvider),
|
||||
data: (cached) => _SummaryTiles(summary: cached.data),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_Card(
|
||||
title: 'Доходность',
|
||||
child: AsyncValueView(
|
||||
value: returns,
|
||||
onRetry: () => ref.invalidate(portfolioReturnsProvider),
|
||||
data: (cached) => cached.data.isEmpty
|
||||
? const EmptyState(icon: Icons.percent, message: 'Пока нечего считать.')
|
||||
: _ReturnsTable(rows: cached.data),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
if (withCharts) ...[
|
||||
_Card(
|
||||
title: 'Стоимость за 365 дней',
|
||||
child: AsyncValueView(
|
||||
value: series,
|
||||
onRetry: () => ref.invalidate(valueSeriesProvider),
|
||||
data: (cached) => cached.data.isEmpty
|
||||
? const EmptyState(
|
||||
icon: Icons.show_chart,
|
||||
message: 'Пока нет данных.',
|
||||
)
|
||||
: _ValueChart(rows: cached.data),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const _Card(
|
||||
title: 'Сравнение с бенчмарками',
|
||||
child: BenchmarksCard(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_Card(
|
||||
title: 'Позиции',
|
||||
child: AsyncValueView(
|
||||
value: holdings,
|
||||
onRetry: () => ref.invalidate(holdingsProvider),
|
||||
data: (cached) => cached.data.isEmpty
|
||||
? const EmptyState(
|
||||
icon: Icons.inventory_2_outlined,
|
||||
message: 'Открытых позиций нет — нужна синхронизация брокера.',
|
||||
)
|
||||
: _HoldingsTable(rows: cached.data),
|
||||
const SizedBox(height: 16),
|
||||
_Card(
|
||||
title: 'Доходность',
|
||||
child: AsyncValueView(
|
||||
value: returns,
|
||||
onRetry: () => ref.invalidate(portfolioReturnsProvider),
|
||||
data: (cached) => cached.data.isEmpty
|
||||
? const EmptyState(
|
||||
icon: Icons.percent,
|
||||
message: 'Пока нечего считать.',
|
||||
)
|
||||
: _ReturnsTable(rows: cached.data),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const _Card(
|
||||
title: 'Сравнение с бенчмарками',
|
||||
child: BenchmarksCard(),
|
||||
),
|
||||
],
|
||||
if (withTable && withCharts) const SizedBox(height: 16),
|
||||
if (withTable)
|
||||
_Card(
|
||||
title: all ? 'Позиции' : null,
|
||||
child: AsyncValueView(
|
||||
value: holdings,
|
||||
onRetry: () => ref.invalidate(holdingsProvider),
|
||||
data: (cached) => cached.data.isEmpty
|
||||
? const EmptyState(
|
||||
icon: Icons.inventory_2_outlined,
|
||||
message: 'Открытых позиций нет — нужна синхронизация брокера.',
|
||||
)
|
||||
: HoldingsTable(rows: cached.data),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -113,7 +151,9 @@ class _SummaryTiles extends StatelessWidget {
|
||||
: MoneyText(
|
||||
summary.pnlTotalRub!,
|
||||
currency: 'RUB',
|
||||
style: TextStyle(color: signColor(context, summary.pnlTotalRub)),
|
||||
style: TextStyle(
|
||||
color: signColor(context, summary.pnlTotalRub),
|
||||
),
|
||||
),
|
||||
note: summary.pnlTotalRub == null
|
||||
? 'часть позиций без цены'
|
||||
@@ -171,14 +211,19 @@ class _Tile extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: theme.textTheme.bodySmall),
|
||||
TermLabel(label, style: theme.textTheme.bodySmall),
|
||||
const SizedBox(height: 4),
|
||||
DefaultTextStyle(style: theme.textTheme.titleMedium!, child: value),
|
||||
DefaultTextStyle(
|
||||
style: theme.textTheme.titleMedium!,
|
||||
child: value,
|
||||
),
|
||||
if (note != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
note!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
maxLines: 2,
|
||||
),
|
||||
],
|
||||
@@ -224,7 +269,10 @@ class _ValueChart extends StatelessWidget {
|
||||
getTitlesWidget: (value, meta) {
|
||||
final i = value.round();
|
||||
if (i < 0 || i >= rows.length) return const SizedBox.shrink();
|
||||
return Text(ruMonthYearShort(rows[i].d), style: theme.textTheme.bodySmall);
|
||||
return Text(
|
||||
ruMonthYearShort(rows[i].d),
|
||||
style: theme.textTheme.bodySmall,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -277,112 +325,78 @@ class _ReturnsTable extends StatelessWidget {
|
||||
child: DataTable(
|
||||
columns: [
|
||||
DataColumn(label: Text('Период', style: headerStyle)),
|
||||
DataColumn(label: Text('Прибыль', style: headerStyle), numeric: true),
|
||||
DataColumn(label: Text('Потоки', style: headerStyle), numeric: true),
|
||||
DataColumn(label: Text('XIRR', style: headerStyle), numeric: true),
|
||||
DataColumn(label: Text('TWR', 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(
|
||||
'XIRR',
|
||||
style: headerStyle,
|
||||
alignment: MainAxisAlignment.end,
|
||||
),
|
||||
numeric: true,
|
||||
),
|
||||
DataColumn(
|
||||
label: TermLabel(
|
||||
'TWR',
|
||||
style: headerStyle,
|
||||
alignment: MainAxisAlignment.end,
|
||||
),
|
||||
numeric: true,
|
||||
),
|
||||
],
|
||||
rows: [
|
||||
for (final r in rows)
|
||||
DataRow(
|
||||
cells: [
|
||||
DataCell(Text(periodLabel(r.period))),
|
||||
DataCell(MoneyText(
|
||||
r.absPnlRub,
|
||||
currency: 'RUB',
|
||||
style: TextStyle(color: signColor(context, r.absPnlRub)),
|
||||
)),
|
||||
DataCell(
|
||||
MoneyText(
|
||||
r.absPnlRub,
|
||||
currency: 'RUB',
|
||||
style: TextStyle(color: signColor(context, r.absPnlRub)),
|
||||
),
|
||||
),
|
||||
DataCell(MoneyText(r.externalFlowRub, currency: 'RUB')),
|
||||
DataCell(Text(
|
||||
formatPercent(r.xirr),
|
||||
style: TextStyle(color: signColor(context, r.xirr)),
|
||||
)),
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
formatPercent(r.twr),
|
||||
style: TextStyle(color: signColor(context, r.twr)),
|
||||
),
|
||||
if (r.twrDaysSkipped > 0) ...[
|
||||
const SizedBox(width: 4),
|
||||
Tooltip(
|
||||
message: 'Пропущено ${r.twrDaysSkipped} дн.: '
|
||||
'в эти дни часть позиции была без цены',
|
||||
child: const Icon(Icons.info_outline, size: 14),
|
||||
DataCell(
|
||||
Text(
|
||||
formatPercent(r.xirr),
|
||||
style: TextStyle(color: signColor(context, r.xirr)),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
formatPercent(r.twr),
|
||||
style: TextStyle(color: signColor(context, r.twr)),
|
||||
),
|
||||
if (r.twrDaysSkipped > 0) ...[
|
||||
const SizedBox(width: 4),
|
||||
Tooltip(
|
||||
message:
|
||||
'Пропущено ${r.twrDaysSkipped} дн.: '
|
||||
'в эти дни часть позиции была без цены',
|
||||
child: const Icon(Icons.info_outline, size: 14),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HoldingsTable extends StatelessWidget {
|
||||
const _HoldingsTable({required this.rows});
|
||||
|
||||
final List<HoldingOut> rows;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final headerStyle = Theme.of(context).textTheme.labelMedium;
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: DataTable(
|
||||
columns: [
|
||||
DataColumn(label: Text('Бумага', style: headerStyle)),
|
||||
DataColumn(label: Text('Класс', style: headerStyle)),
|
||||
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('XIRR', style: headerStyle), numeric: true),
|
||||
],
|
||||
rows: [
|
||||
for (final r in rows)
|
||||
DataRow(
|
||||
onSelectChanged: (_) => context.push('/portfolio/instrument/${r.instrumentId}'),
|
||||
cells: [
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
r.ticker ?? r.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
PriceStatusChip(status: r.priceStatus, priceDate: r.priceDate),
|
||||
],
|
||||
)),
|
||||
DataCell(Text(assetClassLabel(r.assetClass))),
|
||||
DataCell(Text(formatQty(r.qty))),
|
||||
DataCell(r.marketPrice == null
|
||||
? const Text('—')
|
||||
: MoneyText(r.marketPrice!, currency: r.priceCurrency ?? r.currency)),
|
||||
// a position with no price shows nothing, never 0 ₽: it is absent from the
|
||||
// totals above, and a zero would read as "worthless" instead of "unknown"
|
||||
DataCell(r.valueRub == null
|
||||
? const Text('—')
|
||||
: MoneyText(r.valueRub!, currency: 'RUB')),
|
||||
DataCell(Text(formatPercent(r.weight, signed: false))),
|
||||
DataCell(r.unrealizedPnlRub == null
|
||||
? const Text('—')
|
||||
: MoneyText(
|
||||
r.unrealizedPnlRub!,
|
||||
currency: 'RUB',
|
||||
style: TextStyle(color: signColor(context, r.unrealizedPnlRub)),
|
||||
)),
|
||||
DataCell(Text(
|
||||
formatPercent(r.xirr),
|
||||
style: TextStyle(color: signColor(context, r.xirr)),
|
||||
)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -392,9 +406,9 @@ class _HoldingsTable extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _Card extends StatelessWidget {
|
||||
const _Card({required this.title, required this.child});
|
||||
const _Card({required this.child, this.title});
|
||||
|
||||
final String title;
|
||||
final String? title;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
@@ -405,8 +419,10 @@ class _Card extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
if (title != null) ...[
|
||||
TermLabel(title!, style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
child,
|
||||
],
|
||||
),
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
import 'package:decimal/decimal.dart';
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/widgets/asset_icon.dart';
|
||||
import '../../core/widgets/money_text.dart';
|
||||
import '../../core/widgets/help_tip.dart';
|
||||
import 'labels.dart';
|
||||
|
||||
/// Which columns the table shows. Each preset answers one question about the same rows, the
|
||||
/// way Snowball's «Мои активы | Общее | Дивиденды | Прибыль | Облигации» tabs do.
|
||||
enum HoldingsPreset {
|
||||
mine('Мои активы'),
|
||||
overall('Общее'),
|
||||
dividends('Дивиденды'),
|
||||
profit('Прибыль'),
|
||||
bonds('Облигации');
|
||||
|
||||
const HoldingsPreset(this.label);
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// A column: a header, a width, and how a position renders in it. A cell is one or two lines —
|
||||
/// the figure and, muted below it, the same figure per unit or as a share.
|
||||
class _Col {
|
||||
const _Col(this.title, this.width, this.cell, {this.alignEnd = true});
|
||||
|
||||
final String title;
|
||||
final double width;
|
||||
final Widget Function(BuildContext context, HoldingOut h) cell;
|
||||
final bool alignEnd;
|
||||
}
|
||||
|
||||
const _dash = Text('—');
|
||||
|
||||
/// A cell whose figure is unknown is a dash, never a zero: the position is absent from the
|
||||
/// totals, and 0 ₽ would read as «worthless» instead of «unknown».
|
||||
Widget _money(
|
||||
String? v, {
|
||||
String currency = 'RUB',
|
||||
TextStyle? style,
|
||||
bool signed = false,
|
||||
}) {
|
||||
if (v == null) return _dash;
|
||||
final d = Decimal.parse(v);
|
||||
final text = MoneyText.format(v, currency);
|
||||
return Text(signed && d > Decimal.zero ? '+$text' : text, style: style);
|
||||
}
|
||||
|
||||
Widget _stack(BuildContext context, Widget top, Widget? bottom) {
|
||||
final muted = Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DefaultTextStyle.merge(
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
child: top,
|
||||
),
|
||||
if (bottom != null) DefaultTextStyle.merge(style: muted, child: bottom),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// `share` of a decimal-string pair, or null when the base is missing or not positive.
|
||||
String? _ratio(String? part, String? base) {
|
||||
if (part == null || base == null) return null;
|
||||
final b = Decimal.parse(base);
|
||||
if (b <= Decimal.zero) return null;
|
||||
return (Decimal.parse(part) / b)
|
||||
.toDecimal(scaleOnInfinitePrecision: 10)
|
||||
.toString();
|
||||
}
|
||||
|
||||
Widget _signedPercent(BuildContext context, String? share) {
|
||||
if (share == null) return _dash;
|
||||
final color = signColor(context, share);
|
||||
final d = Decimal.parse(share);
|
||||
final arrow = d > Decimal.zero
|
||||
? Icons.arrow_drop_up
|
||||
: d < Decimal.zero
|
||||
? Icons.arrow_drop_down
|
||||
: null;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (arrow != null) Icon(arrow, size: 18, color: color),
|
||||
Text(
|
||||
formatPercent(share, signed: false).replaceAll('-', ''),
|
||||
style: TextStyle(color: color),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _pnl(BuildContext context, String? amount, String? share) {
|
||||
if (amount == null) return _dash;
|
||||
return _stack(
|
||||
context,
|
||||
_money(
|
||||
amount,
|
||||
signed: true,
|
||||
style: TextStyle(color: signColor(context, amount)),
|
||||
),
|
||||
_signedPercent(context, share),
|
||||
);
|
||||
}
|
||||
|
||||
final _asset = _Col('Актив', 260, (context, h) {
|
||||
final theme = Theme.of(context);
|
||||
final title = h.name;
|
||||
final ticker = h.ticker;
|
||||
return Row(
|
||||
children: [
|
||||
AssetIcon(
|
||||
assetClass: h.assetClass,
|
||||
logoUrl: h.logoUrl,
|
||||
logoColor: h.logoColor,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
if (ticker != null && ticker != title)
|
||||
Flexible(
|
||||
child: Text(
|
||||
ticker,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
PriceStatusChip(status: h.priceStatus, priceDate: h.priceDate),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}, alignEnd: false);
|
||||
|
||||
final _qty = _Col('Кол-во', 90, (c, h) => Text(formatQty(h.qty)));
|
||||
|
||||
final _invested = _Col(
|
||||
'Вложено',
|
||||
140,
|
||||
(c, h) => _stack(
|
||||
c,
|
||||
_money(h.costTotalRub),
|
||||
h.avgCost == null
|
||||
? null
|
||||
: _money(h.avgCost, currency: h.costCurrency ?? h.currency),
|
||||
),
|
||||
);
|
||||
|
||||
final _value = _Col(
|
||||
'Текущая стоимость',
|
||||
170,
|
||||
(c, h) => h.valueRub == null
|
||||
? _dash
|
||||
: _stack(
|
||||
c,
|
||||
_money(h.valueRub),
|
||||
h.marketPrice == null
|
||||
? null
|
||||
: _money(h.marketPrice, currency: h.priceCurrency ?? h.currency),
|
||||
),
|
||||
);
|
||||
|
||||
final _income = _Col('Дивиденды', 120, (c, h) => _money(_nonZero(h.incomeRub)));
|
||||
|
||||
final _incomeYield = _Col(
|
||||
'Див. доходность',
|
||||
130,
|
||||
(c, h) => Text(
|
||||
formatPercent(_nonZero(_ratio(h.incomeRub, h.costTotalRub)), signed: false),
|
||||
),
|
||||
);
|
||||
|
||||
final _profit = _Col(
|
||||
'Прибыль',
|
||||
130,
|
||||
(c, h) =>
|
||||
_pnl(c, h.unrealizedPnlRub, _ratio(h.unrealizedPnlRub, h.costTotalRub)),
|
||||
);
|
||||
|
||||
final _weight = _Col(
|
||||
'Доля в портфеле',
|
||||
130,
|
||||
(c, h) => Text(formatPercent(h.weight, signed: false)),
|
||||
);
|
||||
|
||||
final _xirr = _Col(
|
||||
'Доходность',
|
||||
120,
|
||||
(c, h) => Text(
|
||||
formatPercent(h.xirr),
|
||||
style: TextStyle(color: signColor(c, h.xirr)),
|
||||
),
|
||||
);
|
||||
|
||||
final _held = _Col(
|
||||
'В портфеле',
|
||||
120,
|
||||
(c, h) => Text(h.daysHeld == null ? '—' : '${h.daysHeld} дн.'),
|
||||
);
|
||||
|
||||
final _realized = _Col(
|
||||
'Реализовано',
|
||||
130,
|
||||
(c, h) => _money(
|
||||
h.realizedPnlRub,
|
||||
signed: true,
|
||||
style: TextStyle(color: signColor(c, h.realizedPnlRub)),
|
||||
),
|
||||
);
|
||||
|
||||
final _accrued = _Col(
|
||||
'НКД',
|
||||
110,
|
||||
(c, h) => _money(_nonZero(h.accruedInterestRub)),
|
||||
);
|
||||
|
||||
/// A payout or accrual of zero is «none», shown as a dash; only a real amount is a figure.
|
||||
String? _nonZero(String? v) =>
|
||||
v == null || Decimal.parse(v) == Decimal.zero ? null : v;
|
||||
|
||||
final _columns = {
|
||||
HoldingsPreset.mine: [
|
||||
_asset,
|
||||
_qty,
|
||||
_invested,
|
||||
_value,
|
||||
_income,
|
||||
_incomeYield,
|
||||
_profit,
|
||||
_weight,
|
||||
],
|
||||
HoldingsPreset.overall: [
|
||||
_asset,
|
||||
_qty,
|
||||
_invested,
|
||||
_value,
|
||||
_profit,
|
||||
_xirr,
|
||||
_weight,
|
||||
_held,
|
||||
],
|
||||
HoldingsPreset.dividends: [_asset, _qty, _income, _incomeYield],
|
||||
HoldingsPreset.profit: [_asset, _profit, _realized, _income, _xirr],
|
||||
HoldingsPreset.bonds: [
|
||||
_asset,
|
||||
_qty,
|
||||
_invested,
|
||||
_value,
|
||||
_accrued,
|
||||
_profit,
|
||||
_xirr,
|
||||
],
|
||||
};
|
||||
|
||||
/// Позиции as a table: preset tabs, a search box, and one row per holding. A tap opens the
|
||||
/// instrument card.
|
||||
class HoldingsTable extends StatefulWidget {
|
||||
const HoldingsTable({required this.rows, super.key});
|
||||
|
||||
final List<HoldingOut> rows;
|
||||
|
||||
@override
|
||||
State<HoldingsTable> createState() => _HoldingsTableState();
|
||||
}
|
||||
|
||||
class _HoldingsTableState extends State<HoldingsTable> {
|
||||
HoldingsPreset _preset = HoldingsPreset.mine;
|
||||
String _query = '';
|
||||
|
||||
List<HoldingOut> get _visible {
|
||||
final q = _query.trim().toLowerCase();
|
||||
return [
|
||||
for (final h in widget.rows)
|
||||
if ((_preset != HoldingsPreset.bonds || h.assetClass == 'bond') &&
|
||||
(q.isEmpty ||
|
||||
h.name.toLowerCase().contains(q) ||
|
||||
(h.ticker ?? '').toLowerCase().contains(q)))
|
||||
h,
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final columns = _columns[_preset]!;
|
||||
final rows = _visible;
|
||||
const pad = 32.0;
|
||||
final natural = columns.fold<double>(0, (sum, c) => sum + c.width);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// spread the columns over the card when there is room; scroll sideways when there is not
|
||||
final room = constraints.maxWidth.isFinite
|
||||
? constraints.maxWidth - pad
|
||||
: natural;
|
||||
final scale = room > natural ? room / natural : 1.0;
|
||||
final tableWidth = natural * scale + pad;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
alignment: WrapAlignment.spaceBetween,
|
||||
children: [
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
for (final p in HoldingsPreset.values)
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
onTap: () => setState(() => _preset = p),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 6,
|
||||
),
|
||||
child: Text(
|
||||
p.label,
|
||||
style: TextStyle(
|
||||
color: p == _preset
|
||||
? scheme.onSurface
|
||||
: scheme.onSurfaceVariant,
|
||||
fontWeight: p == _preset
|
||||
? FontWeight.w700
|
||||
: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(
|
||||
width: 260,
|
||||
child: TextField(
|
||||
onChanged: (v) => setState(() => _query = v),
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Найти…',
|
||||
isDense: true,
|
||||
prefixIcon: Icon(Icons.search, size: 20),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: SizedBox(
|
||||
width: tableWidth,
|
||||
child: Column(
|
||||
children: [
|
||||
_HeaderRow(columns: columns, scale: scale),
|
||||
Divider(color: scheme.outlineVariant),
|
||||
if (rows.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Text('Ничего не найдено'),
|
||||
),
|
||||
for (final h in rows)
|
||||
InkWell(
|
||||
onTap: () => context.push(
|
||||
'/portfolio/instrument/${h.instrumentId}',
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: scheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final c in columns)
|
||||
SizedBox(
|
||||
width: c.width * scale,
|
||||
child: Align(
|
||||
alignment: c.alignEnd
|
||||
? Alignment.centerRight
|
||||
: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
right: c.alignEnd ? 8 : 0,
|
||||
),
|
||||
child: c.cell(context, h),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeaderRow extends StatelessWidget {
|
||||
const _HeaderRow({required this.columns, required this.scale});
|
||||
|
||||
final List<_Col> columns;
|
||||
final double scale;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = Theme.of(context).textTheme.labelMedium
|
||||
?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final c in columns)
|
||||
SizedBox(
|
||||
width: c.width * scale,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(right: c.alignEnd ? 8 : 0),
|
||||
child: TermLabel(
|
||||
c.title,
|
||||
textAlign: c.alignEnd ? TextAlign.end : TextAlign.start,
|
||||
alignment: c.alignEnd
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.start,
|
||||
style: style,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'labels.dart';
|
||||
|
||||
/// Edit form for the fields of an instrument a person may correct. Returns a patch holding
|
||||
/// only what changed, or null when cancelled or nothing changed.
|
||||
class InstrumentEditDialog extends StatefulWidget {
|
||||
const InstrumentEditDialog({required this.instrument, super.key});
|
||||
|
||||
final InstrumentOut instrument;
|
||||
|
||||
@override
|
||||
State<InstrumentEditDialog> createState() => _InstrumentEditDialogState();
|
||||
}
|
||||
|
||||
class _InstrumentEditDialogState extends State<InstrumentEditDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final _name = TextEditingController(text: widget.instrument.name);
|
||||
late final _board = TextEditingController(
|
||||
text: widget.instrument.board ?? '',
|
||||
);
|
||||
late final _lot = TextEditingController(text: '${widget.instrument.lot}');
|
||||
late final _sector = TextEditingController(
|
||||
text: widget.instrument.sector ?? '',
|
||||
);
|
||||
late String _assetClass = widget.instrument.assetClass;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_name.dispose();
|
||||
_board.dispose();
|
||||
_lot.dispose();
|
||||
_sector.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
InstrumentPatch? _patch() {
|
||||
final i = widget.instrument;
|
||||
final name = _name.text.trim();
|
||||
final board = _board.text.trim();
|
||||
final sector = _sector.text.trim();
|
||||
final lot = int.parse(_lot.text.trim());
|
||||
final patch = InstrumentPatch(
|
||||
name: name != i.name ? name : null,
|
||||
assetClass: _assetClass != i.assetClass ? _assetClass : null,
|
||||
// an empty field means "leave as is": the wire format cannot express "clear"
|
||||
board: board.isNotEmpty && board != (i.board ?? '') ? board : null,
|
||||
lot: lot != i.lot ? lot : null,
|
||||
sector: sector.isNotEmpty && sector != (i.sector ?? '') ? sector : null,
|
||||
);
|
||||
final changed =
|
||||
patch.name != null ||
|
||||
patch.assetClass != null ||
|
||||
patch.board != null ||
|
||||
patch.lot != null ||
|
||||
patch.sector != null;
|
||||
return changed ? patch : null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('Инструмент ${widget.instrument.ticker ?? ''}'.trim()),
|
||||
content: SizedBox(
|
||||
width: 420,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _name,
|
||||
decoration: const InputDecoration(labelText: 'Название'),
|
||||
validator: (v) =>
|
||||
(v ?? '').trim().isEmpty ? 'Обязательное поле' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: assetClassLabels.containsKey(_assetClass)
|
||||
? _assetClass
|
||||
: null,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(labelText: 'Класс актива'),
|
||||
items: [
|
||||
for (final e in assetClassLabels.entries)
|
||||
DropdownMenuItem(
|
||||
value: e.key,
|
||||
child: Text('${e.value} (${e.key})'),
|
||||
),
|
||||
],
|
||||
onChanged: (v) =>
|
||||
setState(() => _assetClass = v ?? _assetClass),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _board,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Доска (TQBR, TQTF…)',
|
||||
helperText: 'По ней синк Мосбиржи находит котировки',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _lot,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Лот',
|
||||
helperText:
|
||||
'Ребалансировка округляет сделки до целого числа лотов',
|
||||
helperMaxLines: 2,
|
||||
),
|
||||
validator: (v) {
|
||||
final n = int.tryParse((v ?? '').trim());
|
||||
return n == null || n < 1
|
||||
? 'Целое число не меньше 1'
|
||||
: null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _sector,
|
||||
decoration: const InputDecoration(labelText: 'Сектор'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
Navigator.of(context).pop(_patch());
|
||||
},
|
||||
child: const Text('Сохранить'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,21 @@
|
||||
import 'package:decimal/decimal.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../../core/auth/auth_controller.dart';
|
||||
import '../../core/theme/chart_colors.dart';
|
||||
import '../../core/utils/ru_date.dart';
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
import '../../core/widgets/asset_icon.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../core/widgets/money_text.dart';
|
||||
import '../../core/widgets/stale_banner.dart';
|
||||
import '../../core/widgets/help_tip.dart';
|
||||
import 'instrument_edit_dialog.dart';
|
||||
import 'labels.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
@@ -23,15 +29,51 @@ class InstrumentPage extends ConsumerWidget {
|
||||
|
||||
final int instrumentId;
|
||||
|
||||
Future<void> _edit(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
InstrumentOut instrument,
|
||||
) async {
|
||||
final patch = await showDialog<InstrumentPatch>(
|
||||
context: context,
|
||||
builder: (_) => InstrumentEditDialog(instrument: instrument),
|
||||
);
|
||||
if (patch == null || !context.mounted) return;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final api = ref.read(apiProvider);
|
||||
await api.getInstrumentsApi().instrumentsPatch(
|
||||
instrumentId: instrumentId,
|
||||
instrumentPatch: patch,
|
||||
);
|
||||
invalidatePortfolioProviders(ref);
|
||||
try {
|
||||
// the asset class feeds the allocation metrics; the lot only the live rebalancing
|
||||
await api.getMetricsApi().metricsRefresh();
|
||||
} on DioException {
|
||||
// the next scheduled or manual refresh picks the change up
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(problemMessage(e))));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final detail = ref.watch(instrumentProvider(instrumentId));
|
||||
final instrument = detail.valueOrNull?.data.instrument;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(detail.valueOrNull?.data.instrument.ticker ??
|
||||
detail.valueOrNull?.data.instrument.name ??
|
||||
'Инструмент'),
|
||||
title: Text(instrument?.ticker ?? instrument?.name ?? 'Инструмент'),
|
||||
actions: [
|
||||
if (instrument != null)
|
||||
IconButton(
|
||||
tooltip: 'Редактировать',
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
onPressed: () => _edit(context, ref, instrument),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: AsyncValueView(
|
||||
value: detail,
|
||||
@@ -41,7 +83,8 @@ class InstrumentPage extends ConsumerWidget {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (cached.fetchedAt != null) StaleBanner(fetchedAt: cached.fetchedAt!),
|
||||
if (cached.fetchedAt != null)
|
||||
StaleBanner(fetchedAt: cached.fetchedAt!),
|
||||
_Header(instrument: d.instrument, holding: d.holding),
|
||||
const SizedBox(height: 16),
|
||||
_Section(
|
||||
@@ -57,14 +100,20 @@ class InstrumentPage extends ConsumerWidget {
|
||||
_Section(
|
||||
title: 'Лоты',
|
||||
child: d.lots.isEmpty
|
||||
? const EmptyState(icon: Icons.layers_outlined, message: 'Лотов нет.')
|
||||
? const EmptyState(
|
||||
icon: Icons.layers_outlined,
|
||||
message: 'Лотов нет.',
|
||||
)
|
||||
: _LotsTable(lots: d.lots),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_Section(
|
||||
title: 'События',
|
||||
child: d.events.isEmpty
|
||||
? const EmptyState(icon: Icons.receipt_long, message: 'Событий нет.')
|
||||
? const EmptyState(
|
||||
icon: Icons.receipt_long,
|
||||
message: 'Событий нет.',
|
||||
)
|
||||
: _EventsTable(events: d.events),
|
||||
),
|
||||
],
|
||||
@@ -97,10 +146,32 @@ class _Header extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(instrument.name, style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 4),
|
||||
Text(facts.join(' · '),
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor)),
|
||||
Row(
|
||||
children: [
|
||||
AssetIcon(
|
||||
assetClass: instrument.assetClass,
|
||||
logoUrl: instrument.logoUrl,
|
||||
logoColor: instrument.logoColor,
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(instrument.name, style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
facts.join(' · '),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (holding == null)
|
||||
Text('Позиция закрыта', style: theme.textTheme.bodyMedium)
|
||||
@@ -139,7 +210,10 @@ class _HoldingFacts extends StatelessWidget {
|
||||
children: [
|
||||
h.marketPrice == null
|
||||
? const Text('—')
|
||||
: MoneyText(h.marketPrice!, currency: h.priceCurrency ?? h.currency),
|
||||
: MoneyText(
|
||||
h.marketPrice!,
|
||||
currency: h.priceCurrency ?? h.currency,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
PriceStatusChip(status: h.priceStatus, priceDate: h.priceDate),
|
||||
],
|
||||
@@ -147,7 +221,9 @@ class _HoldingFacts extends StatelessWidget {
|
||||
),
|
||||
_Fact(
|
||||
label: 'Стоимость',
|
||||
value: h.valueRub == null ? const Text('—') : MoneyText(h.valueRub!, currency: 'RUB'),
|
||||
value: h.valueRub == null
|
||||
? const Text('—')
|
||||
: MoneyText(h.valueRub!, currency: 'RUB'),
|
||||
),
|
||||
_Fact(
|
||||
label: 'Нереализованная',
|
||||
@@ -156,14 +232,19 @@ class _HoldingFacts extends StatelessWidget {
|
||||
: MoneyText(
|
||||
h.unrealizedPnlRub!,
|
||||
currency: 'RUB',
|
||||
style: TextStyle(color: signColor(context, h.unrealizedPnlRub)),
|
||||
style: TextStyle(
|
||||
color: signColor(context, h.unrealizedPnlRub),
|
||||
),
|
||||
),
|
||||
),
|
||||
_Fact(
|
||||
label: 'Реализованная',
|
||||
value: MoneyText(h.realizedPnlRub ?? '0', currency: 'RUB'),
|
||||
),
|
||||
_Fact(label: 'Выплаты', value: MoneyText(h.incomeRub ?? '0', currency: 'RUB')),
|
||||
_Fact(
|
||||
label: 'Выплаты',
|
||||
value: MoneyText(h.incomeRub ?? '0', currency: 'RUB'),
|
||||
),
|
||||
_Fact(
|
||||
label: 'XIRR',
|
||||
value: Text(
|
||||
@@ -195,7 +276,10 @@ class _Fact extends StatelessWidget {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor)),
|
||||
TermLabel(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
DefaultTextStyle(style: theme.textTheme.titleSmall!, child: value),
|
||||
],
|
||||
@@ -230,8 +314,12 @@ class _PriceChart extends StatelessWidget {
|
||||
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);
|
||||
if (i < 0 || i >= prices.length)
|
||||
return const SizedBox.shrink();
|
||||
return Text(
|
||||
ruMonthYearShort(prices[i].d),
|
||||
style: theme.textTheme.bodySmall,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -245,7 +333,7 @@ class _PriceChart extends StatelessWidget {
|
||||
isCurved: false,
|
||||
color: ChartColors.slot1Blue,
|
||||
barWidth: 2,
|
||||
dotData: const FlDotData(show: false),
|
||||
dotData: FlDotData(show: prices.length < 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -268,9 +356,15 @@ class _LotsTable extends StatelessWidget {
|
||||
columns: [
|
||||
DataColumn(label: Text('Открыт', style: headerStyle)),
|
||||
DataColumn(label: Text('Куплено', style: headerStyle), numeric: true),
|
||||
DataColumn(label: Text('Осталось', style: headerStyle), numeric: true),
|
||||
DataColumn(
|
||||
label: Text('Осталось', style: headerStyle),
|
||||
numeric: true,
|
||||
),
|
||||
DataColumn(label: Text('Цена', style: headerStyle), numeric: true),
|
||||
DataColumn(label: Text('Стоимость, ₽', style: headerStyle), numeric: true),
|
||||
DataColumn(
|
||||
label: Text('Стоимость, ₽', style: headerStyle),
|
||||
numeric: true,
|
||||
),
|
||||
DataColumn(label: Text('Закрыт', style: headerStyle)),
|
||||
],
|
||||
rows: [
|
||||
@@ -280,11 +374,17 @@ class _LotsTable extends StatelessWidget {
|
||||
DataCell(Text(ruDate(lot.openDate))),
|
||||
DataCell(Text(formatQty(lot.qtyOpen))),
|
||||
DataCell(Text(formatQty(lot.qtyRemaining))),
|
||||
DataCell(MoneyText(lot.costPerUnit, currency: lot.costCurrency)),
|
||||
DataCell(lot.costTotalRub == null
|
||||
? const Text('—')
|
||||
: MoneyText(lot.costTotalRub!, currency: 'RUB')),
|
||||
DataCell(Text(lot.closedAt == null ? '—' : ruDate(lot.closedAt!))),
|
||||
DataCell(
|
||||
MoneyText(lot.costPerUnit, currency: lot.costCurrency),
|
||||
),
|
||||
DataCell(
|
||||
lot.costTotalRub == null
|
||||
? const Text('—')
|
||||
: MoneyText(lot.costTotalRub!, currency: 'RUB'),
|
||||
),
|
||||
DataCell(
|
||||
Text(lot.closedAt == null ? '—' : ruDate(lot.closedAt!)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -317,32 +417,48 @@ class _EventsTable extends StatelessWidget {
|
||||
DataRow(
|
||||
cells: [
|
||||
DataCell(Text(ruDate(e.tradeDate))),
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(eventKindLabel(e.kind)),
|
||||
if (e.externalFlow) ...[
|
||||
const SizedBox(width: 4),
|
||||
const Tooltip(
|
||||
message: 'Внешний поток — учитывается в XIRR',
|
||||
child: Icon(Icons.swap_vert, size: 14),
|
||||
),
|
||||
DataCell(
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(eventKindLabel(e.kind)),
|
||||
if (e.externalFlow) ...[
|
||||
const SizedBox(width: 4),
|
||||
const Tooltip(
|
||||
message: 'Внешний поток — учитывается в XIRR',
|
||||
child: Icon(Icons.swap_vert, size: 14),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
)),
|
||||
DataCell(Text(e.quantity == null ? '—' : formatQty(e.quantity!))),
|
||||
DataCell(e.price == null
|
||||
? const Text('—')
|
||||
: MoneyText(e.price!, currency: e.priceCurrency ?? e.currency)),
|
||||
DataCell(MoneyText(
|
||||
e.amount,
|
||||
currency: e.currency,
|
||||
style: TextStyle(color: signColor(context, e.amount)),
|
||||
)),
|
||||
DataCell(SizedBox(
|
||||
width: 260,
|
||||
child: Text(e.description ?? '', overflow: TextOverflow.ellipsis),
|
||||
)),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
Text(e.quantity == null ? '—' : formatQty(e.quantity!)),
|
||||
),
|
||||
DataCell(
|
||||
e.price == null
|
||||
? const Text('—')
|
||||
: MoneyText(
|
||||
e.price!,
|
||||
currency: e.priceCurrency ?? e.currency,
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
MoneyText(
|
||||
e.amount,
|
||||
currency: e.currency,
|
||||
style: TextStyle(color: signColor(context, e.amount)),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
SizedBox(
|
||||
width: 260,
|
||||
child: Text(
|
||||
e.description ?? '',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -38,7 +38,8 @@ const dimensionLabels = {
|
||||
'currency': 'Валюта',
|
||||
};
|
||||
|
||||
String assetClassLabel(String? value) => assetClassLabels[value] ?? value ?? '—';
|
||||
String assetClassLabel(String? value) =>
|
||||
assetClassLabels[value] ?? value ?? '—';
|
||||
|
||||
/// A bucket key as a person reads it. `cash` and `unknown` are the two literals the
|
||||
/// allocation step emits; everything else is the source's own value.
|
||||
@@ -89,7 +90,8 @@ const eventKindLabels = {
|
||||
'other': 'Прочее',
|
||||
};
|
||||
|
||||
String eventKindLabel(EventKind kind) => eventKindLabels[kind.value] ?? kind.value;
|
||||
String eventKindLabel(EventKind kind) =>
|
||||
eventKindLabels[kind.value] ?? kind.value;
|
||||
|
||||
/// `'0.1234'` as `'+12,34 %'`. Null becomes an em dash: a return nobody could compute is
|
||||
/// not zero percent.
|
||||
@@ -111,13 +113,17 @@ Color? signColor(BuildContext context, String? value) {
|
||||
if (value == null) return null;
|
||||
final d = Decimal.parse(value);
|
||||
if (d == Decimal.zero) return null;
|
||||
return d > Decimal.zero ? ChartColors.slot3Aqua : ChartColors.slot2Orange;
|
||||
return d > Decimal.zero ? ChartColors.gain : ChartColors.loss;
|
||||
}
|
||||
|
||||
/// The price-status chip every value on screen depends on: a stale price still produces a
|
||||
/// number, a missing one produces nothing at all, and both must be visible.
|
||||
class PriceStatusChip extends StatelessWidget {
|
||||
const PriceStatusChip({required this.status, required this.priceDate, super.key});
|
||||
const PriceStatusChip({
|
||||
required this.status,
|
||||
required this.priceDate,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String status;
|
||||
final DateTime? priceDate;
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import 'package:decimal/decimal.dart';
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/cache/cached.dart';
|
||||
import '../../core/theme/chart_colors.dart';
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
import '../../core/widgets/money_text.dart';
|
||||
import '../home/providers.dart' show scopeCardsProvider;
|
||||
import '../../core/widgets/help_tip.dart';
|
||||
import 'labels.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
/// The four figures on top of Аналитика → Общее: what the scope is worth, what it earned, its
|
||||
/// return and the passive income it should bring in a year. Everything comes from the same
|
||||
/// providers as Портфель and the home cards, so the numbers cannot disagree between screens.
|
||||
class OverviewTiles extends ConsumerWidget {
|
||||
const OverviewTiles({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final summary = ref.watch(portfolioSummaryProvider);
|
||||
final scope = ref.watch(scopeProvider);
|
||||
final card = ref
|
||||
.watch(scopeCardsProvider)
|
||||
.valueOrNull
|
||||
?.data
|
||||
.where((c) => c.scope == scope)
|
||||
.firstOrNull;
|
||||
|
||||
return AsyncValueView<Cached<SummaryOut>>(
|
||||
value: summary,
|
||||
onRetry: () => ref.invalidate(portfolioSummaryProvider),
|
||||
data: (cached) => _Grid(summary: cached.data, card: card),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Grid extends StatelessWidget {
|
||||
const _Grid({required this.summary, required this.card});
|
||||
|
||||
final SummaryOut summary;
|
||||
final ScopeCardOut? card;
|
||||
|
||||
static const _gap = 16.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final yearly = summary.returns.where((r) => r.period == '1y').firstOrNull;
|
||||
final all = summary.returns.where((r) => r.period == 'all').firstOrNull;
|
||||
final xirr = card?.xirr ?? all?.xirr;
|
||||
final pnl = summary.pnlTotalRub;
|
||||
final pnlShare =
|
||||
pnl != null && Decimal.parse(summary.investedNetRub) > Decimal.zero
|
||||
? (Decimal.parse(pnl) / Decimal.parse(summary.investedNetRub))
|
||||
.toDecimal(scaleOnInfinitePrecision: 10)
|
||||
.toString()
|
||||
: null;
|
||||
final day = card?.dayChangeRub;
|
||||
final income = card?.incomeYearRub;
|
||||
|
||||
Widget muted(String text, {Color? color}) => Text(
|
||||
text,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: color ?? scheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
|
||||
final tiles = <Widget>[
|
||||
_Tile(
|
||||
icon: Icons.account_balance_wallet_outlined,
|
||||
label: 'Стоимость',
|
||||
value: Text(MoneyText.format(summary.totalRub, 'RUB')),
|
||||
note: muted(
|
||||
'${MoneyText.format(summary.investedNetRub, 'RUB')} вложено',
|
||||
),
|
||||
),
|
||||
_Tile(
|
||||
icon: Icons.show_chart,
|
||||
label: 'Прибыль',
|
||||
value: pnl == null
|
||||
? const Text('—')
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
(Decimal.parse(pnl) > Decimal.zero ? '+' : '') +
|
||||
MoneyText.format(pnl, 'RUB'),
|
||||
style: TextStyle(color: signColor(context, pnl)),
|
||||
),
|
||||
if (pnlShare != null)
|
||||
Text(
|
||||
' ${formatPercent(pnlShare)}',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: signColor(context, pnl),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
note: day == null
|
||||
? muted(pnl == null ? 'часть позиций без цены' : 'за день —')
|
||||
: muted(
|
||||
'${Decimal.parse(day) > Decimal.zero ? '+' : ''}${MoneyText.format(day, 'RUB')}'
|
||||
'${card?.dayChangePct == null ? '' : ' ${formatPercent(card!.dayChangePct)}'} за день',
|
||||
color: signColor(context, day),
|
||||
),
|
||||
),
|
||||
_Tile(
|
||||
icon: Icons.percent,
|
||||
label: 'Доходность',
|
||||
value: Text(
|
||||
formatPercent(xirr, signed: false),
|
||||
style: TextStyle(color: signColor(context, xirr)),
|
||||
),
|
||||
note: muted(
|
||||
yearly?.twr == null
|
||||
? 'денежно-взвешенная, с начала'
|
||||
: 'рост активов ${formatPercent(yearly!.twr)} за год',
|
||||
),
|
||||
),
|
||||
_Tile(
|
||||
icon: Icons.savings_outlined,
|
||||
label: 'Пассивный доход',
|
||||
value: Text(
|
||||
card?.incomeYearPct == null
|
||||
? '—'
|
||||
: formatPercent(card!.incomeYearPct, signed: false),
|
||||
),
|
||||
note: muted(
|
||||
income == null || Decimal.parse(income) == Decimal.zero
|
||||
? 'прогноза выплат пока нет'
|
||||
: '${MoneyText.format(income, 'RUB')} в год',
|
||||
color: income == null || Decimal.parse(income) == Decimal.zero
|
||||
? null
|
||||
: ChartColors.gain,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final columns = width >= 1000 ? 4 : (width >= 560 ? 2 : 1);
|
||||
final itemWidth = (width - _gap * (columns - 1)) / columns;
|
||||
return Wrap(
|
||||
spacing: _gap,
|
||||
runSpacing: _gap,
|
||||
children: [
|
||||
for (final t in tiles) SizedBox(width: itemWidth, child: t),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Tile extends StatelessWidget {
|
||||
const _Tile({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.note,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Widget value;
|
||||
final Widget note;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primary.withValues(alpha: 0.16),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(icon, size: 18, color: scheme.primary),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
TermLabel(
|
||||
label,
|
||||
hint: label == 'Стоимость'
|
||||
? 'Стоимость — всё сразу: бумаги по последним ценам плюс деньги на счёте. '
|
||||
'Ниже — сколько вы вложили (пополнения минус выводы).'
|
||||
: null,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: scheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DefaultTextStyle.merge(
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
child: value,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
note,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/cache/cached.dart';
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
import '../../core/widgets/stale_banner.dart';
|
||||
import 'allocation_tab.dart';
|
||||
import 'benchmarks_card.dart';
|
||||
import 'holdings_tab.dart';
|
||||
import 'providers.dart';
|
||||
import 'scope_selector.dart';
|
||||
|
||||
/// Портфель: позиции и аллокация as two tabs of one screen, sharing one scope.
|
||||
///
|
||||
@@ -37,9 +36,12 @@ class PortfolioPage extends ConsumerWidget {
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Портфель'),
|
||||
actions: const [_ScopeSelector(), SizedBox(width: 8)],
|
||||
actions: const [ScopeSelector(), SizedBox(width: 8)],
|
||||
bottom: const TabBar(
|
||||
tabs: [Tab(text: 'Позиции'), Tab(text: 'Аллокация')],
|
||||
tabs: [
|
||||
Tab(text: 'Позиции'),
|
||||
Tab(text: 'Аллокация'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
@@ -50,7 +52,12 @@ class PortfolioPage extends ConsumerWidget {
|
||||
child: StaleBanner(fetchedAt: stale),
|
||||
),
|
||||
const Expanded(
|
||||
child: TabBarView(children: [HoldingsTab(), AllocationTab()]),
|
||||
child: TabBarView(
|
||||
children: [
|
||||
HoldingsTab(sections: HoldingsSections.table),
|
||||
AllocationTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -58,39 +65,3 @@ class PortfolioPage extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Switches every portfolio screen at once. Hidden while there is nothing to choose
|
||||
/// between — a dropdown with one option is furniture, not a control.
|
||||
class _ScopeSelector extends ConsumerWidget {
|
||||
const _ScopeSelector();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scopes = ref.watch(scopesProvider);
|
||||
final current = ref.watch(scopeProvider);
|
||||
|
||||
return AsyncValueView<List<ScopeOut>>(
|
||||
value: scopes,
|
||||
data: (rows) {
|
||||
if (rows.length < 2) return const SizedBox.shrink();
|
||||
final known = rows.any((s) => s.scope == current) ? current : rows.first.scope;
|
||||
return DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: known,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
items: [
|
||||
for (final s in rows)
|
||||
DropdownMenuItem(
|
||||
value: s.scope,
|
||||
child: Text(s.name, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) ref.read(scopeProvider.notifier).state = value;
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,51 +20,86 @@ final scopesProvider = FutureProvider.autoDispose<List<ScopeOut>>((ref) async {
|
||||
});
|
||||
|
||||
/// See `docs/ai/offline-cache.md`.
|
||||
final portfolioSummaryProvider = FutureProvider.autoDispose<Cached<SummaryOut>>((ref) async {
|
||||
final portfolioSummaryProvider = FutureProvider.autoDispose<Cached<SummaryOut>>(
|
||||
(ref) async {
|
||||
final scope = ref.watch(scopeProvider);
|
||||
final r = await ref
|
||||
.watch(apiProvider)
|
||||
.getAnalyticsApi()
|
||||
.analyticsSummary(scope: scope);
|
||||
return r.cached;
|
||||
},
|
||||
);
|
||||
|
||||
final holdingsProvider = FutureProvider.autoDispose<Cached<List<HoldingOut>>>((
|
||||
ref,
|
||||
) async {
|
||||
final scope = ref.watch(scopeProvider);
|
||||
final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsSummary(scope: scope);
|
||||
return r.cached;
|
||||
final r = await ref
|
||||
.watch(apiProvider)
|
||||
.getAnalyticsApi()
|
||||
.analyticsHoldings(scope: scope);
|
||||
return Cached(
|
||||
r.data ?? const [],
|
||||
fetchedAt: r.extra['fetchedAt'] as DateTime?,
|
||||
);
|
||||
});
|
||||
|
||||
final holdingsProvider = FutureProvider.autoDispose<Cached<List<HoldingOut>>>((ref) async {
|
||||
final scope = ref.watch(scopeProvider);
|
||||
final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsHoldings(scope: scope);
|
||||
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?);
|
||||
});
|
||||
final portfolioReturnsProvider =
|
||||
FutureProvider.autoDispose<Cached<List<ReturnsOut>>>((ref) async {
|
||||
final scope = ref.watch(scopeProvider);
|
||||
final r = await ref
|
||||
.watch(apiProvider)
|
||||
.getAnalyticsApi()
|
||||
.analyticsReturns(scope: scope);
|
||||
return Cached(
|
||||
r.data ?? const [],
|
||||
fetchedAt: r.extra['fetchedAt'] as DateTime?,
|
||||
);
|
||||
});
|
||||
|
||||
final portfolioReturnsProvider = FutureProvider.autoDispose<Cached<List<ReturnsOut>>>((ref) async {
|
||||
final scope = ref.watch(scopeProvider);
|
||||
final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsReturns(scope: scope);
|
||||
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?);
|
||||
});
|
||||
|
||||
final allocationProvider = FutureProvider.autoDispose<Cached<List<AllocationBucket>>>((ref) async {
|
||||
final scope = ref.watch(scopeProvider);
|
||||
final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsAllocation(scope: scope);
|
||||
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?);
|
||||
});
|
||||
final allocationProvider =
|
||||
FutureProvider.autoDispose<Cached<List<AllocationBucket>>>((ref) async {
|
||||
final scope = ref.watch(scopeProvider);
|
||||
final r = await ref
|
||||
.watch(apiProvider)
|
||||
.getAnalyticsApi()
|
||||
.analyticsAllocation(scope: scope);
|
||||
return Cached(
|
||||
r.data ?? const [],
|
||||
fetchedAt: r.extra['fetchedAt'] as DateTime?,
|
||||
);
|
||||
});
|
||||
|
||||
/// Daily portfolio value for the last year, for the chart on Позиции.
|
||||
final valueSeriesProvider = FutureProvider.autoDispose<Cached<List<ValueDay>>>((ref) async {
|
||||
final valueSeriesProvider = FutureProvider.autoDispose<Cached<List<ValueDay>>>((
|
||||
ref,
|
||||
) async {
|
||||
final scope = ref.watch(scopeProvider);
|
||||
final now = DateTime.now();
|
||||
final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsValueSeries(
|
||||
final r = await ref
|
||||
.watch(apiProvider)
|
||||
.getAnalyticsApi()
|
||||
.analyticsValueSeries(
|
||||
scope: scope,
|
||||
from: now.subtract(const Duration(days: 365)),
|
||||
to: now,
|
||||
);
|
||||
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?);
|
||||
return Cached(
|
||||
r.data ?? const [],
|
||||
fetchedAt: r.extra['fetchedAt'] as DateTime?,
|
||||
);
|
||||
});
|
||||
|
||||
final instrumentProvider =
|
||||
FutureProvider.autoDispose.family<Cached<InstrumentDetail>, int>((ref, instrumentId) async {
|
||||
final scope = ref.watch(scopeProvider);
|
||||
final r = await ref
|
||||
.watch(apiProvider)
|
||||
.getInstrumentsApi()
|
||||
.instrumentsGet(instrumentId: instrumentId, scope: scope);
|
||||
return r.cached;
|
||||
});
|
||||
final instrumentProvider = FutureProvider.autoDispose
|
||||
.family<Cached<InstrumentDetail>, int>((ref, instrumentId) async {
|
||||
final scope = ref.watch(scopeProvider);
|
||||
final r = await ref
|
||||
.watch(apiProvider)
|
||||
.getInstrumentsApi()
|
||||
.instrumentsGet(instrumentId: instrumentId, scope: scope);
|
||||
return r.cached;
|
||||
});
|
||||
|
||||
/// Every portfolio provider, refreshed together after a metrics rebuild or a pull-to-refresh.
|
||||
void invalidatePortfolioProviders(WidgetRef ref) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
/// Switches every portfolio screen at once. Hidden while there is nothing to choose
|
||||
/// between — a dropdown with one option is furniture, not a control.
|
||||
class ScopeSelector extends ConsumerWidget {
|
||||
const ScopeSelector({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scopes = ref.watch(scopesProvider);
|
||||
final current = ref.watch(scopeProvider);
|
||||
|
||||
return AsyncValueView<List<ScopeOut>>(
|
||||
value: scopes,
|
||||
data: (rows) {
|
||||
if (rows.length < 2) return const SizedBox.shrink();
|
||||
final known = rows.any((s) => s.scope == current)
|
||||
? current
|
||||
: rows.first.scope;
|
||||
return DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: known,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
items: [
|
||||
for (final s in rows)
|
||||
DropdownMenuItem(
|
||||
value: s.scope,
|
||||
child: Text(s.name, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) ref.read(scopeProvider.notifier).state = value;
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user