feat(app): экраны портфеля — позиции, аллокация и карточка инструмента
Позиции и аллокация сделаны вкладками одного экрана, а не двумя пунктами навигации: они отвечают на две половины одного вопроса, и десятый пункт в bottom bar оставил бы по сорок пикселей на подпись. Scope переключается один раз и сразу для всех трёх экранов — три экрана с разными scope были бы ловушкой. Позиция без цены показывается прочерком, никогда нулём: её стоимость не входит в итоги выше, и 0 ₽ читался бы как «ничего не стоит» вместо «неизвестно». То же для общей прибыли, когда в портфеле есть хоть одна неоценённая бумага. Подписи ключей берутся по .value, а не по .name: генератор придумывает Dart-имя (assetClass для asset_class), и словарь, ключёванный по .name, молча не совпадает никогда. Тесты пампят экран на высокой поверхности: вкладки это длинные списки, а ListView строит только видимое, и на дефолтных 800x600 таблица позиций просто не существует.
This commit is contained in:
@@ -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,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user