forked from eatvio/eatvio_app
223 lines
7.1 KiB
Dart
223 lines
7.1 KiB
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/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;
|
||
}
|