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, ), ), ), ], ), ); } }