feat(insights): first isights impemetation

This commit is contained in:
misha
2026-11-09 15:35:10 +03:00
parent 67920b2016
commit 45d072cff4
63 changed files with 6374 additions and 1465 deletions

View File

@@ -0,0 +1,84 @@
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';
/// График ИМТ с цветными зонами: норма 18.5–25, избыточная масса 25–30, ожирение 30–40.
class BmiChart extends StatelessWidget {
const BmiChart({super.key, required this.model});
final BmiChartModel model;
@override
Widget build(BuildContext context) {
final points = buildPoints(model.labels, model.values);
if (points.isEmpty) {
return InsightsChartPlaceholder(text: model.deltaText);
}
final s = S.of(context);
final range = paddedRange(points.map((e) => e.value), minPadding: 2);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: insightsChartHeight,
child: SfCartesianChart(
key: const Key('insights_bmi_chart'),
margin: EdgeInsets.zero,
plotAreaBorderWidth: 0,
primaryXAxis: buildInsightsCategoryAxis(),
primaryYAxis: buildInsightsValueAxis(
unit: s.insights_unit_bmi,
min: range.min,
max: range.max,
plotBands: const [
PlotBand(
start: 18.5,
end: 25,
color: InsightsColors.bmiNormalZone,
),
PlotBand(
start: 25,
end: 30,
color: InsightsColors.bmiOverweightZone,
),
PlotBand(
start: 30,
end: 40,
color: InsightsColors.bmiObesityZone,
),
],
),
trackballBehavior: buildInsightsTrackball(),
series: <CartesianSeries<InsightsPoint, String>>[
SplineAreaSeries<InsightsPoint, String>(
name: s.insights_tab_bmi,
dataSource: points,
xValueMapper: (point, _) => point.label,
yValueMapper: (point, _) => point.value,
borderColor: InsightsColors.blue,
borderWidth: 3,
gradient: buildInsightsAreaGradient(InsightsColors.blue),
markerSettings: buildInsightsMarker(InsightsColors.blue),
),
],
),
),
if (model.hint.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
model.hint,
key: const Key('insights_bmi_hint'),
style: const TextStyle(
fontSize: 13,
color: InsightsColors.subtitle,
),
),
],
],
);
}
}

View File

@@ -0,0 +1,113 @@
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';
/// КБЖУ по дням: калории — площадь на левой оси («ккал»), Б/Ж/У — линии на
/// правой оси («граммы»). [visibility] — Калории, Белки, Жиры, Углеводы.
class DailyKbjuChart extends StatelessWidget {
const DailyKbjuChart({
super.key,
required this.model,
required this.visibility,
});
final DailyChartModel model;
final List<bool> visibility;
static const String _gramsAxisName = 'grams';
@override
Widget build(BuildContext context) {
final s = S.of(context);
final calories = buildPoints(model.labels, model.kcal);
if (calories.isEmpty) {
return InsightsChartPlaceholder(text: s.insights_not_enough);
}
final proteins = buildPoints(model.labels, model.proteins);
final fats = buildPoints(model.labels, model.fats);
final carbs = buildPoints(model.labels, model.carbs);
final grams = [
(
name: s.insights_series_proteins,
color: InsightsColors.proteins,
points: proteins,
),
(name: s.insights_series_fats, color: InsightsColors.fats, points: fats),
(
name: s.insights_series_carbs,
color: InsightsColors.carbs,
points: carbs,
),
];
return SizedBox(
height: insightsChartHeight,
child: SfCartesianChart(
key: const Key('insights_daily_chart'),
margin: EdgeInsets.zero,
plotAreaBorderWidth: 0,
primaryXAxis: buildInsightsCategoryAxis(),
primaryYAxis: NumericAxis(
title: AxisTitle(
text: s.insights_unit_kcal,
textStyle: insightsAxisLabelStyle,
),
labelStyle: insightsAxisLabelStyle,
minimum: 0,
majorTickLines: const MajorTickLines(size: 0),
axisLine: const AxisLine(width: 0),
majorGridLines: MajorGridLines(
color: InsightsColors.border.withValues(alpha: 0.8),
dashArray: const [4, 4],
),
),
axes: <ChartAxis>[
NumericAxis(
name: _gramsAxisName,
opposedPosition: true,
minimum: 0,
title: AxisTitle(
text: s.insights_unit_grams,
textStyle: insightsAxisLabelStyle,
),
labelStyle: insightsAxisLabelStyle,
majorTickLines: const MajorTickLines(size: 0),
axisLine: const AxisLine(width: 0),
majorGridLines: const MajorGridLines(width: 0),
),
],
trackballBehavior: buildInsightsTrackball(),
series: <CartesianSeries<InsightsPoint, String>>[
if (_isVisible(0))
SplineAreaSeries<InsightsPoint, String>(
name: s.insights_series_calories,
dataSource: calories,
xValueMapper: (point, _) => point.label,
yValueMapper: (point, _) => point.value,
borderColor: InsightsColors.calories,
borderWidth: 3,
gradient: buildInsightsAreaGradient(InsightsColors.calories),
),
for (var i = 0; i < grams.length; i++)
if (_isVisible(i + 1))
SplineSeries<InsightsPoint, String>(
name: grams[i].name,
yAxisName: _gramsAxisName,
dataSource: grams[i].points,
xValueMapper: (point, _) => point.label,
yValueMapper: (point, _) => point.value,
color: grams[i].color,
width: 2.5,
),
],
),
);
}
bool _isVisible(int index) =>
index < visibility.length ? visibility[index] : true;
}

