Files
eatvio_app/lib/features/insights/presentation/charts/intra_day_chart.dart
2026-11-11 16:05:11 +03:00

215 lines
7.1 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
/// График «В течение дня»: столбики Б/Ж/У (ккал) по приёмам пищи на оси 0–24 ч,
/// линии сытости и настроения (шкала 0–5) и затемнение времени сна.
///
/// [visibility] — Белки, Жиры, Углеводы, Сытость, Настроение.
class IntraDayChart extends StatelessWidget {
const IntraDayChart({
super.key,
required this.model,
required this.visibility,
required this.showSleep,
});
final IntraChartModel model;
final List<bool> visibility;
final bool showSleep;
static const String _scaleAxisName = 'scale';
static const double _barWidthPx = 26;
static const double _axesReservedWidthPx = 80;
@override
Widget build(BuildContext context) {
final s = S.of(context);
if (model.bars.isEmpty &&
model.satiety.isEmpty &&
model.mood.isEmpty &&
model.wake == null &&
model.sleep == null) {
return InsightsChartPlaceholder(text: s.insights_not_enough);
}
return SizedBox(
height: insightsChartHeight,
child: LayoutBuilder(
builder: (context, constraints) {
final plotWidth = (constraints.maxWidth - _axesReservedWidthPx).clamp(
120.0,
double.infinity,
);
final barFraction = (_barWidthPx / (plotWidth / 24)).clamp(0.1, 1.0);
return SfCartesianChart(
key: const Key('insights_intra_chart'),
margin: insightsChartMargin,
plotAreaBorderWidth: 0,
primaryXAxis: NumericAxis(
minimum: 0,
maximum: 24,
interval: 2,
labelStyle: insightsAxisLabelStyle,
majorGridLines: const MajorGridLines(width: 0),
majorTickLines: const MajorTickLines(size: 0),
axisLine: const AxisLine(color: InsightsColors.border, width: 1),
axisLabelFormatter: (details) => ChartAxisLabel(
'${details.value.toInt().toString().padLeft(2, '0')}:00',
details.textStyle,
),
plotBands: _sleepBands(),
),
primaryYAxis: NumericAxis(
minimum: 0,
title: AxisTitle(
text: s.insights_unit_kcal_per_meal,
textStyle: insightsAxisLabelStyle,
),
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],
),
),
axes: const <ChartAxis>[
NumericAxis(
name: _scaleAxisName,
opposedPosition: true,
minimum: 0,
maximum: 5,
interval: 1,
labelStyle: insightsAxisLabelStyle,
majorTickLines: MajorTickLines(size: 0),
axisLine: AxisLine(width: 0),
majorGridLines: MajorGridLines(width: 0),
),
],
trackballBehavior: buildInsightsTrackball(),
series: <CartesianSeries<dynamic, double>>[
if (_isVisible(0))
_barSeries(
s.insights_series_proteins,
InsightsColors.proteins,
(bar) => bar.p,
barFraction,
),
if (_isVisible(1))
_barSeries(
s.insights_series_fats,
InsightsColors.fats,
(bar) => bar.f,
barFraction,
),
if (_isVisible(2))
_barSeries(
s.insights_series_carbs,
InsightsColors.carbs,
(bar) => bar.c,
barFraction,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(6),
),
),
if (_isVisible(3) && model.satiety.isNotEmpty)
_scaleSeries(
s.insights_series_satiety,
InsightsColors.satiety,
model.satiety,
),
if (_isVisible(4) && model.mood.isNotEmpty)
_scaleSeries(
s.insights_series_mood,
InsightsColors.mood,
model.mood,
),
],
);
},
),
);
}
bool _isVisible(int index) =>
index < visibility.length ? visibility[index] : true;
CartesianSeries<IntraBarModel, double> _barSeries(
String name,
Color color,
double Function(IntraBarModel bar) valueOf,
double widthFraction, {
BorderRadius borderRadius = BorderRadius.zero,
}) {
return StackedColumnSeries<IntraBarModel, double>(
name: name,
dataSource: model.bars,
xValueMapper: (bar, _) => bar.x,
yValueMapper: (bar, _) => valueOf(bar),
color: color,
width: widthFraction,
spacing: 0,
borderRadius: borderRadius,
);
}
CartesianSeries<ScalePointModel, double> _scaleSeries(
String name,
Color color,
List<ScalePointModel> points,
) {
return SplineSeries<ScalePointModel, double>(
name: name,
yAxisName: _scaleAxisName,
dataSource: points,
xValueMapper: (point, _) => point.x,
yValueMapper: (point, _) => point.y,
splineType: insightsSplineType,
color: color,
width: 2.5,
markerSettings: MarkerSettings(
isVisible: true,
width: 3,
height: 3,
color: color,
borderColor: color,
),
);
}
/// Затемнение `[0, wake]` и `[sleep, 24]` плюс пунктиры подъёма и отхода ко сну.
List<PlotBand> _sleepBands() {
if (!showSleep) {
return const [];
}
final wake = model.wake;
final sleep = model.sleep;
return [
if (wake != null && wake > 0)
PlotBand(start: 0, end: wake, color: InsightsColors.sleepShade),
if (sleep != null && sleep < 24 && (wake == null || sleep > wake))
PlotBand(start: sleep, end: 24, color: InsightsColors.sleepShade),
if (wake != null)
PlotBand(
start: wake,
end: wake,
borderColor: InsightsColors.wake,
borderWidth: 1.5,
dashArray: const [5, 4],
),
if (sleep != null)
PlotBand(
start: sleep,
end: sleep,
borderColor: InsightsColors.sleepLine,
borderWidth: 1.5,
dashArray: const [5, 4],
),
];
}
}