feat(insights): added kbju fetching for analitics

This commit is contained in:
misha
2026-11-10 12:21:53 +03:00
parent 45d072cff4
commit 8c33f033ff
30 changed files with 1910 additions and 118 deletions

View File

@@ -56,7 +56,7 @@ class RepositoryRegistrations {
sl.registerLazySingleton<VideoCallRepository>(() => VideoCallRepository(videoCallService: sl()));
sl.registerLazySingleton<FeedRepository>(() => FeedRepository(feedService: sl()));
sl.registerLazySingleton<ProfileRepository>(() => ProfileRepository(profileService: sl()));
sl.registerLazySingleton<InsightsRepository>(() => InsightsRepository(profileRepository: sl()));
sl.registerLazySingleton<InsightsRepository>(() => InsightsRepository(profileRepository: sl(), insightsService: sl()));
sl.registerLazySingleton<CalendarRepository>(() => CalendarRepository(calendarService: sl()));
sl.registerLazySingleton<SettingsRepository>(() => SettingsRepository(settingsService: sl()));
sl.registerLazySingleton<ConsultantRepository>(() => ConsultantRepository(consultantService: sl()));

View File

@@ -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>(() => VideoCallService(sl()));
sl.registerLazySingleton<FeedService>(() => FeedService(sl()));
sl.registerLazySingleton<ProfileService>(() => ProfileService(sl()));
sl.registerLazySingleton<InsightsService>(() => InsightsService(sl()));
sl.registerLazySingleton<CalendarService>(() => CalendarService(sl()));
sl.registerLazySingleton<SettingsService>(() => SettingsService(sl()));
sl.registerLazySingleton<ConsultantService>(() => ConsultantService(sl()));

View File

@@ -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._();

View File

@@ -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<String, dynamic> 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<String, dynamic> json) => _$DiaryOwnerModelFromJson(json);
}
@freezed
abstract class DiaryDietModel with _$DiaryDietModel {
const factory DiaryDietModel({required int id, required int duration, required String name}) = _DiaryDietModel;

View File

@@ -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<DiaryScreenContent> {
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<DiaryScreenContent> {
],
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<DiaryScreenContent> {
);
}
}
/// Блок «Аналитика» в дневнике. Для своего дневника перезапрашивает данные,
/// когда в том же дне меняются приёмы пищи или время подъёма/отбоя: от них
/// зависят КБЖУ-графики. Чужой дневник консультант не редактирует.
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<DiaryCubit, DiaryState>(
listenWhen: _isOwnDiaryMutation,
listener: (context, state) => setState(() => _refreshToken++),
child: InsightsCard(
slug: widget.slug,
canAdd: widget.canAdd,
refreshToken: _refreshToken,
),
);
}
}

View File

@@ -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;
}

View File

@@ -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<String, dynamic> 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<String, dynamic> json) =>
_$InsightsIntraResponseModelFromJson(json);
}
@freezed
abstract class InsightsIntraDataModel with _$InsightsIntraDataModel {
const factory InsightsIntraDataModel({required IntraChartModel intra}) =
_InsightsIntraDataModel;
factory InsightsIntraDataModel.fromJson(Map<String, dynamic> json) =>
_$InsightsIntraDataModelFromJson(json);
}

View File

@@ -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<InsightsState> {
Future<void> 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<void> 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<void> setBodyPeriod(BodyPeriod period) async {
@@ -54,7 +68,100 @@ class InsightsCubit extends Cubit<InsightsState> {
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<void> 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<void> setIntraDate(DateTime day) {
return _loadIntra(_formatDate(day));
}
Future<void> showPreviousDay() async {
final base = _currentIntraDay();
if (base != null) {
await setIntraDate(DateTime(base.year, base.month, base.day - 1));
}
}
Future<void> 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<void> _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<InsightsState> {
return true;
}
Future<InsightsPayloadModel> _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<void> _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,
),
);

View File

@@ -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,

View File

@@ -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<InsightsCubit>().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<DailyPeriod>(
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: [

View File

@@ -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<InsightsPayloadModel> 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<IntraChartModel> 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<void> addWeight({required double weight}) {
return profileRepository.addWeight(weight: weight);
}

View File

@@ -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<InsightsResponseModel> getInsights({
@Query('body_period') String? bodyPeriod,
@Query('daily_period') String? dailyPeriod,
@Query('intra_date') String? intraDate,
});
/// Чужие графики целиком (клиент консультанта, публичный профиль).
@GET('/api/profile/insights/{slug}')
Future<InsightsResponseModel> 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<InsightsIntraResponseModel> getIntra({
@Query('intra_date') String? intraDate,
});
/// Только «в течение дня» для другого пользователя.
@GET('/api/profile/insights/{slug}/intra')
Future<InsightsIntraResponseModel> getIntraBySlug({
@Path() required String slug,
@Query('intra_date') String? intraDate,
});
}

View File

@@ -260,6 +260,14 @@ class _ProfileScreenState extends State<ProfileScreen> {
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,

View File

@@ -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<String, dynamic> 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': <dynamic>[],
'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,

View File

@@ -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<DiaryState>? 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<DiaryState>.empty());
when(diaryCubit.stream).thenAnswer((_) => diaryStates ?? const Stream<DiaryState>.empty());
when(diaryCubit.close()).thenAnswer((_) async => Future<void>.value());
when(
diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId')),
@@ -426,7 +433,7 @@ void main() {
expect(find.byType(InsightsCard), findsOneWidget);
expect(tester.widget<InsightsCard>(find.byType(InsightsCard)).slug, isNull);
expect(tester.widget<InsightsCard>(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<InsightsCard>(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<InsightsCard>(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<InsightsCard>(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<DiaryState>.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<DiaryState>.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<DiaryState>.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 {

View File

@@ -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);
});

View File

@@ -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<String, dynamic> _fixture(String name) =>
jsonDecode(File('test/fixtures/insights/$name').readAsStringSync())
as Map<String, dynamic>;
/// Контракт `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<String, dynamic>,
).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 ноября');
});
});
}

View File

@@ -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<InsightsStateHidden>());
await cubit.close();
});
test('ошибка сервера → error', () async {
repository.error = DioException(requestOptions: RequestOptions());
final cubit = buildCubit();
await cubit.load();
expect(cubit.state, isA<InsightsStateError>());
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<InsightsStateHidden>());
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();
});
}

