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
+22
View File
@@ -42,6 +42,28 @@ String ruMonthYear(DateTime d) {
/// `'сен 2026'`, for compact chart/table labels.
String ruMonthYearShort(DateTime d) => '${_monthsShort[d.month - 1]} ${d.year}';
const _monthsAbbr = [
'янв.',
'февр.',
'мар.',
'апр.',
'мая',
'июн.',
'июл.',
'авг.',
'сент.',
'окт.',
'нояб.',
'дек.',
];
/// `'20 сент. 25'`, the range line above a chart.
String ruDayMonthYearShort(DateTime d) =>
'${d.day} ${_monthsAbbr[d.month - 1]} ${(d.year % 100).toString().padLeft(2, '0')}';
/// `'16 сен'`, for chart labels over a span of weeks where the month alone repeats.
String ruDayMonthShort(DateTime d) => '${d.day} ${_monthsShort[d.month - 1]}';
/// `'2026-09'`, the `month` query parameter format the API expects.
String monthKey(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}';
+16 -62
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,14 +86,24 @@ 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(
if (d.prices.isEmpty)
const _Section(
title: 'История цены',
child: EmptyState(
icon: Icons.show_chart,
message: 'Цен нет — стоимость позиции неизвестна.',
),
)
: _PriceChart(prices: d.prices),
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(
@@ -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});
@@ -0,0 +1,946 @@
import 'dart:math' as math;
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 'package:intl/intl.dart';
import '../../core/api/api_client.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';
/// The periods offered above the chart. `all` has no start: the whole history there is.
enum PricePeriod {
week(''),
month(''),
quarter(''),
halfYear(''),
ytd('YTD'),
year('1г'),
fiveYears(''),
all('все');
const PricePeriod(this.label);
final String label;
DateTime? start(DateTime today) => switch (this) {
week => today.subtract(const Duration(days: 7)),
month => DateTime(today.year, today.month - 1, today.day),
quarter => DateTime(today.year, today.month - 3, today.day),
halfYear => DateTime(today.year, today.month - 6, today.day),
ytd => DateTime(today.year),
year => DateTime(today.year - 1, today.month, today.day),
fiveYears => DateTime(today.year - 5, today.month, today.day),
all => null,
};
}
/// What the chart plots.
enum PriceMode {
rub('Цена (₽)'),
rubAccrued('Цена (₽ + НКД)'),
percent('Цена (%)');
const PriceMode(this.label);
final String label;
}
/// «Мои сделки на графике»: kept for the whole app, not per instrument — it is a taste about
/// the chart, not a fact about a paper.
final showTradesProvider = StateProvider<bool>((ref) => true);
/// Which slice of an instrument's price history to load: from a date, or all of it.
typedef PriceQuery = ({int instrumentId, DateTime? from});
/// The price series of one instrument. There is no prices-only endpoint, so this reads the
/// instrument card with `prices_from` and keeps the prices — for an index the lots and events
/// in the same response are empty.
final priceSeriesProvider = FutureProvider.autoDispose
.family<List<PricePoint>, PriceQuery>((ref, q) async {
final r = await ref
.watch(apiProvider)
.getInstrumentsApi()
.instrumentsGet(
instrumentId: q.instrumentId,
pricesFrom: q.from ?? DateTime(1990),
);
return r.data?.prices ?? const [];
});
/// The benchmarks that can be drawn: active, and already synced into an instrument.
final chartBenchmarksProvider = FutureProvider.autoDispose<List<BenchmarkOut>>((
ref,
) async {
final r = await ref.watch(apiProvider).getBenchmarksApi().benchmarksList();
return [
for (final b in r.data ?? const <BenchmarkOut>[])
if (b.isActive && b.instrumentId != null) b,
];
});
// Chart x is whole days since the epoch: the instrument and its benchmarks trade on
// different days, so an index into one list would not line them up.
const _msPerDay = Duration.millisecondsPerDay;
int _day(DateTime d) =>
DateTime.utc(d.year, d.month, d.day).millisecondsSinceEpoch ~/ _msPerDay;
DateTime _fromDay(num day) =>
DateTime.fromMillisecondsSinceEpoch(day.round() * _msPerDay, isUtc: true);
class _Line {
const _Line({
required this.label,
required this.color,
required this.spots,
this.note,
});
final String label;
final Color color;
final List<FlSpot> spots;
/// Marks what the reader has to know about the series, e.g. a price index without dividends.
final String? note;
}
/// Everything the chart and the lines above it are drawn from.
class _Model {
const _Model({
required this.lines,
required this.percent,
required this.currency,
required this.first,
required this.last,
required this.tradeXs,
});
final List<_Line> lines;
final bool percent;
final String currency;
final DateTime first;
final DateTime last;
/// Days of the chart the user traded on, snapped onto a day the paper had a price.
final Set<double> tradeXs;
}
/// A price index throws its dividends away, so a holder's own return is flattered next to it;
/// the legend says so rather than leaving the comparison to look like-for-like.
String? _benchmarkNote(BenchmarkOut b) =>
b.kind == 'price' ? 'без дивидендов' : null;
String _money(double v, String currency) =>
MoneyText.format(v.toStringAsFixed(6), currency);
String _percent(double v, {int digits = 2}) {
final n = NumberFormat.decimalPatternDigits(
locale: 'ru_RU',
decimalDigits: digits,
).format(v);
return '${v > 0 ? '+' : ''}$n %';
}
Color _signColor(double v) => v < 0 ? ChartColors.loss : ChartColors.gain;
/// The chart block of the instrument card: benchmarks to lay over it, then the card with the
/// mode, the period switch, the change over the period and the chart itself.
///
/// With no benchmark the axis is the price; with one the axis turns into the change since the
/// start of the period, the only scale an instrument and an index share.
class PriceHistory extends ConsumerStatefulWidget {
const PriceHistory({
required this.instrumentId,
required this.label,
this.isBond = false,
this.trades = const [],
super.key,
});
final int instrumentId;
final String label;
/// Bonds are quoted without the accrued coupon; only they get «Цена (₽ + НКД)».
final bool isBond;
/// Days the user bought or sold the paper, for the dots on the line.
final List<DateTime> trades;
@override
ConsumerState<PriceHistory> createState() => _PriceHistoryState();
}
class _PriceHistoryState extends ConsumerState<PriceHistory> {
PricePeriod _period = PricePeriod.year;
DateTimeRange? _custom;
PriceMode _mode = PriceMode.rub;
final Set<int> _benchmarkIds = {};
Future<void> _pickRange() async {
final now = DateTime.now();
final picked = await showDateRangePicker(
context: context,
firstDate: DateTime(2000),
lastDate: now,
initialDateRange: _custom,
);
if (picked != null) setState(() => _custom = picked);
}
void _toggleBenchmark(int id, bool on) => setState(() {
if (on) {
_benchmarkIds.add(id);
// an index cannot be laid over a price in roubles: the comparison is in percent
_mode = PriceMode.percent;
} else {
_benchmarkIds.remove(id);
}
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final from = _custom?.start ?? _period.start(today);
final to = _custom?.end;
final benchmarks = ref.watch(chartBenchmarksProvider);
final available = benchmarks.valueOrNull ?? const <BenchmarkOut>[];
final selected = [
for (final b in available)
if (_benchmarkIds.contains(b.id)) b,
];
final showTrades = ref.watch(showTradesProvider);
final series = ref.watch(
priceSeriesProvider((instrumentId: widget.instrumentId, from: from)),
);
final model = series.valueOrNull == null
? null
: _buildModel(series.valueOrNull!, selected, from, to);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: available.isNotEmpty
? _BenchmarkBar(
available: available,
selected: _benchmarkIds,
onChanged: _toggleBenchmark,
)
: Text(
benchmarks.hasValue
// a benchmark is listed only once the MOEX sync has stored its history
? 'Бенчмарков пока нет: они появятся после синка MOEX.'
: 'Бенчмарки',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
),
),
),
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Header(
mode: _mode,
modes: [
PriceMode.rub,
if (widget.isBond) PriceMode.rubAccrued,
PriceMode.percent,
],
onMode: (m) => setState(() => _mode = m),
showTrades: showTrades,
onShowTrades: (v) =>
ref.read(showTradesProvider.notifier).state = v,
),
const SizedBox(height: 12),
Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
runSpacing: 4,
children: [
_PeriodBar(
period: _custom == null ? _period : null,
customActive: _custom != null,
onPeriod: (p) => setState(() {
_period = p;
_custom = null;
}),
onCustom: _pickRange,
),
if (model != null) _Summary(model: model),
],
),
const SizedBox(height: 12),
AsyncValueView(
value: series,
onRetry: () => ref.invalidate(priceSeriesProvider),
data: (_) {
final m = model;
if (m == null) {
return const EmptyState(
icon: Icons.show_chart,
message: 'За выбранный период цен нет.',
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 300,
child: _HistoryChart(
model: m,
showTrades: showTrades,
),
),
const SizedBox(height: 8),
_Legend(
lines: m.lines,
hint: selected.isNotEmpty && !m.percent
? 'Бенчмарки сравниваются в режиме «Цена (%)».'
: null,
),
],
);
},
),
],
),
),
),
],
);
}
_Model? _buildModel(
List<PricePoint> prices,
List<BenchmarkOut> selected,
DateTime? from,
DateTime? to,
) {
final points = _within(prices, to);
if (points.isEmpty) return null;
final percent = _mode == PriceMode.percent;
final startDay = _day(points.first.d);
final lines = <_Line>[];
if (percent) {
lines.add(
_Line(
label: widget.label,
color: ChartColors.slot1Blue,
spots: _percentSpots(points, startDay, null),
),
);
// one earlier quote than the window: the base an index starts from on a day it was shut
final benchFrom = from?.subtract(const Duration(days: 10));
const palette = [
ChartColors.slot2Orange,
ChartColors.slot3Aqua,
ChartColors.slot4Yellow,
ChartColors.slot5Magenta,
];
for (final (i, b) in selected.indexed) {
final async = ref.watch(
priceSeriesProvider((instrumentId: b.instrumentId!, from: benchFrom)),
);
final loaded = async.valueOrNull;
// still loading, or failed: no line rather than a wrong one
if (loaded == null) continue;
lines.add(
_Line(
label: b.code,
color: palette[i % palette.length],
spots: _percentSpots(_within(loaded, to), startDay, startDay),
note: _benchmarkNote(b),
),
);
}
} else {
final withAccrued = _mode == PriceMode.rubAccrued;
lines.add(
_Line(
label: widget.label,
color: ChartColors.slot1Blue,
spots: [
for (final p in points)
FlSpot(
_day(p.d).toDouble(),
double.parse(p.close) +
(withAccrued ? double.parse(p.accruedInterest ?? '0') : 0),
),
],
),
);
}
return _Model(
lines: lines,
percent: percent,
currency: points.first.currency,
first: points.first.d,
last: points.last.d,
tradeXs: _snapTrades(widget.trades, lines.first.spots),
);
}
}
List<PricePoint> _within(List<PricePoint> prices, DateTime? to) => to == null
? prices
: [
for (final p in prices)
if (_day(p.d) <= _day(to)) p,
];
/// A trade happens on a day, but the line only has a point on trading days that have a
/// price: the dot goes on the last point at or before the trade, and only inside the window.
Set<double> _snapTrades(List<DateTime> trades, List<FlSpot> spots) {
if (spots.isEmpty) return const {};
final xs = [for (final s in spots) s.x];
final out = <double>{};
for (final t in trades) {
final day = _day(t).toDouble();
if (day < xs.first || day > xs.last) continue;
out.add(xs.lastWhere((x) => x <= day));
}
return out;
}
/// Change since [startDay], in percent. The base is the last quote on or before the start
/// (the first one after it when there is none), so an index that was shut on the start day
/// still begins at zero on the same date as the instrument.
List<FlSpot> _percentSpots(
List<PricePoint> prices,
int startDay,
int? baseOnOrBefore,
) {
double? base;
if (baseOnOrBefore != null) {
for (final p in prices) {
if (_day(p.d) <= baseOnOrBefore) base = double.parse(p.close);
}
}
final inWindow = [
for (final p in prices)
if (_day(p.d) >= startDay) p,
];
base ??= inWindow.isEmpty ? null : double.parse(inWindow.first.close);
if (base == null || base == 0) return const [];
return [
for (final p in inWindow)
FlSpot(_day(p.d).toDouble(), (double.parse(p.close) / base - 1) * 100),
];
}
/// «История цены», the mode of the chart and the «⋯» menu.
class _Header extends StatelessWidget {
const _Header({
required this.mode,
required this.modes,
required this.onMode,
required this.showTrades,
required this.onShowTrades,
});
final PriceMode mode;
final List<PriceMode> modes;
final ValueChanged<PriceMode> onMode;
final bool showTrades;
final ValueChanged<bool> onShowTrades;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
Expanded(
child: Text('История цены', style: theme.textTheme.titleMedium),
),
DropdownButtonHideUnderline(
child: DropdownButton<PriceMode>(
value: modes.contains(mode) ? mode : modes.first,
borderRadius: BorderRadius.circular(8),
style: theme.textTheme.bodyMedium,
items: [
for (final m in modes)
DropdownMenuItem(value: m, child: Text(m.label)),
],
onChanged: (m) {
if (m != null) onMode(m);
},
),
),
PopupMenuButton<bool>(
tooltip: 'Настройки графика',
icon: const Icon(Icons.more_horiz),
onSelected: onShowTrades,
itemBuilder: (_) => [
CheckedPopupMenuItem<bool>(
value: !showTrades,
checked: showTrades,
child: const Text('Мои сделки на графике'),
),
],
),
],
);
}
}
class _PeriodBar extends StatelessWidget {
const _PeriodBar({
required this.period,
required this.customActive,
required this.onPeriod,
required this.onCustom,
});
final PricePeriod? period;
final bool customActive;
final ValueChanged<PricePeriod> onPeriod;
final VoidCallback onCustom;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
Color? colorFor(bool active) => active ? theme.colorScheme.primary : null;
return Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
for (final p in PricePeriod.values)
TextButton(
onPressed: () => onPeriod(p),
style: TextButton.styleFrom(
minimumSize: const Size(40, 36),
padding: const EdgeInsets.symmetric(horizontal: 10),
foregroundColor:
colorFor(p == period) ?? theme.textTheme.bodyMedium?.color,
),
child: Text(p.label),
),
IconButton(
tooltip: 'Свой период',
onPressed: onCustom,
color: colorFor(customActive),
icon: const Icon(Icons.calendar_month_outlined),
),
],
);
}
}
/// «20 сент. 25 - 20 сент. 26 ● +48,90 ₽ (▲ 5,23 %)»: the change over what the chart shows.
class _Summary extends StatelessWidget {
const _Summary({required this.model});
final _Model model;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hint = theme.textTheme.bodySmall?.copyWith(color: theme.hintColor);
Widget item(_Line l) {
final first = l.spots.first.y;
final last = l.spots.last.y;
final delta = last - first;
// in roubles the change is a sum and a share of the start; in percent it is the value
final text = model.percent
? _percent(last)
: '${delta > 0 ? '+' : ''}${_money(delta, model.currency)} '
'(${delta < 0 ? '' : ''} ${_percent(first == 0 ? 0 : delta / first * 100).replaceFirst('+', '')})';
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: l.color, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(
text,
style: theme.textTheme.bodySmall?.copyWith(
color: _signColor(model.percent ? last : delta),
),
),
],
);
}
return Wrap(
spacing: 12,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
'${ruDayMonthYearShort(model.first)} - ${ruDayMonthYearShort(model.last)}',
style: hint,
),
for (final l in model.lines)
if (l.spots.isNotEmpty) item(l),
],
);
}
}
/// «Бенчмарки [IMOEX ×] Выбрать» — the card above the chart.
class _BenchmarkBar extends StatelessWidget {
const _BenchmarkBar({
required this.available,
required this.selected,
required this.onChanged,
});
final List<BenchmarkOut> available;
final Set<int> selected;
final void Function(int id, bool on) onChanged;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Wrap(
spacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text('Бенчмарки', style: theme.textTheme.bodyLarge),
for (final b in available)
if (selected.contains(b.id))
InputChip(
label: Text(b.code),
onDeleted: () => onChanged(b.id, false),
),
PopupMenuButton<int>(
tooltip: 'Выбрать бенчмарки',
onSelected: (id) => onChanged(id, !selected.contains(id)),
itemBuilder: (_) => [
for (final b in available)
CheckedPopupMenuItem<int>(
value: b.id,
checked: selected.contains(b.id),
child: Text(
_benchmarkNote(b) == null
? '${b.code} · ${b.name}'
: '${b.code} · ${b.name} (${_benchmarkNote(b)})',
),
),
],
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(
'Выбрать',
style: TextStyle(color: theme.colorScheme.primary),
),
),
),
],
);
}
}
/// What the colours are, and what to know about a series (a price index has no dividends,
/// an index may have nothing for the period). Silent for a lone line: the header says it.
class _Legend extends StatelessWidget {
const _Legend({required this.lines, this.hint});
final List<_Line> lines;
final String? hint;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final noted = lines.length > 1 || lines.any((l) => l.spots.isEmpty);
return Wrap(
spacing: 16,
runSpacing: 4,
children: [
if (noted)
for (final l in lines)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: l.color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(
[
l.label,
if (l.note != null) '(${l.note})',
if (l.spots.isEmpty) '— нет данных за период',
].join(' '),
style: theme.textTheme.bodySmall,
),
],
),
if (hint != null)
Text(
hint!,
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
),
],
);
}
}
class _HistoryChart extends StatelessWidget {
const _HistoryChart({required this.model, required this.showTrades});
final _Model model;
final bool showTrades;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final percent = model.percent;
final currency = model.currency;
// fl_chart has no use for a series without points; the legend still names it
final drawn = [
for (final l in model.lines)
if (l.spots.isNotEmpty) l,
];
final main = drawn.first;
final xs = [
for (final l in drawn)
for (final s in l.spots) s.x,
];
final ys = [
for (final l in drawn)
for (final s in l.spots) s.y,
];
var minX = xs.reduce(math.min);
var maxX = xs.reduce(math.max);
// a single quote has no width to draw a line across
if (minX == maxX) {
minX -= 1;
maxX += 1;
}
final span = maxX - minX;
// a few weeks of dates repeat the same month on every label — show the day then
final shortSpan = span <= 180;
// room above and below the data for the min/max captions and the line's own stroke
final lo = ys.reduce(math.min);
final hi = ys.reduce(math.max);
final pad = (hi - lo) > 0 ? (hi - lo) * 0.12 : (hi.abs() * 0.01 + 1);
final minY = lo - pad;
final maxY = hi + pad;
final mainYs = [for (final s in main.spots) s.y];
final mainLo = mainYs.reduce(math.min);
final mainHi = mainYs.reduce(math.max);
String fmt(double v) => percent ? _percent(v) : _money(v, currency);
final captionStyle = theme.textTheme.labelSmall?.copyWith(
color: theme.hintColor,
);
final faint = theme.hintColor.withValues(alpha: 0.35);
HorizontalLine extreme(double y, String prefix, Alignment at) =>
HorizontalLine(
y: y,
color: faint,
strokeWidth: 1,
label: HorizontalLineLabel(
show: true,
alignment: at,
style: captionStyle,
labelResolver: (_) => '$prefix: ${fmt(y)}',
),
);
return LineChart(
LineChartData(
minX: minX,
maxX: maxX,
minY: minY,
maxY: maxY,
gridData: FlGridData(
drawVerticalLine: false,
getDrawingHorizontalLine: (_) => FlLine(
color: theme.dividerColor.withValues(alpha: 0.25),
strokeWidth: 1,
),
),
borderData: FlBorderData(show: false),
extraLinesData: ExtraLinesData(
horizontalLines: [
if (percent)
HorizontalLine(
y: 0,
color: theme.hintColor,
strokeWidth: 1,
dashArray: [4, 4],
),
if (mainHi != mainLo) ...[
extreme(mainHi, 'max', Alignment.topRight),
extreme(mainLo, 'min', Alignment.bottomRight),
],
],
),
lineTouchData: LineTouchData(
getTouchedSpotIndicator: (bar, indexes) => [
for (final _ in indexes)
TouchedSpotIndicatorData(
FlLine(
color: theme.hintColor,
strokeWidth: 1,
dashArray: [4, 4],
),
FlDotData(
getDotPainter: (_, _, _, _) => FlDotCirclePainter(
radius: 4,
color: bar.color ?? ChartColors.slot1Blue,
strokeWidth: 2,
strokeColor: Colors.white,
),
),
),
],
touchTooltipData: LineTouchTooltipData(
tooltipRoundedRadius: 8,
tooltipPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
getTooltipColor: (_) => theme.colorScheme.surfaceContainerHighest,
getTooltipItems: (touched) => [
for (final (i, s) in touched.indexed)
LineTooltipItem(
i == 0 ? '${ruDayMonthYearShort(_fromDay(s.x))}\n' : '',
theme.textTheme.bodySmall!.copyWith(color: theme.hintColor),
children: [
TextSpan(
text: '',
style: TextStyle(color: drawn[s.barIndex].color),
),
TextSpan(
text: '${drawn[s.barIndex].label}: ',
style: TextStyle(color: theme.hintColor),
),
TextSpan(
text: fmt(s.y),
style: TextStyle(
color: theme.colorScheme.onSurface,
fontWeight: FontWeight.w700,
),
),
],
),
],
),
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(),
rightTitles: const AxisTitles(),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: percent ? 64 : 56,
getTitlesWidget: (value, meta) {
// the axis ends sit on the padded range and collide with their neighbours
if (value == meta.min || value == meta.max) {
return const SizedBox.shrink();
}
final digits = meta.appliedInterval >= 1 ? 0 : 2;
return SideTitleWidget(
axisSide: meta.axisSide,
child: Text(
percent
? _percent(value, digits: digits)
: NumberFormat.decimalPatternDigits(
locale: 'ru_RU',
decimalDigits: digits,
).format(value),
style: theme.textTheme.bodySmall,
),
);
},
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
interval: math.max(1, span / 4),
getTitlesWidget: (value, meta) {
// float steps can land a label a hair before the last one; keep only one
if (value != meta.max &&
meta.max - value < meta.appliedInterval * 0.1) {
return const SizedBox.shrink();
}
final d = _fromDay(value);
return SideTitleWidget(
axisSide: meta.axisSide,
fitInside: SideTitleFitInsideData.fromTitleMeta(meta),
child: Text(
shortSpan ? ruDayMonthShort(d) : ruMonthYearShort(d),
style: theme.textTheme.bodySmall,
),
);
},
),
),
),
lineBarsData: [
for (final (i, l) in drawn.indexed)
LineChartBarData(
spots: l.spots,
isCurved: false,
color: l.color,
barWidth: 2,
// the instrument's own line carries the fill and the trades; an index is a ruler
belowBarData: i == 0
? BarAreaData(
show: true,
cutOffY: percent ? 0 : minY,
applyCutOffY: true,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
l.color.withValues(alpha: 0.32),
l.color.withValues(alpha: 0.02),
],
),
)
: BarAreaData(show: false),
dotData: FlDotData(
show: l.spots.length < 2 || (i == 0 && showTrades),
checkToShowDot: (spot, _) =>
l.spots.length < 2 ||
(i == 0 && model.tradeXs.contains(spot.x)),
getDotPainter: (_, _, _, _) => FlDotCirclePainter(
radius: 4,
color: Colors.white,
strokeWidth: 2,
strokeColor: l.color,
),
),
),
],
),
);
}
}