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,222 @@
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
import 'package:eatvio_app/features/insights/models/insights_period.dart';
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
import 'package:eatvio_app/features/profile/models/weight_model.dart';
import 'package:eatvio_app/generated/l10n.dart';
/// Собирает [InsightsPayloadModel] из сырых точек мобильного API.
///
/// Зеркало веб-`UserInsightsService`: порог изменения [deltaEpsilon], минимум две
/// точки для дельты, те же категории ИМТ и формат подписей.
class InsightsTextBuilder {
InsightsTextBuilder(this.s);
final S s;
static const double deltaEpsilon = 0.05;
static const String _emptyHeadline = '—';
InsightsPayloadModel build({
required List<WeightModel> weights,
required List<CircumferenceModel> circumferences,
required BodyPeriod period,
int? heightCm,
}) {
final sortedWeights = [...weights]
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
final sortedCircumferences = [...circumferences]
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
return InsightsPayloadModel(
weight: buildWeight(sortedWeights, period),
bmi: buildBmi(sortedWeights, period, heightCm),
measure: buildMeasure(sortedCircumferences, period),
);
}
WeightChartModel buildWeight(List<WeightModel> weights, BodyPeriod period) {
final values = weights.map((e) => e.weight).toList();
final last = values.isEmpty ? null : values.last;
final delta = values.length < 2 ? null : _round(values.last - values.first);
return WeightChartModel(
labels: weights.map((e) => formatLabel(e.createdAt)).toList(),
values: values,
last: last,
delta: delta,
headline: last == null ? _emptyHeadline : last.toStringAsFixed(1),
unit: s.insights_unit_kg,
deltaText: _weightDeltaText(values, period),
);
}
BmiChartModel buildBmi(
List<WeightModel> weights,
BodyPeriod period,
int? heightCm,
) {
final hasHeight = heightCm != null && heightCm > 0;
if (!hasHeight) {
return BmiChartModel(
headline: _emptyHeadline,
deltaText: s.insights_need_height,
hint: s.insights_need_height,
);
}
final points = weights.where((e) => e.bmi != null).toList();
final values = points.map((e) => e.bmi!).toList();
final last = values.isEmpty ? null : values.last;
final delta = values.length < 2 ? null : _round(values.last - values.first);
final category = last == null ? null : bmiCategory(last);
return BmiChartModel(
labels: points.map((e) => formatLabel(e.createdAt)).toList(),
values: values,
last: last,
delta: delta,
headline: last == null ? _emptyHeadline : last.toStringAsFixed(1),
category: category,
deltaText: _bmiDeltaText(values, period, category),
hint: s.insights_bmi_hint(heightCm.toString()),
);
}
MeasureChartModel buildMeasure(
List<CircumferenceModel> circumferences,
BodyPeriod period,
) {
final waist = circumferences.map((e) => e.waistCircumference).toList();
final chest = circumferences.map((e) => e.chestCircumference).toList();
final hips = circumferences.map((e) => e.hipCircumference).toList();
return MeasureChartModel(
labels: circumferences.map((e) => formatLabel(e.createdAt)).toList(),
waist: waist,
chest: chest,
hips: hips,
headline: waist.isEmpty ? _emptyHeadline : waist.last.round().toString(),
deltaText: _measureDeltaText(
waist: waist,
chest: chest,
hips: hips,
period: period,
),
);
}
/// Категория по таблице ВОЗ из постановки #2120.
String bmiCategory(double bmi) {
if (bmi < 16) {
return s.insights_bmi_severe_underweight;
}
if (bmi < 18.5) {
return s.insights_bmi_underweight;
}
if (bmi < 25) {
return s.insights_bmi_normal;
}
if (bmi < 30) {
return s.insights_bmi_overweight;
}
if (bmi < 35) {
return s.insights_bmi_obesity_1;
}
if (bmi < 40) {
return s.insights_bmi_obesity_2;
}
return s.insights_bmi_obesity_3;
}
String periodLabel(BodyPeriod period) {
switch (period) {
case BodyPeriod.month:
return s.insights_period_label_1;
case BodyPeriod.threeMonths:
return s.insights_period_label_3;
case BodyPeriod.sixMonths:
return s.insights_period_label_6;
case BodyPeriod.year:
return s.insights_period_label_12;
case BodyPeriod.all:
return s.insights_period_label_all;
}
}
String formatLabel(DateTime date) {
final local = date.toLocal();
final day = local.day.toString().padLeft(2, '0');
final month = local.month.toString().padLeft(2, '0');
return '$day.$month';
}
/// Одна цифра после запятой без хвостового нуля: `3`, `3.5`.
String formatNumber(double value) {
final text = value.toStringAsFixed(1);
return text.endsWith('.0') ? text.substring(0, text.length - 2) : text;
}
String _weightDeltaText(List<double> values, BodyPeriod period) {
if (values.length < 2) {
return s.insights_not_enough;
}
final diff = values.last - values.first;
final periodText = periodLabel(period);
if (diff.abs() < deltaEpsilon) {
return s.insights_weight_same(periodText);
}
final value = formatNumber(diff.abs());
return diff < 0
? s.insights_weight_lost(value, periodText)
: s.insights_weight_gained(value, periodText);
}
String _bmiDeltaText(
List<double> values,
BodyPeriod period,
String? category,
) {
if (values.length < 2 || category == null) {
return s.insights_not_enough;
}
final diff = values.last - values.first;
final periodText = periodLabel(period);
if (diff.abs() < deltaEpsilon) {
return s.insights_bmi_same(periodText, category);
}
final value = formatNumber(diff.abs());
return diff < 0
? s.insights_bmi_lower(value, periodText, category)
: s.insights_bmi_higher(value, periodText, category);
}
String _measureDeltaText({
required List<double> waist,
required List<double> chest,
required List<double> hips,
required BodyPeriod period,
}) {
if (waist.length < 2) {
return s.insights_not_enough;
}
final parts = [
_measurePart(s.insights_series_waist, waist),
_measurePart(s.insights_series_chest, chest),
_measurePart(s.insights_series_hips, hips),
];
return s.insights_measures_summary(parts.join(', '), periodLabel(period));
}
String _measurePart(String name, List<double> values) {
final diff = values.last - values.first;
if (diff.abs() < deltaEpsilon) {
return s.insights_measure_same(name);
}
final value = formatNumber(diff.abs());
return diff < 0
? s.insights_measure_less(name, value)
: s.insights_measure_more(name, value);
}
double _round(double value) => (value * 10).round() / 10;
}

View File

@@ -0,0 +1,6 @@
/// Флаги поэтапного включения аналитики.
abstract final class InsightsFeatureFlags {
/// Вкладки «КБЖУ по дням» и «В течение дня» требуют бэкенд-эндпоинта
/// `GET /api/profile/insights` и включаются во второй итерации.
static const bool kbjuEnabled = false;
}

