diff --git a/app/lib/core/utils/ru_date.dart b/app/lib/core/utils/ru_date.dart index 1e4539a..a32bc86 100644 --- a/app/lib/core/utils/ru_date.dart +++ b/app/lib/core/utils/ru_date.dart @@ -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')}'; diff --git a/app/lib/features/portfolio/instrument_page.dart b/app/lib/features/portfolio/instrument_page.dart index bb03b77..627c8c5 100644 --- a/app/lib/features/portfolio/instrument_page.dart +++ b/app/lib/features/portfolio/instrument_page.dart @@ -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 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}); diff --git a/app/lib/features/portfolio/price_history.dart b/app/lib/features/portfolio/price_history.dart new file mode 100644 index 0000000..9a4f07b --- /dev/null +++ b/app/lib/features/portfolio/price_history.dart @@ -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('7д'), + month('1м'), + quarter('3м'), + halfYear('6м'), + ytd('YTD'), + year('1г'), + fiveYears('5л'), + 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((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, 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>(( + ref, +) async { + final r = await ref.watch(apiProvider).getBenchmarksApi().benchmarksList(); + return [ + for (final b in r.data ?? const []) + 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 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 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 trades; + + @override + ConsumerState createState() => _PriceHistoryState(); +} + +class _PriceHistoryState extends ConsumerState { + PricePeriod _period = PricePeriod.year; + DateTimeRange? _custom; + PriceMode _mode = PriceMode.rub; + final Set _benchmarkIds = {}; + + Future _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 []; + 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 prices, + List 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 _within(List 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 _snapTrades(List trades, List spots) { + if (spots.isEmpty) return const {}; + final xs = [for (final s in spots) s.x]; + final out = {}; + 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 _percentSpots( + List 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 modes; + final ValueChanged onMode; + final bool showTrades; + final ValueChanged onShowTrades; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + children: [ + Expanded( + child: Text('История цены', style: theme.textTheme.titleMedium), + ), + DropdownButtonHideUnderline( + child: DropdownButton( + 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( + tooltip: 'Настройки графика', + icon: const Icon(Icons.more_horiz), + onSelected: onShowTrades, + itemBuilder: (_) => [ + CheckedPopupMenuItem( + 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 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 available; + final Set 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( + tooltip: 'Выбрать бенчмарки', + onSelected: (id) => onChanged(id, !selected.contains(id)), + itemBuilder: (_) => [ + for (final b in available) + CheckedPopupMenuItem( + 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, + ), + ), + ), + ], + ), + ); + } +}