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 weights, required List 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 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 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 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 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 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 waist, required List chest, required List 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 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; }