fix(app): подписи осей и легенда графика стоимости
Крайние подписи оси Y (414.6K поверх 400K) скрыты, числа в русской краткой форме, подписи оси X не обрезаются справа. Добавлена легенда: сплошная линия — стоимость, пунктир — вложено нетто.
This commit is contained in:
@@ -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()}';
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user