View File

@@ -0,0 +1,132 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'insights_payload_model.freezed.dart';
part 'insights_payload_model.g.dart';
/// Структура повторяет веб-`UserInsightsService::chartPayload()`, чтобы во второй
/// итерации заменить только источник данных.
@freezed
abstract class InsightsPayloadModel with _$InsightsPayloadModel {
const factory InsightsPayloadModel({
required WeightChartModel weight,
required BmiChartModel bmi,
required MeasureChartModel measure,
DailyChartModel? daily,
IntraChartModel? intra,
}) = _InsightsPayloadModel;
factory InsightsPayloadModel.fromJson(Map<String, dynamic> json) =>
_$InsightsPayloadModelFromJson(json);
}
@freezed
abstract class WeightChartModel with _$WeightChartModel {
const factory WeightChartModel({
@Default([]) List<String> labels,
@Default([]) List<double> values,
double? last,
double? delta,
@Default('') String headline,
@Default('') String unit,
@Default('') String deltaText,
}) = _WeightChartModel;
factory WeightChartModel.fromJson(Map<String, dynamic> json) =>
_$WeightChartModelFromJson(json);
}
@freezed
abstract class BmiChartModel with _$BmiChartModel {
const factory BmiChartModel({
@Default([]) List<String> labels,
@Default([]) List<double> values,
double? last,
double? delta,
@Default('') String headline,
String? category,
@Default('') String deltaText,
@Default('') String hint,
}) = _BmiChartModel;
factory BmiChartModel.fromJson(Map<String, dynamic> json) =>
_$BmiChartModelFromJson(json);
}
@freezed
abstract class MeasureChartModel with _$MeasureChartModel {
const factory MeasureChartModel({
@Default([]) List<String> labels,
@Default([]) List<double> waist,
@Default([]) List<double> chest,
@Default([]) List<double> hips,
@Default('') String headline,
@Default('') String deltaText,
}) = _MeasureChartModel;
factory MeasureChartModel.fromJson(Map<String, dynamic> json) =>
_$MeasureChartModelFromJson(json);
}
@freezed
abstract class DailyChartModel with _$DailyChartModel {
const factory DailyChartModel({
@Default([]) List<String> labels,
@Default([]) List<double> kcal,
@Default([]) List<double> proteins,
@Default([]) List<double> fats,
@Default([]) List<double> carbs,
double? average,
double? percent,
@Default('') String headline,
@Default('') String deltaText,
}) = _DailyChartModel;
factory DailyChartModel.fromJson(Map<String, dynamic> json) =>
_$DailyChartModelFromJson(json);
}
@freezed
abstract class IntraChartModel with _$IntraChartModel {
const factory IntraChartModel({
@Default([]) List<IntraBarModel> bars,
@Default([]) List<ScalePointModel> satiety,
@Default([]) List<ScalePointModel> mood,
double? wake,
double? sleep,
String? wakeLabel,
String? sleepLabel,
String? wakeText,
String? sleepText,
@Default('') String date,
@Default('') String maxDate,
@Default('') String dateLabel,
@Default(false) bool isToday,
}) = _IntraChartModel;
factory IntraChartModel.fromJson(Map<String, dynamic> json) =>
_$IntraChartModelFromJson(json);
}
/// Приём пищи: время `x` (часы 0–24) и калории из белков/жиров/углеводов.
@freezed
abstract class IntraBarModel with _$IntraBarModel {
const factory IntraBarModel({
required double x,
@Default(0) double p,
@Default(0) double f,
@Default(0) double c,
}) = _IntraBarModel;
factory IntraBarModel.fromJson(Map<String, dynamic> json) =>
_$IntraBarModelFromJson(json);
}
/// Точка шкалы 0–5 (сытость/настроение) в момент времени `x`.
@freezed
abstract class ScalePointModel with _$ScalePointModel {
const factory ScalePointModel({required double x, required double y}) =
_ScalePointModel;
factory ScalePointModel.fromJson(Map<String, dynamic> json) =>
_$ScalePointModelFromJson(json);
}

View File

@@ -0,0 +1,26 @@
/// Период для вкладок «Вес», «ИМТ» и «Замеры». Значение совпадает с `{period}` в
/// `/api/profile/measure/weight/{period}`.
enum BodyPeriod {
month('1'),
threeMonths('3'),
sixMonths('6'),
year('12'),
all('all');
const BodyPeriod(this.apiValue);
final String apiValue;
}
/// Период для вкладки «КБЖУ по дням» (используется со второй итерации).
enum DailyPeriod {
week('7'),
twoWeeks('14'),
month('30'),
threeMonths('90'),
all('all');
const DailyPeriod(this.apiValue);
final String apiValue;
}

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),
),
],
),
);
}
}

View File

