feat(app): график цены — периоды, режимы, бенчмарки и мои сделки
Переключатель периода (7д…все и свой диапазон), режимы «Цена ₽ / ₽ + НКД / %», наложение бенчмарков в процентах (у ценового индекса пометка «без дивидендов»), изменение за период, заливка, подписи min/max, курсор с подсказкой и точки покупок и продаж. Ось X по датам, подписи осей без наложений.
This commit is contained in:
@@ -42,6 +42,28 @@ String ruMonthYear(DateTime d) {
|
|||||||
/// `'сен 2026'`, for compact chart/table labels.
|
/// `'сен 2026'`, for compact chart/table labels.
|
||||||
String ruMonthYearShort(DateTime d) => '${_monthsShort[d.month - 1]} ${d.year}';
|
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.
|
/// `'2026-09'`, the `month` query parameter format the API expects.
|
||||||
String monthKey(DateTime d) =>
|
String monthKey(DateTime d) =>
|
||||||
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}';
|
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}';
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import 'package:decimal/decimal.dart';
|
import 'package:decimal/decimal.dart';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:fintracker_api/fintracker_api.dart';
|
import 'package:fintracker_api/fintracker_api.dart';
|
||||||
import 'package:fl_chart/fl_chart.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../core/api/api_client.dart';
|
import '../../core/api/api_client.dart';
|
||||||
import '../../core/auth/auth_controller.dart';
|
import '../../core/auth/auth_controller.dart';
|
||||||
import '../../core/theme/chart_colors.dart';
|
|
||||||
import '../../core/utils/ru_date.dart';
|
import '../../core/utils/ru_date.dart';
|
||||||
import '../../core/widgets/async_value_view.dart';
|
import '../../core/widgets/async_value_view.dart';
|
||||||
import '../../core/widgets/asset_icon.dart';
|
import '../../core/widgets/asset_icon.dart';
|
||||||
@@ -17,6 +15,7 @@ import '../../core/widgets/stale_banner.dart';
|
|||||||
import '../../core/widgets/help_tip.dart';
|
import '../../core/widgets/help_tip.dart';
|
||||||
import 'instrument_edit_dialog.dart';
|
import 'instrument_edit_dialog.dart';
|
||||||
import 'labels.dart';
|
import 'labels.dart';
|
||||||
|
import 'price_history.dart';
|
||||||
import 'providers.dart';
|
import 'providers.dart';
|
||||||
|
|
||||||
double _d(String s) => Decimal.parse(s).toDouble();
|
double _d(String s) => Decimal.parse(s).toDouble();
|
||||||
@@ -87,14 +86,24 @@ class InstrumentPage extends ConsumerWidget {
|
|||||||
StaleBanner(fetchedAt: cached.fetchedAt!),
|
StaleBanner(fetchedAt: cached.fetchedAt!),
|
||||||
_Header(instrument: d.instrument, holding: d.holding),
|
_Header(instrument: d.instrument, holding: d.holding),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_Section(
|
if (d.prices.isEmpty)
|
||||||
title: 'Цена',
|
const _Section(
|
||||||
child: d.prices.isEmpty
|
title: 'История цены',
|
||||||
? const EmptyState(
|
child: EmptyState(
|
||||||
icon: Icons.show_chart,
|
icon: Icons.show_chart,
|
||||||
message: 'Цен нет — стоимость позиции неизвестна.',
|
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),
|
const SizedBox(height: 16),
|
||||||
_Section(
|
_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 {
|
class _LotsTable extends StatelessWidget {
|
||||||
const _LotsTable({required this.lots});
|
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('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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user