From 8c33f033ffb2ca51ce19e36ae81d5b8eac17209d Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 10 Nov 2026 12:21:53 +0300 Subject: [PATCH] feat(insights): added kbju fetching for analitics --- lib/core/di/repository_registrations.dart | 2 +- lib/core/di/service_registrations.dart | 2 + .../diary/entities/diary_day_entity.dart | 3 + .../diary/models/diary_day_model.dart | 12 + .../presentation/pages/diary_screen.dart | 63 +++- .../insights/insights_feature_flags.dart | 8 +- .../models/insights_response_model.dart | 36 ++ .../presentation/cubit/insights_cubit.dart | 158 +++++++- .../presentation/cubit/insights_state.dart | 4 + .../presentation/widgets/insights_card.dart | 51 ++- .../repositories/insights_repository.dart | 54 ++- .../insights/services/insights_service.dart | 43 +++ .../presentation/pages/profile_screen.dart | 8 + .../diary/models/diary_day_model_test.dart | 108 +++++- .../pages/diary_screen_smoke_test.dart | 88 ++++- .../models/insights_payload_model_test.dart | 5 +- .../models/insights_response_model_test.dart | 154 ++++++++ .../cubit/insights_cubit_server_test.dart | 336 ++++++++++++++++++ .../cubit/insights_cubit_test.dart | 9 + .../widgets/insights_kbju_test.dart | 93 ++++- .../insights_repository_test.dart | 127 +++++++ .../pages/profile_screen_smoke_test.dart | 228 +++++++++++- .../user_details_screen_smoke_test.dart | 7 +- test/fixtures/doubles/insights_doubles.dart | 34 ++ .../insights/insights_intra_response.json | 46 +++ .../insights_intra_response_yesterday.json | 46 +++ test/fixtures/insights/insights_payload.json | 98 ++++- .../insights/insights_payload_minimal.json | 41 ++- test/fixtures/insights/insights_response.json | 124 +++++++ .../insights/insights_response_empty.json | 40 +++ 30 files changed, 1910 insertions(+), 118 deletions(-) create mode 100644 lib/features/insights/models/insights_response_model.dart create mode 100644 lib/features/insights/services/insights_service.dart create mode 100644 test/features/insights/models/insights_response_model_test.dart create mode 100644 test/features/insights/presentation/cubit/insights_cubit_server_test.dart create mode 100644 test/fixtures/insights/insights_intra_response.json create mode 100644 test/fixtures/insights/insights_intra_response_yesterday.json create mode 100644 test/fixtures/insights/insights_response.json create mode 100644 test/fixtures/insights/insights_response_empty.json diff --git a/lib/core/di/repository_registrations.dart b/lib/core/di/repository_registrations.dart index a494bb8d..e5b12e92 100644 --- a/lib/core/di/repository_registrations.dart +++ b/lib/core/di/repository_registrations.dart @@ -56,7 +56,7 @@ class RepositoryRegistrations { sl.registerLazySingleton(() => VideoCallRepository(videoCallService: sl())); sl.registerLazySingleton(() => FeedRepository(feedService: sl())); sl.registerLazySingleton(() => ProfileRepository(profileService: sl())); - sl.registerLazySingleton(() => InsightsRepository(profileRepository: sl())); + sl.registerLazySingleton(() => 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/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 d6ec5f55..41818a20 100644 --- a/lib/features/diary/presentation/pages/diary_screen.dart +++ b/lib/features/diary/presentation/pages/diary_screen.dart @@ -22,6 +22,7 @@ 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'; @@ -49,7 +50,12 @@ class UserDiaryScreen extends StatelessWidget { } class DiaryScreenContent extends StatefulWidget { - const DiaryScreenContent({super.key, this.diaryId, this.date, this.ownerSlug}); + const DiaryScreenContent({ + super.key, + this.diaryId, + this.date, + this.ownerSlug, + }); final int? diaryId; final DateTime? date; final String? ownerSlug; @@ -60,9 +66,14 @@ 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 || widget.ownerSlug != null; + return !state.isConsultantView || _ownerSlug(state) != null; } @override @@ -122,9 +133,9 @@ class _DiaryScreenContentState extends State { ], if (state is DiaryStateLoaded && _canShowInsights(state)) Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: InsightsCard( - slug: state.isConsultantView ? widget.ownerSlug : null, + padding: const EdgeInsets.fromLTRB(4, 12, 4, 12), + child: _DiaryInsights( + slug: state.isConsultantView ? _ownerSlug(state) : null, canAdd: !state.isConsultantView, ), ), @@ -267,3 +278,45 @@ class _DiaryScreenContentState extends State { ); } } + +/// Блок «Аналитика» в дневнике. Для своего дневника перезапрашивает данные, +/// когда в том же дне меняются приёмы пищи или время подъёма/отбоя: от них +/// зависят КБЖУ-графики. Чужой дневник консультант не редактирует. +class _DiaryInsights extends StatefulWidget { + const _DiaryInsights({required this.slug, required this.canAdd}); + + final String? slug; + final bool canAdd; + + @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, + ), + ); + } +} diff --git a/lib/features/insights/insights_feature_flags.dart b/lib/features/insights/insights_feature_flags.dart index e3f50237..d33af602 100644 --- a/lib/features/insights/insights_feature_flags.dart +++ b/lib/features/insights/insights_feature_flags.dart @@ -1,6 +1,8 @@ /// Флаги поэтапного включения аналитики. abstract final class InsightsFeatureFlags { - /// Вкладки «КБЖУ по дням» и «В течение дня» требуют бэкенд-эндпоинта - /// `GET /api/profile/insights` и включаются во второй итерации. - static const bool kbjuEnabled = false; + /// Вкладки «КБЖУ по дням» и «В течение дня». При `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_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/cubit/insights_cubit.dart b/lib/features/insights/presentation/cubit/insights_cubit.dart index aeaebbb5..b388d51b 100644 --- a/lib/features/insights/presentation/cubit/insights_cubit.dart +++ b/lib/features/insights/presentation/cubit/insights_cubit.dart @@ -2,6 +2,7 @@ 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'; @@ -35,17 +36,30 @@ class InsightsCubit extends Cubit { Future load() async { emit(const InsightsState.loading()); - await _fetch(bodyPeriod: BodyPeriod.threeMonths, previous: null); + await _fetch( + bodyPeriod: BodyPeriod.threeMonths, + dailyPeriod: DailyPeriod.twoWeeks, + intraDate: null, + previous: null, + ); } /// Перезапрашивает данные, сохраняя вкладку, период, серии и раскрытие. Future refresh() async { final current = state; + if (current is InsightsStateLoading || current is InsightsStateHidden) { + return; + } if (current is! InsightsStateLoaded) { return load(); } emit(current.copyWith(isRefreshing: true)); - await _fetch(bodyPeriod: current.bodyPeriod, previous: current); + await _fetch( + bodyPeriod: current.bodyPeriod, + dailyPeriod: current.dailyPeriod, + intraDate: current.intraDate, + previous: current, + ); } Future setBodyPeriod(BodyPeriod period) async { @@ -54,7 +68,100 @@ class InsightsCubit extends Cubit { return; } emit(current.copyWith(bodyPeriod: period, isRefreshing: true)); - await _fetch(bodyPeriod: period, previous: current); + await _fetch( + bodyPeriod: period, + dailyPeriod: current.dailyPeriod, + intraDate: current.intraDate, + previous: current, + ); + } + + /// Период вкладки «КБЖУ по дням». Работает только с серверными данными. + 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, + previous: current, + ); + } + + /// Показывает вкладку «В течение дня» за [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 || + (current.payload.intra?.isToday ?? true)) { + return; + } + await setIntraDate(DateTime(base.year, base.month, base.day + 1)); + } + + DateTime? _currentIntraDay() { + final current = state; + if (!kbjuEnabled || current is! InsightsStateLoaded) { + return null; + } + return DateTime.tryParse(current.payload.intra?.date ?? ''); + } + + Future _loadIntra(String date) async { + final current = state; + if (!kbjuEnabled || current is! InsightsStateLoaded) { + return; + } + final requestId = ++_requestId; + emit(current.copyWith(isRefreshing: true)); + try { + final intra = await repository.fetchIntra(slug: slug, intraDate: date); + if (requestId != _requestId || isClosed) { + return; + } + emit( + current.copyWith( + payload: current.payload.copyWith(intra: intra), + intraDate: intra.isToday + ? null + : (intra.date.isEmpty ? date : intra.date), + isRefreshing: false, + ), + ); + } on InsightsForbidden { + if (requestId == _requestId && !isClosed) { + emit(const InsightsState.hidden()); + } + } catch (e, st) { + await reportFrom(e, st, this); + log(e.toString(), stackTrace: st); + if (requestId == _requestId && !isClosed) { + emit(current.copyWith(isRefreshing: false)); + } + } + } + + 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() { @@ -136,30 +243,59 @@ class InsightsCubit extends Cubit { 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 InsightsStateLoaded? previous, }) async { final requestId = ++_requestId; try { - if (!_isHeightLoaded) { - _heightCm = await repository.getHeightCm(slug: slug); - _isHeightLoaded = true; - } - final payload = await repository.getInsights( - slug: slug, + final payload = await _requestPayload( bodyPeriod: bodyPeriod, - heightCm: _heightCm, + dailyPeriod: dailyPeriod, + intraDate: intraDate, ); if (requestId != _requestId || isClosed) { return; } emit( previous == null - ? InsightsState.loaded(payload: payload, bodyPeriod: bodyPeriod) + ? InsightsState.loaded( + payload: payload, + bodyPeriod: bodyPeriod, + dailyPeriod: dailyPeriod, + intraDate: intraDate, + ) : previous.copyWith( payload: payload, bodyPeriod: bodyPeriod, + dailyPeriod: dailyPeriod, + intraDate: intraDate, isRefreshing: false, ), ); diff --git a/lib/features/insights/presentation/cubit/insights_state.dart b/lib/features/insights/presentation/cubit/insights_state.dart index a6073daf..d14d5fab 100644 --- a/lib/features/insights/presentation/cubit/insights_state.dart +++ b/lib/features/insights/presentation/cubit/insights_state.dart @@ -35,6 +35,10 @@ abstract class InsightsState with _$InsightsState { 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, diff --git a/lib/features/insights/presentation/widgets/insights_card.dart b/lib/features/insights/presentation/widgets/insights_card.dart index 4f754829..eb3fbb67 100644 --- a/lib/features/insights/presentation/widgets/insights_card.dart +++ b/lib/features/insights/presentation/widgets/insights_card.dart @@ -37,6 +37,7 @@ class InsightsCard extends StatelessWidget { this.canAdd = false, this.repository, this.kbjuEnabled = InsightsFeatureFlags.kbjuEnabled, + this.refreshToken = 0, }); final String? slug; @@ -44,6 +45,10 @@ class InsightsCard extends StatelessWidget { final InsightsRepository? repository; final bool kbjuEnabled; + /// При смене значения блок перезапрашивает данные (например, после правок + /// в дневнике), сохраняя вкладку, период и выбранный день. + final int refreshToken; + @override Widget build(BuildContext context) { return KeyedSubtree( @@ -55,14 +60,29 @@ class InsightsCard extends StatelessWidget { canAdd: canAdd, kbjuEnabled: kbjuEnabled, )..load(), - child: const _InsightsView(), + child: _InsightsView(refreshToken: refreshToken), ), ); } } -class _InsightsView extends StatelessWidget { - const _InsightsView(); +class _InsightsView extends StatefulWidget { + const _InsightsView({required this.refreshToken}); + + final int refreshToken; + + @override + State<_InsightsView> createState() => _InsightsViewState(); +} + +class _InsightsViewState extends State<_InsightsView> { + @override + void didUpdateWidget(covariant _InsightsView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.refreshToken != widget.refreshToken) { + context.read().refresh(); + } + } @override Widget build(BuildContext context) { @@ -113,7 +133,7 @@ class _ErrorContent extends StatelessWidget { Widget build(BuildContext context) { final s = S.of(context); return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ Expanded( @@ -158,9 +178,9 @@ class _LoadedContent extends StatelessWidget { child: state.isExpanded ? Padding( padding: EdgeInsets.fromLTRB( - 20, + 16, 0, - 20, + 16, showFab ? 24 + 44 : 24, ), child: _Body(state: state), @@ -241,7 +261,7 @@ class _Toggle extends StatelessWidget { borderRadius: BorderRadius.circular(8), onTap: onTap, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: Row( children: [ Container( @@ -443,8 +463,7 @@ class _PanelContent extends StatelessWidget { } } -/// «КБЖУ по дням». Период пока фиксирован: перезапрос появится вместе с -/// эндпоинтом `GET /api/profile/insights` (итерация 2). +/// «КБЖУ по дням». Смена периода перезапрашивает `GET /api/profile/insights`. class _DailyPanel extends StatelessWidget { const _DailyPanel({required this.state}); @@ -462,9 +481,10 @@ class _DailyPanel extends StatelessWidget { children: [ PeriodPills( periods: DailyPeriod.values, - selected: DailyPeriod.twoWeeks, + selected: state.dailyPeriod, labelOf: (period) => period.pillLabel(s), keyOf: (period) => period.apiValue, + onSelected: cubit.setDailyPeriod, ), const SizedBox(height: 16), if (daily == null) @@ -474,7 +494,7 @@ class _DailyPanel extends StatelessWidget { headline: daily.headline, unit: s.insights_unit_kcal_avg, deltaText: daily.deltaText, - isDeltaHighlighted: daily.percent != null, + isDeltaHighlighted: (daily.percent ?? 0) != 0, ), const SizedBox(height: 12), SeriesChips( @@ -510,7 +530,7 @@ class _DailyPanel extends StatelessWidget { } } -/// «В течение дня». Навигация по датам неактивна до итерации 2. +/// «В течение дня». Листание дней запрашивает только `/insights/intra`. class _IntraPanel extends StatelessWidget { const _IntraPanel({required this.state}); @@ -530,7 +550,12 @@ class _IntraPanel extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - IntraDateNav(model: intra), + IntraDateNav( + model: intra, + onPreviousDay: cubit.showPreviousDay, + onNextDay: cubit.showNextDay, + onDatePicked: cubit.setIntraDate, + ), const SizedBox(height: 16), SeriesChips( chips: [ diff --git a/lib/features/insights/repositories/insights_repository.dart b/lib/features/insights/repositories/insights_repository.dart index a86d10b0..ef174ce8 100644 --- a/lib/features/insights/repositories/insights_repository.dart +++ b/lib/features/insights/repositories/insights_repository.dart @@ -2,6 +2,7 @@ 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'; @@ -16,17 +17,21 @@ class InsightsForbidden implements Exception { typedef InsightsTextBuilderFactory = InsightsTextBuilder Function(); -/// Итерация 1: собирает [InsightsPayloadModel] из существующих эндпоинтов -/// профиля. Во второй итерации источник заменяется одним запросом -/// `GET /api/profile/insights`. +/// Источники данных блока «Аналитика»: +/// - [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`, если не указан или @@ -70,6 +75,49 @@ class InsightsRepository { } } + /// Полный 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); } diff --git a/lib/features/insights/services/insights_service.dart b/lib/features/insights/services/insights_service.dart new file mode 100644 index 00000000..df06a571 --- /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, + }); + + /// Чужие графики целиком (клиент консультанта, публичный профиль). + @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/presentation/pages/profile_screen.dart b/lib/features/profile/presentation/pages/profile_screen.dart index cbb96982..80d169c9 100644 --- a/lib/features/profile/presentation/pages/profile_screen.dart +++ b/lib/features/profile/presentation/pages/profile_screen.dart @@ -260,6 +260,14 @@ class _ProfileScreenState extends State { padding: const EdgeInsets.only(bottom: 16), child: _badges(state), ), + // Доступ решает сервер: консультант видит клиента, пока консультация + // не завершена, остальные — только публичные профили; иначе 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, 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 a4b3998a..4a0afc24 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'; @@ -70,11 +72,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: '', @@ -97,6 +101,7 @@ void main() { chosenPlanId: hasChosenPlan ? 10 : null, dayOfChosenDiet: null, diet: null, + ownerSlug: ownerSlug, ); const nutrition = NutritionDataModel( dailyNutritionIntake: NutritionModel( @@ -391,12 +396,14 @@ void main() { WidgetTester tester, { required bool isConsultantView, String? ownerSlug, + String? diaryOwnerSlug, + Stream? diaryStates, required FakeInsightsRepository insightsRepository, }) async { final diaryCubit = MockDiaryCubit(); - final state = loadedState(hasChosenPlan: false).copyWith(isConsultantView: isConsultantView); + final state = loadedState(hasChosenPlan: false, ownerSlug: diaryOwnerSlug).copyWith(isConsultantView: isConsultantView); when(diaryCubit.state).thenReturn(state); - when(diaryCubit.stream).thenAnswer((_) => const Stream.empty()); + when(diaryCubit.stream).thenAnswer((_) => diaryStates ?? const Stream.empty()); when(diaryCubit.close()).thenAnswer((_) async => Future.value()); when( diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId')), @@ -426,7 +433,7 @@ void main() { expect(find.byType(InsightsCard), findsOneWidget); expect(tester.widget(find.byType(InsightsCard)).slug, isNull); expect(tester.widget(find.byType(InsightsCard)).canAdd, isTrue); - expect(insightsRepository.insightsRequests.single.slug, isNull); + expect(insightsRepository.serverRequests.single.slug, isNull); }); testWidgets('consultant view shows insights card of the client without add button', (tester) async { @@ -437,7 +444,76 @@ void main() { final card = tester.widget(find.byType(InsightsCard)); expect(card.slug, 'client'); expect(card.canAdd, isFalse); - expect(insightsRepository.insightsRequests.single.slug, 'client'); + 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 { @@ -446,7 +522,7 @@ void main() { await pumpDiaryWithInsights(tester, isConsultantView: true, insightsRepository: insightsRepository); expect(find.byType(InsightsCard), findsNothing); - expect(insightsRepository.insightsRequests, isEmpty); + expect(insightsRepository.serverRequests, isEmpty); }); testWidgets('consultant view hides insights card on 403', (tester) async { diff --git a/test/features/insights/models/insights_payload_model_test.dart b/test/features/insights/models/insights_payload_model_test.dart index 2c0f1bfd..a8296799 100644 --- a/test/features/insights/models/insights_payload_model_test.dart +++ b/test/features/insights/models/insights_payload_model_test.dart @@ -49,7 +49,7 @@ void main() { expect(model.bmi.deltaText, contains('рост')); }); - test('intra без wake/sleep и null в average/percent', () { + test('пустой intra без wake/sleep; daily == null', () { final model = InsightsPayloadModel.fromJson( _fixture('insights_payload_minimal.json'), ); @@ -57,8 +57,7 @@ void main() { expect(model.intra!.wake, isNull); expect(model.intra!.sleep, isNull); expect(model.intra!.bars, isEmpty); - expect(model.daily!.average, isNull); - expect(model.daily!.percent, isNull); + expect(model.daily, isNull); expect(model.weight.last, 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/cubit/insights_cubit_server_test.dart b/test/features/insights/presentation/cubit/insights_cubit_server_test.dart new file mode 100644 index 00000000..6af6cf58 --- /dev/null +++ b/test/features/insights/presentation/cubit/insights_cubit_server_test.dart @@ -0,0 +1,336 @@ +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.twoWeeks); + 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('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 index e45dcefc..bd1d8469 100644 --- a/test/features/insights/presentation/cubit/insights_cubit_test.dart +++ b/test/features/insights/presentation/cubit/insights_cubit_test.dart @@ -26,6 +26,7 @@ void main() { setUpAll(() { registerFallbackValue(BodyPeriod.month); + registerFallbackValue(DailyPeriod.week); }); setUp(() { @@ -40,6 +41,14 @@ void main() { 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({ diff --git a/test/features/insights/presentation/widgets/insights_kbju_test.dart b/test/features/insights/presentation/widgets/insights_kbju_test.dart index 9576a41b..7c0f8cae 100644 --- a/test/features/insights/presentation/widgets/insights_kbju_test.dart +++ b/test/features/insights/presentation/widgets/insights_kbju_test.dart @@ -1,5 +1,6 @@ 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'; @@ -60,7 +61,7 @@ void main() { expect(find.byType(DailyKbjuChart), findsOneWidget); expect(find.text('1930'), findsOneWidget); expect(find.text('ккал в среднем'), findsOneWidget); - expect(find.text('98% от нормы'), findsOneWidget); + expect(find.text('Калораж вырос на 5% за период'), findsOneWidget); for (var i = 0; i < 4; i++) { expect(find.byKey(Key('insights_series_$i')), findsOneWidget); } @@ -85,6 +86,20 @@ void main() { 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( @@ -135,26 +150,66 @@ void main() { } }); - testWidgets( - 'кнопка «следующий день» отключена при isToday, «предыдущий» без колбэка тоже', - (tester) async { - await pumpKbjuCard( - tester, - FakeInsightsRepository(), - 'insights_tab_intra', - ); + 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), - ), - ); + IconButton buttonIn(String key) => tester.widget( + find.descendant( + of: find.byKey(Key(key)), + matching: find.byType(IconButton), + ), + ); - expect(buttonIn('insights_intra_next').onPressed, isNull); - expect(buttonIn('insights_intra_prev').onPressed, isNull); - }, - ); + 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, diff --git a/test/features/insights/repositories/insights_repository_test.dart b/test/features/insights/repositories/insights_repository_test.dart index 5b14230e..3d0affea 100644 --- a/test/features/insights/repositories/insights_repository_test.dart +++ b/test/features/insights/repositories/insights_repository_test.dart @@ -1,7 +1,9 @@ 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'; @@ -11,8 +13,12 @@ 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', @@ -51,6 +57,7 @@ DioException _dio(int status) => DioException( void main() { late _MockProfileRepository profileRepository; + late _MockInsightsService insightsService; late InsightsRepository repository; final date1 = DateTime(2026, 11, 1, 12); @@ -89,8 +96,10 @@ void main() { setUp(() async { final s = await S.load(const Locale('ru')); profileRepository = _MockProfileRepository(); + insightsService = _MockInsightsService(); repository = InsightsRepository( profileRepository: profileRepository, + insightsService: insightsService, textBuilderFactory: () => InsightsTextBuilder(s), ); }); @@ -211,4 +220,122 @@ void main() { ); }); }); + + 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/presentation/pages/profile_screen_smoke_test.dart b/test/features/profile/presentation/pages/profile_screen_smoke_test.dart index 39e98297..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,9 +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/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/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'; @@ -20,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; @@ -31,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(); @@ -44,13 +58,25 @@ void main() { plansRepository = MockPlansRepository(); sl.registerLazySingleton(() => profileRepository); - sl.registerLazySingleton(() => InsightsRepository(profileRepository: 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,8 +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.getMyProfileDetails(), + ).thenAnswer((_) async => profileDetails); + when( + profileRepository.getBlogPostsByAuthorSlug(slug: anyNamed('slug')), + ).thenAnswer((_) async => []); await mockNetworkImagesFor(() async { await tester.pumpWidget(wrap(const ProfileScreen())); @@ -127,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(); @@ -183,11 +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.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'))); @@ -195,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/user_details/presentation/user_details_screen_smoke_test.dart b/test/features/user_details/presentation/user_details_screen_smoke_test.dart index 16e705ad..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 @@ -18,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()]) @@ -102,7 +103,7 @@ void main() { when(profileRepository.getCircumferences(period: '3', slug: 'test-slug')).thenAnswer((_) async => mockCircumferencePoints); sl.registerLazySingleton(() => profileRepository); - sl.registerLazySingleton(() => InsightsRepository(profileRepository: profileRepository)); + sl.registerLazySingleton(() => FakeInsightsRepository()); sl.registerLazySingleton(() => cubit); await tester.pumpWidget(wrap(const UserDetailsScreen(slug: 'test-slug'))); @@ -123,7 +124,7 @@ void main() { when(profileRepository.getCircumferences(period: '3', slug: 'test-slug')).thenAnswer((_) async => mockCircumferencePoints); sl.registerLazySingleton(() => profileRepository); - sl.registerLazySingleton(() => InsightsRepository(profileRepository: profileRepository)); + sl.registerLazySingleton(() => FakeInsightsRepository()); sl.registerLazySingleton(() => cubit); await tester.pumpWidget(wrap(const UserDetailsScreen(slug: 'test-slug'))); @@ -139,7 +140,7 @@ void main() { when(profileRepository.getProfileDetailsBySlug('test-slug')).thenThrow(Exception('Error loading profile')); sl.registerLazySingleton(() => profileRepository); - sl.registerLazySingleton(() => InsightsRepository(profileRepository: 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 index b121ec39..3e97a70d 100644 --- a/test/fixtures/doubles/insights_doubles.dart +++ b/test/fixtures/doubles/insights_doubles.dart @@ -17,11 +17,20 @@ class FakeInsightsRepository extends Fake implements InsightsRepository { InsightsPayloadModel payload; + /// Ответ [fetchIntra] по запрошенной дате (`YYYY-MM-DD`); для остальных дат + /// берётся `payload.intra`. + final Map intraByDate = {}; + + /// Если задан — выбрасывается из [fetchIntra]. + Object? intraError; + /// Если задан — выбрасывается из [getInsights] (например, [InsightsForbidden]). Object? error; int? heightCm; 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 = []; @@ -38,6 +47,31 @@ class FakeInsightsRepository extends Fake implements InsightsRepository { 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 failure = error; + if (failure != null) { + throw failure; + } + return payload; + } + + @override + Future fetchIntra({String? slug, String? intraDate}) async { + intraRequests.add((slug: slug, date: intraDate)); + final failure = intraError ?? error; + if (failure != null) { + throw failure; + } + return intraByDate[intraDate] ?? payload.intra!; + } + @override Future addWeight({required double weight}) async { addedWeights.add(weight); 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 index 3844db21..98bc004f 100644 --- a/test/fixtures/insights/insights_payload.json +++ b/test/fixtures/insights/insights_payload.json @@ -1,7 +1,15 @@ { "weight": { - "labels": ["01.11", "05.11", "09.11"], - "values": [74.5, 73.9, 73.2], + "labels": [ + "01.11", + "05.11", + "09.11" + ], + "values": [ + 74.5, + 73.9, + 73.2 + ], "last": 73.2, "delta": -1.3, "headline": "73.2", @@ -19,31 +27,87 @@ "hint": "" }, "measure": { - "labels": ["01.11", "09.11"], - "waist": [82, 80.5], - "chest": [96, 96], - "hips": [101, 100], + "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], + "labels": [ + "03.11", + "04.11", + "05.11" + ], + "kcal": [ + 1850, + 2010, + 1930 + ], + "proteins": [ + 92, + 101, + 96 + ], + "fats": [ + 60, + 66, + 63 + ], + "carbs": [ + 210, + 222, + 215 + ], "average": 1930, - "percent": 98, + "percent": 5, "headline": "1930", - "deltaText": "98% от нормы" + "deltaText": "Калораж вырос на 5% за период" }, "intra": { "bars": [ - {"x": 8.5, "p": 120, "f": 90, "c": 240}, - {"x": 13, "p": 160, "f": 130, "c": 300} + { + "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 + } ], - "satiety": [{"x": 8.5, "y": 3}, {"x": 13, "y": 4}], - "mood": [{"x": 8.5, "y": 4}], "wake": 7.5, "sleep": 23, "wakeLabel": "07:30", diff --git a/test/fixtures/insights/insights_payload_minimal.json b/test/fixtures/insights/insights_payload_minimal.json index a460af1a..c9b596bf 100644 --- a/test/fixtures/insights/insights_payload_minimal.json +++ b/test/fixtures/insights/insights_payload_minimal.json @@ -1,7 +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": {"labels": [], "kcal": [], "proteins": [], "fats": [], "carbs": [], "average": null, "percent": null, "headline": "—", "deltaText": ""}, - "intra": {"bars": [], "satiety": [], "mood": [], "wake": null, "sleep": null, "date": "2026-11-09", "maxDate": "2026-11-09", "dateLabel": "Сегодня", "isToday": true} + "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 + } + } +}