@@ -0,0 +1,184 @@
import 'dart:developer';
import 'package:eatvio_app/core/error_reporting/report_from.dart';
import 'package:eatvio_app/features/insights/insights_feature_flags.dart';
import 'package:eatvio_app/features/insights/models/insights_period.dart';
import 'package:eatvio_app/features/insights/presentation/cubit/insights_panel.dart';
import 'package:eatvio_app/features/insights/presentation/cubit/insights_state.dart';
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class InsightsCubit extends Cubit<InsightsState> {
InsightsCubit({
required this.repository,
this.slug,
this.canAdd = false,
this.kbjuEnabled = InsightsFeatureFlags.kbjuEnabled,
}) : super(const InsightsState.initial());
final InsightsRepository repository;
/// `null` — собственные данные, иначе slug владельца (консультант → клиент).
final String? slug;
/// Показывать ли кнопку «+» (только владелец).
final bool canAdd;
final bool kbjuEnabled;
bool _isHeightLoaded = false;
int? _heightCm;
int _requestId = 0;
List<InsightsPanel> get panels =>
InsightsPanel.available(kbjuEnabled: kbjuEnabled);
Future<void> load() async {
emit(const InsightsState.loading());
await _fetch(bodyPeriod: BodyPeriod.threeMonths, previous: null);
}
/// Перезапрашивает данные, сохраняя вкладку, период, серии и раскрытие.
Future<void> refresh() async {
final current = state;
if (current is! InsightsStateLoaded) {
return load();
}
emit(current.copyWith(isRefreshing: true));
await _fetch(bodyPeriod: current.bodyPeriod, previous: current);
}
Future<void> setBodyPeriod(BodyPeriod period) async {
final current = state;
if (current is! InsightsStateLoaded || current.bodyPeriod == period) {
return;
}
emit(current.copyWith(bodyPeriod: period, isRefreshing: true));
await _fetch(bodyPeriod: period, previous: current);
}
void toggleExpanded() {
final current = state;
if (current is InsightsStateLoaded) {
emit(current.copyWith(isExpanded: !current.isExpanded));
}
}
void setPanel(InsightsPanel panel) {
final current = state;
if (current is! InsightsStateLoaded || !panels.contains(panel)) {
return;
}
emit(current.copyWith(panel: panel));
}
/// Включает/выключает серию [index] на вкладке [panel].
void toggleSeries(InsightsPanel panel, int index) {
final current = state;
if (current is! InsightsStateLoaded) {
return;
}
final visibility = current.seriesVisibility;
final source = switch (panel) {
InsightsPanel.measures => visibility.measure,
InsightsPanel.daily => visibility.daily,
InsightsPanel.intra => visibility.intra,
_ => null,
};
if (source == null || index < 0 || index >= source.length) {
return;
}
final updated = [...source]..[index] = !source[index];
final nextVisibility = switch (panel) {
InsightsPanel.measures => visibility.copyWith(measure: updated),
InsightsPanel.daily => visibility.copyWith(daily: updated),
_ => visibility.copyWith(intra: updated),
};
emit(current.copyWith(seriesVisibility: nextVisibility));
}
void toggleSleep() {
final current = state;
if (current is InsightsStateLoaded) {
emit(current.copyWith(showSleep: !current.showSleep));
}
}
/// Сохраняет вес и обновляет данные. Возвращает `false`, если сохранить не удалось.
Future<bool> addWeight({required double weight}) {
return _add(() => repository.addWeight(weight: weight));
}
/// Сохраняет замеры и обновляет данные. Возвращает `false`, если сохранить не удалось.
Future<bool> addCircumference({
required int chestCircumference,
required int waistCircumference,
required int hipCircumference,
}) {
return _add(
() => repository.addCircumference(
chestCircumference: chestCircumference,
waistCircumference: waistCircumference,
hipCircumference: hipCircumference,
),
);
}
Future<bool> _add(Future<void> Function() action) async {
try {
await action();
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString(), stackTrace: st);
return false;
}
await refresh();
return true;
}
Future<void> _fetch({
required BodyPeriod bodyPeriod,
required InsightsStateLoaded? previous,
}) async {
final requestId = ++_requestId;
try {
if (!_isHeightLoaded) {
_heightCm = await repository.getHeightCm(slug: slug);
_isHeightLoaded = true;
}
final payload = await repository.getInsights(
slug: slug,
bodyPeriod: bodyPeriod,
heightCm: _heightCm,
);
if (requestId != _requestId || isClosed) {
return;
}
emit(
previous == null
? InsightsState.loaded(payload: payload, bodyPeriod: bodyPeriod)
: previous.copyWith(
payload: payload,
bodyPeriod: bodyPeriod,
isRefreshing: false,
),
);
} on InsightsForbidden {
if (requestId == _requestId && !isClosed) {
emit(const InsightsState.hidden());
}
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString(), stackTrace: st);
if (requestId != _requestId || isClosed) {
return;
}
// Ошибка перезапроса не должна стирать уже показанные данные.
emit(
previous == null
? InsightsState.error(message: e.toString())
: previous.copyWith(isRefreshing: false),
);
}
}
}

View File

@@ -0,0 +1,18 @@
/// Вкладки блока «Аналитика».
enum InsightsPanel {
weight,
bmi,
measures,
daily,
intra;
/// Вкладки КБЖУ доступны только при включённом флаге.
bool get isKbju => this == daily || this == intra;
/// Кнопка «+» есть только у вкладок с ручным вводом.
bool get supportsAdd => this == weight || this == measures;
static List<InsightsPanel> available({required bool kbjuEnabled}) {
return values.where((panel) => kbjuEnabled || !panel.isKbju).toList();
}
}

View File

@@ -0,0 +1,47 @@
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
import 'package:eatvio_app/features/insights/models/insights_period.dart';
import 'package:eatvio_app/features/insights/presentation/cubit/insights_panel.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'insights_state.freezed.dart';
/// Видимость серий (чипы под графиком): `true` — серия показана.
@freezed
abstract class InsightsSeriesVisibility with _$InsightsSeriesVisibility {
const factory InsightsSeriesVisibility({
/// Талия, Грудь, Бёдра.
@Default([true, true, true]) List<bool> measure,
/// Калории, Белки, Жиры, Углеводы.
@Default([true, true, true, true]) List<bool> daily,
/// Белки, Жиры, Углеводы, Сытость, Настроение.
@Default([true, true, true, true, true]) List<bool> intra,
}) = _InsightsSeriesVisibility;
}
@freezed
abstract class InsightsState with _$InsightsState {
const factory InsightsState.initial() = InsightsStateInitial;
const factory InsightsState.loading() = InsightsStateLoading;
/// 403: данные недоступны, блок не показывается.
const factory InsightsState.hidden() = InsightsStateHidden;
const factory InsightsState.error({required String message}) =
InsightsStateError;
const factory InsightsState.loaded({
required InsightsPayloadModel payload,
@Default(BodyPeriod.threeMonths) BodyPeriod bodyPeriod,
@Default(InsightsPanel.weight) InsightsPanel panel,
@Default(InsightsSeriesVisibility())
InsightsSeriesVisibility seriesVisibility,
@Default(true) bool showSleep,
@Default(false) bool isExpanded,
/// Идёт перезапрос (смена периода / обновление) — старые данные остаются на экране.
@Default(false) bool isRefreshing,
}) = InsightsStateLoaded;
}

View File

