Files
Dmitry 617682351b feat(app): график цены — периоды, режимы, бенчмарки и мои сделки
Переключатель периода (7д…все и свой диапазон), режимы «Цена ₽ / ₽ + НКД / %», наложение бенчмарков в процентах (у ценового индекса пометка «без дивидендов»), изменение за период, заливка, подписи min/max, курсор с подсказкой и точки покупок и продаж. Ось X по датам, подписи осей без наложений.
2026-09-20 10:46:33 +03:00

947 lines
30 KiB
Dart
Raw Permalink 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 '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<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,
),
),
),
],
),
);
}
}