Files
fin-tracker/app/lib/features/portfolio/instrument_page.dart
T
Dmitry 62d36aa3e8 feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события
Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
2026-09-19 22:14:21 +03:00

493 lines
16 KiB
Dart
Raw 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: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,
],
),
),
);
}
}