@@ -0,0 +1,175 @@
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
const int _minValue = 20;
const int _maxValue = 199;
/// Значения замеров в сантиметрах.
typedef CircumferenceValues = ({int chest, int waist, int hip});
/// Выбор замеров груди, талии и бёдер. [onSave] вызывается после закрытия шторки.
Future<void> showAddCircumferenceBottomSheet(
BuildContext context, {
CircumferenceValues? initial,
required Future<void> Function(CircumferenceValues values) onSave,
}) {
return showModalBottomSheet<void>(
useRootNavigator: true,
context: context,
builder: (_) =>
AddCircumferenceBottomSheet(initial: initial, onSave: onSave),
);
}
class AddCircumferenceBottomSheet extends StatefulWidget {
const AddCircumferenceBottomSheet({
super.key,
this.initial,
required this.onSave,
});
final CircumferenceValues? initial;
final Future<void> Function(CircumferenceValues values) onSave;
@override
State<AddCircumferenceBottomSheet> createState() =>
_AddCircumferenceBottomSheetState();
}
class _AddCircumferenceBottomSheetState
extends State<AddCircumferenceBottomSheet> {
late final FixedExtentScrollController _chestController;
late final FixedExtentScrollController _waistController;
late final FixedExtentScrollController _hipController;
late int _chest;
late int _waist;
late int _hip;
@override
void initState() {
super.initState();
_chest = _clamp(widget.initial?.chest);
_waist = _clamp(widget.initial?.waist);
_hip = _clamp(widget.initial?.hip);
_chestController = FixedExtentScrollController(
initialItem: _chest - _minValue,
);
_waistController = FixedExtentScrollController(
initialItem: _waist - _minValue,
);
_hipController = FixedExtentScrollController(initialItem: _hip - _minValue);
}
@override
void dispose() {
_chestController.dispose();
_waistController.dispose();
_hipController.dispose();
super.dispose();
}
int _clamp(int? value) => (value ?? _minValue).clamp(_minValue, _maxValue);
void _save() {
final values = (chest: _chest, waist: _waist, hip: _hip);
Navigator.of(context).pop();
widget.onSave(values);
}
@override
Widget build(BuildContext context) {
final s = S.of(context);
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Row(
children: [
for (final title in [
s.insights_series_chest,
s.insights_series_waist,
s.insights_series_hips,
])
Expanded(
child: Center(
child: Text(
title,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
),
],
),
),
SizedBox(
height: 200,
child: Row(
children: [
_picker(
const Key('add_circumference_chest_picker'),
_chestController,
(value) => _chest = value,
),
_picker(
const Key('add_circumference_waist_picker'),
_waistController,
(value) => _waist = value,
),
_picker(
const Key('add_circumference_hip_picker'),
_hipController,
(value) => _hip = value,
),
],
),
),
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: InkWell(
key: const Key('add_sheet_save'),
onTap: _save,
borderRadius: BorderRadius.circular(16),
child: Ink(
width: MediaQuery.of(context).size.width - 32,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: appTheme.blueA400,
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Text(
s.save,
style: const TextStyle(color: Colors.white),
),
),
),
),
),
],
),
);
}
Widget _picker(
Key key,
FixedExtentScrollController controller,
ValueChanged<int> onChanged,
) {
return Expanded(
child: CupertinoPicker(
key: key,
magnification: 1.22,
squeeze: 1.2,
useMagnifier: true,
scrollController: controller,
itemExtent: 24,
onSelectedItemChanged: (index) => onChanged(index + _minValue),
children: [for (int i = _minValue; i <= _maxValue; i++) Text('$i')],
),
);
}
}

View File

