forked from eatvio/eatvio_app
181 lines
5.9 KiB
Dart
181 lines
5.9 KiB
Dart
import 'dart:math' as math;
|
||
|
||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||
|
||
/// Точка графика: индекс по оси X (как у Chart.js), подпись `dd.MM` и значение.
|
||
///
|
||
/// Индекс обязателен: Syncfusion `CategoryAxis` склеивает одинаковые строки
|
||
/// (`05.07` несколько раз → одна категория). На вебе labels идут по индексу,
|
||
/// поэтому здесь X = index, а дата рисуется через [axisLabelFormatter].
|
||
class InsightsPoint {
|
||
const InsightsPoint({
|
||
required this.index,
|
||
required this.label,
|
||
required this.value,
|
||
});
|
||
|
||
final int index;
|
||
final String label;
|
||
final double value;
|
||
}
|
||
|
||
/// Склеивает `labels[]` и `values[]` (по минимальной длине).
|
||
List<InsightsPoint> buildPoints(List<String> labels, List<double> values) {
|
||
final count = math.min(labels.length, values.length);
|
||
return [
|
||
for (var i = 0; i < count; i++)
|
||
InsightsPoint(index: i, label: labels[i], value: values[i]),
|
||
];
|
||
}
|
||
|
||
/// Локальный диапазон оси Y вокруг данных (не от нуля), с запасом под сплайн.
|
||
///
|
||
/// [clampZero] — не уводить минимум в минус на осях КБЖУ (ккал/граммы ≥ 0).
|
||
({double min, double max}) paddedRange(
|
||
Iterable<double> values, {
|
||
double minPadding = 1,
|
||
bool clampZero = false,
|
||
}) {
|
||
if (values.isEmpty) {
|
||
return (min: 0, max: 1);
|
||
}
|
||
final lowest = values.reduce(math.min);
|
||
final highest = values.reduce(math.max);
|
||
final padding = math.max((highest - lowest) * 0.2, minPadding);
|
||
var min = lowest - padding;
|
||
var max = highest + padding;
|
||
if (clampZero) {
|
||
min = math.max(0, min);
|
||
}
|
||
if (max <= min) {
|
||
max = min + minPadding;
|
||
}
|
||
return (min: min, max: max);
|
||
}
|
||
|
||
|
||
const SplineType insightsSplineType = SplineType.monotonic;
|
||
|
||
const TextStyle insightsAxisLabelStyle = TextStyle(
|
||
fontSize: 11,
|
||
color: InsightsColors.subtitle,
|
||
);
|
||
|
||
/// Ось X по индексу точки; подпись — дата из [labels].
|
||
///
|
||
/// Так даты `05.07`, `06.07`, … не схлопываются и не размножаются, когда на
|
||
/// графике несколько серий с одним и тем же набором labels (КБЖУ, замеры).
|
||
NumericAxis buildInsightsIndexAxis(List<String> labels) {
|
||
final lastIndex = math.max(labels.length - 1, 0).toDouble();
|
||
// На узком экране не подписываем каждую точку: ~6 подписей, как авто-тики Chart.js.
|
||
final interval = labels.length <= 7
|
||
? 1.0
|
||
: (labels.length / 6).ceilToDouble();
|
||
|
||
return NumericAxis(
|
||
minimum: 0,
|
||
maximum: lastIndex,
|
||
interval: interval,
|
||
labelStyle: insightsAxisLabelStyle,
|
||
majorGridLines: const MajorGridLines(width: 0),
|
||
majorTickLines: const MajorTickLines(size: 0),
|
||
axisLine: const AxisLine(color: InsightsColors.border, width: 1),
|
||
edgeLabelPlacement: EdgeLabelPlacement.shift,
|
||
axisLabelFormatter: (details) {
|
||
final index = details.value.round();
|
||
if (index < 0 || index >= labels.length) {
|
||
return ChartAxisLabel('', details.textStyle);
|
||
}
|
||
// Промежуточные дробные тики (если Syncfusion их поставит) не подписываем.
|
||
if ((details.value - index).abs() > 0.01) {
|
||
return ChartAxisLabel('', details.textStyle);
|
||
}
|
||
return ChartAxisLabel(labels[index], insightsAxisLabelStyle);
|
||
},
|
||
);
|
||
}
|
||
|
||
NumericAxis buildInsightsValueAxis({
|
||
required String unit,
|
||
required double min,
|
||
required double max,
|
||
List<PlotBand> plotBands = const [],
|
||
}) {
|
||
return NumericAxis(
|
||
minimum: min,
|
||
maximum: max,
|
||
// Доп. воздух, чтобы сплайн не врезался в край plot area.
|
||
rangePadding: ChartRangePadding.additional,
|
||
labelFormat: '{value} $unit',
|
||
labelStyle: insightsAxisLabelStyle,
|
||
majorTickLines: const MajorTickLines(size: 0),
|
||
axisLine: const AxisLine(width: 0),
|
||
majorGridLines: MajorGridLines(
|
||
color: InsightsColors.border.withValues(alpha: 0.8),
|
||
dashArray: const [4, 4],
|
||
),
|
||
plotBands: plotBands,
|
||
);
|
||
}
|
||
|
||
/// Небольшой внутренний отступ, чтобы кривая и подписи не резались контейнером.
|
||
const EdgeInsets insightsChartMargin = EdgeInsets.fromLTRB(0, 8, 0, 4);
|
||
|
||
TrackballBehavior buildInsightsTrackball() {
|
||
return TrackballBehavior(
|
||
enable: true,
|
||
activationMode: ActivationMode.singleTap,
|
||
tooltipDisplayMode: TrackballDisplayMode.groupAllPoints,
|
||
lineType: TrackballLineType.vertical,
|
||
tooltipSettings: const InteractiveTooltip(),
|
||
);
|
||
}
|
||
|
||
MarkerSettings buildInsightsMarker(Color color) {
|
||
return MarkerSettings(
|
||
isVisible: true,
|
||
width: 4,
|
||
height: 4,
|
||
color: Colors.white,
|
||
borderColor: color,
|
||
borderWidth: 2,
|
||
);
|
||
}
|
||
|
||
LinearGradient buildInsightsAreaGradient(Color color) {
|
||
return LinearGradient(
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
colors: [color.withValues(alpha: 0.18), color.withValues(alpha: 0)],
|
||
);
|
||
}
|
||
|
||
/// Пустое состояние графика (нет данных).
|
||
class InsightsChartPlaceholder extends StatelessWidget {
|
||
const InsightsChartPlaceholder({super.key, required this.text});
|
||
|
||
final String text;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return SizedBox(
|
||
height: insightsChartHeight,
|
||
child: Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||
child: Text(
|
||
text,
|
||
textAlign: TextAlign.center,
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
color: InsightsColors.subtitle,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|