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.
|
||||
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')}';
|
||||
|
||||
@@ -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<PricePoint> prices;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SizedBox(
|
||||
height: 200,
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
gridData: const FlGridData(show: true, drawVerticalLine: false),
|
||||
borderData: FlBorderData(show: false),
|
||||
titlesData: FlTitlesData(
|
||||
topTitles: const AxisTitles(),
|
||||
rightTitles: const AxisTitles(),
|
||||
leftTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: true, reservedSize: 52),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 28,
|
||||
interval: (prices.length / 4).clamp(1, double.infinity),
|
||||
getTitlesWidget: (value, meta) {
|
||||
final i = value.round();
|
||||
if (i < 0 || i >= prices.length)
|
||||
return const SizedBox.shrink();
|
||||
return Text(
|
||||
ruMonthYearShort(prices[i].d),
|
||||
style: theme.textTheme.bodySmall,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: [
|
||||
for (var i = 0; i < prices.length; i++)
|
||||
FlSpot(i.toDouble(), _d(prices[i].close)),
|
||||
],
|
||||
isCurved: false,
|
||||
color: ChartColors.slot1Blue,
|
||||
barWidth: 2,
|
||||
dotData: FlDotData(show: prices.length < 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LotsTable extends StatelessWidget {
|
||||
const _LotsTable({required this.lots});
|
||||
|
||||
|
||||
@@ -0,0 +1,946 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../../core/theme/chart_colors.dart';
|
||||
import '../../core/utils/ru_date.dart';
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../core/widgets/money_text.dart';
|
||||
|
||||
/// The periods offered above the chart. `all` has no start: the whole history there is.
|
||||
enum PricePeriod {
|
||||
week('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