@@ -0,0 +1,147 @@
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
const int _minKilo = 20;
const int _maxKilo = 299;
/// Выбор веса (кг + десятые). [onSave] вызывается после закрытия шторки.
Future<void> showAddWeightBottomSheet(
BuildContext context, {
double? initialWeight,
required Future<void> Function(double weight) onSave,
}) {
return showModalBottomSheet<void>(
useRootNavigator: true,
context: context,
builder: (_) =>
AddWeightBottomSheet(initialWeight: initialWeight, onSave: onSave),
);
}
class AddWeightBottomSheet extends StatefulWidget {
const AddWeightBottomSheet({
super.key,
this.initialWeight,
required this.onSave,
});
final double? initialWeight;
final Future<void> Function(double weight) onSave;
@override
State<AddWeightBottomSheet> createState() => _AddWeightBottomSheetState();
}
class _AddWeightBottomSheetState extends State<AddWeightBottomSheet> {
late final FixedExtentScrollController _kiloController;
late final FixedExtentScrollController _gramsController;
late int _kilo;
late int _grams;
@override
void initState() {
super.initState();
final initial = widget.initialWeight;
final tenths = initial == null
? 0
: (initial * 10).round().clamp(_minKilo * 10, _maxKilo * 10 + 9);
_kilo = initial == null ? _minKilo : tenths ~/ 10;
_grams = initial == null ? 0 : tenths % 10;
_kiloController = FixedExtentScrollController(
initialItem: _kilo - _minKilo,
);
_gramsController = FixedExtentScrollController(initialItem: _grams);
}
@override
void dispose() {
_kiloController.dispose();
_gramsController.dispose();
super.dispose();
}
void _save() {
final weight = double.parse('$_kilo.$_grams');
Navigator.of(context).pop();
widget.onSave(weight);
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 200,
child: Row(
children: [
Expanded(
child: CupertinoPicker(
key: const Key('add_weight_kilo_picker'),
magnification: 1.22,
squeeze: 1.2,
useMagnifier: true,
scrollController: _kiloController,
itemExtent: 24,
onSelectedItemChanged: (value) => _kilo = value + _minKilo,
children: [
for (int i = _minKilo; i <= _maxKilo; i++) Text('$i'),
],
),
),
Expanded(
child: CupertinoPicker(
key: const Key('add_weight_grams_picker'),
magnification: 1.22,
squeeze: 1.2,
useMagnifier: true,
scrollController: _gramsController,
itemExtent: 24,
onSelectedItemChanged: (value) => _grams = value,
children: [for (int i = 0; i < 10; i++) Text('$i')],
),
),
],
),
),
_SaveButton(onTap: _save),
],
),
);
}
}
class _SaveButton extends StatelessWidget {
const _SaveButton({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: InkWell(
key: const Key('add_sheet_save'),
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Ink(
width: MediaQuery.of(context).size.width - 32,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: appTheme.blueA400,
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Text(
S.of(context).save,
style: const TextStyle(color: Colors.white),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,593 @@
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/features/insights/insights_feature_flags.dart';
import 'package:eatvio_app/features/insights/models/insights_period.dart';
import 'package:eatvio_app/features/insights/presentation/charts/bmi_chart.dart';
import 'package:eatvio_app/features/insights/presentation/charts/daily_kbju_chart.dart';
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
import 'package:eatvio_app/features/insights/presentation/charts/intra_day_chart.dart';
import 'package:eatvio_app/features/insights/presentation/charts/measure_chart.dart';
import 'package:eatvio_app/features/insights/presentation/charts/weight_chart.dart';
import 'package:eatvio_app/features/insights/presentation/cubit/insights_cubit.dart';
import 'package:eatvio_app/features/insights/presentation/cubit/insights_panel.dart';
import 'package:eatvio_app/features/insights/presentation/cubit/insights_state.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/add_circumference_bottom_sheet.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/add_weight_bottom_sheet.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/insights_fab.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/insights_head.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/insights_tabs.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/intra_date_nav.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/intra_legend.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/period_pills.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/series_chips.dart';
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
/// Сворачиваемый блок «Аналитика» (вес, ИМТ, замеры).
///
/// [slug] — владелец данных (`null` — текущий пользователь). При 403 блок
/// скрывается целиком. [canAdd] включает кнопку «+» на вкладках «Вес» и «Замеры».
class InsightsCard extends StatelessWidget {
const InsightsCard({
super.key,
this.slug,
this.canAdd = false,
this.repository,
this.kbjuEnabled = InsightsFeatureFlags.kbjuEnabled,
});
final String? slug;
final bool canAdd;
final InsightsRepository? repository;
final bool kbjuEnabled;
@override
Widget build(BuildContext context) {
return KeyedSubtree(
key: ValueKey('insights_card_$slug'),
child: BlocProvider<InsightsCubit>(
create: (_) => InsightsCubit(
repository: repository ?? sl<InsightsRepository>(),
slug: slug,
canAdd: canAdd,
kbjuEnabled: kbjuEnabled,
)..load(),
child: const _InsightsView(),
),
);
}
}
class _InsightsView extends StatelessWidget {
const _InsightsView();
@override
Widget build(BuildContext context) {
return BlocBuilder<InsightsCubit, InsightsState>(
builder: (context, state) {
return switch (state) {
InsightsStateLoaded() => _CardShell(
child: _LoadedContent(state: state),
),
InsightsStateError() => const _CardShell(child: _ErrorContent()),
_ => const SizedBox.shrink(),
};
},
);
}
}
class _CardShell extends StatelessWidget {
const _CardShell({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
key: const Key('insights_card'),
width: double.infinity,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: const [
BoxShadow(
color: InsightsColors.cardShadow,
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
child: child,
);
}
}
class _ErrorContent extends StatelessWidget {
const _ErrorContent();
@override
Widget build(BuildContext context) {
final s = S.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
Expanded(
child: Text(
s.insights_load_error,
style: const TextStyle(
fontSize: 14,
color: InsightsColors.subtitle,
),
),
),
TextButton(
key: const Key('insights_retry'),
onPressed: () => context.read<InsightsCubit>().load(),
child: Text(s.insights_retry),
),
],
),
);
}
}
class _LoadedContent extends StatelessWidget {
const _LoadedContent({required this.state});
final InsightsStateLoaded state;
@override
Widget build(BuildContext context) {
final cubit = context.read<InsightsCubit>();
final showFab = cubit.canAdd && state.panel.supportsAdd;
return Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Toggle(isExpanded: state.isExpanded, onTap: cubit.toggleExpanded),
AnimatedSize(
duration: const Duration(milliseconds: 200),
alignment: Alignment.topCenter,
child: state.isExpanded
? Padding(
padding: EdgeInsets.fromLTRB(
20,
0,
20,
showFab ? 24 + 44 : 24,
),
child: _Body(state: state),
)
: const SizedBox(width: double.infinity),
),
],
),
if (showFab && state.isExpanded)
Positioned(
right: 16,
bottom: 16,
child: InsightsFab(
tooltip: S.of(context).insights_add_record,
onPressed: () => _onAddPressed(context, cubit, state),
),
),
],
);
}
void _onAddPressed(
BuildContext context,
InsightsCubit cubit,
InsightsStateLoaded state,
) {
final s = S.of(context);
if (state.panel == InsightsPanel.weight) {
showAddWeightBottomSheet(
context,
initialWeight: state.payload.weight.last,
onSave: (weight) async {
if (!await cubit.addWeight(weight: weight)) {
showErrorToast(s.insights_save_error);
}
},
);
} else if (state.panel == InsightsPanel.measures) {
final measure = state.payload.measure;
showAddCircumferenceBottomSheet(
context,
initial:
measure.waist.isEmpty ||
measure.chest.isEmpty ||
measure.hips.isEmpty
? null
: (
chest: measure.chest.last.round(),
waist: measure.waist.last.round(),
hip: measure.hips.last.round(),
),
onSave: (values) async {
final isSaved = await cubit.addCircumference(
chestCircumference: values.chest,
waistCircumference: values.waist,
hipCircumference: values.hip,
);
if (!isSaved) {
showErrorToast(s.insights_save_error);
}
},
);
}
}
}
class _Toggle extends StatelessWidget {
const _Toggle({required this.isExpanded, required this.onTap});
final bool isExpanded;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final s = S.of(context);
return InkWell(
key: const Key('insights_toggle'),
borderRadius: BorderRadius.circular(8),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: InsightsColors.accentBackground,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.show_chart_rounded,
size: 20,
color: InsightsColors.blue,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
s.insights_title,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: InsightsColors.title,
),
),
const SizedBox(height: 2),
Text(
key: const Key('insights_subtitle'),
isExpanded
? s.insights_subtitle_open
: s.insights_subtitle_closed,
style: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: InsightsColors.subtitle,
),
),
],
),
),
AnimatedRotation(
turns: isExpanded ? 0 : 0.5,
duration: const Duration(milliseconds: 200),
child: const Icon(
Icons.keyboard_arrow_up_rounded,
size: 26,
color: InsightsColors.blue,
),
),
],
),
),
);
}
}
class _Body extends StatelessWidget {
const _Body({required this.state});
final InsightsStateLoaded state;
@override
Widget build(BuildContext context) {
final cubit = context.read<InsightsCubit>();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InsightsTabs(
panels: cubit.panels,
selected: state.panel,
onSelected: cubit.setPanel,
),
const SizedBox(height: 12),
AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: state.isRefreshing ? 0.5 : 1,
child: _PanelContent(state: state, canAdd: cubit.canAdd),
),
],
);
}
}
class _PanelContent extends StatelessWidget {
const _PanelContent({required this.state, required this.canAdd});
final InsightsStateLoaded state;
final bool canAdd;
@override
Widget build(BuildContext context) {
final cubit = context.read<InsightsCubit>();
final s = S.of(context);
final payload = state.payload;
final Widget content;
switch (state.panel) {
case InsightsPanel.weight:
content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InsightsHead(
headline: payload.weight.headline,
unit: payload.weight.unit,
deltaText: payload.weight.deltaText,
isDeltaHighlighted: payload.weight.delta != null,
),
const SizedBox(height: 12),
WeightChart(
model: payload.weight,
emptyText: canAdd
? s.insights_add_weight_empty
: s.insights_not_enough,
),
],
);
case InsightsPanel.bmi:
content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (payload.bmi.values.isNotEmpty) ...[
InsightsHead(
headline: payload.bmi.headline,
unit: s.insights_unit_bmi,
deltaText: payload.bmi.deltaText,
isDeltaHighlighted: payload.bmi.delta != null,
),
const SizedBox(height: 12),
],
BmiChart(model: payload.bmi),
],
);
case InsightsPanel.measures:
final visibility = state.seriesVisibility.measure;
content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InsightsHead(
headline: payload.measure.headline,
unit: s.insights_unit_cm_waist,
deltaText: payload.measure.deltaText,
isDeltaHighlighted: payload.measure.waist.length > 1,
),
const SizedBox(height: 12),
SeriesChips(
chips: [
SeriesChipData(
label: s.insights_series_waist,
color: InsightsColors.waist,
isVisible: visibility[0],
),
SeriesChipData(
label: s.insights_series_chest,
color: InsightsColors.chest,
isVisible: visibility[1],
),
SeriesChipData(
label: s.insights_series_hips,
color: InsightsColors.hips,
isVisible: visibility[2],
),
],
onToggle: (index) =>
cubit.toggleSeries(InsightsPanel.measures, index),
),
const SizedBox(height: 12),
MeasureChart(
model: payload.measure,
visibility: visibility,
emptyText: canAdd
? s.insights_add_measures_empty
: s.insights_not_enough,
),
],
);
case InsightsPanel.daily:
return _DailyPanel(state: state);
case InsightsPanel.intra:
return _IntraPanel(state: state);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PeriodPills<BodyPeriod>(
periods: BodyPeriod.values,
selected: state.bodyPeriod,
labelOf: (period) => period.pillLabel(s),
keyOf: (period) => period.apiValue,
onSelected: cubit.setBodyPeriod,
),
const SizedBox(height: 16),
content,
],
);
}
}
/// «КБЖУ по дням». Период пока фиксирован: перезапрос появится вместе с
/// эндпоинтом `GET /api/profile/insights` (итерация 2).
class _DailyPanel extends StatelessWidget {
const _DailyPanel({required this.state});
final InsightsStateLoaded state;
@override
Widget build(BuildContext context) {
final s = S.of(context);
final cubit = context.read<InsightsCubit>();
final daily = state.payload.daily;
final visibility = state.seriesVisibility.daily;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PeriodPills<DailyPeriod>(
periods: DailyPeriod.values,
selected: DailyPeriod.twoWeeks,
labelOf: (period) => period.pillLabel(s),
keyOf: (period) => period.apiValue,
),
const SizedBox(height: 16),
if (daily == null)
InsightsChartPlaceholder(text: s.insights_not_enough)
else ...[
InsightsHead(
headline: daily.headline,
unit: s.insights_unit_kcal_avg,
deltaText: daily.deltaText,
isDeltaHighlighted: daily.percent != null,
),
const SizedBox(height: 12),
SeriesChips(
chips: [
SeriesChipData(
label: s.insights_series_calories,
color: InsightsColors.calories,
isVisible: visibility[0],
),
SeriesChipData(
label: s.insights_series_proteins,
color: InsightsColors.proteins,
isVisible: visibility[1],
),
SeriesChipData(
label: s.insights_series_fats,
color: InsightsColors.fats,
isVisible: visibility[2],
),
SeriesChipData(
label: s.insights_series_carbs,
color: InsightsColors.carbs,
isVisible: visibility[3],
),
],
onToggle: (index) => cubit.toggleSeries(InsightsPanel.daily, index),
),
const SizedBox(height: 12),
DailyKbjuChart(model: daily, visibility: visibility),
],
],
);
}
}
/// «В течение дня». Навигация по датам неактивна до итерации 2.
class _IntraPanel extends StatelessWidget {
const _IntraPanel({required this.state});
final InsightsStateLoaded state;
@override
Widget build(BuildContext context) {
final s = S.of(context);
final cubit = context.read<InsightsCubit>();
final intra = state.payload.intra;
final visibility = state.seriesVisibility.intra;
if (intra == null) {
return InsightsChartPlaceholder(text: s.insights_not_enough);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IntraDateNav(model: intra),
const SizedBox(height: 16),
SeriesChips(
chips: [
SeriesChipData(
label: s.insights_series_proteins,
color: InsightsColors.proteins,
isVisible: visibility[0],
),
SeriesChipData(
label: s.insights_series_fats,
color: InsightsColors.fats,
isVisible: visibility[1],
),
SeriesChipData(
label: s.insights_series_carbs,
color: InsightsColors.carbs,
isVisible: visibility[2],
),
SeriesChipData(
label: s.insights_series_satiety,
color: InsightsColors.satiety,
isVisible: visibility[3],
),
SeriesChipData(
label: s.insights_series_mood,
color: InsightsColors.mood,
isVisible: visibility[4],
),
SeriesChipData(
label: s.insights_series_sleep,
color: InsightsColors.sleepLine,
isVisible: state.showSleep,
),
],
onToggle: (index) {
if (index == 5) {
cubit.toggleSleep();
} else {
cubit.toggleSeries(InsightsPanel.intra, index);
}
},
),
const SizedBox(height: 12),
IntraDayChart(
model: intra,
visibility: visibility,
showSleep: state.showSleep,
),
const SizedBox(height: 8),
IntraLegend(model: intra),
const SizedBox(height: 8),
Text(
s.insights_intra_hint,
key: const Key('insights_intra_hint'),
style: const TextStyle(fontSize: 13, color: InsightsColors.subtitle),
),
],
);
}
}

