fix(app): подписи осей и легенда графика стоимости

Крайние подписи оси Y (414.6K поверх 400K) скрыты, числа в русской краткой форме, подписи оси X не обрезаются справа. Добавлена легенда: сплошная линия — стоимость, пунктир — вложено нетто.
This commit is contained in:
Dmitry
2026-09-20 10:46:35 +03:00
parent 617682351b
commit 6891028074
2 changed files with 94 additions and 9 deletions
+18
View File
@@ -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()}';
}
+76 -9
View File
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/chart_colors.dart'; import '../../core/theme/chart_colors.dart';
import '../../core/utils/compact_number.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/empty_state.dart'; import '../../core/widgets/empty_state.dart';
@@ -249,7 +250,8 @@ class _ValueChart extends StatelessWidget {
spots.add(FlSpot(i.toDouble(), _d(rows[i].totalRub))); spots.add(FlSpot(i.toDouble(), _d(rows[i].totalRub)));
invested.add(FlSpot(i.toDouble(), _d(rows[i].investedNetRub))); invested.add(FlSpot(i.toDouble(), _d(rows[i].investedNetRub)));
} }
return SizedBox( const names = ['Стоимость', 'Вложено (нетто)'];
final chart = SizedBox(
height: 220, height: 220,
child: LineChart( child: LineChart(
LineChartData( LineChartData(
@@ -258,20 +260,47 @@ class _ValueChart extends StatelessWidget {
titlesData: FlTitlesData( titlesData: FlTitlesData(
topTitles: const AxisTitles(), topTitles: const AxisTitles(),
rightTitles: const AxisTitles(), rightTitles: const AxisTitles(),
leftTitles: const AxisTitles( leftTitles: AxisTitles(
sideTitles: SideTitles(showTitles: true, reservedSize: 56), 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( bottomTitles: AxisTitles(
sideTitles: SideTitles( sideTitles: SideTitles(
showTitles: true, showTitles: true,
reservedSize: 28, 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) { 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(); final i = value.round();
if (i < 0 || i >= rows.length) return const SizedBox.shrink(); if (i < 0 || i >= rows.length) return const SizedBox.shrink();
return Text( return SideTitleWidget(
ruMonthYearShort(rows[i].d), axisSide: meta.axisSide,
style: theme.textTheme.bodySmall, fitInside: SideTitleFitInsideData.fromTitleMeta(meta),
child: Text(
ruMonthYearShort(rows[i].d),
style: theme.textTheme.bodySmall,
),
); );
}, },
), ),
@@ -297,9 +326,10 @@ class _ValueChart extends StatelessWidget {
lineTouchData: LineTouchData( lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData( touchTooltipData: LineTouchTooltipData(
getTooltipItems: (touched) => [ getTooltipItems: (touched) => [
for (final t in touched) for (final (i, t) in touched.indexed)
LineTooltipItem( 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')}', '${MoneyText.format(t.y.toStringAsFixed(2), 'RUB')}',
theme.textTheme.bodySmall ?? const TextStyle(), 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),
],
),
],
);
} }
} }