feat(app): график цены — периоды, режимы, бенчмарки и мои сделки

Переключатель периода (7д…все и свой диапазон), режимы «Цена ₽ / ₽ + НКД / %», наложение бенчмарков в процентах (у ценового индекса пометка «без дивидендов»), изменение за период, заливка, подписи min/max, курсор с подсказкой и точки покупок и продаж. Ось X по датам, подписи осей без наложений.
This commit is contained in:
Dmitry
2026-09-20 10:46:33 +03:00
parent 6a15371960
commit 617682351b
3 changed files with 988 additions and 66 deletions
+20 -66
View File
@@ -1,13 +1,11 @@
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';
@@ -17,6 +15,7 @@ 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();
@@ -87,15 +86,25 @@ class InstrumentPage extends ConsumerWidget {
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),
),
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: 'Лоты',
@@ -287,61 +296,6 @@ class _Fact extends StatelessWidget {
}
}
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});