forked from eatvio/eatvio_app
feat(insights): first isights impemetation
This commit is contained in:
222
lib/features/insights/domain/insights_text_builder.dart
Normal file
222
lib/features/insights/domain/insights_text_builder.dart
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user