diff --git a/lib/core/di/repository_registrations.dart b/lib/core/di/repository_registrations.dart index bb260432..a494bb8d 100644 --- a/lib/core/di/repository_registrations.dart +++ b/lib/core/di/repository_registrations.dart @@ -18,6 +18,7 @@ import 'package:eatvio_app/features/diets/repositories/diet_repository.dart'; import 'package:eatvio_app/features/feed/repositories/feed_repository.dart'; import 'package:eatvio_app/features/glossary/repositories/glossary_repository.dart'; import 'package:eatvio_app/features/barcode/repositories/barcode_repository.dart'; +import 'package:eatvio_app/features/insights/repositories/insights_repository.dart'; import 'package:eatvio_app/features/menu/presentation/repositories/blocked_users_repository.dart'; import 'package:eatvio_app/features/menu/repositories/notification_settings_repository.dart'; import 'package:eatvio_app/features/my_diets/repositories/my_diets_repository.dart'; @@ -55,6 +56,7 @@ class RepositoryRegistrations { sl.registerLazySingleton(() => VideoCallRepository(videoCallService: sl())); sl.registerLazySingleton(() => FeedRepository(feedService: sl())); sl.registerLazySingleton(() => ProfileRepository(profileService: sl())); + sl.registerLazySingleton(() => InsightsRepository(profileRepository: sl())); sl.registerLazySingleton(() => CalendarRepository(calendarService: sl())); sl.registerLazySingleton(() => SettingsRepository(settingsService: sl())); sl.registerLazySingleton(() => ConsultantRepository(consultantService: sl())); diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index 96f5f2cb..7142360b 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -354,9 +354,10 @@ class OtherRoutes { path: UserDiaryScreen.path, name: UserDiaryScreen.name, builder: (context, state) { - final data = state.extra as Map; + final data = state.extra as Map; final diaryId = data['diaryId'] as int; - return UserDiaryScreen(diaryId: diaryId); + final ownerSlug = data['ownerSlug'] as String?; + return UserDiaryScreen(diaryId: diaryId, ownerSlug: ownerSlug); }, ), GoRoute( diff --git a/lib/features/consultations/presentation/widgets/consultation_card_widget.dart b/lib/features/consultations/presentation/widgets/consultation_card_widget.dart index 380ad7a9..e1a6da0a 100644 --- a/lib/features/consultations/presentation/widgets/consultation_card_widget.dart +++ b/lib/features/consultations/presentation/widgets/consultation_card_widget.dart @@ -176,7 +176,7 @@ class _ConsultationCardWidgetState extends State { ); return; } - GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': day.$2.id,}); + GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': day.$2.id, 'ownerSlug': widget.consultationsModel.userSlug}); }, borderRadius: BorderRadius.circular(100), child: Ink( @@ -259,7 +259,7 @@ class _ConsultationCardWidgetState extends State { .where((e) => e.$2.state == DiaryStateEnum.finished || e.$2.state == DiaryStateEnum.lastDayFinished) .lastOrNull; if (lastActiveDay != null) { - GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': lastActiveDay.$2.id,}); + GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': lastActiveDay.$2.id, 'ownerSlug': widget.consultationsModel.userSlug}); return; } showErrorDialog( diff --git a/lib/features/diary/presentation/pages/diary_screen.dart b/lib/features/diary/presentation/pages/diary_screen.dart index b91bcba7..d6ec5f55 100644 --- a/lib/features/diary/presentation/pages/diary_screen.dart +++ b/lib/features/diary/presentation/pages/diary_screen.dart @@ -20,6 +20,7 @@ import 'package:eatvio_app/features/diary/presentation/widgets/day_comment_widge import 'package:eatvio_app/features/diary/presentation/widgets/finish_day_button_widget.dart'; import 'package:eatvio_app/features/diary/presentation/widgets/meal_widget.dart'; import 'package:eatvio_app/features/diary/presentation/widgets/wakeup_and_retire_widget.dart'; +import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -36,18 +37,22 @@ class DiaryScreen extends StatelessWidget { class UserDiaryScreen extends StatelessWidget { static const path = '/user-diary-screen'; static const name = 'userDiaryScreen'; - const UserDiaryScreen({super.key, required this.diaryId}); + const UserDiaryScreen({super.key, required this.diaryId, this.ownerSlug}); final int diaryId; + + /// Slug владельца дневника (клиента) — нужен консультанту для блока «Аналитика». + final String? ownerSlug; @override Widget build(BuildContext context) { - return DiaryScreenContent(diaryId: diaryId); + return DiaryScreenContent(diaryId: diaryId, ownerSlug: ownerSlug); } } class DiaryScreenContent extends StatefulWidget { - const DiaryScreenContent({super.key, this.diaryId, this.date}); + const DiaryScreenContent({super.key, this.diaryId, this.date, this.ownerSlug}); final int? diaryId; final DateTime? date; + final String? ownerSlug; @override State createState() => _DiaryScreenContentState(); } @@ -55,6 +60,11 @@ class DiaryScreenContent extends StatefulWidget { class _DiaryScreenContentState extends State { bool _isDiaryFinishing = false; + /// Консультант видит аналитику клиента только при известном slug владельца. + bool _canShowInsights(DiaryStateLoaded state) { + return !state.isConsultantView || widget.ownerSlug != null; + } + @override Widget build(BuildContext context) { final s = S.of(context); @@ -110,6 +120,14 @@ class _DiaryScreenContentState extends State { const SizedBox(height: 60), const Loader(), ], + if (state is DiaryStateLoaded && _canShowInsights(state)) + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: InsightsCard( + slug: state.isConsultantView ? widget.ownerSlug : null, + canAdd: !state.isConsultantView, + ), + ), if (state is DiaryStateLoaded) WakeupAndRetireWidget( isEnabled: diff --git a/lib/features/insights/domain/insights_text_builder.dart b/lib/features/insights/domain/insights_text_builder.dart new file mode 100644 index 00000000..f6673022 --- /dev/null +++ b/lib/features/insights/domain/insights_text_builder.dart @@ -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 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; +} diff --git a/lib/features/insights/insights_feature_flags.dart b/lib/features/insights/insights_feature_flags.dart new file mode 100644 index 00000000..e3f50237 --- /dev/null +++ b/lib/features/insights/insights_feature_flags.dart @@ -0,0 +1,6 @@ +/// Флаги поэтапного включения аналитики. +abstract final class InsightsFeatureFlags { + /// Вкладки «КБЖУ по дням» и «В течение дня» требуют бэкенд-эндпоинта + /// `GET /api/profile/insights` и включаются во второй итерации. + static const bool kbjuEnabled = false; +} diff --git a/lib/features/insights/models/insights_payload_model.dart b/lib/features/insights/models/insights_payload_model.dart new file mode 100644 index 00000000..17b3e50c --- /dev/null +++ b/lib/features/insights/models/insights_payload_model.dart @@ -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 json) => + _$InsightsPayloadModelFromJson(json); +} + +@freezed +abstract class WeightChartModel with _$WeightChartModel { + const factory WeightChartModel({ + @Default([]) List labels, + @Default([]) List values, + double? last, + double? delta, + @Default('') String headline, + @Default('') String unit, + @Default('') String deltaText, + }) = _WeightChartModel; + + factory WeightChartModel.fromJson(Map json) => + _$WeightChartModelFromJson(json); +} + +@freezed +abstract class BmiChartModel with _$BmiChartModel { + const factory BmiChartModel({ + @Default([]) List labels, + @Default([]) List values, + double? last, + double? delta, + @Default('') String headline, + String? category, + @Default('') String deltaText, + @Default('') String hint, + }) = _BmiChartModel; + + factory BmiChartModel.fromJson(Map json) => + _$BmiChartModelFromJson(json); +} + +@freezed +abstract class MeasureChartModel with _$MeasureChartModel { + const factory MeasureChartModel({ + @Default([]) List labels, + @Default([]) List waist, + @Default([]) List chest, + @Default([]) List hips, + @Default('') String headline, + @Default('') String deltaText, + }) = _MeasureChartModel; + + factory MeasureChartModel.fromJson(Map json) => + _$MeasureChartModelFromJson(json); +} + +@freezed +abstract class DailyChartModel with _$DailyChartModel { + const factory DailyChartModel({ + @Default([]) List labels, + @Default([]) List kcal, + @Default([]) List proteins, + @Default([]) List fats, + @Default([]) List carbs, + double? average, + double? percent, + @Default('') String headline, + @Default('') String deltaText, + }) = _DailyChartModel; + + factory DailyChartModel.fromJson(Map json) => + _$DailyChartModelFromJson(json); +} + +@freezed +abstract class IntraChartModel with _$IntraChartModel { + const factory IntraChartModel({ + @Default([]) List bars, + @Default([]) List satiety, + @Default([]) List 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 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 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 json) => + _$ScalePointModelFromJson(json); +} diff --git a/lib/features/insights/models/insights_period.dart b/lib/features/insights/models/insights_period.dart new file mode 100644 index 00000000..6ca91a9a --- /dev/null +++ b/lib/features/insights/models/insights_period.dart @@ -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; +} diff --git a/lib/features/insights/presentation/charts/bmi_chart.dart b/lib/features/insights/presentation/charts/bmi_chart.dart new file mode 100644 index 00000000..62f3ee92 --- /dev/null +++ b/lib/features/insights/presentation/charts/bmi_chart.dart @@ -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: >[ + SplineAreaSeries( + 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, + ), + ), + ], + ], + ); + } +} diff --git a/lib/features/insights/presentation/charts/daily_kbju_chart.dart b/lib/features/insights/presentation/charts/daily_kbju_chart.dart new file mode 100644 index 00000000..b0d3db46 --- /dev/null +++ b/lib/features/insights/presentation/charts/daily_kbju_chart.dart @@ -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 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: [ + 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: >[ + if (_isVisible(0)) + SplineAreaSeries( + 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( + 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; +} diff --git a/lib/features/insights/presentation/charts/insights_chart_common.dart b/lib/features/insights/presentation/charts/insights_chart_common.dart new file mode 100644 index 00000000..4128a7b1 --- /dev/null +++ b/lib/features/insights/presentation/charts/insights_chart_common.dart @@ -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 buildPoints(List labels, List 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 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 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, + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/insights/presentation/charts/intra_day_chart.dart b/lib/features/insights/presentation/charts/intra_day_chart.dart new file mode 100644 index 00000000..813d0e98 --- /dev/null +++ b/lib/features/insights/presentation/charts/intra_day_chart.dart @@ -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 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 [ + 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: >[ + 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 _barSeries( + String name, + Color color, + double Function(IntraBarModel bar) valueOf, + double widthFraction, { + BorderRadius borderRadius = BorderRadius.zero, + }) { + return StackedColumnSeries( + name: name, + dataSource: model.bars, + xValueMapper: (bar, _) => bar.x, + yValueMapper: (bar, _) => valueOf(bar), + color: color, + width: widthFraction, + spacing: 0, + borderRadius: borderRadius, + ); + } + + CartesianSeries _scaleSeries( + String name, + Color color, + List points, + ) { + return SplineSeries( + 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 _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], + ), + ]; + } +} diff --git a/lib/features/insights/presentation/charts/measure_chart.dart b/lib/features/insights/presentation/charts/measure_chart.dart new file mode 100644 index 00000000..c0dcc562 --- /dev/null +++ b/lib/features/insights/presentation/charts/measure_chart.dart @@ -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 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 = [ + 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: >[ + for (var i = 0; i < series.length; i++) + if (_isVisible(i)) + SplineSeries( + 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; +} diff --git a/lib/features/insights/presentation/charts/weight_chart.dart b/lib/features/insights/presentation/charts/weight_chart.dart new file mode 100644 index 00000000..37f57bc4 --- /dev/null +++ b/lib/features/insights/presentation/charts/weight_chart.dart @@ -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: >[ + SplineAreaSeries( + 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), + ), + ], + ), + ); + } +} diff --git a/lib/features/insights/presentation/cubit/insights_cubit.dart b/lib/features/insights/presentation/cubit/insights_cubit.dart new file mode 100644 index 00000000..aeaebbb5 --- /dev/null +++ b/lib/features/insights/presentation/cubit/insights_cubit.dart @@ -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 { + 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 get panels => + InsightsPanel.available(kbjuEnabled: kbjuEnabled); + + Future load() async { + emit(const InsightsState.loading()); + await _fetch(bodyPeriod: BodyPeriod.threeMonths, previous: null); + } + + /// Перезапрашивает данные, сохраняя вкладку, период, серии и раскрытие. + Future refresh() async { + final current = state; + if (current is! InsightsStateLoaded) { + return load(); + } + emit(current.copyWith(isRefreshing: true)); + await _fetch(bodyPeriod: current.bodyPeriod, previous: current); + } + + Future 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 addWeight({required double weight}) { + return _add(() => repository.addWeight(weight: weight)); + } + + /// Сохраняет замеры и обновляет данные. Возвращает `false`, если сохранить не удалось. + Future addCircumference({ + required int chestCircumference, + required int waistCircumference, + required int hipCircumference, + }) { + return _add( + () => repository.addCircumference( + chestCircumference: chestCircumference, + waistCircumference: waistCircumference, + hipCircumference: hipCircumference, + ), + ); + } + + Future _add(Future 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 _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), + ); + } + } +} diff --git a/lib/features/insights/presentation/cubit/insights_panel.dart b/lib/features/insights/presentation/cubit/insights_panel.dart new file mode 100644 index 00000000..8a7b3a9c --- /dev/null +++ b/lib/features/insights/presentation/cubit/insights_panel.dart @@ -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 available({required bool kbjuEnabled}) { + return values.where((panel) => kbjuEnabled || !panel.isKbju).toList(); + } +} diff --git a/lib/features/insights/presentation/cubit/insights_state.dart b/lib/features/insights/presentation/cubit/insights_state.dart new file mode 100644 index 00000000..a6073daf --- /dev/null +++ b/lib/features/insights/presentation/cubit/insights_state.dart @@ -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 measure, + + /// Калории, Белки, Жиры, Углеводы. + @Default([true, true, true, true]) List daily, + + /// Белки, Жиры, Углеводы, Сытость, Настроение. + @Default([true, true, true, true, true]) List 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; +} diff --git a/lib/features/insights/presentation/widgets/add_circumference_bottom_sheet.dart b/lib/features/insights/presentation/widgets/add_circumference_bottom_sheet.dart new file mode 100644 index 00000000..1dc074f9 --- /dev/null +++ b/lib/features/insights/presentation/widgets/add_circumference_bottom_sheet.dart @@ -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 showAddCircumferenceBottomSheet( + BuildContext context, { + CircumferenceValues? initial, + required Future Function(CircumferenceValues values) onSave, +}) { + return showModalBottomSheet( + 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 Function(CircumferenceValues values) onSave; + + @override + State createState() => + _AddCircumferenceBottomSheetState(); +} + +class _AddCircumferenceBottomSheetState + extends State { + 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 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')], + ), + ); + } +} diff --git a/lib/features/insights/presentation/widgets/add_weight_bottom_sheet.dart b/lib/features/insights/presentation/widgets/add_weight_bottom_sheet.dart new file mode 100644 index 00000000..2228ad0b --- /dev/null +++ b/lib/features/insights/presentation/widgets/add_weight_bottom_sheet.dart @@ -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 showAddWeightBottomSheet( + BuildContext context, { + double? initialWeight, + required Future Function(double weight) onSave, +}) { + return showModalBottomSheet( + 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 Function(double weight) onSave; + + @override + State createState() => _AddWeightBottomSheetState(); +} + +class _AddWeightBottomSheetState extends State { + 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), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/insights/presentation/widgets/insights_card.dart b/lib/features/insights/presentation/widgets/insights_card.dart new file mode 100644 index 00000000..4f754829 --- /dev/null +++ b/lib/features/insights/presentation/widgets/insights_card.dart @@ -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( + create: (_) => InsightsCubit( + repository: repository ?? sl(), + slug: slug, + canAdd: canAdd, + kbjuEnabled: kbjuEnabled, + )..load(), + child: const _InsightsView(), + ), + ); + } +} + +class _InsightsView extends StatelessWidget { + const _InsightsView(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + 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().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(); + 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(); + 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(); + 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( + 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(); + final daily = state.payload.daily; + final visibility = state.seriesVisibility.daily; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PeriodPills( + 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(); + 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), + ), + ], + ); + } +} diff --git a/lib/features/insights/presentation/widgets/insights_colors.dart b/lib/features/insights/presentation/widgets/insights_colors.dart new file mode 100644 index 00000000..38bd90f8 --- /dev/null +++ b/lib/features/insights/presentation/widgets/insights_colors.dart @@ -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; diff --git a/lib/features/insights/presentation/widgets/insights_fab.dart b/lib/features/insights/presentation/widgets/insights_fab.dart new file mode 100644 index 00000000..62403286 --- /dev/null +++ b/lib/features/insights/presentation/widgets/insights_fab.dart @@ -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), + ), + ), + ), + ); + } +} diff --git a/lib/features/insights/presentation/widgets/insights_head.dart b/lib/features/insights/presentation/widgets/insights_head.dart new file mode 100644 index 00000000..beffd9f3 --- /dev/null +++ b/lib/features/insights/presentation/widgets/insights_head.dart @@ -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, + ), + ), + ], + ], + ); + } +} diff --git a/lib/features/insights/presentation/widgets/insights_tabs.dart b/lib/features/insights/presentation/widgets/insights_tabs.dart new file mode 100644 index 00000000..e3babaf7 --- /dev/null +++ b/lib/features/insights/presentation/widgets/insights_tabs.dart @@ -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 panels; + final InsightsPanel selected; + final ValueChanged 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, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/insights/presentation/widgets/intra_date_nav.dart b/lib/features/insights/presentation/widgets/intra_date_nav.dart new file mode 100644 index 00000000..90b24c1b --- /dev/null +++ b/lib/features/insights/presentation/widgets/intra_date_nav.dart @@ -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? 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 _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), + ), + ), + ); + } +} diff --git a/lib/features/insights/presentation/widgets/intra_legend.dart b/lib/features/insights/presentation/widgets/intra_legend.dart new file mode 100644 index 00000000..31536714 --- /dev/null +++ b/lib/features/insights/presentation/widgets/intra_legend.dart @@ -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, + ), + ), + ], + ); + } +} diff --git a/lib/features/insights/presentation/widgets/period_pills.dart b/lib/features/insights/presentation/widgets/period_pills.dart new file mode 100644 index 00000000..e3c02996 --- /dev/null +++ b/lib/features/insights/presentation/widgets/period_pills.dart @@ -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 extends StatelessWidget { + const PeriodPills({ + super.key, + required this.periods, + required this.selected, + required this.labelOf, + required this.keyOf, + this.onSelected, + }); + + final List periods; + final T selected; + final String Function(T period) labelOf; + final String Function(T period) keyOf; + + /// `null` — пилюли неинтерактивны (данные для периода ещё не подключены). + final ValueChanged? 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, + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/insights/presentation/widgets/series_chips.dart b/lib/features/insights/presentation/widgets/series_chips.dart new file mode 100644 index 00000000..5319b80f --- /dev/null +++ b/lib/features/insights/presentation/widgets/series_chips.dart @@ -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 chips; + final ValueChanged 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, + ), + ), + ], + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/insights/repositories/insights_repository.dart b/lib/features/insights/repositories/insights_repository.dart new file mode 100644 index 00000000..a86d10b0 --- /dev/null +++ b/lib/features/insights/repositories/insights_repository.dart @@ -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 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 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 addWeight({required double weight}) { + return profileRepository.addWeight(weight: weight); + } + + Future 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(); + } + } +} diff --git a/lib/features/profile/models/circumference_model.dart b/lib/features/profile/models/circumference_model.dart index c06d5ac5..9615b2d3 100644 --- a/lib/features/profile/models/circumference_model.dart +++ b/lib/features/profile/models/circumference_model.dart @@ -9,9 +9,9 @@ abstract class CircumferenceModel with _$CircumferenceModel { const factory CircumferenceModel({ @JsonKey(name: "id") required int id, @JsonKey(name: "user_id") required int userId, - @JsonKey(name: "chest_circumference") required int chestCircumference, - @JsonKey(name: "waist_circumference") required int waistCircumference, - @JsonKey(name: "hip_circumference") required int hipCircumference, + @JsonKey(name: "chest_circumference") required double chestCircumference, + @JsonKey(name: "waist_circumference") required double waistCircumference, + @JsonKey(name: "hip_circumference") required double hipCircumference, @JsonKey(name: "created_at") required DateTime createdAt, @JsonKey(name: "updated_at") required DateTime updatedAt, }) = _CircumferenceModel; diff --git a/lib/features/profile/models/weight_model.dart b/lib/features/profile/models/weight_model.dart index 1bc45383..f31c35e8 100644 --- a/lib/features/profile/models/weight_model.dart +++ b/lib/features/profile/models/weight_model.dart @@ -9,10 +9,10 @@ abstract class WeightModel with _$WeightModel { const factory WeightModel({ @JsonKey(name: "id") required int id, @JsonKey(name: "user_id") required int userId, - @JsonKey(name: "weight") required int weight, + @JsonKey(name: "weight") required double weight, @JsonKey(name: "created_at") required DateTime createdAt, @JsonKey(name: "updated_at") required DateTime updatedAt, - @JsonKey(name: "bmi") required double bmi, + @JsonKey(name: "bmi") double? bmi, }) = _WeightModel; factory WeightModel.fromJson(Map json) => _$WeightModelFromJson(json); diff --git a/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart b/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart index d191f294..bd6bd761 100644 --- a/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart +++ b/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart @@ -17,7 +17,6 @@ class ProfileCubit extends Cubit { final ProfileRepository profileRepository; final ChatRepository chatRepository; final ConsultantRepository consultantRepository; - String _currentPeriod = '1'; /// Bottom-nav / own profile instance, used to refresh posts after create/update/delete. static ProfileCubit? myProfileCubit; @@ -55,10 +54,9 @@ class ProfileCubit extends Cubit { } } - Future getMyProfile({String period = '1'}) async { + Future getMyProfile() async { myProfileCubit = this; emit(const ProfileState.loading()); - _currentPeriod = period; try { final profile = await profileRepository.getMyProfile(); List certPhotos = await consultantRepository.getConsultantCertPhotos(); @@ -69,8 +67,6 @@ class ProfileCubit extends Cubit { isMyProfile: true, profileDetails: await profileRepository.getMyProfileDetails(), blogPosts: await profileRepository.getBlogPostsByAuthorSlug(slug: profile.slug), - weightPoints: await profileRepository.getWeight(period: period), - circumferencePoints: await profileRepository.getCircumferences(period: period), consultantInfo: profile.roles.contains('consultant') ? await consultantRepository.getConsultantRequestStatus(profile.slug) : null, certPhotos: certPhotos, ), @@ -110,8 +106,6 @@ class ProfileCubit extends Cubit { profile: (state as ProfileStateLoaded).profile, profileDetails: (state as ProfileStateLoaded).profileDetails, blogPosts: feedBlogPostList, - weightPoints: (state as ProfileStateLoaded).weightPoints, - circumferencePoints: (state as ProfileStateLoaded).circumferencePoints, consultantInfo: (state as ProfileStateLoaded).consultantInfo, isMyProfile: (state as ProfileStateLoaded).isMyProfile, certPhotos: (state as ProfileStateLoaded).certPhotos, @@ -126,7 +120,6 @@ class ProfileCubit extends Cubit { waistCircumference: waistCircumference, hipCircumference: hipCircumference, ); - await getMyProfile(period: _currentPeriod); } catch (e, st) { await reportFrom(e, st, this); emit(ProfileState.error(errorMessage: e.toString())); @@ -136,7 +129,6 @@ class ProfileCubit extends Cubit { Future addWeight({required double weight}) async { try { await profileRepository.addWeight(weight: weight); - await getMyProfile(period: _currentPeriod); } catch (e, st) { await reportFrom(e, st, this); emit(ProfileState.error(errorMessage: e.toString())); diff --git a/lib/features/profile/presentation/bloc/profile_cubit/profile_state.dart b/lib/features/profile/presentation/bloc/profile_cubit/profile_state.dart index 4706964e..bfc0a8fe 100644 --- a/lib/features/profile/presentation/bloc/profile_cubit/profile_state.dart +++ b/lib/features/profile/presentation/bloc/profile_cubit/profile_state.dart @@ -1,9 +1,7 @@ import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; -import 'package:eatvio_app/features/profile/models/circumference_model.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart'; -import 'package:eatvio_app/features/profile/models/weight_model.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'profile_state.freezed.dart'; @@ -20,8 +18,6 @@ abstract class ProfileState with _$ProfileState { required List blogPosts, required bool isMyProfile, required List certPhotos, - List? weightPoints, - List? circumferencePoints, ConsultationRequestModel? consultantInfo, }) = ProfileStateLoaded; diff --git a/lib/features/profile/presentation/pages/profile_screen.dart b/lib/features/profile/presentation/pages/profile_screen.dart index b35733f0..cbb96982 100644 --- a/lib/features/profile/presentation/pages/profile_screen.dart +++ b/lib/features/profile/presentation/pages/profile_screen.dart @@ -18,21 +18,17 @@ import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/prof import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart'; import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart'; import 'package:eatvio_app/features/feed/presentation/widgets/plan_widget.dart'; +import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.dart'; import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.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/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart'; import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_state.dart'; import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart'; import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart'; import 'package:eatvio_app/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart'; import 'package:eatvio_app/features/profile/presentation/pages/subs_screen.dart'; -import 'package:eatvio_app/features/profile/presentation/widgets/circumference_chart_widget.dart'; import 'package:eatvio_app/features/profile/presentation/widgets/expandable_page_view.dart'; -import 'package:eatvio_app/features/profile/presentation/widgets/imt_chart_widget.dart'; import 'package:eatvio_app/features/profile/presentation/widgets/profile_blog_post_widget.dart'; import 'package:eatvio_app/features/profile/presentation/widgets/toggle_cert_photo_bottom_sheet.dart'; -import 'package:eatvio_app/features/profile/presentation/widgets/weight_chart_widget.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/generated/l10n.dart'; @@ -60,21 +56,8 @@ class ProfileScreen extends StatefulWidget { class _ProfileScreenState extends State { late final ProfileCubit profileCubit = sl(); final PlansCubit plansCubit = sl(); - final List chipsLabelList = ['Вес', 'Замеры', 'ИМТ']; - final List chipsIntervalLabelList = [ - '1М', - '3М', - '6М', - '1Г', - 'ВСЁ ВРЕМЯ', - ]; - final List chipsIntervalList = ['1', '3', '6', '12', 'all']; final TextEditingController controller = TextEditingController(); - List weightPoints = []; - List circumferencePoints = []; - int _selectedChipIndex = 0; - int _selectedIntervalChipIndex = 0; late bool canSub; bool isFirstFetch = true; int _currentBullet = 0; @@ -96,10 +79,6 @@ class _ProfileScreenState extends State { bloc: profileCubit, builder: (BuildContext context, ProfileState state) { if (state is ProfileStateLoaded) { - if (state.circumferencePoints != null) { - circumferencePoints = state.circumferencePoints!; - weightPoints = state.weightPoints!; - } if (isFirstFetch) { canSub = !state.profile.isEntitySubscribedByMe; if (state.profile.roles.contains('consultant') && !hidePlans) { @@ -442,10 +421,11 @@ class _ProfileScreenState extends State { child: _badges(state), ), if (state.profile.roles.contains('consultant')) _socialLinks(state), - Padding( - padding: const EdgeInsets.only(bottom: 16), - child: _charts(), - ), + if (state.isMyProfile) + const Padding( + padding: EdgeInsets.only(bottom: 16), + child: InsightsCard(canAdd: true), + ), if (state.profile.roles.contains('consultant')) _education(state), if (state.profile.roles.contains('consultant')) _diplomasAndCertificates(state), @@ -1722,114 +1702,4 @@ class _ProfileScreenState extends State { ], ); } - - Material _charts() { - return Material( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 8), - SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 16), - scrollDirection: Axis.horizontal, - child: Row( - children: [ - _choiceChipWidget(0), - const SizedBox(width: 8), - _choiceChipWidget(1), - const SizedBox(width: 8), - _choiceChipWidget(2), - ], - ), - ), - Builder( - builder: (context) { - switch (_selectedChipIndex) { - case 0: - return WeightChartWidget( - weightPoints: weightPoints, - profileCubit: profileCubit, - ); - case 1: - return CircumferenceChartWidget( - circumferencePoints: circumferencePoints, - profileCubit: profileCubit, - ); - case 2: - return ImtChartWidget(weightPoints: weightPoints); - default: - return const SizedBox.shrink(); - } - }, - ), - const SizedBox(height: 8), - Row( - children: [ - const SizedBox(width: 16), - _choiceIntervalChipWidget(0), - const SizedBox(width: 8), - _choiceIntervalChipWidget(1), - const SizedBox(width: 8), - _choiceIntervalChipWidget(2), - const SizedBox(width: 8), - _choiceIntervalChipWidget(3), - const SizedBox(width: 8), - _choiceIntervalChipWidget(4), - ], - ), - const SizedBox(height: 8), - ], - ), - ); - } - - ChoiceChip _choiceIntervalChipWidget(int index) { - return ChoiceChip( - selectedColor: Colors.grey.withValues(alpha: 0.1), - pressElevation: 0, - showCheckmark: false, - side: BorderSide.none, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - label: Text(chipsIntervalLabelList[index]), - labelStyle: TextStyle( - color: appTheme.gray600, - fontWeight: FontWeight.w500, - fontSize: 12, - ), - selected: _selectedIntervalChipIndex == index, - onSelected: (bool selected) { - profileCubit.getMyProfile(period: chipsIntervalList[index]); - setState(() { - _selectedIntervalChipIndex = index; - }); - }, - ); - } - - ChoiceChip _choiceChipWidget(int index) { - return ChoiceChip( - selectedColor: appTheme.blueA400, - pressElevation: 0, - showCheckmark: false, - side: BorderSide.none, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - label: Text(chipsLabelList[index]), - labelStyle: TextStyle( - color: index == _selectedChipIndex ? Colors.white : appTheme.gray600, - fontWeight: FontWeight.w500, - fontSize: 12, - ), - selected: _selectedChipIndex == index, - backgroundColor: appTheme.gray100, - onSelected: (bool selected) { - setState(() { - _selectedChipIndex = index; - }); - }, - ); - } } diff --git a/lib/features/profile/presentation/widgets/circumference_chart_widget.dart b/lib/features/profile/presentation/widgets/circumference_chart_widget.dart deleted file mode 100644 index 99d674e5..00000000 --- a/lib/features/profile/presentation/widgets/circumference_chart_widget.dart +++ /dev/null @@ -1,389 +0,0 @@ -import 'package:eatvio_app/features/profile/models/circumference_model.dart'; -import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart'; -import 'package:eatvio_app/core/theme/theme_helper.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; -import 'package:syncfusion_flutter_charts/charts.dart'; - -class CircumferenceChartWidget extends StatefulWidget { - final List circumferencePoints; - final ProfileCubit? profileCubit; - - const CircumferenceChartWidget({ - super.key, - required this.circumferencePoints, - this.profileCubit, - }); - - @override - State createState() => _CircumferenceChartWidgetState(); -} - -class _CircumferenceChartWidgetState extends State { - late FixedExtentScrollController chestController; - late FixedExtentScrollController waistController; - late FixedExtentScrollController hipController; - late int chest; - late int waist; - late int hip; - - @override - Widget build(BuildContext context) { - return Column( - children: [ - if (widget.circumferencePoints.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - Text( - '${widget.circumferencePoints.last.chestCircumference}', - style: const TextStyle( - color: Colors.black, - fontSize: 40, - fontWeight: FontWeight.w500, - ), - ), - if (widget.circumferencePoints.length == 1) - Container( - margin: const EdgeInsets.symmetric(horizontal: 12), - width: 8, - height: 8, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Colors.black12, - ), - ), - if (widget.circumferencePoints.length > 1) - widget.circumferencePoints.last.chestCircumference > widget.circumferencePoints[widget.circumferencePoints.length - 2].chestCircumference - ? const Icon( - Icons.arrow_drop_up, - size: 40, - color: Colors.grey, - ) - : const Icon( - Icons.arrow_drop_down, - size: 40, - color: Colors.grey, - ), - Text( - '${widget.circumferencePoints.last.waistCircumference}', - style: const TextStyle( - color: Colors.black, - fontSize: 40, - fontWeight: FontWeight.w500, - ), - ), - if (widget.circumferencePoints.length == 1) - Container( - margin: const EdgeInsets.symmetric(horizontal: 12), - width: 8, - height: 8, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Colors.black12, - ), - ), - if (widget.circumferencePoints.length > 1) - widget.circumferencePoints.last.waistCircumference > widget.circumferencePoints[widget.circumferencePoints.length - 2].waistCircumference - ? const Icon( - Icons.arrow_drop_up, - size: 40, - color: Colors.grey, - ) - : const Icon( - Icons.arrow_drop_down, - size: 40, - color: Colors.grey, - ), - Text( - '${widget.circumferencePoints.last.hipCircumference}', - style: const TextStyle( - color: Colors.black, - fontSize: 40, - fontWeight: FontWeight.w500, - ), - ), - if (widget.circumferencePoints.length == 1) - Container( - margin: const EdgeInsets.symmetric(horizontal: 12), - width: 8, - height: 8, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Colors.black12, - ), - ), - if (widget.circumferencePoints.length > 1) - widget.circumferencePoints.last.hipCircumference > widget.circumferencePoints[widget.circumferencePoints.length - 2].hipCircumference - ? const Icon( - Icons.arrow_drop_up, - size: 40, - color: Colors.grey, - ) - : const Icon( - Icons.arrow_drop_down, - size: 40, - color: Colors.grey, - ), - ], - ), - ), - Stack( - alignment: Alignment.center, - children: [ - SfCartesianChart( - margin: const EdgeInsets.symmetric(horizontal: 8), - enableAxisAnimation: true, - plotAreaBorderWidth: 0, - trackballBehavior: TrackballBehavior( - enable: true, - activationMode: ActivationMode.singleTap, - tooltipDisplayMode: TrackballDisplayMode.groupAllPoints, - tooltipSettings: const InteractiveTooltip(), - lineType: TrackballLineType.vertical, - ), - primaryXAxis: DateTimeCategoryAxis( - tickPosition: TickPosition.inside, - majorTickLines: const MajorTickLines( - size: 0, - ), - edgeLabelPlacement: EdgeLabelPlacement.shift, - dateFormat: DateFormat.MMMd('ru'), - labelStyle: const TextStyle( - color: Colors.grey, - ), - majorGridLines: MajorGridLines(dashArray: const [1], color: Colors.grey.withValues(alpha: 0.3)), - axisLine: AxisLine( - color: Colors.grey.withValues(alpha: 0.3), - width: 0.4, - ), - ), - primaryYAxis: const NumericAxis( - isVisible: false, - labelFormat: '{value} см', - axisLine: AxisLine( - width: 1, - ), - majorTickLines: MajorTickLines(size: 0), - ), - series: _getSplineSeries(), - tooltipBehavior: TooltipBehavior(enable: true), - ), - if (widget.profileCubit != null) - Positioned( - right: 16, - bottom: 16, - child: FloatingActionButton.small( - heroTag: 'addCircumference', - onPressed: () { - if (widget.circumferencePoints.isNotEmpty) { - chest = widget.circumferencePoints.last.chestCircumference; - waist = widget.circumferencePoints.last.waistCircumference; - hip = widget.circumferencePoints.last.hipCircumference; - chestController = FixedExtentScrollController(initialItem: chest - 20); - waistController = FixedExtentScrollController(initialItem: waist - 20); - hipController = FixedExtentScrollController(initialItem: hip - 20); - } else { - chestController = FixedExtentScrollController(); - waistController = FixedExtentScrollController(); - hipController = FixedExtentScrollController(); - } - showModalBottomSheet( - useRootNavigator: true, - context: context, - builder: (context) { - return SafeArea( - child: Column( - children: [ - Expanded( - child: Row( - children: [ - Expanded( - child: CupertinoPicker( - magnification: 1.22, - squeeze: 1.2, - useMagnifier: true, - scrollController: chestController, - itemExtent: 24, - onSelectedItemChanged: (int value) { - chest = value; - }, - children: [ - for (int i = 20; i < 200; i++) Text('$i'), - ], - ), - ), - Expanded( - child: CupertinoPicker( - magnification: 1.22, - squeeze: 1.2, - useMagnifier: true, - scrollController: waistController, - itemExtent: 24, - onSelectedItemChanged: (int value) { - waist = value; - }, - children: [ - for (int i = 20; i < 200; i++) Text('$i'), - ], - ), - ), - Expanded( - child: CupertinoPicker( - magnification: 1.22, - squeeze: 1.2, - useMagnifier: true, - scrollController: hipController, - itemExtent: 24, - onSelectedItemChanged: (int value) { - hip = value; - }, - children: [ - for (int i = 20; i < 200; i++) Text('$i'), - ], - ), - ), - ], - ), - ), - InkWell( - onTap: () { - widget.profileCubit?.addCircumference( - chestCircumference: chest + 20, - waistCircumference: waist + 20, - hipCircumference: hip + 20, - ); - Navigator.of(context).pop(); - }, - 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: const Center( - child: Text( - 'Сохранить', - style: TextStyle( - color: Colors.white, - ), - ), - ), - ), - ), - ], - ), - ); - }, - ); - }, - backgroundColor: appTheme.blueA400, - shape: const CircleBorder(), - child: const Icon( - Icons.add, - color: Colors.white, - ), - ), - ), - if (widget.circumferencePoints.isEmpty) - const Padding( - padding: EdgeInsets.symmetric(horizontal: 48), - child: Text( - 'Добавьте Ваши замеры', - textAlign: TextAlign.center, - ), - ), - ], - ), - ], - ); - } - - List> _getSplineSeries() { - const Color seriesColor1 = Colors.red; - final Color seriesColor2 = appTheme.blueA400; - const Color seriesColor3 = Colors.green; - - return >[ - SplineAreaSeries( - markerSettings: const MarkerSettings( - isVisible: true, - color: seriesColor1, - height: 8, - width: 8, - ), - dataLabelSettings: const DataLabelSettings( - labelAlignment: ChartDataLabelAlignment.top, - isVisible: true, - ), - dataSource: widget.circumferencePoints, - borderColor: seriesColor1, - gradient: LinearGradient( - colors: [ - seriesColor1.withValues(alpha: 0.14), - Colors.transparent, - ], - begin: Alignment.topCenter, - end: const Alignment(0, 0.6), - ), - name: 'Грудь', - xValueMapper: (CircumferenceModel value, int index) => value.createdAt, - yValueMapper: (CircumferenceModel value, int index) => value.chestCircumference, - ), - SplineAreaSeries( - markerSettings: MarkerSettings( - isVisible: true, - color: seriesColor2, - height: 8, - width: 8, - ), - dataLabelSettings: const DataLabelSettings( - labelAlignment: ChartDataLabelAlignment.top, - isVisible: true, - ), - dataSource: widget.circumferencePoints, - borderColor: seriesColor2, - gradient: LinearGradient( - colors: [ - seriesColor2.withValues(alpha: 0.14), - Colors.transparent, - ], - begin: Alignment.topCenter, - end: const Alignment(0, 0.6), - ), - name: 'Талия', - xValueMapper: (CircumferenceModel value, int index) => value.createdAt, - yValueMapper: (CircumferenceModel value, int index) => value.waistCircumference, - ), - SplineAreaSeries( - markerSettings: const MarkerSettings( - isVisible: true, - color: seriesColor3, - height: 8, - width: 8, - ), - dataLabelSettings: const DataLabelSettings( - labelAlignment: ChartDataLabelAlignment.top, - isVisible: true, - ), - dataSource: widget.circumferencePoints, - borderColor: seriesColor3, - gradient: LinearGradient( - colors: [ - seriesColor3.withValues(alpha: 0.14), - Colors.transparent, - ], - begin: Alignment.topCenter, - end: const Alignment(0, 0.6), - ), - name: 'Бёдра', - xValueMapper: (CircumferenceModel value, int index) => value.createdAt, - yValueMapper: (CircumferenceModel value, int index) => value.hipCircumference, - ), - ]; - } -} diff --git a/lib/features/profile/presentation/widgets/imt_chart_widget.dart b/lib/features/profile/presentation/widgets/imt_chart_widget.dart deleted file mode 100644 index e716c12d..00000000 --- a/lib/features/profile/presentation/widgets/imt_chart_widget.dart +++ /dev/null @@ -1,127 +0,0 @@ -import 'package:eatvio_app/features/profile/models/weight_model.dart'; -import 'package:eatvio_app/core/theme/theme_helper.dart'; -import 'package:flutter/material.dart'; -import 'package:syncfusion_flutter_charts/charts.dart'; -import 'package:intl/intl.dart'; - -class ImtChartWidget extends StatelessWidget { - final List weightPoints; - - const ImtChartWidget({ - super.key, - required this.weightPoints, - }); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - if (weightPoints.isNotEmpty) - Text( - weightPoints.last.bmi.toStringAsFixed(2), - style: const TextStyle( - color: Colors.black, - fontSize: 40, - fontWeight: FontWeight.w500, - ), - ), - if (weightPoints.length > 1) - weightPoints.last.bmi > weightPoints[weightPoints.length - 2].bmi - ? const Icon( - Icons.arrow_drop_up, - size: 40, - color: Colors.grey, - ) - : const Icon( - Icons.arrow_drop_down, - size: 40, - color: Colors.grey, - ), - ], - ), - ), - Stack( - alignment: Alignment.center, - children: [ - SfCartesianChart( - margin: const EdgeInsets.symmetric(horizontal: 8), - enableAxisAnimation: true, - plotAreaBorderWidth: 0, - trackballBehavior: TrackballBehavior( - enable: true, - activationMode: ActivationMode.singleTap, - tooltipDisplayMode: TrackballDisplayMode.groupAllPoints, - tooltipSettings: const InteractiveTooltip(), - lineType: TrackballLineType.vertical, - ), - primaryXAxis: DateTimeCategoryAxis( - tickPosition: TickPosition.inside, - majorTickLines: const MajorTickLines( - size: 0, - ), - edgeLabelPlacement: EdgeLabelPlacement.shift, - dateFormat: DateFormat.MMMd('ru'), - labelStyle: const TextStyle( - color: Colors.grey, - ), - majorGridLines: MajorGridLines(dashArray: const [1], color: Colors.grey.withValues(alpha: 0.3)), - axisLine: AxisLine( - color: Colors.grey.withValues(alpha: 0.3), - width: 0.4, - ), - ), - primaryYAxis: const NumericAxis( - isVisible: false, - ), - series: _getSplineSeries(), - tooltipBehavior: TooltipBehavior(enable: true), - ), - if (weightPoints.isEmpty) - const Padding( - padding: EdgeInsets.symmetric(horizontal: 32), - child: Text( - 'Индекс Массы Тела рассчитывается при указанных весе и росте', - textAlign: TextAlign.center, - ), - ), - ], - ), - ], - ); - } - - List> _getSplineSeries() { - return >[ - SplineAreaSeries( - markerSettings: MarkerSettings( - isVisible: true, - color: appTheme.blueA400, - height: 8, - width: 8, - ), - dataLabelSettings: const DataLabelSettings( - labelAlignment: ChartDataLabelAlignment.top, - isVisible: true, - ), - dataSource: weightPoints, - borderColor: appTheme.blueA400, - gradient: LinearGradient( - colors: [ - appTheme.blueA400.withValues(alpha: 0.14), - Colors.transparent, - ], - begin: Alignment.topCenter, - end: const Alignment(0, 0.6), - ), - name: 'ИМТ', - xValueMapper: (WeightModel value, int index) => value.createdAt, - yValueMapper: (WeightModel value, int index) => double.parse(value.bmi.toStringAsFixed(2)), - ), - ]; - } -} diff --git a/lib/features/profile/presentation/widgets/weight_chart_widget.dart b/lib/features/profile/presentation/widgets/weight_chart_widget.dart deleted file mode 100644 index 3084f882..00000000 --- a/lib/features/profile/presentation/widgets/weight_chart_widget.dart +++ /dev/null @@ -1,250 +0,0 @@ -import 'package:eatvio_app/features/profile/models/weight_model.dart'; -import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart'; -import 'package:eatvio_app/core/theme/theme_helper.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; -import 'package:syncfusion_flutter_charts/charts.dart'; - -class WeightChartWidget extends StatefulWidget { - final List weightPoints; - final ProfileCubit? profileCubit; - - const WeightChartWidget({ - super.key, - required this.weightPoints, - this.profileCubit, - }); - - @override - State createState() => _WeightChartWidgetState(); -} - -class _WeightChartWidgetState extends State { - late FixedExtentScrollController weightKiloController; - late FixedExtentScrollController weightGramsController; - int weightKilo = 20; - int weightGrams = 0; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (widget.weightPoints.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - Text( - '${widget.weightPoints.last.weight.toStringAsFixed(1)} кг', - style: const TextStyle( - color: Colors.black, - fontSize: 40, - fontWeight: FontWeight.w500, - ), - ), - if (widget.weightPoints.length == 1) - Container( - margin: const EdgeInsets.symmetric(horizontal: 12), - width: 8, - height: 8, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Colors.black12, - ), - ), - if (widget.weightPoints.length > 1) - widget.weightPoints.last.weight > widget.weightPoints[widget.weightPoints.length - 2].weight - ? Icon( - Icons.arrow_drop_up, - size: 40, - color: Colors.grey.withValues(alpha: 0.4), - ) - : Icon( - Icons.arrow_drop_down, - size: 40, - color: Colors.grey.withValues(alpha: 0.4), - ), - ], - ), - ), - Stack( - alignment: Alignment.center, - children: [ - SfCartesianChart( - margin: const EdgeInsets.symmetric(horizontal: 8), - enableAxisAnimation: true, - plotAreaBorderWidth: 0, - trackballBehavior: TrackballBehavior( - enable: true, - activationMode: ActivationMode.singleTap, - tooltipDisplayMode: TrackballDisplayMode.groupAllPoints, - tooltipSettings: const InteractiveTooltip(), - lineType: TrackballLineType.vertical, - ), - primaryXAxis: DateTimeCategoryAxis( - tickPosition: TickPosition.inside, - majorTickLines: const MajorTickLines( - size: 0, - ), - edgeLabelPlacement: EdgeLabelPlacement.shift, - dateFormat: DateFormat.MMMd('ru'), - labelStyle: const TextStyle( - color: Colors.grey, - ), - majorGridLines: MajorGridLines(dashArray: const [1], color: Colors.grey.withValues(alpha: 0.3)), - axisLine: AxisLine( - color: Colors.grey.withValues(alpha: 0.3), - width: 0.4, - ), - ), - primaryYAxis: const NumericAxis( - isVisible: false, - labelFormat: '{value} кг', - majorGridLines: MajorGridLines(dashArray: [1], color: Colors.grey), - axisLine: AxisLine( - width: 1, - ), - majorTickLines: MajorTickLines(size: 0), - ), - series: _getSplineSeries(), - tooltipBehavior: TooltipBehavior(enable: true), - ), - if (widget.profileCubit != null) - Positioned( - right: 16, - bottom: 16, - child: FloatingActionButton.small( - heroTag: 'addWeight', - onPressed: () { - if (widget.weightPoints.isNotEmpty) { - try { - weightKilo = int.parse(widget.weightPoints.last.weight.toString().split('.')[0]); - weightGrams = int.parse(widget.weightPoints.last.weight.toString().split('.')[1][0]); - } catch (e) { - weightGrams = 0; - } - weightKiloController = FixedExtentScrollController(initialItem: weightKilo - 20); - weightGramsController = FixedExtentScrollController(initialItem: weightGrams); - } else { - weightKiloController = FixedExtentScrollController(); - weightGramsController = FixedExtentScrollController(); - } - showModalBottomSheet( - useRootNavigator: true, - context: context, - builder: (context) { - return SafeArea( - child: Column( - children: [ - Expanded( - child: Row( - children: [ - Expanded( - child: CupertinoPicker( - magnification: 1.22, - squeeze: 1.2, - useMagnifier: true, - scrollController: weightKiloController, - itemExtent: 24, - onSelectedItemChanged: (int value) { - weightKilo = value + 20; - }, - children: [ - for (int i = 20; i < 300; i++) Text('$i'), - ], - ), - ), - Expanded( - child: CupertinoPicker( - magnification: 1.22, - squeeze: 1.2, - useMagnifier: true, - scrollController: weightGramsController, - itemExtent: 24, - onSelectedItemChanged: (int value) { - weightGrams = value; - }, - children: [ - for (int i = 0; i < 10; i++) Text('$i'), - ], - ), - ), - ], - ), - ), - InkWell( - onTap: () { - widget.profileCubit?.addWeight(weight: double.parse('$weightKilo.$weightGrams')); - Navigator.of(context).pop(); - }, - 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: const Center( - child: Text( - 'Сохранить', - style: TextStyle( - color: Colors.white, - ), - ), - ), - ), - ), - ], - ), - ); - }, - ); - }, - backgroundColor: appTheme.blueA400, - shape: const CircleBorder(), - child: const Icon( - Icons.add, - color: Colors.white, - ), - ), - ), - if (widget.weightPoints.isEmpty) const Text('Добавьте Ваш вес'), - ], - ), - ], - ); - } - - List> _getSplineSeries() { - return >[ - SplineAreaSeries( - markerSettings: MarkerSettings( - isVisible: true, - color: appTheme.blueA400, - height: 8, - width: 8, - ), - dataLabelSettings: const DataLabelSettings( - labelAlignment: ChartDataLabelAlignment.top, - isVisible: true, - ), - dataSource: widget.weightPoints, - borderColor: appTheme.blueA400, - gradient: LinearGradient( - colors: [ - appTheme.blueA400.withValues(alpha: 0.14), - Colors.transparent, - ], - begin: Alignment.topCenter, - end: const Alignment(0, 0.6), - ), - name: 'Вес', - xValueMapper: (WeightModel value, int index) => value.createdAt, - yValueMapper: (WeightModel value, int index) => value.weight, - ), - ]; - } -} diff --git a/lib/features/user_details/presentation/cubit/user_details_cubit.dart b/lib/features/user_details/presentation/cubit/user_details_cubit.dart index 6735043b..cc44a995 100644 --- a/lib/features/user_details/presentation/cubit/user_details_cubit.dart +++ b/lib/features/user_details/presentation/cubit/user_details_cubit.dart @@ -14,23 +14,10 @@ class UserDetailsCubit extends Cubit { try { final profileDetails = await profileRepository.getProfileDetailsBySlug(slug); emit(state.copyWith(profileDetails: profileDetails, isLoading: false)); - getChartData(slug: slug); } catch (e, st) { await reportFrom(e, st, this); log(e.toString()); emit(state.copyWith(error: e.toString(), isLoading: false)); } } - - void getChartData({String period = '1', required String slug}) async { - try { - final weightPoints = await profileRepository.getWeight(period: period, slug: slug); - final circumferencePoints = await profileRepository.getCircumferences(period: period, slug: slug); - emit(state.copyWith(weightPoints: weightPoints, circumferencePoints: circumferencePoints)); - } catch (e, stackTrace) { - await reportFrom(e, stackTrace, this); - log(e.toString(), stackTrace: stackTrace); - emit(state.copyWith(error: e.toString(), isLoading: false)); - } - } } diff --git a/lib/features/user_details/presentation/cubit/user_details_state.dart b/lib/features/user_details/presentation/cubit/user_details_state.dart index 7f4d081b..39ddd4b4 100644 --- a/lib/features/user_details/presentation/cubit/user_details_state.dart +++ b/lib/features/user_details/presentation/cubit/user_details_state.dart @@ -1,6 +1,4 @@ -import 'package:eatvio_app/features/profile/models/circumference_model.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart'; -import 'package:eatvio_app/features/profile/models/weight_model.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'user_details_state.freezed.dart'; @@ -11,7 +9,5 @@ abstract class UserDetailsState with _$UserDetailsState { @Default(false) bool isLoading, String? error, ProfileDetailsModel? profileDetails, - List? weightPoints, - List? circumferencePoints, }) = _UserDetailsState; } \ No newline at end of file diff --git a/lib/features/user_details/presentation/user_details_screen.dart b/lib/features/user_details/presentation/user_details_screen.dart index a5552802..82269a08 100644 --- a/lib/features/user_details/presentation/user_details_screen.dart +++ b/lib/features/user_details/presentation/user_details_screen.dart @@ -8,11 +8,7 @@ import 'package:eatvio_app/core/widgets/custom_text_form_field.dart'; import 'package:eatvio_app/core/widgets/data_error_widget.dart'; import 'package:eatvio_app/core/widgets/loader.dart'; import 'package:eatvio_app/features/diets/presentation/widgets/more_options.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/features/profile/presentation/widgets/circumference_chart_widget.dart'; -import 'package:eatvio_app/features/profile/presentation/widgets/imt_chart_widget.dart'; -import 'package:eatvio_app/features/profile/presentation/widgets/weight_chart_widget.dart'; +import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.dart'; import 'package:eatvio_app/features/user_details/presentation/cubit/user_details_cubit.dart'; import 'package:eatvio_app/features/user_details/presentation/cubit/user_details_state.dart'; import 'package:eatvio_app/features/user_details/presentation/widgets/expandable_bouble.dart'; @@ -44,13 +40,6 @@ class UserDetailsContent extends StatefulWidget { } class _UserDetailsContentState extends State { - final List chipsLabelList = ['Вес', 'Замеры', 'ИМТ']; - final List chipsIntervalLabelList = ['1М', '3М', '6М', '1Г', 'ВСЁ ВРЕМЯ']; - final List chipsIntervalList = ['1', '3', '6', '12', 'all']; - - List weightPoints = []; - List circumferencePoints = []; int _selectedChipIndex = 0; - int _selectedIntervalChipIndex = 0; bool isMoreOptionsVisible = false; @override Widget build(BuildContext context) { @@ -59,8 +48,6 @@ class _UserDetailsContentState extends State { builder: (context, state) { final profileDetails = state.profileDetails; if (profileDetails != null && state.isLoading == false) { - weightPoints = state.weightPoints ?? []; - circumferencePoints = state.circumferencePoints ?? []; return Scaffold( backgroundColor: appTheme.gray100, appBar: AppBar( @@ -104,7 +91,7 @@ class _UserDetailsContentState extends State { spacing: 12, children: [ const SizedBox.shrink(), - _charts(), + InsightsCard(slug: widget.slug), ExpandableBouble( initiallyExpanded: true, title: s.general_info, @@ -309,92 +296,4 @@ class _UserDetailsContentState extends State { }, ); } - - Material _charts() { - return Material( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 8), - SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 16), - scrollDirection: Axis.horizontal, - child: Row(children: [_choiceChipWidget(0), const SizedBox(width: 8), _choiceChipWidget(1), const SizedBox(width: 8), _choiceChipWidget(2)]), - ), - Builder( - builder: (context) { - switch (_selectedChipIndex) { - case 0: - return WeightChartWidget(weightPoints: weightPoints); - case 1: - return CircumferenceChartWidget(circumferencePoints: circumferencePoints); - case 2: - return ImtChartWidget(weightPoints: weightPoints); - default: - return const SizedBox.shrink(); - } - }, - ), - const SizedBox(height: 8), - Row( - children: [ - const SizedBox(width: 16), - _choiceIntervalChipWidget(0), - const SizedBox(width: 8), - _choiceIntervalChipWidget(1), - const SizedBox(width: 8), - _choiceIntervalChipWidget(2), - const SizedBox(width: 8), - _choiceIntervalChipWidget(3), - const SizedBox(width: 8), - _choiceIntervalChipWidget(4), - ], - ), - const SizedBox(height: 8), - ], - ), - ); - } - - ChoiceChip _choiceIntervalChipWidget(int index) { - return ChoiceChip( - selectedColor: Colors.grey.withValues(alpha: 0.1), - pressElevation: 0, - showCheckmark: false, - side: BorderSide.none, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - label: Text(chipsIntervalLabelList[index]), - labelStyle: TextStyle(color: appTheme.gray600, fontWeight: FontWeight.w500, fontSize: 12), - selected: _selectedIntervalChipIndex == index, - onSelected: (bool selected) { - context.read().getChartData(period: chipsIntervalList[index], slug: widget.slug); - setState(() { - _selectedIntervalChipIndex = index; - }); - }, - ); - } - - ChoiceChip _choiceChipWidget(int index) { - return ChoiceChip( - selectedColor: appTheme.blueA400, - pressElevation: 0, - showCheckmark: false, - side: BorderSide.none, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - label: Text(chipsLabelList[index]), - labelStyle: TextStyle(color: index == _selectedChipIndex ? Colors.white : appTheme.gray600, fontWeight: FontWeight.w500, fontSize: 12), - selected: _selectedChipIndex == index, - backgroundColor: appTheme.gray100, - onSelected: (bool selected) { - setState(() { - _selectedChipIndex = index; - }); - }, - ); - } } diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index b4ae871b..0c9de781 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -22,32 +22,62 @@ class MessageLookup extends MessageLookupByLibrary { static String m0(date) => "Консультант прокомментировал день ${date}"; - static String m1(planName) => "План «${planName}» начался"; + static String m1(value, period, category) => + "BMI higher by ${value} over ${period} · ${category}"; - static String m2(planName, name) => + static String m2(height) => + "Green zone is normal (18.5–25), yellow is overweight, pink is obesity. Calculated for a height of ${height} cm."; + + static String m3(value, period, category) => + "BMI lower by ${value} over ${period} · ${category}"; + + static String m4(period, category) => + "BMI unchanged over ${period} · ${category}"; + + static String m5(name, value) => "${name} down ${value} cm"; + + static String m6(name, value) => "${name} up ${value} cm"; + + static String m7(name) => "${name} unchanged"; + + static String m8(parts, period) => "${parts} over ${period}"; + + static String m9(time) => "Bedtime at ${time}"; + + static String m10(time) => "Wake-up at ${time}"; + + static String m11(value, period) => "Gained ${value} kg over ${period}"; + + static String m12(value, period) => "Lost ${value} kg over ${period}"; + + static String m13(period) => "Weight unchanged over ${period}"; + + static String m14(planName) => "План «${planName}» начался"; + + static String m15(planName, name) => "План «${planName}» с пользователем ${name} начался"; - static String m3(percent, amount) => + static String m16(percent, amount) => "Discount ${percent}% — you save ${amount} ₽"; - static String m4(step) => "шаг ${step} из 2"; + static String m17(step) => "шаг ${step} из 2"; - static String m5(planName, name) => + static String m18(planName, name) => "Завтра начинается план «${planName}» с пользователем ${name}"; - static String m6(planName) => + static String m19(planName) => "Завтра начнется прохождение плана «${planName}»"; - static String m7(name, planName) => + static String m20(name, planName) => "Пользователь ${name} отменил подписку на план «${planName}»"; - static String m8(name, planName) => + static String m21(name, planName) => "Пользователь ${name} оплатил план «${planName}»"; - static String m9(name, planName) => + static String m22(name, planName) => "Пользователь ${name} подписался на план «${planName}»"; - static String m10(planName) => "Вы отменили подписку на план «${planName}»"; + static String m23(planName) => "Вы отменили подписку на план «${planName}»"; final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { @@ -503,6 +533,126 @@ class MessageLookup extends MessageLookupByLibrary { "initial_measurements": MessageLookupByLibrary.simpleMessage( "Первичные замеры", ), + "insights_add_measures_empty": MessageLookupByLibrary.simpleMessage( + "Add your measurements", + ), + "insights_add_record": MessageLookupByLibrary.simpleMessage("Add entry"), + "insights_add_weight_empty": MessageLookupByLibrary.simpleMessage( + "Add your weight", + ), + "insights_bmi_higher": m1, + "insights_bmi_hint": m2, + "insights_bmi_lower": m3, + "insights_bmi_normal": MessageLookupByLibrary.simpleMessage("normal"), + "insights_bmi_obesity_1": MessageLookupByLibrary.simpleMessage( + "obesity class I", + ), + "insights_bmi_obesity_2": MessageLookupByLibrary.simpleMessage( + "obesity class II", + ), + "insights_bmi_obesity_3": MessageLookupByLibrary.simpleMessage( + "obesity class III", + ), + "insights_bmi_overweight": MessageLookupByLibrary.simpleMessage( + "overweight", + ), + "insights_bmi_same": m4, + "insights_bmi_severe_underweight": MessageLookupByLibrary.simpleMessage( + "severe underweight", + ), + "insights_bmi_underweight": MessageLookupByLibrary.simpleMessage( + "underweight", + ), + "insights_intra_hint": MessageLookupByLibrary.simpleMessage( + "Bars show what each meal consists of: protein, fat and carbs in kcal. Lines show satiety and mood on a 0–5 scale.", + ), + "insights_load_error": MessageLookupByLibrary.simpleMessage( + "Failed to load analytics", + ), + "insights_measure_less": m5, + "insights_measure_more": m6, + "insights_measure_same": m7, + "insights_measures_summary": m8, + "insights_need_height": MessageLookupByLibrary.simpleMessage( + "Set your height to calculate BMI.", + ), + "insights_next_day": MessageLookupByLibrary.simpleMessage("Next day"), + "insights_not_enough": MessageLookupByLibrary.simpleMessage( + "Not enough data", + ), + "insights_period_1m": MessageLookupByLibrary.simpleMessage("1M"), + "insights_period_1w": MessageLookupByLibrary.simpleMessage("1W"), + "insights_period_1y": MessageLookupByLibrary.simpleMessage("1Y"), + "insights_period_2w": MessageLookupByLibrary.simpleMessage("2W"), + "insights_period_3m": MessageLookupByLibrary.simpleMessage("3M"), + "insights_period_6m": MessageLookupByLibrary.simpleMessage("6M"), + "insights_period_all": MessageLookupByLibrary.simpleMessage("All time"), + "insights_period_label_1": MessageLookupByLibrary.simpleMessage("month"), + "insights_period_label_12": MessageLookupByLibrary.simpleMessage("year"), + "insights_period_label_3": MessageLookupByLibrary.simpleMessage("3 months"), + "insights_period_label_6": MessageLookupByLibrary.simpleMessage("6 months"), + "insights_period_label_all": MessageLookupByLibrary.simpleMessage( + "all time", + ), + "insights_pick_date": MessageLookupByLibrary.simpleMessage("Pick a date"), + "insights_prev_day": MessageLookupByLibrary.simpleMessage("Previous day"), + "insights_retry": MessageLookupByLibrary.simpleMessage("Retry"), + "insights_save_error": MessageLookupByLibrary.simpleMessage( + "Failed to save the entry", + ), + "insights_series_calories": MessageLookupByLibrary.simpleMessage( + "Calories", + ), + "insights_series_carbs": MessageLookupByLibrary.simpleMessage("Carbs"), + "insights_series_chest": MessageLookupByLibrary.simpleMessage("Chest"), + "insights_series_fats": MessageLookupByLibrary.simpleMessage("Fat"), + "insights_series_hips": MessageLookupByLibrary.simpleMessage("Hips"), + "insights_series_mood": MessageLookupByLibrary.simpleMessage("Mood"), + "insights_series_proteins": MessageLookupByLibrary.simpleMessage("Protein"), + "insights_series_satiety": MessageLookupByLibrary.simpleMessage("Satiety"), + "insights_series_sleep": MessageLookupByLibrary.simpleMessage( + "Sleep and wake-up", + ), + "insights_series_waist": MessageLookupByLibrary.simpleMessage("Waist"), + "insights_sleep_at": m9, + "insights_sleep_shade": MessageLookupByLibrary.simpleMessage( + "Shading — sleep time", + ), + "insights_subtitle_closed": MessageLookupByLibrary.simpleMessage( + "tap to see your progress", + ), + "insights_subtitle_open": MessageLookupByLibrary.simpleMessage( + "weight, measurements and nutrition", + ), + "insights_tab_bmi": MessageLookupByLibrary.simpleMessage("BMI"), + "insights_tab_daily": MessageLookupByLibrary.simpleMessage( + "Nutrition by day", + ), + "insights_tab_intra": MessageLookupByLibrary.simpleMessage( + "Throughout the day", + ), + "insights_tab_measures": MessageLookupByLibrary.simpleMessage( + "Measurements", + ), + "insights_tab_weight": MessageLookupByLibrary.simpleMessage("Weight"), + "insights_title": MessageLookupByLibrary.simpleMessage("Analytics"), + "insights_today": MessageLookupByLibrary.simpleMessage("Today"), + "insights_unit_bmi": MessageLookupByLibrary.simpleMessage("BMI"), + "insights_unit_cm": MessageLookupByLibrary.simpleMessage("cm"), + "insights_unit_cm_waist": MessageLookupByLibrary.simpleMessage("cm waist"), + "insights_unit_grams": MessageLookupByLibrary.simpleMessage("grams"), + "insights_unit_kcal": MessageLookupByLibrary.simpleMessage("kcal"), + "insights_unit_kcal_avg": MessageLookupByLibrary.simpleMessage( + "kcal on average", + ), + "insights_unit_kcal_per_meal": MessageLookupByLibrary.simpleMessage( + "kcal per meal", + ), + "insights_unit_kg": MessageLookupByLibrary.simpleMessage("kg"), + "insights_wake_at": m10, + "insights_weight_gained": m11, + "insights_weight_lost": m12, + "insights_weight_same": m13, "invite": MessageLookupByLibrary.simpleMessage("Пригласить"), "invited_people_count": MessageLookupByLibrary.simpleMessage( "Количество приглашенных", @@ -684,11 +834,11 @@ class MessageLookup extends MessageLookupByLibrary { "Физическая форма", ), "place_of_living": MessageLookupByLibrary.simpleMessage("Место проживания"), - "planStarted": m1, + "planStarted": m14, "planWillBeConfirmedAfterThreeDays": MessageLookupByLibrary.simpleMessage( "Если вы не подтвердите, то план через три дня подтвердится автоматически", ), - "planWithUserStarted": m2, + "planWithUserStarted": m15, "plan_author": MessageLookupByLibrary.simpleMessage("Автор плана"), "plan_desc": MessageLookupByLibrary.simpleMessage("Описание плана"), "plan_desc_example": MessageLookupByLibrary.simpleMessage( @@ -731,7 +881,7 @@ class MessageLookup extends MessageLookupByLibrary { "Promo code applied!", ), "promo_code": MessageLookupByLibrary.simpleMessage("Promo code"), - "promo_discount_hint": m3, + "promo_discount_hint": m16, "promo_invalid": MessageLookupByLibrary.simpleMessage( "Promo code not found or expired", ), @@ -857,7 +1007,7 @@ class MessageLookup extends MessageLookupByLibrary { "step_by_step_photo_recipe": MessageLookupByLibrary.simpleMessage( "Пошаговый фоторецепт", ), - "step_widget": m4, + "step_widget": m17, "successfully_published": MessageLookupByLibrary.simpleMessage( "успешно опубликован", ), @@ -882,8 +1032,8 @@ class MessageLookup extends MessageLookupByLibrary { "toOffer": MessageLookupByLibrary.simpleMessage("Предложить"), "to_draft": MessageLookupByLibrary.simpleMessage("В черновик"), "to_order": MessageLookupByLibrary.simpleMessage("Заказать"), - "tommorowPlanWithUserWillStart": m5, - "tomorrowYourPlanWillStart": m6, + "tommorowPlanWithUserWillStart": m18, + "tomorrowYourPlanWillStart": m19, "try_again": MessageLookupByLibrary.simpleMessage("Попробовать снова"), "try_again_2": MessageLookupByLibrary.simpleMessage("Попробуйте ещё раз"), "typeAmoutOfReturn": MessageLookupByLibrary.simpleMessage( @@ -917,7 +1067,7 @@ class MessageLookup extends MessageLookupByLibrary { "Верхние показатели давления:", ), "upper": MessageLookupByLibrary.simpleMessage("Верхнее"), - "userCanceldThePlanSubscription": m7, + "userCanceldThePlanSubscription": m20, "userConfirmedPlanCompletion": MessageLookupByLibrary.simpleMessage( "Пользователь подтвердил прохождения плана. В ближайшее время Вы получите", ), @@ -925,8 +1075,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage( "Пользователь подтвердил прохождения плана.", ), - "userPaidThePlan": m8, - "userSubscribedToPlan": m9, + "userPaidThePlan": m21, + "userSubscribedToPlan": m22, "user_authorization": MessageLookupByLibrary.simpleMessage( "Аутентификация", ), @@ -1056,7 +1206,7 @@ class MessageLookup extends MessageLookupByLibrary { "youCanComplain": MessageLookupByLibrary.simpleMessage( "Если вас что-то не устроило, вы можете подать жалобу", ), - "youCanceledSubscriptionToPlan": m10, + "youCanceledSubscriptionToPlan": m23, "youShureYouWantToCancelOrder": MessageLookupByLibrary.simpleMessage( "Вы уверены, что хотите отменить заказ?", ), diff --git a/lib/generated/intl/messages_ru.dart b/lib/generated/intl/messages_ru.dart index 28874ef1..8a623abf 100644 --- a/lib/generated/intl/messages_ru.dart +++ b/lib/generated/intl/messages_ru.dart @@ -20,7 +20,37 @@ typedef String MessageIfAbsent(String messageStr, List args); class MessageLookup extends MessageLookupByLibrary { String get localeName => 'ru'; - static String m3(percent, amount) => + static String m1(value, period, category) => + "ИМТ выше на ${value} за ${period} · ${category}"; + + static String m2(height) => + "Зелёная зона — норма (18.5–25), жёлтая — избыточный вес, розовая — ожирение. Считаем при росте ${height} см."; + + static String m3(value, period, category) => + "ИМТ ниже на ${value} за ${period} · ${category}"; + + static String m4(period, category) => + "ИМТ не изменился за ${period} · ${category}"; + + static String m5(name, value) => "${name} меньше на ${value} см"; + + static String m6(name, value) => "${name} больше на ${value} см"; + + static String m7(name) => "${name} без изменений"; + + static String m8(parts, period) => "${parts} за ${period}"; + + static String m9(time) => "Отход ко сну в ${time}"; + + static String m10(time) => "Подъём в ${time}"; + + static String m11(value, period) => "Набрали ${value} кг за ${period}"; + + static String m12(value, period) => "Снизили на ${value} кг за ${period}"; + + static String m13(period) => "Вес не изменился за ${period}"; + + static String m16(percent, amount) => "Скидка ${percent}% — вы экономите ${amount} ₽"; final messages = _notInlinedMessages(_notInlinedMessages); @@ -212,6 +242,124 @@ class MessageLookup extends MessageLookupByLibrary { "initial_measurements": MessageLookupByLibrary.simpleMessage( "Первичные замеры", ), + "insights_add_measures_empty": MessageLookupByLibrary.simpleMessage( + "Добавьте Ваши замеры", + ), + "insights_add_record": MessageLookupByLibrary.simpleMessage( + "Добавить запись", + ), + "insights_add_weight_empty": MessageLookupByLibrary.simpleMessage( + "Добавьте Ваш вес", + ), + "insights_bmi_higher": m1, + "insights_bmi_hint": m2, + "insights_bmi_lower": m3, + "insights_bmi_normal": MessageLookupByLibrary.simpleMessage("норма"), + "insights_bmi_obesity_1": MessageLookupByLibrary.simpleMessage( + "ожирение I ст.", + ), + "insights_bmi_obesity_2": MessageLookupByLibrary.simpleMessage( + "ожирение II ст.", + ), + "insights_bmi_obesity_3": MessageLookupByLibrary.simpleMessage( + "ожирение III ст.", + ), + "insights_bmi_overweight": MessageLookupByLibrary.simpleMessage( + "избыточная масса", + ), + "insights_bmi_same": m4, + "insights_bmi_severe_underweight": MessageLookupByLibrary.simpleMessage( + "выраженный дефицит массы", + ), + "insights_bmi_underweight": MessageLookupByLibrary.simpleMessage( + "недостаточная масса", + ), + "insights_intra_hint": MessageLookupByLibrary.simpleMessage( + "Столбики показывают, из чего состоят приёмы пищи: белки, жиры и углеводы в ккал. Линии — сытость и настроение по шкале от 0 до 5.", + ), + "insights_load_error": MessageLookupByLibrary.simpleMessage( + "Не удалось загрузить аналитику", + ), + "insights_measure_less": m5, + "insights_measure_more": m6, + "insights_measure_same": m7, + "insights_measures_summary": m8, + "insights_need_height": MessageLookupByLibrary.simpleMessage( + "Укажите рост, чтобы посчитать ИМТ.", + ), + "insights_next_day": MessageLookupByLibrary.simpleMessage("Следующий день"), + "insights_not_enough": MessageLookupByLibrary.simpleMessage( + "Недостаточно данных", + ), + "insights_period_1m": MessageLookupByLibrary.simpleMessage("1М"), + "insights_period_1w": MessageLookupByLibrary.simpleMessage("1Н"), + "insights_period_1y": MessageLookupByLibrary.simpleMessage("1Г"), + "insights_period_2w": MessageLookupByLibrary.simpleMessage("2Н"), + "insights_period_3m": MessageLookupByLibrary.simpleMessage("3М"), + "insights_period_6m": MessageLookupByLibrary.simpleMessage("6М"), + "insights_period_all": MessageLookupByLibrary.simpleMessage("Всё время"), + "insights_period_label_1": MessageLookupByLibrary.simpleMessage("месяц"), + "insights_period_label_12": MessageLookupByLibrary.simpleMessage("год"), + "insights_period_label_3": MessageLookupByLibrary.simpleMessage("3 месяца"), + "insights_period_label_6": MessageLookupByLibrary.simpleMessage( + "6 месяцев", + ), + "insights_period_label_all": MessageLookupByLibrary.simpleMessage( + "всё время", + ), + "insights_pick_date": MessageLookupByLibrary.simpleMessage("Выбрать дату"), + "insights_prev_day": MessageLookupByLibrary.simpleMessage( + "Предыдущий день", + ), + "insights_retry": MessageLookupByLibrary.simpleMessage("Повторить"), + "insights_save_error": MessageLookupByLibrary.simpleMessage( + "Не удалось сохранить запись", + ), + "insights_series_calories": MessageLookupByLibrary.simpleMessage("Калории"), + "insights_series_carbs": MessageLookupByLibrary.simpleMessage("Углеводы"), + "insights_series_chest": MessageLookupByLibrary.simpleMessage("Грудь"), + "insights_series_fats": MessageLookupByLibrary.simpleMessage("Жиры"), + "insights_series_hips": MessageLookupByLibrary.simpleMessage("Бёдра"), + "insights_series_mood": MessageLookupByLibrary.simpleMessage("Настроение"), + "insights_series_proteins": MessageLookupByLibrary.simpleMessage("Белки"), + "insights_series_satiety": MessageLookupByLibrary.simpleMessage("Сытость"), + "insights_series_sleep": MessageLookupByLibrary.simpleMessage( + "Сон и подъём", + ), + "insights_series_waist": MessageLookupByLibrary.simpleMessage("Талия"), + "insights_sleep_at": m9, + "insights_sleep_shade": MessageLookupByLibrary.simpleMessage( + "Затемнение — время сна", + ), + "insights_subtitle_closed": MessageLookupByLibrary.simpleMessage( + "нажмите, чтобы посмотреть прогресс", + ), + "insights_subtitle_open": MessageLookupByLibrary.simpleMessage( + "вес, замеры и питание", + ), + "insights_tab_bmi": MessageLookupByLibrary.simpleMessage("ИМТ"), + "insights_tab_daily": MessageLookupByLibrary.simpleMessage("КБЖУ по дням"), + "insights_tab_intra": MessageLookupByLibrary.simpleMessage("В течение дня"), + "insights_tab_measures": MessageLookupByLibrary.simpleMessage("Замеры"), + "insights_tab_weight": MessageLookupByLibrary.simpleMessage("Вес"), + "insights_title": MessageLookupByLibrary.simpleMessage("Аналитика"), + "insights_today": MessageLookupByLibrary.simpleMessage("Сегодня"), + "insights_unit_bmi": MessageLookupByLibrary.simpleMessage("ИМТ"), + "insights_unit_cm": MessageLookupByLibrary.simpleMessage("см"), + "insights_unit_cm_waist": MessageLookupByLibrary.simpleMessage("см талия"), + "insights_unit_grams": MessageLookupByLibrary.simpleMessage("граммы"), + "insights_unit_kcal": MessageLookupByLibrary.simpleMessage("ккал"), + "insights_unit_kcal_avg": MessageLookupByLibrary.simpleMessage( + "ккал в среднем", + ), + "insights_unit_kcal_per_meal": MessageLookupByLibrary.simpleMessage( + "ккал за приём", + ), + "insights_unit_kg": MessageLookupByLibrary.simpleMessage("кг"), + "insights_wake_at": m10, + "insights_weight_gained": m11, + "insights_weight_lost": m12, + "insights_weight_same": m13, "label": MessageLookupByLibrary.simpleMessage("Название"), "language": MessageLookupByLibrary.simpleMessage("Язык"), "last_name": MessageLookupByLibrary.simpleMessage("Фамилия"), @@ -338,7 +486,7 @@ class MessageLookup extends MessageLookupByLibrary { "Промокод применён!", ), "promo_code": MessageLookupByLibrary.simpleMessage("Промокод"), - "promo_discount_hint": m3, + "promo_discount_hint": m16, "promo_invalid": MessageLookupByLibrary.simpleMessage( "Промокод не найден или истёк", ), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 26dab236..6d77b17b 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -4553,6 +4553,661 @@ class S { args: [], ); } + + /// `Analytics` + String get insights_title { + return Intl.message( + 'Analytics', + name: 'insights_title', + desc: '', + args: [], + ); + } + + /// `tap to see your progress` + String get insights_subtitle_closed { + return Intl.message( + 'tap to see your progress', + name: 'insights_subtitle_closed', + desc: '', + args: [], + ); + } + + /// `weight, measurements and nutrition` + String get insights_subtitle_open { + return Intl.message( + 'weight, measurements and nutrition', + name: 'insights_subtitle_open', + desc: '', + args: [], + ); + } + + /// `Weight` + String get insights_tab_weight { + return Intl.message( + 'Weight', + name: 'insights_tab_weight', + desc: '', + args: [], + ); + } + + /// `BMI` + String get insights_tab_bmi { + return Intl.message('BMI', name: 'insights_tab_bmi', desc: '', args: []); + } + + /// `Measurements` + String get insights_tab_measures { + return Intl.message( + 'Measurements', + name: 'insights_tab_measures', + desc: '', + args: [], + ); + } + + /// `Nutrition by day` + String get insights_tab_daily { + return Intl.message( + 'Nutrition by day', + name: 'insights_tab_daily', + desc: '', + args: [], + ); + } + + /// `Throughout the day` + String get insights_tab_intra { + return Intl.message( + 'Throughout the day', + name: 'insights_tab_intra', + desc: '', + args: [], + ); + } + + /// `1M` + String get insights_period_1m { + return Intl.message('1M', name: 'insights_period_1m', desc: '', args: []); + } + + /// `3M` + String get insights_period_3m { + return Intl.message('3M', name: 'insights_period_3m', desc: '', args: []); + } + + /// `6M` + String get insights_period_6m { + return Intl.message('6M', name: 'insights_period_6m', desc: '', args: []); + } + + /// `1Y` + String get insights_period_1y { + return Intl.message('1Y', name: 'insights_period_1y', desc: '', args: []); + } + + /// `All time` + String get insights_period_all { + return Intl.message( + 'All time', + name: 'insights_period_all', + desc: '', + args: [], + ); + } + + /// `1W` + String get insights_period_1w { + return Intl.message('1W', name: 'insights_period_1w', desc: '', args: []); + } + + /// `2W` + String get insights_period_2w { + return Intl.message('2W', name: 'insights_period_2w', desc: '', args: []); + } + + /// `Waist` + String get insights_series_waist { + return Intl.message( + 'Waist', + name: 'insights_series_waist', + desc: '', + args: [], + ); + } + + /// `Chest` + String get insights_series_chest { + return Intl.message( + 'Chest', + name: 'insights_series_chest', + desc: '', + args: [], + ); + } + + /// `Hips` + String get insights_series_hips { + return Intl.message( + 'Hips', + name: 'insights_series_hips', + desc: '', + args: [], + ); + } + + /// `Calories` + String get insights_series_calories { + return Intl.message( + 'Calories', + name: 'insights_series_calories', + desc: '', + args: [], + ); + } + + /// `Protein` + String get insights_series_proteins { + return Intl.message( + 'Protein', + name: 'insights_series_proteins', + desc: '', + args: [], + ); + } + + /// `Fat` + String get insights_series_fats { + return Intl.message( + 'Fat', + name: 'insights_series_fats', + desc: '', + args: [], + ); + } + + /// `Carbs` + String get insights_series_carbs { + return Intl.message( + 'Carbs', + name: 'insights_series_carbs', + desc: '', + args: [], + ); + } + + /// `Satiety` + String get insights_series_satiety { + return Intl.message( + 'Satiety', + name: 'insights_series_satiety', + desc: '', + args: [], + ); + } + + /// `Mood` + String get insights_series_mood { + return Intl.message( + 'Mood', + name: 'insights_series_mood', + desc: '', + args: [], + ); + } + + /// `Sleep and wake-up` + String get insights_series_sleep { + return Intl.message( + 'Sleep and wake-up', + name: 'insights_series_sleep', + desc: '', + args: [], + ); + } + + /// `kg` + String get insights_unit_kg { + return Intl.message('kg', name: 'insights_unit_kg', desc: '', args: []); + } + + /// `BMI` + String get insights_unit_bmi { + return Intl.message('BMI', name: 'insights_unit_bmi', desc: '', args: []); + } + + /// `cm` + String get insights_unit_cm { + return Intl.message('cm', name: 'insights_unit_cm', desc: '', args: []); + } + + /// `cm waist` + String get insights_unit_cm_waist { + return Intl.message( + 'cm waist', + name: 'insights_unit_cm_waist', + desc: '', + args: [], + ); + } + + /// `kcal` + String get insights_unit_kcal { + return Intl.message('kcal', name: 'insights_unit_kcal', desc: '', args: []); + } + + /// `kcal on average` + String get insights_unit_kcal_avg { + return Intl.message( + 'kcal on average', + name: 'insights_unit_kcal_avg', + desc: '', + args: [], + ); + } + + /// `grams` + String get insights_unit_grams { + return Intl.message( + 'grams', + name: 'insights_unit_grams', + desc: '', + args: [], + ); + } + + /// `kcal per meal` + String get insights_unit_kcal_per_meal { + return Intl.message( + 'kcal per meal', + name: 'insights_unit_kcal_per_meal', + desc: '', + args: [], + ); + } + + /// `month` + String get insights_period_label_1 { + return Intl.message( + 'month', + name: 'insights_period_label_1', + desc: '', + args: [], + ); + } + + /// `3 months` + String get insights_period_label_3 { + return Intl.message( + '3 months', + name: 'insights_period_label_3', + desc: '', + args: [], + ); + } + + /// `6 months` + String get insights_period_label_6 { + return Intl.message( + '6 months', + name: 'insights_period_label_6', + desc: '', + args: [], + ); + } + + /// `year` + String get insights_period_label_12 { + return Intl.message( + 'year', + name: 'insights_period_label_12', + desc: '', + args: [], + ); + } + + /// `all time` + String get insights_period_label_all { + return Intl.message( + 'all time', + name: 'insights_period_label_all', + desc: '', + args: [], + ); + } + + /// `Not enough data` + String get insights_not_enough { + return Intl.message( + 'Not enough data', + name: 'insights_not_enough', + desc: '', + args: [], + ); + } + + /// `Set your height to calculate BMI.` + String get insights_need_height { + return Intl.message( + 'Set your height to calculate BMI.', + name: 'insights_need_height', + desc: '', + args: [], + ); + } + + /// `Lost {value} kg over {period}` + String insights_weight_lost(String value, String period) { + return Intl.message( + 'Lost $value kg over $period', + name: 'insights_weight_lost', + desc: '', + args: [value, period], + ); + } + + /// `Gained {value} kg over {period}` + String insights_weight_gained(String value, String period) { + return Intl.message( + 'Gained $value kg over $period', + name: 'insights_weight_gained', + desc: '', + args: [value, period], + ); + } + + /// `Weight unchanged over {period}` + String insights_weight_same(String period) { + return Intl.message( + 'Weight unchanged over $period', + name: 'insights_weight_same', + desc: '', + args: [period], + ); + } + + /// `BMI lower by {value} over {period} · {category}` + String insights_bmi_lower(String value, String period, String category) { + return Intl.message( + 'BMI lower by $value over $period · $category', + name: 'insights_bmi_lower', + desc: '', + args: [value, period, category], + ); + } + + /// `BMI higher by {value} over {period} · {category}` + String insights_bmi_higher(String value, String period, String category) { + return Intl.message( + 'BMI higher by $value over $period · $category', + name: 'insights_bmi_higher', + desc: '', + args: [value, period, category], + ); + } + + /// `BMI unchanged over {period} · {category}` + String insights_bmi_same(String period, String category) { + return Intl.message( + 'BMI unchanged over $period · $category', + name: 'insights_bmi_same', + desc: '', + args: [period, category], + ); + } + + /// `Green zone is normal (18.5–25), yellow is overweight, pink is obesity. Calculated for a height of {height} cm.` + String insights_bmi_hint(String height) { + return Intl.message( + 'Green zone is normal (18.5–25), yellow is overweight, pink is obesity. Calculated for a height of $height cm.', + name: 'insights_bmi_hint', + desc: '', + args: [height], + ); + } + + /// `severe underweight` + String get insights_bmi_severe_underweight { + return Intl.message( + 'severe underweight', + name: 'insights_bmi_severe_underweight', + desc: '', + args: [], + ); + } + + /// `underweight` + String get insights_bmi_underweight { + return Intl.message( + 'underweight', + name: 'insights_bmi_underweight', + desc: '', + args: [], + ); + } + + /// `normal` + String get insights_bmi_normal { + return Intl.message( + 'normal', + name: 'insights_bmi_normal', + desc: '', + args: [], + ); + } + + /// `overweight` + String get insights_bmi_overweight { + return Intl.message( + 'overweight', + name: 'insights_bmi_overweight', + desc: '', + args: [], + ); + } + + /// `obesity class I` + String get insights_bmi_obesity_1 { + return Intl.message( + 'obesity class I', + name: 'insights_bmi_obesity_1', + desc: '', + args: [], + ); + } + + /// `obesity class II` + String get insights_bmi_obesity_2 { + return Intl.message( + 'obesity class II', + name: 'insights_bmi_obesity_2', + desc: '', + args: [], + ); + } + + /// `obesity class III` + String get insights_bmi_obesity_3 { + return Intl.message( + 'obesity class III', + name: 'insights_bmi_obesity_3', + desc: '', + args: [], + ); + } + + /// `{name} down {value} cm` + String insights_measure_less(String name, String value) { + return Intl.message( + '$name down $value cm', + name: 'insights_measure_less', + desc: '', + args: [name, value], + ); + } + + /// `{name} up {value} cm` + String insights_measure_more(String name, String value) { + return Intl.message( + '$name up $value cm', + name: 'insights_measure_more', + desc: '', + args: [name, value], + ); + } + + /// `{name} unchanged` + String insights_measure_same(String name) { + return Intl.message( + '$name unchanged', + name: 'insights_measure_same', + desc: '', + args: [name], + ); + } + + /// `{parts} over {period}` + String insights_measures_summary(String parts, String period) { + return Intl.message( + '$parts over $period', + name: 'insights_measures_summary', + desc: '', + args: [parts, period], + ); + } + + /// `Add entry` + String get insights_add_record { + return Intl.message( + 'Add entry', + name: 'insights_add_record', + desc: '', + args: [], + ); + } + + /// `Add your weight` + String get insights_add_weight_empty { + return Intl.message( + 'Add your weight', + name: 'insights_add_weight_empty', + desc: '', + args: [], + ); + } + + /// `Add your measurements` + String get insights_add_measures_empty { + return Intl.message( + 'Add your measurements', + name: 'insights_add_measures_empty', + desc: '', + args: [], + ); + } + + /// `Failed to load analytics` + String get insights_load_error { + return Intl.message( + 'Failed to load analytics', + name: 'insights_load_error', + desc: '', + args: [], + ); + } + + /// `Retry` + String get insights_retry { + return Intl.message('Retry', name: 'insights_retry', desc: '', args: []); + } + + /// `Bars show what each meal consists of: protein, fat and carbs in kcal. Lines show satiety and mood on a 0–5 scale.` + String get insights_intra_hint { + return Intl.message( + 'Bars show what each meal consists of: protein, fat and carbs in kcal. Lines show satiety and mood on a 0–5 scale.', + name: 'insights_intra_hint', + desc: '', + args: [], + ); + } + + /// `Shading — sleep time` + String get insights_sleep_shade { + return Intl.message( + 'Shading — sleep time', + name: 'insights_sleep_shade', + desc: '', + args: [], + ); + } + + /// `Wake-up at {time}` + String insights_wake_at(String time) { + return Intl.message( + 'Wake-up at $time', + name: 'insights_wake_at', + desc: '', + args: [time], + ); + } + + /// `Bedtime at {time}` + String insights_sleep_at(String time) { + return Intl.message( + 'Bedtime at $time', + name: 'insights_sleep_at', + desc: '', + args: [time], + ); + } + + /// `Previous day` + String get insights_prev_day { + return Intl.message( + 'Previous day', + name: 'insights_prev_day', + desc: '', + args: [], + ); + } + + /// `Next day` + String get insights_next_day { + return Intl.message( + 'Next day', + name: 'insights_next_day', + desc: '', + args: [], + ); + } + + /// `Pick a date` + String get insights_pick_date { + return Intl.message( + 'Pick a date', + name: 'insights_pick_date', + desc: '', + args: [], + ); + } + + /// `Today` + String get insights_today { + return Intl.message('Today', name: 'insights_today', desc: '', args: []); + } + + /// `Failed to save the entry` + String get insights_save_error { + return Intl.message( + 'Failed to save the entry', + name: 'insights_save_error', + desc: '', + args: [], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index bac64c12..549c4569 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -537,6 +537,198 @@ "video_call_close": "Close", "video_call_notification_in_progress": "Call in progress", "video_call_notification_return": "Return to call", - "video_call_notification_end": "End call" + "video_call_notification_end": "End call", + "insights_title": "Analytics", + "insights_subtitle_closed": "tap to see your progress", + "insights_subtitle_open": "weight, measurements and nutrition", + "insights_tab_weight": "Weight", + "insights_tab_bmi": "BMI", + "insights_tab_measures": "Measurements", + "insights_tab_daily": "Nutrition by day", + "insights_tab_intra": "Throughout the day", + "insights_period_1m": "1M", + "insights_period_3m": "3M", + "insights_period_6m": "6M", + "insights_period_1y": "1Y", + "insights_period_all": "All time", + "insights_period_1w": "1W", + "insights_period_2w": "2W", + "insights_series_waist": "Waist", + "insights_series_chest": "Chest", + "insights_series_hips": "Hips", + "insights_series_calories": "Calories", + "insights_series_proteins": "Protein", + "insights_series_fats": "Fat", + "insights_series_carbs": "Carbs", + "insights_series_satiety": "Satiety", + "insights_series_mood": "Mood", + "insights_series_sleep": "Sleep and wake-up", + "insights_unit_kg": "kg", + "insights_unit_bmi": "BMI", + "insights_unit_cm": "cm", + "insights_unit_cm_waist": "cm waist", + "insights_unit_kcal": "kcal", + "insights_unit_kcal_avg": "kcal on average", + "insights_unit_grams": "grams", + "insights_unit_kcal_per_meal": "kcal per meal", + "insights_period_label_1": "month", + "insights_period_label_3": "3 months", + "insights_period_label_6": "6 months", + "insights_period_label_12": "year", + "insights_period_label_all": "all time", + "insights_not_enough": "Not enough data", + "insights_need_height": "Set your height to calculate BMI.", + "insights_weight_lost": "Lost {value} kg over {period}", + "@insights_weight_lost": { + "placeholders": { + "value": { + "type": "String" + }, + "period": { + "type": "String" + } + } + }, + "insights_weight_gained": "Gained {value} kg over {period}", + "@insights_weight_gained": { + "placeholders": { + "value": { + "type": "String" + }, + "period": { + "type": "String" + } + } + }, + "insights_weight_same": "Weight unchanged over {period}", + "@insights_weight_same": { + "placeholders": { + "period": { + "type": "String" + } + } + }, + "insights_bmi_lower": "BMI lower by {value} over {period} · {category}", + "@insights_bmi_lower": { + "placeholders": { + "value": { + "type": "String" + }, + "period": { + "type": "String" + }, + "category": { + "type": "String" + } + } + }, + "insights_bmi_higher": "BMI higher by {value} over {period} · {category}", + "@insights_bmi_higher": { + "placeholders": { + "value": { + "type": "String" + }, + "period": { + "type": "String" + }, + "category": { + "type": "String" + } + } + }, + "insights_bmi_same": "BMI unchanged over {period} · {category}", + "@insights_bmi_same": { + "placeholders": { + "period": { + "type": "String" + }, + "category": { + "type": "String" + } + } + }, + "insights_bmi_hint": "Green zone is normal (18.5–25), yellow is overweight, pink is obesity. Calculated for a height of {height} cm.", + "@insights_bmi_hint": { + "placeholders": { + "height": { + "type": "String" + } + } + }, + "insights_bmi_severe_underweight": "severe underweight", + "insights_bmi_underweight": "underweight", + "insights_bmi_normal": "normal", + "insights_bmi_overweight": "overweight", + "insights_bmi_obesity_1": "obesity class I", + "insights_bmi_obesity_2": "obesity class II", + "insights_bmi_obesity_3": "obesity class III", + "insights_measure_less": "{name} down {value} cm", + "@insights_measure_less": { + "placeholders": { + "name": { + "type": "String" + }, + "value": { + "type": "String" + } + } + }, + "insights_measure_more": "{name} up {value} cm", + "@insights_measure_more": { + "placeholders": { + "name": { + "type": "String" + }, + "value": { + "type": "String" + } + } + }, + "insights_measure_same": "{name} unchanged", + "@insights_measure_same": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "insights_measures_summary": "{parts} over {period}", + "@insights_measures_summary": { + "placeholders": { + "parts": { + "type": "String" + }, + "period": { + "type": "String" + } + } + }, + "insights_add_record": "Add entry", + "insights_add_weight_empty": "Add your weight", + "insights_add_measures_empty": "Add your measurements", + "insights_load_error": "Failed to load analytics", + "insights_retry": "Retry", + "insights_intra_hint": "Bars show what each meal consists of: protein, fat and carbs in kcal. Lines show satiety and mood on a 0–5 scale.", + "insights_sleep_shade": "Shading — sleep time", + "insights_wake_at": "Wake-up at {time}", + "@insights_wake_at": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "insights_sleep_at": "Bedtime at {time}", + "@insights_sleep_at": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "insights_prev_day": "Previous day", + "insights_next_day": "Next day", + "insights_pick_date": "Pick a date", + "insights_today": "Today", + "insights_save_error": "Failed to save the entry" } - diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index 2f821fce..44149e9b 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -282,5 +282,198 @@ "video_call_notification_in_progress": "Звонок идёт", "video_call_notification_return": "Вернуться к звонку", "video_call_notification_end": "Завершить звонок", - "blog_post_image_min_size_error": "Минимальный размер изображения — 500×500 px" + "blog_post_image_min_size_error": "Минимальный размер изображения — 500×500 px", + "insights_title": "Аналитика", + "insights_subtitle_closed": "нажмите, чтобы посмотреть прогресс", + "insights_subtitle_open": "вес, замеры и питание", + "insights_tab_weight": "Вес", + "insights_tab_bmi": "ИМТ", + "insights_tab_measures": "Замеры", + "insights_tab_daily": "КБЖУ по дням", + "insights_tab_intra": "В течение дня", + "insights_period_1m": "1М", + "insights_period_3m": "3М", + "insights_period_6m": "6М", + "insights_period_1y": "1Г", + "insights_period_all": "Всё время", + "insights_period_1w": "1Н", + "insights_period_2w": "2Н", + "insights_series_waist": "Талия", + "insights_series_chest": "Грудь", + "insights_series_hips": "Бёдра", + "insights_series_calories": "Калории", + "insights_series_proteins": "Белки", + "insights_series_fats": "Жиры", + "insights_series_carbs": "Углеводы", + "insights_series_satiety": "Сытость", + "insights_series_mood": "Настроение", + "insights_series_sleep": "Сон и подъём", + "insights_unit_kg": "кг", + "insights_unit_bmi": "ИМТ", + "insights_unit_cm": "см", + "insights_unit_cm_waist": "см талия", + "insights_unit_kcal": "ккал", + "insights_unit_kcal_avg": "ккал в среднем", + "insights_unit_grams": "граммы", + "insights_unit_kcal_per_meal": "ккал за приём", + "insights_period_label_1": "месяц", + "insights_period_label_3": "3 месяца", + "insights_period_label_6": "6 месяцев", + "insights_period_label_12": "год", + "insights_period_label_all": "всё время", + "insights_not_enough": "Недостаточно данных", + "insights_need_height": "Укажите рост, чтобы посчитать ИМТ.", + "insights_weight_lost": "Снизили на {value} кг за {period}", + "@insights_weight_lost": { + "placeholders": { + "value": { + "type": "String" + }, + "period": { + "type": "String" + } + } + }, + "insights_weight_gained": "Набрали {value} кг за {period}", + "@insights_weight_gained": { + "placeholders": { + "value": { + "type": "String" + }, + "period": { + "type": "String" + } + } + }, + "insights_weight_same": "Вес не изменился за {period}", + "@insights_weight_same": { + "placeholders": { + "period": { + "type": "String" + } + } + }, + "insights_bmi_lower": "ИМТ ниже на {value} за {period} · {category}", + "@insights_bmi_lower": { + "placeholders": { + "value": { + "type": "String" + }, + "period": { + "type": "String" + }, + "category": { + "type": "String" + } + } + }, + "insights_bmi_higher": "ИМТ выше на {value} за {period} · {category}", + "@insights_bmi_higher": { + "placeholders": { + "value": { + "type": "String" + }, + "period": { + "type": "String" + }, + "category": { + "type": "String" + } + } + }, + "insights_bmi_same": "ИМТ не изменился за {period} · {category}", + "@insights_bmi_same": { + "placeholders": { + "period": { + "type": "String" + }, + "category": { + "type": "String" + } + } + }, + "insights_bmi_hint": "Зелёная зона — норма (18.5–25), жёлтая — избыточный вес, розовая — ожирение. Считаем при росте {height} см.", + "@insights_bmi_hint": { + "placeholders": { + "height": { + "type": "String" + } + } + }, + "insights_bmi_severe_underweight": "выраженный дефицит массы", + "insights_bmi_underweight": "недостаточная масса", + "insights_bmi_normal": "норма", + "insights_bmi_overweight": "избыточная масса", + "insights_bmi_obesity_1": "ожирение I ст.", + "insights_bmi_obesity_2": "ожирение II ст.", + "insights_bmi_obesity_3": "ожирение III ст.", + "insights_measure_less": "{name} меньше на {value} см", + "@insights_measure_less": { + "placeholders": { + "name": { + "type": "String" + }, + "value": { + "type": "String" + } + } + }, + "insights_measure_more": "{name} больше на {value} см", + "@insights_measure_more": { + "placeholders": { + "name": { + "type": "String" + }, + "value": { + "type": "String" + } + } + }, + "insights_measure_same": "{name} без изменений", + "@insights_measure_same": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "insights_measures_summary": "{parts} за {period}", + "@insights_measures_summary": { + "placeholders": { + "parts": { + "type": "String" + }, + "period": { + "type": "String" + } + } + }, + "insights_add_record": "Добавить запись", + "insights_add_weight_empty": "Добавьте Ваш вес", + "insights_add_measures_empty": "Добавьте Ваши замеры", + "insights_load_error": "Не удалось загрузить аналитику", + "insights_retry": "Повторить", + "insights_intra_hint": "Столбики показывают, из чего состоят приёмы пищи: белки, жиры и углеводы в ккал. Линии — сытость и настроение по шкале от 0 до 5.", + "insights_sleep_shade": "Затемнение — время сна", + "insights_wake_at": "Подъём в {time}", + "@insights_wake_at": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "insights_sleep_at": "Отход ко сну в {time}", + "@insights_sleep_at": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "insights_prev_day": "Предыдущий день", + "insights_next_day": "Следующий день", + "insights_pick_date": "Выбрать дату", + "insights_today": "Сегодня", + "insights_save_error": "Не удалось сохранить запись" } diff --git a/test/features/diary/presentation/pages/diary_screen_smoke_test.dart b/test/features/diary/presentation/pages/diary_screen_smoke_test.dart index cf2f669a..a4b3998a 100644 --- a/test/features/diary/presentation/pages/diary_screen_smoke_test.dart +++ b/test/features/diary/presentation/pages/diary_screen_smoke_test.dart @@ -15,6 +15,8 @@ import 'package:eatvio_app/features/diary/models/diary_day_model.dart'; import 'package:eatvio_app/features/diary/models/nutrition_model.dart'; import 'package:eatvio_app/core/widgets/data_error_widget.dart'; import 'package:eatvio_app/features/diary/presentation/widgets/meal_widget.dart'; +import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.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_dotenv/flutter_dotenv.dart'; @@ -23,6 +25,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; +import '../../../../fixtures/doubles/insights_doubles.dart'; import 'diary_screen_smoke_test.mocks.dart'; @GenerateNiceMocks([ @@ -44,6 +47,7 @@ void main() { AppConfig(buildType: AppBuildType.tests) ..setBaseUrl('http://localhost'), ); + sl.registerLazySingleton(() => FakeInsightsRepository()); }); Widget wrap(Widget child) { @@ -383,6 +387,76 @@ void main() { expect(find.byType(Text), findsWidgets); }); + Future pumpDiaryWithInsights( + WidgetTester tester, { + required bool isConsultantView, + String? ownerSlug, + required FakeInsightsRepository insightsRepository, + }) async { + final diaryCubit = MockDiaryCubit(); + final state = loadedState(hasChosenPlan: false).copyWith(isConsultantView: isConsultantView); + when(diaryCubit.state).thenReturn(state); + when(diaryCubit.stream).thenAnswer((_) => const Stream.empty()); + when(diaryCubit.close()).thenAnswer((_) async => Future.value()); + when( + diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId')), + ).thenAnswer((_) async => true); + + final commentsCubit = MockConsultantCommentsCubit(); + when(commentsCubit.state).thenReturn(ConsultantCommentsState(commentsModel: ConsultantCommentModel(comment: ''))); + when(commentsCubit.stream).thenAnswer((_) => const Stream.empty()); + when(commentsCubit.close()).thenAnswer((_) async => Future.value()); + + sl.registerFactory(() => diaryCubit); + sl.registerFactory(() => commentsCubit); + sl.unregister(); + sl.registerLazySingleton(() => insightsRepository); + + await tester.pumpWidget( + wrap(isConsultantView ? UserDiaryScreen(diaryId: 1, ownerSlug: ownerSlug) : DiaryScreen(date: DateTime(2025, 1, 1))), + ); + await tester.pump(const Duration(milliseconds: 200)); + } + + testWidgets('own diary shows insights card for own data with add button enabled', (tester) async { + final insightsRepository = FakeInsightsRepository(); + + await pumpDiaryWithInsights(tester, isConsultantView: false, insightsRepository: insightsRepository); + + expect(find.byType(InsightsCard), findsOneWidget); + expect(tester.widget(find.byType(InsightsCard)).slug, isNull); + expect(tester.widget(find.byType(InsightsCard)).canAdd, isTrue); + expect(insightsRepository.insightsRequests.single.slug, isNull); + }); + + testWidgets('consultant view shows insights card of the client without add button', (tester) async { + final insightsRepository = FakeInsightsRepository(); + + await pumpDiaryWithInsights(tester, isConsultantView: true, ownerSlug: 'client', insightsRepository: insightsRepository); + + final card = tester.widget(find.byType(InsightsCard)); + expect(card.slug, 'client'); + expect(card.canAdd, isFalse); + expect(insightsRepository.insightsRequests.single.slug, 'client'); + }); + + testWidgets('consultant view without owner slug hides insights card', (tester) async { + final insightsRepository = FakeInsightsRepository(); + + await pumpDiaryWithInsights(tester, isConsultantView: true, insightsRepository: insightsRepository); + + expect(find.byType(InsightsCard), findsNothing); + expect(insightsRepository.insightsRequests, isEmpty); + }); + + testWidgets('consultant view hides insights card on 403', (tester) async { + final insightsRepository = FakeInsightsRepository(error: const InsightsForbidden()); + + await pumpDiaryWithInsights(tester, isConsultantView: true, ownerSlug: 'client', insightsRepository: insightsRepository); + + expect(find.byKey(const Key('insights_card')), findsNothing); + }); + testWidgets('shows add button for a finished day inside the edit window', ( tester, ) async { diff --git a/test/features/insights/domain/insights_text_builder_test.dart b/test/features/insights/domain/insights_text_builder_test.dart new file mode 100644 index 00000000..78dfe39a --- /dev/null +++ b/test/features/insights/domain/insights_text_builder_test.dart @@ -0,0 +1,285 @@ +import 'package:eatvio_app/features/insights/domain/insights_text_builder.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'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +WeightModel _weight(double weight, {double? bmi, int day = 1}) { + final date = DateTime(2026, 11, day, 12); + return WeightModel( + id: day, + userId: 1, + weight: weight, + createdAt: date, + updatedAt: date, + bmi: bmi, + ); +} + +CircumferenceModel _circumference({ + double waist = 80, + double chest = 95, + double hips = 100, + int day = 1, +}) { + final date = DateTime(2026, 11, day, 12); + return CircumferenceModel( + id: day, + userId: 1, + chestCircumference: chest, + waistCircumference: waist, + hipCircumference: hips, + createdAt: date, + updatedAt: date, + ); +} + +void main() { + late InsightsTextBuilder builder; + + setUpAll(() async { + final s = await S.load(const Locale('ru')); + builder = InsightsTextBuilder(s); + }); + + group('форматирование', () { + test('formatNumber: один знак без хвостового нуля', () { + expect(builder.formatNumber(3), '3'); + expect(builder.formatNumber(3.5), '3.5'); + expect(builder.formatNumber(3.04), '3'); + expect(builder.formatNumber(3.26), '3.3'); + }); + + test('formatLabel: dd.MM', () { + expect(builder.formatLabel(DateTime(2026, 11, 9, 12)), '09.11'); + }); + + test('periodLabel: все периоды', () { + expect(builder.periodLabel(BodyPeriod.month), 'месяц'); + expect(builder.periodLabel(BodyPeriod.threeMonths), '3 месяца'); + expect(builder.periodLabel(BodyPeriod.sixMonths), '6 месяцев'); + expect(builder.periodLabel(BodyPeriod.year), 'год'); + expect(builder.periodLabel(BodyPeriod.all), 'всё время'); + }); + }); + + group('вес', () { + test('снижение', () { + final model = builder.buildWeight([ + _weight(74.5), + _weight(73.2, day: 9), + ], BodyPeriod.month); + + expect(model.deltaText, 'Снизили на 1.3 кг за месяц'); + expect(model.delta, -1.3); + expect(model.last, 73.2); + expect(model.headline, '73.2'); + expect(model.unit, 'кг'); + expect(model.labels, ['01.11', '09.11']); + expect(model.values, [74.5, 73.2]); + }); + + test('набор', () { + final model = builder.buildWeight([ + _weight(70), + _weight(72, day: 3), + ], BodyPeriod.threeMonths); + + expect(model.deltaText, 'Набрали 2 кг за 3 месяца'); + expect(model.delta, 2); + }); + + test('порог 0.05: меньше — не изменился', () { + final model = builder.buildWeight([ + _weight(70), + _weight(70.04, day: 3), + ], BodyPeriod.year); + + expect(model.deltaText, 'Вес не изменился за год'); + }); + + test('порог 0.05: ровно 0.25 — уже изменение', () { + final model = builder.buildWeight([ + _weight(70), + _weight(70.25, day: 3), + ], BodyPeriod.all); + + expect(model.deltaText, 'Набрали 0.3 кг за всё время'); + }); + + test('одна точка: недостаточно данных, но headline есть', () { + final model = builder.buildWeight([_weight(72)], BodyPeriod.month); + + expect(model.deltaText, 'Недостаточно данных'); + expect(model.delta, isNull); + expect(model.headline, '72.0'); + }); + + test('нет точек', () { + final model = builder.buildWeight([], BodyPeriod.month); + + expect(model.deltaText, 'Недостаточно данных'); + expect(model.last, isNull); + expect(model.headline, '—'); + expect(model.values, isEmpty); + }); + }); + + group('категории ИМТ (границы)', () { + final cases = { + 15.9: 'выраженный дефицит массы', + 16.0: 'недостаточная масса', + 18.4: 'недостаточная масса', + 18.5: 'норма', + 24.9: 'норма', + 25.0: 'избыточная масса', + 29.9: 'избыточная масса', + 30.0: 'ожирение I ст.', + 34.9: 'ожирение I ст.', + 35.0: 'ожирение II ст.', + 39.9: 'ожирение II ст.', + 40.0: 'ожирение III ст.', + 55.0: 'ожирение III ст.', + }; + + cases.forEach((bmi, expected) { + test('$bmi → $expected', () { + expect(builder.bmiCategory(bmi), expected); + }); + }); + }); + + group('ИМТ', () { + test('без роста: пусто и подсказка', () { + final model = builder.buildBmi( + [_weight(70, bmi: 22)], + BodyPeriod.month, + null, + ); + + expect(model.values, isEmpty); + expect(model.headline, '—'); + expect(model.hint, 'Укажите рост, чтобы посчитать ИМТ.'); + expect(model.deltaText, 'Укажите рост, чтобы посчитать ИМТ.'); + expect(model.category, isNull); + }); + + test('нулевой рост считается отсутствующим', () { + final model = builder.buildBmi( + [_weight(70, bmi: 22)], + BodyPeriod.month, + 0, + ); + + expect(model.values, isEmpty); + }); + + test('ИМТ ниже', () { + final model = builder.buildBmi( + [_weight(80, bmi: 26.5), _weight(75, bmi: 24.2, day: 5)], + BodyPeriod.threeMonths, + 176, + ); + + expect(model.deltaText, 'ИМТ ниже на 2.3 за 3 месяца · норма'); + expect(model.category, 'норма'); + expect(model.headline, '24.2'); + expect(model.last, 24.2); + expect(model.delta, -2.3); + expect(model.hint, contains('Считаем при росте 176 см.')); + }); + + test('ИМТ выше', () { + final model = builder.buildBmi( + [_weight(70, bmi: 24), _weight(80, bmi: 27, day: 5)], + BodyPeriod.month, + 170, + ); + + expect(model.deltaText, 'ИМТ выше на 3 за месяц · избыточная масса'); + }); + + test('ИМТ не изменился', () { + final model = builder.buildBmi( + [_weight(70, bmi: 22), _weight(70, bmi: 22.01, day: 5)], + BodyPeriod.year, + 170, + ); + + expect(model.deltaText, 'ИМТ не изменился за год · норма'); + }); + + test('точки с bmi = null пропускаются', () { + final model = builder.buildBmi( + [_weight(70, bmi: null), _weight(71, bmi: 22, day: 3)], + BodyPeriod.month, + 170, + ); + + expect(model.values, [22]); + expect(model.labels, ['03.11']); + expect(model.deltaText, 'Недостаточно данных'); + }); + + test('рост есть, точек нет', () { + final model = builder.buildBmi([], BodyPeriod.month, 170); + + expect(model.values, isEmpty); + expect(model.deltaText, 'Недостаточно данных'); + expect(model.hint, contains('170')); + }); + }); + + group('замеры', () { + test('по каждой части: меньше / больше / без изменений', () { + final model = builder.buildMeasure([ + _circumference(waist: 82, chest: 96, hips: 100), + _circumference(waist: 80.5, chest: 98, hips: 100, day: 9), + ], BodyPeriod.month); + + expect( + model.deltaText, + 'Талия меньше на 1.5 см, Грудь больше на 2 см, Бёдра без изменений за месяц', + ); + expect(model.headline, '81'); + expect(model.waist, [82, 80.5]); + expect(model.labels, ['01.11', '09.11']); + }); + + test('одна точка — недостаточно данных', () { + final model = builder.buildMeasure([_circumference()], BodyPeriod.month); + + expect(model.deltaText, 'Недостаточно данных'); + expect(model.headline, '80'); + }); + + test('нет точек', () { + final model = builder.buildMeasure([], BodyPeriod.month); + + expect(model.headline, '—'); + expect(model.deltaText, 'Недостаточно данных'); + }); + }); + + group('build', () { + test('сортирует по дате, daily/intra = null', () { + final payload = builder.build( + weights: [_weight(73, bmi: 23, day: 9), _weight(75, bmi: 24, day: 1)], + circumferences: [ + _circumference(waist: 79, day: 9), + _circumference(waist: 81), + ], + period: BodyPeriod.month, + heightCm: 175, + ); + + expect(payload.weight.values, [75, 73]); + expect(payload.bmi.values, [24, 23]); + expect(payload.measure.waist, [81, 79]); + expect(payload.daily, isNull); + expect(payload.intra, isNull); + }); + }); +} diff --git a/test/features/insights/models/insights_payload_model_test.dart b/test/features/insights/models/insights_payload_model_test.dart new file mode 100644 index 00000000..2c0f1bfd --- /dev/null +++ b/test/features/insights/models/insights_payload_model_test.dart @@ -0,0 +1,76 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:eatvio_app/features/insights/models/insights_payload_model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Map _fixture(String name) { + return jsonDecode(File('test/fixtures/insights/$name').readAsStringSync()) + as Map; +} + +void main() { + group('InsightsPayloadModel', () { + test('парсит полный payload (weight/bmi/measure/daily/intra)', () { + final model = InsightsPayloadModel.fromJson( + _fixture('insights_payload.json'), + ); + + expect(model.weight.values, [74.5, 73.9, 73.2]); + expect(model.weight.last, 73.2); + expect(model.weight.delta, -1.3); + expect(model.weight.unit, 'кг'); + + expect(model.measure.waist, [82.0, 80.5]); + expect(model.measure.chest, [96.0, 96.0]); + + expect(model.daily, isNotNull); + expect(model.daily!.kcal, [1850.0, 2010.0, 1930.0]); + expect(model.daily!.average, 1930); + + expect(model.intra, isNotNull); + expect(model.intra!.bars, hasLength(2)); + expect(model.intra!.bars.first.x, 8.5); + expect(model.intra!.bars.first.c, 240); + expect(model.intra!.satiety.last.y, 4); + expect(model.intra!.wake, 7.5); + expect(model.intra!.isToday, isTrue); + }); + + test('bmi.values пустой и last/delta/category = null', () { + final model = InsightsPayloadModel.fromJson( + _fixture('insights_payload.json'), + ); + + expect(model.bmi.values, isEmpty); + expect(model.bmi.last, isNull); + expect(model.bmi.delta, isNull); + expect(model.bmi.category, isNull); + expect(model.bmi.deltaText, contains('рост')); + }); + + test('intra без wake/sleep и null в average/percent', () { + final model = InsightsPayloadModel.fromJson( + _fixture('insights_payload_minimal.json'), + ); + + expect(model.intra!.wake, isNull); + expect(model.intra!.sleep, isNull); + expect(model.intra!.bars, isEmpty); + expect(model.daily!.average, isNull); + expect(model.daily!.percent, isNull); + expect(model.weight.last, isNull); + }); + + test('daily и intra необязательны', () { + final json = _fixture('insights_payload_minimal.json') + ..remove('daily') + ..remove('intra'); + + final model = InsightsPayloadModel.fromJson(json); + + expect(model.daily, isNull); + expect(model.intra, isNull); + }); + }); +} diff --git a/test/features/insights/presentation/cubit/insights_cubit_test.dart b/test/features/insights/presentation/cubit/insights_cubit_test.dart new file mode 100644 index 00000000..e45dcefc --- /dev/null +++ b/test/features/insights/presentation/cubit/insights_cubit_test.dart @@ -0,0 +1,430 @@ +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_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/repositories/insights_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockInsightsRepository extends Mock implements InsightsRepository {} + +const _payload = InsightsPayloadModel( + weight: WeightChartModel(values: [70, 71], headline: '71.0'), + bmi: BmiChartModel(), + measure: MeasureChartModel(), +); + +const _payload2 = InsightsPayloadModel( + weight: WeightChartModel(values: [72], headline: '72.0'), + bmi: BmiChartModel(), + measure: MeasureChartModel(), +); + +void main() { + late _MockInsightsRepository repository; + + setUpAll(() { + registerFallbackValue(BodyPeriod.month); + }); + + setUp(() { + repository = _MockInsightsRepository(); + when( + () => repository.getHeightCm(slug: any(named: 'slug')), + ).thenAnswer((_) async => 176); + when( + () => repository.getInsights( + slug: any(named: 'slug'), + bodyPeriod: any(named: 'bodyPeriod'), + heightCm: any(named: 'heightCm'), + ), + ).thenAnswer((_) async => _payload); + }); + + InsightsCubit buildCubit({ + String? slug, + bool canAdd = false, + bool kbjuEnabled = false, + }) { + return InsightsCubit( + repository: repository, + slug: slug, + canAdd: canAdd, + kbjuEnabled: kbjuEnabled, + ); + } + + InsightsStateLoaded loaded(InsightsCubit cubit) => + cubit.state as InsightsStateLoaded; + + test('начальное состояние — initial', () { + expect(buildCubit().state, isA()); + }); + + group('load', () { + test('дефолты: период 3М, вкладка «Вес», блок свёрнут', () async { + final cubit = buildCubit(); + final states = []; + cubit.stream.listen(states.add); + + await cubit.load(); + await Future.delayed(Duration.zero); + + expect(states.first, isA()); + final state = loaded(cubit); + expect(state.bodyPeriod, BodyPeriod.threeMonths); + expect(state.panel, InsightsPanel.weight); + expect(state.isExpanded, isFalse); + expect(state.isRefreshing, isFalse); + expect(state.showSleep, isTrue); + expect(state.payload, _payload); + verify( + () => repository.getInsights( + slug: null, + bodyPeriod: BodyPeriod.threeMonths, + heightCm: 176, + ), + ).called(1); + await cubit.close(); + }); + + test('пробрасывает slug владельца', () async { + final cubit = buildCubit(slug: 'client'); + + await cubit.load(); + + verify(() => repository.getHeightCm(slug: 'client')).called(1); + verify( + () => repository.getInsights( + slug: 'client', + bodyPeriod: BodyPeriod.threeMonths, + heightCm: 176, + ), + ).called(1); + await cubit.close(); + }); + + test('403 → hidden', () async { + when( + () => repository.getHeightCm(slug: any(named: 'slug')), + ).thenThrow(const InsightsForbidden()); + final cubit = buildCubit(slug: 'client'); + + await cubit.load(); + + expect(cubit.state, isA()); + await cubit.close(); + }); + + test('403 на запросе точек → hidden', () async { + when( + () => repository.getInsights( + slug: any(named: 'slug'), + bodyPeriod: any(named: 'bodyPeriod'), + heightCm: any(named: 'heightCm'), + ), + ).thenThrow(const InsightsForbidden()); + final cubit = buildCubit(slug: 'client'); + + await cubit.load(); + + expect(cubit.state, isA()); + await cubit.close(); + }); + + test('другая ошибка → error', () async { + when( + () => repository.getInsights( + slug: any(named: 'slug'), + bodyPeriod: any(named: 'bodyPeriod'), + heightCm: any(named: 'heightCm'), + ), + ).thenThrow(Exception('boom')); + final cubit = buildCubit(); + + await cubit.load(); + + expect(cubit.state, isA()); + await cubit.close(); + }); + }); + + group('setBodyPeriod', () { + test( + 'смена периода → перезапрос; рост не запрашивается повторно', + () async { + final cubit = buildCubit(); + await cubit.load(); + when( + () => repository.getInsights( + slug: null, + bodyPeriod: BodyPeriod.year, + heightCm: 176, + ), + ).thenAnswer((_) async => _payload2); + + await cubit.setBodyPeriod(BodyPeriod.year); + + expect(loaded(cubit).bodyPeriod, BodyPeriod.year); + expect(loaded(cubit).payload, _payload2); + expect(loaded(cubit).isRefreshing, isFalse); + verify(() => repository.getHeightCm(slug: null)).called(1); + await cubit.close(); + }, + ); + + test('тот же период — без запроса', () async { + final cubit = buildCubit(); + await cubit.load(); + clearInteractions(repository); + + await cubit.setBodyPeriod(BodyPeriod.threeMonths); + + verifyNever( + () => repository.getInsights( + slug: any(named: 'slug'), + bodyPeriod: any(named: 'bodyPeriod'), + heightCm: any(named: 'heightCm'), + ), + ); + await cubit.close(); + }); + + test('ошибка перезапроса возвращает прежний период и данные', () async { + final cubit = buildCubit(); + await cubit.load(); + when( + () => repository.getInsights( + slug: null, + bodyPeriod: BodyPeriod.all, + heightCm: 176, + ), + ).thenThrow(Exception('boom')); + + await cubit.setBodyPeriod(BodyPeriod.all); + + expect(loaded(cubit).bodyPeriod, BodyPeriod.threeMonths); + expect(loaded(cubit).payload, _payload); + expect(loaded(cubit).isRefreshing, isFalse); + await cubit.close(); + }); + + test('403 при перезапросе → hidden', () async { + final cubit = buildCubit(slug: 'c'); + await cubit.load(); + when( + () => repository.getInsights( + slug: 'c', + bodyPeriod: BodyPeriod.month, + heightCm: 176, + ), + ).thenThrow(const InsightsForbidden()); + + await cubit.setBodyPeriod(BodyPeriod.month); + + expect(cubit.state, isA()); + await cubit.close(); + }); + }); + + group('панели и серии', () { + test('toggleExpanded раскрывает и сворачивает', () async { + final cubit = buildCubit(); + await cubit.load(); + + cubit.toggleExpanded(); + expect(loaded(cubit).isExpanded, isTrue); + cubit.toggleExpanded(); + expect(loaded(cubit).isExpanded, isFalse); + await cubit.close(); + }); + + test('setPanel переключает вкладку', () async { + final cubit = buildCubit(); + await cubit.load(); + + cubit.setPanel(InsightsPanel.measures); + + expect(loaded(cubit).panel, InsightsPanel.measures); + await cubit.close(); + }); + + test('вкладки КБЖУ недоступны при выключенном флаге', () async { + final cubit = buildCubit(); + await cubit.load(); + + cubit.setPanel(InsightsPanel.daily); + + expect(loaded(cubit).panel, InsightsPanel.weight); + expect(cubit.panels, [ + InsightsPanel.weight, + InsightsPanel.bmi, + InsightsPanel.measures, + ]); + await cubit.close(); + }); + + test('вкладки КБЖУ доступны при включённом флаге', () async { + final cubit = buildCubit(kbjuEnabled: true); + await cubit.load(); + + cubit.setPanel(InsightsPanel.intra); + + expect(loaded(cubit).panel, InsightsPanel.intra); + expect(cubit.panels, InsightsPanel.values); + await cubit.close(); + }); + + test('toggleSeries переключает только выбранную серию', () async { + final cubit = buildCubit(); + await cubit.load(); + + cubit.toggleSeries(InsightsPanel.measures, 1); + expect(loaded(cubit).seriesVisibility.measure, [true, false, true]); + + cubit.toggleSeries(InsightsPanel.measures, 1); + expect(loaded(cubit).seriesVisibility.measure, [true, true, true]); + + cubit.toggleSeries(InsightsPanel.daily, 0); + expect(loaded(cubit).seriesVisibility.daily, [false, true, true, true]); + expect(loaded(cubit).seriesVisibility.measure, [true, true, true]); + await cubit.close(); + }); + + test( + 'toggleSeries игнорирует неверный индекс и вкладку без серий', + () async { + final cubit = buildCubit(); + await cubit.load(); + final before = loaded(cubit).seriesVisibility; + + cubit.toggleSeries(InsightsPanel.measures, 5); + cubit.toggleSeries(InsightsPanel.weight, 0); + + expect(loaded(cubit).seriesVisibility, before); + await cubit.close(); + }, + ); + + test('toggleSleep', () async { + final cubit = buildCubit(); + await cubit.load(); + + cubit.toggleSleep(); + + expect(loaded(cubit).showSleep, isFalse); + await cubit.close(); + }); + }); + + group('refresh и добавление записей', () { + test('refresh сохраняет вкладку, период, серии и раскрытие', () async { + final cubit = buildCubit(); + await cubit.load(); + cubit.toggleExpanded(); + cubit.setPanel(InsightsPanel.measures); + cubit.toggleSeries(InsightsPanel.measures, 0); + when( + () => repository.getInsights( + slug: null, + bodyPeriod: BodyPeriod.threeMonths, + heightCm: 176, + ), + ).thenAnswer((_) async => _payload2); + + await cubit.refresh(); + + final state = loaded(cubit); + expect(state.payload, _payload2); + expect(state.panel, InsightsPanel.measures); + expect(state.isExpanded, isTrue); + expect(state.seriesVisibility.measure, [false, true, true]); + expect(state.isRefreshing, isFalse); + await cubit.close(); + }); + + test('addWeight сохраняет и обновляет данные', () async { + when( + () => repository.addWeight(weight: any(named: 'weight')), + ).thenAnswer((_) async {}); + final cubit = buildCubit(canAdd: true); + await cubit.load(); + when( + () => repository.getInsights( + slug: null, + bodyPeriod: BodyPeriod.threeMonths, + heightCm: 176, + ), + ).thenAnswer((_) async => _payload2); + + final isSaved = await cubit.addWeight(weight: 72); + + expect(isSaved, isTrue); + verify(() => repository.addWeight(weight: 72)).called(1); + expect(loaded(cubit).payload, _payload2); + await cubit.close(); + }); + + test('addCircumference сохраняет и обновляет данные', () async { + when( + () => repository.addCircumference( + chestCircumference: any(named: 'chestCircumference'), + waistCircumference: any(named: 'waistCircumference'), + hipCircumference: any(named: 'hipCircumference'), + ), + ).thenAnswer((_) async {}); + final cubit = buildCubit(canAdd: true); + await cubit.load(); + when( + () => repository.getInsights( + slug: null, + bodyPeriod: BodyPeriod.threeMonths, + heightCm: 176, + ), + ).thenAnswer((_) async => _payload2); + + final isSaved = await cubit.addCircumference( + chestCircumference: 95, + waistCircumference: 80, + hipCircumference: 100, + ); + + expect(isSaved, isTrue); + + verify( + () => repository.addCircumference( + chestCircumference: 95, + waistCircumference: 80, + hipCircumference: 100, + ), + ).called(1); + expect(loaded(cubit).payload, _payload2); + await cubit.close(); + }); + + test( + 'ошибка сохранения не перезагружает данные и возвращает false', + () async { + when( + () => repository.addWeight(weight: any(named: 'weight')), + ).thenThrow(Exception('boom')); + final cubit = buildCubit(canAdd: true); + await cubit.load(); + clearInteractions(repository); + + expect(await cubit.addWeight(weight: 72), isFalse); + + verifyNever( + () => repository.getInsights( + slug: any(named: 'slug'), + bodyPeriod: any(named: 'bodyPeriod'), + heightCm: any(named: 'heightCm'), + ), + ); + expect(loaded(cubit).payload, _payload); + await cubit.close(); + }, + ); + }); +} diff --git a/test/features/insights/presentation/widgets/insights_card_test.dart b/test/features/insights/presentation/widgets/insights_card_test.dart new file mode 100644 index 00000000..18a29a44 --- /dev/null +++ b/test/features/insights/presentation/widgets/insights_card_test.dart @@ -0,0 +1,382 @@ +import 'package:eatvio_app/core/theme/theme_helper.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/measure_chart.dart'; +import 'package:eatvio_app/features/insights/presentation/charts/weight_chart.dart'; +import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.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_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../../fixtures/doubles/insights_doubles.dart'; + +void main() { + Widget wrap(Widget child) { + return MaterialApp( + theme: ThemeHelper().themeData(), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('ru')], + home: Scaffold(body: SingleChildScrollView(child: child)), + ); + } + + Future pumpCard( + WidgetTester tester, + FakeInsightsRepository repository, { + String? slug, + bool canAdd = false, + bool kbjuEnabled = false, + bool expand = true, + }) async { + tester.view.physicalSize = const Size(900, 2400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + wrap( + InsightsCard( + slug: slug, + canAdd: canAdd, + repository: repository, + kbjuEnabled: kbjuEnabled, + ), + ), + ); + await tester.pumpAndSettle(); + if (expand && + find.byKey(const Key('insights_toggle')).evaluate().isNotEmpty) { + await tester.tap(find.byKey(const Key('insights_toggle'))); + await tester.pumpAndSettle(); + } + } + + group('свёрнутое состояние', () { + testWidgets('по умолчанию свёрнут: виден заголовок, графиков нет', ( + tester, + ) async { + await pumpCard(tester, FakeInsightsRepository(), expand: false); + + expect(find.text('Аналитика'), findsOneWidget); + expect(find.text('нажмите, чтобы посмотреть прогресс'), findsOneWidget); + expect(find.byType(WeightChart), findsNothing); + expect(find.byKey(const Key('insights_tab_weight')), findsNothing); + }); + + testWidgets('тап по заголовку раскрывает и сворачивает блок', ( + tester, + ) async { + await pumpCard(tester, FakeInsightsRepository(), expand: false); + + await tester.tap(find.byKey(const Key('insights_toggle'))); + await tester.pumpAndSettle(); + expect(find.byType(WeightChart), findsOneWidget); + expect(find.text('вес, замеры и питание'), findsOneWidget); + + await tester.tap(find.byKey(const Key('insights_toggle'))); + await tester.pumpAndSettle(); + expect(find.byType(WeightChart), findsNothing); + }); + }); + + group('вкладки', () { + testWidgets( + 'показаны Вес, ИМТ, Замеры; вкладки КБЖУ скрыты при выключенном флаге', + (tester) async { + await pumpCard(tester, FakeInsightsRepository()); + + expect(find.byKey(const Key('insights_tab_weight')), findsOneWidget); + expect(find.byKey(const Key('insights_tab_bmi')), findsOneWidget); + expect(find.byKey(const Key('insights_tab_measures')), findsOneWidget); + expect(find.byKey(const Key('insights_tab_daily')), findsNothing); + expect(find.byKey(const Key('insights_tab_intra')), findsNothing); + }, + ); + + testWidgets('вкладки переключают панели', (tester) async { + await pumpCard(tester, FakeInsightsRepository()); + + expect(find.byType(WeightChart), findsOneWidget); + expect(find.byKey(const Key('insights_headline')), findsOneWidget); + + await tester.tap(find.byKey(const Key('insights_tab_measures'))); + await tester.pumpAndSettle(); + expect(find.byType(MeasureChart), findsOneWidget); + expect(find.byType(WeightChart), findsNothing); + + await tester.tap(find.byKey(const Key('insights_tab_bmi'))); + await tester.pumpAndSettle(); + expect(find.byType(BmiChart), findsOneWidget); + expect(find.byType(MeasureChart), findsNothing); + + await tester.tap(find.byKey(const Key('insights_tab_weight'))); + await tester.pumpAndSettle(); + expect(find.byType(WeightChart), findsOneWidget); + }); + + testWidgets('вкладки КБЖУ видны при включённом флаге', (tester) async { + await pumpCard(tester, FakeInsightsRepository(), kbjuEnabled: true); + + expect(find.byKey(const Key('insights_tab_daily')), findsOneWidget); + expect(find.byKey(const Key('insights_tab_intra')), findsOneWidget); + }); + }); + + group('вес', () { + testWidgets('показывает крупное число и текст дельты из payload', ( + tester, + ) async { + await pumpCard(tester, FakeInsightsRepository()); + + expect(find.text('73.2'), findsOneWidget); + expect(find.text('кг'), findsWidgets); + expect(find.text('Снизили на 1.3 кг за период'), findsOneWidget); + }); + }); + + group('пилюли периодов', () { + testWidgets( + 'по умолчанию запрошены 3 месяца; смена периода перезапрашивает данные', + (tester) async { + final repository = FakeInsightsRepository(); + await pumpCard(tester, repository); + + expect( + repository.insightsRequests.single.period, + BodyPeriod.threeMonths, + ); + + await tester.tap(find.byKey(const Key('insights_period_12'))); + await tester.pumpAndSettle(); + + expect(repository.insightsRequests.map((e) => e.period), [ + BodyPeriod.threeMonths, + BodyPeriod.year, + ]); + + await tester.tap(find.byKey(const Key('insights_period_all'))); + await tester.pumpAndSettle(); + + expect(repository.insightsRequests.last.period, BodyPeriod.all); + }, + ); + + testWidgets('повторный тап по активной пилюле не делает запрос', ( + tester, + ) async { + final repository = FakeInsightsRepository(); + await pumpCard(tester, repository); + + await tester.tap(find.byKey(const Key('insights_period_3'))); + await tester.pumpAndSettle(); + + expect(repository.insightsRequests, hasLength(1)); + }); + + testWidgets('slug пробрасывается в запросы', (tester) async { + final repository = FakeInsightsRepository(); + await pumpCard(tester, repository, slug: 'client'); + + await tester.tap(find.byKey(const Key('insights_period_1'))); + await tester.pumpAndSettle(); + + expect(repository.insightsRequests.map((e) => e.slug), [ + 'client', + 'client', + ]); + }); + }); + + group('кнопка «+»', () { + testWidgets('нет при canAdd = false', (tester) async { + await pumpCard(tester, FakeInsightsRepository()); + + expect(find.byKey(const Key('insights_fab')), findsNothing); + }); + + testWidgets('есть на вкладках «Вес» и «Замеры», нет на «ИМТ»', ( + tester, + ) async { + await pumpCard(tester, FakeInsightsRepository(), canAdd: true); + + expect(find.byKey(const Key('insights_fab')), findsOneWidget); + + await tester.tap(find.byKey(const Key('insights_tab_bmi'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('insights_fab')), findsNothing); + + await tester.tap(find.byKey(const Key('insights_tab_measures'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('insights_fab')), findsOneWidget); + }); + + testWidgets('нет в свёрнутом состоянии', (tester) async { + await pumpCard( + tester, + FakeInsightsRepository(), + canAdd: true, + expand: false, + ); + + expect(find.byKey(const Key('insights_fab')), findsNothing); + }); + + testWidgets( + 'сохранение веса отправляет значение и перезапрашивает данные', + (tester) async { + final repository = FakeInsightsRepository(); + await pumpCard(tester, repository, canAdd: true); + + await tester.tap(find.byKey(const Key('insights_fab'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('add_weight_kilo_picker')), findsOneWidget); + + await tester.tap(find.byKey(const Key('add_sheet_save'))); + await tester.pumpAndSettle(); + + // Начальное значение шторки — последний вес из payload (73.2). + expect(repository.addedWeights, [73.2]); + expect(repository.insightsRequests, hasLength(2)); + }, + ); + + testWidgets( + 'сохранение замеров отправляет значения и перезапрашивает данные', + (tester) async { + final repository = FakeInsightsRepository(); + await pumpCard(tester, repository, canAdd: true); + + await tester.tap(find.byKey(const Key('insights_tab_measures'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('insights_fab'))); + await tester.pumpAndSettle(); + expect( + find.byKey(const Key('add_circumference_waist_picker')), + findsOneWidget, + ); + + await tester.tap(find.byKey(const Key('add_sheet_save'))); + await tester.pumpAndSettle(); + + // Последние значения из payload: грудь 96, талия 80.5 → 81 (округление), бёдра 100. + expect(repository.addedCircumferences, [ + (chest: 96, waist: 81, hip: 100), + ]); + expect(repository.insightsRequests, hasLength(2)); + }, + ); + }); + + group('серии замеров', () { + testWidgets('чипы переключают видимость серии', (tester) async { + await pumpCard(tester, FakeInsightsRepository()); + await tester.tap(find.byKey(const Key('insights_tab_measures'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('insights_series_0')), findsOneWidget); + expect(find.byKey(const Key('insights_series_1')), findsOneWidget); + expect(find.byKey(const Key('insights_series_2')), findsOneWidget); + expect( + tester + .widget( + find + .ancestor( + of: find.byKey(const Key('insights_series_1')), + matching: find.byType(Opacity), + ) + .first, + ) + .opacity, + 1, + ); + + await tester.tap(find.byKey(const Key('insights_series_1'))); + await tester.pumpAndSettle(); + + expect( + tester + .widget( + find + .ancestor( + of: find.byKey(const Key('insights_series_1')), + matching: find.byType(Opacity), + ) + .first, + ) + .opacity, + 0.45, + ); + }); + }); + + group('ИМТ', () { + testWidgets('без значений показывает только deltaText', (tester) async { + await pumpCard(tester, FakeInsightsRepository()); + + await tester.tap(find.byKey(const Key('insights_tab_bmi'))); + await tester.pumpAndSettle(); + + expect(find.text('Укажите рост, чтобы посчитать ИМТ.'), findsOneWidget); + expect(find.byKey(const Key('insights_bmi_chart')), findsNothing); + expect(find.byKey(const Key('insights_headline')), findsNothing); + }); + + testWidgets('со значениями рисует график и подсказку', (tester) async { + final payload = loadInsightsPayloadFixture().copyWith.bmi( + labels: ['01.11', '09.11'], + values: [26.1, 24.2], + last: 24.2, + delta: -1.9, + headline: '24.2', + category: 'норма', + deltaText: 'ИМТ ниже на 1.9 за 3 месяца · норма', + hint: 'Считаем при росте 176 см.', + ); + await pumpCard(tester, FakeInsightsRepository(payload: payload)); + + await tester.tap(find.byKey(const Key('insights_tab_bmi'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('insights_bmi_chart')), findsOneWidget); + expect(find.byKey(const Key('insights_bmi_hint')), findsOneWidget); + expect(find.text('ИМТ ниже на 1.9 за 3 месяца · норма'), findsOneWidget); + }); + }); + + group('скрытие и ошибки', () { + testWidgets('403 → блок скрыт целиком', (tester) async { + await pumpCard( + tester, + FakeInsightsRepository(error: const InsightsForbidden()), + slug: 'client', + expand: false, + ); + + expect(find.byKey(const Key('insights_card')), findsNothing); + expect(find.text('Аналитика'), findsNothing); + }); + + testWidgets( + 'ошибка загрузки показывает кнопку «Повторить», повтор грузит данные', + (tester) async { + final repository = FakeInsightsRepository(error: Exception('boom')); + await pumpCard(tester, repository, expand: false); + + expect(find.text('Не удалось загрузить аналитику'), findsOneWidget); + repository.error = null; + + await tester.tap(find.byKey(const Key('insights_retry'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('insights_toggle')), findsOneWidget); + expect(find.text('Не удалось загрузить аналитику'), findsNothing); + }, + ); + }); +} diff --git a/test/features/insights/presentation/widgets/insights_kbju_test.dart b/test/features/insights/presentation/widgets/insights_kbju_test.dart new file mode 100644 index 00000000..9576a41b --- /dev/null +++ b/test/features/insights/presentation/widgets/insights_kbju_test.dart @@ -0,0 +1,367 @@ +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/insights/models/insights_payload_model.dart'; +import 'package:eatvio_app/features/insights/presentation/charts/daily_kbju_chart.dart'; +import 'package:eatvio_app/features/insights/presentation/charts/intra_day_chart.dart'; +import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.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/generated/l10n.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:syncfusion_flutter_charts/charts.dart'; + +import '../../../../fixtures/doubles/insights_doubles.dart'; + +void main() { + Widget wrap(Widget child) { + return MaterialApp( + theme: ThemeHelper().themeData(), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('ru')], + home: Scaffold(body: SingleChildScrollView(child: child)), + ); + } + + Future pumpKbjuCard( + WidgetTester tester, + FakeInsightsRepository repository, + String tabKey, + ) async { + tester.view.physicalSize = const Size(900, 2600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + wrap(InsightsCard(repository: repository, kbjuEnabled: true)), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('insights_toggle'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(Key(tabKey))); + await tester.pumpAndSettle(); + } + + group('КБЖУ по дням', () { + testWidgets('рисует график, шапку со средним и 4 чипа серий', ( + tester, + ) async { + await pumpKbjuCard( + tester, + FakeInsightsRepository(), + 'insights_tab_daily', + ); + + expect(find.byType(DailyKbjuChart), findsOneWidget); + expect(find.text('1930'), findsOneWidget); + expect(find.text('ккал в среднем'), findsOneWidget); + expect(find.text('98% от нормы'), findsOneWidget); + for (var i = 0; i < 4; i++) { + expect(find.byKey(Key('insights_series_$i')), findsOneWidget); + } + expect(find.byKey(const Key('insights_series_4')), findsNothing); + }); + + testWidgets('чипы скрывают серии', (tester) async { + await pumpKbjuCard( + tester, + FakeInsightsRepository(), + 'insights_tab_daily', + ); + + SfCartesianChart chart() => tester.widget( + find.byKey(const Key('insights_daily_chart')), + ); + expect(chart().series, hasLength(4)); + + await tester.tap(find.byKey(const Key('insights_series_1'))); + await tester.pumpAndSettle(); + + expect(chart().series, hasLength(3)); + }); + + testWidgets('без daily показывает заглушку', (tester) async { + final payload = loadInsightsPayloadFixture().copyWith(daily: null); + await pumpKbjuCard( + tester, + FakeInsightsRepository(payload: payload), + 'insights_tab_daily', + ); + + expect(find.byType(DailyKbjuChart), findsNothing); + expect(find.text('Недостаточно данных'), findsOneWidget); + }); + + testWidgets('пустой daily показывает заглушку вместо графика', ( + tester, + ) async { + await pumpKbjuCard( + tester, + FakeInsightsRepository( + payload: loadInsightsPayloadFixture('insights_payload_minimal.json'), + ), + 'insights_tab_daily', + ); + + expect(find.byKey(const Key('insights_daily_chart')), findsNothing); + expect(find.text('Недостаточно данных'), findsWidgets); + }); + }); + + group('В течение дня', () { + testWidgets('рисует график, навигацию по дням, легенду и подсказку', ( + tester, + ) async { + await pumpKbjuCard( + tester, + FakeInsightsRepository(), + 'insights_tab_intra', + ); + + expect(find.byType(IntraDayChart), findsOneWidget); + expect(find.byType(IntraDateNav), findsOneWidget); + expect(find.text('Сегодня'), findsOneWidget); + expect(find.text('Подъём в 07:30'), findsOneWidget); + expect(find.text('Отход ко сну в 23:00'), findsOneWidget); + expect(find.text('Затемнение — время сна'), findsOneWidget); + expect(find.byKey(const Key('insights_intra_hint')), findsOneWidget); + for (var i = 0; i < 6; i++) { + expect(find.byKey(Key('insights_series_$i')), findsOneWidget); + } + }); + + testWidgets( + 'кнопка «следующий день» отключена при isToday, «предыдущий» без колбэка тоже', + (tester) async { + await pumpKbjuCard( + tester, + FakeInsightsRepository(), + 'insights_tab_intra', + ); + + IconButton buttonIn(String key) => tester.widget( + find.descendant( + of: find.byKey(Key(key)), + matching: find.byType(IconButton), + ), + ); + + expect(buttonIn('insights_intra_next').onPressed, isNull); + expect(buttonIn('insights_intra_prev').onPressed, isNull); + }, + ); + + testWidgets('чип «Сон и подъём» убирает затемнение и легенду не трогает', ( + tester, + ) async { + await pumpKbjuCard( + tester, + FakeInsightsRepository(), + 'insights_tab_intra', + ); + + NumericAxis xAxis() => + tester + .widget( + find.byKey(const Key('insights_intra_chart')), + ) + .primaryXAxis + as NumericAxis; + + // wake + sleep: две полосы затемнения и две линии. + expect(xAxis().plotBands, hasLength(4)); + + await tester.tap(find.byKey(const Key('insights_series_5'))); + await tester.pumpAndSettle(); + + expect(xAxis().plotBands, isEmpty); + expect(find.text('Затемнение — время сна'), findsOneWidget); + }); + + testWidgets('чипы сытости и настроения скрывают линии', (tester) async { + await pumpKbjuCard( + tester, + FakeInsightsRepository(), + 'insights_tab_intra', + ); + + int seriesCount() => tester + .widget( + find.byKey(const Key('insights_intra_chart')), + ) + .series + .length; + + // 3 столбика + сытость + настроение. + expect(seriesCount(), 5); + + await tester.tap(find.byKey(const Key('insights_series_3'))); + await tester.pumpAndSettle(); + expect(seriesCount(), 4); + + await tester.tap(find.byKey(const Key('insights_series_4'))); + await tester.pumpAndSettle(); + expect(seriesCount(), 3); + }); + + testWidgets('пустые линии не создают серий (без экстраполяции)', ( + tester, + ) async { + final intra = loadInsightsPayloadFixture().intra!.copyWith( + satiety: const [], + mood: const [], + ); + final payload = loadInsightsPayloadFixture().copyWith(intra: intra); + await pumpKbjuCard( + tester, + FakeInsightsRepository(payload: payload), + 'insights_tab_intra', + ); + + final chart = tester.widget( + find.byKey(const Key('insights_intra_chart')), + ); + expect(chart.series, hasLength(3)); + }); + + testWidgets('без wake/sleep затемнения нет', (tester) async { + const intra = IntraChartModel( + bars: [IntraBarModel(x: 12, p: 100, f: 50, c: 200)], + date: '2026-11-09', + maxDate: '2026-11-09', + dateLabel: 'Сегодня', + isToday: true, + ); + final payload = loadInsightsPayloadFixture().copyWith(intra: intra); + await pumpKbjuCard( + tester, + FakeInsightsRepository(payload: payload), + 'insights_tab_intra', + ); + + final xAxis = + tester + .widget( + find.byKey(const Key('insights_intra_chart')), + ) + .primaryXAxis + as NumericAxis; + expect(xAxis.plotBands, isEmpty); + expect(find.byKey(const Key('insights_legend_wake')), findsNothing); + expect(find.byKey(const Key('insights_legend_sleep')), findsNothing); + expect(find.byType(IntraLegend), findsOneWidget); + }); + + testWidgets('пустой день показывает заглушку', (tester) async { + await pumpKbjuCard( + tester, + FakeInsightsRepository( + payload: loadInsightsPayloadFixture('insights_payload_minimal.json'), + ), + 'insights_tab_intra', + ); + + expect(find.byKey(const Key('insights_intra_chart')), findsNothing); + expect(find.text('Недостаточно данных'), findsOneWidget); + }); + }); + + group('IntraDateNav', () { + const model = IntraChartModel( + date: '2026-11-08', + maxDate: '2026-11-09', + dateLabel: '8 ноября', + isToday: false, + ); + + testWidgets('вызывает колбэки предыдущего и следующего дня', ( + tester, + ) async { + var prev = 0; + var next = 0; + await tester.pumpWidget( + wrap( + IntraDateNav( + model: model, + onPreviousDay: () => prev++, + onNextDay: () => next++, + onDatePicked: (_) {}, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('insights_intra_prev'))); + await tester.tap(find.byKey(const Key('insights_intra_next'))); + + expect(prev, 1); + expect(next, 1); + expect(find.text('8 ноября'), findsOneWidget); + }); + + testWidgets('«следующий день» отключён, когда выбран сегодняшний день', ( + tester, + ) async { + var next = 0; + await tester.pumpWidget( + wrap( + IntraDateNav( + model: model.copyWith(isToday: true, dateLabel: 'Сегодня'), + onPreviousDay: () {}, + onNextDay: () => next++, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey(const Key('insights_intra_next')), + warnIfMissed: false, + ); + + expect(next, 0); + }); + + testWidgets( + 'выбор даты: календарь ограничен maxDate и возвращает выбранный день', + (tester) async { + DateTime? picked; + await tester.pumpWidget( + wrap( + IntraDateNav( + model: model, + onPreviousDay: () {}, + onNextDay: () {}, + onDatePicked: (date) => picked = date, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('insights_intra_pick'))); + await tester.pumpAndSettle(); + + final dialog = tester.widget( + find.byType(CalendarDatePicker), + ); + expect(dialog.lastDate, DateTime(2026, 11, 9)); + expect(dialog.initialDate, DateTime(2026, 11, 8)); + + await tester.tap(find.text('5')); + await tester.tap( + find.text('OK').evaluate().isNotEmpty + ? find.text('OK') + : find.text('ОК'), + ); + await tester.pumpAndSettle(); + + expect(picked, DateTime(2026, 11, 5)); + }, + ); + }); +} diff --git a/test/features/insights/repositories/insights_repository_test.dart b/test/features/insights/repositories/insights_repository_test.dart new file mode 100644 index 00000000..5b14230e --- /dev/null +++ b/test/features/insights/repositories/insights_repository_test.dart @@ -0,0 +1,214 @@ +import 'package:dio/dio.dart'; +import 'package:eatvio_app/features/insights/domain/insights_text_builder.dart'; +import 'package:eatvio_app/features/insights/models/insights_period.dart'; +import 'package:eatvio_app/features/insights/repositories/insights_repository.dart'; +import 'package:eatvio_app/features/profile/models/circumference_model.dart'; +import 'package:eatvio_app/features/profile/models/profile_model.dart'; +import 'package:eatvio_app/features/profile/models/weight_model.dart'; +import 'package:eatvio_app/features/profile/repositories/profile_repository.dart'; +import 'package:eatvio_app/generated/l10n.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockProfileRepository extends Mock implements ProfileRepository {} + +ProfileDetailsModel _details({int height = 176}) => ProfileDetailsModel( + firstName: 'A', + lastName: 'B', + birthdate: '1990-01-01', + gender: 'male', + country: 'RU', + city: 'Moscow', + lang: 'ru', + weight: 70, + height: height, + chestCircumference: 90, + waistCircumference: 80, + hipCircumference: 95, + bloodPressureUp: 120, + bloodPressureDown: 80, + hasHypo: false, + hasHyper: false, + aboutAlergy: '', + aboutTakingPils: '', + aboutSportHobbies: '', + favoriteFoods: '', + unfavoriteFoods: '', + additionalInfo: '', + isMoodFillable: false, + isBedtimeFillable: false, + activityCoefficient: 1.2, +); + +DioException _dio(int status) => DioException( + requestOptions: RequestOptions(path: '/x'), + response: Response( + requestOptions: RequestOptions(path: '/x'), + statusCode: status, + ), +); + +void main() { + late _MockProfileRepository profileRepository; + late InsightsRepository repository; + + final date1 = DateTime(2026, 11, 1, 12); + final date2 = DateTime(2026, 11, 9, 12); + + final weights = [ + WeightModel( + id: 1, + userId: 1, + weight: 75, + createdAt: date1, + updatedAt: date1, + bmi: 24, + ), + WeightModel( + id: 2, + userId: 1, + weight: 73.5, + createdAt: date2, + updatedAt: date2, + bmi: 23.5, + ), + ]; + final circumferences = [ + CircumferenceModel( + id: 1, + userId: 1, + chestCircumference: 95, + waistCircumference: 82, + hipCircumference: 100, + createdAt: date1, + updatedAt: date1, + ), + ]; + + setUp(() async { + final s = await S.load(const Locale('ru')); + profileRepository = _MockProfileRepository(); + repository = InsightsRepository( + profileRepository: profileRepository, + textBuilderFactory: () => InsightsTextBuilder(s), + ); + }); + + group('getInsights', () { + test('собирает payload из веса и замеров; daily/intra == null', () async { + when( + () => profileRepository.getWeight(period: '3', slug: null), + ).thenAnswer((_) async => weights); + when( + () => profileRepository.getCircumferences(period: '3', slug: null), + ).thenAnswer((_) async => circumferences); + + final payload = await repository.getInsights( + bodyPeriod: BodyPeriod.threeMonths, + heightCm: 176, + ); + + expect(payload.weight.values, [75, 73.5]); + expect(payload.weight.deltaText, 'Снизили на 1.5 кг за 3 месяца'); + expect(payload.bmi.values, [24, 23.5]); + expect(payload.measure.waist, [82]); + expect(payload.daily, isNull); + expect(payload.intra, isNull); + }); + + test('пробрасывает slug и значение периода', () async { + when( + () => profileRepository.getWeight(period: 'all', slug: 'client'), + ).thenAnswer((_) async => weights); + when( + () => + profileRepository.getCircumferences(period: 'all', slug: 'client'), + ).thenAnswer((_) async => circumferences); + + await repository.getInsights(slug: 'client', bodyPeriod: BodyPeriod.all); + + verify( + () => profileRepository.getWeight(period: 'all', slug: 'client'), + ).called(1); + verify( + () => + profileRepository.getCircumferences(period: 'all', slug: 'client'), + ).called(1); + }); + + test('403 → InsightsForbidden', () async { + when( + () => profileRepository.getWeight(period: '3', slug: 'client'), + ).thenThrow(_dio(403)); + when( + () => profileRepository.getCircumferences(period: '3', slug: 'client'), + ).thenAnswer((_) async => circumferences); + + expect( + repository.getInsights( + slug: 'client', + bodyPeriod: BodyPeriod.threeMonths, + ), + throwsA(isA()), + ); + }); + + test('прочие ошибки пробрасываются как есть', () async { + when( + () => profileRepository.getWeight(period: '3', slug: null), + ).thenThrow(_dio(500)); + when( + () => profileRepository.getCircumferences(period: '3', slug: null), + ).thenAnswer((_) async => circumferences); + + expect( + repository.getInsights(bodyPeriod: BodyPeriod.threeMonths), + throwsA(isA()), + ); + }); + }); + + group('getHeightCm', () { + test('свой профиль', () async { + when( + () => profileRepository.getMyProfileDetails(), + ).thenAnswer((_) async => _details()); + + expect(await repository.getHeightCm(), 176); + }); + + test('профиль клиента по slug', () async { + when( + () => profileRepository.getProfileDetailsBySlug('client'), + ).thenAnswer((_) async => _details(height: 168)); + + expect(await repository.getHeightCm(slug: 'client'), 168); + }); + + test('нулевой рост → null', () async { + when( + () => profileRepository.getMyProfileDetails(), + ).thenAnswer((_) async => _details(height: 0)); + + expect(await repository.getHeightCm(), isNull); + }); + + test('ошибка не 403 → null', () async { + when(() => profileRepository.getMyProfileDetails()).thenThrow(_dio(500)); + + expect(await repository.getHeightCm(), isNull); + }); + + test('403 → InsightsForbidden', () async { + when( + () => profileRepository.getProfileDetailsBySlug('client'), + ).thenThrow(_dio(403)); + + expect( + repository.getHeightCm(slug: 'client'), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/features/profile/models/weight_model_test.dart b/test/features/profile/models/weight_model_test.dart index 2fa157b1..66c09398 100644 --- a/test/features/profile/models/weight_model_test.dart +++ b/test/features/profile/models/weight_model_test.dart @@ -1,3 +1,4 @@ +import 'package:eatvio_app/features/profile/models/circumference_model.dart'; import 'package:eatvio_app/features/profile/models/weight_model.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -42,6 +43,51 @@ void main() { expect(json['created_at'], isA()); expect(json['updated_at'], isA()); }); + + Map json({Object? weight = 72.3, Object? bmi = 22.4}) => { + 'id': 1, + 'user_id': 2, + 'weight': weight, + 'created_at': '2026-11-01T10:00:00.000000Z', + 'updated_at': '2026-11-01T10:00:00.000000Z', + 'bmi': bmi, + }; + + test('парсит дробный weight', () { + final model = WeightModel.fromJson(json()); + + expect(model.weight, 72.3); + expect(model.bmi, 22.4); + }); + + test('парсит целый weight как double', () { + final model = WeightModel.fromJson(json(weight: 72)); + + expect(model.weight, 72.0); + }); + + test('допускает bmi: null (рост не указан)', () { + final model = WeightModel.fromJson(json(bmi: null)); + + expect(model.bmi, isNull); + }); + }); + + group('CircumferenceModel', () { + test('парсит целые и дробные значения (прореженные средние)', () { + final model = CircumferenceModel.fromJson({ + 'id': 1, + 'user_id': 2, + 'chest_circumference': 95, + 'waist_circumference': 80.5, + 'hip_circumference': 100, + 'created_at': '2026-11-01T10:00:00.000000Z', + 'updated_at': '2026-11-01T10:00:00.000000Z', + }); + + expect(model.chestCircumference, 95.0); + expect(model.waistCircumference, 80.5); + expect(model.hipCircumference, 100.0); + }); }); } - diff --git a/test/features/profile/presentation/pages/profile_screen_smoke_test.dart b/test/features/profile/presentation/pages/profile_screen_smoke_test.dart index 4ecef522..39e98297 100644 --- a/test/features/profile/presentation/pages/profile_screen_smoke_test.dart +++ b/test/features/profile/presentation/pages/profile_screen_smoke_test.dart @@ -4,9 +4,8 @@ import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; -import 'package:eatvio_app/features/profile/models/circumference_model.dart'; +import 'package:eatvio_app/features/insights/repositories/insights_repository.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart'; -import 'package:eatvio_app/features/profile/models/weight_model.dart'; import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart' as profile_plans; import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart'; import 'package:eatvio_app/features/profile/presentation/pages/profile_screen.dart'; @@ -45,6 +44,7 @@ void main() { plansRepository = MockPlansRepository(); sl.registerLazySingleton(() => profileRepository); + sl.registerLazySingleton(() => InsightsRepository(profileRepository: profileRepository)); sl.registerLazySingleton(() => chatRepository); sl.registerLazySingleton(() => consultantRepository); sl.registerLazySingleton(() => plansRepository); @@ -117,8 +117,6 @@ void main() { when(profileRepository.getMyProfile()).thenAnswer((_) async => profile); when(profileRepository.getMyProfileDetails()).thenAnswer((_) async => profileDetails); when(profileRepository.getBlogPostsByAuthorSlug(slug: anyNamed('slug'))).thenAnswer((_) async => []); - when(profileRepository.getWeight(period: anyNamed('period'))).thenAnswer((_) async => []); - when(profileRepository.getCircumferences(period: anyNamed('period'))).thenAnswer((_) async => []); await mockNetworkImagesFor(() async { await tester.pumpWidget(wrap(const ProfileScreen())); @@ -190,8 +188,6 @@ void main() { when(profileRepository.getMyProfileDetails()).thenAnswer((_) async => profileDetails); when(profileRepository.getProfileDetailsBySlug(any)).thenAnswer((_) async => profileDetails); when(profileRepository.getBlogPostsByAuthorSlug(slug: anyNamed('slug'))).thenAnswer((_) async => []); - when(profileRepository.getWeight(period: anyNamed('period'))).thenAnswer((_) async => []); - when(profileRepository.getCircumferences(period: anyNamed('period'))).thenAnswer((_) async => []); await mockNetworkImagesFor(() async { await tester.pumpWidget(wrap(const ProfileScreen(slug: 'test-user'))); diff --git a/test/features/profile/presentation/widgets/circumference_chart_widget_test.dart b/test/features/profile/presentation/widgets/circumference_chart_widget_test.dart deleted file mode 100644 index 1c68a19f..00000000 --- a/test/features/profile/presentation/widgets/circumference_chart_widget_test.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:eatvio_app/core/theme/theme_helper.dart'; -import 'package:eatvio_app/features/profile/models/circumference_model.dart'; -import 'package:eatvio_app/features/profile/presentation/widgets/circumference_chart_widget.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:intl/date_symbol_data_local.dart'; - -void main() { - group('CircumferenceChartWidget smoke', () { - setUpAll(() async { - await initializeDateFormatting('ru', null); - }); - Widget wrap(Widget child) { - return MaterialApp( - theme: ThemeHelper().themeData(), - home: Scaffold(body: child), - ); - } - - testWidgets('renders circumference chart widget with empty points', (tester) async { - await tester.pumpWidget(wrap(const CircumferenceChartWidget(circumferencePoints: []))); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - - expect(find.byType(CircumferenceChartWidget), findsOneWidget); - }); - - testWidgets('renders circumference chart widget with points', (tester) async { - final points = [ - CircumferenceModel( - id: 1, - userId: 1, - chestCircumference: 90, - waistCircumference: 80, - hipCircumference: 95, - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - ), - ]; - await tester.pumpWidget(wrap(CircumferenceChartWidget(circumferencePoints: points))); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - - expect(find.byType(CircumferenceChartWidget), findsOneWidget); - }); - }); -} diff --git a/test/features/profile/presentation/widgets/imt_chart_widget_test.dart b/test/features/profile/presentation/widgets/imt_chart_widget_test.dart deleted file mode 100644 index 59eb1da3..00000000 --- a/test/features/profile/presentation/widgets/imt_chart_widget_test.dart +++ /dev/null @@ -1,132 +0,0 @@ -import 'package:eatvio_app/core/app_config.dart'; -import 'package:eatvio_app/core/app_initializer.dart'; -import 'package:eatvio_app/core/locator_service.dart'; -import 'package:eatvio_app/core/theme/theme_helper.dart'; -import 'package:eatvio_app/features/profile/models/weight_model.dart'; -import 'package:eatvio_app/features/profile/presentation/widgets/imt_chart_widget.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_portal/flutter_portal.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:intl/date_symbol_data_local.dart'; - -void main() { - group('ImtChartWidget', () { - setUp(() async { - await sl.reset(); - await initializeDateFormatting('ru', null); - dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); - sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); - }); - - Widget createTestWidget(Widget child) { - return MaterialApp( - theme: ThemeHelper().themeData(), - home: Portal( - child: Scaffold(body: SingleChildScrollView(child: child)), - ), - ); - } - - testWidgets('should display chart with empty weight points', (WidgetTester tester) async { - // Arrange - const widget = ImtChartWidget(weightPoints: []); - - // Act - await tester.pumpWidget(createTestWidget(widget)); - await tester.pump(); - await tester.pump(const Duration(seconds: 1)); - - // Assert - expect(find.byType(ImtChartWidget), findsOneWidget); - }); - - testWidgets('should display chart with single weight point', (WidgetTester tester) async { - // Arrange - final weightPoints = [ - WeightModel( - id: 1, - userId: 1, - weight: 70, - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - bmi: 23.5, - ), - ]; - final widget = ImtChartWidget(weightPoints: weightPoints); - - // Act - await tester.pumpWidget(createTestWidget(widget)); - await tester.pump(); - await tester.pump(const Duration(seconds: 1)); - - // Assert - expect(find.byType(ImtChartWidget), findsOneWidget); - }); - - testWidgets('should display chart with multiple weight points and show increase arrow', (WidgetTester tester) async { - // Arrange - final now = DateTime.now(); - final weightPoints = [ - WeightModel( - id: 1, - userId: 1, - weight: 70, - createdAt: now.subtract(const Duration(days: 7)), - updatedAt: now.subtract(const Duration(days: 7)), - bmi: 23.0, - ), - WeightModel( - id: 2, - userId: 1, - weight: 72, - createdAt: now, - updatedAt: now, - bmi: 24.0, - ), - ]; - final widget = ImtChartWidget(weightPoints: weightPoints); - - // Act - await tester.pumpWidget(createTestWidget(widget)); - await tester.pump(); - await tester.pump(const Duration(seconds: 1)); - - // Assert - expect(find.byType(ImtChartWidget), findsOneWidget); - }); - - testWidgets('should display chart with multiple weight points and show decrease arrow', (WidgetTester tester) async { - // Arrange - final now = DateTime.now(); - final weightPoints = [ - WeightModel( - id: 1, - userId: 1, - weight: 72, - createdAt: now.subtract(const Duration(days: 7)), - updatedAt: now.subtract(const Duration(days: 7)), - bmi: 24.0, - ), - WeightModel( - id: 2, - userId: 1, - weight: 70, - createdAt: now, - updatedAt: now, - bmi: 23.0, - ), - ]; - final widget = ImtChartWidget(weightPoints: weightPoints); - - // Act - await tester.pumpWidget(createTestWidget(widget)); - await tester.pump(); - await tester.pump(const Duration(seconds: 1)); - - // Assert - expect(find.byType(ImtChartWidget), findsOneWidget); - }); - }); -} - diff --git a/test/features/profile/presentation/widgets/profile_charts_smoke_test.dart b/test/features/profile/presentation/widgets/profile_charts_smoke_test.dart deleted file mode 100644 index df88a7b5..00000000 --- a/test/features/profile/presentation/widgets/profile_charts_smoke_test.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:eatvio_app/core/theme/theme_helper.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/features/profile/presentation/widgets/circumference_chart_widget.dart'; -import 'package:eatvio_app/features/profile/presentation/widgets/weight_chart_widget.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:intl/date_symbol_data_local.dart'; -import 'package:intl/intl.dart'; - -void main() { - group('Profile chart widgets smoke', () { - setUpAll(() async { - Intl.defaultLocale = 'ru'; - await initializeDateFormatting('ru'); - }); - - Widget wrap(Widget child) { - return MaterialApp( - theme: ThemeHelper().themeData(), - home: Scaffold(body: SingleChildScrollView(child: child)), - ); - } - - testWidgets('WeightChartWidget renders empty state', (tester) async { - await tester.pumpWidget(wrap(const WeightChartWidget(weightPoints: []))); - await tester.pumpAndSettle(const Duration(milliseconds: 200)); - expect(find.text('Добавьте Ваш вес'), findsOneWidget); - }); - - testWidgets('WeightChartWidget renders with one point', (tester) async { - final points = [WeightModel(id: 1, userId: 1, weight: 70, createdAt: DateTime(2025, 1, 1), updatedAt: DateTime(2025, 1, 1), bmi: 22.0)]; - await tester.pumpWidget(wrap(WeightChartWidget(weightPoints: points))); - await tester.pumpAndSettle(const Duration(milliseconds: 200)); - expect(find.textContaining('кг'), findsWidgets); - }); - - testWidgets('CircumferenceChartWidget renders empty state', (tester) async { - await tester.pumpWidget(wrap(const CircumferenceChartWidget(circumferencePoints: []))); - await tester.pumpAndSettle(const Duration(milliseconds: 200)); - expect(find.text('Добавьте Ваши замеры'), findsOneWidget); - }); - - testWidgets('CircumferenceChartWidget renders with one point', (tester) async { - final points = [ - CircumferenceModel( - id: 1, - userId: 1, - chestCircumference: 100, - waistCircumference: 80, - hipCircumference: 90, - createdAt: DateTime(2025, 1, 1), - updatedAt: DateTime(2025, 1, 1), - ), - ]; - await tester.pumpWidget(wrap(CircumferenceChartWidget(circumferencePoints: points))); - await tester.pumpAndSettle(const Duration(milliseconds: 200)); - - expect(find.text('100'), findsOneWidget); - expect(find.text('80'), findsOneWidget); - expect(find.text('90'), findsOneWidget); - }); - }); -} diff --git a/test/features/profile/presentation/widgets/weight_chart_widget_test.dart b/test/features/profile/presentation/widgets/weight_chart_widget_test.dart deleted file mode 100644 index f16d5322..00000000 --- a/test/features/profile/presentation/widgets/weight_chart_widget_test.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:eatvio_app/core/theme/theme_helper.dart'; -import 'package:eatvio_app/features/profile/models/weight_model.dart'; -import 'package:eatvio_app/features/profile/presentation/widgets/weight_chart_widget.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:intl/date_symbol_data_local.dart'; - -void main() { - group('WeightChartWidget smoke', () { - setUpAll(() async { - await initializeDateFormatting('ru', null); - }); - Widget wrap(Widget child) { - return MaterialApp( - theme: ThemeHelper().themeData(), - home: Scaffold(body: child), - ); - } - - testWidgets('renders weight chart widget with empty points', (tester) async { - await tester.pumpWidget(wrap(const WeightChartWidget(weightPoints: []))); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - - expect(find.byType(WeightChartWidget), findsOneWidget); - }); - - testWidgets('renders weight chart widget with points', (tester) async { - final points = [WeightModel(id: 1, userId: 1, weight: 70, bmi: 22.5, createdAt: DateTime.now(), updatedAt: DateTime.now())]; - await tester.pumpWidget(wrap(WeightChartWidget(weightPoints: points))); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - - expect(find.byType(WeightChartWidget), findsOneWidget); - }); - }); -} diff --git a/test/features/user_details/presentation/cubit/user_details_cubit_test.dart b/test/features/user_details/presentation/cubit/user_details_cubit_test.dart index e12fa8f2..afad3f91 100644 --- a/test/features/user_details/presentation/cubit/user_details_cubit_test.dart +++ b/test/features/user_details/presentation/cubit/user_details_cubit_test.dart @@ -1,6 +1,4 @@ -import 'package:eatvio_app/features/profile/models/circumference_model.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart'; -import 'package:eatvio_app/features/profile/models/weight_model.dart'; import 'package:eatvio_app/features/profile/repositories/profile_repository.dart'; import 'package:eatvio_app/features/user_details/presentation/cubit/user_details_cubit.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -28,8 +26,6 @@ void main() { expect(cubit.state.isLoading, isFalse); expect(cubit.state.error, isNull); expect(cubit.state.profileDetails, isNull); - expect(cubit.state.weightPoints, isNull); - expect(cubit.state.circumferencePoints, isNull); }); test('getUserDetails - успешная загрузка деталей пользователя', () async { @@ -62,37 +58,8 @@ void main() { activityCoefficient: 1.5, ); - final weightPoints = [ - WeightModel( - id: 1, - userId: 1, - weight: 70, - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - bmi: 22.9, - ), - ]; - - final circumferencePoints = [ - CircumferenceModel( - id: 1, - userId: 1, - chestCircumference: 100, - waistCircumference: 80, - hipCircumference: 90, - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - ), - ]; - when(mockProfileRepository.getProfileDetailsBySlug(slug)) .thenAnswer((_) async => profileDetails); - - when(mockProfileRepository.getWeight(period: '1', slug: slug)) - .thenAnswer((_) async => weightPoints); - - when(mockProfileRepository.getCircumferences(period: '1', slug: slug)) - .thenAnswer((_) async => circumferencePoints); cubit.getUserDetails(slug); @@ -119,75 +86,6 @@ void main() { expect(cubit.state.error, equals(error.toString())); expect(cubit.state.isLoading, isFalse); }); - - test('getChartData - успешная загрузка данных графика', () async { - const slug = 'test-user'; - const period = '1'; - - final weightPoints = [ - WeightModel( - id: 1, - userId: 1, - weight: 70, - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - bmi: 22.9, - ), - WeightModel( - id: 2, - userId: 1, - weight: 71, - createdAt: DateTime.now().add(const Duration(days: 1)), - updatedAt: DateTime.now().add(const Duration(days: 1)), - bmi: 23.2, - ), - ]; - - final circumferencePoints = [ - CircumferenceModel( - id: 1, - userId: 1, - chestCircumference: 100, - waistCircumference: 80, - hipCircumference: 90, - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - ), - ]; - - when(mockProfileRepository.getWeight(period: period, slug: slug)) - .thenAnswer((_) async => weightPoints); - - when(mockProfileRepository.getCircumferences(period: period, slug: slug)) - .thenAnswer((_) async => circumferencePoints); - - cubit.getChartData(period: period, slug: slug); - - // Ждем завершения асинхронных операций - await Future.delayed(const Duration(milliseconds: 100)); - - expect(cubit.state.weightPoints, equals(weightPoints)); - expect(cubit.state.circumferencePoints, equals(circumferencePoints)); - verify(mockProfileRepository.getWeight(period: period, slug: slug)).called(1); - verify(mockProfileRepository.getCircumferences(period: period, slug: slug)).called(1); - }); - - test('getChartData - обработка ошибки', () async { - const slug = 'test-user'; - const period = '1'; - final error = Exception('Ошибка загрузки'); - - when(mockProfileRepository.getWeight(period: period, slug: slug)) - .thenThrow(error); - - cubit.getChartData(period: period, slug: slug); - - // Ждем завершения асинхронных операций - await Future.delayed(const Duration(milliseconds: 100)); - - expect(cubit.state.error, equals(error.toString())); - expect(cubit.state.isLoading, isFalse); - }); }); } diff --git a/test/features/user_details/presentation/user_details_screen_smoke_test.dart b/test/features/user_details/presentation/user_details_screen_smoke_test.dart index 6b8a2996..16e705ad 100644 --- a/test/features/user_details/presentation/user_details_screen_smoke_test.dart +++ b/test/features/user_details/presentation/user_details_screen_smoke_test.dart @@ -2,6 +2,7 @@ import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/insights/repositories/insights_repository.dart'; import 'package:eatvio_app/features/profile/models/circumference_model.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart'; import 'package:eatvio_app/features/profile/models/weight_model.dart'; @@ -97,10 +98,11 @@ void main() { final cubit = UserDetailsCubit(profileRepository: profileRepository); when(profileRepository.getProfileDetailsBySlug('test-slug')).thenAnswer((_) async => mockProfileDetails); - when(profileRepository.getWeight(period: '1', slug: 'test-slug')).thenAnswer((_) async => mockWeightPoints); - when(profileRepository.getCircumferences(period: '1', slug: 'test-slug')).thenAnswer((_) async => mockCircumferencePoints); + when(profileRepository.getWeight(period: '3', slug: 'test-slug')).thenAnswer((_) async => mockWeightPoints); + when(profileRepository.getCircumferences(period: '3', slug: 'test-slug')).thenAnswer((_) async => mockCircumferencePoints); sl.registerLazySingleton(() => profileRepository); + sl.registerLazySingleton(() => InsightsRepository(profileRepository: profileRepository)); sl.registerLazySingleton(() => cubit); await tester.pumpWidget(wrap(const UserDetailsScreen(slug: 'test-slug'))); @@ -117,10 +119,11 @@ void main() { final cubit = UserDetailsCubit(profileRepository: profileRepository); when(profileRepository.getProfileDetailsBySlug('test-slug')).thenAnswer((_) async => mockProfileDetails); - when(profileRepository.getWeight(period: '1', slug: 'test-slug')).thenAnswer((_) async => mockWeightPoints); - when(profileRepository.getCircumferences(period: '1', slug: 'test-slug')).thenAnswer((_) async => mockCircumferencePoints); + when(profileRepository.getWeight(period: '3', slug: 'test-slug')).thenAnswer((_) async => mockWeightPoints); + when(profileRepository.getCircumferences(period: '3', slug: 'test-slug')).thenAnswer((_) async => mockCircumferencePoints); sl.registerLazySingleton(() => profileRepository); + sl.registerLazySingleton(() => InsightsRepository(profileRepository: profileRepository)); sl.registerLazySingleton(() => cubit); await tester.pumpWidget(wrap(const UserDetailsScreen(slug: 'test-slug'))); @@ -136,6 +139,7 @@ void main() { when(profileRepository.getProfileDetailsBySlug('test-slug')).thenThrow(Exception('Error loading profile')); sl.registerLazySingleton(() => profileRepository); + sl.registerLazySingleton(() => InsightsRepository(profileRepository: profileRepository)); sl.registerLazySingleton(() => cubit); await tester.pumpWidget(wrap(const UserDetailsScreen(slug: 'test-slug'))); diff --git a/test/fixtures/doubles/insights_doubles.dart b/test/fixtures/doubles/insights_doubles.dart new file mode 100644 index 00000000..b121ec39 --- /dev/null +++ b/test/fixtures/doubles/insights_doubles.dart @@ -0,0 +1,54 @@ +import 'dart:convert'; +import 'dart:io'; + +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/repositories/insights_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; + +InsightsPayloadModel loadInsightsPayloadFixture([String name = 'insights_payload.json']) { + final json = jsonDecode(File('test/fixtures/insights/$name').readAsStringSync()) as Map; + return InsightsPayloadModel.fromJson(json); +} + +/// Подмена [InsightsRepository] для виджет-тестов: без сети, с журналом вызовов. +class FakeInsightsRepository extends Fake implements InsightsRepository { + FakeInsightsRepository({InsightsPayloadModel? payload, this.error, this.heightCm = 176}) : payload = payload ?? loadInsightsPayloadFixture(); + + InsightsPayloadModel payload; + + /// Если задан — выбрасывается из [getInsights] (например, [InsightsForbidden]). + Object? error; + int? heightCm; + + final List<({String? slug, BodyPeriod period})> insightsRequests = []; + final List addedWeights = []; + final List<({int chest, int waist, int hip})> addedCircumferences = []; + + @override + Future getHeightCm({String? slug}) async => heightCm; + + @override + Future getInsights({String? slug, required BodyPeriod bodyPeriod, int? heightCm}) async { + insightsRequests.add((slug: slug, period: bodyPeriod)); + final failure = error; + if (failure != null) { + throw failure; + } + return payload; + } + + @override + Future addWeight({required double weight}) async { + addedWeights.add(weight); + } + + @override + Future addCircumference({ + required int chestCircumference, + required int waistCircumference, + required int hipCircumference, + }) async { + addedCircumferences.add((chest: chestCircumference, waist: waistCircumference, hip: hipCircumference)); + } +} diff --git a/test/fixtures/insights/insights_payload.json b/test/fixtures/insights/insights_payload.json new file mode 100644 index 00000000..3844db21 --- /dev/null +++ b/test/fixtures/insights/insights_payload.json @@ -0,0 +1,58 @@ +{ + "weight": { + "labels": ["01.11", "05.11", "09.11"], + "values": [74.5, 73.9, 73.2], + "last": 73.2, + "delta": -1.3, + "headline": "73.2", + "unit": "кг", + "deltaText": "Снизили на 1.3 кг за период" + }, + "bmi": { + "labels": [], + "values": [], + "last": null, + "delta": null, + "headline": "—", + "category": null, + "deltaText": "Укажите рост, чтобы посчитать ИМТ.", + "hint": "" + }, + "measure": { + "labels": ["01.11", "09.11"], + "waist": [82, 80.5], + "chest": [96, 96], + "hips": [101, 100], + "headline": "81", + "deltaText": "Талия меньше на 1.5 см за период" + }, + "daily": { + "labels": ["03.11", "04.11", "05.11"], + "kcal": [1850, 2010, 1930], + "proteins": [92, 101, 96], + "fats": [60, 66, 63], + "carbs": [210, 222, 215], + "average": 1930, + "percent": 98, + "headline": "1930", + "deltaText": "98% от нормы" + }, + "intra": { + "bars": [ + {"x": 8.5, "p": 120, "f": 90, "c": 240}, + {"x": 13, "p": 160, "f": 130, "c": 300} + ], + "satiety": [{"x": 8.5, "y": 3}, {"x": 13, "y": 4}], + "mood": [{"x": 8.5, "y": 4}], + "wake": 7.5, + "sleep": 23, + "wakeLabel": "07:30", + "sleepLabel": "23:00", + "wakeText": "Подъём в 07:30", + "sleepText": "Отход ко сну в 23:00", + "date": "2026-11-09", + "maxDate": "2026-11-09", + "dateLabel": "Сегодня", + "isToday": true + } +} diff --git a/test/fixtures/insights/insights_payload_minimal.json b/test/fixtures/insights/insights_payload_minimal.json new file mode 100644 index 00000000..a460af1a --- /dev/null +++ b/test/fixtures/insights/insights_payload_minimal.json @@ -0,0 +1,7 @@ +{ + "weight": {"labels": [], "values": [], "last": null, "delta": null, "headline": "—", "unit": "кг", "deltaText": "Недостаточно данных"}, + "bmi": {"labels": [], "values": [], "headline": "—", "deltaText": "Укажите рост, чтобы посчитать ИМТ.", "hint": "Укажите рост, чтобы посчитать ИМТ."}, + "measure": {"labels": [], "waist": [], "chest": [], "hips": [], "headline": "—", "deltaText": "Недостаточно данных"}, + "daily": {"labels": [], "kcal": [], "proteins": [], "fats": [], "carbs": [], "average": null, "percent": null, "headline": "—", "deltaText": ""}, + "intra": {"bars": [], "satiety": [], "mood": [], "wake": null, "sleep": null, "date": "2026-11-09", "maxDate": "2026-11-09", "dateLabel": "Сегодня", "isToday": true} +}