View File

@@ -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({

View File

@@ -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<IconButton>(
find.descendant(
of: find.byKey(Key(key)),
matching: find.byType(IconButton),
),
);
IconButton buttonIn(String key) => tester.widget<IconButton>(
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,

View File

@@ -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<InsightsForbidden>()),
);
});
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<DioException>()),
);
});
});
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<InsightsForbidden>()));
});
});
}

View File

@@ -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<ProfileRepository>(), MockSpec<ChatRepository>(), MockSpec<ConsultantRepository>(), MockSpec<PlansRepository>()])
@GenerateNiceMocks([
MockSpec<ProfileRepository>(),
MockSpec<ChatRepository>(),
MockSpec<ConsultantRepository>(),
MockSpec<PlansRepository>(),
])
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>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
sl.registerLazySingleton<AppConfig>(
() =>
AppConfig(buildType: AppBuildType.tests)
..setBaseUrl('http://localhost'),
);
profileRepository = MockProfileRepository();
chatRepository = MockChatRepository();
@@ -44,13 +58,25 @@ void main() {
plansRepository = MockPlansRepository();
sl.registerLazySingleton<ProfileRepository>(() => profileRepository);
sl.registerLazySingleton<InsightsRepository>(() => InsightsRepository(profileRepository: profileRepository));
sl.registerLazySingleton<InsightsRepository>(
() => FakeInsightsRepository(),
);
sl.registerLazySingleton<ChatRepository>(() => chatRepository);
sl.registerLazySingleton<ConsultantRepository>(() => consultantRepository);
sl.registerLazySingleton<ConsultantRepository>(
() => consultantRepository,
);
sl.registerLazySingleton<PlansRepository>(() => plansRepository);
sl.registerFactory<ProfileCubit>(() => ProfileCubit(profileRepository: sl(), chatRepository: sl(), consultantRepository: sl()));
sl.registerFactory<profile_plans.PlansCubit>(() => profile_plans.PlansCubit(plansRepository: sl()));
sl.registerFactory<ProfileCubit>(
() => ProfileCubit(
profileRepository: sl(),
chatRepository: sl(),
consultantRepository: sl(),
),
);
sl.registerFactory<profile_plans.PlansCubit>(
() => 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<FakeInsightsRepository> pumpProfile(
WidgetTester tester, {
required ProfileModel viewed,
String? slug,
Object? insightsError,
}) async {
final insights = FakeInsightsRepository(error: insightsError);
sl.unregister<InsightsRepository>();
sl.registerLazySingleton<InsightsRepository>(() => 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<InsightsCard>(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<InsightsCard>(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);
});
});
});
}

View File

@@ -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<ProfileRepository>()])
@@ -102,7 +103,7 @@ void main() {
when(profileRepository.getCircumferences(period: '3', slug: 'test-slug')).thenAnswer((_) async => mockCircumferencePoints);
sl.registerLazySingleton<ProfileRepository>(() => profileRepository);
sl.registerLazySingleton<InsightsRepository>(() => InsightsRepository(profileRepository: profileRepository));
sl.registerLazySingleton<InsightsRepository>(() => FakeInsightsRepository());
sl.registerLazySingleton<UserDetailsCubit>(() => 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>(() => profileRepository);
sl.registerLazySingleton<InsightsRepository>(() => InsightsRepository(profileRepository: profileRepository));
sl.registerLazySingleton<InsightsRepository>(() => FakeInsightsRepository());
sl.registerLazySingleton<UserDetailsCubit>(() => 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>(() => profileRepository);
sl.registerLazySingleton<InsightsRepository>(() => InsightsRepository(profileRepository: profileRepository));
sl.registerLazySingleton<InsightsRepository>(() => FakeInsightsRepository());
sl.registerLazySingleton<UserDetailsCubit>(() => cubit);
await tester.pumpWidget(wrap(const UserDetailsScreen(slug: 'test-slug')));

View File

@@ -17,11 +17,20 @@ class FakeInsightsRepository extends Fake implements InsightsRepository {
InsightsPayloadModel payload;
/// Ответ [fetchIntra] по запрошенной дате (`YYYY-MM-DD`); для остальных дат
/// берётся `payload.intra`.
final Map<String, IntraChartModel> 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<double> addedWeights = [];
final List<({int chest, int waist, int hip})> addedCircumferences = [];
@@ -38,6 +47,31 @@ class FakeInsightsRepository extends Fake implements InsightsRepository {
return payload;
}
@override
Future<InsightsPayloadModel> 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<IntraChartModel> 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<void> addWeight({required double weight}) async {
addedWeights.add(weight);

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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",

View File

@@ -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
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}