diff --git a/lib/core/di/repository_registrations.dart b/lib/core/di/repository_registrations.dart index bb260432..e5b12e92 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(), insightsService: sl())); sl.registerLazySingleton(() => CalendarRepository(calendarService: sl())); sl.registerLazySingleton(() => SettingsRepository(settingsService: sl())); sl.registerLazySingleton(() => ConsultantRepository(consultantService: sl())); diff --git a/lib/core/di/service_registrations.dart b/lib/core/di/service_registrations.dart index 27152661..6232d813 100644 --- a/lib/core/di/service_registrations.dart +++ b/lib/core/di/service_registrations.dart @@ -19,6 +19,7 @@ import 'package:eatvio_app/features/diary/services/diary_service.dart'; import 'package:eatvio_app/features/diets/services/diet_service.dart'; import 'package:eatvio_app/features/feed/services/feed_service.dart'; import 'package:eatvio_app/features/glossary/services/glossary_service.dart'; +import 'package:eatvio_app/features/insights/services/insights_service.dart'; import 'package:eatvio_app/features/barcode/services/barcode_service.dart'; import 'package:eatvio_app/features/menu/services/blocked_users_service.dart'; import 'package:eatvio_app/features/menu/services/notification_settings_service.dart'; @@ -57,6 +58,7 @@ class ServiceRegistrations { sl.registerLazySingleton(() => VideoCallService(sl())); sl.registerLazySingleton(() => FeedService(sl())); sl.registerLazySingleton(() => ProfileService(sl())); + sl.registerLazySingleton(() => InsightsService(sl())); sl.registerLazySingleton(() => CalendarService(sl())); sl.registerLazySingleton(() => SettingsService(sl())); sl.registerLazySingleton(() => ConsultantService(sl())); diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index 266ecece..6eb6c2c7 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -365,9 +365,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 8ad68bf8..7aa2c52d 100644 --- a/lib/features/consultations/presentation/widgets/consultation_card_widget.dart +++ b/lib/features/consultations/presentation/widgets/consultation_card_widget.dart @@ -200,7 +200,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( @@ -283,7 +283,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/entities/diary_day_entity.dart b/lib/features/diary/entities/diary_day_entity.dart index e533dda7..99dd1dad 100644 --- a/lib/features/diary/entities/diary_day_entity.dart +++ b/lib/features/diary/entities/diary_day_entity.dart @@ -17,6 +17,9 @@ abstract class DiaryDayEntity with _$DiaryDayEntity { @JsonKey(name: "chosen_plan_id") required int? chosenPlanId, @JsonKey(name: "day_of_chosen_diet") required int? dayOfChosenDiet, @JsonKey(name: "chosen_diet", fromJson: parseDiet) required DiaryDietModel? diet, + + /// Slug владельца дневника; `null`, если сервер его не вернул. + String? ownerSlug, }) = _DiaryDayEntity; const DiaryDayEntity._(); diff --git a/lib/features/diary/models/diary_day_model.dart b/lib/features/diary/models/diary_day_model.dart index 76a353d9..62212f1e 100644 --- a/lib/features/diary/models/diary_day_model.dart +++ b/lib/features/diary/models/diary_day_model.dart @@ -48,6 +48,10 @@ abstract class DiaryDayModel with _$DiaryDayModel { @JsonKey(name: "chosen_plan_id") required int? chosenPlanId, @JsonKey(name: "day_of_chosen_diet") required int? dayOfChosenDiet, @JsonKey(name: "chosen_diet", fromJson: parseDiet) required DiaryDietModel? diet, + + /// Владелец дневника (`user` в `DiaryResource`): по `slug` консультант + /// запрашивает `/api/profile/insights/{slug}`. + @JsonKey(name: "user") DiaryOwnerModel? owner, }) = _DiaryDayModel; const DiaryDayModel._(); factory DiaryDayModel.fromJson(Map json) => _$DiaryDayModelFromJson(json); @@ -68,6 +72,7 @@ abstract class DiaryDayModel with _$DiaryDayModel { wakeup: wakeup, retire: retire, diet: diet, + ownerSlug: owner?.slug, ); } @@ -81,6 +86,13 @@ DiaryDietModel? parseDiet(dynamic json) { return DiaryDietModel.fromJson(json['diet']); } +@freezed +abstract class DiaryOwnerModel with _$DiaryOwnerModel { + const factory DiaryOwnerModel({int? id, String? slug}) = _DiaryOwnerModel; + + factory DiaryOwnerModel.fromJson(Map json) => _$DiaryOwnerModelFromJson(json); +} + @freezed abstract class DiaryDietModel with _$DiaryDietModel { const factory DiaryDietModel({required int id, required int duration, required String name}) = _DiaryDietModel; diff --git a/lib/features/diary/presentation/pages/diary_screen.dart b/lib/features/diary/presentation/pages/diary_screen.dart index b91bcba7..8b9431b6 100644 --- a/lib/features/diary/presentation/pages/diary_screen.dart +++ b/lib/features/diary/presentation/pages/diary_screen.dart @@ -20,7 +20,9 @@ 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/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -36,18 +38,27 @@ 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 +66,16 @@ class DiaryScreenContent extends StatefulWidget { class _DiaryScreenContentState extends State { bool _isDiaryFinishing = false; + /// Slug владельца: из `user.slug` дневника, иначе из параметра экрана. + String? _ownerSlug(DiaryStateLoaded state) { + return state.diary.ownerSlug ?? widget.ownerSlug; + } + + /// Консультант видит аналитику клиента только при известном slug владельца. + bool _canShowInsights(DiaryStateLoaded state) { + return !state.isConsultantView || _ownerSlug(state) != null; + } + @override Widget build(BuildContext context) { final s = S.of(context); @@ -110,6 +131,15 @@ class _DiaryScreenContentState extends State { const SizedBox(height: 60), const Loader(), ], + if (state is DiaryStateLoaded && _canShowInsights(state)) + Padding( + padding: const EdgeInsets.fromLTRB(4, 12, 4, 12), + child: _DiaryInsights( + slug: state.isConsultantView ? _ownerSlug(state) : null, + canAdd: !state.isConsultantView, + diaryDate: state.diary.date, + ), + ), if (state is DiaryStateLoaded) WakeupAndRetireWidget( isEnabled: @@ -249,3 +279,53 @@ class _DiaryScreenContentState extends State { ); } } + +/// Блок «Аналитика» в дневнике. Для своего дневника перезапрашивает данные, +/// когда в том же дне меняются приёмы пищи или время подъёма/отбоя: от них +/// зависят КБЖУ-графики. Чужой дневник консультант не редактирует. +class _DiaryInsights extends StatefulWidget { + const _DiaryInsights({ + required this.slug, + required this.canAdd, + required this.diaryDate, + }); + + final String? slug; + final bool canAdd; + + /// Дата открытого дня дневника (`YYYY-MM-DD`) — стартовый день «В течение дня». + final String diaryDate; + + @override + State<_DiaryInsights> createState() => _DiaryInsightsState(); +} + +class _DiaryInsightsState extends State<_DiaryInsights> { + int _refreshToken = 0; + + bool _isOwnDiaryMutation(DiaryState previous, DiaryState current) { + if (!widget.canAdd || + previous is! DiaryStateLoaded || + current is! DiaryStateLoaded || + previous.diary.id != current.diary.id) { + return false; + } + return !listEquals(previous.diary.meals, current.diary.meals) || + previous.diary.wakeup != current.diary.wakeup || + previous.diary.retire != current.diary.retire; + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listenWhen: _isOwnDiaryMutation, + listener: (context, state) => setState(() => _refreshToken++), + child: InsightsCard( + slug: widget.slug, + canAdd: widget.canAdd, + refreshToken: _refreshToken, + initialIntraDate: widget.diaryDate, + ), + ); + } +} 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..d33af602 --- /dev/null +++ b/lib/features/insights/insights_feature_flags.dart @@ -0,0 +1,8 @@ +/// Флаги поэтапного включения аналитики. +abstract final class InsightsFeatureFlags { + /// Вкладки «КБЖУ по дням» и «В течение дня». При `true` весь блок берёт + /// данные и тексты с сервера (`GET /api/profile/insights*`, eatvio/eatvio#3017), + /// при `false` — вкладки скрыты, а вес/ИМТ/замеры собираются на клиенте из + /// `/api/profile/measure/*`. + static const bool kbjuEnabled = true; +} 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..cd3d01a2 --- /dev/null +++ b/lib/features/insights/models/insights_period.dart @@ -0,0 +1,46 @@ +import 'package:eatvio_app/generated/l10n.dart'; + +/// Период для вкладок «Вес», «ИМТ» и «Замеры». Значение совпадает с `{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; + + /// Подпись пилюли периода. + String pillLabel(S s) => switch (this) { + month => s.insights_period_1m, + threeMonths => s.insights_period_3m, + sixMonths => s.insights_period_6m, + year => s.insights_period_1y, + all => s.insights_period_all, + }; +} + +/// Период для вкладки «КБЖУ по дням». Значение совпадает с `daily_period` в `/api/profile/insights`. +enum DailyPeriod { + week('7'), + twoWeeks('14'), + month('30'), + threeMonths('90'), + all('all'); + + const DailyPeriod(this.apiValue); + + final String apiValue; + + /// Подпись пилюли периода. + String pillLabel(S s) => switch (this) { + week => s.insights_period_1w, + twoWeeks => s.insights_period_2w, + month => s.insights_period_1m, + threeMonths => s.insights_period_3m, + all => s.insights_period_all, + }; +} diff --git a/lib/features/insights/models/insights_response_model.dart b/lib/features/insights/models/insights_response_model.dart new file mode 100644 index 00000000..0d78f7b3 --- /dev/null +++ b/lib/features/insights/models/insights_response_model.dart @@ -0,0 +1,36 @@ +import 'package:eatvio_app/features/insights/models/insights_payload_model.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'insights_response_model.freezed.dart'; +part 'insights_response_model.g.dart'; + +/// Ответ `GET /api/profile/insights[/{slug}]`: `{ "data": { weight, bmi, measure, daily, intra } }`. +@freezed +abstract class InsightsResponseModel with _$InsightsResponseModel { + const factory InsightsResponseModel({required InsightsPayloadModel data}) = + _InsightsResponseModel; + + factory InsightsResponseModel.fromJson(Map json) => + _$InsightsResponseModelFromJson(json); +} + +/// Ответ `GET /api/profile/insights/intra` и `/insights/{slug}/intra`: +/// `{ "data": { "intra": { ... } } }`. +@freezed +abstract class InsightsIntraResponseModel with _$InsightsIntraResponseModel { + const factory InsightsIntraResponseModel({ + required InsightsIntraDataModel data, + }) = _InsightsIntraResponseModel; + + factory InsightsIntraResponseModel.fromJson(Map json) => + _$InsightsIntraResponseModelFromJson(json); +} + +@freezed +abstract class InsightsIntraDataModel with _$InsightsIntraDataModel { + const factory InsightsIntraDataModel({required IntraChartModel intra}) = + _InsightsIntraDataModel; + + factory InsightsIntraDataModel.fromJson(Map json) => + _$InsightsIntraDataModelFromJson(json); +} 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..eedfbf3e --- /dev/null +++ b/lib/features/insights/presentation/charts/bmi_chart.dart @@ -0,0 +1,91 @@ +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 dataRange = paddedRange(points.map((e) => e.value), minPadding: 2); + // Ось всегда покрывает зоны нормы/избытка/ожирения, иначе при ИМТ ≈30 + // зелёная полоса 18.5–25 уезжает за нижний край графика. + const zoneMin = 18.5; + const zoneMax = 40.0; + final min = dataRange.min < zoneMin ? dataRange.min : zoneMin; + final max = dataRange.max > zoneMax ? dataRange.max : zoneMax; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: insightsChartHeight, + child: SfCartesianChart( + key: const Key('insights_bmi_chart'), + margin: insightsChartMargin, + plotAreaBorderWidth: 0, + primaryXAxis: buildInsightsIndexAxis(model.labels), + primaryYAxis: buildInsightsValueAxis( + unit: s.insights_unit_bmi, + min: min, + max: 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.index, + yValueMapper: (point, _) => point.value, + splineType: insightsSplineType, + 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..dca6d310 --- /dev/null +++ b/lib/features/insights/presentation/charts/daily_kbju_chart.dart @@ -0,0 +1,135 @@ +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); + + // Локальный диапазон по данным (как на вебе), не ось от нуля. + // Кривую ниже точек держит insightsSplineType (monotonic). + final kcalRange = paddedRange( + calories.map((e) => e.value), + minPadding: 50, + clampZero: true, + ); + final gramValues = [ + ...proteins.map((e) => e.value), + ...fats.map((e) => e.value), + ...carbs.map((e) => e.value), + ]; + final gramsRange = paddedRange( + gramValues, + minPadding: 5, + clampZero: true, + ); + + 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: insightsChartMargin, + plotAreaBorderWidth: 0, + primaryXAxis: buildInsightsIndexAxis(model.labels), + primaryYAxis: NumericAxis( + title: AxisTitle( + text: s.insights_unit_kcal, + textStyle: insightsAxisLabelStyle, + ), + labelStyle: insightsAxisLabelStyle, + minimum: kcalRange.min, + maximum: kcalRange.max, + 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: gramsRange.min, + maximum: gramsRange.max, + 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.index, + yValueMapper: (point, _) => point.value, + splineType: insightsSplineType, + 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.index, + yValueMapper: (point, _) => point.value, + splineType: insightsSplineType, + 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..07a2c35a --- /dev/null +++ b/lib/features/insights/presentation/charts/insights_chart_common.dart @@ -0,0 +1,180 @@ +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 (как у Chart.js), подпись `dd.MM` и значение. +/// +/// Индекс обязателен: Syncfusion `CategoryAxis` склеивает одинаковые строки +/// (`05.07` несколько раз → одна категория). На вебе labels идут по индексу, +/// поэтому здесь X = index, а дата рисуется через [axisLabelFormatter]. +class InsightsPoint { + const InsightsPoint({ + required this.index, + required this.label, + required this.value, + }); + + final int index; + 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(index: i, label: labels[i], value: values[i]), + ]; +} + +/// Локальный диапазон оси Y вокруг данных (не от нуля), с запасом под сплайн. +/// +/// [clampZero] — не уводить минимум в минус на осях КБЖУ (ккал/граммы ≥ 0). +({double min, double max}) paddedRange( + Iterable values, { + double minPadding = 1, + bool clampZero = false, +}) { + 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); + var min = lowest - padding; + var max = highest + padding; + if (clampZero) { + min = math.max(0, min); + } + if (max <= min) { + max = min + minPadding; + } + return (min: min, max: max); +} + + +const SplineType insightsSplineType = SplineType.monotonic; + +const TextStyle insightsAxisLabelStyle = TextStyle( + fontSize: 11, + color: InsightsColors.subtitle, +); + +/// Ось X по индексу точки; подпись — дата из [labels]. +/// +/// Так даты `05.07`, `06.07`, … не схлопываются и не размножаются, когда на +/// графике несколько серий с одним и тем же набором labels (КБЖУ, замеры). +NumericAxis buildInsightsIndexAxis(List labels) { + final lastIndex = math.max(labels.length - 1, 0).toDouble(); + // На узком экране не подписываем каждую точку: ~6 подписей, как авто-тики Chart.js. + final interval = labels.length <= 7 + ? 1.0 + : (labels.length / 6).ceilToDouble(); + + return NumericAxis( + minimum: 0, + maximum: lastIndex, + interval: interval, + labelStyle: insightsAxisLabelStyle, + majorGridLines: const MajorGridLines(width: 0), + majorTickLines: const MajorTickLines(size: 0), + axisLine: const AxisLine(color: InsightsColors.border, width: 1), + edgeLabelPlacement: EdgeLabelPlacement.shift, + axisLabelFormatter: (details) { + final index = details.value.round(); + if (index < 0 || index >= labels.length) { + return ChartAxisLabel('', details.textStyle); + } + // Промежуточные дробные тики (если Syncfusion их поставит) не подписываем. + if ((details.value - index).abs() > 0.01) { + return ChartAxisLabel('', details.textStyle); + } + return ChartAxisLabel(labels[index], insightsAxisLabelStyle); + }, + ); +} + +NumericAxis buildInsightsValueAxis({ + required String unit, + required double min, + required double max, + List plotBands = const [], +}) { + return NumericAxis( + minimum: min, + maximum: max, + // Доп. воздух, чтобы сплайн не врезался в край plot area. + rangePadding: ChartRangePadding.additional, + 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, + ); +} + +/// Небольшой внутренний отступ, чтобы кривая и подписи не резались контейнером. +const EdgeInsets insightsChartMargin = EdgeInsets.fromLTRB(0, 8, 0, 4); + +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..05d90a78 --- /dev/null +++ b/lib/features/insights/presentation/charts/intra_day_chart.dart @@ -0,0 +1,214 @@ +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 && + model.wake == null && + model.sleep == null) { + 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: insightsChartMargin, + 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: insightsSplineType, + 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..33bd3e35 --- /dev/null +++ b/lib/features/insights/presentation/charts/measure_chart.dart @@ -0,0 +1,87 @@ +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: insightsChartMargin, + plotAreaBorderWidth: 0, + primaryXAxis: buildInsightsIndexAxis(model.labels), + 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.index, + yValueMapper: (point, _) => point.value, + splineType: insightsSplineType, + 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..0a85860a --- /dev/null +++ b/lib/features/insights/presentation/charts/weight_chart.dart @@ -0,0 +1,54 @@ +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: insightsChartMargin, + plotAreaBorderWidth: 0, + primaryXAxis: buildInsightsIndexAxis(model.labels), + 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.index, + yValueMapper: (point, _) => point.value, + splineType: insightsSplineType, + 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..4c1a4cd1 --- /dev/null +++ b/lib/features/insights/presentation/cubit/insights_cubit.dart @@ -0,0 +1,400 @@ +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_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: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; + + /// Полный payload и вкладка «В течение дня» координируются отдельно: смена + /// дня не должна отменять загрузку периода, и наоборот. + int _fullRequestId = 0; + int _intraRequestId = 0; + int _inflightFull = 0; + int _inflightIntra = 0; + + List get panels => + InsightsPanel.available(kbjuEnabled: kbjuEnabled); + + bool get _isBusy => _inflightFull > 0 || _inflightIntra > 0; + + Future load({String? intraDate}) async { + emit(const InsightsState.loading()); + await _fetch( + bodyPeriod: BodyPeriod.threeMonths, + dailyPeriod: DailyPeriod.twoWeeks, + intraDate: intraDate, + isInitial: true, + ); + } + + /// Перезапрашивает данные, сохраняя вкладку, период, серии и раскрытие. + Future refresh() async { + final current = state; + if (current is InsightsStateLoading || current is InsightsStateHidden) { + return; + } + if (current is! InsightsStateLoaded) { + return load(); + } + await _fetch( + bodyPeriod: current.bodyPeriod, + dailyPeriod: current.dailyPeriod, + intraDate: current.intraDate, + isInitial: false, + ); + } + + 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, + dailyPeriod: current.dailyPeriod, + intraDate: current.intraDate, + isInitial: false, + ); + } + + /// Период вкладки «КБЖУ по дням». Работает только с серверными данными. + Future setDailyPeriod(DailyPeriod period) async { + final current = state; + if (!kbjuEnabled || + current is! InsightsStateLoaded || + current.dailyPeriod == period) { + return; + } + emit(current.copyWith(dailyPeriod: period, isRefreshing: true)); + await _fetch( + bodyPeriod: current.bodyPeriod, + dailyPeriod: period, + intraDate: current.intraDate, + isInitial: false, + ); + } + + /// Показывает вкладку «В течение дня» за [day] (запрашивается только она). + Future setIntraDate(DateTime day) { + return _loadIntra(_formatDate(day)); + } + + Future showPreviousDay() async { + final base = _currentIntraDay(); + if (base != null) { + await setIntraDate(DateTime(base.year, base.month, base.day - 1)); + } + } + + Future showNextDay() async { + final current = state; + final base = _currentIntraDay(); + if (base == null || + current is! InsightsStateLoaded || + !_canGoToNextDay(current, base)) { + return; + } + await setIntraDate(DateTime(base.year, base.month, base.day + 1)); + } + + /// База для листания: сначала оптимистично запрошенная дата, иначе ответ сервера. + DateTime? _currentIntraDay() { + final current = state; + if (!kbjuEnabled || current is! InsightsStateLoaded) { + return null; + } + final optimistic = current.intraDate; + if (optimistic != null && optimistic.isNotEmpty) { + return DateTime.tryParse(optimistic); + } + return DateTime.tryParse(current.payload.intra?.date ?? ''); + } + + bool _canGoToNextDay(InsightsStateLoaded current, DateTime base) { + final maxRaw = + current.payload.intra?.maxDate ?? current.payload.intra?.date ?? ''; + final maxDate = DateTime.tryParse(maxRaw); + if (maxDate == null) { + return !(current.payload.intra?.isToday ?? true); + } + final baseDay = DateTime(base.year, base.month, base.day); + final maxDay = DateTime(maxDate.year, maxDate.month, maxDate.day); + return baseDay.isBefore(maxDay); + } + + Future _loadIntra(String date) async { + final current = state; + if (!kbjuEnabled || current is! InsightsStateLoaded) { + return; + } + final requestId = ++_intraRequestId; + _inflightIntra++; + final previousIntraDate = current.intraDate; + // Оптимистично фиксируем запрошенный день до ответа, чтобы быстрые + // нажатия «‹ ›» считали базу от него, а не от устаревшего payload. + emit(current.copyWith(intraDate: date, isRefreshing: true)); + try { + final intra = await repository.fetchIntra(slug: slug, intraDate: date); + if (requestId != _intraRequestId || isClosed) { + return; + } + final latest = state; + if (latest is! InsightsStateLoaded) { + return; + } + emit( + latest.copyWith( + payload: latest.payload.copyWith(intra: intra), + intraDate: intra.isToday + ? null + : (intra.date.isEmpty ? date : intra.date), + isRefreshing: _inflightFull > 0 || _inflightIntra > 1, + ), + ); + } on InsightsForbidden { + if (requestId == _intraRequestId && !isClosed) { + emit(const InsightsState.hidden()); + } + } catch (e, st) { + await reportFrom(e, st, this); + log(e.toString(), stackTrace: st); + if (requestId == _intraRequestId && !isClosed) { + final latest = state; + if (latest is InsightsStateLoaded) { + emit( + latest.copyWith( + intraDate: previousIntraDate, + isRefreshing: _inflightFull > 0 || _inflightIntra > 1, + ), + ); + } + } + } finally { + _inflightIntra--; + _syncRefreshingFlag(); + } + } + + static String _formatDate(DateTime day) { + final month = day.month.toString().padLeft(2, '0'); + final dayOfMonth = day.day.toString().padLeft(2, '0'); + return '${day.year.toString().padLeft(4, '0')}-$month-$dayOfMonth'; + } + + 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 _requestPayload({ + required BodyPeriod bodyPeriod, + required DailyPeriod dailyPeriod, + required String? intraDate, + }) async { + if (kbjuEnabled) { + return repository.fetchInsights( + slug: slug, + bodyPeriod: bodyPeriod, + dailyPeriod: dailyPeriod, + intraDate: intraDate, + ); + } + if (!_isHeightLoaded) { + _heightCm = await repository.getHeightCm(slug: slug); + _isHeightLoaded = true; + } + return repository.getInsights( + slug: slug, + bodyPeriod: bodyPeriod, + heightCm: _heightCm, + ); + } + + Future _fetch({ + required BodyPeriod bodyPeriod, + required DailyPeriod dailyPeriod, + required String? intraDate, + required bool isInitial, + }) async { + final requestId = ++_fullRequestId; + final intraGenerationAtStart = _intraRequestId; + _inflightFull++; + final before = state; + if (before is InsightsStateLoaded && !before.isRefreshing) { + emit(before.copyWith(isRefreshing: true)); + } + try { + final payload = await _requestPayload( + bodyPeriod: bodyPeriod, + dailyPeriod: dailyPeriod, + intraDate: intraDate, + ); + if (requestId != _fullRequestId || isClosed) { + return; + } + final latest = state; + if (latest is InsightsStateLoaded) { + // Более свежий intra-запрос не перезаписываем полным ответом. + final keepIntra = _intraRequestId > intraGenerationAtStart; + emit( + latest.copyWith( + payload: keepIntra + ? payload.copyWith(intra: latest.payload.intra) + : payload, + bodyPeriod: bodyPeriod, + dailyPeriod: dailyPeriod, + intraDate: keepIntra ? latest.intraDate : intraDate, + isRefreshing: _inflightIntra > 0 || _inflightFull > 1, + ), + ); + } else if (isInitial || latest is! InsightsStateError) { + emit( + InsightsState.loaded( + payload: payload, + bodyPeriod: bodyPeriod, + dailyPeriod: dailyPeriod, + intraDate: intraDate, + ), + ); + } + } on InsightsForbidden { + if (requestId == _fullRequestId && !isClosed) { + emit(const InsightsState.hidden()); + } + } catch (e, st) { + await reportFrom(e, st, this); + log(e.toString(), stackTrace: st); + if (requestId != _fullRequestId || isClosed) { + return; + } + final latest = state; + // Ошибка перезапроса не должна стирать уже показанные данные и UI-выбор. + if (latest is InsightsStateLoaded) { + emit( + latest.copyWith( + isRefreshing: _inflightIntra > 0 || _inflightFull > 1, + ), + ); + } else if (isInitial) { + emit(InsightsState.error(message: e.toString())); + } + } finally { + _inflightFull--; + _syncRefreshingFlag(); + } + } + + void _syncRefreshingFlag() { + if (isClosed) { + return; + } + final current = state; + if (current is! InsightsStateLoaded) { + return; + } + final busy = _isBusy; + if (current.isRefreshing != busy) { + emit(current.copyWith(isRefreshing: busy)); + } + } +} 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..39aff3a9 --- /dev/null +++ b/lib/features/insights/presentation/cubit/insights_panel.dart @@ -0,0 +1,29 @@ +import 'package:eatvio_app/generated/l10n.dart'; + +/// Вкладки блока «Аналитика». +enum InsightsPanel { + weight, + bmi, + measures, + daily, + intra; + + /// Вкладки КБЖУ доступны только при включённом флаге. + bool get isKbju => this == daily || this == intra; + + /// Кнопка «+» есть только у вкладок с ручным вводом. + bool get supportsAdd => this == weight || this == measures; + + /// Подпись вкладки. + String label(S s) => switch (this) { + weight => s.insights_tab_weight, + bmi => s.insights_tab_bmi, + measures => s.insights_tab_measures, + daily => s.insights_tab_daily, + intra => s.insights_tab_intra, + }; + + 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..d14d5fab --- /dev/null +++ b/lib/features/insights/presentation/cubit/insights_state.dart @@ -0,0 +1,51 @@ +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(DailyPeriod.twoWeeks) DailyPeriod dailyPeriod, + + /// День вкладки «В течение дня» (`YYYY-MM-DD`); `null` — сегодня. + String? intraDate, + @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..df1d1fa9 --- /dev/null +++ b/lib/features/insights/presentation/widgets/insights_card.dart @@ -0,0 +1,640 @@ +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, + this.refreshToken = 0, + this.initialIntraDate, + }); + + final String? slug; + final bool canAdd; + final InsightsRepository? repository; + final bool kbjuEnabled; + + /// При смене значения блок перезапрашивает данные (например, после правок + /// в дневнике), сохраняя вкладку, период и выбранный день. + final int refreshToken; + + /// День вкладки «В течение дня» при первой загрузке (`YYYY-MM-DD`). + /// В дневнике совпадает с открытой датой; `null` — сегодня. + final String? initialIntraDate; + + @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(intraDate: initialIntraDate), + child: _InsightsView( + refreshToken: refreshToken, + initialIntraDate: initialIntraDate, + ), + ), + ); + } +} + +class _InsightsView extends StatefulWidget { + const _InsightsView({ + required this.refreshToken, + required this.initialIntraDate, + }); + + final int refreshToken; + final String? initialIntraDate; + + @override + State<_InsightsView> createState() => _InsightsViewState(); +} + +class _InsightsViewState extends State<_InsightsView> { + @override + void didUpdateWidget(covariant _InsightsView oldWidget) { + super.didUpdateWidget(oldWidget); + final cubit = context.read(); + if (oldWidget.refreshToken != widget.refreshToken) { + cubit.refresh(); + } + final nextDate = widget.initialIntraDate; + if (nextDate != null && + nextDate.isNotEmpty && + oldWidget.initialIntraDate != nextDate) { + final parsed = DateTime.tryParse(nextDate); + if (parsed != null) { + cubit.setIntraDate(parsed); + } + } + } + + @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: 16, 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( + 16, + 0, + 16, + 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: 16, 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`. +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: state.dailyPeriod, + labelOf: (period) => period.pillLabel(s), + keyOf: (period) => period.apiValue, + onSelected: cubit.setDailyPeriod, + ), + 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 ?? 0) != 0, + ), + 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), + ], + ], + ); + } +} + +/// «В течение дня». Листание дней запрашивает только `/insights/intra`. +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, + onPreviousDay: cubit.showPreviousDay, + onNextDay: cubit.showNextDay, + onDatePicked: cubit.setIntraDate, + ), + 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..83b4e0f6 --- /dev/null +++ b/lib/features/insights/presentation/widgets/insights_tabs.dart @@ -0,0 +1,63 @@ +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'; + +/// Горизонтально прокручиваемые вкладки (`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..edc3366c --- /dev/null +++ b/lib/features/insights/presentation/widgets/period_pills.dart @@ -0,0 +1,79 @@ +import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart'; +import 'package:flutter/material.dart'; + +/// Пилюли выбора периода (`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..4da18a0d --- /dev/null +++ b/lib/features/insights/repositories/insights_repository.dart @@ -0,0 +1,103 @@ +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/insights/services/insights_service.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(); + +/// Источники данных блока «Аналитика»: +/// - [fetchInsights] / [fetchIntra] — серверные `GET /api/profile/insights*` +/// (графики и тексты считает `UserInsightsService`, включая КБЖУ); +/// - [getInsights] — собирает payload на клиенте из эндпоинтов веса и замеров +/// (используется, пока выключен `InsightsFeatureFlags.kbjuEnabled`). +class InsightsRepository { + InsightsRepository({required this.profileRepository, required this.insightsService, InsightsTextBuilderFactory? textBuilderFactory}) + : _textBuilderFactory = textBuilderFactory ?? (() => InsightsTextBuilder(S.current)); + + final ProfileRepository profileRepository; + final InsightsService insightsService; + 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; + } + } + + /// Полный payload с сервера. [intraDate] — `YYYY-MM-DD`, `null` — сегодня. + Future fetchInsights({String? slug, required BodyPeriod bodyPeriod, required DailyPeriod dailyPeriod, String? intraDate}) async { + try { + final response = slug == null + ? await insightsService.getInsights(bodyPeriod: bodyPeriod.apiValue, dailyPeriod: dailyPeriod.apiValue, intraDate: intraDate) + : await insightsService.getInsightsBySlug(slug: slug, bodyPeriod: bodyPeriod.apiValue, dailyPeriod: dailyPeriod.apiValue, intraDate: intraDate); + return response.data; + } on DioException catch (e) { + _throwIfForbidden(e); + rethrow; + } + } + + /// Только вкладка «В течение дня» за [intraDate] (`YYYY-MM-DD`). + Future fetchIntra({String? slug, String? intraDate}) async { + try { + final response = slug == null + ? await insightsService.getIntra(intraDate: intraDate) + : await insightsService.getIntraBySlug(slug: slug, intraDate: intraDate); + return response.data.intra; + } 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/insights/services/insights_service.dart b/lib/features/insights/services/insights_service.dart new file mode 100644 index 00000000..f0e64028 --- /dev/null +++ b/lib/features/insights/services/insights_service.dart @@ -0,0 +1,43 @@ +import 'package:dio/dio.dart'; +import 'package:eatvio_app/features/insights/models/insights_response_model.dart'; +import 'package:retrofit/retrofit.dart'; + +part 'insights_service.g.dart'; + +/// Эндпоинты инсайтов (eatvio/eatvio#3017). Сервер приводит будущую +/// `intra_date` к сегодняшней, неверные значения периода дают 422, чужой +/// недоступный профиль — 403. +@RestApi() +abstract class InsightsService { + factory InsightsService(Dio dio, {String baseUrl}) = _InsightsService; + + /// Свои графики целиком. + @GET('/api/profile/insights') + Future getInsights({ + @Query('body_period') String? bodyPeriod, + @Query('daily_period') String? dailyPeriod, + @Query('intra_date') String? intraDate, + }); + + /// Чужие графики целиком (клиент консультанта; остальным 403). + @GET('/api/profile/insights/{slug}') + Future getInsightsBySlug({ + @Path() required String slug, + @Query('body_period') String? bodyPeriod, + @Query('daily_period') String? dailyPeriod, + @Query('intra_date') String? intraDate, + }); + + /// Только «в течение дня» для себя. + @GET('/api/profile/insights/intra') + Future getIntra({ + @Query('intra_date') String? intraDate, + }); + + /// Только «в течение дня» для другого пользователя. + @GET('/api/profile/insights/{slug}/intra') + Future getIntraBySlug({ + @Path() required String slug, + @Query('intra_date') String? intraDate, + }); +} 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..817fdd02 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) { @@ -281,6 +260,14 @@ class _ProfileScreenState extends State { padding: const EdgeInsets.only(bottom: 16), child: _badges(state), ), + // Доступ решает сервер (UserPolicy::viewInsights): консультант видит + // клиента, пока консультация не завершена; всем остальным 403 и блок скрыт. + if (!state.profile.isBlockedByMe && + !state.profile.isBlockedByAuthor) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: InsightsCard(slug: state.profile.slug), + ), if (state.profile.isBlockedByAuthor) Column( mainAxisAlignment: MainAxisAlignment.center, @@ -442,10 +429,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 +1710,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 99a1c3c0..20a73529 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -22,50 +22,80 @@ class MessageLookup extends MessageLookupByLibrary { static String m0(date) => "Консультант прокомментировал день ${date}"; - static String m1(userName) => + static String m1(value, period, category) => + "BMI higher by ${value} over ${period} · ${category}"; + + 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(userName) => "You have proposed a personal diet to ${userName}"; - static String m2(userName, dietName) => + static String m15(userName, dietName) => "You have proposed a personal diet to ${userName}, selected diet: «${dietName}»"; - static String m3(duration, dayWord) => + static String m16(duration, dayWord) => "The diet lasts ${duration} ${dayWord}. It can only be offered for a plan with the same duration."; - static String m4(dietDuration, dietDayWord, planDuration, planDayWord) => + static String m17(dietDuration, dietDayWord, planDuration, planDayWord) => "Not suitable: diet lasts ${dietDuration} ${dietDayWord}, plan lasts ${planDuration} ${planDayWord}."; - static String m5(name) => "Diet «${name}» proposed"; + static String m18(name) => "Diet «${name}» proposed"; - static String m6(planName) => "План «${planName}» начался"; + static String m19(planName) => "План «${planName}» начался"; - static String m7(planName, name) => + static String m20(planName, name) => "План «${planName}» с пользователем ${name} начался"; - static String m8(duration, dayWord) => "Plan for ${duration} ${dayWord}"; + static String m21(duration, dayWord) => "Plan for ${duration} ${dayWord}"; - static String m9(percent, amount) => + static String m22(percent, amount) => "Discount ${percent}% — you save ${amount} ₽"; - static String m10(date) => "Start: ${date}"; + static String m23(date) => "Start: ${date}"; - static String m11(step) => "шаг ${step} из 2"; + static String m24(step) => "шаг ${step} из 2"; - static String m12(planName, name) => + static String m25(planName, name) => "Завтра начинается план «${planName}» с пользователем ${name}"; - static String m13(planName) => + static String m26(planName) => "Завтра начнется прохождение плана «${planName}»"; - static String m14(name, planName) => + static String m27(name, planName) => "Пользователь ${name} отменил подписку на план «${planName}»"; - static String m15(name, planName) => + static String m28(name, planName) => "Пользователь ${name} оплатил план «${planName}»"; - static String m16(name, planName) => + static String m29(name, planName) => "Пользователь ${name} подписался на план «${planName}»"; - static String m17(planName) => "Вы отменили подписку на план «${planName}»"; + static String m30(planName) => "Вы отменили подписку на план «${planName}»"; final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { @@ -525,6 +555,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( "Количество приглашенных", @@ -697,17 +847,17 @@ class MessageLookup extends MessageLookupByLibrary { "personalDietCreating": MessageLookupByLibrary.simpleMessage( "Составление индивидуальной диеты", ), - "personal_diet_defined_to_author": m1, - "personal_diet_defined_to_author_named": m2, - "personal_diet_duration_hint": m3, - "personal_diet_duration_mismatch": m4, + "personal_diet_defined_to_author": m14, + "personal_diet_defined_to_author_named": m15, + "personal_diet_duration_hint": m16, + "personal_diet_duration_mismatch": m17, "personal_diet_not_proposed": MessageLookupByLibrary.simpleMessage( "Personal diet not proposed", ), "personal_diet_proposed": MessageLookupByLibrary.simpleMessage( "Personal diet proposed", ), - "personal_diet_proposed_named": m5, + "personal_diet_proposed_named": m18, "personal_diet_replaces_tomorrow": MessageLookupByLibrary.simpleMessage( "The change takes effect tomorrow", ), @@ -727,18 +877,18 @@ class MessageLookup extends MessageLookupByLibrary { "Физическая форма", ), "place_of_living": MessageLookupByLibrary.simpleMessage("Место проживания"), - "planStarted": m6, + "planStarted": m19, "planWillBeConfirmedAfterThreeDays": MessageLookupByLibrary.simpleMessage( "Если вы не подтвердите, то план через три дня подтвердится автоматически", ), - "planWithUserStarted": m7, + "planWithUserStarted": m20, "plan_author": MessageLookupByLibrary.simpleMessage("Автор плана"), "plan_desc": MessageLookupByLibrary.simpleMessage("Описание плана"), "plan_desc_example": MessageLookupByLibrary.simpleMessage( "Большое подробное, привлекательное описание плана", ), "plan_duration": MessageLookupByLibrary.simpleMessage("Срок плана"), - "plan_duration_label": m8, + "plan_duration_label": m21, "plan_label_desc": MessageLookupByLibrary.simpleMessage( "План для поддержания формы", ), @@ -775,7 +925,7 @@ class MessageLookup extends MessageLookupByLibrary { "Promo code applied!", ), "promo_code": MessageLookupByLibrary.simpleMessage("Promo code"), - "promo_discount_hint": m9, + "promo_discount_hint": m22, "promo_invalid": MessageLookupByLibrary.simpleMessage( "Promo code not found or expired", ), @@ -896,13 +1046,13 @@ class MessageLookup extends MessageLookupByLibrary { "Выбрана дата старта", ), "startDiet": MessageLookupByLibrary.simpleMessage("Начать диету"), - "start_date_label": m10, + "start_date_label": m23, "statistic": MessageLookupByLibrary.simpleMessage("Статистика"), "step": MessageLookupByLibrary.simpleMessage("Шаг"), "step_by_step_photo_recipe": MessageLookupByLibrary.simpleMessage( "Пошаговый фоторецепт", ), - "step_widget": m11, + "step_widget": m24, "successfully_published": MessageLookupByLibrary.simpleMessage( "успешно опубликован", ), @@ -927,8 +1077,8 @@ class MessageLookup extends MessageLookupByLibrary { "toOffer": MessageLookupByLibrary.simpleMessage("Предложить"), "to_draft": MessageLookupByLibrary.simpleMessage("В черновик"), "to_order": MessageLookupByLibrary.simpleMessage("Заказать"), - "tommorowPlanWithUserWillStart": m12, - "tomorrowYourPlanWillStart": m13, + "tommorowPlanWithUserWillStart": m25, + "tomorrowYourPlanWillStart": m26, "try_again": MessageLookupByLibrary.simpleMessage("Попробовать снова"), "try_again_2": MessageLookupByLibrary.simpleMessage("Попробуйте ещё раз"), "typeAmoutOfReturn": MessageLookupByLibrary.simpleMessage( @@ -962,7 +1112,7 @@ class MessageLookup extends MessageLookupByLibrary { "Верхние показатели давления:", ), "upper": MessageLookupByLibrary.simpleMessage("Верхнее"), - "userCanceldThePlanSubscription": m14, + "userCanceldThePlanSubscription": m27, "userConfirmedPlanCompletion": MessageLookupByLibrary.simpleMessage( "Пользователь подтвердил прохождения плана. В ближайшее время Вы получите", ), @@ -970,8 +1120,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage( "Пользователь подтвердил прохождения плана.", ), - "userPaidThePlan": m15, - "userSubscribedToPlan": m16, + "userPaidThePlan": m28, + "userSubscribedToPlan": m29, "user_authorization": MessageLookupByLibrary.simpleMessage( "Аутентификация", ), @@ -1101,7 +1251,7 @@ class MessageLookup extends MessageLookupByLibrary { "youCanComplain": MessageLookupByLibrary.simpleMessage( "Если вас что-то не устроило, вы можете подать жалобу", ), - "youCanceledSubscriptionToPlan": m17, + "youCanceledSubscriptionToPlan": m30, "youShureYouWantToCancelOrder": MessageLookupByLibrary.simpleMessage( "Вы уверены, что хотите отменить заказ?", ), diff --git a/lib/generated/intl/messages_ru.dart b/lib/generated/intl/messages_ru.dart index 5f6457d6..2a3fdec0 100644 --- a/lib/generated/intl/messages_ru.dart +++ b/lib/generated/intl/messages_ru.dart @@ -20,26 +20,56 @@ typedef String MessageIfAbsent(String messageStr, List args); class MessageLookup extends MessageLookupByLibrary { String get localeName => 'ru'; - static String m1(userName) => + 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 m14(userName) => "Вы предложили персональную диету пользователю ${userName}"; - static String m2(userName, dietName) => + static String m15(userName, dietName) => "Вы предложили персональную диету пользователю ${userName}, выбранная диета: «${dietName}»"; - static String m3(duration, dayWord) => + static String m16(duration, dayWord) => "Диета рассчитана на ${duration} ${dayWord}. Предложить её можно только для плана с такой же длительностью."; - static String m4(dietDuration, dietDayWord, planDuration, planDayWord) => + static String m17(dietDuration, dietDayWord, planDuration, planDayWord) => "Не подходит: диета на ${dietDuration} ${dietDayWord}, план на ${planDuration} ${planDayWord}."; - static String m5(name) => "Диета «${name}» предложена"; + static String m18(name) => "Диета «${name}» предложена"; - static String m8(duration, dayWord) => "План на ${duration} ${dayWord}"; + static String m21(duration, dayWord) => "План на ${duration} ${dayWord}"; - static String m9(percent, amount) => + static String m22(percent, amount) => "Скидка ${percent}% — вы экономите ${amount} ₽"; - static String m10(date) => "Старт: ${date}"; + static String m23(date) => "Старт: ${date}"; final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { @@ -243,6 +273,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("Фамилия"), @@ -341,17 +489,17 @@ class MessageLookup extends MessageLookupByLibrary { "payment_failed": MessageLookupByLibrary.simpleMessage("Оплата не прошла"), "pcs": MessageLookupByLibrary.simpleMessage("шт"), "personal": MessageLookupByLibrary.simpleMessage("Персональные"), - "personal_diet_defined_to_author": m1, - "personal_diet_defined_to_author_named": m2, - "personal_diet_duration_hint": m3, - "personal_diet_duration_mismatch": m4, + "personal_diet_defined_to_author": m14, + "personal_diet_defined_to_author_named": m15, + "personal_diet_duration_hint": m16, + "personal_diet_duration_mismatch": m17, "personal_diet_not_proposed": MessageLookupByLibrary.simpleMessage( "Диета не предложена", ), "personal_diet_proposed": MessageLookupByLibrary.simpleMessage( "Диета предложена", ), - "personal_diet_proposed_named": m5, + "personal_diet_proposed_named": m18, "personal_diet_replaces_tomorrow": MessageLookupByLibrary.simpleMessage( "Замена действует со следующего дня", ), @@ -367,7 +515,7 @@ class MessageLookup extends MessageLookupByLibrary { "Большое подробное, привлекательное описание плана", ), "plan_duration": MessageLookupByLibrary.simpleMessage("Срок плана"), - "plan_duration_label": m8, + "plan_duration_label": m21, "plan_label_desc": MessageLookupByLibrary.simpleMessage( "План для поддержания формы", ), @@ -395,7 +543,7 @@ class MessageLookup extends MessageLookupByLibrary { "Промокод применён!", ), "promo_code": MessageLookupByLibrary.simpleMessage("Промокод"), - "promo_discount_hint": m9, + "promo_discount_hint": m22, "promo_invalid": MessageLookupByLibrary.simpleMessage( "Промокод не найден или истёк", ), @@ -456,7 +604,7 @@ class MessageLookup extends MessageLookupByLibrary { "Выбрана дата старта", ), "startDiet": MessageLookupByLibrary.simpleMessage("Начать диету"), - "start_date_label": m10, + "start_date_label": m23, "suggestDiet": MessageLookupByLibrary.simpleMessage("Предложить диету"), "tell_your_schedule": MessageLookupByLibrary.simpleMessage( "Расскажите в свободной форме о том как просыпаетесь, когда едите, когда ложитесь спать:", diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index fc12f142..4f787674 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -4554,6 +4554,661 @@ class S { ); } + /// `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: [], + ); + } + /// `Order in progress` String get order_in_progress { return Intl.message( diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index becf3334..94dde4f0 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -210,27 +210,27 @@ "personal": "Персональные", "edit": "Редактировать", "toOffer": "Предложить", - "removeFromActive": "Снять с публикации", + "removeFromActive": "Снять с публикации", "diet": "Диета", "deleted": "Удалена", "my_plans": "Мои планы", - "from": "от", + "from": "от", "user_data": "Данные пользователя", "general_info": "Общая информация", "sex": "Пол", - "physical_fitness": "Физическая форма", + "physical_fitness": "Физическая форма", "height": "Рост", "pressure": "Давление", "upper": "Верхнее", "lower": "Нижнее", "has_hypo": "Склонность к гипотонии", - "has_hyper": "Склонность к гипертонии", + "has_hyper": "Склонность к гипертонии", "other_info": "Прочая информация", "has_alergy": "Наличие аллергии", "takes_pills": "Принимает лекарства", "sport_hobbies": "Спортивные увлечения", "liked_groceries": "Любимые продукты", - "not_liked_groceries": "Не любимые продукты", + "not_liked_groceries": "Не любимые продукты", "diet_started": "Диета уже начата", "cancel_diet": "Отменить диету?", "confirm": "Подтвердить", @@ -239,7 +239,7 @@ "hour": "час", "another_form_of_hour": "часа", "minutes": "минуты", - "minute": "минута", + "minute": "минута", "another_form_of_minute": "минут", "there_is_a_video": "Есть видео", "recipe_author": "Автор рецепта", @@ -323,8 +323,8 @@ "can_not_choose_start_date": "Дату старта плана невозможно выбрать, пока Вы не привяжите карту", "add_card": "Привязать карту", "choose_plan_start_date": "Выберите дату старта плана питания", - "choose_date": "Выбрать дату", - "date_has_ben_set": "Дата старта начала консультации установлена", + "choose_date": "Выбрать дату", + "date_has_ben_set": "Дата старта начала консультации установлена", "tap_on_link_to_pay": "Перейдите по ссылке для оплаты консультации", "pay": "Оплатить", "choose_bank_for_payment": "Выберите банк", @@ -343,13 +343,13 @@ "sbp_qr_test_hint": "Симуляция оплаты на тестовом стенде Альфа-Банка", "more_about_order": "Подробнее о заказе", "cancel_order": "Отменить заказ", - "refuse_order":"Отказаться от заказа", + "refuse_order": "Отказаться от заказа", "user_info": "Анкета пользователя", "pending_payment": "Ожидается оплата", "my_cards": "Мои карты", "new_card": "Добавить карту", "no_default_cards": "У вас нет привязанных карт. Что бы начать работу, привяжите карту", - "chose_plan_start_date_2":"Выберите дату старты плана. Она отобразиться в дневнике пользователя", + "chose_plan_start_date_2": "Выберите дату старты плана. Она отобразиться в дневнике пользователя", "plan_seller": "Продавец плана", "payment_failed": "Оплата не прошла", "order_paid": "Заказ оплачен", @@ -396,7 +396,7 @@ "chargeApproved": "Выплата одобрена", "moneySentToCard": "Средства отправлены на карту", "chargeCollectionFailed": "Не удалось оформить выплату", - "contactSupport": "Обратитесь в поддержку", + "contactSupport": "Обратитесь в поддержку", "atLeast3Symbols": "Минимум 3 символа", "chosePlanType": "Выберите тип плана", "minPlanPrice": "Минимальная сумма 1200₽", @@ -414,13 +414,13 @@ "tomorrowYourPlanWillStart": "Завтра начнется прохождение плана «{planName}»", "planStarted": "План «{planName}» начался", "userCanceldThePlanSubscription": "Пользователь {name} отменил подписку на план «{planName}»", - "userSubscribedToPlan": "Пользователь {name} подписался на план «{planName}»", + "userSubscribedToPlan": "Пользователь {name} подписался на план «{planName}»", "markAsRead": "Отметить всё прочитанным", "allowAcessToGalery": "Разрешите доступ к фото", "allowAcessToGaleryDescription": "Предоставление доступа позволит вам добавлять фото к записям", "allowAccessToCamera": "Разрешите доступ к камере", "allowAccessToCameraDescription": "Предоставление доступа позволит вам делать фотографии и добавлять к записям", - "allowAccess": "Разрешить доступ" , + "allowAccess": "Разрешить доступ", "copy": "Скопировать", "reply": "Ответить", "deleteComment": "Удалить комментарий", @@ -431,7 +431,7 @@ "choose_plan_start_date_with_personal_diet": "Выберите дату старта плана питания с учетом составления персональной диеты", "chosenPlanStartDateDescr": "Дату старта плана можно выбрать не ранее, чем через два. Такое ограничение дается на создание персональной диеты", "createPersonalDiet": "Составьте персональную диету", - "goToMyDiets": "Перейти к моим диетам", + "goToMyDiets": "Перейти к моим диетам", "accountDeleting": "Удаление аккаунта", "youShureYouWantToDeleteAccount": "Вы уверены, что хотите удалить аккаунт?", "itCantBeRecovered": "Его нельзя будет восстановить", @@ -439,11 +439,11 @@ "cannotDeleteAccount": "Не получилось удалить ваш аккаунт", "resend": "Отправить повторно", "notifications": "Уведомления", - "step_widget": "шаг {step} из 2", + "step_widget": "шаг {step} из 2", "your_name": "Ваше имя", "your_last_name": "Ваша фамилия", "type_email": "Введите email", - "phone": "Телефон", + "phone": "Телефон", "male_short": "М", "female_short": "Ж", "next": "Далее", @@ -456,19 +456,19 @@ "activity_second_value": "1,375 — низкий (тренировки не менее 20 мин 1–3 раза в неделю)", "activity_third_value": "1,55 — умеренный (тренировки 30–60 мин 3–4 раза в неделю)", "activity_fourth_value": "1,7 — высокий (тренировки 30–60 мин 5–7 раза в неделю; тяжёлая физическая работа)", - "activity_fifth_value": "1,9 — экстремальный (несколько интенсивных тренировок в день)", + "activity_fifth_value": "1,9 — экстремальный (несколько интенсивных тренировок в день)", "registration_completed": "Регистрация завершена!", "acc_has_been_created": "Ваш аккаунт создан. Для входа используйте этот пароль:", "password_desc": "Сохраните его в надежном месте! Вы сможете изменить пароль в настройках профиля", "acc_has_been_created_btn": "Войти в аккаунт", - "first_name_validation_error": "Имя обязательно к заполнению", + "first_name_validation_error": "Имя обязательно к заполнению", "last_name_error": "Фамилия обязательна для заполнения", "email_error": "Некорректный адрес электронной почты", "phone_error": "Некорректный номер телефона", "gender_error": "Выберите пол", "date_of_birth_error": "Некорректная дата рождения", "height_error": "Введите рост", - "weight_error": "Введите вес", + "weight_error": "Введите вес", "phisical_activity": "Уровень физической активности", "copied2": "Скопировано!", "login": "Войти в аккаунт", @@ -483,7 +483,7 @@ "letsInvite": "Давайте пригласим близких в итвио", "eatvioIsAHelthyHabbit": "Итвио — это полезная привычка", "invite": "Пригласить", - "comment_hint": "Комментарий", + "comment_hint": "Комментарий", "hide_photo_title": "Спрятать фото?", "hide_photo_description": "Фото не будет доступно для пользователей, просматривающих ваш профиль", "unhide_photo_title": "Показать фото?", @@ -520,8 +520,12 @@ "promo_discount_hint": "Discount {percent}% — you save {amount} ₽", "@promo_discount_hint": { "placeholders": { - "percent": {"type": "int"}, - "amount": {"type": "String"} + "percent": { + "type": "int" + }, + "amount": { + "type": "String" + } } }, "promo_remove_failed": "Could not remove the promo code. Try again later", @@ -538,26 +542,227 @@ "video_call_notification_in_progress": "Call in progress", "video_call_notification_return": "Return to 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", "order_in_progress": "Order in progress", "personal_diet_proposed": "Personal diet proposed", "personal_diet_proposed_named": "Diet «{name}» proposed", "@personal_diet_proposed_named": { "placeholders": { - "name": {"type": "String"} + "name": { + "type": "String" + } } }, "default_user_name": "User", "personal_diet_defined_to_author": "You have proposed a personal diet to {userName}", "@personal_diet_defined_to_author": { "placeholders": { - "userName": {"type": "String"} + "userName": { + "type": "String" + } } }, "personal_diet_defined_to_author_named": "You have proposed a personal diet to {userName}, selected diet: «{dietName}»", "@personal_diet_defined_to_author_named": { "placeholders": { - "userName": {"type": "String"}, - "dietName": {"type": "String"} + "userName": { + "type": "String" + }, + "dietName": { + "type": "String" + } } }, "personal_diet_not_proposed": "Personal diet not proposed", @@ -566,32 +771,49 @@ "start_date_label": "Start: {date}", "@start_date_label": { "placeholders": { - "date": {"type": "String"} + "date": { + "type": "String" + } } }, "personal_diet_duration_hint": "The diet lasts {duration} {dayWord}. It can only be offered for a plan with the same duration.", "@personal_diet_duration_hint": { "placeholders": { - "duration": {"type": "int"}, - "dayWord": {"type": "String"} + "duration": { + "type": "int" + }, + "dayWord": { + "type": "String" + } } }, "personal_diet_duration_mismatch": "Not suitable: diet lasts {dietDuration} {dietDayWord}, plan lasts {planDuration} {planDayWord}.", "@personal_diet_duration_mismatch": { "placeholders": { - "dietDuration": {"type": "int"}, - "dietDayWord": {"type": "String"}, - "planDuration": {"type": "int"}, - "planDayWord": {"type": "String"} + "dietDuration": { + "type": "int" + }, + "dietDayWord": { + "type": "String" + }, + "planDuration": { + "type": "int" + }, + "planDayWord": { + "type": "String" + } } }, "plan_duration_label": "Plan for {duration} {dayWord}", "@plan_duration_label": { "placeholders": { - "duration": {"type": "int"}, - "dayWord": {"type": "String"} + "duration": { + "type": "int" + }, + "dayWord": { + "type": "String" + } } }, "no_active_personal_diet_consultations": "No active consultations with a personal diet" } - diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index 298a587a..2cc23e38 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -263,8 +263,12 @@ "promo_discount_hint": "Скидка {percent}% — вы экономите {amount} ₽", "@promo_discount_hint": { "placeholders": { - "percent": {"type": "int"}, - "amount": {"type": "String"} + "percent": { + "type": "int" + }, + "amount": { + "type": "String" + } } }, "promo_remove_failed": "Не удалось убрать промокод. Попробуйте позже", @@ -283,6 +287,199 @@ "video_call_notification_return": "Вернуться к звонку", "video_call_notification_end": "Завершить звонок", "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": "Не удалось сохранить запись", "startDateHasBeenSet": "Выбрана дата старта", "order_paid": "Заказ оплачен", "done": "Завершен", @@ -298,21 +495,29 @@ "personal_diet_proposed_named": "Диета «{name}» предложена", "@personal_diet_proposed_named": { "placeholders": { - "name": {"type": "String"} + "name": { + "type": "String" + } } }, "default_user_name": "Пользователь", "personal_diet_defined_to_author": "Вы предложили персональную диету пользователю {userName}", "@personal_diet_defined_to_author": { "placeholders": { - "userName": {"type": "String"} + "userName": { + "type": "String" + } } }, "personal_diet_defined_to_author_named": "Вы предложили персональную диету пользователю {userName}, выбранная диета: «{dietName}»", "@personal_diet_defined_to_author_named": { "placeholders": { - "userName": {"type": "String"}, - "dietName": {"type": "String"} + "userName": { + "type": "String" + }, + "dietName": { + "type": "String" + } } }, "personal_diet_not_proposed": "Диета не предложена", @@ -321,30 +526,48 @@ "start_date_label": "Старт: {date}", "@start_date_label": { "placeholders": { - "date": {"type": "String"} + "date": { + "type": "String" + } } }, "personal_diet_duration_hint": "Диета рассчитана на {duration} {dayWord}. Предложить её можно только для плана с такой же длительностью.", "@personal_diet_duration_hint": { "placeholders": { - "duration": {"type": "int"}, - "dayWord": {"type": "String"} + "duration": { + "type": "int" + }, + "dayWord": { + "type": "String" + } } }, "personal_diet_duration_mismatch": "Не подходит: диета на {dietDuration} {dietDayWord}, план на {planDuration} {planDayWord}.", "@personal_diet_duration_mismatch": { "placeholders": { - "dietDuration": {"type": "int"}, - "dietDayWord": {"type": "String"}, - "planDuration": {"type": "int"}, - "planDayWord": {"type": "String"} + "dietDuration": { + "type": "int" + }, + "dietDayWord": { + "type": "String" + }, + "planDuration": { + "type": "int" + }, + "planDayWord": { + "type": "String" + } } }, "plan_duration_label": "План на {duration} {dayWord}", "@plan_duration_label": { "placeholders": { - "duration": {"type": "int"}, - "dayWord": {"type": "String"} + "duration": { + "type": "int" + }, + "dayWord": { + "type": "String" + } } }, "no_active_personal_diet_consultations": "Нет активных консультаций с персональной диетой" diff --git a/pubspec.yaml b/pubspec.yaml index b8218101..bb1d567f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.71+160 +version: 1.3.72+161 publish_to: "none" environment: diff --git a/test/features/diary/models/diary_day_model_test.dart b/test/features/diary/models/diary_day_model_test.dart index 8de1266d..3ecb0713 100644 --- a/test/features/diary/models/diary_day_model_test.dart +++ b/test/features/diary/models/diary_day_model_test.dart @@ -31,24 +31,70 @@ void main() { }; test('parses commented_from_diet state', () { - final model = PortionModel.fromJson(portionJson(state: 'commented_from_diet')); + final model = PortionModel.fromJson( + portionJson(state: 'commented_from_diet'), + ); expect(model.portionState, PortionState.commentedFromDiet); }); test('parses reviewed_from_diet state', () { - final model = PortionModel.fromJson(portionJson(state: 'reviewed_from_diet')); + final model = PortionModel.fromJson( + portionJson(state: 'reviewed_from_diet'), + ); expect(model.portionState, PortionState.reviewedFromDiet); }); test('does not throw on unknown state from backend', () { - final model = PortionModel.fromJson(portionJson(state: 'some_future_state')); + final model = PortionModel.fromJson( + portionJson(state: 'some_future_state'), + ); expect(model.portionState, PortionState.unknown); }); }); + group('DiaryDayModel.user (владелец дневника)', () { + Map diaryJson({Object? user}) => { + 'id': 1, + 'date': '2026-11-09', + 'closed': 0, + 'comment': '', + 'state': { + 'has_chosen_diet': false, + 'has_chosen_plan': false, + 'last_state': null, + }, + 'meals': [], + 'chosen_plan_id': null, + 'day_of_chosen_diet': null, + 'chosen_diet': null, + 'user': ?user, + }; + + test('user.slug попадает в entity как ownerSlug', () { + final model = DiaryDayModel.fromJson( + diaryJson(user: {'id': 3577, 'slug': 'id-3577'}), + ); + + expect(model.owner?.id, 3577); + expect(model.toEntity()!.ownerSlug, 'id-3577'); + }); + + test('старый ответ без user: ownerSlug == null', () { + expect(DiaryDayModel.fromJson(diaryJson()).toEntity()!.ownerSlug, isNull); + }); + + test('user.slug == null (у владельца нет slug): ownerSlug == null', () { + final model = DiaryDayModel.fromJson( + diaryJson(user: {'id': 1, 'slug': null}), + ); + + expect(model.toEntity()!.ownerSlug, isNull); + }); + }); + group('DiaryDayWrapperModel', () { test('fromJson parses diary correctly', () { final json = { @@ -57,7 +103,11 @@ void main() { 'date': '2024-01-15', 'closed': 0, 'comment': 'Test comment', - 'state': {'has_chosen_diet': false, 'has_chosen_plan': false, 'last_state': null}, + 'state': { + 'has_chosen_diet': false, + 'has_chosen_plan': false, + 'last_state': null, + }, 'meals': [], 'chosen_plan_id': null, 'day_of_chosen_diet': null, @@ -78,7 +128,11 @@ void main() { date: '2024-01-15', closed: 0, comment: 'Test comment', - typeOfDiary: TypeOfDiaryModel(hasChosenDiet: false, hasChosenPlan: false, diaryState: null), + typeOfDiary: TypeOfDiaryModel( + hasChosenDiet: false, + hasChosenPlan: false, + diaryState: null, + ), meals: [], chosenPlanId: null, dayOfChosenDiet: null, @@ -119,7 +173,12 @@ void main() { group('DiaryPhotoModel', () { test('fromJson parses all fields correctly', () { - final json = {'id': 10, 'original_name': 'photo.jpg', 'path': '/path/to/photo.jpg', 'size': 1024}; + final json = { + 'id': 10, + 'original_name': 'photo.jpg', + 'path': '/path/to/photo.jpg', + 'size': 1024, + }; final model = DiaryPhotoModel.fromJson(json); @@ -130,7 +189,12 @@ void main() { }); test('toJson serializes all fields correctly', () { - const model = DiaryPhotoModel(id: 10, originalName: 'photo.jpg', path: '/path/to/photo.jpg', size: 1024); + const model = DiaryPhotoModel( + id: 10, + originalName: 'photo.jpg', + path: '/path/to/photo.jpg', + size: 1024, + ); final json = model.toJson(); @@ -201,7 +265,11 @@ void main() { group('ProductInMeal', () { test('fromJson parses all fields correctly', () { - final json = {'product_name': 'Banana', 'portion': '1 piece', 'quantity': 2}; + final json = { + 'product_name': 'Banana', + 'portion': '1 piece', + 'quantity': 2, + }; final model = ProductInMeal.fromJson(json); @@ -211,7 +279,11 @@ void main() { }); test('toJson serializes all fields correctly', () { - const model = ProductInMeal(productName: 'Banana', portion: '1 piece', quantity: 2); + const model = ProductInMeal( + productName: 'Banana', + portion: '1 piece', + quantity: 2, + ); final json = model.toJson(); @@ -228,7 +300,11 @@ void main() { date: '2024-01-15', closed: 0, comment: 'Test', - typeOfDiary: TypeOfDiaryModel(hasChosenDiet: false, hasChosenPlan: false, diaryState: null), + typeOfDiary: TypeOfDiaryModel( + hasChosenDiet: false, + hasChosenPlan: false, + diaryState: null, + ), meals: [], chosenPlanId: null, dayOfChosenDiet: null, @@ -248,7 +324,11 @@ void main() { retire: '22:00', closed: 0, comment: 'Test comment', - typeOfDiary: TypeOfDiaryModel(hasChosenDiet: false, hasChosenPlan: false, diaryState: null), + typeOfDiary: TypeOfDiaryModel( + hasChosenDiet: false, + hasChosenPlan: false, + diaryState: null, + ), meals: [], chosenPlanId: null, dayOfChosenDiet: null, @@ -272,7 +352,11 @@ void main() { date: '2024-01-15', closed: 0, comment: 'Test', - typeOfDiary: TypeOfDiaryModel(hasChosenDiet: true, hasChosenPlan: false, diaryState: null), + typeOfDiary: TypeOfDiaryModel( + hasChosenDiet: true, + hasChosenPlan: false, + diaryState: null, + ), meals: [], chosenPlanId: null, dayOfChosenDiet: 5, 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..e2e2ad49 100644 --- a/test/features/diary/presentation/pages/diary_screen_smoke_test.dart +++ b/test/features/diary/presentation/pages/diary_screen_smoke_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/locator_service.dart'; @@ -15,6 +17,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 +27,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 +49,9 @@ void main() { AppConfig(buildType: AppBuildType.tests) ..setBaseUrl('http://localhost'), ); + sl.registerLazySingleton( + () => FakeInsightsRepository(), + ); }); Widget wrap(Widget child) { @@ -66,11 +74,13 @@ void main() { bool isDiaryFinished = false, bool isDiaryCommented = false, int closed = 0, + String? ownerSlug, + String wakeup = '07:00', }) { final diary = DiaryDayEntity( id: 1, date: date, - wakeup: '07:00', + wakeup: wakeup, retire: '23:00', closed: closed, comment: '', @@ -93,6 +103,7 @@ void main() { chosenPlanId: hasChosenPlan ? 10 : null, dayOfChosenDiet: null, diet: null, + ownerSlug: ownerSlug, ); const nutrition = NutritionDataModel( dailyNutritionIntake: NutritionModel( @@ -383,6 +394,241 @@ void main() { expect(find.byType(Text), findsWidgets); }); + Future pumpDiaryWithInsights( + WidgetTester tester, { + required bool isConsultantView, + String? ownerSlug, + String? diaryOwnerSlug, + Stream? diaryStates, + required FakeInsightsRepository insightsRepository, + }) async { + final diaryCubit = MockDiaryCubit(); + final state = loadedState( + hasChosenPlan: false, + ownerSlug: diaryOwnerSlug, + ).copyWith(isConsultantView: isConsultantView); + when(diaryCubit.state).thenReturn(state); + when( + diaryCubit.stream, + ).thenAnswer((_) => diaryStates ?? 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); + final card = tester.widget(find.byType(InsightsCard)); + expect(card.slug, isNull); + expect(card.canAdd, isTrue); + expect(card.initialIntraDate, '2025-01-01'); + expect(insightsRepository.serverRequests.single.slug, isNull); + expect( + insightsRepository.serverRequests.single.intraDate, + '2025-01-01', + ); + }, + ); + + 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.serverRequests.single.slug, 'client'); + }, + ); + + testWidgets( + 'consultant view takes the owner slug from the diary (user.slug)', + (tester) async { + final insightsRepository = FakeInsightsRepository(); + + await pumpDiaryWithInsights( + tester, + isConsultantView: true, + diaryOwnerSlug: 'client-from-diary', + insightsRepository: insightsRepository, + ); + + expect( + tester.widget(find.byType(InsightsCard)).slug, + 'client-from-diary', + ); + expect( + insightsRepository.serverRequests.single.slug, + 'client-from-diary', + ); + }, + ); + + testWidgets('diary owner slug wins over the route parameter', ( + tester, + ) async { + final insightsRepository = FakeInsightsRepository(); + + await pumpDiaryWithInsights( + tester, + isConsultantView: true, + ownerSlug: 'from-route', + diaryOwnerSlug: 'from-diary', + insightsRepository: insightsRepository, + ); + + expect( + tester.widget(find.byType(InsightsCard)).slug, + 'from-diary', + ); + }); + + testWidgets( + 'own diary refreshes insights when meals or wake-up time change', + (tester) async { + final insightsRepository = FakeInsightsRepository(); + final states = StreamController.broadcast(); + addTearDown(states.close); + + await pumpDiaryWithInsights( + tester, + isConsultantView: false, + diaryStates: states.stream, + insightsRepository: insightsRepository, + ); + expect(insightsRepository.serverRequests, hasLength(1)); + + states.add(loadedState(hasChosenPlan: false, wakeup: '06:30')); + await tester.pump(const Duration(milliseconds: 200)); + + expect(insightsRepository.serverRequests, hasLength(2)); + }, + ); + + testWidgets( + 'own diary does not refresh insights when nothing relevant changed', + (tester) async { + final insightsRepository = FakeInsightsRepository(); + final states = StreamController.broadcast(); + addTearDown(states.close); + + await pumpDiaryWithInsights( + tester, + isConsultantView: false, + diaryStates: states.stream, + insightsRepository: insightsRepository, + ); + + states.add(loadedState(hasChosenPlan: false)); + await tester.pump(const Duration(milliseconds: 200)); + + expect(insightsRepository.serverRequests, hasLength(1)); + }, + ); + + testWidgets('consultant view does not refresh insights on diary changes', ( + tester, + ) async { + final insightsRepository = FakeInsightsRepository(); + final states = StreamController.broadcast(); + addTearDown(states.close); + + await pumpDiaryWithInsights( + tester, + isConsultantView: true, + ownerSlug: 'client', + diaryStates: states.stream, + insightsRepository: insightsRepository, + ); + + states.add( + loadedState( + hasChosenPlan: false, + wakeup: '06:30', + ).copyWith(isConsultantView: true), + ); + await tester.pump(const Duration(milliseconds: 200)); + + expect(insightsRepository.serverRequests, hasLength(1)); + }); + + 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.serverRequests, 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..a8296799 --- /dev/null +++ b/test/features/insights/models/insights_payload_model_test.dart @@ -0,0 +1,75 @@ +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; daily == null', () { + 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, 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/models/insights_response_model_test.dart b/test/features/insights/models/insights_response_model_test.dart new file mode 100644 index 00000000..9a3e3095 --- /dev/null +++ b/test/features/insights/models/insights_response_model_test.dart @@ -0,0 +1,154 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:eatvio_app/features/insights/models/insights_response_model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Map _fixture(String name) => + jsonDecode(File('test/fixtures/insights/$name').readAsStringSync()) + as Map; + +/// Контракт `GET /api/profile/insights*` (eatvio/eatvio#3017). +void main() { + group('InsightsResponseModel', () { + test('полный ответ: data с weight/bmi/measure/daily/intra', () { + final data = InsightsResponseModel.fromJson( + _fixture('insights_response.json'), + ).data; + + expect(data.weight.headline, '73.2'); + expect(data.measure.waist, [82, 80.5]); + expect(data.daily!.kcal, hasLength(3)); + expect(data.intra!.bars, hasLength(2)); + }); + + test('daily.percent — изменение за период, а не процент от нормы', () { + final daily = InsightsResponseModel.fromJson( + _fixture('insights_response.json'), + ).data.daily!; + + expect(daily.percent, 5); + expect(daily.deltaText, 'Калораж вырос на 5% за период'); + }); + + test('нет КБЖУ за период: daily == null', () { + final data = InsightsResponseModel.fromJson( + _fixture('insights_response_empty.json'), + ).data; + + expect(data.daily, isNull); + }); + + test('пустые замеры: headline "—" и текст «Недостаточно данных»', () { + final measure = InsightsResponseModel.fromJson( + _fixture('insights_response_empty.json'), + ).data.measure; + + expect(measure.labels, isEmpty); + expect(measure.headline, '—'); + expect(measure.deltaText, 'Недостаточно данных'); + }); + + test('пустой день: intra приходит объектом с датами, без сна', () { + final intra = InsightsResponseModel.fromJson( + _fixture('insights_response_empty.json'), + ).data.intra!; + + expect(intra.bars, isEmpty); + expect(intra.satiety, isEmpty); + expect(intra.mood, isEmpty); + expect(intra.wake, isNull); + expect(intra.sleep, isNull); + expect(intra.date, '2026-11-09'); + expect(intra.maxDate, '2026-11-09'); + expect(intra.isToday, isTrue); + }); + + test( + 'сокращённый пример из описания бэка разбирается без подписей сна', + () { + final data = InsightsResponseModel.fromJson( + jsonDecode( + jsonEncode({ + 'data': { + 'weight': { + 'labels': ['30.10', '07.11'], + 'values': [80, 78], + 'last': 78, + 'delta': -2, + 'headline': '78', + 'unit': 'кг', + 'deltaText': 'Снизили на 2 кг за 3 месяца', + }, + 'bmi': {}, + 'measure': { + 'labels': [], + 'waist': [], + 'chest': [], + 'hips': [], + 'headline': '—', + 'deltaText': 'Недостаточно данных', + }, + 'daily': { + 'labels': ['09.11'], + 'kcal': [250], + 'proteins': [10], + 'fats': [5], + 'carbs': [30], + 'average': 250, + 'percent': 0, + 'headline': '250', + 'deltaText': 'Калораж не изменился за период', + }, + 'intra': { + 'bars': [ + {'x': 12.5, 'p': 40, 'f': 45, 'c': 120}, + ], + 'satiety': [ + {'x': 12.5, 'y': 4}, + ], + 'mood': [ + {'x': 12.5, 'y': 3}, + ], + 'wake': 7, + 'sleep': 23, + 'date': '2026-11-09', + 'maxDate': '2026-11-09', + 'dateLabel': 'Сегодня', + 'isToday': true, + }, + }, + }), + ) + as Map, + ).data; + + expect(data.weight.values, [80, 78]); + expect(data.daily!.percent, 0); + expect(data.intra!.wake, 7); + expect(data.intra!.wakeLabel, isNull); + expect(data.intra!.wakeText, isNull); + }, + ); + }); + + group('InsightsIntraResponseModel', () { + test('ответ /insights/intra: data.intra', () { + final intra = InsightsIntraResponseModel.fromJson( + _fixture('insights_intra_response.json'), + ).data.intra; + + expect(intra.date, '2026-11-09'); + expect(intra.bars, isNotEmpty); + }); + + test('прошедший день: isToday == false', () { + final intra = InsightsIntraResponseModel.fromJson( + _fixture('insights_intra_response_yesterday.json'), + ).data.intra; + + expect(intra.isToday, isFalse); + expect(intra.dateLabel, '8 ноября'); + }); + }); +} diff --git a/test/features/insights/presentation/charts/insights_chart_common_test.dart b/test/features/insights/presentation/charts/insights_chart_common_test.dart new file mode 100644 index 00000000..d3e0e435 --- /dev/null +++ b/test/features/insights/presentation/charts/insights_chart_common_test.dart @@ -0,0 +1,76 @@ +import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:syncfusion_flutter_charts/charts.dart'; + +void main() { + test('insightsSplineType — monotonic, чтобы кривая не уходила ниже точек', () { + expect(insightsSplineType, SplineType.monotonic); + }); + + group('buildPoints', () { + test('сохраняет индекс и дату даже при повторяющихся labels', () { + final points = buildPoints( + ['05.07', '05.07', '06.07', '07.07'], + [10, 20, 30, 40], + ); + + expect(points.map((p) => p.index).toList(), [0, 1, 2, 3]); + expect(points.map((p) => p.label).toList(), [ + '05.07', + '05.07', + '06.07', + '07.07', + ]); + expect(points.map((p) => p.value).toList(), [10, 20, 30, 40]); + }); + + test('обрезает по короткому массиву', () { + expect(buildPoints(['a', 'b'], [1]), hasLength(1)); + }); + }); + + group('buildInsightsIndexAxis', () { + test('ось строится по числу точек, а не по уникальным строкам дат', () { + final labels = [ + '05.07', + '06.07', + '07.07', + '08.07', + '09.07', + '10.07', + '11.07', + ]; + final axis = buildInsightsIndexAxis(labels); + + expect(axis.minimum, 0); + expect(axis.maximum, 6); + expect(axis.interval, 1); + expect(axis.axisLabelFormatter, isNotNull); + }); + + test('для длинного периода ставит интервал больше 1', () { + final labels = List.generate(30, (i) => '${i + 1}.07'); + final axis = buildInsightsIndexAxis(labels); + + expect(axis.maximum, 29); + expect(axis.interval, greaterThan(1)); + }); + }); + + group('paddedRange', () { + test('даёт локальный диапазон вокруг данных', () { + final range = paddedRange([70, 72], minPadding: 1); + + expect(range.min, lessThan(70)); + expect(range.max, greaterThan(72)); + expect(range.min, greaterThan(0)); + }); + + test('clampZero не уводит минимум ниже нуля', () { + final range = paddedRange([2, 8], minPadding: 5, clampZero: true); + + expect(range.min, 0); + expect(range.max, greaterThan(8)); + }); + }); +} diff --git a/test/features/insights/presentation/cubit/insights_cubit_server_test.dart b/test/features/insights/presentation/cubit/insights_cubit_server_test.dart new file mode 100644 index 00000000..8382c534 --- /dev/null +++ b/test/features/insights/presentation/cubit/insights_cubit_server_test.dart @@ -0,0 +1,395 @@ +import 'package:dio/dio.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/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 '../../../../fixtures/doubles/insights_doubles.dart'; + +/// Серверный режим (`kbjuEnabled: true`): `GET /api/profile/insights*`. +void main() { + late FakeInsightsRepository repository; + late IntraChartModel today; + late IntraChartModel yesterday; + + setUp(() { + repository = FakeInsightsRepository(); + today = repository.payload.intra!; + yesterday = today.copyWith( + date: '2026-11-08', + dateLabel: '8 ноября', + isToday: false, + ); + repository.intraByDate['2026-11-08'] = yesterday; + repository.intraByDate['2026-11-09'] = today; + }); + + InsightsCubit buildCubit({String? slug}) { + return InsightsCubit(repository: repository, slug: slug, kbjuEnabled: true); + } + + InsightsStateLoaded loaded(InsightsCubit cubit) => + cubit.state as InsightsStateLoaded; + + group('load', () { + test('запрашивает серверный payload с дефолтными периодами', () async { + final cubit = buildCubit(); + + await cubit.load(); + + expect(repository.serverRequests, [ + ( + slug: null, + bodyPeriod: BodyPeriod.threeMonths, + dailyPeriod: DailyPeriod.twoWeeks, + intraDate: null, + ), + ]); + expect(repository.insightsRequests, isEmpty); + expect(loaded(cubit).payload.daily, isNotNull); + expect(loaded(cubit).dailyPeriod, DailyPeriod.twoWeeks); + expect(loaded(cubit).intraDate, isNull); + await cubit.close(); + }); + + test('рост профиля не запрашивается: ИМТ считает сервер', () async { + repository.heightCm = null; + final cubit = buildCubit(slug: 'client'); + + await cubit.load(); + + expect(repository.serverRequests.single.slug, 'client'); + expect(loaded(cubit).payload.bmi, repository.payload.bmi); + await cubit.close(); + }); + + test('403 скрывает блок', () async { + repository.error = const InsightsForbidden(); + final cubit = buildCubit(slug: 'client'); + + await cubit.load(); + + expect(cubit.state, isA()); + await cubit.close(); + }); + + test('ошибка сервера → error', () async { + repository.error = DioException(requestOptions: RequestOptions()); + final cubit = buildCubit(); + + await cubit.load(); + + expect(cubit.state, isA()); + await cubit.close(); + }); + }); + + group('setDailyPeriod', () { + test('перезапрашивает все данные, сохраняя период тела и день', () async { + final cubit = buildCubit(); + await cubit.load(); + await cubit.setBodyPeriod(BodyPeriod.year); + await cubit.setIntraDate(DateTime(2026, 11, 8)); + repository.serverRequests.clear(); + + await cubit.setDailyPeriod(DailyPeriod.month); + + expect(repository.serverRequests, [ + ( + slug: null, + bodyPeriod: BodyPeriod.year, + dailyPeriod: DailyPeriod.month, + intraDate: '2026-11-08', + ), + ]); + expect(loaded(cubit).dailyPeriod, DailyPeriod.month); + expect(loaded(cubit).isRefreshing, isFalse); + await cubit.close(); + }); + + test('тот же период не перезапрашивается', () async { + final cubit = buildCubit(); + await cubit.load(); + repository.serverRequests.clear(); + + await cubit.setDailyPeriod(DailyPeriod.twoWeeks); + + expect(repository.serverRequests, isEmpty); + await cubit.close(); + }); + + test('ошибка оставляет выбранный период и старые данные', () async { + final cubit = buildCubit(); + await cubit.load(); + final before = loaded(cubit).payload; + repository.error = DioException(requestOptions: RequestOptions()); + + await cubit.setDailyPeriod(DailyPeriod.week); + + expect(loaded(cubit).dailyPeriod, DailyPeriod.week); + expect(loaded(cubit).payload, before); + expect(loaded(cubit).isRefreshing, isFalse); + await cubit.close(); + }); + + test('смена периода тела передаёт текущий период КБЖУ', () async { + final cubit = buildCubit(); + await cubit.load(); + await cubit.setDailyPeriod(DailyPeriod.week); + repository.serverRequests.clear(); + + await cubit.setBodyPeriod(BodyPeriod.month); + + expect(repository.serverRequests.single.dailyPeriod, DailyPeriod.week); + await cubit.close(); + }); + }); + + group('«В течение дня»', () { + test( + 'setIntraDate запрашивает только intra и подменяет её в payload', + () async { + final cubit = buildCubit(); + await cubit.load(); + repository.serverRequests.clear(); + + await cubit.setIntraDate(DateTime(2026, 11, 8)); + + expect(repository.serverRequests, isEmpty); + expect(repository.intraRequests, [(slug: null, date: '2026-11-08')]); + final state = loaded(cubit); + expect(state.payload.intra, yesterday); + expect(state.payload.weight, repository.payload.weight); + expect(state.intraDate, '2026-11-08'); + expect(state.isRefreshing, isFalse); + await cubit.close(); + }, + ); + + test('slug передаётся в запрос чужого дня', () async { + final cubit = buildCubit(slug: 'client'); + await cubit.load(); + + await cubit.setIntraDate(DateTime(2026, 11, 8)); + + expect(repository.intraRequests.single.slug, 'client'); + await cubit.close(); + }); + + test( + 'быстрые «назад» запрашивают разные дни (оптимистичная дата)', + () async { + repository.fetchIntraDelay = const Duration(milliseconds: 30); + repository.intraByDate['2026-11-07'] = yesterday.copyWith( + date: '2026-11-07', + dateLabel: '7 ноября', + ); + final cubit = buildCubit(); + await cubit.load(); + + final first = cubit.showPreviousDay(); + final second = cubit.showPreviousDay(); + await Future.wait([first, second]); + + expect(repository.intraRequests.map((e) => e.date).toList(), [ + '2026-11-08', + '2026-11-07', + ]); + expect(loaded(cubit).payload.intra!.date, '2026-11-07'); + await cubit.close(); + }, + ); + + test('полный ответ не затирает более свежий intra и UI-выбор', () async { + repository.fetchInsightsDelay = const Duration(milliseconds: 40); + final cubit = buildCubit(); + await cubit.load(); + repository.fetchInsightsDelay = const Duration(milliseconds: 80); + repository.serverRequests.clear(); + + final periodChange = cubit.setBodyPeriod(BodyPeriod.year); + await Future.delayed(const Duration(milliseconds: 10)); + cubit.setPanel(InsightsPanel.measures); + cubit.toggleExpanded(); + await cubit.setIntraDate(DateTime(2026, 11, 8)); + await periodChange; + + final state = loaded(cubit); + expect(state.bodyPeriod, BodyPeriod.year); + expect(state.payload.weight, repository.payload.weight); + expect(state.payload.intra, yesterday); + expect(state.intraDate, '2026-11-08'); + expect(state.panel, InsightsPanel.measures); + expect(state.isExpanded, isTrue); + expect(state.isRefreshing, isFalse); + await cubit.close(); + }); + + test('load с initialIntraDate запрашивает этот день', () async { + final cubit = buildCubit(); + + await cubit.load(intraDate: '2026-11-08'); + + expect(repository.serverRequests.single.intraDate, '2026-11-08'); + expect(loaded(cubit).intraDate, '2026-11-08'); + await cubit.close(); + }); + + test('showPreviousDay и showNextDay листают по дню', () async { + final cubit = buildCubit(); + await cubit.load(); + + await cubit.showPreviousDay(); + expect(repository.intraRequests.last.date, '2026-11-08'); + expect(loaded(cubit).payload.intra!.isToday, isFalse); + + await cubit.showNextDay(); + expect(repository.intraRequests.last.date, '2026-11-09'); + expect(loaded(cubit).payload.intra!.isToday, isTrue); + expect(loaded(cubit).intraDate, isNull); + await cubit.close(); + }); + + test('showPreviousDay переходит через границу месяца', () async { + repository.payload = repository.payload.copyWith( + intra: today.copyWith(date: '2026-11-01', isToday: false), + ); + final cubit = buildCubit(); + await cubit.load(); + + await cubit.showPreviousDay(); + + expect(repository.intraRequests.single.date, '2026-10-31'); + await cubit.close(); + }); + + test('showNextDay на сегодняшнем дне ничего не запрашивает', () async { + final cubit = buildCubit(); + await cubit.load(); + + await cubit.showNextDay(); + + expect(repository.intraRequests, isEmpty); + await cubit.close(); + }); + + test('сервер вернул другую дату (clamp) — берём её', () async { + repository.intraByDate['2026-11-20'] = today; + final cubit = buildCubit(); + await cubit.load(); + + await cubit.setIntraDate(DateTime(2026, 11, 20)); + + expect(loaded(cubit).intraDate, isNull); + expect(loaded(cubit).payload.intra!.date, '2026-11-09'); + await cubit.close(); + }); + + test('пустой день: intra с пустыми сериями остаётся объектом', () async { + final empty = today.copyWith( + date: '2026-11-07', + dateLabel: '7 ноября', + isToday: false, + bars: const [], + satiety: const [], + mood: const [], + wake: null, + sleep: null, + wakeText: null, + sleepText: null, + ); + repository.intraByDate['2026-11-07'] = empty; + final cubit = buildCubit(); + await cubit.load(); + + await cubit.setIntraDate(DateTime(2026, 11, 7)); + + expect(loaded(cubit).payload.intra, empty); + await cubit.close(); + }); + + test('ошибка запроса дня не стирает показанные данные', () async { + final cubit = buildCubit(); + await cubit.load(); + repository.intraError = DioException(requestOptions: RequestOptions()); + + await cubit.setIntraDate(DateTime(2026, 11, 8)); + + final state = loaded(cubit); + expect(state.payload.intra, today); + expect(state.intraDate, isNull); + expect(state.isRefreshing, isFalse); + await cubit.close(); + }); + + test('403 при листании скрывает блок', () async { + final cubit = buildCubit(slug: 'client'); + await cubit.load(); + repository.intraError = const InsightsForbidden(); + + await cubit.showPreviousDay(); + + expect(cubit.state, isA()); + await cubit.close(); + }); + + test('refresh сохраняет выбранный день, вкладку и серии', () async { + final cubit = buildCubit(); + await cubit.load(); + cubit.toggleExpanded(); + cubit.setPanel(InsightsPanel.intra); + cubit.toggleSleep(); + await cubit.setIntraDate(DateTime(2026, 11, 8)); + repository.serverRequests.clear(); + + await cubit.refresh(); + + expect(repository.serverRequests.single.intraDate, '2026-11-08'); + final state = loaded(cubit); + expect(state.panel, InsightsPanel.intra); + expect(state.isExpanded, isTrue); + expect(state.showSleep, isFalse); + expect(state.intraDate, '2026-11-08'); + await cubit.close(); + }); + + test('refresh во время первой загрузки не дублирует запрос', () async { + final cubit = buildCubit(); + final loading = cubit.load(); + + await cubit.refresh(); + await loading; + + expect(repository.serverRequests, hasLength(1)); + await cubit.close(); + }); + + test('без флага навигация по дням игнорируется', () async { + final cubit = InsightsCubit(repository: repository, kbjuEnabled: false); + await cubit.load(); + + await cubit.showPreviousDay(); + await cubit.setDailyPeriod(DailyPeriod.week); + + expect(repository.intraRequests, isEmpty); + expect(repository.serverRequests, isEmpty); + await cubit.close(); + }); + }); + + test('после добавления веса данные обновляются с сервера', () async { + final cubit = buildCubit(); + await cubit.load(); + repository.serverRequests.clear(); + + final isSaved = await cubit.addWeight(weight: 72.5); + + expect(isSaved, isTrue); + expect(repository.addedWeights, [72.5]); + expect(repository.serverRequests, hasLength(1)); + await cubit.close(); + }); +} 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..f23d9b0f --- /dev/null +++ b/test/features/insights/presentation/cubit/insights_cubit_test.dart @@ -0,0 +1,439 @@ +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); + registerFallbackValue(DailyPeriod.week); + }); + + 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); + when( + () => repository.fetchInsights( + slug: any(named: 'slug'), + bodyPeriod: any(named: 'bodyPeriod'), + dailyPeriod: any(named: 'dailyPeriod'), + intraDate: any(named: 'intraDate'), + ), + ).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.all); + 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..deb66788 --- /dev/null +++ b/test/features/insights/presentation/widgets/insights_kbju_test.dart @@ -0,0 +1,453 @@ +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/models/insights_period.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('Калораж вырос на 5% за период'), 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('пилюли периода активны и перезапрашивают данные', ( + tester, + ) async { + final repository = FakeInsightsRepository(); + await pumpKbjuCard(tester, repository, 'insights_tab_daily'); + repository.serverRequests.clear(); + + await tester.tap(find.byKey(const Key('insights_period_30'))); + await tester.pumpAndSettle(); + + expect(repository.serverRequests.single.dailyPeriod, DailyPeriod.month); + expect( + repository.serverRequests.single.bodyPeriod, + BodyPeriod.threeMonths, + ); + }); + + 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( + 'на сегодняшнем дне «следующий день» отключён, «предыдущий» нет', + (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, isNotNull); + }, + ); + + testWidgets('«предыдущий день» запрашивает только intra и меняет дату', ( + tester, + ) async { + final repository = FakeInsightsRepository(); + repository.intraByDate['2026-11-08'] = loadInsightsPayloadFixture().intra! + .copyWith(date: '2026-11-08', dateLabel: '8 ноября', isToday: false); + await pumpKbjuCard(tester, repository, 'insights_tab_intra'); + repository.serverRequests.clear(); + + await tester.tap(find.byKey(const Key('insights_intra_prev'))); + await tester.pumpAndSettle(); + + expect(repository.intraRequests, [(slug: null, date: '2026-11-08')]); + expect(repository.serverRequests, isEmpty); + expect(find.text('8 ноября'), findsOneWidget); + expect(find.text('Сегодня'), findsNothing); + }); + + testWidgets('с прошедшего дня можно вернуться «следующим днём»', ( + tester, + ) async { + final repository = FakeInsightsRepository(); + final today = loadInsightsPayloadFixture().intra!; + repository.intraByDate['2026-11-08'] = today.copyWith( + date: '2026-11-08', + dateLabel: '8 ноября', + isToday: false, + ); + repository.intraByDate['2026-11-09'] = today; + await pumpKbjuCard(tester, repository, 'insights_tab_intra'); + + await tester.tap(find.byKey(const Key('insights_intra_prev'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('insights_intra_next'))); + await tester.pumpAndSettle(); + + expect(repository.intraRequests.last.date, '2026-11-09'); + expect(find.text('Сегодня'), findsOneWidget); + }); + + 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 { + final intra = loadInsightsPayloadFixture().intra!.copyWith( + bars: const [], + satiety: const [], + mood: const [], + ); + await pumpKbjuCard( + tester, + FakeInsightsRepository( + payload: loadInsightsPayloadFixture().copyWith(intra: intra), + ), + 'insights_tab_intra', + ); + + expect(find.byKey(const Key('insights_intra_chart')), findsOneWidget); + expect(find.text('Недостаточно данных'), findsNothing); + final xAxis = + tester + .widget( + find.byKey(const Key('insights_intra_chart')), + ) + .primaryXAxis + as NumericAxis; + expect(xAxis.plotBands, isNotEmpty); + }); + + 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..3d0affea --- /dev/null +++ b/test/features/insights/repositories/insights_repository_test.dart @@ -0,0 +1,341 @@ +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/models/insights_response_model.dart'; +import 'package:eatvio_app/features/insights/repositories/insights_repository.dart'; +import 'package:eatvio_app/features/insights/services/insights_service.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'; + +import '../../../fixtures/doubles/insights_doubles.dart'; + +class _MockProfileRepository extends Mock implements ProfileRepository {} + +class _MockInsightsService extends Mock implements InsightsService {} + +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 _MockInsightsService insightsService; + 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(); + insightsService = _MockInsightsService(); + repository = InsightsRepository( + profileRepository: profileRepository, + insightsService: insightsService, + 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()), + ); + }); + }); + + group('fetchInsights (сервер)', () { + final response = InsightsResponseModel(data: loadInsightsPayloadFixture()); + + test('свой профиль: /insights с периодами и датой', () async { + when( + () => insightsService.getInsights( + bodyPeriod: '6', + dailyPeriod: '30', + intraDate: '2026-11-08', + ), + ).thenAnswer((_) async => response); + + final payload = await repository.fetchInsights( + bodyPeriod: BodyPeriod.sixMonths, + dailyPeriod: DailyPeriod.month, + intraDate: '2026-11-08', + ); + + expect(payload, response.data); + verifyNever( + () => insightsService.getInsightsBySlug( + slug: any(named: 'slug'), + bodyPeriod: any(named: 'bodyPeriod'), + dailyPeriod: any(named: 'dailyPeriod'), + intraDate: any(named: 'intraDate'), + ), + ); + }); + + test('чужой профиль: /insights/{slug}', () async { + when( + () => insightsService.getInsightsBySlug( + slug: 'client', + bodyPeriod: 'all', + dailyPeriod: 'all', + intraDate: null, + ), + ).thenAnswer((_) async => response); + + final payload = await repository.fetchInsights( + slug: 'client', + bodyPeriod: BodyPeriod.all, + dailyPeriod: DailyPeriod.all, + ); + + expect(payload, response.data); + }); + + test('403 → InsightsForbidden', () { + when( + () => insightsService.getInsightsBySlug( + slug: 'client', + bodyPeriod: '3', + dailyPeriod: '14', + intraDate: null, + ), + ).thenThrow(_dio(403)); + + expect( + repository.fetchInsights( + slug: 'client', + bodyPeriod: BodyPeriod.threeMonths, + dailyPeriod: DailyPeriod.twoWeeks, + ), + throwsA(isA()), + ); + }); + + test('422 пробрасывается как DioException', () { + when( + () => insightsService.getInsights( + bodyPeriod: '3', + dailyPeriod: '14', + intraDate: null, + ), + ).thenThrow(_dio(422)); + + expect( + repository.fetchInsights( + bodyPeriod: BodyPeriod.threeMonths, + dailyPeriod: DailyPeriod.twoWeeks, + ), + throwsA(isA()), + ); + }); + }); + + group('fetchIntra (сервер)', () { + final intra = loadInsightsPayloadFixture().intra!; + final response = InsightsIntraResponseModel( + data: InsightsIntraDataModel(intra: intra), + ); + + test('свой профиль: /insights/intra', () async { + when( + () => insightsService.getIntra(intraDate: '2026-11-09'), + ).thenAnswer((_) async => response); + + expect(await repository.fetchIntra(intraDate: '2026-11-09'), intra); + }); + + test('чужой профиль: /insights/{slug}/intra', () async { + when( + () => insightsService.getIntraBySlug(slug: 'client', intraDate: null), + ).thenAnswer((_) async => response); + + expect(await repository.fetchIntra(slug: 'client'), intra); + }); + + test('403 → InsightsForbidden', () { + when( + () => insightsService.getIntra(intraDate: null), + ).thenThrow(_dio(403)); + + expect(repository.fetchIntra(), 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..215ffbd5 100644 --- a/test/features/profile/presentation/pages/profile_screen_smoke_test.dart +++ b/test/features/profile/presentation/pages/profile_screen_smoke_test.dart @@ -4,10 +4,11 @@ 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/presentation/widgets/insights_card.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/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'; import 'package:eatvio_app/features/profile/repositories/profile_repository.dart'; @@ -21,9 +22,15 @@ import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:network_image_mock/network_image_mock.dart'; +import '../../../../fixtures/doubles/insights_doubles.dart'; import 'profile_screen_smoke_test.mocks.dart'; -@GenerateNiceMocks([MockSpec(), MockSpec(), MockSpec(), MockSpec()]) +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), +]) void main() { group('ProfileScreen smoke', () { late MockProfileRepository profileRepository; @@ -32,12 +39,18 @@ void main() { late MockPlansRepository plansRepository; setUpAll(() async { - dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + dotenv.loadFromString( + envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost', + ); }); setUp(() async { await sl.reset(); - sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + sl.registerLazySingleton( + () => + AppConfig(buildType: AppBuildType.tests) + ..setBaseUrl('http://localhost'), + ); profileRepository = MockProfileRepository(); chatRepository = MockChatRepository(); @@ -45,12 +58,25 @@ void main() { plansRepository = MockPlansRepository(); sl.registerLazySingleton(() => profileRepository); + sl.registerLazySingleton( + () => FakeInsightsRepository(), + ); sl.registerLazySingleton(() => chatRepository); - sl.registerLazySingleton(() => consultantRepository); + sl.registerLazySingleton( + () => consultantRepository, + ); sl.registerLazySingleton(() => plansRepository); - sl.registerFactory(() => ProfileCubit(profileRepository: sl(), chatRepository: sl(), consultantRepository: sl())); - sl.registerFactory(() => profile_plans.PlansCubit(plansRepository: sl())); + sl.registerFactory( + () => ProfileCubit( + profileRepository: sl(), + chatRepository: sl(), + consultantRepository: sl(), + ), + ); + sl.registerFactory( + () => profile_plans.PlansCubit(plansRepository: sl()), + ); }); Widget wrap(Widget child) { @@ -115,10 +141,12 @@ 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 => []); + when( + profileRepository.getMyProfileDetails(), + ).thenAnswer((_) async => profileDetails); + when( + profileRepository.getBlogPostsByAuthorSlug(slug: anyNamed('slug')), + ).thenAnswer((_) async => []); await mockNetworkImagesFor(() async { await tester.pumpWidget(wrap(const ProfileScreen())); @@ -129,8 +157,12 @@ void main() { }); testWidgets('renders error state', (tester) async { - when(profileRepository.getMyProfile()).thenThrow(Exception('Error loading profile')); - when(profileRepository.getMyProfileDetails()).thenThrow(Exception('Error')); + when( + profileRepository.getMyProfile(), + ).thenThrow(Exception('Error loading profile')); + when( + profileRepository.getMyProfileDetails(), + ).thenThrow(Exception('Error')); await tester.pumpWidget(wrap(const ProfileScreen())); await tester.pumpAndSettle(); @@ -185,13 +217,19 @@ void main() { activityCoefficient: 1.5, ); - when(profileRepository.getProfileBySlug(any)).thenAnswer((_) async => profile); + when( + profileRepository.getProfileBySlug(any), + ).thenAnswer((_) async => profile); when(profileRepository.getMyProfile()).thenAnswer((_) async => profile); - 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 => []); + when( + profileRepository.getMyProfileDetails(), + ).thenAnswer((_) async => profileDetails); + when( + profileRepository.getProfileDetailsBySlug(any), + ).thenAnswer((_) async => profileDetails); + when( + profileRepository.getBlogPostsByAuthorSlug(slug: anyNamed('slug')), + ).thenAnswer((_) async => []); await mockNetworkImagesFor(() async { await tester.pumpWidget(wrap(const ProfileScreen(slug: 'test-user'))); @@ -199,7 +237,159 @@ void main() { }); expect(find.byType(ProfileScreen), findsOneWidget); - expect(find.text('Пользователь ограничил доступ к своей странице'), findsOneWidget); + expect( + find.text('Пользователь ограничил доступ к своей странице'), + findsOneWidget, + ); + }); + group('аналитика (InsightsCard)', () { + ProfileModel profileOf( + String slug, { + bool blockedByMe = false, + bool blockedByAuthor = false, + }) => ProfileModel( + id: 1, + name: 'Test User', + email: 'test@example.com', + phoneConfirmationState: 'confirmed', + slug: slug, + isPublicProfile: true, + isBlockedByMe: blockedByMe, + isBlockedByAuthor: blockedByAuthor, + isBlockedByUser: false, + isEntitySubscribedByMe: false, + profilePhotoUrl: '', + roles: const [], + badges: const [], + subscriptions: const [], + subscribers: const [], + ); + + const details = ProfileDetailsModel( + firstName: 'Test', + lastName: 'User', + birthdate: '1990-01-01', + gender: 'male', + country: 'Russia', + city: 'Moscow', + lang: 'ru', + weight: 70.0, + height: 175, + chestCircumference: 100, + waistCircumference: 80, + hipCircumference: 90, + bloodPressureUp: 120, + bloodPressureDown: 80, + hasHypo: false, + hasHyper: false, + aboutAlergy: '', + aboutTakingPils: '', + aboutSportHobbies: '', + favoriteFoods: '', + unfavoriteFoods: '', + additionalInfo: '', + isMoodFillable: true, + isBedtimeFillable: true, + activityCoefficient: 1.5, + ); + + Future pumpProfile( + WidgetTester tester, { + required ProfileModel viewed, + String? slug, + Object? insightsError, + }) async { + final insights = FakeInsightsRepository(error: insightsError); + sl.unregister(); + sl.registerLazySingleton(() => insights); + when( + profileRepository.getProfileBySlug(any), + ).thenAnswer((_) async => viewed); + when( + profileRepository.getMyProfile(), + ).thenAnswer((_) async => profileOf('me')); + when( + profileRepository.getMyProfileDetails(), + ).thenAnswer((_) async => details); + when( + profileRepository.getProfileDetailsBySlug(any), + ).thenAnswer((_) async => details); + when( + profileRepository.getBlogPostsByAuthorSlug(slug: anyNamed('slug')), + ).thenAnswer((_) async => []); + + await mockNetworkImagesFor(() async { + await tester.pumpWidget(wrap(ProfileScreen(slug: slug))); + await tester.pumpAndSettle(); + }); + return insights; + } + + testWidgets('собственный профиль: свои данные и кнопка добавления', ( + tester, + ) async { + final insights = await pumpProfile(tester, viewed: profileOf('me')); + + final card = tester.widget(find.byType(InsightsCard)); + expect(card.slug, isNull); + expect(card.canAdd, isTrue); + expect(insights.serverRequests.single.slug, isNull); + }); + + testWidgets( + 'чужой профиль (консультант → клиент): данные по slug без кнопки добавления', + (tester) async { + final insights = await pumpProfile( + tester, + viewed: profileOf('client'), + slug: 'client', + ); + + final card = tester.widget(find.byType(InsightsCard)); + expect(card.slug, 'client'); + expect(card.canAdd, isFalse); + expect(insights.serverRequests.single.slug, 'client'); + expect(find.byKey(const Key('insights_card')), findsOneWidget); + }, + ); + + testWidgets('чужой профиль: 403 скрывает блок', (tester) async { + await pumpProfile( + tester, + viewed: profileOf('client'), + slug: 'client', + insightsError: const InsightsForbidden(), + ); + + expect(find.byKey(const Key('insights_card')), findsNothing); + }); + + testWidgets( + 'профиль, заблокированный мной, не показывает аналитику и не запрашивает её', + (tester) async { + final insights = await pumpProfile( + tester, + viewed: profileOf('client', blockedByMe: true), + slug: 'client', + ); + + expect(find.byType(InsightsCard), findsNothing); + expect(insights.serverRequests, isEmpty); + }, + ); + + testWidgets('профиль, ограничивший доступ, не показывает аналитику', ( + tester, + ) async { + final insights = await pumpProfile( + tester, + viewed: profileOf('client', blockedByAuthor: true), + slug: 'client', + ); + + expect(find.byType(InsightsCard), findsNothing); + expect(insights.serverRequests, isEmpty); + }); }); }); } 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..1bd8bf64 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'; @@ -17,6 +18,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 'user_details_screen_smoke_test.mocks.dart'; @GenerateNiceMocks([MockSpec()]) @@ -97,10 +99,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(() => FakeInsightsRepository()); sl.registerLazySingleton(() => cubit); await tester.pumpWidget(wrap(const UserDetailsScreen(slug: 'test-slug'))); @@ -117,10 +120,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(() => FakeInsightsRepository()); sl.registerLazySingleton(() => cubit); await tester.pumpWidget(wrap(const UserDetailsScreen(slug: 'test-slug'))); @@ -136,6 +140,7 @@ void main() { when(profileRepository.getProfileDetailsBySlug('test-slug')).thenThrow(Exception('Error loading profile')); sl.registerLazySingleton(() => profileRepository); + sl.registerLazySingleton(() => FakeInsightsRepository()); 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..5e07a3a8 --- /dev/null +++ b/test/fixtures/doubles/insights_doubles.dart @@ -0,0 +1,131 @@ +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; + + /// Ответ [fetchIntra] по запрошенной дате (`YYYY-MM-DD`); для остальных дат + /// берётся `payload.intra`. + final Map intraByDate = {}; + + /// Если задан — выбрасывается из [fetchIntra]. + Object? intraError; + + /// Если задан — выбрасывается из [getInsights] (например, [InsightsForbidden]). + Object? error; + int? heightCm; + + /// Задержка ответа [fetchInsights] (для тестов гонок). + Duration? fetchInsightsDelay; + + /// Задержка ответа [fetchIntra] (для тестов гонок). + Duration? fetchIntraDelay; + + final List<({String? slug, BodyPeriod period})> insightsRequests = []; + final List< + ({ + String? slug, + BodyPeriod bodyPeriod, + DailyPeriod dailyPeriod, + String? intraDate, + }) + > + serverRequests = []; + final List<({String? slug, String? date})> intraRequests = []; + 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 fetchInsights({ + String? slug, + required BodyPeriod bodyPeriod, + required DailyPeriod dailyPeriod, + String? intraDate, + }) async { + serverRequests.add(( + slug: slug, + bodyPeriod: bodyPeriod, + dailyPeriod: dailyPeriod, + intraDate: intraDate, + )); + final delay = fetchInsightsDelay; + if (delay != null) { + await Future.delayed(delay); + } + final failure = error; + if (failure != null) { + throw failure; + } + return payload; + } + + @override + Future fetchIntra({String? slug, String? intraDate}) async { + intraRequests.add((slug: slug, date: intraDate)); + final delay = fetchIntraDelay; + if (delay != null) { + await Future.delayed(delay); + } + final failure = intraError ?? error; + if (failure != null) { + throw failure; + } + return intraByDate[intraDate] ?? payload.intra!; + } + + @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_intra_response.json b/test/fixtures/insights/insights_intra_response.json new file mode 100644 index 00000000..efb10df4 --- /dev/null +++ b/test/fixtures/insights/insights_intra_response.json @@ -0,0 +1,46 @@ +{ + "data": { + "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_intra_response_yesterday.json b/test/fixtures/insights/insights_intra_response_yesterday.json new file mode 100644 index 00000000..21bb45ae --- /dev/null +++ b/test/fixtures/insights/insights_intra_response_yesterday.json @@ -0,0 +1,46 @@ +{ + "data": { + "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-08", + "maxDate": "2026-11-09", + "dateLabel": "8 ноября", + "isToday": false + } + } +} diff --git a/test/fixtures/insights/insights_payload.json b/test/fixtures/insights/insights_payload.json new file mode 100644 index 00000000..98bc004f --- /dev/null +++ b/test/fixtures/insights/insights_payload.json @@ -0,0 +1,122 @@ +{ + "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": 5, + "headline": "1930", + "deltaText": "Калораж вырос на 5% за период" + }, + "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..c9b596bf --- /dev/null +++ b/test/fixtures/insights/insights_payload_minimal.json @@ -0,0 +1,38 @@ +{ + "weight": { + "labels": [], + "values": [], + "last": null, + "delta": null, + "headline": "—", + "unit": "кг", + "deltaText": "Недостаточно данных" + }, + "bmi": { + "labels": [], + "values": [], + "headline": "—", + "deltaText": "Укажите рост, чтобы посчитать ИМТ.", + "hint": "" + }, + "measure": { + "labels": [], + "waist": [], + "chest": [], + "hips": [], + "headline": "—", + "deltaText": "Недостаточно данных" + }, + "daily": null, + "intra": { + "bars": [], + "satiety": [], + "mood": [], + "wake": null, + "sleep": null, + "date": "2026-11-09", + "maxDate": "2026-11-09", + "dateLabel": "Сегодня", + "isToday": true + } +} diff --git a/test/fixtures/insights/insights_response.json b/test/fixtures/insights/insights_response.json new file mode 100644 index 00000000..7a562d58 --- /dev/null +++ b/test/fixtures/insights/insights_response.json @@ -0,0 +1,124 @@ +{ + "data": { + "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": 5, + "headline": "1930", + "deltaText": "Калораж вырос на 5% за период" + }, + "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_response_empty.json b/test/fixtures/insights/insights_response_empty.json new file mode 100644 index 00000000..ae96e9c1 --- /dev/null +++ b/test/fixtures/insights/insights_response_empty.json @@ -0,0 +1,40 @@ +{ + "data": { + "weight": { + "labels": [], + "values": [], + "last": null, + "delta": null, + "headline": "—", + "unit": "кг", + "deltaText": "Недостаточно данных" + }, + "bmi": { + "labels": [], + "values": [], + "headline": "—", + "deltaText": "Укажите рост, чтобы посчитать ИМТ.", + "hint": "" + }, + "measure": { + "labels": [], + "waist": [], + "chest": [], + "hips": [], + "headline": "—", + "deltaText": "Недостаточно данных" + }, + "daily": null, + "intra": { + "bars": [], + "satiety": [], + "mood": [], + "wake": null, + "sleep": null, + "date": "2026-11-09", + "maxDate": "2026-11-09", + "dateLabel": "Сегодня", + "isToday": true + } + } +}