Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
493 lines
16 KiB
Dart
493 lines
16 KiB
Dart
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';
|
||
|
||
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;
|
||
|
||
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(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,
|
||
onRetry: () => ref.invalidate(instrumentProvider(instrumentId)),
|
||
data: (cached) {
|
||
final d = cached.data;
|
||
return ListView(
|
||
padding: const EdgeInsets.all(16),
|
||
children: [
|
||
if (cached.fetchedAt != null)
|
||
StaleBanner(fetchedAt: cached.fetchedAt!),
|
||
_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: [
|
||
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)
|
||
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: [
|
||
TermLabel(
|
||
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: FlDotData(show: prices.length < 2),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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,
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|