From 689102807472643c012fb80c182c82d2cd349b1b Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sun, 20 Sep 2026 10:46:35 +0300 Subject: [PATCH] =?UTF-8?q?fix(app):=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=B8=20=D0=BE=D1=81=D0=B5=D0=B9=20=D0=B8=20=D0=BB=D0=B5?= =?UTF-8?q?=D0=B3=D0=B5=D0=BD=D0=B4=D0=B0=20=D0=B3=D1=80=D0=B0=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=81=D1=82=D0=BE=D0=B8=D0=BC=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Крайние подписи оси Y (414.6K поверх 400K) скрыты, числа в русской краткой форме, подписи оси X не обрезаются справа. Добавлена легенда: сплошная линия — стоимость, пунктир — вложено нетто. --- app/lib/core/utils/compact_number.dart | 18 +++++ app/lib/features/portfolio/holdings_tab.dart | 85 +++++++++++++++++--- 2 files changed, 94 insertions(+), 9 deletions(-) create mode 100644 app/lib/core/utils/compact_number.dart diff --git a/app/lib/core/utils/compact_number.dart b/app/lib/core/utils/compact_number.dart new file mode 100644 index 0000000..a46a847 --- /dev/null +++ b/app/lib/core/utils/compact_number.dart @@ -0,0 +1,18 @@ +/// «12,3 тыс», «1,2 млн» — for axis labels and cells too narrow for a full amount. +/// +/// fl_chart's own short form («414.6K») is English and rounds a step of 50 000 to the same +/// label twice; this one keeps the Russian suffixes and one decimal below a hundred. +String compactNumber(double value) { + final n = value.abs(); + String scaled(double x, String suffix) { + final s = x >= 100 + ? x.round().toString() + : x.toStringAsFixed(1).replaceAll(RegExp(r'\.0$'), ''); + return '${s.replaceAll('.', ',')} $suffix'; + } + + final sign = value < 0 ? '−' : ''; + if (n >= 1e6) return '$sign${scaled(n / 1e6, 'млн')}'; + if (n >= 1e3) return '$sign${scaled(n / 1e3, 'тыс')}'; + return '$sign${n.round()}'; +} diff --git a/app/lib/features/portfolio/holdings_tab.dart b/app/lib/features/portfolio/holdings_tab.dart index 8b862df..93ecb5b 100644 --- a/app/lib/features/portfolio/holdings_tab.dart +++ b/app/lib/features/portfolio/holdings_tab.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/theme/chart_colors.dart'; +import '../../core/utils/compact_number.dart'; import '../../core/utils/ru_date.dart'; import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/empty_state.dart'; @@ -249,7 +250,8 @@ class _ValueChart extends StatelessWidget { spots.add(FlSpot(i.toDouble(), _d(rows[i].totalRub))); invested.add(FlSpot(i.toDouble(), _d(rows[i].investedNetRub))); } - return SizedBox( + const names = ['Стоимость', 'Вложено (нетто)']; + final chart = SizedBox( height: 220, child: LineChart( LineChartData( @@ -258,20 +260,47 @@ class _ValueChart extends StatelessWidget { titlesData: FlTitlesData( topTitles: const AxisTitles(), rightTitles: const AxisTitles(), - leftTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: true, reservedSize: 56), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 56, + getTitlesWidget: (value, meta) { + // the axis ends sit on the data's min/max and land on top of the round + // labels next to them (414,6 тыс over 400 тыс) + if (value == meta.min || value == meta.max) { + return const SizedBox.shrink(); + } + return SideTitleWidget( + axisSide: meta.axisSide, + child: Text( + compactNumber(value), + style: theme.textTheme.bodySmall, + ), + ); + }, + ), ), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 28, - interval: (rows.length / 4).clamp(1, double.infinity), + // ticks at the quarters of the span, so the last one is the last day + interval: ((rows.length - 1) / 4).clamp(1.0, double.infinity), 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 i = value.round(); if (i < 0 || i >= rows.length) return const SizedBox.shrink(); - return Text( - ruMonthYearShort(rows[i].d), - style: theme.textTheme.bodySmall, + return SideTitleWidget( + axisSide: meta.axisSide, + fitInside: SideTitleFitInsideData.fromTitleMeta(meta), + child: Text( + ruMonthYearShort(rows[i].d), + style: theme.textTheme.bodySmall, + ), ); }, ), @@ -297,9 +326,10 @@ class _ValueChart extends StatelessWidget { lineTouchData: LineTouchData( touchTooltipData: LineTouchTooltipData( getTooltipItems: (touched) => [ - for (final t in touched) + for (final (i, t) in touched.indexed) LineTooltipItem( - '${ruDate(rows[t.x.round()].d)}\n' + '${i == 0 ? '${ruDate(rows[t.x.round()].d)}\n' : ''}' + '${names[t.barIndex]}: ' '${MoneyText.format(t.y.toStringAsFixed(2), 'RUB')}', theme.textTheme.bodySmall ?? const TextStyle(), ), @@ -309,6 +339,43 @@ class _ValueChart extends StatelessWidget { ), ), ); + + // the dashed line has no other explanation: say what each of the two is + Widget key(Color color, String label, {bool dashed = false}) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 18, + child: dashed + ? Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + for (var i = 0; i < 3; i++) + Container(width: 4, height: 2, color: color), + ], + ) + : Container(height: 2, color: color), + ), + const SizedBox(width: 6), + Text(label, style: theme.textTheme.bodySmall), + ], + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + chart, + const SizedBox(height: 8), + Wrap( + spacing: 16, + runSpacing: 4, + children: [ + key(ChartColors.slot1Blue, names[0]), + key(ChartColors.slot4Yellow, names[1], dashed: true), + ], + ), + ], + ); } }