View File

@@ -0,0 +1,36 @@
import 'package:flutter/painting.dart';
/// Токены из `latest-tmpls/src/scss/pages/diary.scss` (`&__analytics*`).
abstract final class InsightsColors {
static const Color blue = Color(0xFF2F80ED);
static const Color accentBackground = Color(0xFFF6FAFF);
static const Color title = Color(0xFF323F4B);
static const Color subtitle = Color(0xFF7B8794);
static const Color border = Color(0xFFE6E6E6);
static const Color positive = Color(0xFF019A3E);
static const Color calories = Color(0xFF323F4B);
static const Color proteins = Color(0xFF2F80ED);
static const Color fats = Color(0xFFF2994A);
static const Color carbs = Color(0xFF0D6EFD);
static const Color satiety = Color(0xFF019A3E);
static const Color mood = Color(0xFFEB555C);
static const Color waist = Color(0xFF2F80ED);
static const Color chest = Color(0xFF9B51E0);
static const Color hips = Color(0xFFF2994A);
static const Color wake = Color(0xFFF2994A);
static const Color sleepLine = Color(0xFF7B8794);
static const Color sleepShade = Color(0x1A7B8794);
static const Color bmiNormalZone = Color(0x14019A3E);
static const Color bmiOverweightZone = Color(0x1AF2994A);
static const Color bmiObesityZone = Color(0x14EB555C);
static const Color cardShadow = Color(0x26000000);
static const Color fabShadow = Color(0x592F80ED);
}
/// Высота области графика.
const double insightsChartHeight = 270;

View File

