forked from eatvio/eatvio_app
feat(insights): added kbju fetching for analitics
This commit is contained in:
@@ -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()));
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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._();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
36
lib/features/insights/models/insights_response_model.dart
Normal file
36
lib/features/insights/models/insights_response_model.dart
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
43
lib/features/insights/services/insights_service.dart
Normal file
43
lib/features/insights/services/insights_service.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
154
test/features/insights/models/insights_response_model_test.dart
Normal file
154
test/features/insights/models/insights_response_model_test.dart
Normal 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 ноября');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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')));
|
||||
|
||||
34
test/fixtures/doubles/insights_doubles.dart
vendored
34
test/fixtures/doubles/insights_doubles.dart
vendored
@@ -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);
|
||||
|
||||
46
test/fixtures/insights/insights_intra_response.json
vendored
Normal file
46
test/fixtures/insights/insights_intra_response.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
46
test/fixtures/insights/insights_intra_response_yesterday.json
vendored
Normal file
46
test/fixtures/insights/insights_intra_response_yesterday.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
98
test/fixtures/insights/insights_payload.json
vendored
98
test/fixtures/insights/insights_payload.json
vendored
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
124
test/fixtures/insights/insights_response.json
vendored
Normal file
124
test/fixtures/insights/insights_response.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
40
test/fixtures/insights/insights_response_empty.json
vendored
Normal file
40
test/fixtures/insights/insights_response_empty.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user