feat(app): экраны портфеля — позиции, аллокация и карточка инструмента

Позиции и аллокация сделаны вкладками одного экрана, а не двумя пунктами навигации:
они отвечают на две половины одного вопроса, и десятый пункт в bottom bar оставил бы
по сорок пикселей на подпись. Scope переключается один раз и сразу для всех трёх
экранов — три экрана с разными scope были бы ловушкой.

Позиция без цены показывается прочерком, никогда нулём: её стоимость не входит в
итоги выше, и 0 ₽ читался бы как «ничего не стоит» вместо «неизвестно». То же для
общей прибыли, когда в портфеле есть хоть одна неоценённая бумага.

Подписи ключей берутся по .value, а не по .name: генератор придумывает Dart-имя
(assetClass для asset_class), и словарь, ключёванный по .name, молча не совпадает
никогда.

Тесты пампят экран на высокой поверхности: вкладки это длинные списки, а ListView
строит только видимое, и на дефолтных 800x600 таблица позиций просто не существует.
This commit is contained in:
Dmitry
2026-09-18 14:21:06 +03:00
parent 28ff63bdfa
commit 1d7769ffbf
11 changed files with 1530 additions and 2 deletions
@@ -0,0 +1,371 @@
import 'package:decimal/decimal.dart';
import 'package:fintracker_api/fintracker_api.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../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 'labels.dart';
import 'providers.dart';
double _d(String s) => Decimal.parse(s).toDouble();
/// The instrument card: the position, the lots behind its cost, every event that touched it
/// and the price history behind its value — the screen that answers "where does this number
/// come from" when a reconciliation finding points here.
class InstrumentPage extends ConsumerWidget {
const InstrumentPage({required this.instrumentId, super.key});
final int instrumentId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final detail = ref.watch(instrumentProvider(instrumentId));
return Scaffold(
appBar: AppBar(
title: Text(detail.valueOrNull?.instrument.ticker ??
detail.valueOrNull?.instrument.name ??
'Инструмент'),
),
body: AsyncValueView(
value: detail,
onRetry: () => ref.invalidate(instrumentProvider(instrumentId)),
data: (d) => ListView(
padding: const EdgeInsets.all(16),
children: [
_Header(instrument: d.instrument, holding: d.holding),
const SizedBox(height: 16),
_Section(
title: 'Цена',
child: d.prices.isEmpty
? const EmptyState(
icon: Icons.show_chart,
message: 'Цен нет — стоимость позиции неизвестна.',
)
: _PriceChart(prices: d.prices),
),
const SizedBox(height: 16),
_Section(
title: 'Лоты',
child: d.lots.isEmpty
? 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: 'Событий нет.')
: _EventsTable(events: d.events),
),
],
),
),
);
}
}
class _Header extends StatelessWidget {
const _Header({required this.instrument, required this.holding});
final InstrumentOut instrument;
final HoldingOut? holding;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final facts = <String>[
assetClassLabel(instrument.assetClass),
if (instrument.board != null) instrument.board!,
if (instrument.isin != null) instrument.isin!,
if (instrument.country != null) instrument.country!,
instrument.currency,
];
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
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)
else
_HoldingFacts(holding: holding!),
],
),
),
);
}
}
class _HoldingFacts extends StatelessWidget {
const _HoldingFacts({required this.holding});
final HoldingOut holding;
@override
Widget build(BuildContext context) {
final h = holding;
return Wrap(
spacing: 24,
runSpacing: 12,
children: [
_Fact(label: 'Количество', value: Text(formatQty(h.qty))),
_Fact(
label: 'Средняя цена',
value: h.avgCost == null
? const Text('')
: MoneyText(h.avgCost!, currency: h.costCurrency ?? h.currency),
),
_Fact(
label: 'Текущая цена',
value: Row(
mainAxisSize: MainAxisSize.min,
children: [
h.marketPrice == null
? const Text('')
: MoneyText(h.marketPrice!, currency: h.priceCurrency ?? h.currency),
const SizedBox(width: 6),
PriceStatusChip(status: h.priceStatus, priceDate: h.priceDate),
],
),
),
_Fact(
label: 'Стоимость',
value: h.valueRub == null ? const Text('') : MoneyText(h.valueRub!, currency: 'RUB'),
),
_Fact(
label: 'Нереализованная',
value: h.unrealizedPnlRub == null
? const Text('')
: MoneyText(
h.unrealizedPnlRub!,
currency: 'RUB',
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: 'XIRR',
value: Text(
formatPercent(h.xirr),
style: TextStyle(color: signColor(context, h.xirr)),
),
),
if (h.firstBuyDate != null)
_Fact(
label: 'В портфеле',
value: Text('${h.daysHeld ?? 0} дн. с ${ruDate(h.firstBuyDate!)}'),
),
if (_d(h.ldvEligibleQty) > 0)
_Fact(label: 'Под ЛДВ', value: Text(formatQty(h.ldvEligibleQty))),
],
);
}
}
class _Fact extends StatelessWidget {
const _Fact({required this.label, required this.value});
final String label;
final Widget value;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor)),
const SizedBox(height: 2),
DefaultTextStyle(style: theme.textTheme.titleSmall!, child: value),
],
);
}
}
class _PriceChart extends StatelessWidget {
const _PriceChart({required this.prices});
final List<PricePoint> prices;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox(
height: 200,
child: LineChart(
LineChartData(
gridData: const FlGridData(show: true, drawVerticalLine: false),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
topTitles: const AxisTitles(),
rightTitles: const AxisTitles(),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: true, reservedSize: 52),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
interval: (prices.length / 4).clamp(1, double.infinity),
getTitlesWidget: (value, meta) {
final i = value.round();
if (i < 0 || i >= prices.length) return const SizedBox.shrink();
return Text(ruMonthYearShort(prices[i].d), style: theme.textTheme.bodySmall);
},
),
),
),
lineBarsData: [
LineChartBarData(
spots: [
for (var i = 0; i < prices.length; i++)
FlSpot(i.toDouble(), _d(prices[i].close)),
],
isCurved: false,
color: ChartColors.slot1Blue,
barWidth: 2,
dotData: const FlDotData(show: false),
),
],
),
),
);
}
}
class _LotsTable extends StatelessWidget {
const _LotsTable({required this.lots});
final List<LotOut> lots;
@override
Widget build(BuildContext context) {
final headerStyle = Theme.of(context).textTheme.labelMedium;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
columns: [
DataColumn(label: Text('Открыт', style: headerStyle)),
DataColumn(label: Text('Куплено', style: headerStyle), numeric: true),
DataColumn(label: Text('Осталось', style: headerStyle), numeric: true),
DataColumn(label: Text('Цена', style: headerStyle), numeric: true),
DataColumn(label: Text('Стоимость, ₽', style: headerStyle), numeric: true),
DataColumn(label: Text('Закрыт', style: headerStyle)),
],
rows: [
for (final lot in lots)
DataRow(
cells: [
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!))),
],
),
],
),
);
}
}
class _EventsTable extends StatelessWidget {
const _EventsTable({required this.events});
final List<EventOut> events;
@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)),
],
rows: [
for (final e in events)
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(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),
)),
],
),
],
),
);
}
}
class _Section extends StatelessWidget {
const _Section({required this.title, required this.child});
final String title;
final Widget child;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
child,
],
),
),
);
}
}