@@ -0,0 +1,42 @@
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
import 'package:flutter/material.dart';
/// Круглая кнопка «+» (52px) справа-снизу карточки.
class InsightsFab extends StatelessWidget {
const InsightsFab({super.key, required this.onPressed, this.tooltip});
final VoidCallback onPressed;
final String? tooltip;
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip ?? '',
child: Container(
width: 52,
height: 52,
decoration: const BoxDecoration(
color: InsightsColors.blue,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: InsightsColors.fabShadow,
blurRadius: 20,
offset: Offset(0, 8),
),
],
),
child: Material(
color: Colors.transparent,
shape: const CircleBorder(),
child: InkWell(
key: const Key('insights_fab'),
customBorder: const CircleBorder(),
onTap: onPressed,
child: const Icon(Icons.add, color: Colors.white, size: 28),
),
),
),
);
}
}

View File

@@ -0,0 +1,70 @@
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
import 'package:flutter/material.dart';
/// Крупное число + единица + строка с дельтой.
class InsightsHead extends StatelessWidget {
const InsightsHead({
super.key,
required this.headline,
required this.unit,
required this.deltaText,
this.isDeltaHighlighted = true,
});
final String headline;
final String unit;
final String deltaText;
/// Зелёная дельта при наличии изменения, иначе нейтральный серый.
final bool isDeltaHighlighted;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
headline,
key: const Key('insights_headline'),
style: const TextStyle(
fontSize: 34,
fontWeight: FontWeight.w800,
color: InsightsColors.title,
height: 1.1,
),
),
if (unit.isNotEmpty) ...[
const SizedBox(width: 6),
Text(
unit,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: InsightsColors.subtitle,
),
),
],
],
),
if (deltaText.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
deltaText,
key: const Key('insights_delta'),
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w700,
color: isDeltaHighlighted
? InsightsColors.positive
: InsightsColors.subtitle,
),
),
],
],
);
}
}

View File

@@ -0,0 +1,80 @@
import 'package:eatvio_app/features/insights/presentation/cubit/insights_panel.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
extension InsightsPanelLabel on InsightsPanel {
String label(S s) {
switch (this) {
case InsightsPanel.weight:
return s.insights_tab_weight;
case InsightsPanel.bmi:
return s.insights_tab_bmi;
case InsightsPanel.measures:
return s.insights_tab_measures;
case InsightsPanel.daily:
return s.insights_tab_daily;
case InsightsPanel.intra:
return s.insights_tab_intra;
}
}
}
/// Горизонтально прокручиваемые вкладки (`l-tab`).
class InsightsTabs extends StatelessWidget {
const InsightsTabs({
super.key,
required this.panels,
required this.selected,
required this.onSelected,
});
final List<InsightsPanel> panels;
final InsightsPanel selected;
final ValueChanged<InsightsPanel> onSelected;
@override
Widget build(BuildContext context) {
final s = S.of(context);
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final panel in panels)
InkWell(
key: Key('insights_tab_${panel.name}'),
onTap: () => onSelected(panel),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: panel == selected
? InsightsColors.blue
: Colors.transparent,
width: 2,
),
),
),
child: Text(
panel.label(s),
style: TextStyle(
fontSize: 13,
fontWeight: panel == selected
? FontWeight.w700
: FontWeight.w600,
color: panel == selected
? InsightsColors.blue
: InsightsColors.subtitle,
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,130 @@
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
/// Навигация по дням для вкладки «В течение дня»: `‹ дата ›` и выбор даты.
///
/// Колбэки `null` — навигация неактивна (данные других дней ещё не подключены).
class IntraDateNav extends StatelessWidget {
const IntraDateNav({
super.key,
required this.model,
this.onPreviousDay,
this.onNextDay,
this.onDatePicked,
});
final IntraChartModel model;
final VoidCallback? onPreviousDay;
final VoidCallback? onNextDay;
final ValueChanged<DateTime>? onDatePicked;
@override
Widget build(BuildContext context) {
final s = S.of(context);
final canGoNext = !model.isToday && onNextDay != null;
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: InsightsColors.accentBackground,
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_NavButton(
key: const Key('insights_intra_prev'),
icon: Icons.chevron_left_rounded,
tooltip: s.insights_prev_day,
onPressed: onPreviousDay,
),
InkWell(
key: const Key('insights_intra_pick'),
borderRadius: BorderRadius.circular(999),
onTap: onDatePicked == null ? null : () => _pickDate(context),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 72),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.calendar_today_rounded,
size: 14,
color: InsightsColors.blue,
),
const SizedBox(width: 6),
Text(
model.dateLabel,
key: const Key('insights_intra_date_label'),
style: const TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w700,
color: InsightsColors.title,
),
),
],
),
),
),
),
_NavButton(
key: const Key('insights_intra_next'),
icon: Icons.chevron_right_rounded,
tooltip: s.insights_next_day,
onPressed: canGoNext ? onNextDay : null,
),
],
),
);
}
Future<void> _pickDate(BuildContext context) async {
final lastDate = DateTime.tryParse(model.maxDate) ?? DateTime.now();
final current = DateTime.tryParse(model.date) ?? lastDate;
final picked = await showDatePicker(
context: context,
initialDate: current.isAfter(lastDate) ? lastDate : current,
firstDate: DateTime(2000),
lastDate: lastDate,
helpText: S.of(context).insights_pick_date,
);
if (picked != null) {
onDatePicked?.call(picked);
}
}
}
class _NavButton extends StatelessWidget {
const _NavButton({
super.key,
required this.icon,
required this.tooltip,
required this.onPressed,
});
final IconData icon;
final String tooltip;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return Opacity(
opacity: onPressed == null ? 0.35 : 1,
child: SizedBox(
width: 36,
height: 36,
child: IconButton(
padding: EdgeInsets.zero,
tooltip: tooltip,
onPressed: onPressed,
icon: Icon(icon, color: InsightsColors.blue),
),
),
);
}
}

View File

@@ -0,0 +1,96 @@
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
/// Легенда под графиком «В течение дня»: подъём, отход ко сну, затемнение сна.
class IntraLegend extends StatelessWidget {
const IntraLegend({super.key, required this.model});
final IntraChartModel model;
@override
Widget build(BuildContext context) {
final s = S.of(context);
final wakeText =
model.wakeText ??
(model.wakeLabel == null ? null : s.insights_wake_at(model.wakeLabel!));
final sleepText =
model.sleepText ??
(model.sleepLabel == null
? null
: s.insights_sleep_at(model.sleepLabel!));
return Wrap(
spacing: 16,
runSpacing: 6,
children: [
if (wakeText != null)
_LegendItem(
key: const Key('insights_legend_wake'),
color: InsightsColors.wake,
text: wakeText,
isDashed: true,
),
if (sleepText != null)
_LegendItem(
key: const Key('insights_legend_sleep'),
color: InsightsColors.sleepLine,
text: sleepText,
isDashed: true,
),
_LegendItem(
key: const Key('insights_legend_shade'),
color: InsightsColors.sleepShade,
text: s.insights_sleep_shade,
),
],
);
}
}
class _LegendItem extends StatelessWidget {
const _LegendItem({
super.key,
required this.color,
required this.text,
this.isDashed = false,
});
final Color color;
final String text;
final bool isDashed;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
isDashed
? Container(
width: 18,
height: 0,
decoration: BoxDecoration(
border: Border(top: BorderSide(color: color, width: 2)),
),
)
: Container(
width: 18,
height: 10,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 6),
Text(
text,
style: const TextStyle(
fontSize: 12.5,
color: InsightsColors.subtitle,
),
),
],
);
}
}