View File

@@ -0,0 +1,130 @@
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 (`dd.MM`) и значение.
class InsightsPoint {
const InsightsPoint(this.label, this.value);
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(labels[i], values[i])];
}
/// Границы оси Y с отступом, чтобы линия не прижималась к краям (и ось не начиналась с нуля).
({double min, double max}) paddedRange(
Iterable<double> values, {
double minPadding = 1,
}) {
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);
return (
min: (lowest - padding).floorToDouble(),
max: (highest + padding).ceilToDouble(),
);
}
const TextStyle insightsAxisLabelStyle = TextStyle(
fontSize: 11,
color: InsightsColors.subtitle,
);
CategoryAxis buildInsightsCategoryAxis() {
return const CategoryAxis(
arrangeByIndex: true,
labelStyle: insightsAxisLabelStyle,
majorGridLines: MajorGridLines(width: 0),
majorTickLines: MajorTickLines(size: 0),
axisLine: AxisLine(color: InsightsColors.border, width: 1),
edgeLabelPlacement: EdgeLabelPlacement.shift,
labelIntersectAction: AxisLabelIntersectAction.hide,
);
}
NumericAxis buildInsightsValueAxis({
required String unit,
required double min,
required double max,
List<PlotBand> plotBands = const [],
}) {
return NumericAxis(
minimum: min,
maximum: max,
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,
);
}
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,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,210 @@
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) {
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: EdgeInsets.zero,
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: SplineType.monotonic,
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],
),
];
}
}

View File

@@ -0,0 +1,86 @@
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';
/// Три линии: талия, грудь, бёдра. [visibility] — видимость серий в этом порядке.
class MeasureChart extends StatelessWidget {
const MeasureChart({
super.key,
required this.model,
required this.visibility,
required this.emptyText,
});
final MeasureChartModel model;
final List<bool> visibility;
final String emptyText;
@override
Widget build(BuildContext context) {
final waist = buildPoints(model.labels, model.waist);
final chest = buildPoints(model.labels, model.chest);
final hips = buildPoints(model.labels, model.hips);
if (waist.isEmpty && chest.isEmpty && hips.isEmpty) {
return InsightsChartPlaceholder(text: emptyText);
}
final s = S.of(context);
final series = [
(
name: s.insights_series_waist,
color: InsightsColors.waist,
points: waist,
),
(
name: s.insights_series_chest,
color: InsightsColors.chest,
points: chest,
),
(name: s.insights_series_hips, color: InsightsColors.hips, points: hips),
];
final visibleValues = <double>[
for (var i = 0; i < series.length; i++)
if (_isVisible(i)) ...series[i].points.map((e) => e.value),
];
final range = paddedRange(
visibleValues.isEmpty
? series.expand((e) => e.points).map((e) => e.value)
: visibleValues,
minPadding: 2,
);
return SizedBox(
height: insightsChartHeight,
child: SfCartesianChart(
key: const Key('insights_measure_chart'),
margin: EdgeInsets.zero,
plotAreaBorderWidth: 0,
primaryXAxis: buildInsightsCategoryAxis(),
primaryYAxis: buildInsightsValueAxis(
unit: s.insights_unit_cm,
min: range.min,
max: range.max,
),
trackballBehavior: buildInsightsTrackball(),
series: <CartesianSeries<InsightsPoint, String>>[
for (var i = 0; i < series.length; i++)
if (_isVisible(i))
SplineSeries<InsightsPoint, String>(
name: series[i].name,
dataSource: series[i].points,
xValueMapper: (point, _) => point.label,
yValueMapper: (point, _) => point.value,
color: series[i].color,
width: 3,
markerSettings: buildInsightsMarker(series[i].color),
),
],
),
);
}
bool _isVisible(int index) =>
index < visibility.length ? visibility[index] : true;
}

View File

@@ -0,0 +1,53 @@
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';
class WeightChart extends StatelessWidget {
const WeightChart({super.key, required this.model, required this.emptyText});
final WeightChartModel model;
/// Текст при отсутствии точек («Добавьте Ваш вес»).
final String emptyText;
@override
Widget build(BuildContext context) {
final points = buildPoints(model.labels, model.values);
if (points.isEmpty) {
return InsightsChartPlaceholder(text: emptyText);
}
final s = S.of(context);
final range = paddedRange(points.map((e) => e.value));
return SizedBox(
height: insightsChartHeight,
child: SfCartesianChart(
key: const Key('insights_weight_chart'),
margin: EdgeInsets.zero,
plotAreaBorderWidth: 0,
primaryXAxis: buildInsightsCategoryAxis(),
primaryYAxis: buildInsightsValueAxis(
unit: s.insights_unit_kg,
min: range.min,
max: range.max,
),
trackballBehavior: buildInsightsTrackball(),
series: <CartesianSeries<InsightsPoint, String>>[
SplineAreaSeries<InsightsPoint, String>(
name: s.insights_tab_weight,
dataSource: points,
xValueMapper: (point, _) => point.label,
yValueMapper: (point, _) => point.value,
borderColor: InsightsColors.blue,
borderWidth: 3,
gradient: buildInsightsAreaGradient(InsightsColors.blue),
markerSettings: buildInsightsMarker(InsightsColors.blue),
),
],
),
);
}
}