feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события

Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
This commit is contained in:
Dmitry
2026-09-19 22:14:21 +03:00
parent a559d6de3e
commit 62d36aa3e8
73 changed files with 6406 additions and 1192 deletions
+26 -10
View File
@@ -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,
+30 -18
View File
@@ -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?);
}
}
+167 -151
View File
@@ -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('Сохранить'),
),
],
);
}
}
+166 -50
View File
@@ -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,
),
),
),
],
),
],
+10 -4
View File
@@ -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,
],
),
),
);
}
}
+12 -41
View File
@@ -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;
},
),
);
},
);
}
}
+66 -31
View File
@@ -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;
},
),
);
},
);
}
}