Files
Dmitry 617682351b feat(app): график цены — периоды, режимы, бенчмарки и мои сделки
Переключатель периода (7д…все и свой диапазон), режимы «Цена ₽ / ₽ + НКД / %», наложение бенчмарков в процентах (у ценового индекса пометка «без дивидендов»), изменение за период, заливка, подписи min/max, курсор с подсказкой и точки покупок и продаж. Ось X по датам, подписи осей без наложений.
2026-09-20 10:46:33 +03:00

447 lines
14 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:decimal/decimal.dart';
import 'package:dio/dio.dart';
import 'package:fintracker_api/fintracker_api.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/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 'price_history.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),
if (d.prices.isEmpty)
const _Section(
title: 'История цены',
child: EmptyState(
icon: Icons.show_chart,
message: 'Цен нет — стоимость позиции неизвестна.',
),
)
else
PriceHistory(
instrumentId: instrumentId,
label: d.instrument.ticker ?? d.instrument.name,
isBond: d.instrument.assetClass == 'bond',
trades: [
for (final e in d.events)
if (e.kind == EventKind.buy || e.kind == EventKind.sell)
e.tradeDate,
],
),
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 _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,
],
),
),
);
}
}