View File

@@ -0,0 +1,115 @@
import 'package:eatvio_app/features/insights/models/insights_period.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
extension BodyPeriodLabel on BodyPeriod {
String pillLabel(S s) {
switch (this) {
case BodyPeriod.month:
return s.insights_period_1m;
case BodyPeriod.threeMonths:
return s.insights_period_3m;
case BodyPeriod.sixMonths:
return s.insights_period_6m;
case BodyPeriod.year:
return s.insights_period_1y;
case BodyPeriod.all:
return s.insights_period_all;
}
}
}
extension DailyPeriodLabel on DailyPeriod {
String pillLabel(S s) {
switch (this) {
case DailyPeriod.week:
return s.insights_period_1w;
case DailyPeriod.twoWeeks:
return s.insights_period_2w;
case DailyPeriod.month:
return s.insights_period_1m;
case DailyPeriod.threeMonths:
return s.insights_period_3m;
case DailyPeriod.all:
return s.insights_period_all;
}
}
}
/// Пилюли выбора периода (`1М 3М 6М 1Г Всё время`).
class PeriodPills<T> extends StatelessWidget {
const PeriodPills({
super.key,
required this.periods,
required this.selected,
required this.labelOf,
required this.keyOf,
this.onSelected,
});
final List<T> periods;
final T selected;
final String Function(T period) labelOf;
final String Function(T period) keyOf;
/// `null` — пилюли неинтерактивны (данные для периода ещё не подключены).
final ValueChanged<T>? onSelected;
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final period in periods)
Padding(
padding: const EdgeInsets.only(right: 6),
child: _Pill(
key: Key('insights_period_${keyOf(period)}'),
label: labelOf(period),
isActive: period == selected,
onTap: onSelected == null ? null : () => onSelected!(period),
),
),
],
),
);
}
}
class _Pill extends StatelessWidget {
const _Pill({
super.key,
required this.label,
required this.isActive,
required this.onTap,
});
final String label;
final bool isActive;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Material(
color: isActive ? InsightsColors.blue : InsightsColors.accentBackground,
borderRadius: BorderRadius.circular(999),
child: InkWell(
borderRadius: BorderRadius.circular(999),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 7),
child: Text(
label,
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
color: isActive ? Colors.white : InsightsColors.subtitle,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,76 @@
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
import 'package:flutter/material.dart';
class SeriesChipData {
const SeriesChipData({
required this.label,
required this.color,
required this.isVisible,
});
final String label;
final Color color;
final bool isVisible;
}
/// Чипы-переключатели серий под графиком.
class SeriesChips extends StatelessWidget {
const SeriesChips({super.key, required this.chips, required this.onToggle});
final List<SeriesChipData> chips;
final ValueChanged<int> onToggle;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (var i = 0; i < chips.length; i++)
Opacity(
opacity: chips[i].isVisible ? 1 : 0.45,
child: InkWell(
key: Key('insights_series_$i'),
borderRadius: BorderRadius.circular(999),
onTap: () => onToggle(i),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 7,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
border: Border.all(color: InsightsColors.border, width: 1.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: chips[i].color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(
chips[i].label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: InsightsColors.title,
decoration: chips[i].isVisible
? TextDecoration.none
: TextDecoration.lineThrough,
),
),
],
),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,94 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/features/insights/domain/insights_text_builder.dart';
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
import 'package:eatvio_app/features/insights/models/insights_period.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:eatvio_app/generated/l10n.dart';
/// Данные аналитики недоступны текущему пользователю (HTTP 403): консультация
/// не активна либо это чужой профиль без доступа.
class InsightsForbidden implements Exception {
const InsightsForbidden();
@override
String toString() => 'InsightsForbidden';
}
typedef InsightsTextBuilderFactory = InsightsTextBuilder Function();
/// Итерация 1: собирает [InsightsPayloadModel] из существующих эндпоинтов
/// профиля. Во второй итерации источник заменяется одним запросом
/// `GET /api/profile/insights`.
class InsightsRepository {
InsightsRepository({
required this.profileRepository,
InsightsTextBuilderFactory? textBuilderFactory,
}) : _textBuilderFactory =
textBuilderFactory ?? (() => InsightsTextBuilder(S.current));
final ProfileRepository profileRepository;
final InsightsTextBuilderFactory _textBuilderFactory;
/// Рост в сантиметрах для расчёта подсказки ИМТ; `null`, если не указан или
/// профиль не удалось загрузить (кроме 403 — он пробрасывается как [InsightsForbidden]).
Future<int?> getHeightCm({String? slug}) async {
try {
final details = slug == null
? await profileRepository.getMyProfileDetails()
: await profileRepository.getProfileDetailsBySlug(slug);
return details.height > 0 ? details.height : null;
} on DioException catch (e) {
_throwIfForbidden(e);
return null;
} catch (_) {
return null;
}
}
Future<InsightsPayloadModel> getInsights({
String? slug,
required BodyPeriod bodyPeriod,
int? heightCm,
}) async {
try {
final (weights, circumferences) = await (
profileRepository.getWeight(period: bodyPeriod.apiValue, slug: slug),
profileRepository.getCircumferences(
period: bodyPeriod.apiValue,
slug: slug,
),
).wait;
return _textBuilderFactory().build(
weights: weights,
circumferences: circumferences,
period: bodyPeriod,
heightCm: heightCm,
);
} on DioException catch (e) {
_throwIfForbidden(e);
rethrow;
}
}
Future<void> addWeight({required double weight}) {
return profileRepository.addWeight(weight: weight);
}
Future<void> addCircumference({
required int chestCircumference,
required int waistCircumference,
required int hipCircumference,
}) {
return profileRepository.addCircumference(
chestCircumference: chestCircumference,
waistCircumference: waistCircumference,
hipCircumference: hipCircumference,
);
}
void _throwIfForbidden(DioException e) {
if (e.response?.statusCode == 403) {
throw const InsightsForbidden();
}
}
}