forked from eatvio/eatvio_app
Merge pull request 'feat(insights)' (#1135) from misha/eatvio_app:issue-1123 into main
Reviewed-on: eatvio/eatvio_app#1135 Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
This commit is contained in:
@@ -18,6 +18,7 @@ import 'package:eatvio_app/features/diets/repositories/diet_repository.dart';
|
|||||||
import 'package:eatvio_app/features/feed/repositories/feed_repository.dart';
|
import 'package:eatvio_app/features/feed/repositories/feed_repository.dart';
|
||||||
import 'package:eatvio_app/features/glossary/repositories/glossary_repository.dart';
|
import 'package:eatvio_app/features/glossary/repositories/glossary_repository.dart';
|
||||||
import 'package:eatvio_app/features/barcode/repositories/barcode_repository.dart';
|
import 'package:eatvio_app/features/barcode/repositories/barcode_repository.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
|
||||||
import 'package:eatvio_app/features/menu/presentation/repositories/blocked_users_repository.dart';
|
import 'package:eatvio_app/features/menu/presentation/repositories/blocked_users_repository.dart';
|
||||||
import 'package:eatvio_app/features/menu/repositories/notification_settings_repository.dart';
|
import 'package:eatvio_app/features/menu/repositories/notification_settings_repository.dart';
|
||||||
import 'package:eatvio_app/features/my_diets/repositories/my_diets_repository.dart';
|
import 'package:eatvio_app/features/my_diets/repositories/my_diets_repository.dart';
|
||||||
@@ -55,6 +56,7 @@ class RepositoryRegistrations {
|
|||||||
sl.registerLazySingleton<VideoCallRepository>(() => VideoCallRepository(videoCallService: sl()));
|
sl.registerLazySingleton<VideoCallRepository>(() => VideoCallRepository(videoCallService: sl()));
|
||||||
sl.registerLazySingleton<FeedRepository>(() => FeedRepository(feedService: sl()));
|
sl.registerLazySingleton<FeedRepository>(() => FeedRepository(feedService: sl()));
|
||||||
sl.registerLazySingleton<ProfileRepository>(() => ProfileRepository(profileService: sl()));
|
sl.registerLazySingleton<ProfileRepository>(() => ProfileRepository(profileService: sl()));
|
||||||
|
sl.registerLazySingleton<InsightsRepository>(() => InsightsRepository(profileRepository: sl(), insightsService: sl()));
|
||||||
sl.registerLazySingleton<CalendarRepository>(() => CalendarRepository(calendarService: sl()));
|
sl.registerLazySingleton<CalendarRepository>(() => CalendarRepository(calendarService: sl()));
|
||||||
sl.registerLazySingleton<SettingsRepository>(() => SettingsRepository(settingsService: sl()));
|
sl.registerLazySingleton<SettingsRepository>(() => SettingsRepository(settingsService: sl()));
|
||||||
sl.registerLazySingleton<ConsultantRepository>(() => ConsultantRepository(consultantService: 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/diets/services/diet_service.dart';
|
||||||
import 'package:eatvio_app/features/feed/services/feed_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/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/barcode/services/barcode_service.dart';
|
||||||
import 'package:eatvio_app/features/menu/services/blocked_users_service.dart';
|
import 'package:eatvio_app/features/menu/services/blocked_users_service.dart';
|
||||||
import 'package:eatvio_app/features/menu/services/notification_settings_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<VideoCallService>(() => VideoCallService(sl()));
|
||||||
sl.registerLazySingleton<FeedService>(() => FeedService(sl()));
|
sl.registerLazySingleton<FeedService>(() => FeedService(sl()));
|
||||||
sl.registerLazySingleton<ProfileService>(() => ProfileService(sl()));
|
sl.registerLazySingleton<ProfileService>(() => ProfileService(sl()));
|
||||||
|
sl.registerLazySingleton<InsightsService>(() => InsightsService(sl()));
|
||||||
sl.registerLazySingleton<CalendarService>(() => CalendarService(sl()));
|
sl.registerLazySingleton<CalendarService>(() => CalendarService(sl()));
|
||||||
sl.registerLazySingleton<SettingsService>(() => SettingsService(sl()));
|
sl.registerLazySingleton<SettingsService>(() => SettingsService(sl()));
|
||||||
sl.registerLazySingleton<ConsultantService>(() => ConsultantService(sl()));
|
sl.registerLazySingleton<ConsultantService>(() => ConsultantService(sl()));
|
||||||
|
|||||||
@@ -365,9 +365,10 @@ class OtherRoutes {
|
|||||||
path: UserDiaryScreen.path,
|
path: UserDiaryScreen.path,
|
||||||
name: UserDiaryScreen.name,
|
name: UserDiaryScreen.name,
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final data = state.extra as Map<String, int>;
|
final data = state.extra as Map<String, Object?>;
|
||||||
final diaryId = data['diaryId'] as int;
|
final diaryId = data['diaryId'] as int;
|
||||||
return UserDiaryScreen(diaryId: diaryId);
|
final ownerSlug = data['ownerSlug'] as String?;
|
||||||
|
return UserDiaryScreen(diaryId: diaryId, ownerSlug: ownerSlug);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': day.$2.id,});
|
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': day.$2.id, 'ownerSlug': widget.consultationsModel.userSlug});
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(100),
|
borderRadius: BorderRadius.circular(100),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
@@ -283,7 +283,7 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
|
|||||||
.where((e) => e.$2.state == DiaryStateEnum.finished || e.$2.state == DiaryStateEnum.lastDayFinished)
|
.where((e) => e.$2.state == DiaryStateEnum.finished || e.$2.state == DiaryStateEnum.lastDayFinished)
|
||||||
.lastOrNull;
|
.lastOrNull;
|
||||||
if (lastActiveDay != null) {
|
if (lastActiveDay != null) {
|
||||||
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': lastActiveDay.$2.id,});
|
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': lastActiveDay.$2.id, 'ownerSlug': widget.consultationsModel.userSlug});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showErrorDialog(
|
showErrorDialog(
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ abstract class DiaryDayEntity with _$DiaryDayEntity {
|
|||||||
@JsonKey(name: "chosen_plan_id") required int? chosenPlanId,
|
@JsonKey(name: "chosen_plan_id") required int? chosenPlanId,
|
||||||
@JsonKey(name: "day_of_chosen_diet") required int? dayOfChosenDiet,
|
@JsonKey(name: "day_of_chosen_diet") required int? dayOfChosenDiet,
|
||||||
@JsonKey(name: "chosen_diet", fromJson: parseDiet) required DiaryDietModel? diet,
|
@JsonKey(name: "chosen_diet", fromJson: parseDiet) required DiaryDietModel? diet,
|
||||||
|
|
||||||
|
/// Slug владельца дневника; `null`, если сервер его не вернул.
|
||||||
|
String? ownerSlug,
|
||||||
}) = _DiaryDayEntity;
|
}) = _DiaryDayEntity;
|
||||||
|
|
||||||
const DiaryDayEntity._();
|
const DiaryDayEntity._();
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ abstract class DiaryDayModel with _$DiaryDayModel {
|
|||||||
@JsonKey(name: "chosen_plan_id") required int? chosenPlanId,
|
@JsonKey(name: "chosen_plan_id") required int? chosenPlanId,
|
||||||
@JsonKey(name: "day_of_chosen_diet") required int? dayOfChosenDiet,
|
@JsonKey(name: "day_of_chosen_diet") required int? dayOfChosenDiet,
|
||||||
@JsonKey(name: "chosen_diet", fromJson: parseDiet) required DiaryDietModel? diet,
|
@JsonKey(name: "chosen_diet", fromJson: parseDiet) required DiaryDietModel? diet,
|
||||||
|
|
||||||
|
/// Владелец дневника (`user` в `DiaryResource`): по `slug` консультант
|
||||||
|
/// запрашивает `/api/profile/insights/{slug}`.
|
||||||
|
@JsonKey(name: "user") DiaryOwnerModel? owner,
|
||||||
}) = _DiaryDayModel;
|
}) = _DiaryDayModel;
|
||||||
const DiaryDayModel._();
|
const DiaryDayModel._();
|
||||||
factory DiaryDayModel.fromJson(Map<String, dynamic> json) => _$DiaryDayModelFromJson(json);
|
factory DiaryDayModel.fromJson(Map<String, dynamic> json) => _$DiaryDayModelFromJson(json);
|
||||||
@@ -68,6 +72,7 @@ abstract class DiaryDayModel with _$DiaryDayModel {
|
|||||||
wakeup: wakeup,
|
wakeup: wakeup,
|
||||||
retire: retire,
|
retire: retire,
|
||||||
diet: diet,
|
diet: diet,
|
||||||
|
ownerSlug: owner?.slug,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +86,13 @@ DiaryDietModel? parseDiet(dynamic json) {
|
|||||||
return DiaryDietModel.fromJson(json['diet']);
|
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
|
@freezed
|
||||||
abstract class DiaryDietModel with _$DiaryDietModel {
|
abstract class DiaryDietModel with _$DiaryDietModel {
|
||||||
const factory DiaryDietModel({required int id, required int duration, required String name}) = _DiaryDietModel;
|
const factory DiaryDietModel({required int id, required int duration, required String name}) = _DiaryDietModel;
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ import 'package:eatvio_app/features/diary/presentation/widgets/day_comment_widge
|
|||||||
import 'package:eatvio_app/features/diary/presentation/widgets/finish_day_button_widget.dart';
|
import 'package:eatvio_app/features/diary/presentation/widgets/finish_day_button_widget.dart';
|
||||||
import 'package:eatvio_app/features/diary/presentation/widgets/meal_widget.dart';
|
import 'package:eatvio_app/features/diary/presentation/widgets/meal_widget.dart';
|
||||||
import 'package:eatvio_app/features/diary/presentation/widgets/wakeup_and_retire_widget.dart';
|
import 'package:eatvio_app/features/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:eatvio_app/generated/l10n.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
@@ -36,18 +38,27 @@ class DiaryScreen extends StatelessWidget {
|
|||||||
class UserDiaryScreen extends StatelessWidget {
|
class UserDiaryScreen extends StatelessWidget {
|
||||||
static const path = '/user-diary-screen';
|
static const path = '/user-diary-screen';
|
||||||
static const name = 'userDiaryScreen';
|
static const name = 'userDiaryScreen';
|
||||||
const UserDiaryScreen({super.key, required this.diaryId});
|
const UserDiaryScreen({super.key, required this.diaryId, this.ownerSlug});
|
||||||
final int diaryId;
|
final int diaryId;
|
||||||
|
|
||||||
|
/// Slug владельца дневника (клиента) — нужен консультанту для блока «Аналитика».
|
||||||
|
final String? ownerSlug;
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DiaryScreenContent(diaryId: diaryId);
|
return DiaryScreenContent(diaryId: diaryId, ownerSlug: ownerSlug);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class DiaryScreenContent extends StatefulWidget {
|
class DiaryScreenContent extends StatefulWidget {
|
||||||
const DiaryScreenContent({super.key, this.diaryId, this.date});
|
const DiaryScreenContent({
|
||||||
|
super.key,
|
||||||
|
this.diaryId,
|
||||||
|
this.date,
|
||||||
|
this.ownerSlug,
|
||||||
|
});
|
||||||
final int? diaryId;
|
final int? diaryId;
|
||||||
final DateTime? date;
|
final DateTime? date;
|
||||||
|
final String? ownerSlug;
|
||||||
@override
|
@override
|
||||||
State<DiaryScreenContent> createState() => _DiaryScreenContentState();
|
State<DiaryScreenContent> createState() => _DiaryScreenContentState();
|
||||||
}
|
}
|
||||||
@@ -55,6 +66,16 @@ class DiaryScreenContent extends StatefulWidget {
|
|||||||
class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||||
bool _isDiaryFinishing = false;
|
bool _isDiaryFinishing = false;
|
||||||
|
|
||||||
|
/// Slug владельца: из `user.slug` дневника, иначе из параметра экрана.
|
||||||
|
String? _ownerSlug(DiaryStateLoaded state) {
|
||||||
|
return state.diary.ownerSlug ?? widget.ownerSlug;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Консультант видит аналитику клиента только при известном slug владельца.
|
||||||
|
bool _canShowInsights(DiaryStateLoaded state) {
|
||||||
|
return !state.isConsultantView || _ownerSlug(state) != null;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final s = S.of(context);
|
final s = S.of(context);
|
||||||
@@ -110,6 +131,15 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
|||||||
const SizedBox(height: 60),
|
const SizedBox(height: 60),
|
||||||
const Loader(),
|
const Loader(),
|
||||||
],
|
],
|
||||||
|
if (state is DiaryStateLoaded && _canShowInsights(state))
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(4, 12, 4, 12),
|
||||||
|
child: _DiaryInsights(
|
||||||
|
slug: state.isConsultantView ? _ownerSlug(state) : null,
|
||||||
|
canAdd: !state.isConsultantView,
|
||||||
|
diaryDate: state.diary.date,
|
||||||
|
),
|
||||||
|
),
|
||||||
if (state is DiaryStateLoaded)
|
if (state is DiaryStateLoaded)
|
||||||
WakeupAndRetireWidget(
|
WakeupAndRetireWidget(
|
||||||
isEnabled:
|
isEnabled:
|
||||||
@@ -249,3 +279,53 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Блок «Аналитика» в дневнике. Для своего дневника перезапрашивает данные,
|
||||||
|
/// когда в том же дне меняются приёмы пищи или время подъёма/отбоя: от них
|
||||||
|
/// зависят КБЖУ-графики. Чужой дневник консультант не редактирует.
|
||||||
|
class _DiaryInsights extends StatefulWidget {
|
||||||
|
const _DiaryInsights({
|
||||||
|
required this.slug,
|
||||||
|
required this.canAdd,
|
||||||
|
required this.diaryDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String? slug;
|
||||||
|
final bool canAdd;
|
||||||
|
|
||||||
|
/// Дата открытого дня дневника (`YYYY-MM-DD`) — стартовый день «В течение дня».
|
||||||
|
final String diaryDate;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_DiaryInsights> createState() => _DiaryInsightsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DiaryInsightsState extends State<_DiaryInsights> {
|
||||||
|
int _refreshToken = 0;
|
||||||
|
|
||||||
|
bool _isOwnDiaryMutation(DiaryState previous, DiaryState current) {
|
||||||
|
if (!widget.canAdd ||
|
||||||
|
previous is! DiaryStateLoaded ||
|
||||||
|
current is! DiaryStateLoaded ||
|
||||||
|
previous.diary.id != current.diary.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !listEquals(previous.diary.meals, current.diary.meals) ||
|
||||||
|
previous.diary.wakeup != current.diary.wakeup ||
|
||||||
|
previous.diary.retire != current.diary.retire;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocListener<DiaryCubit, DiaryState>(
|
||||||
|
listenWhen: _isOwnDiaryMutation,
|
||||||
|
listener: (context, state) => setState(() => _refreshToken++),
|
||||||
|
child: InsightsCard(
|
||||||
|
slug: widget.slug,
|
||||||
|
canAdd: widget.canAdd,
|
||||||
|
refreshToken: _refreshToken,
|
||||||
|
initialIntraDate: widget.diaryDate,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
222
lib/features/insights/domain/insights_text_builder.dart
Normal file
222
lib/features/insights/domain/insights_text_builder.dart
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||||
|
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
||||||
|
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
|
|
||||||
|
/// Собирает [InsightsPayloadModel] из сырых точек мобильного API.
|
||||||
|
///
|
||||||
|
/// Зеркало веб-`UserInsightsService`: порог изменения [deltaEpsilon], минимум две
|
||||||
|
/// точки для дельты, те же категории ИМТ и формат подписей.
|
||||||
|
class InsightsTextBuilder {
|
||||||
|
InsightsTextBuilder(this.s);
|
||||||
|
|
||||||
|
final S s;
|
||||||
|
|
||||||
|
static const double deltaEpsilon = 0.05;
|
||||||
|
static const String _emptyHeadline = '—';
|
||||||
|
|
||||||
|
InsightsPayloadModel build({
|
||||||
|
required List<WeightModel> weights,
|
||||||
|
required List<CircumferenceModel> circumferences,
|
||||||
|
required BodyPeriod period,
|
||||||
|
int? heightCm,
|
||||||
|
}) {
|
||||||
|
final sortedWeights = [...weights]
|
||||||
|
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||||
|
final sortedCircumferences = [...circumferences]
|
||||||
|
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||||
|
|
||||||
|
return InsightsPayloadModel(
|
||||||
|
weight: buildWeight(sortedWeights, period),
|
||||||
|
bmi: buildBmi(sortedWeights, period, heightCm),
|
||||||
|
measure: buildMeasure(sortedCircumferences, period),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
WeightChartModel buildWeight(List<WeightModel> weights, BodyPeriod period) {
|
||||||
|
final values = weights.map((e) => e.weight).toList();
|
||||||
|
final last = values.isEmpty ? null : values.last;
|
||||||
|
final delta = values.length < 2 ? null : _round(values.last - values.first);
|
||||||
|
|
||||||
|
return WeightChartModel(
|
||||||
|
labels: weights.map((e) => formatLabel(e.createdAt)).toList(),
|
||||||
|
values: values,
|
||||||
|
last: last,
|
||||||
|
delta: delta,
|
||||||
|
headline: last == null ? _emptyHeadline : last.toStringAsFixed(1),
|
||||||
|
unit: s.insights_unit_kg,
|
||||||
|
deltaText: _weightDeltaText(values, period),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
BmiChartModel buildBmi(
|
||||||
|
List<WeightModel> weights,
|
||||||
|
BodyPeriod period,
|
||||||
|
int? heightCm,
|
||||||
|
) {
|
||||||
|
final hasHeight = heightCm != null && heightCm > 0;
|
||||||
|
if (!hasHeight) {
|
||||||
|
return BmiChartModel(
|
||||||
|
headline: _emptyHeadline,
|
||||||
|
deltaText: s.insights_need_height,
|
||||||
|
hint: s.insights_need_height,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final points = weights.where((e) => e.bmi != null).toList();
|
||||||
|
final values = points.map((e) => e.bmi!).toList();
|
||||||
|
final last = values.isEmpty ? null : values.last;
|
||||||
|
final delta = values.length < 2 ? null : _round(values.last - values.first);
|
||||||
|
final category = last == null ? null : bmiCategory(last);
|
||||||
|
|
||||||
|
return BmiChartModel(
|
||||||
|
labels: points.map((e) => formatLabel(e.createdAt)).toList(),
|
||||||
|
values: values,
|
||||||
|
last: last,
|
||||||
|
delta: delta,
|
||||||
|
headline: last == null ? _emptyHeadline : last.toStringAsFixed(1),
|
||||||
|
category: category,
|
||||||
|
deltaText: _bmiDeltaText(values, period, category),
|
||||||
|
hint: s.insights_bmi_hint(heightCm.toString()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
MeasureChartModel buildMeasure(
|
||||||
|
List<CircumferenceModel> circumferences,
|
||||||
|
BodyPeriod period,
|
||||||
|
) {
|
||||||
|
final waist = circumferences.map((e) => e.waistCircumference).toList();
|
||||||
|
final chest = circumferences.map((e) => e.chestCircumference).toList();
|
||||||
|
final hips = circumferences.map((e) => e.hipCircumference).toList();
|
||||||
|
|
||||||
|
return MeasureChartModel(
|
||||||
|
labels: circumferences.map((e) => formatLabel(e.createdAt)).toList(),
|
||||||
|
waist: waist,
|
||||||
|
chest: chest,
|
||||||
|
hips: hips,
|
||||||
|
headline: waist.isEmpty ? _emptyHeadline : waist.last.round().toString(),
|
||||||
|
deltaText: _measureDeltaText(
|
||||||
|
waist: waist,
|
||||||
|
chest: chest,
|
||||||
|
hips: hips,
|
||||||
|
period: period,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Категория по таблице ВОЗ из постановки #2120.
|
||||||
|
String bmiCategory(double bmi) {
|
||||||
|
if (bmi < 16) {
|
||||||
|
return s.insights_bmi_severe_underweight;
|
||||||
|
}
|
||||||
|
if (bmi < 18.5) {
|
||||||
|
return s.insights_bmi_underweight;
|
||||||
|
}
|
||||||
|
if (bmi < 25) {
|
||||||
|
return s.insights_bmi_normal;
|
||||||
|
}
|
||||||
|
if (bmi < 30) {
|
||||||
|
return s.insights_bmi_overweight;
|
||||||
|
}
|
||||||
|
if (bmi < 35) {
|
||||||
|
return s.insights_bmi_obesity_1;
|
||||||
|
}
|
||||||
|
if (bmi < 40) {
|
||||||
|
return s.insights_bmi_obesity_2;
|
||||||
|
}
|
||||||
|
return s.insights_bmi_obesity_3;
|
||||||
|
}
|
||||||
|
|
||||||
|
String periodLabel(BodyPeriod period) {
|
||||||
|
switch (period) {
|
||||||
|
case BodyPeriod.month:
|
||||||
|
return s.insights_period_label_1;
|
||||||
|
case BodyPeriod.threeMonths:
|
||||||
|
return s.insights_period_label_3;
|
||||||
|
case BodyPeriod.sixMonths:
|
||||||
|
return s.insights_period_label_6;
|
||||||
|
case BodyPeriod.year:
|
||||||
|
return s.insights_period_label_12;
|
||||||
|
case BodyPeriod.all:
|
||||||
|
return s.insights_period_label_all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatLabel(DateTime date) {
|
||||||
|
final local = date.toLocal();
|
||||||
|
final day = local.day.toString().padLeft(2, '0');
|
||||||
|
final month = local.month.toString().padLeft(2, '0');
|
||||||
|
return '$day.$month';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Одна цифра после запятой без хвостового нуля: `3`, `3.5`.
|
||||||
|
String formatNumber(double value) {
|
||||||
|
final text = value.toStringAsFixed(1);
|
||||||
|
return text.endsWith('.0') ? text.substring(0, text.length - 2) : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _weightDeltaText(List<double> values, BodyPeriod period) {
|
||||||
|
if (values.length < 2) {
|
||||||
|
return s.insights_not_enough;
|
||||||
|
}
|
||||||
|
final diff = values.last - values.first;
|
||||||
|
final periodText = periodLabel(period);
|
||||||
|
if (diff.abs() < deltaEpsilon) {
|
||||||
|
return s.insights_weight_same(periodText);
|
||||||
|
}
|
||||||
|
final value = formatNumber(diff.abs());
|
||||||
|
return diff < 0
|
||||||
|
? s.insights_weight_lost(value, periodText)
|
||||||
|
: s.insights_weight_gained(value, periodText);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _bmiDeltaText(
|
||||||
|
List<double> values,
|
||||||
|
BodyPeriod period,
|
||||||
|
String? category,
|
||||||
|
) {
|
||||||
|
if (values.length < 2 || category == null) {
|
||||||
|
return s.insights_not_enough;
|
||||||
|
}
|
||||||
|
final diff = values.last - values.first;
|
||||||
|
final periodText = periodLabel(period);
|
||||||
|
if (diff.abs() < deltaEpsilon) {
|
||||||
|
return s.insights_bmi_same(periodText, category);
|
||||||
|
}
|
||||||
|
final value = formatNumber(diff.abs());
|
||||||
|
return diff < 0
|
||||||
|
? s.insights_bmi_lower(value, periodText, category)
|
||||||
|
: s.insights_bmi_higher(value, periodText, category);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _measureDeltaText({
|
||||||
|
required List<double> waist,
|
||||||
|
required List<double> chest,
|
||||||
|
required List<double> hips,
|
||||||
|
required BodyPeriod period,
|
||||||
|
}) {
|
||||||
|
if (waist.length < 2) {
|
||||||
|
return s.insights_not_enough;
|
||||||
|
}
|
||||||
|
final parts = [
|
||||||
|
_measurePart(s.insights_series_waist, waist),
|
||||||
|
_measurePart(s.insights_series_chest, chest),
|
||||||
|
_measurePart(s.insights_series_hips, hips),
|
||||||
|
];
|
||||||
|
return s.insights_measures_summary(parts.join(', '), periodLabel(period));
|
||||||
|
}
|
||||||
|
|
||||||
|
String _measurePart(String name, List<double> values) {
|
||||||
|
final diff = values.last - values.first;
|
||||||
|
if (diff.abs() < deltaEpsilon) {
|
||||||
|
return s.insights_measure_same(name);
|
||||||
|
}
|
||||||
|
final value = formatNumber(diff.abs());
|
||||||
|
return diff < 0
|
||||||
|
? s.insights_measure_less(name, value)
|
||||||
|
: s.insights_measure_more(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _round(double value) => (value * 10).round() / 10;
|
||||||
|
}
|
||||||
8
lib/features/insights/insights_feature_flags.dart
Normal file
8
lib/features/insights/insights_feature_flags.dart
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/// Флаги поэтапного включения аналитики.
|
||||||
|
abstract final class InsightsFeatureFlags {
|
||||||
|
/// Вкладки «КБЖУ по дням» и «В течение дня». При `true` весь блок берёт
|
||||||
|
/// данные и тексты с сервера (`GET /api/profile/insights*`, eatvio/eatvio#3017),
|
||||||
|
/// при `false` — вкладки скрыты, а вес/ИМТ/замеры собираются на клиенте из
|
||||||
|
/// `/api/profile/measure/*`.
|
||||||
|
static const bool kbjuEnabled = true;
|
||||||
|
}
|
||||||
132
lib/features/insights/models/insights_payload_model.dart
Normal file
132
lib/features/insights/models/insights_payload_model.dart
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'insights_payload_model.freezed.dart';
|
||||||
|
part 'insights_payload_model.g.dart';
|
||||||
|
|
||||||
|
/// Структура повторяет веб-`UserInsightsService::chartPayload()`, чтобы во второй
|
||||||
|
/// итерации заменить только источник данных.
|
||||||
|
@freezed
|
||||||
|
abstract class InsightsPayloadModel with _$InsightsPayloadModel {
|
||||||
|
const factory InsightsPayloadModel({
|
||||||
|
required WeightChartModel weight,
|
||||||
|
required BmiChartModel bmi,
|
||||||
|
required MeasureChartModel measure,
|
||||||
|
DailyChartModel? daily,
|
||||||
|
IntraChartModel? intra,
|
||||||
|
}) = _InsightsPayloadModel;
|
||||||
|
|
||||||
|
factory InsightsPayloadModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$InsightsPayloadModelFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class WeightChartModel with _$WeightChartModel {
|
||||||
|
const factory WeightChartModel({
|
||||||
|
@Default([]) List<String> labels,
|
||||||
|
@Default([]) List<double> values,
|
||||||
|
double? last,
|
||||||
|
double? delta,
|
||||||
|
@Default('') String headline,
|
||||||
|
@Default('') String unit,
|
||||||
|
@Default('') String deltaText,
|
||||||
|
}) = _WeightChartModel;
|
||||||
|
|
||||||
|
factory WeightChartModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$WeightChartModelFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class BmiChartModel with _$BmiChartModel {
|
||||||
|
const factory BmiChartModel({
|
||||||
|
@Default([]) List<String> labels,
|
||||||
|
@Default([]) List<double> values,
|
||||||
|
double? last,
|
||||||
|
double? delta,
|
||||||
|
@Default('') String headline,
|
||||||
|
String? category,
|
||||||
|
@Default('') String deltaText,
|
||||||
|
@Default('') String hint,
|
||||||
|
}) = _BmiChartModel;
|
||||||
|
|
||||||
|
factory BmiChartModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$BmiChartModelFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class MeasureChartModel with _$MeasureChartModel {
|
||||||
|
const factory MeasureChartModel({
|
||||||
|
@Default([]) List<String> labels,
|
||||||
|
@Default([]) List<double> waist,
|
||||||
|
@Default([]) List<double> chest,
|
||||||
|
@Default([]) List<double> hips,
|
||||||
|
@Default('') String headline,
|
||||||
|
@Default('') String deltaText,
|
||||||
|
}) = _MeasureChartModel;
|
||||||
|
|
||||||
|
factory MeasureChartModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$MeasureChartModelFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class DailyChartModel with _$DailyChartModel {
|
||||||
|
const factory DailyChartModel({
|
||||||
|
@Default([]) List<String> labels,
|
||||||
|
@Default([]) List<double> kcal,
|
||||||
|
@Default([]) List<double> proteins,
|
||||||
|
@Default([]) List<double> fats,
|
||||||
|
@Default([]) List<double> carbs,
|
||||||
|
double? average,
|
||||||
|
double? percent,
|
||||||
|
@Default('') String headline,
|
||||||
|
@Default('') String deltaText,
|
||||||
|
}) = _DailyChartModel;
|
||||||
|
|
||||||
|
factory DailyChartModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$DailyChartModelFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class IntraChartModel with _$IntraChartModel {
|
||||||
|
const factory IntraChartModel({
|
||||||
|
@Default([]) List<IntraBarModel> bars,
|
||||||
|
@Default([]) List<ScalePointModel> satiety,
|
||||||
|
@Default([]) List<ScalePointModel> mood,
|
||||||
|
double? wake,
|
||||||
|
double? sleep,
|
||||||
|
String? wakeLabel,
|
||||||
|
String? sleepLabel,
|
||||||
|
String? wakeText,
|
||||||
|
String? sleepText,
|
||||||
|
@Default('') String date,
|
||||||
|
@Default('') String maxDate,
|
||||||
|
@Default('') String dateLabel,
|
||||||
|
@Default(false) bool isToday,
|
||||||
|
}) = _IntraChartModel;
|
||||||
|
|
||||||
|
factory IntraChartModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$IntraChartModelFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Приём пищи: время `x` (часы 0–24) и калории из белков/жиров/углеводов.
|
||||||
|
@freezed
|
||||||
|
abstract class IntraBarModel with _$IntraBarModel {
|
||||||
|
const factory IntraBarModel({
|
||||||
|
required double x,
|
||||||
|
@Default(0) double p,
|
||||||
|
@Default(0) double f,
|
||||||
|
@Default(0) double c,
|
||||||
|
}) = _IntraBarModel;
|
||||||
|
|
||||||
|
factory IntraBarModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$IntraBarModelFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Точка шкалы 0–5 (сытость/настроение) в момент времени `x`.
|
||||||
|
@freezed
|
||||||
|
abstract class ScalePointModel with _$ScalePointModel {
|
||||||
|
const factory ScalePointModel({required double x, required double y}) =
|
||||||
|
_ScalePointModel;
|
||||||
|
|
||||||
|
factory ScalePointModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ScalePointModelFromJson(json);
|
||||||
|
}
|
||||||
46
lib/features/insights/models/insights_period.dart
Normal file
46
lib/features/insights/models/insights_period.dart
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
|
|
||||||
|
/// Период для вкладок «Вес», «ИМТ» и «Замеры». Значение совпадает с `{period}` в
|
||||||
|
/// `/api/profile/measure/weight/{period}`.
|
||||||
|
enum BodyPeriod {
|
||||||
|
month('1'),
|
||||||
|
threeMonths('3'),
|
||||||
|
sixMonths('6'),
|
||||||
|
year('12'),
|
||||||
|
all('all');
|
||||||
|
|
||||||
|
const BodyPeriod(this.apiValue);
|
||||||
|
|
||||||
|
final String apiValue;
|
||||||
|
|
||||||
|
/// Подпись пилюли периода.
|
||||||
|
String pillLabel(S s) => switch (this) {
|
||||||
|
month => s.insights_period_1m,
|
||||||
|
threeMonths => s.insights_period_3m,
|
||||||
|
sixMonths => s.insights_period_6m,
|
||||||
|
year => s.insights_period_1y,
|
||||||
|
all => s.insights_period_all,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Период для вкладки «КБЖУ по дням». Значение совпадает с `daily_period` в `/api/profile/insights`.
|
||||||
|
enum DailyPeriod {
|
||||||
|
week('7'),
|
||||||
|
twoWeeks('14'),
|
||||||
|
month('30'),
|
||||||
|
threeMonths('90'),
|
||||||
|
all('all');
|
||||||
|
|
||||||
|
const DailyPeriod(this.apiValue);
|
||||||
|
|
||||||
|
final String apiValue;
|
||||||
|
|
||||||
|
/// Подпись пилюли периода.
|
||||||
|
String pillLabel(S s) => switch (this) {
|
||||||
|
week => s.insights_period_1w,
|
||||||
|
twoWeeks => s.insights_period_2w,
|
||||||
|
month => s.insights_period_1m,
|
||||||
|
threeMonths => s.insights_period_3m,
|
||||||
|
all => s.insights_period_all,
|
||||||
|
};
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
91
lib/features/insights/presentation/charts/bmi_chart.dart
Normal file
91
lib/features/insights/presentation/charts/bmi_chart.dart
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||||
|
|
||||||
|
/// График ИМТ с цветными зонами: норма 18.5–25, избыточная масса 25–30, ожирение 30–40.
|
||||||
|
class BmiChart extends StatelessWidget {
|
||||||
|
const BmiChart({super.key, required this.model});
|
||||||
|
|
||||||
|
final BmiChartModel model;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final points = buildPoints(model.labels, model.values);
|
||||||
|
if (points.isEmpty) {
|
||||||
|
return InsightsChartPlaceholder(text: model.deltaText);
|
||||||
|
}
|
||||||
|
final s = S.of(context);
|
||||||
|
final dataRange = paddedRange(points.map((e) => e.value), minPadding: 2);
|
||||||
|
// Ось всегда покрывает зоны нормы/избытка/ожирения, иначе при ИМТ ≈30
|
||||||
|
// зелёная полоса 18.5–25 уезжает за нижний край графика.
|
||||||
|
const zoneMin = 18.5;
|
||||||
|
const zoneMax = 40.0;
|
||||||
|
final min = dataRange.min < zoneMin ? dataRange.min : zoneMin;
|
||||||
|
final max = dataRange.max > zoneMax ? dataRange.max : zoneMax;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: insightsChartHeight,
|
||||||
|
child: SfCartesianChart(
|
||||||
|
key: const Key('insights_bmi_chart'),
|
||||||
|
margin: insightsChartMargin,
|
||||||
|
plotAreaBorderWidth: 0,
|
||||||
|
primaryXAxis: buildInsightsIndexAxis(model.labels),
|
||||||
|
primaryYAxis: buildInsightsValueAxis(
|
||||||
|
unit: s.insights_unit_bmi,
|
||||||
|
min: min,
|
||||||
|
max: max,
|
||||||
|
plotBands: const [
|
||||||
|
PlotBand(
|
||||||
|
start: 18.5,
|
||||||
|
end: 25,
|
||||||
|
color: InsightsColors.bmiNormalZone,
|
||||||
|
),
|
||||||
|
PlotBand(
|
||||||
|
start: 25,
|
||||||
|
end: 30,
|
||||||
|
color: InsightsColors.bmiOverweightZone,
|
||||||
|
),
|
||||||
|
PlotBand(
|
||||||
|
start: 30,
|
||||||
|
end: 40,
|
||||||
|
color: InsightsColors.bmiObesityZone,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trackballBehavior: buildInsightsTrackball(),
|
||||||
|
series: <CartesianSeries<InsightsPoint, num>>[
|
||||||
|
SplineAreaSeries<InsightsPoint, num>(
|
||||||
|
name: s.insights_tab_bmi,
|
||||||
|
dataSource: points,
|
||||||
|
xValueMapper: (point, _) => point.index,
|
||||||
|
yValueMapper: (point, _) => point.value,
|
||||||
|
splineType: insightsSplineType,
|
||||||
|
borderColor: InsightsColors.blue,
|
||||||
|
borderWidth: 3,
|
||||||
|
gradient: buildInsightsAreaGradient(InsightsColors.blue),
|
||||||
|
markerSettings: buildInsightsMarker(InsightsColors.blue),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (model.hint.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
model.hint,
|
||||||
|
key: const Key('insights_bmi_hint'),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: InsightsColors.subtitle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
135
lib/features/insights/presentation/charts/daily_kbju_chart.dart
Normal file
135
lib/features/insights/presentation/charts/daily_kbju_chart.dart
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||||
|
|
||||||
|
/// КБЖУ по дням: калории — площадь на левой оси («ккал»), Б/Ж/У — линии на
|
||||||
|
/// правой оси («граммы»). [visibility] — Калории, Белки, Жиры, Углеводы.
|
||||||
|
class DailyKbjuChart extends StatelessWidget {
|
||||||
|
const DailyKbjuChart({
|
||||||
|
super.key,
|
||||||
|
required this.model,
|
||||||
|
required this.visibility,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DailyChartModel model;
|
||||||
|
final List<bool> visibility;
|
||||||
|
|
||||||
|
static const String _gramsAxisName = 'grams';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final s = S.of(context);
|
||||||
|
final calories = buildPoints(model.labels, model.kcal);
|
||||||
|
if (calories.isEmpty) {
|
||||||
|
return InsightsChartPlaceholder(text: s.insights_not_enough);
|
||||||
|
}
|
||||||
|
final proteins = buildPoints(model.labels, model.proteins);
|
||||||
|
final fats = buildPoints(model.labels, model.fats);
|
||||||
|
final carbs = buildPoints(model.labels, model.carbs);
|
||||||
|
|
||||||
|
// Локальный диапазон по данным (как на вебе), не ось от нуля.
|
||||||
|
// Кривую ниже точек держит insightsSplineType (monotonic).
|
||||||
|
final kcalRange = paddedRange(
|
||||||
|
calories.map((e) => e.value),
|
||||||
|
minPadding: 50,
|
||||||
|
clampZero: true,
|
||||||
|
);
|
||||||
|
final gramValues = [
|
||||||
|
...proteins.map((e) => e.value),
|
||||||
|
...fats.map((e) => e.value),
|
||||||
|
...carbs.map((e) => e.value),
|
||||||
|
];
|
||||||
|
final gramsRange = paddedRange(
|
||||||
|
gramValues,
|
||||||
|
minPadding: 5,
|
||||||
|
clampZero: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
final grams = [
|
||||||
|
(
|
||||||
|
name: s.insights_series_proteins,
|
||||||
|
color: InsightsColors.proteins,
|
||||||
|
points: proteins,
|
||||||
|
),
|
||||||
|
(name: s.insights_series_fats, color: InsightsColors.fats, points: fats),
|
||||||
|
(
|
||||||
|
name: s.insights_series_carbs,
|
||||||
|
color: InsightsColors.carbs,
|
||||||
|
points: carbs,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: insightsChartHeight,
|
||||||
|
child: SfCartesianChart(
|
||||||
|
key: const Key('insights_daily_chart'),
|
||||||
|
margin: insightsChartMargin,
|
||||||
|
plotAreaBorderWidth: 0,
|
||||||
|
primaryXAxis: buildInsightsIndexAxis(model.labels),
|
||||||
|
primaryYAxis: NumericAxis(
|
||||||
|
title: AxisTitle(
|
||||||
|
text: s.insights_unit_kcal,
|
||||||
|
textStyle: insightsAxisLabelStyle,
|
||||||
|
),
|
||||||
|
labelStyle: insightsAxisLabelStyle,
|
||||||
|
minimum: kcalRange.min,
|
||||||
|
maximum: kcalRange.max,
|
||||||
|
majorTickLines: const MajorTickLines(size: 0),
|
||||||
|
axisLine: const AxisLine(width: 0),
|
||||||
|
majorGridLines: MajorGridLines(
|
||||||
|
color: InsightsColors.border.withValues(alpha: 0.8),
|
||||||
|
dashArray: const [4, 4],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
axes: <ChartAxis>[
|
||||||
|
NumericAxis(
|
||||||
|
name: _gramsAxisName,
|
||||||
|
opposedPosition: true,
|
||||||
|
minimum: gramsRange.min,
|
||||||
|
maximum: gramsRange.max,
|
||||||
|
title: AxisTitle(
|
||||||
|
text: s.insights_unit_grams,
|
||||||
|
textStyle: insightsAxisLabelStyle,
|
||||||
|
),
|
||||||
|
labelStyle: insightsAxisLabelStyle,
|
||||||
|
majorTickLines: const MajorTickLines(size: 0),
|
||||||
|
axisLine: const AxisLine(width: 0),
|
||||||
|
majorGridLines: const MajorGridLines(width: 0),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
trackballBehavior: buildInsightsTrackball(),
|
||||||
|
series: <CartesianSeries<InsightsPoint, num>>[
|
||||||
|
if (_isVisible(0))
|
||||||
|
SplineAreaSeries<InsightsPoint, num>(
|
||||||
|
name: s.insights_series_calories,
|
||||||
|
dataSource: calories,
|
||||||
|
xValueMapper: (point, _) => point.index,
|
||||||
|
yValueMapper: (point, _) => point.value,
|
||||||
|
splineType: insightsSplineType,
|
||||||
|
borderColor: InsightsColors.calories,
|
||||||
|
borderWidth: 3,
|
||||||
|
gradient: buildInsightsAreaGradient(InsightsColors.calories),
|
||||||
|
),
|
||||||
|
for (var i = 0; i < grams.length; i++)
|
||||||
|
if (_isVisible(i + 1))
|
||||||
|
SplineSeries<InsightsPoint, num>(
|
||||||
|
name: grams[i].name,
|
||||||
|
yAxisName: _gramsAxisName,
|
||||||
|
dataSource: grams[i].points,
|
||||||
|
xValueMapper: (point, _) => point.index,
|
||||||
|
yValueMapper: (point, _) => point.value,
|
||||||
|
splineType: insightsSplineType,
|
||||||
|
color: grams[i].color,
|
||||||
|
width: 2.5,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isVisible(int index) =>
|
||||||
|
index < visibility.length ? visibility[index] : true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||||
|
|
||||||
|
/// Точка графика: индекс по оси X (как у Chart.js), подпись `dd.MM` и значение.
|
||||||
|
///
|
||||||
|
/// Индекс обязателен: Syncfusion `CategoryAxis` склеивает одинаковые строки
|
||||||
|
/// (`05.07` несколько раз → одна категория). На вебе labels идут по индексу,
|
||||||
|
/// поэтому здесь X = index, а дата рисуется через [axisLabelFormatter].
|
||||||
|
class InsightsPoint {
|
||||||
|
const InsightsPoint({
|
||||||
|
required this.index,
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int index;
|
||||||
|
final String label;
|
||||||
|
final double value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Склеивает `labels[]` и `values[]` (по минимальной длине).
|
||||||
|
List<InsightsPoint> buildPoints(List<String> labels, List<double> values) {
|
||||||
|
final count = math.min(labels.length, values.length);
|
||||||
|
return [
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
InsightsPoint(index: i, label: labels[i], value: values[i]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Локальный диапазон оси Y вокруг данных (не от нуля), с запасом под сплайн.
|
||||||
|
///
|
||||||
|
/// [clampZero] — не уводить минимум в минус на осях КБЖУ (ккал/граммы ≥ 0).
|
||||||
|
({double min, double max}) paddedRange(
|
||||||
|
Iterable<double> values, {
|
||||||
|
double minPadding = 1,
|
||||||
|
bool clampZero = false,
|
||||||
|
}) {
|
||||||
|
if (values.isEmpty) {
|
||||||
|
return (min: 0, max: 1);
|
||||||
|
}
|
||||||
|
final lowest = values.reduce(math.min);
|
||||||
|
final highest = values.reduce(math.max);
|
||||||
|
final padding = math.max((highest - lowest) * 0.2, minPadding);
|
||||||
|
var min = lowest - padding;
|
||||||
|
var max = highest + padding;
|
||||||
|
if (clampZero) {
|
||||||
|
min = math.max(0, min);
|
||||||
|
}
|
||||||
|
if (max <= min) {
|
||||||
|
max = min + minPadding;
|
||||||
|
}
|
||||||
|
return (min: min, max: max);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const SplineType insightsSplineType = SplineType.monotonic;
|
||||||
|
|
||||||
|
const TextStyle insightsAxisLabelStyle = TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: InsightsColors.subtitle,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Ось X по индексу точки; подпись — дата из [labels].
|
||||||
|
///
|
||||||
|
/// Так даты `05.07`, `06.07`, … не схлопываются и не размножаются, когда на
|
||||||
|
/// графике несколько серий с одним и тем же набором labels (КБЖУ, замеры).
|
||||||
|
NumericAxis buildInsightsIndexAxis(List<String> labels) {
|
||||||
|
final lastIndex = math.max(labels.length - 1, 0).toDouble();
|
||||||
|
// На узком экране не подписываем каждую точку: ~6 подписей, как авто-тики Chart.js.
|
||||||
|
final interval = labels.length <= 7
|
||||||
|
? 1.0
|
||||||
|
: (labels.length / 6).ceilToDouble();
|
||||||
|
|
||||||
|
return NumericAxis(
|
||||||
|
minimum: 0,
|
||||||
|
maximum: lastIndex,
|
||||||
|
interval: interval,
|
||||||
|
labelStyle: insightsAxisLabelStyle,
|
||||||
|
majorGridLines: const MajorGridLines(width: 0),
|
||||||
|
majorTickLines: const MajorTickLines(size: 0),
|
||||||
|
axisLine: const AxisLine(color: InsightsColors.border, width: 1),
|
||||||
|
edgeLabelPlacement: EdgeLabelPlacement.shift,
|
||||||
|
axisLabelFormatter: (details) {
|
||||||
|
final index = details.value.round();
|
||||||
|
if (index < 0 || index >= labels.length) {
|
||||||
|
return ChartAxisLabel('', details.textStyle);
|
||||||
|
}
|
||||||
|
// Промежуточные дробные тики (если Syncfusion их поставит) не подписываем.
|
||||||
|
if ((details.value - index).abs() > 0.01) {
|
||||||
|
return ChartAxisLabel('', details.textStyle);
|
||||||
|
}
|
||||||
|
return ChartAxisLabel(labels[index], insightsAxisLabelStyle);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
NumericAxis buildInsightsValueAxis({
|
||||||
|
required String unit,
|
||||||
|
required double min,
|
||||||
|
required double max,
|
||||||
|
List<PlotBand> plotBands = const [],
|
||||||
|
}) {
|
||||||
|
return NumericAxis(
|
||||||
|
minimum: min,
|
||||||
|
maximum: max,
|
||||||
|
// Доп. воздух, чтобы сплайн не врезался в край plot area.
|
||||||
|
rangePadding: ChartRangePadding.additional,
|
||||||
|
labelFormat: '{value} $unit',
|
||||||
|
labelStyle: insightsAxisLabelStyle,
|
||||||
|
majorTickLines: const MajorTickLines(size: 0),
|
||||||
|
axisLine: const AxisLine(width: 0),
|
||||||
|
majorGridLines: MajorGridLines(
|
||||||
|
color: InsightsColors.border.withValues(alpha: 0.8),
|
||||||
|
dashArray: const [4, 4],
|
||||||
|
),
|
||||||
|
plotBands: plotBands,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Небольшой внутренний отступ, чтобы кривая и подписи не резались контейнером.
|
||||||
|
const EdgeInsets insightsChartMargin = EdgeInsets.fromLTRB(0, 8, 0, 4);
|
||||||
|
|
||||||
|
TrackballBehavior buildInsightsTrackball() {
|
||||||
|
return TrackballBehavior(
|
||||||
|
enable: true,
|
||||||
|
activationMode: ActivationMode.singleTap,
|
||||||
|
tooltipDisplayMode: TrackballDisplayMode.groupAllPoints,
|
||||||
|
lineType: TrackballLineType.vertical,
|
||||||
|
tooltipSettings: const InteractiveTooltip(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
MarkerSettings buildInsightsMarker(Color color) {
|
||||||
|
return MarkerSettings(
|
||||||
|
isVisible: true,
|
||||||
|
width: 4,
|
||||||
|
height: 4,
|
||||||
|
color: Colors.white,
|
||||||
|
borderColor: color,
|
||||||
|
borderWidth: 2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
LinearGradient buildInsightsAreaGradient(Color color) {
|
||||||
|
return LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [color.withValues(alpha: 0.18), color.withValues(alpha: 0)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Пустое состояние графика (нет данных).
|
||||||
|
class InsightsChartPlaceholder extends StatelessWidget {
|
||||||
|
const InsightsChartPlaceholder({super.key, required this.text});
|
||||||
|
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
height: insightsChartHeight,
|
||||||
|
child: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: InsightsColors.subtitle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
214
lib/features/insights/presentation/charts/intra_day_chart.dart
Normal file
214
lib/features/insights/presentation/charts/intra_day_chart.dart
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||||
|
|
||||||
|
/// График «В течение дня»: столбики Б/Ж/У (ккал) по приёмам пищи на оси 0–24 ч,
|
||||||
|
/// линии сытости и настроения (шкала 0–5) и затемнение времени сна.
|
||||||
|
///
|
||||||
|
/// [visibility] — Белки, Жиры, Углеводы, Сытость, Настроение.
|
||||||
|
class IntraDayChart extends StatelessWidget {
|
||||||
|
const IntraDayChart({
|
||||||
|
super.key,
|
||||||
|
required this.model,
|
||||||
|
required this.visibility,
|
||||||
|
required this.showSleep,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IntraChartModel model;
|
||||||
|
final List<bool> visibility;
|
||||||
|
final bool showSleep;
|
||||||
|
|
||||||
|
static const String _scaleAxisName = 'scale';
|
||||||
|
static const double _barWidthPx = 26;
|
||||||
|
static const double _axesReservedWidthPx = 80;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final s = S.of(context);
|
||||||
|
if (model.bars.isEmpty &&
|
||||||
|
model.satiety.isEmpty &&
|
||||||
|
model.mood.isEmpty &&
|
||||||
|
model.wake == null &&
|
||||||
|
model.sleep == null) {
|
||||||
|
return InsightsChartPlaceholder(text: s.insights_not_enough);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: insightsChartHeight,
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final plotWidth = (constraints.maxWidth - _axesReservedWidthPx).clamp(
|
||||||
|
120.0,
|
||||||
|
double.infinity,
|
||||||
|
);
|
||||||
|
final barFraction = (_barWidthPx / (plotWidth / 24)).clamp(0.1, 1.0);
|
||||||
|
return SfCartesianChart(
|
||||||
|
key: const Key('insights_intra_chart'),
|
||||||
|
margin: insightsChartMargin,
|
||||||
|
plotAreaBorderWidth: 0,
|
||||||
|
primaryXAxis: NumericAxis(
|
||||||
|
minimum: 0,
|
||||||
|
maximum: 24,
|
||||||
|
interval: 2,
|
||||||
|
labelStyle: insightsAxisLabelStyle,
|
||||||
|
majorGridLines: const MajorGridLines(width: 0),
|
||||||
|
majorTickLines: const MajorTickLines(size: 0),
|
||||||
|
axisLine: const AxisLine(color: InsightsColors.border, width: 1),
|
||||||
|
axisLabelFormatter: (details) => ChartAxisLabel(
|
||||||
|
'${details.value.toInt().toString().padLeft(2, '0')}:00',
|
||||||
|
details.textStyle,
|
||||||
|
),
|
||||||
|
plotBands: _sleepBands(),
|
||||||
|
),
|
||||||
|
primaryYAxis: NumericAxis(
|
||||||
|
minimum: 0,
|
||||||
|
title: AxisTitle(
|
||||||
|
text: s.insights_unit_kcal_per_meal,
|
||||||
|
textStyle: insightsAxisLabelStyle,
|
||||||
|
),
|
||||||
|
labelStyle: insightsAxisLabelStyle,
|
||||||
|
majorTickLines: const MajorTickLines(size: 0),
|
||||||
|
axisLine: const AxisLine(width: 0),
|
||||||
|
majorGridLines: MajorGridLines(
|
||||||
|
color: InsightsColors.border.withValues(alpha: 0.8),
|
||||||
|
dashArray: const [4, 4],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
axes: const <ChartAxis>[
|
||||||
|
NumericAxis(
|
||||||
|
name: _scaleAxisName,
|
||||||
|
opposedPosition: true,
|
||||||
|
minimum: 0,
|
||||||
|
maximum: 5,
|
||||||
|
interval: 1,
|
||||||
|
labelStyle: insightsAxisLabelStyle,
|
||||||
|
majorTickLines: MajorTickLines(size: 0),
|
||||||
|
axisLine: AxisLine(width: 0),
|
||||||
|
majorGridLines: MajorGridLines(width: 0),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
trackballBehavior: buildInsightsTrackball(),
|
||||||
|
series: <CartesianSeries<dynamic, double>>[
|
||||||
|
if (_isVisible(0))
|
||||||
|
_barSeries(
|
||||||
|
s.insights_series_proteins,
|
||||||
|
InsightsColors.proteins,
|
||||||
|
(bar) => bar.p,
|
||||||
|
barFraction,
|
||||||
|
),
|
||||||
|
if (_isVisible(1))
|
||||||
|
_barSeries(
|
||||||
|
s.insights_series_fats,
|
||||||
|
InsightsColors.fats,
|
||||||
|
(bar) => bar.f,
|
||||||
|
barFraction,
|
||||||
|
),
|
||||||
|
if (_isVisible(2))
|
||||||
|
_barSeries(
|
||||||
|
s.insights_series_carbs,
|
||||||
|
InsightsColors.carbs,
|
||||||
|
(bar) => bar.c,
|
||||||
|
barFraction,
|
||||||
|
borderRadius: const BorderRadius.vertical(
|
||||||
|
top: Radius.circular(6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isVisible(3) && model.satiety.isNotEmpty)
|
||||||
|
_scaleSeries(
|
||||||
|
s.insights_series_satiety,
|
||||||
|
InsightsColors.satiety,
|
||||||
|
model.satiety,
|
||||||
|
),
|
||||||
|
if (_isVisible(4) && model.mood.isNotEmpty)
|
||||||
|
_scaleSeries(
|
||||||
|
s.insights_series_mood,
|
||||||
|
InsightsColors.mood,
|
||||||
|
model.mood,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isVisible(int index) =>
|
||||||
|
index < visibility.length ? visibility[index] : true;
|
||||||
|
|
||||||
|
CartesianSeries<IntraBarModel, double> _barSeries(
|
||||||
|
String name,
|
||||||
|
Color color,
|
||||||
|
double Function(IntraBarModel bar) valueOf,
|
||||||
|
double widthFraction, {
|
||||||
|
BorderRadius borderRadius = BorderRadius.zero,
|
||||||
|
}) {
|
||||||
|
return StackedColumnSeries<IntraBarModel, double>(
|
||||||
|
name: name,
|
||||||
|
dataSource: model.bars,
|
||||||
|
xValueMapper: (bar, _) => bar.x,
|
||||||
|
yValueMapper: (bar, _) => valueOf(bar),
|
||||||
|
color: color,
|
||||||
|
width: widthFraction,
|
||||||
|
spacing: 0,
|
||||||
|
borderRadius: borderRadius,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
CartesianSeries<ScalePointModel, double> _scaleSeries(
|
||||||
|
String name,
|
||||||
|
Color color,
|
||||||
|
List<ScalePointModel> points,
|
||||||
|
) {
|
||||||
|
return SplineSeries<ScalePointModel, double>(
|
||||||
|
name: name,
|
||||||
|
yAxisName: _scaleAxisName,
|
||||||
|
dataSource: points,
|
||||||
|
xValueMapper: (point, _) => point.x,
|
||||||
|
yValueMapper: (point, _) => point.y,
|
||||||
|
splineType: insightsSplineType,
|
||||||
|
color: color,
|
||||||
|
width: 2.5,
|
||||||
|
markerSettings: MarkerSettings(
|
||||||
|
isVisible: true,
|
||||||
|
width: 3,
|
||||||
|
height: 3,
|
||||||
|
color: color,
|
||||||
|
borderColor: color,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Затемнение `[0, wake]` и `[sleep, 24]` плюс пунктиры подъёма и отхода ко сну.
|
||||||
|
List<PlotBand> _sleepBands() {
|
||||||
|
if (!showSleep) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
final wake = model.wake;
|
||||||
|
final sleep = model.sleep;
|
||||||
|
return [
|
||||||
|
if (wake != null && wake > 0)
|
||||||
|
PlotBand(start: 0, end: wake, color: InsightsColors.sleepShade),
|
||||||
|
if (sleep != null && sleep < 24 && (wake == null || sleep > wake))
|
||||||
|
PlotBand(start: sleep, end: 24, color: InsightsColors.sleepShade),
|
||||||
|
if (wake != null)
|
||||||
|
PlotBand(
|
||||||
|
start: wake,
|
||||||
|
end: wake,
|
||||||
|
borderColor: InsightsColors.wake,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
dashArray: const [5, 4],
|
||||||
|
),
|
||||||
|
if (sleep != null)
|
||||||
|
PlotBand(
|
||||||
|
start: sleep,
|
||||||
|
end: sleep,
|
||||||
|
borderColor: InsightsColors.sleepLine,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
dashArray: const [5, 4],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
87
lib/features/insights/presentation/charts/measure_chart.dart
Normal file
87
lib/features/insights/presentation/charts/measure_chart.dart
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||||
|
|
||||||
|
/// Три линии: талия, грудь, бёдра. [visibility] — видимость серий в этом порядке.
|
||||||
|
class MeasureChart extends StatelessWidget {
|
||||||
|
const MeasureChart({
|
||||||
|
super.key,
|
||||||
|
required this.model,
|
||||||
|
required this.visibility,
|
||||||
|
required this.emptyText,
|
||||||
|
});
|
||||||
|
|
||||||
|
final MeasureChartModel model;
|
||||||
|
final List<bool> visibility;
|
||||||
|
final String emptyText;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final waist = buildPoints(model.labels, model.waist);
|
||||||
|
final chest = buildPoints(model.labels, model.chest);
|
||||||
|
final hips = buildPoints(model.labels, model.hips);
|
||||||
|
if (waist.isEmpty && chest.isEmpty && hips.isEmpty) {
|
||||||
|
return InsightsChartPlaceholder(text: emptyText);
|
||||||
|
}
|
||||||
|
final s = S.of(context);
|
||||||
|
final series = [
|
||||||
|
(
|
||||||
|
name: s.insights_series_waist,
|
||||||
|
color: InsightsColors.waist,
|
||||||
|
points: waist,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
name: s.insights_series_chest,
|
||||||
|
color: InsightsColors.chest,
|
||||||
|
points: chest,
|
||||||
|
),
|
||||||
|
(name: s.insights_series_hips, color: InsightsColors.hips, points: hips),
|
||||||
|
];
|
||||||
|
final visibleValues = <double>[
|
||||||
|
for (var i = 0; i < series.length; i++)
|
||||||
|
if (_isVisible(i)) ...series[i].points.map((e) => e.value),
|
||||||
|
];
|
||||||
|
final range = paddedRange(
|
||||||
|
visibleValues.isEmpty
|
||||||
|
? series.expand((e) => e.points).map((e) => e.value)
|
||||||
|
: visibleValues,
|
||||||
|
minPadding: 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: insightsChartHeight,
|
||||||
|
child: SfCartesianChart(
|
||||||
|
key: const Key('insights_measure_chart'),
|
||||||
|
margin: insightsChartMargin,
|
||||||
|
plotAreaBorderWidth: 0,
|
||||||
|
primaryXAxis: buildInsightsIndexAxis(model.labels),
|
||||||
|
primaryYAxis: buildInsightsValueAxis(
|
||||||
|
unit: s.insights_unit_cm,
|
||||||
|
min: range.min,
|
||||||
|
max: range.max,
|
||||||
|
),
|
||||||
|
trackballBehavior: buildInsightsTrackball(),
|
||||||
|
series: <CartesianSeries<InsightsPoint, num>>[
|
||||||
|
for (var i = 0; i < series.length; i++)
|
||||||
|
if (_isVisible(i))
|
||||||
|
SplineSeries<InsightsPoint, num>(
|
||||||
|
name: series[i].name,
|
||||||
|
dataSource: series[i].points,
|
||||||
|
xValueMapper: (point, _) => point.index,
|
||||||
|
yValueMapper: (point, _) => point.value,
|
||||||
|
splineType: insightsSplineType,
|
||||||
|
color: series[i].color,
|
||||||
|
width: 3,
|
||||||
|
markerSettings: buildInsightsMarker(series[i].color),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isVisible(int index) =>
|
||||||
|
index < visibility.length ? visibility[index] : true;
|
||||||
|
}
|
||||||
54
lib/features/insights/presentation/charts/weight_chart.dart
Normal file
54
lib/features/insights/presentation/charts/weight_chart.dart
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||||
|
|
||||||
|
class WeightChart extends StatelessWidget {
|
||||||
|
const WeightChart({super.key, required this.model, required this.emptyText});
|
||||||
|
|
||||||
|
final WeightChartModel model;
|
||||||
|
|
||||||
|
/// Текст при отсутствии точек («Добавьте Ваш вес»).
|
||||||
|
final String emptyText;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final points = buildPoints(model.labels, model.values);
|
||||||
|
if (points.isEmpty) {
|
||||||
|
return InsightsChartPlaceholder(text: emptyText);
|
||||||
|
}
|
||||||
|
final s = S.of(context);
|
||||||
|
final range = paddedRange(points.map((e) => e.value));
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: insightsChartHeight,
|
||||||
|
child: SfCartesianChart(
|
||||||
|
key: const Key('insights_weight_chart'),
|
||||||
|
margin: insightsChartMargin,
|
||||||
|
plotAreaBorderWidth: 0,
|
||||||
|
primaryXAxis: buildInsightsIndexAxis(model.labels),
|
||||||
|
primaryYAxis: buildInsightsValueAxis(
|
||||||
|
unit: s.insights_unit_kg,
|
||||||
|
min: range.min,
|
||||||
|
max: range.max,
|
||||||
|
),
|
||||||
|
trackballBehavior: buildInsightsTrackball(),
|
||||||
|
series: <CartesianSeries<InsightsPoint, num>>[
|
||||||
|
SplineAreaSeries<InsightsPoint, num>(
|
||||||
|
name: s.insights_tab_weight,
|
||||||
|
dataSource: points,
|
||||||
|
xValueMapper: (point, _) => point.index,
|
||||||
|
yValueMapper: (point, _) => point.value,
|
||||||
|
splineType: insightsSplineType,
|
||||||
|
borderColor: InsightsColors.blue,
|
||||||
|
borderWidth: 3,
|
||||||
|
gradient: buildInsightsAreaGradient(InsightsColors.blue),
|
||||||
|
markerSettings: buildInsightsMarker(InsightsColors.blue),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
400
lib/features/insights/presentation/cubit/insights_cubit.dart
Normal file
400
lib/features/insights/presentation/cubit/insights_cubit.dart
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/insights_feature_flags.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/cubit/insights_panel.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/cubit/insights_state.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
class InsightsCubit extends Cubit<InsightsState> {
|
||||||
|
InsightsCubit({
|
||||||
|
required this.repository,
|
||||||
|
this.slug,
|
||||||
|
this.canAdd = false,
|
||||||
|
this.kbjuEnabled = InsightsFeatureFlags.kbjuEnabled,
|
||||||
|
}) : super(const InsightsState.initial());
|
||||||
|
|
||||||
|
final InsightsRepository repository;
|
||||||
|
|
||||||
|
/// `null` — собственные данные, иначе slug владельца (консультант → клиент).
|
||||||
|
final String? slug;
|
||||||
|
|
||||||
|
/// Показывать ли кнопку «+» (только владелец).
|
||||||
|
final bool canAdd;
|
||||||
|
|
||||||
|
final bool kbjuEnabled;
|
||||||
|
|
||||||
|
bool _isHeightLoaded = false;
|
||||||
|
int? _heightCm;
|
||||||
|
|
||||||
|
/// Полный payload и вкладка «В течение дня» координируются отдельно: смена
|
||||||
|
/// дня не должна отменять загрузку периода, и наоборот.
|
||||||
|
int _fullRequestId = 0;
|
||||||
|
int _intraRequestId = 0;
|
||||||
|
int _inflightFull = 0;
|
||||||
|
int _inflightIntra = 0;
|
||||||
|
|
||||||
|
List<InsightsPanel> get panels =>
|
||||||
|
InsightsPanel.available(kbjuEnabled: kbjuEnabled);
|
||||||
|
|
||||||
|
bool get _isBusy => _inflightFull > 0 || _inflightIntra > 0;
|
||||||
|
|
||||||
|
Future<void> load({String? intraDate}) async {
|
||||||
|
emit(const InsightsState.loading());
|
||||||
|
await _fetch(
|
||||||
|
bodyPeriod: BodyPeriod.threeMonths,
|
||||||
|
dailyPeriod: DailyPeriod.twoWeeks,
|
||||||
|
intraDate: intraDate,
|
||||||
|
isInitial: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Перезапрашивает данные, сохраняя вкладку, период, серии и раскрытие.
|
||||||
|
Future<void> refresh() async {
|
||||||
|
final current = state;
|
||||||
|
if (current is InsightsStateLoading || current is InsightsStateHidden) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (current is! InsightsStateLoaded) {
|
||||||
|
return load();
|
||||||
|
}
|
||||||
|
await _fetch(
|
||||||
|
bodyPeriod: current.bodyPeriod,
|
||||||
|
dailyPeriod: current.dailyPeriod,
|
||||||
|
intraDate: current.intraDate,
|
||||||
|
isInitial: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setBodyPeriod(BodyPeriod period) async {
|
||||||
|
final current = state;
|
||||||
|
if (current is! InsightsStateLoaded || current.bodyPeriod == period) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit(current.copyWith(bodyPeriod: period, isRefreshing: true));
|
||||||
|
await _fetch(
|
||||||
|
bodyPeriod: period,
|
||||||
|
dailyPeriod: current.dailyPeriod,
|
||||||
|
intraDate: current.intraDate,
|
||||||
|
isInitial: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Период вкладки «КБЖУ по дням». Работает только с серверными данными.
|
||||||
|
Future<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,
|
||||||
|
isInitial: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Показывает вкладку «В течение дня» за [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 ||
|
||||||
|
!_canGoToNextDay(current, base)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await setIntraDate(DateTime(base.year, base.month, base.day + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// База для листания: сначала оптимистично запрошенная дата, иначе ответ сервера.
|
||||||
|
DateTime? _currentIntraDay() {
|
||||||
|
final current = state;
|
||||||
|
if (!kbjuEnabled || current is! InsightsStateLoaded) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final optimistic = current.intraDate;
|
||||||
|
if (optimistic != null && optimistic.isNotEmpty) {
|
||||||
|
return DateTime.tryParse(optimistic);
|
||||||
|
}
|
||||||
|
return DateTime.tryParse(current.payload.intra?.date ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _canGoToNextDay(InsightsStateLoaded current, DateTime base) {
|
||||||
|
final maxRaw =
|
||||||
|
current.payload.intra?.maxDate ?? current.payload.intra?.date ?? '';
|
||||||
|
final maxDate = DateTime.tryParse(maxRaw);
|
||||||
|
if (maxDate == null) {
|
||||||
|
return !(current.payload.intra?.isToday ?? true);
|
||||||
|
}
|
||||||
|
final baseDay = DateTime(base.year, base.month, base.day);
|
||||||
|
final maxDay = DateTime(maxDate.year, maxDate.month, maxDate.day);
|
||||||
|
return baseDay.isBefore(maxDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadIntra(String date) async {
|
||||||
|
final current = state;
|
||||||
|
if (!kbjuEnabled || current is! InsightsStateLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final requestId = ++_intraRequestId;
|
||||||
|
_inflightIntra++;
|
||||||
|
final previousIntraDate = current.intraDate;
|
||||||
|
// Оптимистично фиксируем запрошенный день до ответа, чтобы быстрые
|
||||||
|
// нажатия «‹ ›» считали базу от него, а не от устаревшего payload.
|
||||||
|
emit(current.copyWith(intraDate: date, isRefreshing: true));
|
||||||
|
try {
|
||||||
|
final intra = await repository.fetchIntra(slug: slug, intraDate: date);
|
||||||
|
if (requestId != _intraRequestId || isClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final latest = state;
|
||||||
|
if (latest is! InsightsStateLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit(
|
||||||
|
latest.copyWith(
|
||||||
|
payload: latest.payload.copyWith(intra: intra),
|
||||||
|
intraDate: intra.isToday
|
||||||
|
? null
|
||||||
|
: (intra.date.isEmpty ? date : intra.date),
|
||||||
|
isRefreshing: _inflightFull > 0 || _inflightIntra > 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} on InsightsForbidden {
|
||||||
|
if (requestId == _intraRequestId && !isClosed) {
|
||||||
|
emit(const InsightsState.hidden());
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
log(e.toString(), stackTrace: st);
|
||||||
|
if (requestId == _intraRequestId && !isClosed) {
|
||||||
|
final latest = state;
|
||||||
|
if (latest is InsightsStateLoaded) {
|
||||||
|
emit(
|
||||||
|
latest.copyWith(
|
||||||
|
intraDate: previousIntraDate,
|
||||||
|
isRefreshing: _inflightFull > 0 || _inflightIntra > 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_inflightIntra--;
|
||||||
|
_syncRefreshingFlag();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _formatDate(DateTime day) {
|
||||||
|
final month = day.month.toString().padLeft(2, '0');
|
||||||
|
final dayOfMonth = day.day.toString().padLeft(2, '0');
|
||||||
|
return '${day.year.toString().padLeft(4, '0')}-$month-$dayOfMonth';
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggleExpanded() {
|
||||||
|
final current = state;
|
||||||
|
if (current is InsightsStateLoaded) {
|
||||||
|
emit(current.copyWith(isExpanded: !current.isExpanded));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setPanel(InsightsPanel panel) {
|
||||||
|
final current = state;
|
||||||
|
if (current is! InsightsStateLoaded || !panels.contains(panel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit(current.copyWith(panel: panel));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Включает/выключает серию [index] на вкладке [panel].
|
||||||
|
void toggleSeries(InsightsPanel panel, int index) {
|
||||||
|
final current = state;
|
||||||
|
if (current is! InsightsStateLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final visibility = current.seriesVisibility;
|
||||||
|
final source = switch (panel) {
|
||||||
|
InsightsPanel.measures => visibility.measure,
|
||||||
|
InsightsPanel.daily => visibility.daily,
|
||||||
|
InsightsPanel.intra => visibility.intra,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
if (source == null || index < 0 || index >= source.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final updated = [...source]..[index] = !source[index];
|
||||||
|
final nextVisibility = switch (panel) {
|
||||||
|
InsightsPanel.measures => visibility.copyWith(measure: updated),
|
||||||
|
InsightsPanel.daily => visibility.copyWith(daily: updated),
|
||||||
|
_ => visibility.copyWith(intra: updated),
|
||||||
|
};
|
||||||
|
emit(current.copyWith(seriesVisibility: nextVisibility));
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggleSleep() {
|
||||||
|
final current = state;
|
||||||
|
if (current is InsightsStateLoaded) {
|
||||||
|
emit(current.copyWith(showSleep: !current.showSleep));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Сохраняет вес и обновляет данные. Возвращает `false`, если сохранить не удалось.
|
||||||
|
Future<bool> addWeight({required double weight}) {
|
||||||
|
return _add(() => repository.addWeight(weight: weight));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Сохраняет замеры и обновляет данные. Возвращает `false`, если сохранить не удалось.
|
||||||
|
Future<bool> addCircumference({
|
||||||
|
required int chestCircumference,
|
||||||
|
required int waistCircumference,
|
||||||
|
required int hipCircumference,
|
||||||
|
}) {
|
||||||
|
return _add(
|
||||||
|
() => repository.addCircumference(
|
||||||
|
chestCircumference: chestCircumference,
|
||||||
|
waistCircumference: waistCircumference,
|
||||||
|
hipCircumference: hipCircumference,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _add(Future<void> Function() action) async {
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
log(e.toString(), stackTrace: st);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<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 bool isInitial,
|
||||||
|
}) async {
|
||||||
|
final requestId = ++_fullRequestId;
|
||||||
|
final intraGenerationAtStart = _intraRequestId;
|
||||||
|
_inflightFull++;
|
||||||
|
final before = state;
|
||||||
|
if (before is InsightsStateLoaded && !before.isRefreshing) {
|
||||||
|
emit(before.copyWith(isRefreshing: true));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final payload = await _requestPayload(
|
||||||
|
bodyPeriod: bodyPeriod,
|
||||||
|
dailyPeriod: dailyPeriod,
|
||||||
|
intraDate: intraDate,
|
||||||
|
);
|
||||||
|
if (requestId != _fullRequestId || isClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final latest = state;
|
||||||
|
if (latest is InsightsStateLoaded) {
|
||||||
|
// Более свежий intra-запрос не перезаписываем полным ответом.
|
||||||
|
final keepIntra = _intraRequestId > intraGenerationAtStart;
|
||||||
|
emit(
|
||||||
|
latest.copyWith(
|
||||||
|
payload: keepIntra
|
||||||
|
? payload.copyWith(intra: latest.payload.intra)
|
||||||
|
: payload,
|
||||||
|
bodyPeriod: bodyPeriod,
|
||||||
|
dailyPeriod: dailyPeriod,
|
||||||
|
intraDate: keepIntra ? latest.intraDate : intraDate,
|
||||||
|
isRefreshing: _inflightIntra > 0 || _inflightFull > 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (isInitial || latest is! InsightsStateError) {
|
||||||
|
emit(
|
||||||
|
InsightsState.loaded(
|
||||||
|
payload: payload,
|
||||||
|
bodyPeriod: bodyPeriod,
|
||||||
|
dailyPeriod: dailyPeriod,
|
||||||
|
intraDate: intraDate,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} on InsightsForbidden {
|
||||||
|
if (requestId == _fullRequestId && !isClosed) {
|
||||||
|
emit(const InsightsState.hidden());
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
log(e.toString(), stackTrace: st);
|
||||||
|
if (requestId != _fullRequestId || isClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final latest = state;
|
||||||
|
// Ошибка перезапроса не должна стирать уже показанные данные и UI-выбор.
|
||||||
|
if (latest is InsightsStateLoaded) {
|
||||||
|
emit(
|
||||||
|
latest.copyWith(
|
||||||
|
isRefreshing: _inflightIntra > 0 || _inflightFull > 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (isInitial) {
|
||||||
|
emit(InsightsState.error(message: e.toString()));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_inflightFull--;
|
||||||
|
_syncRefreshingFlag();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncRefreshingFlag() {
|
||||||
|
if (isClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final current = state;
|
||||||
|
if (current is! InsightsStateLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final busy = _isBusy;
|
||||||
|
if (current.isRefreshing != busy) {
|
||||||
|
emit(current.copyWith(isRefreshing: busy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
lib/features/insights/presentation/cubit/insights_panel.dart
Normal file
29
lib/features/insights/presentation/cubit/insights_panel.dart
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
|
|
||||||
|
/// Вкладки блока «Аналитика».
|
||||||
|
enum InsightsPanel {
|
||||||
|
weight,
|
||||||
|
bmi,
|
||||||
|
measures,
|
||||||
|
daily,
|
||||||
|
intra;
|
||||||
|
|
||||||
|
/// Вкладки КБЖУ доступны только при включённом флаге.
|
||||||
|
bool get isKbju => this == daily || this == intra;
|
||||||
|
|
||||||
|
/// Кнопка «+» есть только у вкладок с ручным вводом.
|
||||||
|
bool get supportsAdd => this == weight || this == measures;
|
||||||
|
|
||||||
|
/// Подпись вкладки.
|
||||||
|
String label(S s) => switch (this) {
|
||||||
|
weight => s.insights_tab_weight,
|
||||||
|
bmi => s.insights_tab_bmi,
|
||||||
|
measures => s.insights_tab_measures,
|
||||||
|
daily => s.insights_tab_daily,
|
||||||
|
intra => s.insights_tab_intra,
|
||||||
|
};
|
||||||
|
|
||||||
|
static List<InsightsPanel> available({required bool kbjuEnabled}) {
|
||||||
|
return values.where((panel) => kbjuEnabled || !panel.isKbju).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
51
lib/features/insights/presentation/cubit/insights_state.dart
Normal file
51
lib/features/insights/presentation/cubit/insights_state.dart
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/cubit/insights_panel.dart';
|
||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'insights_state.freezed.dart';
|
||||||
|
|
||||||
|
/// Видимость серий (чипы под графиком): `true` — серия показана.
|
||||||
|
@freezed
|
||||||
|
abstract class InsightsSeriesVisibility with _$InsightsSeriesVisibility {
|
||||||
|
const factory InsightsSeriesVisibility({
|
||||||
|
/// Талия, Грудь, Бёдра.
|
||||||
|
@Default([true, true, true]) List<bool> measure,
|
||||||
|
|
||||||
|
/// Калории, Белки, Жиры, Углеводы.
|
||||||
|
@Default([true, true, true, true]) List<bool> daily,
|
||||||
|
|
||||||
|
/// Белки, Жиры, Углеводы, Сытость, Настроение.
|
||||||
|
@Default([true, true, true, true, true]) List<bool> intra,
|
||||||
|
}) = _InsightsSeriesVisibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class InsightsState with _$InsightsState {
|
||||||
|
const factory InsightsState.initial() = InsightsStateInitial;
|
||||||
|
|
||||||
|
const factory InsightsState.loading() = InsightsStateLoading;
|
||||||
|
|
||||||
|
/// 403: данные недоступны, блок не показывается.
|
||||||
|
const factory InsightsState.hidden() = InsightsStateHidden;
|
||||||
|
|
||||||
|
const factory InsightsState.error({required String message}) =
|
||||||
|
InsightsStateError;
|
||||||
|
|
||||||
|
const factory InsightsState.loaded({
|
||||||
|
required InsightsPayloadModel payload,
|
||||||
|
@Default(BodyPeriod.threeMonths) BodyPeriod bodyPeriod,
|
||||||
|
@Default(DailyPeriod.twoWeeks) DailyPeriod dailyPeriod,
|
||||||
|
|
||||||
|
/// День вкладки «В течение дня» (`YYYY-MM-DD`); `null` — сегодня.
|
||||||
|
String? intraDate,
|
||||||
|
@Default(InsightsPanel.weight) InsightsPanel panel,
|
||||||
|
@Default(InsightsSeriesVisibility())
|
||||||
|
InsightsSeriesVisibility seriesVisibility,
|
||||||
|
@Default(true) bool showSleep,
|
||||||
|
@Default(false) bool isExpanded,
|
||||||
|
|
||||||
|
/// Идёт перезапрос (смена периода / обновление) — старые данные остаются на экране.
|
||||||
|
@Default(false) bool isRefreshing,
|
||||||
|
}) = InsightsStateLoaded;
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
const int _minValue = 20;
|
||||||
|
const int _maxValue = 199;
|
||||||
|
|
||||||
|
/// Значения замеров в сантиметрах.
|
||||||
|
typedef CircumferenceValues = ({int chest, int waist, int hip});
|
||||||
|
|
||||||
|
/// Выбор замеров груди, талии и бёдер. [onSave] вызывается после закрытия шторки.
|
||||||
|
Future<void> showAddCircumferenceBottomSheet(
|
||||||
|
BuildContext context, {
|
||||||
|
CircumferenceValues? initial,
|
||||||
|
required Future<void> Function(CircumferenceValues values) onSave,
|
||||||
|
}) {
|
||||||
|
return showModalBottomSheet<void>(
|
||||||
|
useRootNavigator: true,
|
||||||
|
context: context,
|
||||||
|
builder: (_) =>
|
||||||
|
AddCircumferenceBottomSheet(initial: initial, onSave: onSave),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddCircumferenceBottomSheet extends StatefulWidget {
|
||||||
|
const AddCircumferenceBottomSheet({
|
||||||
|
super.key,
|
||||||
|
this.initial,
|
||||||
|
required this.onSave,
|
||||||
|
});
|
||||||
|
|
||||||
|
final CircumferenceValues? initial;
|
||||||
|
final Future<void> Function(CircumferenceValues values) onSave;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AddCircumferenceBottomSheet> createState() =>
|
||||||
|
_AddCircumferenceBottomSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddCircumferenceBottomSheetState
|
||||||
|
extends State<AddCircumferenceBottomSheet> {
|
||||||
|
late final FixedExtentScrollController _chestController;
|
||||||
|
late final FixedExtentScrollController _waistController;
|
||||||
|
late final FixedExtentScrollController _hipController;
|
||||||
|
late int _chest;
|
||||||
|
late int _waist;
|
||||||
|
late int _hip;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_chest = _clamp(widget.initial?.chest);
|
||||||
|
_waist = _clamp(widget.initial?.waist);
|
||||||
|
_hip = _clamp(widget.initial?.hip);
|
||||||
|
_chestController = FixedExtentScrollController(
|
||||||
|
initialItem: _chest - _minValue,
|
||||||
|
);
|
||||||
|
_waistController = FixedExtentScrollController(
|
||||||
|
initialItem: _waist - _minValue,
|
||||||
|
);
|
||||||
|
_hipController = FixedExtentScrollController(initialItem: _hip - _minValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_chestController.dispose();
|
||||||
|
_waistController.dispose();
|
||||||
|
_hipController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
int _clamp(int? value) => (value ?? _minValue).clamp(_minValue, _maxValue);
|
||||||
|
|
||||||
|
void _save() {
|
||||||
|
final values = (chest: _chest, waist: _waist, hip: _hip);
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
widget.onSave(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final s = S.of(context);
|
||||||
|
return SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
for (final title in [
|
||||||
|
s.insights_series_chest,
|
||||||
|
s.insights_series_waist,
|
||||||
|
s.insights_series_hips,
|
||||||
|
])
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
height: 200,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_picker(
|
||||||
|
const Key('add_circumference_chest_picker'),
|
||||||
|
_chestController,
|
||||||
|
(value) => _chest = value,
|
||||||
|
),
|
||||||
|
_picker(
|
||||||
|
const Key('add_circumference_waist_picker'),
|
||||||
|
_waistController,
|
||||||
|
(value) => _waist = value,
|
||||||
|
),
|
||||||
|
_picker(
|
||||||
|
const Key('add_circumference_hip_picker'),
|
||||||
|
_hipController,
|
||||||
|
(value) => _hip = value,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: InkWell(
|
||||||
|
key: const Key('add_sheet_save'),
|
||||||
|
onTap: _save,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Ink(
|
||||||
|
width: MediaQuery.of(context).size.width - 32,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
s.save,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _picker(
|
||||||
|
Key key,
|
||||||
|
FixedExtentScrollController controller,
|
||||||
|
ValueChanged<int> onChanged,
|
||||||
|
) {
|
||||||
|
return Expanded(
|
||||||
|
child: CupertinoPicker(
|
||||||
|
key: key,
|
||||||
|
magnification: 1.22,
|
||||||
|
squeeze: 1.2,
|
||||||
|
useMagnifier: true,
|
||||||
|
scrollController: controller,
|
||||||
|
itemExtent: 24,
|
||||||
|
onSelectedItemChanged: (index) => onChanged(index + _minValue),
|
||||||
|
children: [for (int i = _minValue; i <= _maxValue; i++) Text('$i')],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
const int _minKilo = 20;
|
||||||
|
const int _maxKilo = 299;
|
||||||
|
|
||||||
|
/// Выбор веса (кг + десятые). [onSave] вызывается после закрытия шторки.
|
||||||
|
Future<void> showAddWeightBottomSheet(
|
||||||
|
BuildContext context, {
|
||||||
|
double? initialWeight,
|
||||||
|
required Future<void> Function(double weight) onSave,
|
||||||
|
}) {
|
||||||
|
return showModalBottomSheet<void>(
|
||||||
|
useRootNavigator: true,
|
||||||
|
context: context,
|
||||||
|
builder: (_) =>
|
||||||
|
AddWeightBottomSheet(initialWeight: initialWeight, onSave: onSave),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddWeightBottomSheet extends StatefulWidget {
|
||||||
|
const AddWeightBottomSheet({
|
||||||
|
super.key,
|
||||||
|
this.initialWeight,
|
||||||
|
required this.onSave,
|
||||||
|
});
|
||||||
|
|
||||||
|
final double? initialWeight;
|
||||||
|
final Future<void> Function(double weight) onSave;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AddWeightBottomSheet> createState() => _AddWeightBottomSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddWeightBottomSheetState extends State<AddWeightBottomSheet> {
|
||||||
|
late final FixedExtentScrollController _kiloController;
|
||||||
|
late final FixedExtentScrollController _gramsController;
|
||||||
|
late int _kilo;
|
||||||
|
late int _grams;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final initial = widget.initialWeight;
|
||||||
|
final tenths = initial == null
|
||||||
|
? 0
|
||||||
|
: (initial * 10).round().clamp(_minKilo * 10, _maxKilo * 10 + 9);
|
||||||
|
_kilo = initial == null ? _minKilo : tenths ~/ 10;
|
||||||
|
_grams = initial == null ? 0 : tenths % 10;
|
||||||
|
_kiloController = FixedExtentScrollController(
|
||||||
|
initialItem: _kilo - _minKilo,
|
||||||
|
);
|
||||||
|
_gramsController = FixedExtentScrollController(initialItem: _grams);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_kiloController.dispose();
|
||||||
|
_gramsController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _save() {
|
||||||
|
final weight = double.parse('$_kilo.$_grams');
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
widget.onSave(weight);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: 200,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: CupertinoPicker(
|
||||||
|
key: const Key('add_weight_kilo_picker'),
|
||||||
|
magnification: 1.22,
|
||||||
|
squeeze: 1.2,
|
||||||
|
useMagnifier: true,
|
||||||
|
scrollController: _kiloController,
|
||||||
|
itemExtent: 24,
|
||||||
|
onSelectedItemChanged: (value) => _kilo = value + _minKilo,
|
||||||
|
children: [
|
||||||
|
for (int i = _minKilo; i <= _maxKilo; i++) Text('$i'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: CupertinoPicker(
|
||||||
|
key: const Key('add_weight_grams_picker'),
|
||||||
|
magnification: 1.22,
|
||||||
|
squeeze: 1.2,
|
||||||
|
useMagnifier: true,
|
||||||
|
scrollController: _gramsController,
|
||||||
|
itemExtent: 24,
|
||||||
|
onSelectedItemChanged: (value) => _grams = value,
|
||||||
|
children: [for (int i = 0; i < 10; i++) Text('$i')],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_SaveButton(onTap: _save),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SaveButton extends StatelessWidget {
|
||||||
|
const _SaveButton({required this.onTap});
|
||||||
|
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: InkWell(
|
||||||
|
key: const Key('add_sheet_save'),
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Ink(
|
||||||
|
width: MediaQuery.of(context).size.width - 32,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
S.of(context).save,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
640
lib/features/insights/presentation/widgets/insights_card.dart
Normal file
640
lib/features/insights/presentation/widgets/insights_card.dart
Normal file
@@ -0,0 +1,640 @@
|
|||||||
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
|
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/insights_feature_flags.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/charts/bmi_chart.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/charts/daily_kbju_chart.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/charts/intra_day_chart.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/charts/measure_chart.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/charts/weight_chart.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/cubit/insights_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/cubit/insights_panel.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/cubit/insights_state.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/add_circumference_bottom_sheet.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/add_weight_bottom_sheet.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/insights_fab.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/insights_head.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/insights_tabs.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/intra_date_nav.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/intra_legend.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/period_pills.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/series_chips.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
|
||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
/// Сворачиваемый блок «Аналитика» (вес, ИМТ, замеры).
|
||||||
|
///
|
||||||
|
/// [slug] — владелец данных (`null` — текущий пользователь). При 403 блок
|
||||||
|
/// скрывается целиком. [canAdd] включает кнопку «+» на вкладках «Вес» и «Замеры».
|
||||||
|
class InsightsCard extends StatelessWidget {
|
||||||
|
const InsightsCard({
|
||||||
|
super.key,
|
||||||
|
this.slug,
|
||||||
|
this.canAdd = false,
|
||||||
|
this.repository,
|
||||||
|
this.kbjuEnabled = InsightsFeatureFlags.kbjuEnabled,
|
||||||
|
this.refreshToken = 0,
|
||||||
|
this.initialIntraDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String? slug;
|
||||||
|
final bool canAdd;
|
||||||
|
final InsightsRepository? repository;
|
||||||
|
final bool kbjuEnabled;
|
||||||
|
|
||||||
|
/// При смене значения блок перезапрашивает данные (например, после правок
|
||||||
|
/// в дневнике), сохраняя вкладку, период и выбранный день.
|
||||||
|
final int refreshToken;
|
||||||
|
|
||||||
|
/// День вкладки «В течение дня» при первой загрузке (`YYYY-MM-DD`).
|
||||||
|
/// В дневнике совпадает с открытой датой; `null` — сегодня.
|
||||||
|
final String? initialIntraDate;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: ValueKey('insights_card_$slug'),
|
||||||
|
child: BlocProvider<InsightsCubit>(
|
||||||
|
create: (_) => InsightsCubit(
|
||||||
|
repository: repository ?? sl<InsightsRepository>(),
|
||||||
|
slug: slug,
|
||||||
|
canAdd: canAdd,
|
||||||
|
kbjuEnabled: kbjuEnabled,
|
||||||
|
)..load(intraDate: initialIntraDate),
|
||||||
|
child: _InsightsView(
|
||||||
|
refreshToken: refreshToken,
|
||||||
|
initialIntraDate: initialIntraDate,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InsightsView extends StatefulWidget {
|
||||||
|
const _InsightsView({
|
||||||
|
required this.refreshToken,
|
||||||
|
required this.initialIntraDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int refreshToken;
|
||||||
|
final String? initialIntraDate;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_InsightsView> createState() => _InsightsViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InsightsViewState extends State<_InsightsView> {
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant _InsightsView oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
final cubit = context.read<InsightsCubit>();
|
||||||
|
if (oldWidget.refreshToken != widget.refreshToken) {
|
||||||
|
cubit.refresh();
|
||||||
|
}
|
||||||
|
final nextDate = widget.initialIntraDate;
|
||||||
|
if (nextDate != null &&
|
||||||
|
nextDate.isNotEmpty &&
|
||||||
|
oldWidget.initialIntraDate != nextDate) {
|
||||||
|
final parsed = DateTime.tryParse(nextDate);
|
||||||
|
if (parsed != null) {
|
||||||
|
cubit.setIntraDate(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocBuilder<InsightsCubit, InsightsState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
return switch (state) {
|
||||||
|
InsightsStateLoaded() => _CardShell(
|
||||||
|
child: _LoadedContent(state: state),
|
||||||
|
),
|
||||||
|
InsightsStateError() => const _CardShell(child: _ErrorContent()),
|
||||||
|
_ => const SizedBox.shrink(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CardShell extends StatelessWidget {
|
||||||
|
const _CardShell({required this.child});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
key: const Key('insights_card'),
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(
|
||||||
|
color: InsightsColors.cardShadow,
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ErrorContent extends StatelessWidget {
|
||||||
|
const _ErrorContent();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final s = S.of(context);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
s.insights_load_error,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: InsightsColors.subtitle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
key: const Key('insights_retry'),
|
||||||
|
onPressed: () => context.read<InsightsCubit>().load(),
|
||||||
|
child: Text(s.insights_retry),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoadedContent extends StatelessWidget {
|
||||||
|
const _LoadedContent({required this.state});
|
||||||
|
|
||||||
|
final InsightsStateLoaded state;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cubit = context.read<InsightsCubit>();
|
||||||
|
final showFab = cubit.canAdd && state.panel.supportsAdd;
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_Toggle(isExpanded: state.isExpanded, onTap: cubit.toggleExpanded),
|
||||||
|
AnimatedSize(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: state.isExpanded
|
||||||
|
? Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
16,
|
||||||
|
0,
|
||||||
|
16,
|
||||||
|
showFab ? 24 + 44 : 24,
|
||||||
|
),
|
||||||
|
child: _Body(state: state),
|
||||||
|
)
|
||||||
|
: const SizedBox(width: double.infinity),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (showFab && state.isExpanded)
|
||||||
|
Positioned(
|
||||||
|
right: 16,
|
||||||
|
bottom: 16,
|
||||||
|
child: InsightsFab(
|
||||||
|
tooltip: S.of(context).insights_add_record,
|
||||||
|
onPressed: () => _onAddPressed(context, cubit, state),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onAddPressed(
|
||||||
|
BuildContext context,
|
||||||
|
InsightsCubit cubit,
|
||||||
|
InsightsStateLoaded state,
|
||||||
|
) {
|
||||||
|
final s = S.of(context);
|
||||||
|
if (state.panel == InsightsPanel.weight) {
|
||||||
|
showAddWeightBottomSheet(
|
||||||
|
context,
|
||||||
|
initialWeight: state.payload.weight.last,
|
||||||
|
onSave: (weight) async {
|
||||||
|
if (!await cubit.addWeight(weight: weight)) {
|
||||||
|
showErrorToast(s.insights_save_error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else if (state.panel == InsightsPanel.measures) {
|
||||||
|
final measure = state.payload.measure;
|
||||||
|
showAddCircumferenceBottomSheet(
|
||||||
|
context,
|
||||||
|
initial:
|
||||||
|
measure.waist.isEmpty ||
|
||||||
|
measure.chest.isEmpty ||
|
||||||
|
measure.hips.isEmpty
|
||||||
|
? null
|
||||||
|
: (
|
||||||
|
chest: measure.chest.last.round(),
|
||||||
|
waist: measure.waist.last.round(),
|
||||||
|
hip: measure.hips.last.round(),
|
||||||
|
),
|
||||||
|
onSave: (values) async {
|
||||||
|
final isSaved = await cubit.addCircumference(
|
||||||
|
chestCircumference: values.chest,
|
||||||
|
waistCircumference: values.waist,
|
||||||
|
hipCircumference: values.hip,
|
||||||
|
);
|
||||||
|
if (!isSaved) {
|
||||||
|
showErrorToast(s.insights_save_error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Toggle extends StatelessWidget {
|
||||||
|
const _Toggle({required this.isExpanded, required this.onTap});
|
||||||
|
|
||||||
|
final bool isExpanded;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final s = S.of(context);
|
||||||
|
return InkWell(
|
||||||
|
key: const Key('insights_toggle'),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: InsightsColors.accentBackground,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.show_chart_rounded,
|
||||||
|
size: 20,
|
||||||
|
color: InsightsColors.blue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
s.insights_title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: InsightsColors.title,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
key: const Key('insights_subtitle'),
|
||||||
|
isExpanded
|
||||||
|
? s.insights_subtitle_open
|
||||||
|
: s.insights_subtitle_closed,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12.5,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: InsightsColors.subtitle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AnimatedRotation(
|
||||||
|
turns: isExpanded ? 0 : 0.5,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.keyboard_arrow_up_rounded,
|
||||||
|
size: 26,
|
||||||
|
color: InsightsColors.blue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Body extends StatelessWidget {
|
||||||
|
const _Body({required this.state});
|
||||||
|
|
||||||
|
final InsightsStateLoaded state;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cubit = context.read<InsightsCubit>();
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
InsightsTabs(
|
||||||
|
panels: cubit.panels,
|
||||||
|
selected: state.panel,
|
||||||
|
onSelected: cubit.setPanel,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
AnimatedOpacity(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
opacity: state.isRefreshing ? 0.5 : 1,
|
||||||
|
child: _PanelContent(state: state, canAdd: cubit.canAdd),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PanelContent extends StatelessWidget {
|
||||||
|
const _PanelContent({required this.state, required this.canAdd});
|
||||||
|
|
||||||
|
final InsightsStateLoaded state;
|
||||||
|
final bool canAdd;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cubit = context.read<InsightsCubit>();
|
||||||
|
final s = S.of(context);
|
||||||
|
final payload = state.payload;
|
||||||
|
|
||||||
|
final Widget content;
|
||||||
|
switch (state.panel) {
|
||||||
|
case InsightsPanel.weight:
|
||||||
|
content = Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
InsightsHead(
|
||||||
|
headline: payload.weight.headline,
|
||||||
|
unit: payload.weight.unit,
|
||||||
|
deltaText: payload.weight.deltaText,
|
||||||
|
isDeltaHighlighted: payload.weight.delta != null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
WeightChart(
|
||||||
|
model: payload.weight,
|
||||||
|
emptyText: canAdd
|
||||||
|
? s.insights_add_weight_empty
|
||||||
|
: s.insights_not_enough,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
case InsightsPanel.bmi:
|
||||||
|
content = Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (payload.bmi.values.isNotEmpty) ...[
|
||||||
|
InsightsHead(
|
||||||
|
headline: payload.bmi.headline,
|
||||||
|
unit: s.insights_unit_bmi,
|
||||||
|
deltaText: payload.bmi.deltaText,
|
||||||
|
isDeltaHighlighted: payload.bmi.delta != null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
BmiChart(model: payload.bmi),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
case InsightsPanel.measures:
|
||||||
|
final visibility = state.seriesVisibility.measure;
|
||||||
|
content = Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
InsightsHead(
|
||||||
|
headline: payload.measure.headline,
|
||||||
|
unit: s.insights_unit_cm_waist,
|
||||||
|
deltaText: payload.measure.deltaText,
|
||||||
|
isDeltaHighlighted: payload.measure.waist.length > 1,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SeriesChips(
|
||||||
|
chips: [
|
||||||
|
SeriesChipData(
|
||||||
|
label: s.insights_series_waist,
|
||||||
|
color: InsightsColors.waist,
|
||||||
|
isVisible: visibility[0],
|
||||||
|
),
|
||||||
|
SeriesChipData(
|
||||||
|
label: s.insights_series_chest,
|
||||||
|
color: InsightsColors.chest,
|
||||||
|
isVisible: visibility[1],
|
||||||
|
),
|
||||||
|
SeriesChipData(
|
||||||
|
label: s.insights_series_hips,
|
||||||
|
color: InsightsColors.hips,
|
||||||
|
isVisible: visibility[2],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onToggle: (index) =>
|
||||||
|
cubit.toggleSeries(InsightsPanel.measures, index),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
MeasureChart(
|
||||||
|
model: payload.measure,
|
||||||
|
visibility: visibility,
|
||||||
|
emptyText: canAdd
|
||||||
|
? s.insights_add_measures_empty
|
||||||
|
: s.insights_not_enough,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
case InsightsPanel.daily:
|
||||||
|
return _DailyPanel(state: state);
|
||||||
|
case InsightsPanel.intra:
|
||||||
|
return _IntraPanel(state: state);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
PeriodPills<BodyPeriod>(
|
||||||
|
periods: BodyPeriod.values,
|
||||||
|
selected: state.bodyPeriod,
|
||||||
|
labelOf: (period) => period.pillLabel(s),
|
||||||
|
keyOf: (period) => period.apiValue,
|
||||||
|
onSelected: cubit.setBodyPeriod,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
content,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// «КБЖУ по дням». Смена периода перезапрашивает `GET /api/profile/insights`.
|
||||||
|
class _DailyPanel extends StatelessWidget {
|
||||||
|
const _DailyPanel({required this.state});
|
||||||
|
|
||||||
|
final InsightsStateLoaded state;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final s = S.of(context);
|
||||||
|
final cubit = context.read<InsightsCubit>();
|
||||||
|
final daily = state.payload.daily;
|
||||||
|
final visibility = state.seriesVisibility.daily;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
PeriodPills<DailyPeriod>(
|
||||||
|
periods: DailyPeriod.values,
|
||||||
|
selected: state.dailyPeriod,
|
||||||
|
labelOf: (period) => period.pillLabel(s),
|
||||||
|
keyOf: (period) => period.apiValue,
|
||||||
|
onSelected: cubit.setDailyPeriod,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (daily == null)
|
||||||
|
InsightsChartPlaceholder(text: s.insights_not_enough)
|
||||||
|
else ...[
|
||||||
|
InsightsHead(
|
||||||
|
headline: daily.headline,
|
||||||
|
unit: s.insights_unit_kcal_avg,
|
||||||
|
deltaText: daily.deltaText,
|
||||||
|
isDeltaHighlighted: (daily.percent ?? 0) != 0,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SeriesChips(
|
||||||
|
chips: [
|
||||||
|
SeriesChipData(
|
||||||
|
label: s.insights_series_calories,
|
||||||
|
color: InsightsColors.calories,
|
||||||
|
isVisible: visibility[0],
|
||||||
|
),
|
||||||
|
SeriesChipData(
|
||||||
|
label: s.insights_series_proteins,
|
||||||
|
color: InsightsColors.proteins,
|
||||||
|
isVisible: visibility[1],
|
||||||
|
),
|
||||||
|
SeriesChipData(
|
||||||
|
label: s.insights_series_fats,
|
||||||
|
color: InsightsColors.fats,
|
||||||
|
isVisible: visibility[2],
|
||||||
|
),
|
||||||
|
SeriesChipData(
|
||||||
|
label: s.insights_series_carbs,
|
||||||
|
color: InsightsColors.carbs,
|
||||||
|
isVisible: visibility[3],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onToggle: (index) => cubit.toggleSeries(InsightsPanel.daily, index),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
DailyKbjuChart(model: daily, visibility: visibility),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// «В течение дня». Листание дней запрашивает только `/insights/intra`.
|
||||||
|
class _IntraPanel extends StatelessWidget {
|
||||||
|
const _IntraPanel({required this.state});
|
||||||
|
|
||||||
|
final InsightsStateLoaded state;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final s = S.of(context);
|
||||||
|
final cubit = context.read<InsightsCubit>();
|
||||||
|
final intra = state.payload.intra;
|
||||||
|
final visibility = state.seriesVisibility.intra;
|
||||||
|
|
||||||
|
if (intra == null) {
|
||||||
|
return InsightsChartPlaceholder(text: s.insights_not_enough);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
IntraDateNav(
|
||||||
|
model: intra,
|
||||||
|
onPreviousDay: cubit.showPreviousDay,
|
||||||
|
onNextDay: cubit.showNextDay,
|
||||||
|
onDatePicked: cubit.setIntraDate,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SeriesChips(
|
||||||
|
chips: [
|
||||||
|
SeriesChipData(
|
||||||
|
label: s.insights_series_proteins,
|
||||||
|
color: InsightsColors.proteins,
|
||||||
|
isVisible: visibility[0],
|
||||||
|
),
|
||||||
|
SeriesChipData(
|
||||||
|
label: s.insights_series_fats,
|
||||||
|
color: InsightsColors.fats,
|
||||||
|
isVisible: visibility[1],
|
||||||
|
),
|
||||||
|
SeriesChipData(
|
||||||
|
label: s.insights_series_carbs,
|
||||||
|
color: InsightsColors.carbs,
|
||||||
|
isVisible: visibility[2],
|
||||||
|
),
|
||||||
|
SeriesChipData(
|
||||||
|
label: s.insights_series_satiety,
|
||||||
|
color: InsightsColors.satiety,
|
||||||
|
isVisible: visibility[3],
|
||||||
|
),
|
||||||
|
SeriesChipData(
|
||||||
|
label: s.insights_series_mood,
|
||||||
|
color: InsightsColors.mood,
|
||||||
|
isVisible: visibility[4],
|
||||||
|
),
|
||||||
|
SeriesChipData(
|
||||||
|
label: s.insights_series_sleep,
|
||||||
|
color: InsightsColors.sleepLine,
|
||||||
|
isVisible: state.showSleep,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onToggle: (index) {
|
||||||
|
if (index == 5) {
|
||||||
|
cubit.toggleSleep();
|
||||||
|
} else {
|
||||||
|
cubit.toggleSeries(InsightsPanel.intra, index);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
IntraDayChart(
|
||||||
|
model: intra,
|
||||||
|
visibility: visibility,
|
||||||
|
showSleep: state.showSleep,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
IntraLegend(model: intra),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
s.insights_intra_hint,
|
||||||
|
key: const Key('insights_intra_hint'),
|
||||||
|
style: const TextStyle(fontSize: 13, color: InsightsColors.subtitle),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:flutter/painting.dart';
|
||||||
|
|
||||||
|
/// Токены из `latest-tmpls/src/scss/pages/diary.scss` (`&__analytics*`).
|
||||||
|
abstract final class InsightsColors {
|
||||||
|
static const Color blue = Color(0xFF2F80ED);
|
||||||
|
static const Color accentBackground = Color(0xFFF6FAFF);
|
||||||
|
static const Color title = Color(0xFF323F4B);
|
||||||
|
static const Color subtitle = Color(0xFF7B8794);
|
||||||
|
static const Color border = Color(0xFFE6E6E6);
|
||||||
|
static const Color positive = Color(0xFF019A3E);
|
||||||
|
|
||||||
|
static const Color calories = Color(0xFF323F4B);
|
||||||
|
static const Color proteins = Color(0xFF2F80ED);
|
||||||
|
static const Color fats = Color(0xFFF2994A);
|
||||||
|
static const Color carbs = Color(0xFF0D6EFD);
|
||||||
|
static const Color satiety = Color(0xFF019A3E);
|
||||||
|
static const Color mood = Color(0xFFEB555C);
|
||||||
|
|
||||||
|
static const Color waist = Color(0xFF2F80ED);
|
||||||
|
static const Color chest = Color(0xFF9B51E0);
|
||||||
|
static const Color hips = Color(0xFFF2994A);
|
||||||
|
|
||||||
|
static const Color wake = Color(0xFFF2994A);
|
||||||
|
static const Color sleepLine = Color(0xFF7B8794);
|
||||||
|
static const Color sleepShade = Color(0x1A7B8794);
|
||||||
|
|
||||||
|
static const Color bmiNormalZone = Color(0x14019A3E);
|
||||||
|
static const Color bmiOverweightZone = Color(0x1AF2994A);
|
||||||
|
static const Color bmiObesityZone = Color(0x14EB555C);
|
||||||
|
|
||||||
|
static const Color cardShadow = Color(0x26000000);
|
||||||
|
static const Color fabShadow = Color(0x592F80ED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Высота области графика.
|
||||||
|
const double insightsChartHeight = 270;
|
||||||
42
lib/features/insights/presentation/widgets/insights_fab.dart
Normal file
42
lib/features/insights/presentation/widgets/insights_fab.dart
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Круглая кнопка «+» (52px) справа-снизу карточки.
|
||||||
|
class InsightsFab extends StatelessWidget {
|
||||||
|
const InsightsFab({super.key, required this.onPressed, this.tooltip});
|
||||||
|
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
final String? tooltip;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Tooltip(
|
||||||
|
message: tooltip ?? '',
|
||||||
|
child: Container(
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: InsightsColors.blue,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: InsightsColors.fabShadow,
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: Offset(0, 8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
child: InkWell(
|
||||||
|
key: const Key('insights_fab'),
|
||||||
|
customBorder: const CircleBorder(),
|
||||||
|
onTap: onPressed,
|
||||||
|
child: const Icon(Icons.add, color: Colors.white, size: 28),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Крупное число + единица + строка с дельтой.
|
||||||
|
class InsightsHead extends StatelessWidget {
|
||||||
|
const InsightsHead({
|
||||||
|
super.key,
|
||||||
|
required this.headline,
|
||||||
|
required this.unit,
|
||||||
|
required this.deltaText,
|
||||||
|
this.isDeltaHighlighted = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String headline;
|
||||||
|
final String unit;
|
||||||
|
final String deltaText;
|
||||||
|
|
||||||
|
/// Зелёная дельта при наличии изменения, иначе нейтральный серый.
|
||||||
|
final bool isDeltaHighlighted;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||||
|
textBaseline: TextBaseline.alphabetic,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
headline,
|
||||||
|
key: const Key('insights_headline'),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 34,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: InsightsColors.title,
|
||||||
|
height: 1.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (unit.isNotEmpty) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
unit,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: InsightsColors.subtitle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (deltaText.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
deltaText,
|
||||||
|
key: const Key('insights_delta'),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: isDeltaHighlighted
|
||||||
|
? InsightsColors.positive
|
||||||
|
: InsightsColors.subtitle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import 'package:eatvio_app/features/insights/presentation/cubit/insights_panel.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Горизонтально прокручиваемые вкладки (`l-tab`).
|
||||||
|
class InsightsTabs extends StatelessWidget {
|
||||||
|
const InsightsTabs({
|
||||||
|
super.key,
|
||||||
|
required this.panels,
|
||||||
|
required this.selected,
|
||||||
|
required this.onSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<InsightsPanel> panels;
|
||||||
|
final InsightsPanel selected;
|
||||||
|
final ValueChanged<InsightsPanel> onSelected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final s = S.of(context);
|
||||||
|
return SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
for (final panel in panels)
|
||||||
|
InkWell(
|
||||||
|
key: Key('insights_tab_${panel.name}'),
|
||||||
|
onTap: () => onSelected(panel),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: panel == selected
|
||||||
|
? InsightsColors.blue
|
||||||
|
: Colors.transparent,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
panel.label(s),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: panel == selected
|
||||||
|
? FontWeight.w700
|
||||||
|
: FontWeight.w600,
|
||||||
|
color: panel == selected
|
||||||
|
? InsightsColors.blue
|
||||||
|
: InsightsColors.subtitle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
130
lib/features/insights/presentation/widgets/intra_date_nav.dart
Normal file
130
lib/features/insights/presentation/widgets/intra_date_nav.dart
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Навигация по дням для вкладки «В течение дня»: `‹ дата ›` и выбор даты.
|
||||||
|
///
|
||||||
|
/// Колбэки `null` — навигация неактивна (данные других дней ещё не подключены).
|
||||||
|
class IntraDateNav extends StatelessWidget {
|
||||||
|
const IntraDateNav({
|
||||||
|
super.key,
|
||||||
|
required this.model,
|
||||||
|
this.onPreviousDay,
|
||||||
|
this.onNextDay,
|
||||||
|
this.onDatePicked,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IntraChartModel model;
|
||||||
|
final VoidCallback? onPreviousDay;
|
||||||
|
final VoidCallback? onNextDay;
|
||||||
|
final ValueChanged<DateTime>? onDatePicked;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final s = S.of(context);
|
||||||
|
final canGoNext = !model.isToday && onNextDay != null;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: InsightsColors.accentBackground,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_NavButton(
|
||||||
|
key: const Key('insights_intra_prev'),
|
||||||
|
icon: Icons.chevron_left_rounded,
|
||||||
|
tooltip: s.insights_prev_day,
|
||||||
|
onPressed: onPreviousDay,
|
||||||
|
),
|
||||||
|
InkWell(
|
||||||
|
key: const Key('insights_intra_pick'),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
onTap: onDatePicked == null ? null : () => _pickDate(context),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(minWidth: 72),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.calendar_today_rounded,
|
||||||
|
size: 14,
|
||||||
|
color: InsightsColors.blue,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
model.dateLabel,
|
||||||
|
key: const Key('insights_intra_date_label'),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: InsightsColors.title,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_NavButton(
|
||||||
|
key: const Key('insights_intra_next'),
|
||||||
|
icon: Icons.chevron_right_rounded,
|
||||||
|
tooltip: s.insights_next_day,
|
||||||
|
onPressed: canGoNext ? onNextDay : null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickDate(BuildContext context) async {
|
||||||
|
final lastDate = DateTime.tryParse(model.maxDate) ?? DateTime.now();
|
||||||
|
final current = DateTime.tryParse(model.date) ?? lastDate;
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: current.isAfter(lastDate) ? lastDate : current,
|
||||||
|
firstDate: DateTime(2000),
|
||||||
|
lastDate: lastDate,
|
||||||
|
helpText: S.of(context).insights_pick_date,
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
onDatePicked?.call(picked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NavButton extends StatelessWidget {
|
||||||
|
const _NavButton({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.tooltip,
|
||||||
|
required this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String tooltip;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Opacity(
|
||||||
|
opacity: onPressed == null ? 0.35 : 1,
|
||||||
|
child: SizedBox(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
child: IconButton(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
tooltip: tooltip,
|
||||||
|
onPressed: onPressed,
|
||||||
|
icon: Icon(icon, color: InsightsColors.blue),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
lib/features/insights/presentation/widgets/intra_legend.dart
Normal file
96
lib/features/insights/presentation/widgets/intra_legend.dart
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Легенда под графиком «В течение дня»: подъём, отход ко сну, затемнение сна.
|
||||||
|
class IntraLegend extends StatelessWidget {
|
||||||
|
const IntraLegend({super.key, required this.model});
|
||||||
|
|
||||||
|
final IntraChartModel model;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final s = S.of(context);
|
||||||
|
final wakeText =
|
||||||
|
model.wakeText ??
|
||||||
|
(model.wakeLabel == null ? null : s.insights_wake_at(model.wakeLabel!));
|
||||||
|
final sleepText =
|
||||||
|
model.sleepText ??
|
||||||
|
(model.sleepLabel == null
|
||||||
|
? null
|
||||||
|
: s.insights_sleep_at(model.sleepLabel!));
|
||||||
|
|
||||||
|
return Wrap(
|
||||||
|
spacing: 16,
|
||||||
|
runSpacing: 6,
|
||||||
|
children: [
|
||||||
|
if (wakeText != null)
|
||||||
|
_LegendItem(
|
||||||
|
key: const Key('insights_legend_wake'),
|
||||||
|
color: InsightsColors.wake,
|
||||||
|
text: wakeText,
|
||||||
|
isDashed: true,
|
||||||
|
),
|
||||||
|
if (sleepText != null)
|
||||||
|
_LegendItem(
|
||||||
|
key: const Key('insights_legend_sleep'),
|
||||||
|
color: InsightsColors.sleepLine,
|
||||||
|
text: sleepText,
|
||||||
|
isDashed: true,
|
||||||
|
),
|
||||||
|
_LegendItem(
|
||||||
|
key: const Key('insights_legend_shade'),
|
||||||
|
color: InsightsColors.sleepShade,
|
||||||
|
text: s.insights_sleep_shade,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LegendItem extends StatelessWidget {
|
||||||
|
const _LegendItem({
|
||||||
|
super.key,
|
||||||
|
required this.color,
|
||||||
|
required this.text,
|
||||||
|
this.isDashed = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Color color;
|
||||||
|
final String text;
|
||||||
|
final bool isDashed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
isDashed
|
||||||
|
? Container(
|
||||||
|
width: 18,
|
||||||
|
height: 0,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(top: BorderSide(color: color, width: 2)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
width: 18,
|
||||||
|
height: 10,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: InsightsColors.subtitle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
lib/features/insights/presentation/widgets/period_pills.dart
Normal file
79
lib/features/insights/presentation/widgets/period_pills.dart
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Пилюли выбора периода (`1М 3М 6М 1Г Всё время`).
|
||||||
|
class PeriodPills<T> extends StatelessWidget {
|
||||||
|
const PeriodPills({
|
||||||
|
super.key,
|
||||||
|
required this.periods,
|
||||||
|
required this.selected,
|
||||||
|
required this.labelOf,
|
||||||
|
required this.keyOf,
|
||||||
|
this.onSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<T> periods;
|
||||||
|
final T selected;
|
||||||
|
final String Function(T period) labelOf;
|
||||||
|
final String Function(T period) keyOf;
|
||||||
|
|
||||||
|
/// `null` — пилюли неинтерактивны (данные для периода ещё не подключены).
|
||||||
|
final ValueChanged<T>? onSelected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
for (final period in periods)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 6),
|
||||||
|
child: _Pill(
|
||||||
|
key: Key('insights_period_${keyOf(period)}'),
|
||||||
|
label: labelOf(period),
|
||||||
|
isActive: period == selected,
|
||||||
|
onTap: onSelected == null ? null : () => onSelected!(period),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Pill extends StatelessWidget {
|
||||||
|
const _Pill({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
required this.isActive,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final bool isActive;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Material(
|
||||||
|
color: isActive ? InsightsColors.blue : InsightsColors.accentBackground,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 7),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12.5,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: isActive ? Colors.white : InsightsColors.subtitle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
lib/features/insights/presentation/widgets/series_chips.dart
Normal file
76
lib/features/insights/presentation/widgets/series_chips.dart
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class SeriesChipData {
|
||||||
|
const SeriesChipData({
|
||||||
|
required this.label,
|
||||||
|
required this.color,
|
||||||
|
required this.isVisible,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final Color color;
|
||||||
|
final bool isVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Чипы-переключатели серий под графиком.
|
||||||
|
class SeriesChips extends StatelessWidget {
|
||||||
|
const SeriesChips({super.key, required this.chips, required this.onToggle});
|
||||||
|
|
||||||
|
final List<SeriesChipData> chips;
|
||||||
|
final ValueChanged<int> onToggle;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < chips.length; i++)
|
||||||
|
Opacity(
|
||||||
|
opacity: chips[i].isVisible ? 1 : 0.45,
|
||||||
|
child: InkWell(
|
||||||
|
key: Key('insights_series_$i'),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
onTap: () => onToggle(i),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14,
|
||||||
|
vertical: 7,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(color: InsightsColors.border, width: 1.5),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: chips[i].color,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
chips[i].label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: InsightsColors.title,
|
||||||
|
decoration: chips[i].isVisible
|
||||||
|
? TextDecoration.none
|
||||||
|
: TextDecoration.lineThrough,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
103
lib/features/insights/repositories/insights_repository.dart
Normal file
103
lib/features/insights/repositories/insights_repository.dart
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/domain/insights_text_builder.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/services/insights_service.dart';
|
||||||
|
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
|
|
||||||
|
/// Данные аналитики недоступны текущему пользователю (HTTP 403): консультация
|
||||||
|
/// не активна либо это чужой профиль без доступа.
|
||||||
|
class InsightsForbidden implements Exception {
|
||||||
|
const InsightsForbidden();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'InsightsForbidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef InsightsTextBuilderFactory = InsightsTextBuilder Function();
|
||||||
|
|
||||||
|
/// Источники данных блока «Аналитика»:
|
||||||
|
/// - [fetchInsights] / [fetchIntra] — серверные `GET /api/profile/insights*`
|
||||||
|
/// (графики и тексты считает `UserInsightsService`, включая КБЖУ);
|
||||||
|
/// - [getInsights] — собирает payload на клиенте из эндпоинтов веса и замеров
|
||||||
|
/// (используется, пока выключен `InsightsFeatureFlags.kbjuEnabled`).
|
||||||
|
class InsightsRepository {
|
||||||
|
InsightsRepository({required this.profileRepository, required this.insightsService, InsightsTextBuilderFactory? textBuilderFactory})
|
||||||
|
: _textBuilderFactory = textBuilderFactory ?? (() => InsightsTextBuilder(S.current));
|
||||||
|
|
||||||
|
final ProfileRepository profileRepository;
|
||||||
|
final InsightsService insightsService;
|
||||||
|
final InsightsTextBuilderFactory _textBuilderFactory;
|
||||||
|
|
||||||
|
/// Рост в сантиметрах для расчёта подсказки ИМТ; `null`, если не указан или
|
||||||
|
/// профиль не удалось загрузить (кроме 403 — он пробрасывается как [InsightsForbidden]).
|
||||||
|
Future<int?> getHeightCm({String? slug}) async {
|
||||||
|
try {
|
||||||
|
final details = slug == null ? await profileRepository.getMyProfileDetails() : await profileRepository.getProfileDetailsBySlug(slug);
|
||||||
|
return details.height > 0 ? details.height : null;
|
||||||
|
} on DioException catch (e) {
|
||||||
|
_throwIfForbidden(e);
|
||||||
|
return null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<InsightsPayloadModel> getInsights({String? slug, required BodyPeriod bodyPeriod, int? heightCm}) async {
|
||||||
|
try {
|
||||||
|
final (weights, circumferences) = await (
|
||||||
|
profileRepository.getWeight(period: bodyPeriod.apiValue, slug: slug),
|
||||||
|
profileRepository.getCircumferences(period: bodyPeriod.apiValue, slug: slug),
|
||||||
|
).wait;
|
||||||
|
return _textBuilderFactory().build(weights: weights, circumferences: circumferences, period: bodyPeriod, heightCm: heightCm);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
_throwIfForbidden(e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Полный payload с сервера. [intraDate] — `YYYY-MM-DD`, `null` — сегодня.
|
||||||
|
Future<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);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addCircumference({required int chestCircumference, required int waistCircumference, required int hipCircumference}) {
|
||||||
|
return profileRepository.addCircumference(
|
||||||
|
chestCircumference: chestCircumference,
|
||||||
|
waistCircumference: waistCircumference,
|
||||||
|
hipCircumference: hipCircumference,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _throwIfForbidden(DioException e) {
|
||||||
|
if (e.response?.statusCode == 403) {
|
||||||
|
throw const InsightsForbidden();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Чужие графики целиком (клиент консультанта; остальным 403).
|
||||||
|
@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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9,9 +9,9 @@ abstract class CircumferenceModel with _$CircumferenceModel {
|
|||||||
const factory CircumferenceModel({
|
const factory CircumferenceModel({
|
||||||
@JsonKey(name: "id") required int id,
|
@JsonKey(name: "id") required int id,
|
||||||
@JsonKey(name: "user_id") required int userId,
|
@JsonKey(name: "user_id") required int userId,
|
||||||
@JsonKey(name: "chest_circumference") required int chestCircumference,
|
@JsonKey(name: "chest_circumference") required double chestCircumference,
|
||||||
@JsonKey(name: "waist_circumference") required int waistCircumference,
|
@JsonKey(name: "waist_circumference") required double waistCircumference,
|
||||||
@JsonKey(name: "hip_circumference") required int hipCircumference,
|
@JsonKey(name: "hip_circumference") required double hipCircumference,
|
||||||
@JsonKey(name: "created_at") required DateTime createdAt,
|
@JsonKey(name: "created_at") required DateTime createdAt,
|
||||||
@JsonKey(name: "updated_at") required DateTime updatedAt,
|
@JsonKey(name: "updated_at") required DateTime updatedAt,
|
||||||
}) = _CircumferenceModel;
|
}) = _CircumferenceModel;
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ abstract class WeightModel with _$WeightModel {
|
|||||||
const factory WeightModel({
|
const factory WeightModel({
|
||||||
@JsonKey(name: "id") required int id,
|
@JsonKey(name: "id") required int id,
|
||||||
@JsonKey(name: "user_id") required int userId,
|
@JsonKey(name: "user_id") required int userId,
|
||||||
@JsonKey(name: "weight") required int weight,
|
@JsonKey(name: "weight") required double weight,
|
||||||
@JsonKey(name: "created_at") required DateTime createdAt,
|
@JsonKey(name: "created_at") required DateTime createdAt,
|
||||||
@JsonKey(name: "updated_at") required DateTime updatedAt,
|
@JsonKey(name: "updated_at") required DateTime updatedAt,
|
||||||
@JsonKey(name: "bmi") required double bmi,
|
@JsonKey(name: "bmi") double? bmi,
|
||||||
}) = _WeightModel;
|
}) = _WeightModel;
|
||||||
|
|
||||||
factory WeightModel.fromJson(Map<String, dynamic> json) => _$WeightModelFromJson(json);
|
factory WeightModel.fromJson(Map<String, dynamic> json) => _$WeightModelFromJson(json);
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ class ProfileCubit extends Cubit<ProfileState> {
|
|||||||
final ProfileRepository profileRepository;
|
final ProfileRepository profileRepository;
|
||||||
final ChatRepository chatRepository;
|
final ChatRepository chatRepository;
|
||||||
final ConsultantRepository consultantRepository;
|
final ConsultantRepository consultantRepository;
|
||||||
String _currentPeriod = '1';
|
|
||||||
|
|
||||||
/// Bottom-nav / own profile instance, used to refresh posts after create/update/delete.
|
/// Bottom-nav / own profile instance, used to refresh posts after create/update/delete.
|
||||||
static ProfileCubit? myProfileCubit;
|
static ProfileCubit? myProfileCubit;
|
||||||
@@ -55,10 +54,9 @@ class ProfileCubit extends Cubit<ProfileState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> getMyProfile({String period = '1'}) async {
|
Future<void> getMyProfile() async {
|
||||||
myProfileCubit = this;
|
myProfileCubit = this;
|
||||||
emit(const ProfileState.loading());
|
emit(const ProfileState.loading());
|
||||||
_currentPeriod = period;
|
|
||||||
try {
|
try {
|
||||||
final profile = await profileRepository.getMyProfile();
|
final profile = await profileRepository.getMyProfile();
|
||||||
List<ConsultantCertPhotoModel> certPhotos = await consultantRepository.getConsultantCertPhotos();
|
List<ConsultantCertPhotoModel> certPhotos = await consultantRepository.getConsultantCertPhotos();
|
||||||
@@ -69,8 +67,6 @@ class ProfileCubit extends Cubit<ProfileState> {
|
|||||||
isMyProfile: true,
|
isMyProfile: true,
|
||||||
profileDetails: await profileRepository.getMyProfileDetails(),
|
profileDetails: await profileRepository.getMyProfileDetails(),
|
||||||
blogPosts: await profileRepository.getBlogPostsByAuthorSlug(slug: profile.slug),
|
blogPosts: await profileRepository.getBlogPostsByAuthorSlug(slug: profile.slug),
|
||||||
weightPoints: await profileRepository.getWeight(period: period),
|
|
||||||
circumferencePoints: await profileRepository.getCircumferences(period: period),
|
|
||||||
consultantInfo: profile.roles.contains('consultant') ? await consultantRepository.getConsultantRequestStatus(profile.slug) : null,
|
consultantInfo: profile.roles.contains('consultant') ? await consultantRepository.getConsultantRequestStatus(profile.slug) : null,
|
||||||
certPhotos: certPhotos,
|
certPhotos: certPhotos,
|
||||||
),
|
),
|
||||||
@@ -110,8 +106,6 @@ class ProfileCubit extends Cubit<ProfileState> {
|
|||||||
profile: (state as ProfileStateLoaded).profile,
|
profile: (state as ProfileStateLoaded).profile,
|
||||||
profileDetails: (state as ProfileStateLoaded).profileDetails,
|
profileDetails: (state as ProfileStateLoaded).profileDetails,
|
||||||
blogPosts: feedBlogPostList,
|
blogPosts: feedBlogPostList,
|
||||||
weightPoints: (state as ProfileStateLoaded).weightPoints,
|
|
||||||
circumferencePoints: (state as ProfileStateLoaded).circumferencePoints,
|
|
||||||
consultantInfo: (state as ProfileStateLoaded).consultantInfo,
|
consultantInfo: (state as ProfileStateLoaded).consultantInfo,
|
||||||
isMyProfile: (state as ProfileStateLoaded).isMyProfile,
|
isMyProfile: (state as ProfileStateLoaded).isMyProfile,
|
||||||
certPhotos: (state as ProfileStateLoaded).certPhotos,
|
certPhotos: (state as ProfileStateLoaded).certPhotos,
|
||||||
@@ -126,7 +120,6 @@ class ProfileCubit extends Cubit<ProfileState> {
|
|||||||
waistCircumference: waistCircumference,
|
waistCircumference: waistCircumference,
|
||||||
hipCircumference: hipCircumference,
|
hipCircumference: hipCircumference,
|
||||||
);
|
);
|
||||||
await getMyProfile(period: _currentPeriod);
|
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
emit(ProfileState.error(errorMessage: e.toString()));
|
emit(ProfileState.error(errorMessage: e.toString()));
|
||||||
@@ -136,7 +129,6 @@ class ProfileCubit extends Cubit<ProfileState> {
|
|||||||
Future<void> addWeight({required double weight}) async {
|
Future<void> addWeight({required double weight}) async {
|
||||||
try {
|
try {
|
||||||
await profileRepository.addWeight(weight: weight);
|
await profileRepository.addWeight(weight: weight);
|
||||||
await getMyProfile(period: _currentPeriod);
|
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
emit(ProfileState.error(errorMessage: e.toString()));
|
emit(ProfileState.error(errorMessage: e.toString()));
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||||
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
|
||||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
part 'profile_state.freezed.dart';
|
part 'profile_state.freezed.dart';
|
||||||
@@ -20,8 +18,6 @@ abstract class ProfileState with _$ProfileState {
|
|||||||
required List<FeedBlogPostModel> blogPosts,
|
required List<FeedBlogPostModel> blogPosts,
|
||||||
required bool isMyProfile,
|
required bool isMyProfile,
|
||||||
required List<ConsultantCertPhotoModel> certPhotos,
|
required List<ConsultantCertPhotoModel> certPhotos,
|
||||||
List<WeightModel>? weightPoints,
|
|
||||||
List<CircumferenceModel>? circumferencePoints,
|
|
||||||
ConsultationRequestModel? consultantInfo,
|
ConsultationRequestModel? consultantInfo,
|
||||||
}) = ProfileStateLoaded;
|
}) = ProfileStateLoaded;
|
||||||
|
|
||||||
|
|||||||
@@ -18,21 +18,17 @@ import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/prof
|
|||||||
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
||||||
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
|
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/widgets/plan_widget.dart';
|
import 'package:eatvio_app/features/feed/presentation/widgets/plan_widget.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.dart';
|
||||||
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
|
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
|
||||||
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
|
||||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
|
||||||
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart';
|
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart';
|
||||||
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_state.dart';
|
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_state.dart';
|
||||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart';
|
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart';
|
||||||
import 'package:eatvio_app/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart';
|
import 'package:eatvio_app/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart';
|
||||||
import 'package:eatvio_app/features/profile/presentation/pages/subs_screen.dart';
|
import 'package:eatvio_app/features/profile/presentation/pages/subs_screen.dart';
|
||||||
import 'package:eatvio_app/features/profile/presentation/widgets/circumference_chart_widget.dart';
|
|
||||||
import 'package:eatvio_app/features/profile/presentation/widgets/expandable_page_view.dart';
|
import 'package:eatvio_app/features/profile/presentation/widgets/expandable_page_view.dart';
|
||||||
import 'package:eatvio_app/features/profile/presentation/widgets/imt_chart_widget.dart';
|
|
||||||
import 'package:eatvio_app/features/profile/presentation/widgets/profile_blog_post_widget.dart';
|
import 'package:eatvio_app/features/profile/presentation/widgets/profile_blog_post_widget.dart';
|
||||||
import 'package:eatvio_app/features/profile/presentation/widgets/toggle_cert_photo_bottom_sheet.dart';
|
import 'package:eatvio_app/features/profile/presentation/widgets/toggle_cert_photo_bottom_sheet.dart';
|
||||||
import 'package:eatvio_app/features/profile/presentation/widgets/weight_chart_widget.dart';
|
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
import 'package:eatvio_app/generated/l10n.dart';
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
@@ -60,21 +56,8 @@ class ProfileScreen extends StatefulWidget {
|
|||||||
class _ProfileScreenState extends State<ProfileScreen> {
|
class _ProfileScreenState extends State<ProfileScreen> {
|
||||||
late final ProfileCubit profileCubit = sl<ProfileCubit>();
|
late final ProfileCubit profileCubit = sl<ProfileCubit>();
|
||||||
final PlansCubit plansCubit = sl<PlansCubit>();
|
final PlansCubit plansCubit = sl<PlansCubit>();
|
||||||
final List<String> chipsLabelList = ['Вес', 'Замеры', 'ИМТ'];
|
|
||||||
final List<String> chipsIntervalLabelList = [
|
|
||||||
'1М',
|
|
||||||
'3М',
|
|
||||||
'6М',
|
|
||||||
'1Г',
|
|
||||||
'ВСЁ ВРЕМЯ',
|
|
||||||
];
|
|
||||||
final List<String> chipsIntervalList = ['1', '3', '6', '12', 'all'];
|
|
||||||
final TextEditingController controller = TextEditingController();
|
final TextEditingController controller = TextEditingController();
|
||||||
|
|
||||||
List<WeightModel> weightPoints = [];
|
|
||||||
List<CircumferenceModel> circumferencePoints = [];
|
|
||||||
int _selectedChipIndex = 0;
|
|
||||||
int _selectedIntervalChipIndex = 0;
|
|
||||||
late bool canSub;
|
late bool canSub;
|
||||||
bool isFirstFetch = true;
|
bool isFirstFetch = true;
|
||||||
int _currentBullet = 0;
|
int _currentBullet = 0;
|
||||||
@@ -96,10 +79,6 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|||||||
bloc: profileCubit,
|
bloc: profileCubit,
|
||||||
builder: (BuildContext context, ProfileState state) {
|
builder: (BuildContext context, ProfileState state) {
|
||||||
if (state is ProfileStateLoaded) {
|
if (state is ProfileStateLoaded) {
|
||||||
if (state.circumferencePoints != null) {
|
|
||||||
circumferencePoints = state.circumferencePoints!;
|
|
||||||
weightPoints = state.weightPoints!;
|
|
||||||
}
|
|
||||||
if (isFirstFetch) {
|
if (isFirstFetch) {
|
||||||
canSub = !state.profile.isEntitySubscribedByMe;
|
canSub = !state.profile.isEntitySubscribedByMe;
|
||||||
if (state.profile.roles.contains('consultant') && !hidePlans) {
|
if (state.profile.roles.contains('consultant') && !hidePlans) {
|
||||||
@@ -281,6 +260,14 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|||||||
padding: const EdgeInsets.only(bottom: 16),
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
child: _badges(state),
|
child: _badges(state),
|
||||||
),
|
),
|
||||||
|
// Доступ решает сервер (UserPolicy::viewInsights): консультант видит
|
||||||
|
// клиента, пока консультация не завершена; всем остальным 403 и блок скрыт.
|
||||||
|
if (!state.profile.isBlockedByMe &&
|
||||||
|
!state.profile.isBlockedByAuthor)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: InsightsCard(slug: state.profile.slug),
|
||||||
|
),
|
||||||
if (state.profile.isBlockedByAuthor)
|
if (state.profile.isBlockedByAuthor)
|
||||||
Column(
|
Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -442,10 +429,11 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|||||||
child: _badges(state),
|
child: _badges(state),
|
||||||
),
|
),
|
||||||
if (state.profile.roles.contains('consultant')) _socialLinks(state),
|
if (state.profile.roles.contains('consultant')) _socialLinks(state),
|
||||||
Padding(
|
if (state.isMyProfile)
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
const Padding(
|
||||||
child: _charts(),
|
padding: EdgeInsets.only(bottom: 16),
|
||||||
),
|
child: InsightsCard(canAdd: true),
|
||||||
|
),
|
||||||
if (state.profile.roles.contains('consultant')) _education(state),
|
if (state.profile.roles.contains('consultant')) _education(state),
|
||||||
if (state.profile.roles.contains('consultant'))
|
if (state.profile.roles.contains('consultant'))
|
||||||
_diplomasAndCertificates(state),
|
_diplomasAndCertificates(state),
|
||||||
@@ -1722,114 +1710,4 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Material _charts() {
|
|
||||||
return Material(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
_choiceChipWidget(0),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_choiceChipWidget(1),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_choiceChipWidget(2),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Builder(
|
|
||||||
builder: (context) {
|
|
||||||
switch (_selectedChipIndex) {
|
|
||||||
case 0:
|
|
||||||
return WeightChartWidget(
|
|
||||||
weightPoints: weightPoints,
|
|
||||||
profileCubit: profileCubit,
|
|
||||||
);
|
|
||||||
case 1:
|
|
||||||
return CircumferenceChartWidget(
|
|
||||||
circumferencePoints: circumferencePoints,
|
|
||||||
profileCubit: profileCubit,
|
|
||||||
);
|
|
||||||
case 2:
|
|
||||||
return ImtChartWidget(weightPoints: weightPoints);
|
|
||||||
default:
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
_choiceIntervalChipWidget(0),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_choiceIntervalChipWidget(1),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_choiceIntervalChipWidget(2),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_choiceIntervalChipWidget(3),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_choiceIntervalChipWidget(4),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ChoiceChip _choiceIntervalChipWidget(int index) {
|
|
||||||
return ChoiceChip(
|
|
||||||
selectedColor: Colors.grey.withValues(alpha: 0.1),
|
|
||||||
pressElevation: 0,
|
|
||||||
showCheckmark: false,
|
|
||||||
side: BorderSide.none,
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
||||||
label: Text(chipsIntervalLabelList[index]),
|
|
||||||
labelStyle: TextStyle(
|
|
||||||
color: appTheme.gray600,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
selected: _selectedIntervalChipIndex == index,
|
|
||||||
onSelected: (bool selected) {
|
|
||||||
profileCubit.getMyProfile(period: chipsIntervalList[index]);
|
|
||||||
setState(() {
|
|
||||||
_selectedIntervalChipIndex = index;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ChoiceChip _choiceChipWidget(int index) {
|
|
||||||
return ChoiceChip(
|
|
||||||
selectedColor: appTheme.blueA400,
|
|
||||||
pressElevation: 0,
|
|
||||||
showCheckmark: false,
|
|
||||||
side: BorderSide.none,
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
||||||
label: Text(chipsLabelList[index]),
|
|
||||||
labelStyle: TextStyle(
|
|
||||||
color: index == _selectedChipIndex ? Colors.white : appTheme.gray600,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
selected: _selectedChipIndex == index,
|
|
||||||
backgroundColor: appTheme.gray100,
|
|
||||||
onSelected: (bool selected) {
|
|
||||||
setState(() {
|
|
||||||
_selectedChipIndex = index;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,389 +0,0 @@
|
|||||||
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
|
||||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
|
||||||
import 'package:flutter/cupertino.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
|
||||||
|
|
||||||
class CircumferenceChartWidget extends StatefulWidget {
|
|
||||||
final List<CircumferenceModel> circumferencePoints;
|
|
||||||
final ProfileCubit? profileCubit;
|
|
||||||
|
|
||||||
const CircumferenceChartWidget({
|
|
||||||
super.key,
|
|
||||||
required this.circumferencePoints,
|
|
||||||
this.profileCubit,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<CircumferenceChartWidget> createState() => _CircumferenceChartWidgetState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CircumferenceChartWidgetState extends State<CircumferenceChartWidget> {
|
|
||||||
late FixedExtentScrollController chestController;
|
|
||||||
late FixedExtentScrollController waistController;
|
|
||||||
late FixedExtentScrollController hipController;
|
|
||||||
late int chest;
|
|
||||||
late int waist;
|
|
||||||
late int hip;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
if (widget.circumferencePoints.isNotEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'${widget.circumferencePoints.last.chestCircumference}',
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.black,
|
|
||||||
fontSize: 40,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.circumferencePoints.length == 1)
|
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: Colors.black12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.circumferencePoints.length > 1)
|
|
||||||
widget.circumferencePoints.last.chestCircumference > widget.circumferencePoints[widget.circumferencePoints.length - 2].chestCircumference
|
|
||||||
? const Icon(
|
|
||||||
Icons.arrow_drop_up,
|
|
||||||
size: 40,
|
|
||||||
color: Colors.grey,
|
|
||||||
)
|
|
||||||
: const Icon(
|
|
||||||
Icons.arrow_drop_down,
|
|
||||||
size: 40,
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'${widget.circumferencePoints.last.waistCircumference}',
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.black,
|
|
||||||
fontSize: 40,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.circumferencePoints.length == 1)
|
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: Colors.black12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.circumferencePoints.length > 1)
|
|
||||||
widget.circumferencePoints.last.waistCircumference > widget.circumferencePoints[widget.circumferencePoints.length - 2].waistCircumference
|
|
||||||
? const Icon(
|
|
||||||
Icons.arrow_drop_up,
|
|
||||||
size: 40,
|
|
||||||
color: Colors.grey,
|
|
||||||
)
|
|
||||||
: const Icon(
|
|
||||||
Icons.arrow_drop_down,
|
|
||||||
size: 40,
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'${widget.circumferencePoints.last.hipCircumference}',
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.black,
|
|
||||||
fontSize: 40,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.circumferencePoints.length == 1)
|
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: Colors.black12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.circumferencePoints.length > 1)
|
|
||||||
widget.circumferencePoints.last.hipCircumference > widget.circumferencePoints[widget.circumferencePoints.length - 2].hipCircumference
|
|
||||||
? const Icon(
|
|
||||||
Icons.arrow_drop_up,
|
|
||||||
size: 40,
|
|
||||||
color: Colors.grey,
|
|
||||||
)
|
|
||||||
: const Icon(
|
|
||||||
Icons.arrow_drop_down,
|
|
||||||
size: 40,
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Stack(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
children: [
|
|
||||||
SfCartesianChart(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
|
||||||
enableAxisAnimation: true,
|
|
||||||
plotAreaBorderWidth: 0,
|
|
||||||
trackballBehavior: TrackballBehavior(
|
|
||||||
enable: true,
|
|
||||||
activationMode: ActivationMode.singleTap,
|
|
||||||
tooltipDisplayMode: TrackballDisplayMode.groupAllPoints,
|
|
||||||
tooltipSettings: const InteractiveTooltip(),
|
|
||||||
lineType: TrackballLineType.vertical,
|
|
||||||
),
|
|
||||||
primaryXAxis: DateTimeCategoryAxis(
|
|
||||||
tickPosition: TickPosition.inside,
|
|
||||||
majorTickLines: const MajorTickLines(
|
|
||||||
size: 0,
|
|
||||||
),
|
|
||||||
edgeLabelPlacement: EdgeLabelPlacement.shift,
|
|
||||||
dateFormat: DateFormat.MMMd('ru'),
|
|
||||||
labelStyle: const TextStyle(
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
majorGridLines: MajorGridLines(dashArray: const [1], color: Colors.grey.withValues(alpha: 0.3)),
|
|
||||||
axisLine: AxisLine(
|
|
||||||
color: Colors.grey.withValues(alpha: 0.3),
|
|
||||||
width: 0.4,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
primaryYAxis: const NumericAxis(
|
|
||||||
isVisible: false,
|
|
||||||
labelFormat: '{value} см',
|
|
||||||
axisLine: AxisLine(
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
majorTickLines: MajorTickLines(size: 0),
|
|
||||||
),
|
|
||||||
series: _getSplineSeries(),
|
|
||||||
tooltipBehavior: TooltipBehavior(enable: true),
|
|
||||||
),
|
|
||||||
if (widget.profileCubit != null)
|
|
||||||
Positioned(
|
|
||||||
right: 16,
|
|
||||||
bottom: 16,
|
|
||||||
child: FloatingActionButton.small(
|
|
||||||
heroTag: 'addCircumference',
|
|
||||||
onPressed: () {
|
|
||||||
if (widget.circumferencePoints.isNotEmpty) {
|
|
||||||
chest = widget.circumferencePoints.last.chestCircumference;
|
|
||||||
waist = widget.circumferencePoints.last.waistCircumference;
|
|
||||||
hip = widget.circumferencePoints.last.hipCircumference;
|
|
||||||
chestController = FixedExtentScrollController(initialItem: chest - 20);
|
|
||||||
waistController = FixedExtentScrollController(initialItem: waist - 20);
|
|
||||||
hipController = FixedExtentScrollController(initialItem: hip - 20);
|
|
||||||
} else {
|
|
||||||
chestController = FixedExtentScrollController();
|
|
||||||
waistController = FixedExtentScrollController();
|
|
||||||
hipController = FixedExtentScrollController();
|
|
||||||
}
|
|
||||||
showModalBottomSheet(
|
|
||||||
useRootNavigator: true,
|
|
||||||
context: context,
|
|
||||||
builder: (context) {
|
|
||||||
return SafeArea(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: CupertinoPicker(
|
|
||||||
magnification: 1.22,
|
|
||||||
squeeze: 1.2,
|
|
||||||
useMagnifier: true,
|
|
||||||
scrollController: chestController,
|
|
||||||
itemExtent: 24,
|
|
||||||
onSelectedItemChanged: (int value) {
|
|
||||||
chest = value;
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
for (int i = 20; i < 200; i++) Text('$i'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: CupertinoPicker(
|
|
||||||
magnification: 1.22,
|
|
||||||
squeeze: 1.2,
|
|
||||||
useMagnifier: true,
|
|
||||||
scrollController: waistController,
|
|
||||||
itemExtent: 24,
|
|
||||||
onSelectedItemChanged: (int value) {
|
|
||||||
waist = value;
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
for (int i = 20; i < 200; i++) Text('$i'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: CupertinoPicker(
|
|
||||||
magnification: 1.22,
|
|
||||||
squeeze: 1.2,
|
|
||||||
useMagnifier: true,
|
|
||||||
scrollController: hipController,
|
|
||||||
itemExtent: 24,
|
|
||||||
onSelectedItemChanged: (int value) {
|
|
||||||
hip = value;
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
for (int i = 20; i < 200; i++) Text('$i'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
InkWell(
|
|
||||||
onTap: () {
|
|
||||||
widget.profileCubit?.addCircumference(
|
|
||||||
chestCircumference: chest + 20,
|
|
||||||
waistCircumference: waist + 20,
|
|
||||||
hipCircumference: hip + 20,
|
|
||||||
);
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
},
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: Ink(
|
|
||||||
width: MediaQuery.of(context).size.width - 32,
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: appTheme.blueA400,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
child: const Center(
|
|
||||||
child: Text(
|
|
||||||
'Сохранить',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
backgroundColor: appTheme.blueA400,
|
|
||||||
shape: const CircleBorder(),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.add,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.circumferencePoints.isEmpty)
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 48),
|
|
||||||
child: Text(
|
|
||||||
'Добавьте Ваши замеры',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<CartesianSeries<CircumferenceModel, DateTime>> _getSplineSeries() {
|
|
||||||
const Color seriesColor1 = Colors.red;
|
|
||||||
final Color seriesColor2 = appTheme.blueA400;
|
|
||||||
const Color seriesColor3 = Colors.green;
|
|
||||||
|
|
||||||
return <CartesianSeries<CircumferenceModel, DateTime>>[
|
|
||||||
SplineAreaSeries<CircumferenceModel, DateTime>(
|
|
||||||
markerSettings: const MarkerSettings(
|
|
||||||
isVisible: true,
|
|
||||||
color: seriesColor1,
|
|
||||||
height: 8,
|
|
||||||
width: 8,
|
|
||||||
),
|
|
||||||
dataLabelSettings: const DataLabelSettings(
|
|
||||||
labelAlignment: ChartDataLabelAlignment.top,
|
|
||||||
isVisible: true,
|
|
||||||
),
|
|
||||||
dataSource: widget.circumferencePoints,
|
|
||||||
borderColor: seriesColor1,
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [
|
|
||||||
seriesColor1.withValues(alpha: 0.14),
|
|
||||||
Colors.transparent,
|
|
||||||
],
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: const Alignment(0, 0.6),
|
|
||||||
),
|
|
||||||
name: 'Грудь',
|
|
||||||
xValueMapper: (CircumferenceModel value, int index) => value.createdAt,
|
|
||||||
yValueMapper: (CircumferenceModel value, int index) => value.chestCircumference,
|
|
||||||
),
|
|
||||||
SplineAreaSeries<CircumferenceModel, DateTime>(
|
|
||||||
markerSettings: MarkerSettings(
|
|
||||||
isVisible: true,
|
|
||||||
color: seriesColor2,
|
|
||||||
height: 8,
|
|
||||||
width: 8,
|
|
||||||
),
|
|
||||||
dataLabelSettings: const DataLabelSettings(
|
|
||||||
labelAlignment: ChartDataLabelAlignment.top,
|
|
||||||
isVisible: true,
|
|
||||||
),
|
|
||||||
dataSource: widget.circumferencePoints,
|
|
||||||
borderColor: seriesColor2,
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [
|
|
||||||
seriesColor2.withValues(alpha: 0.14),
|
|
||||||
Colors.transparent,
|
|
||||||
],
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: const Alignment(0, 0.6),
|
|
||||||
),
|
|
||||||
name: 'Талия',
|
|
||||||
xValueMapper: (CircumferenceModel value, int index) => value.createdAt,
|
|
||||||
yValueMapper: (CircumferenceModel value, int index) => value.waistCircumference,
|
|
||||||
),
|
|
||||||
SplineAreaSeries<CircumferenceModel, DateTime>(
|
|
||||||
markerSettings: const MarkerSettings(
|
|
||||||
isVisible: true,
|
|
||||||
color: seriesColor3,
|
|
||||||
height: 8,
|
|
||||||
width: 8,
|
|
||||||
),
|
|
||||||
dataLabelSettings: const DataLabelSettings(
|
|
||||||
labelAlignment: ChartDataLabelAlignment.top,
|
|
||||||
isVisible: true,
|
|
||||||
),
|
|
||||||
dataSource: widget.circumferencePoints,
|
|
||||||
borderColor: seriesColor3,
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [
|
|
||||||
seriesColor3.withValues(alpha: 0.14),
|
|
||||||
Colors.transparent,
|
|
||||||
],
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: const Alignment(0, 0.6),
|
|
||||||
),
|
|
||||||
name: 'Бёдра',
|
|
||||||
xValueMapper: (CircumferenceModel value, int index) => value.createdAt,
|
|
||||||
yValueMapper: (CircumferenceModel value, int index) => value.hipCircumference,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
class ImtChartWidget extends StatelessWidget {
|
|
||||||
final List<WeightModel> weightPoints;
|
|
||||||
|
|
||||||
const ImtChartWidget({
|
|
||||||
super.key,
|
|
||||||
required this.weightPoints,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
if (weightPoints.isNotEmpty)
|
|
||||||
Text(
|
|
||||||
weightPoints.last.bmi.toStringAsFixed(2),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.black,
|
|
||||||
fontSize: 40,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (weightPoints.length > 1)
|
|
||||||
weightPoints.last.bmi > weightPoints[weightPoints.length - 2].bmi
|
|
||||||
? const Icon(
|
|
||||||
Icons.arrow_drop_up,
|
|
||||||
size: 40,
|
|
||||||
color: Colors.grey,
|
|
||||||
)
|
|
||||||
: const Icon(
|
|
||||||
Icons.arrow_drop_down,
|
|
||||||
size: 40,
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Stack(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
children: [
|
|
||||||
SfCartesianChart(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
|
||||||
enableAxisAnimation: true,
|
|
||||||
plotAreaBorderWidth: 0,
|
|
||||||
trackballBehavior: TrackballBehavior(
|
|
||||||
enable: true,
|
|
||||||
activationMode: ActivationMode.singleTap,
|
|
||||||
tooltipDisplayMode: TrackballDisplayMode.groupAllPoints,
|
|
||||||
tooltipSettings: const InteractiveTooltip(),
|
|
||||||
lineType: TrackballLineType.vertical,
|
|
||||||
),
|
|
||||||
primaryXAxis: DateTimeCategoryAxis(
|
|
||||||
tickPosition: TickPosition.inside,
|
|
||||||
majorTickLines: const MajorTickLines(
|
|
||||||
size: 0,
|
|
||||||
),
|
|
||||||
edgeLabelPlacement: EdgeLabelPlacement.shift,
|
|
||||||
dateFormat: DateFormat.MMMd('ru'),
|
|
||||||
labelStyle: const TextStyle(
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
majorGridLines: MajorGridLines(dashArray: const [1], color: Colors.grey.withValues(alpha: 0.3)),
|
|
||||||
axisLine: AxisLine(
|
|
||||||
color: Colors.grey.withValues(alpha: 0.3),
|
|
||||||
width: 0.4,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
primaryYAxis: const NumericAxis(
|
|
||||||
isVisible: false,
|
|
||||||
),
|
|
||||||
series: _getSplineSeries(),
|
|
||||||
tooltipBehavior: TooltipBehavior(enable: true),
|
|
||||||
),
|
|
||||||
if (weightPoints.isEmpty)
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 32),
|
|
||||||
child: Text(
|
|
||||||
'Индекс Массы Тела рассчитывается при указанных весе и росте',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<CartesianSeries<WeightModel, DateTime>> _getSplineSeries() {
|
|
||||||
return <CartesianSeries<WeightModel, DateTime>>[
|
|
||||||
SplineAreaSeries<WeightModel, DateTime>(
|
|
||||||
markerSettings: MarkerSettings(
|
|
||||||
isVisible: true,
|
|
||||||
color: appTheme.blueA400,
|
|
||||||
height: 8,
|
|
||||||
width: 8,
|
|
||||||
),
|
|
||||||
dataLabelSettings: const DataLabelSettings(
|
|
||||||
labelAlignment: ChartDataLabelAlignment.top,
|
|
||||||
isVisible: true,
|
|
||||||
),
|
|
||||||
dataSource: weightPoints,
|
|
||||||
borderColor: appTheme.blueA400,
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [
|
|
||||||
appTheme.blueA400.withValues(alpha: 0.14),
|
|
||||||
Colors.transparent,
|
|
||||||
],
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: const Alignment(0, 0.6),
|
|
||||||
),
|
|
||||||
name: 'ИМТ',
|
|
||||||
xValueMapper: (WeightModel value, int index) => value.createdAt,
|
|
||||||
yValueMapper: (WeightModel value, int index) => double.parse(value.bmi.toStringAsFixed(2)),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
|
||||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
|
||||||
import 'package:flutter/cupertino.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
|
||||||
|
|
||||||
class WeightChartWidget extends StatefulWidget {
|
|
||||||
final List<WeightModel> weightPoints;
|
|
||||||
final ProfileCubit? profileCubit;
|
|
||||||
|
|
||||||
const WeightChartWidget({
|
|
||||||
super.key,
|
|
||||||
required this.weightPoints,
|
|
||||||
this.profileCubit,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<WeightChartWidget> createState() => _WeightChartWidgetState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _WeightChartWidgetState extends State<WeightChartWidget> {
|
|
||||||
late FixedExtentScrollController weightKiloController;
|
|
||||||
late FixedExtentScrollController weightGramsController;
|
|
||||||
int weightKilo = 20;
|
|
||||||
int weightGrams = 0;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
if (widget.weightPoints.isNotEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'${widget.weightPoints.last.weight.toStringAsFixed(1)} кг',
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.black,
|
|
||||||
fontSize: 40,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.weightPoints.length == 1)
|
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: Colors.black12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.weightPoints.length > 1)
|
|
||||||
widget.weightPoints.last.weight > widget.weightPoints[widget.weightPoints.length - 2].weight
|
|
||||||
? Icon(
|
|
||||||
Icons.arrow_drop_up,
|
|
||||||
size: 40,
|
|
||||||
color: Colors.grey.withValues(alpha: 0.4),
|
|
||||||
)
|
|
||||||
: Icon(
|
|
||||||
Icons.arrow_drop_down,
|
|
||||||
size: 40,
|
|
||||||
color: Colors.grey.withValues(alpha: 0.4),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Stack(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
children: [
|
|
||||||
SfCartesianChart(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
|
||||||
enableAxisAnimation: true,
|
|
||||||
plotAreaBorderWidth: 0,
|
|
||||||
trackballBehavior: TrackballBehavior(
|
|
||||||
enable: true,
|
|
||||||
activationMode: ActivationMode.singleTap,
|
|
||||||
tooltipDisplayMode: TrackballDisplayMode.groupAllPoints,
|
|
||||||
tooltipSettings: const InteractiveTooltip(),
|
|
||||||
lineType: TrackballLineType.vertical,
|
|
||||||
),
|
|
||||||
primaryXAxis: DateTimeCategoryAxis(
|
|
||||||
tickPosition: TickPosition.inside,
|
|
||||||
majorTickLines: const MajorTickLines(
|
|
||||||
size: 0,
|
|
||||||
),
|
|
||||||
edgeLabelPlacement: EdgeLabelPlacement.shift,
|
|
||||||
dateFormat: DateFormat.MMMd('ru'),
|
|
||||||
labelStyle: const TextStyle(
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
majorGridLines: MajorGridLines(dashArray: const [1], color: Colors.grey.withValues(alpha: 0.3)),
|
|
||||||
axisLine: AxisLine(
|
|
||||||
color: Colors.grey.withValues(alpha: 0.3),
|
|
||||||
width: 0.4,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
primaryYAxis: const NumericAxis(
|
|
||||||
isVisible: false,
|
|
||||||
labelFormat: '{value} кг',
|
|
||||||
majorGridLines: MajorGridLines(dashArray: [1], color: Colors.grey),
|
|
||||||
axisLine: AxisLine(
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
majorTickLines: MajorTickLines(size: 0),
|
|
||||||
),
|
|
||||||
series: _getSplineSeries(),
|
|
||||||
tooltipBehavior: TooltipBehavior(enable: true),
|
|
||||||
),
|
|
||||||
if (widget.profileCubit != null)
|
|
||||||
Positioned(
|
|
||||||
right: 16,
|
|
||||||
bottom: 16,
|
|
||||||
child: FloatingActionButton.small(
|
|
||||||
heroTag: 'addWeight',
|
|
||||||
onPressed: () {
|
|
||||||
if (widget.weightPoints.isNotEmpty) {
|
|
||||||
try {
|
|
||||||
weightKilo = int.parse(widget.weightPoints.last.weight.toString().split('.')[0]);
|
|
||||||
weightGrams = int.parse(widget.weightPoints.last.weight.toString().split('.')[1][0]);
|
|
||||||
} catch (e) {
|
|
||||||
weightGrams = 0;
|
|
||||||
}
|
|
||||||
weightKiloController = FixedExtentScrollController(initialItem: weightKilo - 20);
|
|
||||||
weightGramsController = FixedExtentScrollController(initialItem: weightGrams);
|
|
||||||
} else {
|
|
||||||
weightKiloController = FixedExtentScrollController();
|
|
||||||
weightGramsController = FixedExtentScrollController();
|
|
||||||
}
|
|
||||||
showModalBottomSheet(
|
|
||||||
useRootNavigator: true,
|
|
||||||
context: context,
|
|
||||||
builder: (context) {
|
|
||||||
return SafeArea(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: CupertinoPicker(
|
|
||||||
magnification: 1.22,
|
|
||||||
squeeze: 1.2,
|
|
||||||
useMagnifier: true,
|
|
||||||
scrollController: weightKiloController,
|
|
||||||
itemExtent: 24,
|
|
||||||
onSelectedItemChanged: (int value) {
|
|
||||||
weightKilo = value + 20;
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
for (int i = 20; i < 300; i++) Text('$i'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: CupertinoPicker(
|
|
||||||
magnification: 1.22,
|
|
||||||
squeeze: 1.2,
|
|
||||||
useMagnifier: true,
|
|
||||||
scrollController: weightGramsController,
|
|
||||||
itemExtent: 24,
|
|
||||||
onSelectedItemChanged: (int value) {
|
|
||||||
weightGrams = value;
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
for (int i = 0; i < 10; i++) Text('$i'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
InkWell(
|
|
||||||
onTap: () {
|
|
||||||
widget.profileCubit?.addWeight(weight: double.parse('$weightKilo.$weightGrams'));
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
},
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: Ink(
|
|
||||||
width: MediaQuery.of(context).size.width - 32,
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: appTheme.blueA400,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
child: const Center(
|
|
||||||
child: Text(
|
|
||||||
'Сохранить',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
backgroundColor: appTheme.blueA400,
|
|
||||||
shape: const CircleBorder(),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.add,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.weightPoints.isEmpty) const Text('Добавьте Ваш вес'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<CartesianSeries<WeightModel, DateTime>> _getSplineSeries() {
|
|
||||||
return <CartesianSeries<WeightModel, DateTime>>[
|
|
||||||
SplineAreaSeries<WeightModel, DateTime>(
|
|
||||||
markerSettings: MarkerSettings(
|
|
||||||
isVisible: true,
|
|
||||||
color: appTheme.blueA400,
|
|
||||||
height: 8,
|
|
||||||
width: 8,
|
|
||||||
),
|
|
||||||
dataLabelSettings: const DataLabelSettings(
|
|
||||||
labelAlignment: ChartDataLabelAlignment.top,
|
|
||||||
isVisible: true,
|
|
||||||
),
|
|
||||||
dataSource: widget.weightPoints,
|
|
||||||
borderColor: appTheme.blueA400,
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [
|
|
||||||
appTheme.blueA400.withValues(alpha: 0.14),
|
|
||||||
Colors.transparent,
|
|
||||||
],
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: const Alignment(0, 0.6),
|
|
||||||
),
|
|
||||||
name: 'Вес',
|
|
||||||
xValueMapper: (WeightModel value, int index) => value.createdAt,
|
|
||||||
yValueMapper: (WeightModel value, int index) => value.weight,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,23 +14,10 @@ class UserDetailsCubit extends Cubit<UserDetailsState> {
|
|||||||
try {
|
try {
|
||||||
final profileDetails = await profileRepository.getProfileDetailsBySlug(slug);
|
final profileDetails = await profileRepository.getProfileDetailsBySlug(slug);
|
||||||
emit(state.copyWith(profileDetails: profileDetails, isLoading: false));
|
emit(state.copyWith(profileDetails: profileDetails, isLoading: false));
|
||||||
getChartData(slug: slug);
|
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
log(e.toString());
|
log(e.toString());
|
||||||
emit(state.copyWith(error: e.toString(), isLoading: false));
|
emit(state.copyWith(error: e.toString(), isLoading: false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void getChartData({String period = '1', required String slug}) async {
|
|
||||||
try {
|
|
||||||
final weightPoints = await profileRepository.getWeight(period: period, slug: slug);
|
|
||||||
final circumferencePoints = await profileRepository.getCircumferences(period: period, slug: slug);
|
|
||||||
emit(state.copyWith(weightPoints: weightPoints, circumferencePoints: circumferencePoints));
|
|
||||||
} catch (e, stackTrace) {
|
|
||||||
await reportFrom(e, stackTrace, this);
|
|
||||||
log(e.toString(), stackTrace: stackTrace);
|
|
||||||
emit(state.copyWith(error: e.toString(), isLoading: false));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
|
||||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
part 'user_details_state.freezed.dart';
|
part 'user_details_state.freezed.dart';
|
||||||
@@ -11,7 +9,5 @@ abstract class UserDetailsState with _$UserDetailsState {
|
|||||||
@Default(false) bool isLoading,
|
@Default(false) bool isLoading,
|
||||||
String? error,
|
String? error,
|
||||||
ProfileDetailsModel? profileDetails,
|
ProfileDetailsModel? profileDetails,
|
||||||
List<WeightModel>? weightPoints,
|
|
||||||
List<CircumferenceModel>? circumferencePoints,
|
|
||||||
}) = _UserDetailsState;
|
}) = _UserDetailsState;
|
||||||
}
|
}
|
||||||
@@ -8,11 +8,7 @@ import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
|
|||||||
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
|
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
|
||||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||||
import 'package:eatvio_app/features/diets/presentation/widgets/more_options.dart';
|
import 'package:eatvio_app/features/diets/presentation/widgets/more_options.dart';
|
||||||
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.dart';
|
||||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
|
||||||
import 'package:eatvio_app/features/profile/presentation/widgets/circumference_chart_widget.dart';
|
|
||||||
import 'package:eatvio_app/features/profile/presentation/widgets/imt_chart_widget.dart';
|
|
||||||
import 'package:eatvio_app/features/profile/presentation/widgets/weight_chart_widget.dart';
|
|
||||||
import 'package:eatvio_app/features/user_details/presentation/cubit/user_details_cubit.dart';
|
import 'package:eatvio_app/features/user_details/presentation/cubit/user_details_cubit.dart';
|
||||||
import 'package:eatvio_app/features/user_details/presentation/cubit/user_details_state.dart';
|
import 'package:eatvio_app/features/user_details/presentation/cubit/user_details_state.dart';
|
||||||
import 'package:eatvio_app/features/user_details/presentation/widgets/expandable_bouble.dart';
|
import 'package:eatvio_app/features/user_details/presentation/widgets/expandable_bouble.dart';
|
||||||
@@ -44,13 +40,6 @@ class UserDetailsContent extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _UserDetailsContentState extends State<UserDetailsContent> {
|
class _UserDetailsContentState extends State<UserDetailsContent> {
|
||||||
final List<String> chipsLabelList = ['Вес', 'Замеры', 'ИМТ'];
|
|
||||||
final List<String> chipsIntervalLabelList = ['1М', '3М', '6М', '1Г', 'ВСЁ ВРЕМЯ'];
|
|
||||||
final List<String> chipsIntervalList = ['1', '3', '6', '12', 'all'];
|
|
||||||
|
|
||||||
List<WeightModel> weightPoints = [];
|
|
||||||
List<CircumferenceModel> circumferencePoints = []; int _selectedChipIndex = 0;
|
|
||||||
int _selectedIntervalChipIndex = 0;
|
|
||||||
bool isMoreOptionsVisible = false;
|
bool isMoreOptionsVisible = false;
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -59,8 +48,6 @@ class _UserDetailsContentState extends State<UserDetailsContent> {
|
|||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final profileDetails = state.profileDetails;
|
final profileDetails = state.profileDetails;
|
||||||
if (profileDetails != null && state.isLoading == false) {
|
if (profileDetails != null && state.isLoading == false) {
|
||||||
weightPoints = state.weightPoints ?? [];
|
|
||||||
circumferencePoints = state.circumferencePoints ?? [];
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: appTheme.gray100,
|
backgroundColor: appTheme.gray100,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -104,7 +91,7 @@ class _UserDetailsContentState extends State<UserDetailsContent> {
|
|||||||
spacing: 12,
|
spacing: 12,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox.shrink(),
|
const SizedBox.shrink(),
|
||||||
_charts(),
|
InsightsCard(slug: widget.slug),
|
||||||
ExpandableBouble(
|
ExpandableBouble(
|
||||||
initiallyExpanded: true,
|
initiallyExpanded: true,
|
||||||
title: s.general_info,
|
title: s.general_info,
|
||||||
@@ -309,92 +296,4 @@ class _UserDetailsContentState extends State<UserDetailsContent> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Material _charts() {
|
|
||||||
return Material(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
child: Row(children: [_choiceChipWidget(0), const SizedBox(width: 8), _choiceChipWidget(1), const SizedBox(width: 8), _choiceChipWidget(2)]),
|
|
||||||
),
|
|
||||||
Builder(
|
|
||||||
builder: (context) {
|
|
||||||
switch (_selectedChipIndex) {
|
|
||||||
case 0:
|
|
||||||
return WeightChartWidget(weightPoints: weightPoints);
|
|
||||||
case 1:
|
|
||||||
return CircumferenceChartWidget(circumferencePoints: circumferencePoints);
|
|
||||||
case 2:
|
|
||||||
return ImtChartWidget(weightPoints: weightPoints);
|
|
||||||
default:
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
_choiceIntervalChipWidget(0),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_choiceIntervalChipWidget(1),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_choiceIntervalChipWidget(2),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_choiceIntervalChipWidget(3),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_choiceIntervalChipWidget(4),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ChoiceChip _choiceIntervalChipWidget(int index) {
|
|
||||||
return ChoiceChip(
|
|
||||||
selectedColor: Colors.grey.withValues(alpha: 0.1),
|
|
||||||
pressElevation: 0,
|
|
||||||
showCheckmark: false,
|
|
||||||
side: BorderSide.none,
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
||||||
label: Text(chipsIntervalLabelList[index]),
|
|
||||||
labelStyle: TextStyle(color: appTheme.gray600, fontWeight: FontWeight.w500, fontSize: 12),
|
|
||||||
selected: _selectedIntervalChipIndex == index,
|
|
||||||
onSelected: (bool selected) {
|
|
||||||
context.read<UserDetailsCubit>().getChartData(period: chipsIntervalList[index], slug: widget.slug);
|
|
||||||
setState(() {
|
|
||||||
_selectedIntervalChipIndex = index;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ChoiceChip _choiceChipWidget(int index) {
|
|
||||||
return ChoiceChip(
|
|
||||||
selectedColor: appTheme.blueA400,
|
|
||||||
pressElevation: 0,
|
|
||||||
showCheckmark: false,
|
|
||||||
side: BorderSide.none,
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
||||||
label: Text(chipsLabelList[index]),
|
|
||||||
labelStyle: TextStyle(color: index == _selectedChipIndex ? Colors.white : appTheme.gray600, fontWeight: FontWeight.w500, fontSize: 12),
|
|
||||||
selected: _selectedChipIndex == index,
|
|
||||||
backgroundColor: appTheme.gray100,
|
|
||||||
onSelected: (bool selected) {
|
|
||||||
setState(() {
|
|
||||||
_selectedChipIndex = index;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,50 +22,80 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
|
|
||||||
static String m0(date) => "Консультант прокомментировал день ${date}";
|
static String m0(date) => "Консультант прокомментировал день ${date}";
|
||||||
|
|
||||||
static String m1(userName) =>
|
static String m1(value, period, category) =>
|
||||||
|
"BMI higher by ${value} over ${period} · ${category}";
|
||||||
|
|
||||||
|
static String m2(height) =>
|
||||||
|
"Green zone is normal (18.5–25), yellow is overweight, pink is obesity. Calculated for a height of ${height} cm.";
|
||||||
|
|
||||||
|
static String m3(value, period, category) =>
|
||||||
|
"BMI lower by ${value} over ${period} · ${category}";
|
||||||
|
|
||||||
|
static String m4(period, category) =>
|
||||||
|
"BMI unchanged over ${period} · ${category}";
|
||||||
|
|
||||||
|
static String m5(name, value) => "${name} down ${value} cm";
|
||||||
|
|
||||||
|
static String m6(name, value) => "${name} up ${value} cm";
|
||||||
|
|
||||||
|
static String m7(name) => "${name} unchanged";
|
||||||
|
|
||||||
|
static String m8(parts, period) => "${parts} over ${period}";
|
||||||
|
|
||||||
|
static String m9(time) => "Bedtime at ${time}";
|
||||||
|
|
||||||
|
static String m10(time) => "Wake-up at ${time}";
|
||||||
|
|
||||||
|
static String m11(value, period) => "Gained ${value} kg over ${period}";
|
||||||
|
|
||||||
|
static String m12(value, period) => "Lost ${value} kg over ${period}";
|
||||||
|
|
||||||
|
static String m13(period) => "Weight unchanged over ${period}";
|
||||||
|
|
||||||
|
static String m14(userName) =>
|
||||||
"You have proposed a personal diet to ${userName}";
|
"You have proposed a personal diet to ${userName}";
|
||||||
|
|
||||||
static String m2(userName, dietName) =>
|
static String m15(userName, dietName) =>
|
||||||
"You have proposed a personal diet to ${userName}, selected diet: «${dietName}»";
|
"You have proposed a personal diet to ${userName}, selected diet: «${dietName}»";
|
||||||
|
|
||||||
static String m3(duration, dayWord) =>
|
static String m16(duration, dayWord) =>
|
||||||
"The diet lasts ${duration} ${dayWord}. It can only be offered for a plan with the same duration.";
|
"The diet lasts ${duration} ${dayWord}. It can only be offered for a plan with the same duration.";
|
||||||
|
|
||||||
static String m4(dietDuration, dietDayWord, planDuration, planDayWord) =>
|
static String m17(dietDuration, dietDayWord, planDuration, planDayWord) =>
|
||||||
"Not suitable: diet lasts ${dietDuration} ${dietDayWord}, plan lasts ${planDuration} ${planDayWord}.";
|
"Not suitable: diet lasts ${dietDuration} ${dietDayWord}, plan lasts ${planDuration} ${planDayWord}.";
|
||||||
|
|
||||||
static String m5(name) => "Diet «${name}» proposed";
|
static String m18(name) => "Diet «${name}» proposed";
|
||||||
|
|
||||||
static String m6(planName) => "План «${planName}» начался";
|
static String m19(planName) => "План «${planName}» начался";
|
||||||
|
|
||||||
static String m7(planName, name) =>
|
static String m20(planName, name) =>
|
||||||
"План «${planName}» с пользователем ${name} начался";
|
"План «${planName}» с пользователем ${name} начался";
|
||||||
|
|
||||||
static String m8(duration, dayWord) => "Plan for ${duration} ${dayWord}";
|
static String m21(duration, dayWord) => "Plan for ${duration} ${dayWord}";
|
||||||
|
|
||||||
static String m9(percent, amount) =>
|
static String m22(percent, amount) =>
|
||||||
"Discount ${percent}% — you save ${amount} ₽";
|
"Discount ${percent}% — you save ${amount} ₽";
|
||||||
|
|
||||||
static String m10(date) => "Start: ${date}";
|
static String m23(date) => "Start: ${date}";
|
||||||
|
|
||||||
static String m11(step) => "шаг ${step} из 2";
|
static String m24(step) => "шаг ${step} из 2";
|
||||||
|
|
||||||
static String m12(planName, name) =>
|
static String m25(planName, name) =>
|
||||||
"Завтра начинается план «${planName}» с пользователем ${name}";
|
"Завтра начинается план «${planName}» с пользователем ${name}";
|
||||||
|
|
||||||
static String m13(planName) =>
|
static String m26(planName) =>
|
||||||
"Завтра начнется прохождение плана «${planName}»";
|
"Завтра начнется прохождение плана «${planName}»";
|
||||||
|
|
||||||
static String m14(name, planName) =>
|
static String m27(name, planName) =>
|
||||||
"Пользователь ${name} отменил подписку на план «${planName}»";
|
"Пользователь ${name} отменил подписку на план «${planName}»";
|
||||||
|
|
||||||
static String m15(name, planName) =>
|
static String m28(name, planName) =>
|
||||||
"Пользователь ${name} оплатил план «${planName}»";
|
"Пользователь ${name} оплатил план «${planName}»";
|
||||||
|
|
||||||
static String m16(name, planName) =>
|
static String m29(name, planName) =>
|
||||||
"Пользователь ${name} подписался на план «${planName}»";
|
"Пользователь ${name} подписался на план «${planName}»";
|
||||||
|
|
||||||
static String m17(planName) => "Вы отменили подписку на план «${planName}»";
|
static String m30(planName) => "Вы отменили подписку на план «${planName}»";
|
||||||
|
|
||||||
final messages = _notInlinedMessages(_notInlinedMessages);
|
final messages = _notInlinedMessages(_notInlinedMessages);
|
||||||
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
|
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
|
||||||
@@ -525,6 +555,126 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
"initial_measurements": MessageLookupByLibrary.simpleMessage(
|
"initial_measurements": MessageLookupByLibrary.simpleMessage(
|
||||||
"Первичные замеры",
|
"Первичные замеры",
|
||||||
),
|
),
|
||||||
|
"insights_add_measures_empty": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Add your measurements",
|
||||||
|
),
|
||||||
|
"insights_add_record": MessageLookupByLibrary.simpleMessage("Add entry"),
|
||||||
|
"insights_add_weight_empty": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Add your weight",
|
||||||
|
),
|
||||||
|
"insights_bmi_higher": m1,
|
||||||
|
"insights_bmi_hint": m2,
|
||||||
|
"insights_bmi_lower": m3,
|
||||||
|
"insights_bmi_normal": MessageLookupByLibrary.simpleMessage("normal"),
|
||||||
|
"insights_bmi_obesity_1": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"obesity class I",
|
||||||
|
),
|
||||||
|
"insights_bmi_obesity_2": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"obesity class II",
|
||||||
|
),
|
||||||
|
"insights_bmi_obesity_3": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"obesity class III",
|
||||||
|
),
|
||||||
|
"insights_bmi_overweight": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"overweight",
|
||||||
|
),
|
||||||
|
"insights_bmi_same": m4,
|
||||||
|
"insights_bmi_severe_underweight": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"severe underweight",
|
||||||
|
),
|
||||||
|
"insights_bmi_underweight": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"underweight",
|
||||||
|
),
|
||||||
|
"insights_intra_hint": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Bars show what each meal consists of: protein, fat and carbs in kcal. Lines show satiety and mood on a 0–5 scale.",
|
||||||
|
),
|
||||||
|
"insights_load_error": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Failed to load analytics",
|
||||||
|
),
|
||||||
|
"insights_measure_less": m5,
|
||||||
|
"insights_measure_more": m6,
|
||||||
|
"insights_measure_same": m7,
|
||||||
|
"insights_measures_summary": m8,
|
||||||
|
"insights_need_height": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Set your height to calculate BMI.",
|
||||||
|
),
|
||||||
|
"insights_next_day": MessageLookupByLibrary.simpleMessage("Next day"),
|
||||||
|
"insights_not_enough": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Not enough data",
|
||||||
|
),
|
||||||
|
"insights_period_1m": MessageLookupByLibrary.simpleMessage("1M"),
|
||||||
|
"insights_period_1w": MessageLookupByLibrary.simpleMessage("1W"),
|
||||||
|
"insights_period_1y": MessageLookupByLibrary.simpleMessage("1Y"),
|
||||||
|
"insights_period_2w": MessageLookupByLibrary.simpleMessage("2W"),
|
||||||
|
"insights_period_3m": MessageLookupByLibrary.simpleMessage("3M"),
|
||||||
|
"insights_period_6m": MessageLookupByLibrary.simpleMessage("6M"),
|
||||||
|
"insights_period_all": MessageLookupByLibrary.simpleMessage("All time"),
|
||||||
|
"insights_period_label_1": MessageLookupByLibrary.simpleMessage("month"),
|
||||||
|
"insights_period_label_12": MessageLookupByLibrary.simpleMessage("year"),
|
||||||
|
"insights_period_label_3": MessageLookupByLibrary.simpleMessage("3 months"),
|
||||||
|
"insights_period_label_6": MessageLookupByLibrary.simpleMessage("6 months"),
|
||||||
|
"insights_period_label_all": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"all time",
|
||||||
|
),
|
||||||
|
"insights_pick_date": MessageLookupByLibrary.simpleMessage("Pick a date"),
|
||||||
|
"insights_prev_day": MessageLookupByLibrary.simpleMessage("Previous day"),
|
||||||
|
"insights_retry": MessageLookupByLibrary.simpleMessage("Retry"),
|
||||||
|
"insights_save_error": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Failed to save the entry",
|
||||||
|
),
|
||||||
|
"insights_series_calories": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Calories",
|
||||||
|
),
|
||||||
|
"insights_series_carbs": MessageLookupByLibrary.simpleMessage("Carbs"),
|
||||||
|
"insights_series_chest": MessageLookupByLibrary.simpleMessage("Chest"),
|
||||||
|
"insights_series_fats": MessageLookupByLibrary.simpleMessage("Fat"),
|
||||||
|
"insights_series_hips": MessageLookupByLibrary.simpleMessage("Hips"),
|
||||||
|
"insights_series_mood": MessageLookupByLibrary.simpleMessage("Mood"),
|
||||||
|
"insights_series_proteins": MessageLookupByLibrary.simpleMessage("Protein"),
|
||||||
|
"insights_series_satiety": MessageLookupByLibrary.simpleMessage("Satiety"),
|
||||||
|
"insights_series_sleep": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Sleep and wake-up",
|
||||||
|
),
|
||||||
|
"insights_series_waist": MessageLookupByLibrary.simpleMessage("Waist"),
|
||||||
|
"insights_sleep_at": m9,
|
||||||
|
"insights_sleep_shade": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Shading — sleep time",
|
||||||
|
),
|
||||||
|
"insights_subtitle_closed": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"tap to see your progress",
|
||||||
|
),
|
||||||
|
"insights_subtitle_open": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"weight, measurements and nutrition",
|
||||||
|
),
|
||||||
|
"insights_tab_bmi": MessageLookupByLibrary.simpleMessage("BMI"),
|
||||||
|
"insights_tab_daily": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Nutrition by day",
|
||||||
|
),
|
||||||
|
"insights_tab_intra": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Throughout the day",
|
||||||
|
),
|
||||||
|
"insights_tab_measures": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Measurements",
|
||||||
|
),
|
||||||
|
"insights_tab_weight": MessageLookupByLibrary.simpleMessage("Weight"),
|
||||||
|
"insights_title": MessageLookupByLibrary.simpleMessage("Analytics"),
|
||||||
|
"insights_today": MessageLookupByLibrary.simpleMessage("Today"),
|
||||||
|
"insights_unit_bmi": MessageLookupByLibrary.simpleMessage("BMI"),
|
||||||
|
"insights_unit_cm": MessageLookupByLibrary.simpleMessage("cm"),
|
||||||
|
"insights_unit_cm_waist": MessageLookupByLibrary.simpleMessage("cm waist"),
|
||||||
|
"insights_unit_grams": MessageLookupByLibrary.simpleMessage("grams"),
|
||||||
|
"insights_unit_kcal": MessageLookupByLibrary.simpleMessage("kcal"),
|
||||||
|
"insights_unit_kcal_avg": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"kcal on average",
|
||||||
|
),
|
||||||
|
"insights_unit_kcal_per_meal": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"kcal per meal",
|
||||||
|
),
|
||||||
|
"insights_unit_kg": MessageLookupByLibrary.simpleMessage("kg"),
|
||||||
|
"insights_wake_at": m10,
|
||||||
|
"insights_weight_gained": m11,
|
||||||
|
"insights_weight_lost": m12,
|
||||||
|
"insights_weight_same": m13,
|
||||||
"invite": MessageLookupByLibrary.simpleMessage("Пригласить"),
|
"invite": MessageLookupByLibrary.simpleMessage("Пригласить"),
|
||||||
"invited_people_count": MessageLookupByLibrary.simpleMessage(
|
"invited_people_count": MessageLookupByLibrary.simpleMessage(
|
||||||
"Количество приглашенных",
|
"Количество приглашенных",
|
||||||
@@ -697,17 +847,17 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
"personalDietCreating": MessageLookupByLibrary.simpleMessage(
|
"personalDietCreating": MessageLookupByLibrary.simpleMessage(
|
||||||
"Составление индивидуальной диеты",
|
"Составление индивидуальной диеты",
|
||||||
),
|
),
|
||||||
"personal_diet_defined_to_author": m1,
|
"personal_diet_defined_to_author": m14,
|
||||||
"personal_diet_defined_to_author_named": m2,
|
"personal_diet_defined_to_author_named": m15,
|
||||||
"personal_diet_duration_hint": m3,
|
"personal_diet_duration_hint": m16,
|
||||||
"personal_diet_duration_mismatch": m4,
|
"personal_diet_duration_mismatch": m17,
|
||||||
"personal_diet_not_proposed": MessageLookupByLibrary.simpleMessage(
|
"personal_diet_not_proposed": MessageLookupByLibrary.simpleMessage(
|
||||||
"Personal diet not proposed",
|
"Personal diet not proposed",
|
||||||
),
|
),
|
||||||
"personal_diet_proposed": MessageLookupByLibrary.simpleMessage(
|
"personal_diet_proposed": MessageLookupByLibrary.simpleMessage(
|
||||||
"Personal diet proposed",
|
"Personal diet proposed",
|
||||||
),
|
),
|
||||||
"personal_diet_proposed_named": m5,
|
"personal_diet_proposed_named": m18,
|
||||||
"personal_diet_replaces_tomorrow": MessageLookupByLibrary.simpleMessage(
|
"personal_diet_replaces_tomorrow": MessageLookupByLibrary.simpleMessage(
|
||||||
"The change takes effect tomorrow",
|
"The change takes effect tomorrow",
|
||||||
),
|
),
|
||||||
@@ -727,18 +877,18 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
"Физическая форма",
|
"Физическая форма",
|
||||||
),
|
),
|
||||||
"place_of_living": MessageLookupByLibrary.simpleMessage("Место проживания"),
|
"place_of_living": MessageLookupByLibrary.simpleMessage("Место проживания"),
|
||||||
"planStarted": m6,
|
"planStarted": m19,
|
||||||
"planWillBeConfirmedAfterThreeDays": MessageLookupByLibrary.simpleMessage(
|
"planWillBeConfirmedAfterThreeDays": MessageLookupByLibrary.simpleMessage(
|
||||||
"Если вы не подтвердите, то план через три дня подтвердится автоматически",
|
"Если вы не подтвердите, то план через три дня подтвердится автоматически",
|
||||||
),
|
),
|
||||||
"planWithUserStarted": m7,
|
"planWithUserStarted": m20,
|
||||||
"plan_author": MessageLookupByLibrary.simpleMessage("Автор плана"),
|
"plan_author": MessageLookupByLibrary.simpleMessage("Автор плана"),
|
||||||
"plan_desc": MessageLookupByLibrary.simpleMessage("Описание плана"),
|
"plan_desc": MessageLookupByLibrary.simpleMessage("Описание плана"),
|
||||||
"plan_desc_example": MessageLookupByLibrary.simpleMessage(
|
"plan_desc_example": MessageLookupByLibrary.simpleMessage(
|
||||||
"Большое подробное, привлекательное описание плана",
|
"Большое подробное, привлекательное описание плана",
|
||||||
),
|
),
|
||||||
"plan_duration": MessageLookupByLibrary.simpleMessage("Срок плана"),
|
"plan_duration": MessageLookupByLibrary.simpleMessage("Срок плана"),
|
||||||
"plan_duration_label": m8,
|
"plan_duration_label": m21,
|
||||||
"plan_label_desc": MessageLookupByLibrary.simpleMessage(
|
"plan_label_desc": MessageLookupByLibrary.simpleMessage(
|
||||||
"План для поддержания формы",
|
"План для поддержания формы",
|
||||||
),
|
),
|
||||||
@@ -775,7 +925,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
"Promo code applied!",
|
"Promo code applied!",
|
||||||
),
|
),
|
||||||
"promo_code": MessageLookupByLibrary.simpleMessage("Promo code"),
|
"promo_code": MessageLookupByLibrary.simpleMessage("Promo code"),
|
||||||
"promo_discount_hint": m9,
|
"promo_discount_hint": m22,
|
||||||
"promo_invalid": MessageLookupByLibrary.simpleMessage(
|
"promo_invalid": MessageLookupByLibrary.simpleMessage(
|
||||||
"Promo code not found or expired",
|
"Promo code not found or expired",
|
||||||
),
|
),
|
||||||
@@ -896,13 +1046,13 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
"Выбрана дата старта",
|
"Выбрана дата старта",
|
||||||
),
|
),
|
||||||
"startDiet": MessageLookupByLibrary.simpleMessage("Начать диету"),
|
"startDiet": MessageLookupByLibrary.simpleMessage("Начать диету"),
|
||||||
"start_date_label": m10,
|
"start_date_label": m23,
|
||||||
"statistic": MessageLookupByLibrary.simpleMessage("Статистика"),
|
"statistic": MessageLookupByLibrary.simpleMessage("Статистика"),
|
||||||
"step": MessageLookupByLibrary.simpleMessage("Шаг"),
|
"step": MessageLookupByLibrary.simpleMessage("Шаг"),
|
||||||
"step_by_step_photo_recipe": MessageLookupByLibrary.simpleMessage(
|
"step_by_step_photo_recipe": MessageLookupByLibrary.simpleMessage(
|
||||||
"Пошаговый фоторецепт",
|
"Пошаговый фоторецепт",
|
||||||
),
|
),
|
||||||
"step_widget": m11,
|
"step_widget": m24,
|
||||||
"successfully_published": MessageLookupByLibrary.simpleMessage(
|
"successfully_published": MessageLookupByLibrary.simpleMessage(
|
||||||
"успешно опубликован",
|
"успешно опубликован",
|
||||||
),
|
),
|
||||||
@@ -927,8 +1077,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
"toOffer": MessageLookupByLibrary.simpleMessage("Предложить"),
|
"toOffer": MessageLookupByLibrary.simpleMessage("Предложить"),
|
||||||
"to_draft": MessageLookupByLibrary.simpleMessage("В черновик"),
|
"to_draft": MessageLookupByLibrary.simpleMessage("В черновик"),
|
||||||
"to_order": MessageLookupByLibrary.simpleMessage("Заказать"),
|
"to_order": MessageLookupByLibrary.simpleMessage("Заказать"),
|
||||||
"tommorowPlanWithUserWillStart": m12,
|
"tommorowPlanWithUserWillStart": m25,
|
||||||
"tomorrowYourPlanWillStart": m13,
|
"tomorrowYourPlanWillStart": m26,
|
||||||
"try_again": MessageLookupByLibrary.simpleMessage("Попробовать снова"),
|
"try_again": MessageLookupByLibrary.simpleMessage("Попробовать снова"),
|
||||||
"try_again_2": MessageLookupByLibrary.simpleMessage("Попробуйте ещё раз"),
|
"try_again_2": MessageLookupByLibrary.simpleMessage("Попробуйте ещё раз"),
|
||||||
"typeAmoutOfReturn": MessageLookupByLibrary.simpleMessage(
|
"typeAmoutOfReturn": MessageLookupByLibrary.simpleMessage(
|
||||||
@@ -962,7 +1112,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
"Верхние показатели давления:",
|
"Верхние показатели давления:",
|
||||||
),
|
),
|
||||||
"upper": MessageLookupByLibrary.simpleMessage("Верхнее"),
|
"upper": MessageLookupByLibrary.simpleMessage("Верхнее"),
|
||||||
"userCanceldThePlanSubscription": m14,
|
"userCanceldThePlanSubscription": m27,
|
||||||
"userConfirmedPlanCompletion": MessageLookupByLibrary.simpleMessage(
|
"userConfirmedPlanCompletion": MessageLookupByLibrary.simpleMessage(
|
||||||
"Пользователь подтвердил прохождения плана. В ближайшее время Вы получите",
|
"Пользователь подтвердил прохождения плана. В ближайшее время Вы получите",
|
||||||
),
|
),
|
||||||
@@ -970,8 +1120,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
MessageLookupByLibrary.simpleMessage(
|
MessageLookupByLibrary.simpleMessage(
|
||||||
"Пользователь подтвердил прохождения плана.",
|
"Пользователь подтвердил прохождения плана.",
|
||||||
),
|
),
|
||||||
"userPaidThePlan": m15,
|
"userPaidThePlan": m28,
|
||||||
"userSubscribedToPlan": m16,
|
"userSubscribedToPlan": m29,
|
||||||
"user_authorization": MessageLookupByLibrary.simpleMessage(
|
"user_authorization": MessageLookupByLibrary.simpleMessage(
|
||||||
"Аутентификация",
|
"Аутентификация",
|
||||||
),
|
),
|
||||||
@@ -1101,7 +1251,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
"youCanComplain": MessageLookupByLibrary.simpleMessage(
|
"youCanComplain": MessageLookupByLibrary.simpleMessage(
|
||||||
"Если вас что-то не устроило, вы можете подать жалобу",
|
"Если вас что-то не устроило, вы можете подать жалобу",
|
||||||
),
|
),
|
||||||
"youCanceledSubscriptionToPlan": m17,
|
"youCanceledSubscriptionToPlan": m30,
|
||||||
"youShureYouWantToCancelOrder": MessageLookupByLibrary.simpleMessage(
|
"youShureYouWantToCancelOrder": MessageLookupByLibrary.simpleMessage(
|
||||||
"Вы уверены, что хотите отменить заказ?",
|
"Вы уверены, что хотите отменить заказ?",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -20,26 +20,56 @@ typedef String MessageIfAbsent(String messageStr, List<dynamic> args);
|
|||||||
class MessageLookup extends MessageLookupByLibrary {
|
class MessageLookup extends MessageLookupByLibrary {
|
||||||
String get localeName => 'ru';
|
String get localeName => 'ru';
|
||||||
|
|
||||||
static String m1(userName) =>
|
static String m1(value, period, category) =>
|
||||||
|
"ИМТ выше на ${value} за ${period} · ${category}";
|
||||||
|
|
||||||
|
static String m2(height) =>
|
||||||
|
"Зелёная зона — норма (18.5–25), жёлтая — избыточный вес, розовая — ожирение. Считаем при росте ${height} см.";
|
||||||
|
|
||||||
|
static String m3(value, period, category) =>
|
||||||
|
"ИМТ ниже на ${value} за ${period} · ${category}";
|
||||||
|
|
||||||
|
static String m4(period, category) =>
|
||||||
|
"ИМТ не изменился за ${period} · ${category}";
|
||||||
|
|
||||||
|
static String m5(name, value) => "${name} меньше на ${value} см";
|
||||||
|
|
||||||
|
static String m6(name, value) => "${name} больше на ${value} см";
|
||||||
|
|
||||||
|
static String m7(name) => "${name} без изменений";
|
||||||
|
|
||||||
|
static String m8(parts, period) => "${parts} за ${period}";
|
||||||
|
|
||||||
|
static String m9(time) => "Отход ко сну в ${time}";
|
||||||
|
|
||||||
|
static String m10(time) => "Подъём в ${time}";
|
||||||
|
|
||||||
|
static String m11(value, period) => "Набрали ${value} кг за ${period}";
|
||||||
|
|
||||||
|
static String m12(value, period) => "Снизили на ${value} кг за ${period}";
|
||||||
|
|
||||||
|
static String m13(period) => "Вес не изменился за ${period}";
|
||||||
|
|
||||||
|
static String m14(userName) =>
|
||||||
"Вы предложили персональную диету пользователю ${userName}";
|
"Вы предложили персональную диету пользователю ${userName}";
|
||||||
|
|
||||||
static String m2(userName, dietName) =>
|
static String m15(userName, dietName) =>
|
||||||
"Вы предложили персональную диету пользователю ${userName}, выбранная диета: «${dietName}»";
|
"Вы предложили персональную диету пользователю ${userName}, выбранная диета: «${dietName}»";
|
||||||
|
|
||||||
static String m3(duration, dayWord) =>
|
static String m16(duration, dayWord) =>
|
||||||
"Диета рассчитана на ${duration} ${dayWord}. Предложить её можно только для плана с такой же длительностью.";
|
"Диета рассчитана на ${duration} ${dayWord}. Предложить её можно только для плана с такой же длительностью.";
|
||||||
|
|
||||||
static String m4(dietDuration, dietDayWord, planDuration, planDayWord) =>
|
static String m17(dietDuration, dietDayWord, planDuration, planDayWord) =>
|
||||||
"Не подходит: диета на ${dietDuration} ${dietDayWord}, план на ${planDuration} ${planDayWord}.";
|
"Не подходит: диета на ${dietDuration} ${dietDayWord}, план на ${planDuration} ${planDayWord}.";
|
||||||
|
|
||||||
static String m5(name) => "Диета «${name}» предложена";
|
static String m18(name) => "Диета «${name}» предложена";
|
||||||
|
|
||||||
static String m8(duration, dayWord) => "План на ${duration} ${dayWord}";
|
static String m21(duration, dayWord) => "План на ${duration} ${dayWord}";
|
||||||
|
|
||||||
static String m9(percent, amount) =>
|
static String m22(percent, amount) =>
|
||||||
"Скидка ${percent}% — вы экономите ${amount} ₽";
|
"Скидка ${percent}% — вы экономите ${amount} ₽";
|
||||||
|
|
||||||
static String m10(date) => "Старт: ${date}";
|
static String m23(date) => "Старт: ${date}";
|
||||||
|
|
||||||
final messages = _notInlinedMessages(_notInlinedMessages);
|
final messages = _notInlinedMessages(_notInlinedMessages);
|
||||||
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
|
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
|
||||||
@@ -243,6 +273,124 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
"initial_measurements": MessageLookupByLibrary.simpleMessage(
|
"initial_measurements": MessageLookupByLibrary.simpleMessage(
|
||||||
"Первичные замеры",
|
"Первичные замеры",
|
||||||
),
|
),
|
||||||
|
"insights_add_measures_empty": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Добавьте Ваши замеры",
|
||||||
|
),
|
||||||
|
"insights_add_record": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Добавить запись",
|
||||||
|
),
|
||||||
|
"insights_add_weight_empty": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Добавьте Ваш вес",
|
||||||
|
),
|
||||||
|
"insights_bmi_higher": m1,
|
||||||
|
"insights_bmi_hint": m2,
|
||||||
|
"insights_bmi_lower": m3,
|
||||||
|
"insights_bmi_normal": MessageLookupByLibrary.simpleMessage("норма"),
|
||||||
|
"insights_bmi_obesity_1": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"ожирение I ст.",
|
||||||
|
),
|
||||||
|
"insights_bmi_obesity_2": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"ожирение II ст.",
|
||||||
|
),
|
||||||
|
"insights_bmi_obesity_3": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"ожирение III ст.",
|
||||||
|
),
|
||||||
|
"insights_bmi_overweight": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"избыточная масса",
|
||||||
|
),
|
||||||
|
"insights_bmi_same": m4,
|
||||||
|
"insights_bmi_severe_underweight": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"выраженный дефицит массы",
|
||||||
|
),
|
||||||
|
"insights_bmi_underweight": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"недостаточная масса",
|
||||||
|
),
|
||||||
|
"insights_intra_hint": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Столбики показывают, из чего состоят приёмы пищи: белки, жиры и углеводы в ккал. Линии — сытость и настроение по шкале от 0 до 5.",
|
||||||
|
),
|
||||||
|
"insights_load_error": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Не удалось загрузить аналитику",
|
||||||
|
),
|
||||||
|
"insights_measure_less": m5,
|
||||||
|
"insights_measure_more": m6,
|
||||||
|
"insights_measure_same": m7,
|
||||||
|
"insights_measures_summary": m8,
|
||||||
|
"insights_need_height": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Укажите рост, чтобы посчитать ИМТ.",
|
||||||
|
),
|
||||||
|
"insights_next_day": MessageLookupByLibrary.simpleMessage("Следующий день"),
|
||||||
|
"insights_not_enough": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Недостаточно данных",
|
||||||
|
),
|
||||||
|
"insights_period_1m": MessageLookupByLibrary.simpleMessage("1М"),
|
||||||
|
"insights_period_1w": MessageLookupByLibrary.simpleMessage("1Н"),
|
||||||
|
"insights_period_1y": MessageLookupByLibrary.simpleMessage("1Г"),
|
||||||
|
"insights_period_2w": MessageLookupByLibrary.simpleMessage("2Н"),
|
||||||
|
"insights_period_3m": MessageLookupByLibrary.simpleMessage("3М"),
|
||||||
|
"insights_period_6m": MessageLookupByLibrary.simpleMessage("6М"),
|
||||||
|
"insights_period_all": MessageLookupByLibrary.simpleMessage("Всё время"),
|
||||||
|
"insights_period_label_1": MessageLookupByLibrary.simpleMessage("месяц"),
|
||||||
|
"insights_period_label_12": MessageLookupByLibrary.simpleMessage("год"),
|
||||||
|
"insights_period_label_3": MessageLookupByLibrary.simpleMessage("3 месяца"),
|
||||||
|
"insights_period_label_6": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"6 месяцев",
|
||||||
|
),
|
||||||
|
"insights_period_label_all": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"всё время",
|
||||||
|
),
|
||||||
|
"insights_pick_date": MessageLookupByLibrary.simpleMessage("Выбрать дату"),
|
||||||
|
"insights_prev_day": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Предыдущий день",
|
||||||
|
),
|
||||||
|
"insights_retry": MessageLookupByLibrary.simpleMessage("Повторить"),
|
||||||
|
"insights_save_error": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Не удалось сохранить запись",
|
||||||
|
),
|
||||||
|
"insights_series_calories": MessageLookupByLibrary.simpleMessage("Калории"),
|
||||||
|
"insights_series_carbs": MessageLookupByLibrary.simpleMessage("Углеводы"),
|
||||||
|
"insights_series_chest": MessageLookupByLibrary.simpleMessage("Грудь"),
|
||||||
|
"insights_series_fats": MessageLookupByLibrary.simpleMessage("Жиры"),
|
||||||
|
"insights_series_hips": MessageLookupByLibrary.simpleMessage("Бёдра"),
|
||||||
|
"insights_series_mood": MessageLookupByLibrary.simpleMessage("Настроение"),
|
||||||
|
"insights_series_proteins": MessageLookupByLibrary.simpleMessage("Белки"),
|
||||||
|
"insights_series_satiety": MessageLookupByLibrary.simpleMessage("Сытость"),
|
||||||
|
"insights_series_sleep": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Сон и подъём",
|
||||||
|
),
|
||||||
|
"insights_series_waist": MessageLookupByLibrary.simpleMessage("Талия"),
|
||||||
|
"insights_sleep_at": m9,
|
||||||
|
"insights_sleep_shade": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Затемнение — время сна",
|
||||||
|
),
|
||||||
|
"insights_subtitle_closed": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"нажмите, чтобы посмотреть прогресс",
|
||||||
|
),
|
||||||
|
"insights_subtitle_open": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"вес, замеры и питание",
|
||||||
|
),
|
||||||
|
"insights_tab_bmi": MessageLookupByLibrary.simpleMessage("ИМТ"),
|
||||||
|
"insights_tab_daily": MessageLookupByLibrary.simpleMessage("КБЖУ по дням"),
|
||||||
|
"insights_tab_intra": MessageLookupByLibrary.simpleMessage("В течение дня"),
|
||||||
|
"insights_tab_measures": MessageLookupByLibrary.simpleMessage("Замеры"),
|
||||||
|
"insights_tab_weight": MessageLookupByLibrary.simpleMessage("Вес"),
|
||||||
|
"insights_title": MessageLookupByLibrary.simpleMessage("Аналитика"),
|
||||||
|
"insights_today": MessageLookupByLibrary.simpleMessage("Сегодня"),
|
||||||
|
"insights_unit_bmi": MessageLookupByLibrary.simpleMessage("ИМТ"),
|
||||||
|
"insights_unit_cm": MessageLookupByLibrary.simpleMessage("см"),
|
||||||
|
"insights_unit_cm_waist": MessageLookupByLibrary.simpleMessage("см талия"),
|
||||||
|
"insights_unit_grams": MessageLookupByLibrary.simpleMessage("граммы"),
|
||||||
|
"insights_unit_kcal": MessageLookupByLibrary.simpleMessage("ккал"),
|
||||||
|
"insights_unit_kcal_avg": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"ккал в среднем",
|
||||||
|
),
|
||||||
|
"insights_unit_kcal_per_meal": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"ккал за приём",
|
||||||
|
),
|
||||||
|
"insights_unit_kg": MessageLookupByLibrary.simpleMessage("кг"),
|
||||||
|
"insights_wake_at": m10,
|
||||||
|
"insights_weight_gained": m11,
|
||||||
|
"insights_weight_lost": m12,
|
||||||
|
"insights_weight_same": m13,
|
||||||
"label": MessageLookupByLibrary.simpleMessage("Название"),
|
"label": MessageLookupByLibrary.simpleMessage("Название"),
|
||||||
"language": MessageLookupByLibrary.simpleMessage("Язык"),
|
"language": MessageLookupByLibrary.simpleMessage("Язык"),
|
||||||
"last_name": MessageLookupByLibrary.simpleMessage("Фамилия"),
|
"last_name": MessageLookupByLibrary.simpleMessage("Фамилия"),
|
||||||
@@ -341,17 +489,17 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
"payment_failed": MessageLookupByLibrary.simpleMessage("Оплата не прошла"),
|
"payment_failed": MessageLookupByLibrary.simpleMessage("Оплата не прошла"),
|
||||||
"pcs": MessageLookupByLibrary.simpleMessage("шт"),
|
"pcs": MessageLookupByLibrary.simpleMessage("шт"),
|
||||||
"personal": MessageLookupByLibrary.simpleMessage("Персональные"),
|
"personal": MessageLookupByLibrary.simpleMessage("Персональные"),
|
||||||
"personal_diet_defined_to_author": m1,
|
"personal_diet_defined_to_author": m14,
|
||||||
"personal_diet_defined_to_author_named": m2,
|
"personal_diet_defined_to_author_named": m15,
|
||||||
"personal_diet_duration_hint": m3,
|
"personal_diet_duration_hint": m16,
|
||||||
"personal_diet_duration_mismatch": m4,
|
"personal_diet_duration_mismatch": m17,
|
||||||
"personal_diet_not_proposed": MessageLookupByLibrary.simpleMessage(
|
"personal_diet_not_proposed": MessageLookupByLibrary.simpleMessage(
|
||||||
"Диета не предложена",
|
"Диета не предложена",
|
||||||
),
|
),
|
||||||
"personal_diet_proposed": MessageLookupByLibrary.simpleMessage(
|
"personal_diet_proposed": MessageLookupByLibrary.simpleMessage(
|
||||||
"Диета предложена",
|
"Диета предложена",
|
||||||
),
|
),
|
||||||
"personal_diet_proposed_named": m5,
|
"personal_diet_proposed_named": m18,
|
||||||
"personal_diet_replaces_tomorrow": MessageLookupByLibrary.simpleMessage(
|
"personal_diet_replaces_tomorrow": MessageLookupByLibrary.simpleMessage(
|
||||||
"Замена действует со следующего дня",
|
"Замена действует со следующего дня",
|
||||||
),
|
),
|
||||||
@@ -367,7 +515,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
"Большое подробное, привлекательное описание плана",
|
"Большое подробное, привлекательное описание плана",
|
||||||
),
|
),
|
||||||
"plan_duration": MessageLookupByLibrary.simpleMessage("Срок плана"),
|
"plan_duration": MessageLookupByLibrary.simpleMessage("Срок плана"),
|
||||||
"plan_duration_label": m8,
|
"plan_duration_label": m21,
|
||||||
"plan_label_desc": MessageLookupByLibrary.simpleMessage(
|
"plan_label_desc": MessageLookupByLibrary.simpleMessage(
|
||||||
"План для поддержания формы",
|
"План для поддержания формы",
|
||||||
),
|
),
|
||||||
@@ -395,7 +543,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
"Промокод применён!",
|
"Промокод применён!",
|
||||||
),
|
),
|
||||||
"promo_code": MessageLookupByLibrary.simpleMessage("Промокод"),
|
"promo_code": MessageLookupByLibrary.simpleMessage("Промокод"),
|
||||||
"promo_discount_hint": m9,
|
"promo_discount_hint": m22,
|
||||||
"promo_invalid": MessageLookupByLibrary.simpleMessage(
|
"promo_invalid": MessageLookupByLibrary.simpleMessage(
|
||||||
"Промокод не найден или истёк",
|
"Промокод не найден или истёк",
|
||||||
),
|
),
|
||||||
@@ -456,7 +604,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
"Выбрана дата старта",
|
"Выбрана дата старта",
|
||||||
),
|
),
|
||||||
"startDiet": MessageLookupByLibrary.simpleMessage("Начать диету"),
|
"startDiet": MessageLookupByLibrary.simpleMessage("Начать диету"),
|
||||||
"start_date_label": m10,
|
"start_date_label": m23,
|
||||||
"suggestDiet": MessageLookupByLibrary.simpleMessage("Предложить диету"),
|
"suggestDiet": MessageLookupByLibrary.simpleMessage("Предложить диету"),
|
||||||
"tell_your_schedule": MessageLookupByLibrary.simpleMessage(
|
"tell_your_schedule": MessageLookupByLibrary.simpleMessage(
|
||||||
"Расскажите в свободной форме о том как просыпаетесь, когда едите, когда ложитесь спать:",
|
"Расскажите в свободной форме о том как просыпаетесь, когда едите, когда ложитесь спать:",
|
||||||
|
|||||||
@@ -4554,6 +4554,661 @@ class S {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `Analytics`
|
||||||
|
String get insights_title {
|
||||||
|
return Intl.message(
|
||||||
|
'Analytics',
|
||||||
|
name: 'insights_title',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `tap to see your progress`
|
||||||
|
String get insights_subtitle_closed {
|
||||||
|
return Intl.message(
|
||||||
|
'tap to see your progress',
|
||||||
|
name: 'insights_subtitle_closed',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `weight, measurements and nutrition`
|
||||||
|
String get insights_subtitle_open {
|
||||||
|
return Intl.message(
|
||||||
|
'weight, measurements and nutrition',
|
||||||
|
name: 'insights_subtitle_open',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Weight`
|
||||||
|
String get insights_tab_weight {
|
||||||
|
return Intl.message(
|
||||||
|
'Weight',
|
||||||
|
name: 'insights_tab_weight',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `BMI`
|
||||||
|
String get insights_tab_bmi {
|
||||||
|
return Intl.message('BMI', name: 'insights_tab_bmi', desc: '', args: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Measurements`
|
||||||
|
String get insights_tab_measures {
|
||||||
|
return Intl.message(
|
||||||
|
'Measurements',
|
||||||
|
name: 'insights_tab_measures',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Nutrition by day`
|
||||||
|
String get insights_tab_daily {
|
||||||
|
return Intl.message(
|
||||||
|
'Nutrition by day',
|
||||||
|
name: 'insights_tab_daily',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Throughout the day`
|
||||||
|
String get insights_tab_intra {
|
||||||
|
return Intl.message(
|
||||||
|
'Throughout the day',
|
||||||
|
name: 'insights_tab_intra',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `1M`
|
||||||
|
String get insights_period_1m {
|
||||||
|
return Intl.message('1M', name: 'insights_period_1m', desc: '', args: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `3M`
|
||||||
|
String get insights_period_3m {
|
||||||
|
return Intl.message('3M', name: 'insights_period_3m', desc: '', args: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `6M`
|
||||||
|
String get insights_period_6m {
|
||||||
|
return Intl.message('6M', name: 'insights_period_6m', desc: '', args: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `1Y`
|
||||||
|
String get insights_period_1y {
|
||||||
|
return Intl.message('1Y', name: 'insights_period_1y', desc: '', args: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `All time`
|
||||||
|
String get insights_period_all {
|
||||||
|
return Intl.message(
|
||||||
|
'All time',
|
||||||
|
name: 'insights_period_all',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `1W`
|
||||||
|
String get insights_period_1w {
|
||||||
|
return Intl.message('1W', name: 'insights_period_1w', desc: '', args: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `2W`
|
||||||
|
String get insights_period_2w {
|
||||||
|
return Intl.message('2W', name: 'insights_period_2w', desc: '', args: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Waist`
|
||||||
|
String get insights_series_waist {
|
||||||
|
return Intl.message(
|
||||||
|
'Waist',
|
||||||
|
name: 'insights_series_waist',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Chest`
|
||||||
|
String get insights_series_chest {
|
||||||
|
return Intl.message(
|
||||||
|
'Chest',
|
||||||
|
name: 'insights_series_chest',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Hips`
|
||||||
|
String get insights_series_hips {
|
||||||
|
return Intl.message(
|
||||||
|
'Hips',
|
||||||
|
name: 'insights_series_hips',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Calories`
|
||||||
|
String get insights_series_calories {
|
||||||
|
return Intl.message(
|
||||||
|
'Calories',
|
||||||
|
name: 'insights_series_calories',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Protein`
|
||||||
|
String get insights_series_proteins {
|
||||||
|
return Intl.message(
|
||||||
|
'Protein',
|
||||||
|
name: 'insights_series_proteins',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Fat`
|
||||||
|
String get insights_series_fats {
|
||||||
|
return Intl.message(
|
||||||
|
'Fat',
|
||||||
|
name: 'insights_series_fats',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Carbs`
|
||||||
|
String get insights_series_carbs {
|
||||||
|
return Intl.message(
|
||||||
|
'Carbs',
|
||||||
|
name: 'insights_series_carbs',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Satiety`
|
||||||
|
String get insights_series_satiety {
|
||||||
|
return Intl.message(
|
||||||
|
'Satiety',
|
||||||
|
name: 'insights_series_satiety',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Mood`
|
||||||
|
String get insights_series_mood {
|
||||||
|
return Intl.message(
|
||||||
|
'Mood',
|
||||||
|
name: 'insights_series_mood',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Sleep and wake-up`
|
||||||
|
String get insights_series_sleep {
|
||||||
|
return Intl.message(
|
||||||
|
'Sleep and wake-up',
|
||||||
|
name: 'insights_series_sleep',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `kg`
|
||||||
|
String get insights_unit_kg {
|
||||||
|
return Intl.message('kg', name: 'insights_unit_kg', desc: '', args: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `BMI`
|
||||||
|
String get insights_unit_bmi {
|
||||||
|
return Intl.message('BMI', name: 'insights_unit_bmi', desc: '', args: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `cm`
|
||||||
|
String get insights_unit_cm {
|
||||||
|
return Intl.message('cm', name: 'insights_unit_cm', desc: '', args: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `cm waist`
|
||||||
|
String get insights_unit_cm_waist {
|
||||||
|
return Intl.message(
|
||||||
|
'cm waist',
|
||||||
|
name: 'insights_unit_cm_waist',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `kcal`
|
||||||
|
String get insights_unit_kcal {
|
||||||
|
return Intl.message('kcal', name: 'insights_unit_kcal', desc: '', args: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `kcal on average`
|
||||||
|
String get insights_unit_kcal_avg {
|
||||||
|
return Intl.message(
|
||||||
|
'kcal on average',
|
||||||
|
name: 'insights_unit_kcal_avg',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `grams`
|
||||||
|
String get insights_unit_grams {
|
||||||
|
return Intl.message(
|
||||||
|
'grams',
|
||||||
|
name: 'insights_unit_grams',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `kcal per meal`
|
||||||
|
String get insights_unit_kcal_per_meal {
|
||||||
|
return Intl.message(
|
||||||
|
'kcal per meal',
|
||||||
|
name: 'insights_unit_kcal_per_meal',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `month`
|
||||||
|
String get insights_period_label_1 {
|
||||||
|
return Intl.message(
|
||||||
|
'month',
|
||||||
|
name: 'insights_period_label_1',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `3 months`
|
||||||
|
String get insights_period_label_3 {
|
||||||
|
return Intl.message(
|
||||||
|
'3 months',
|
||||||
|
name: 'insights_period_label_3',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `6 months`
|
||||||
|
String get insights_period_label_6 {
|
||||||
|
return Intl.message(
|
||||||
|
'6 months',
|
||||||
|
name: 'insights_period_label_6',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `year`
|
||||||
|
String get insights_period_label_12 {
|
||||||
|
return Intl.message(
|
||||||
|
'year',
|
||||||
|
name: 'insights_period_label_12',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `all time`
|
||||||
|
String get insights_period_label_all {
|
||||||
|
return Intl.message(
|
||||||
|
'all time',
|
||||||
|
name: 'insights_period_label_all',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Not enough data`
|
||||||
|
String get insights_not_enough {
|
||||||
|
return Intl.message(
|
||||||
|
'Not enough data',
|
||||||
|
name: 'insights_not_enough',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Set your height to calculate BMI.`
|
||||||
|
String get insights_need_height {
|
||||||
|
return Intl.message(
|
||||||
|
'Set your height to calculate BMI.',
|
||||||
|
name: 'insights_need_height',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Lost {value} kg over {period}`
|
||||||
|
String insights_weight_lost(String value, String period) {
|
||||||
|
return Intl.message(
|
||||||
|
'Lost $value kg over $period',
|
||||||
|
name: 'insights_weight_lost',
|
||||||
|
desc: '',
|
||||||
|
args: [value, period],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Gained {value} kg over {period}`
|
||||||
|
String insights_weight_gained(String value, String period) {
|
||||||
|
return Intl.message(
|
||||||
|
'Gained $value kg over $period',
|
||||||
|
name: 'insights_weight_gained',
|
||||||
|
desc: '',
|
||||||
|
args: [value, period],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Weight unchanged over {period}`
|
||||||
|
String insights_weight_same(String period) {
|
||||||
|
return Intl.message(
|
||||||
|
'Weight unchanged over $period',
|
||||||
|
name: 'insights_weight_same',
|
||||||
|
desc: '',
|
||||||
|
args: [period],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `BMI lower by {value} over {period} · {category}`
|
||||||
|
String insights_bmi_lower(String value, String period, String category) {
|
||||||
|
return Intl.message(
|
||||||
|
'BMI lower by $value over $period · $category',
|
||||||
|
name: 'insights_bmi_lower',
|
||||||
|
desc: '',
|
||||||
|
args: [value, period, category],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `BMI higher by {value} over {period} · {category}`
|
||||||
|
String insights_bmi_higher(String value, String period, String category) {
|
||||||
|
return Intl.message(
|
||||||
|
'BMI higher by $value over $period · $category',
|
||||||
|
name: 'insights_bmi_higher',
|
||||||
|
desc: '',
|
||||||
|
args: [value, period, category],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `BMI unchanged over {period} · {category}`
|
||||||
|
String insights_bmi_same(String period, String category) {
|
||||||
|
return Intl.message(
|
||||||
|
'BMI unchanged over $period · $category',
|
||||||
|
name: 'insights_bmi_same',
|
||||||
|
desc: '',
|
||||||
|
args: [period, category],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Green zone is normal (18.5–25), yellow is overweight, pink is obesity. Calculated for a height of {height} cm.`
|
||||||
|
String insights_bmi_hint(String height) {
|
||||||
|
return Intl.message(
|
||||||
|
'Green zone is normal (18.5–25), yellow is overweight, pink is obesity. Calculated for a height of $height cm.',
|
||||||
|
name: 'insights_bmi_hint',
|
||||||
|
desc: '',
|
||||||
|
args: [height],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `severe underweight`
|
||||||
|
String get insights_bmi_severe_underweight {
|
||||||
|
return Intl.message(
|
||||||
|
'severe underweight',
|
||||||
|
name: 'insights_bmi_severe_underweight',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `underweight`
|
||||||
|
String get insights_bmi_underweight {
|
||||||
|
return Intl.message(
|
||||||
|
'underweight',
|
||||||
|
name: 'insights_bmi_underweight',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `normal`
|
||||||
|
String get insights_bmi_normal {
|
||||||
|
return Intl.message(
|
||||||
|
'normal',
|
||||||
|
name: 'insights_bmi_normal',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `overweight`
|
||||||
|
String get insights_bmi_overweight {
|
||||||
|
return Intl.message(
|
||||||
|
'overweight',
|
||||||
|
name: 'insights_bmi_overweight',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `obesity class I`
|
||||||
|
String get insights_bmi_obesity_1 {
|
||||||
|
return Intl.message(
|
||||||
|
'obesity class I',
|
||||||
|
name: 'insights_bmi_obesity_1',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `obesity class II`
|
||||||
|
String get insights_bmi_obesity_2 {
|
||||||
|
return Intl.message(
|
||||||
|
'obesity class II',
|
||||||
|
name: 'insights_bmi_obesity_2',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `obesity class III`
|
||||||
|
String get insights_bmi_obesity_3 {
|
||||||
|
return Intl.message(
|
||||||
|
'obesity class III',
|
||||||
|
name: 'insights_bmi_obesity_3',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `{name} down {value} cm`
|
||||||
|
String insights_measure_less(String name, String value) {
|
||||||
|
return Intl.message(
|
||||||
|
'$name down $value cm',
|
||||||
|
name: 'insights_measure_less',
|
||||||
|
desc: '',
|
||||||
|
args: [name, value],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `{name} up {value} cm`
|
||||||
|
String insights_measure_more(String name, String value) {
|
||||||
|
return Intl.message(
|
||||||
|
'$name up $value cm',
|
||||||
|
name: 'insights_measure_more',
|
||||||
|
desc: '',
|
||||||
|
args: [name, value],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `{name} unchanged`
|
||||||
|
String insights_measure_same(String name) {
|
||||||
|
return Intl.message(
|
||||||
|
'$name unchanged',
|
||||||
|
name: 'insights_measure_same',
|
||||||
|
desc: '',
|
||||||
|
args: [name],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `{parts} over {period}`
|
||||||
|
String insights_measures_summary(String parts, String period) {
|
||||||
|
return Intl.message(
|
||||||
|
'$parts over $period',
|
||||||
|
name: 'insights_measures_summary',
|
||||||
|
desc: '',
|
||||||
|
args: [parts, period],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Add entry`
|
||||||
|
String get insights_add_record {
|
||||||
|
return Intl.message(
|
||||||
|
'Add entry',
|
||||||
|
name: 'insights_add_record',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Add your weight`
|
||||||
|
String get insights_add_weight_empty {
|
||||||
|
return Intl.message(
|
||||||
|
'Add your weight',
|
||||||
|
name: 'insights_add_weight_empty',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Add your measurements`
|
||||||
|
String get insights_add_measures_empty {
|
||||||
|
return Intl.message(
|
||||||
|
'Add your measurements',
|
||||||
|
name: 'insights_add_measures_empty',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Failed to load analytics`
|
||||||
|
String get insights_load_error {
|
||||||
|
return Intl.message(
|
||||||
|
'Failed to load analytics',
|
||||||
|
name: 'insights_load_error',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Retry`
|
||||||
|
String get insights_retry {
|
||||||
|
return Intl.message('Retry', name: 'insights_retry', desc: '', args: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Bars show what each meal consists of: protein, fat and carbs in kcal. Lines show satiety and mood on a 0–5 scale.`
|
||||||
|
String get insights_intra_hint {
|
||||||
|
return Intl.message(
|
||||||
|
'Bars show what each meal consists of: protein, fat and carbs in kcal. Lines show satiety and mood on a 0–5 scale.',
|
||||||
|
name: 'insights_intra_hint',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Shading — sleep time`
|
||||||
|
String get insights_sleep_shade {
|
||||||
|
return Intl.message(
|
||||||
|
'Shading — sleep time',
|
||||||
|
name: 'insights_sleep_shade',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Wake-up at {time}`
|
||||||
|
String insights_wake_at(String time) {
|
||||||
|
return Intl.message(
|
||||||
|
'Wake-up at $time',
|
||||||
|
name: 'insights_wake_at',
|
||||||
|
desc: '',
|
||||||
|
args: [time],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Bedtime at {time}`
|
||||||
|
String insights_sleep_at(String time) {
|
||||||
|
return Intl.message(
|
||||||
|
'Bedtime at $time',
|
||||||
|
name: 'insights_sleep_at',
|
||||||
|
desc: '',
|
||||||
|
args: [time],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Previous day`
|
||||||
|
String get insights_prev_day {
|
||||||
|
return Intl.message(
|
||||||
|
'Previous day',
|
||||||
|
name: 'insights_prev_day',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Next day`
|
||||||
|
String get insights_next_day {
|
||||||
|
return Intl.message(
|
||||||
|
'Next day',
|
||||||
|
name: 'insights_next_day',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Pick a date`
|
||||||
|
String get insights_pick_date {
|
||||||
|
return Intl.message(
|
||||||
|
'Pick a date',
|
||||||
|
name: 'insights_pick_date',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Today`
|
||||||
|
String get insights_today {
|
||||||
|
return Intl.message('Today', name: 'insights_today', desc: '', args: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Failed to save the entry`
|
||||||
|
String get insights_save_error {
|
||||||
|
return Intl.message(
|
||||||
|
'Failed to save the entry',
|
||||||
|
name: 'insights_save_error',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// `Order in progress`
|
/// `Order in progress`
|
||||||
String get order_in_progress {
|
String get order_in_progress {
|
||||||
return Intl.message(
|
return Intl.message(
|
||||||
|
|||||||
@@ -210,27 +210,27 @@
|
|||||||
"personal": "Персональные",
|
"personal": "Персональные",
|
||||||
"edit": "Редактировать",
|
"edit": "Редактировать",
|
||||||
"toOffer": "Предложить",
|
"toOffer": "Предложить",
|
||||||
"removeFromActive": "Снять с публикации",
|
"removeFromActive": "Снять с публикации",
|
||||||
"diet": "Диета",
|
"diet": "Диета",
|
||||||
"deleted": "Удалена",
|
"deleted": "Удалена",
|
||||||
"my_plans": "Мои планы",
|
"my_plans": "Мои планы",
|
||||||
"from": "от",
|
"from": "от",
|
||||||
"user_data": "Данные пользователя",
|
"user_data": "Данные пользователя",
|
||||||
"general_info": "Общая информация",
|
"general_info": "Общая информация",
|
||||||
"sex": "Пол",
|
"sex": "Пол",
|
||||||
"physical_fitness": "Физическая форма",
|
"physical_fitness": "Физическая форма",
|
||||||
"height": "Рост",
|
"height": "Рост",
|
||||||
"pressure": "Давление",
|
"pressure": "Давление",
|
||||||
"upper": "Верхнее",
|
"upper": "Верхнее",
|
||||||
"lower": "Нижнее",
|
"lower": "Нижнее",
|
||||||
"has_hypo": "Склонность к гипотонии",
|
"has_hypo": "Склонность к гипотонии",
|
||||||
"has_hyper": "Склонность к гипертонии",
|
"has_hyper": "Склонность к гипертонии",
|
||||||
"other_info": "Прочая информация",
|
"other_info": "Прочая информация",
|
||||||
"has_alergy": "Наличие аллергии",
|
"has_alergy": "Наличие аллергии",
|
||||||
"takes_pills": "Принимает лекарства",
|
"takes_pills": "Принимает лекарства",
|
||||||
"sport_hobbies": "Спортивные увлечения",
|
"sport_hobbies": "Спортивные увлечения",
|
||||||
"liked_groceries": "Любимые продукты",
|
"liked_groceries": "Любимые продукты",
|
||||||
"not_liked_groceries": "Не любимые продукты",
|
"not_liked_groceries": "Не любимые продукты",
|
||||||
"diet_started": "Диета уже начата",
|
"diet_started": "Диета уже начата",
|
||||||
"cancel_diet": "Отменить диету?",
|
"cancel_diet": "Отменить диету?",
|
||||||
"confirm": "Подтвердить",
|
"confirm": "Подтвердить",
|
||||||
@@ -239,7 +239,7 @@
|
|||||||
"hour": "час",
|
"hour": "час",
|
||||||
"another_form_of_hour": "часа",
|
"another_form_of_hour": "часа",
|
||||||
"minutes": "минуты",
|
"minutes": "минуты",
|
||||||
"minute": "минута",
|
"minute": "минута",
|
||||||
"another_form_of_minute": "минут",
|
"another_form_of_minute": "минут",
|
||||||
"there_is_a_video": "Есть видео",
|
"there_is_a_video": "Есть видео",
|
||||||
"recipe_author": "Автор рецепта",
|
"recipe_author": "Автор рецепта",
|
||||||
@@ -323,8 +323,8 @@
|
|||||||
"can_not_choose_start_date": "Дату старта плана невозможно выбрать, пока Вы не привяжите карту",
|
"can_not_choose_start_date": "Дату старта плана невозможно выбрать, пока Вы не привяжите карту",
|
||||||
"add_card": "Привязать карту",
|
"add_card": "Привязать карту",
|
||||||
"choose_plan_start_date": "Выберите дату старта плана питания",
|
"choose_plan_start_date": "Выберите дату старта плана питания",
|
||||||
"choose_date": "Выбрать дату",
|
"choose_date": "Выбрать дату",
|
||||||
"date_has_ben_set": "Дата старта начала консультации установлена",
|
"date_has_ben_set": "Дата старта начала консультации установлена",
|
||||||
"tap_on_link_to_pay": "Перейдите по ссылке для оплаты консультации",
|
"tap_on_link_to_pay": "Перейдите по ссылке для оплаты консультации",
|
||||||
"pay": "Оплатить",
|
"pay": "Оплатить",
|
||||||
"choose_bank_for_payment": "Выберите банк",
|
"choose_bank_for_payment": "Выберите банк",
|
||||||
@@ -343,13 +343,13 @@
|
|||||||
"sbp_qr_test_hint": "Симуляция оплаты на тестовом стенде Альфа-Банка",
|
"sbp_qr_test_hint": "Симуляция оплаты на тестовом стенде Альфа-Банка",
|
||||||
"more_about_order": "Подробнее о заказе",
|
"more_about_order": "Подробнее о заказе",
|
||||||
"cancel_order": "Отменить заказ",
|
"cancel_order": "Отменить заказ",
|
||||||
"refuse_order":"Отказаться от заказа",
|
"refuse_order": "Отказаться от заказа",
|
||||||
"user_info": "Анкета пользователя",
|
"user_info": "Анкета пользователя",
|
||||||
"pending_payment": "Ожидается оплата",
|
"pending_payment": "Ожидается оплата",
|
||||||
"my_cards": "Мои карты",
|
"my_cards": "Мои карты",
|
||||||
"new_card": "Добавить карту",
|
"new_card": "Добавить карту",
|
||||||
"no_default_cards": "У вас нет привязанных карт. Что бы начать работу, привяжите карту",
|
"no_default_cards": "У вас нет привязанных карт. Что бы начать работу, привяжите карту",
|
||||||
"chose_plan_start_date_2":"Выберите дату старты плана. Она отобразиться в дневнике пользователя",
|
"chose_plan_start_date_2": "Выберите дату старты плана. Она отобразиться в дневнике пользователя",
|
||||||
"plan_seller": "Продавец плана",
|
"plan_seller": "Продавец плана",
|
||||||
"payment_failed": "Оплата не прошла",
|
"payment_failed": "Оплата не прошла",
|
||||||
"order_paid": "Заказ оплачен",
|
"order_paid": "Заказ оплачен",
|
||||||
@@ -396,7 +396,7 @@
|
|||||||
"chargeApproved": "Выплата одобрена",
|
"chargeApproved": "Выплата одобрена",
|
||||||
"moneySentToCard": "Средства отправлены на карту",
|
"moneySentToCard": "Средства отправлены на карту",
|
||||||
"chargeCollectionFailed": "Не удалось оформить выплату",
|
"chargeCollectionFailed": "Не удалось оформить выплату",
|
||||||
"contactSupport": "Обратитесь в поддержку",
|
"contactSupport": "Обратитесь в поддержку",
|
||||||
"atLeast3Symbols": "Минимум 3 символа",
|
"atLeast3Symbols": "Минимум 3 символа",
|
||||||
"chosePlanType": "Выберите тип плана",
|
"chosePlanType": "Выберите тип плана",
|
||||||
"minPlanPrice": "Минимальная сумма 1200₽",
|
"minPlanPrice": "Минимальная сумма 1200₽",
|
||||||
@@ -414,13 +414,13 @@
|
|||||||
"tomorrowYourPlanWillStart": "Завтра начнется прохождение плана «{planName}»",
|
"tomorrowYourPlanWillStart": "Завтра начнется прохождение плана «{planName}»",
|
||||||
"planStarted": "План «{planName}» начался",
|
"planStarted": "План «{planName}» начался",
|
||||||
"userCanceldThePlanSubscription": "Пользователь {name} отменил подписку на план «{planName}»",
|
"userCanceldThePlanSubscription": "Пользователь {name} отменил подписку на план «{planName}»",
|
||||||
"userSubscribedToPlan": "Пользователь {name} подписался на план «{planName}»",
|
"userSubscribedToPlan": "Пользователь {name} подписался на план «{planName}»",
|
||||||
"markAsRead": "Отметить всё прочитанным",
|
"markAsRead": "Отметить всё прочитанным",
|
||||||
"allowAcessToGalery": "Разрешите доступ к фото",
|
"allowAcessToGalery": "Разрешите доступ к фото",
|
||||||
"allowAcessToGaleryDescription": "Предоставление доступа позволит вам добавлять фото к записям",
|
"allowAcessToGaleryDescription": "Предоставление доступа позволит вам добавлять фото к записям",
|
||||||
"allowAccessToCamera": "Разрешите доступ к камере",
|
"allowAccessToCamera": "Разрешите доступ к камере",
|
||||||
"allowAccessToCameraDescription": "Предоставление доступа позволит вам делать фотографии и добавлять к записям",
|
"allowAccessToCameraDescription": "Предоставление доступа позволит вам делать фотографии и добавлять к записям",
|
||||||
"allowAccess": "Разрешить доступ" ,
|
"allowAccess": "Разрешить доступ",
|
||||||
"copy": "Скопировать",
|
"copy": "Скопировать",
|
||||||
"reply": "Ответить",
|
"reply": "Ответить",
|
||||||
"deleteComment": "Удалить комментарий",
|
"deleteComment": "Удалить комментарий",
|
||||||
@@ -431,7 +431,7 @@
|
|||||||
"choose_plan_start_date_with_personal_diet": "Выберите дату старта плана питания с учетом составления персональной диеты",
|
"choose_plan_start_date_with_personal_diet": "Выберите дату старта плана питания с учетом составления персональной диеты",
|
||||||
"chosenPlanStartDateDescr": "Дату старта плана можно выбрать не ранее, чем через два. Такое ограничение дается на создание персональной диеты",
|
"chosenPlanStartDateDescr": "Дату старта плана можно выбрать не ранее, чем через два. Такое ограничение дается на создание персональной диеты",
|
||||||
"createPersonalDiet": "Составьте персональную диету",
|
"createPersonalDiet": "Составьте персональную диету",
|
||||||
"goToMyDiets": "Перейти к моим диетам",
|
"goToMyDiets": "Перейти к моим диетам",
|
||||||
"accountDeleting": "Удаление аккаунта",
|
"accountDeleting": "Удаление аккаунта",
|
||||||
"youShureYouWantToDeleteAccount": "Вы уверены, что хотите удалить аккаунт?",
|
"youShureYouWantToDeleteAccount": "Вы уверены, что хотите удалить аккаунт?",
|
||||||
"itCantBeRecovered": "Его нельзя будет восстановить",
|
"itCantBeRecovered": "Его нельзя будет восстановить",
|
||||||
@@ -439,11 +439,11 @@
|
|||||||
"cannotDeleteAccount": "Не получилось удалить ваш аккаунт",
|
"cannotDeleteAccount": "Не получилось удалить ваш аккаунт",
|
||||||
"resend": "Отправить повторно",
|
"resend": "Отправить повторно",
|
||||||
"notifications": "Уведомления",
|
"notifications": "Уведомления",
|
||||||
"step_widget": "шаг {step} из 2",
|
"step_widget": "шаг {step} из 2",
|
||||||
"your_name": "Ваше имя",
|
"your_name": "Ваше имя",
|
||||||
"your_last_name": "Ваша фамилия",
|
"your_last_name": "Ваша фамилия",
|
||||||
"type_email": "Введите email",
|
"type_email": "Введите email",
|
||||||
"phone": "Телефон",
|
"phone": "Телефон",
|
||||||
"male_short": "М",
|
"male_short": "М",
|
||||||
"female_short": "Ж",
|
"female_short": "Ж",
|
||||||
"next": "Далее",
|
"next": "Далее",
|
||||||
@@ -456,19 +456,19 @@
|
|||||||
"activity_second_value": "1,375 — низкий (тренировки не менее 20 мин 1–3 раза в неделю)",
|
"activity_second_value": "1,375 — низкий (тренировки не менее 20 мин 1–3 раза в неделю)",
|
||||||
"activity_third_value": "1,55 — умеренный (тренировки 30–60 мин 3–4 раза в неделю)",
|
"activity_third_value": "1,55 — умеренный (тренировки 30–60 мин 3–4 раза в неделю)",
|
||||||
"activity_fourth_value": "1,7 — высокий (тренировки 30–60 мин 5–7 раза в неделю; тяжёлая физическая работа)",
|
"activity_fourth_value": "1,7 — высокий (тренировки 30–60 мин 5–7 раза в неделю; тяжёлая физическая работа)",
|
||||||
"activity_fifth_value": "1,9 — экстремальный (несколько интенсивных тренировок в день)",
|
"activity_fifth_value": "1,9 — экстремальный (несколько интенсивных тренировок в день)",
|
||||||
"registration_completed": "Регистрация завершена!",
|
"registration_completed": "Регистрация завершена!",
|
||||||
"acc_has_been_created": "Ваш аккаунт создан. Для входа используйте этот пароль:",
|
"acc_has_been_created": "Ваш аккаунт создан. Для входа используйте этот пароль:",
|
||||||
"password_desc": "Сохраните его в надежном месте! Вы сможете изменить пароль в настройках профиля",
|
"password_desc": "Сохраните его в надежном месте! Вы сможете изменить пароль в настройках профиля",
|
||||||
"acc_has_been_created_btn": "Войти в аккаунт",
|
"acc_has_been_created_btn": "Войти в аккаунт",
|
||||||
"first_name_validation_error": "Имя обязательно к заполнению",
|
"first_name_validation_error": "Имя обязательно к заполнению",
|
||||||
"last_name_error": "Фамилия обязательна для заполнения",
|
"last_name_error": "Фамилия обязательна для заполнения",
|
||||||
"email_error": "Некорректный адрес электронной почты",
|
"email_error": "Некорректный адрес электронной почты",
|
||||||
"phone_error": "Некорректный номер телефона",
|
"phone_error": "Некорректный номер телефона",
|
||||||
"gender_error": "Выберите пол",
|
"gender_error": "Выберите пол",
|
||||||
"date_of_birth_error": "Некорректная дата рождения",
|
"date_of_birth_error": "Некорректная дата рождения",
|
||||||
"height_error": "Введите рост",
|
"height_error": "Введите рост",
|
||||||
"weight_error": "Введите вес",
|
"weight_error": "Введите вес",
|
||||||
"phisical_activity": "Уровень физической активности",
|
"phisical_activity": "Уровень физической активности",
|
||||||
"copied2": "Скопировано!",
|
"copied2": "Скопировано!",
|
||||||
"login": "Войти в аккаунт",
|
"login": "Войти в аккаунт",
|
||||||
@@ -483,7 +483,7 @@
|
|||||||
"letsInvite": "Давайте пригласим близких в итвио",
|
"letsInvite": "Давайте пригласим близких в итвио",
|
||||||
"eatvioIsAHelthyHabbit": "Итвио — это полезная привычка",
|
"eatvioIsAHelthyHabbit": "Итвио — это полезная привычка",
|
||||||
"invite": "Пригласить",
|
"invite": "Пригласить",
|
||||||
"comment_hint": "Комментарий",
|
"comment_hint": "Комментарий",
|
||||||
"hide_photo_title": "Спрятать фото?",
|
"hide_photo_title": "Спрятать фото?",
|
||||||
"hide_photo_description": "Фото не будет доступно для пользователей, просматривающих ваш профиль",
|
"hide_photo_description": "Фото не будет доступно для пользователей, просматривающих ваш профиль",
|
||||||
"unhide_photo_title": "Показать фото?",
|
"unhide_photo_title": "Показать фото?",
|
||||||
@@ -520,8 +520,12 @@
|
|||||||
"promo_discount_hint": "Discount {percent}% — you save {amount} ₽",
|
"promo_discount_hint": "Discount {percent}% — you save {amount} ₽",
|
||||||
"@promo_discount_hint": {
|
"@promo_discount_hint": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"percent": {"type": "int"},
|
"percent": {
|
||||||
"amount": {"type": "String"}
|
"type": "int"
|
||||||
|
},
|
||||||
|
"amount": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"promo_remove_failed": "Could not remove the promo code. Try again later",
|
"promo_remove_failed": "Could not remove the promo code. Try again later",
|
||||||
@@ -538,26 +542,227 @@
|
|||||||
"video_call_notification_in_progress": "Call in progress",
|
"video_call_notification_in_progress": "Call in progress",
|
||||||
"video_call_notification_return": "Return to call",
|
"video_call_notification_return": "Return to call",
|
||||||
"video_call_notification_end": "End call",
|
"video_call_notification_end": "End call",
|
||||||
|
"insights_title": "Analytics",
|
||||||
|
"insights_subtitle_closed": "tap to see your progress",
|
||||||
|
"insights_subtitle_open": "weight, measurements and nutrition",
|
||||||
|
"insights_tab_weight": "Weight",
|
||||||
|
"insights_tab_bmi": "BMI",
|
||||||
|
"insights_tab_measures": "Measurements",
|
||||||
|
"insights_tab_daily": "Nutrition by day",
|
||||||
|
"insights_tab_intra": "Throughout the day",
|
||||||
|
"insights_period_1m": "1M",
|
||||||
|
"insights_period_3m": "3M",
|
||||||
|
"insights_period_6m": "6M",
|
||||||
|
"insights_period_1y": "1Y",
|
||||||
|
"insights_period_all": "All time",
|
||||||
|
"insights_period_1w": "1W",
|
||||||
|
"insights_period_2w": "2W",
|
||||||
|
"insights_series_waist": "Waist",
|
||||||
|
"insights_series_chest": "Chest",
|
||||||
|
"insights_series_hips": "Hips",
|
||||||
|
"insights_series_calories": "Calories",
|
||||||
|
"insights_series_proteins": "Protein",
|
||||||
|
"insights_series_fats": "Fat",
|
||||||
|
"insights_series_carbs": "Carbs",
|
||||||
|
"insights_series_satiety": "Satiety",
|
||||||
|
"insights_series_mood": "Mood",
|
||||||
|
"insights_series_sleep": "Sleep and wake-up",
|
||||||
|
"insights_unit_kg": "kg",
|
||||||
|
"insights_unit_bmi": "BMI",
|
||||||
|
"insights_unit_cm": "cm",
|
||||||
|
"insights_unit_cm_waist": "cm waist",
|
||||||
|
"insights_unit_kcal": "kcal",
|
||||||
|
"insights_unit_kcal_avg": "kcal on average",
|
||||||
|
"insights_unit_grams": "grams",
|
||||||
|
"insights_unit_kcal_per_meal": "kcal per meal",
|
||||||
|
"insights_period_label_1": "month",
|
||||||
|
"insights_period_label_3": "3 months",
|
||||||
|
"insights_period_label_6": "6 months",
|
||||||
|
"insights_period_label_12": "year",
|
||||||
|
"insights_period_label_all": "all time",
|
||||||
|
"insights_not_enough": "Not enough data",
|
||||||
|
"insights_need_height": "Set your height to calculate BMI.",
|
||||||
|
"insights_weight_lost": "Lost {value} kg over {period}",
|
||||||
|
"@insights_weight_lost": {
|
||||||
|
"placeholders": {
|
||||||
|
"value": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"period": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insights_weight_gained": "Gained {value} kg over {period}",
|
||||||
|
"@insights_weight_gained": {
|
||||||
|
"placeholders": {
|
||||||
|
"value": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"period": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insights_weight_same": "Weight unchanged over {period}",
|
||||||
|
"@insights_weight_same": {
|
||||||
|
"placeholders": {
|
||||||
|
"period": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insights_bmi_lower": "BMI lower by {value} over {period} · {category}",
|
||||||
|
"@insights_bmi_lower": {
|
||||||
|
"placeholders": {
|
||||||
|
"value": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"period": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insights_bmi_higher": "BMI higher by {value} over {period} · {category}",
|
||||||
|
"@insights_bmi_higher": {
|
||||||
|
"placeholders": {
|
||||||
|
"value": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"period": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insights_bmi_same": "BMI unchanged over {period} · {category}",
|
||||||
|
"@insights_bmi_same": {
|
||||||
|
"placeholders": {
|
||||||
|
"period": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insights_bmi_hint": "Green zone is normal (18.5–25), yellow is overweight, pink is obesity. Calculated for a height of {height} cm.",
|
||||||
|
"@insights_bmi_hint": {
|
||||||
|
"placeholders": {
|
||||||
|
"height": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insights_bmi_severe_underweight": "severe underweight",
|
||||||
|
"insights_bmi_underweight": "underweight",
|
||||||
|
"insights_bmi_normal": "normal",
|
||||||
|
"insights_bmi_overweight": "overweight",
|
||||||
|
"insights_bmi_obesity_1": "obesity class I",
|
||||||
|
"insights_bmi_obesity_2": "obesity class II",
|
||||||
|
"insights_bmi_obesity_3": "obesity class III",
|
||||||
|
"insights_measure_less": "{name} down {value} cm",
|
||||||
|
"@insights_measure_less": {
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insights_measure_more": "{name} up {value} cm",
|
||||||
|
"@insights_measure_more": {
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insights_measure_same": "{name} unchanged",
|
||||||
|
"@insights_measure_same": {
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insights_measures_summary": "{parts} over {period}",
|
||||||
|
"@insights_measures_summary": {
|
||||||
|
"placeholders": {
|
||||||
|
"parts": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"period": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insights_add_record": "Add entry",
|
||||||
|
"insights_add_weight_empty": "Add your weight",
|
||||||
|
"insights_add_measures_empty": "Add your measurements",
|
||||||
|
"insights_load_error": "Failed to load analytics",
|
||||||
|
"insights_retry": "Retry",
|
||||||
|
"insights_intra_hint": "Bars show what each meal consists of: protein, fat and carbs in kcal. Lines show satiety and mood on a 0–5 scale.",
|
||||||
|
"insights_sleep_shade": "Shading — sleep time",
|
||||||
|
"insights_wake_at": "Wake-up at {time}",
|
||||||
|
"@insights_wake_at": {
|
||||||
|
"placeholders": {
|
||||||
|
"time": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insights_sleep_at": "Bedtime at {time}",
|
||||||
|
"@insights_sleep_at": {
|
||||||
|
"placeholders": {
|
||||||
|
"time": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insights_prev_day": "Previous day",
|
||||||
|
"insights_next_day": "Next day",
|
||||||
|
"insights_pick_date": "Pick a date",
|
||||||
|
"insights_today": "Today",
|
||||||
|
"insights_save_error": "Failed to save the entry",
|
||||||
"order_in_progress": "Order in progress",
|
"order_in_progress": "Order in progress",
|
||||||
"personal_diet_proposed": "Personal diet proposed",
|
"personal_diet_proposed": "Personal diet proposed",
|
||||||
"personal_diet_proposed_named": "Diet «{name}» proposed",
|
"personal_diet_proposed_named": "Diet «{name}» proposed",
|
||||||
"@personal_diet_proposed_named": {
|
"@personal_diet_proposed_named": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"name": {"type": "String"}
|
"name": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"default_user_name": "User",
|
"default_user_name": "User",
|
||||||
"personal_diet_defined_to_author": "You have proposed a personal diet to {userName}",
|
"personal_diet_defined_to_author": "You have proposed a personal diet to {userName}",
|
||||||
"@personal_diet_defined_to_author": {
|
"@personal_diet_defined_to_author": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"userName": {"type": "String"}
|
"userName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"personal_diet_defined_to_author_named": "You have proposed a personal diet to {userName}, selected diet: «{dietName}»",
|
"personal_diet_defined_to_author_named": "You have proposed a personal diet to {userName}, selected diet: «{dietName}»",
|
||||||
"@personal_diet_defined_to_author_named": {
|
"@personal_diet_defined_to_author_named": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"userName": {"type": "String"},
|
"userName": {
|
||||||
"dietName": {"type": "String"}
|
"type": "String"
|
||||||
|
},
|
||||||
|
"dietName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"personal_diet_not_proposed": "Personal diet not proposed",
|
"personal_diet_not_proposed": "Personal diet not proposed",
|
||||||
@@ -566,32 +771,49 @@
|
|||||||
"start_date_label": "Start: {date}",
|
"start_date_label": "Start: {date}",
|
||||||
"@start_date_label": {
|
"@start_date_label": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"date": {"type": "String"}
|
"date": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"personal_diet_duration_hint": "The diet lasts {duration} {dayWord}. It can only be offered for a plan with the same duration.",
|
"personal_diet_duration_hint": "The diet lasts {duration} {dayWord}. It can only be offered for a plan with the same duration.",
|
||||||
"@personal_diet_duration_hint": {
|
"@personal_diet_duration_hint": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"duration": {"type": "int"},
|
"duration": {
|
||||||
"dayWord": {"type": "String"}
|
"type": "int"
|
||||||
|
},
|
||||||
|
"dayWord": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"personal_diet_duration_mismatch": "Not suitable: diet lasts {dietDuration} {dietDayWord}, plan lasts {planDuration} {planDayWord}.",
|
"personal_diet_duration_mismatch": "Not suitable: diet lasts {dietDuration} {dietDayWord}, plan lasts {planDuration} {planDayWord}.",
|
||||||
"@personal_diet_duration_mismatch": {
|
"@personal_diet_duration_mismatch": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"dietDuration": {"type": "int"},
|
"dietDuration": {
|
||||||
"dietDayWord": {"type": "String"},
|
"type": "int"
|
||||||
"planDuration": {"type": "int"},
|
},
|
||||||
"planDayWord": {"type": "String"}
|
"dietDayWord": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"planDuration": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"planDayWord": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"plan_duration_label": "Plan for {duration} {dayWord}",
|
"plan_duration_label": "Plan for {duration} {dayWord}",
|
||||||
"@plan_duration_label": {
|
"@plan_duration_label": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"duration": {"type": "int"},
|
"duration": {
|
||||||
"dayWord": {"type": "String"}
|
"type": "int"
|
||||||
|
},
|
||||||
|
"dayWord": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"no_active_personal_diet_consultations": "No active consultations with a personal diet"
|
"no_active_personal_diet_consultations": "No active consultations with a personal diet"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -263,8 +263,12 @@
|
|||||||
"promo_discount_hint": "Скидка {percent}% — вы экономите {amount} ₽",
|
"promo_discount_hint": "Скидка {percent}% — вы экономите {amount} ₽",
|
||||||
"@promo_discount_hint": {
|
"@promo_discount_hint": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"percent": {"type": "int"},
|
"percent": {
|
||||||
"amount": {"type": "String"}
|
"type": "int"
|
||||||
|
},
|
||||||
|
"amount": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"promo_remove_failed": "Не удалось убрать промокод. Попробуйте позже",
|
"promo_remove_failed": "Не удалось убрать промокод. Попробуйте позже",
|
||||||
@@ -283,6 +287,199 @@
|
|||||||
"video_call_notification_return": "Вернуться к звонку",
|
"video_call_notification_return": "Вернуться к звонку",
|
||||||
"video_call_notification_end": "Завершить звонок",
|
"video_call_notification_end": "Завершить звонок",
|
||||||
"blog_post_image_min_size_error": "Минимальный размер изображения — 500×500 px",
|
"blog_post_image_min_size_error": "Минимальный размер изображения — 500×500 px",
|
||||||
|
"insights_title": "Аналитика",
|
||||||
|
"insights_subtitle_closed": "нажмите, чтобы посмотреть прогресс",
|
||||||
|
"insights_subtitle_open": "вес, замеры и питание",
|
||||||
|
"insights_tab_weight": "Вес",
|
||||||
|
"insights_tab_bmi": "ИМТ",
|
||||||
|
"insights_tab_measures": "Замеры",
|
||||||
|
"insights_tab_daily": "КБЖУ по дням",
|
||||||
|
"insights_tab_intra": "В течение дня",
|
||||||
|
"insights_period_1m": "1М",
|
||||||
|
"insights_period_3m": "3М",
|
||||||
|
"insights_period_6m": "6М",
|
||||||
|
"insights_period_1y": "1Г",
|
||||||
|
"insights_period_all": "Всё время",
|
||||||
|
"insights_period_1w": "1Н",
|
||||||
|
"insights_period_2w": "2Н",
|
||||||
|
"insights_series_waist": "Талия",
|
||||||
|
"insights_series_chest": "Грудь",
|
||||||
|
"insights_series_hips": "Бёдра",
|
||||||
|
"insights_series_calories": "Калории",
|
||||||
|
"insights_series_proteins": "Белки",
|
||||||
|
"insights_series_fats": "Жиры",
|
||||||
|
"insights_series_carbs": "Углеводы",
|
||||||
|
"insights_series_satiety": "Сытость",
|
||||||
|
"insights_series_mood": "Настроение",
|
||||||
|
"insights_series_sleep": "Сон и подъём",
|
||||||
|
"insights_unit_kg": "кг",
|
||||||
|
"insights_unit_bmi": "ИМТ",
|
||||||
|
"insights_unit_cm": "см",
|
||||||
|
"insights_unit_cm_waist": "см талия",
|
||||||
|
"insights_unit_kcal": "ккал",
|
||||||
|
"insights_unit_kcal_avg": "ккал в среднем",
|
||||||
|
"insights_unit_grams": "граммы",
|
||||||
|
"insights_unit_kcal_per_meal": "ккал за приём",
|
||||||
|
"insights_period_label_1": "месяц",
|
||||||
|
"insights_period_label_3": "3 месяца",
|
||||||
|
"insights_period_label_6": "6 месяцев",
|
||||||
|
"insights_period_label_12": "год",
|
||||||
|
"insights_period_label_all": "всё время",
|
||||||
|
"insights_not_enough": "Недостаточно данных",
|
||||||
|
"insights_need_height": "Укажите рост, чтобы посчитать ИМТ.",
|
||||||
|
"insights_weight_lost": "Снизили на {value} кг за {period}",
|
||||||
|
"@insights_weight_lost": {
|
||||||
|
"placeholders": {
|
||||||
|
"value": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"period": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insights_weight_gained": "Набрали {value} кг за {period}",
|
||||||
|
"@insights_weight_gained": {
|
||||||
|
"placeholders": {
|
||||||
|
"value": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"period": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insights_weight_same": "Вес не изменился за {period}",
|
||||||
|
"@insights_weight_same": {
|
||||||
|
"placeholders": {
|
||||||
|
"period": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insights_bmi_lower": "ИМТ ниже на {value} за {period} · {category}",
|
||||||
|
"@insights_bmi_lower": {
|
||||||
|
"placeholders": {
|
||||||
|
"value": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"period": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insights_bmi_higher": "ИМТ выше на {value} за {period} · {category}",
|
||||||
|
"@insights_bmi_higher": {
|
||||||
|
"placeholders": {
|
||||||
|
"value": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"period": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insights_bmi_same": "ИМТ не изменился за {period} · {category}",
|
||||||
|
"@insights_bmi_same": {
|
||||||
|
"placeholders": {
|
||||||
|
"period": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insights_bmi_hint": "Зелёная зона — норма (18.5–25), жёлтая — избыточный вес, розовая — ожирение. Считаем при росте {height} см.",
|
||||||
|
"@insights_bmi_hint": {
|
||||||
|
"placeholders": {
|
||||||
|
"height": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insights_bmi_severe_underweight": "выраженный дефицит массы",
|
||||||
|
"insights_bmi_underweight": "недостаточная масса",
|
||||||
|
"insights_bmi_normal": "норма",
|
||||||
|
"insights_bmi_overweight": "избыточная масса",
|
||||||
|
"insights_bmi_obesity_1": "ожирение I ст.",
|
||||||
|
"insights_bmi_obesity_2": "ожирение II ст.",
|
||||||
|
"insights_bmi_obesity_3": "ожирение III ст.",
|
||||||
|
"insights_measure_less": "{name} меньше на {value} см",
|
||||||
|
"@insights_measure_less": {
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insights_measure_more": "{name} больше на {value} см",
|
||||||
|
"@insights_measure_more": {
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insights_measure_same": "{name} без изменений",
|
||||||
|
"@insights_measure_same": {
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insights_measures_summary": "{parts} за {period}",
|
||||||
|
"@insights_measures_summary": {
|
||||||
|
"placeholders": {
|
||||||
|
"parts": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"period": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insights_add_record": "Добавить запись",
|
||||||
|
"insights_add_weight_empty": "Добавьте Ваш вес",
|
||||||
|
"insights_add_measures_empty": "Добавьте Ваши замеры",
|
||||||
|
"insights_load_error": "Не удалось загрузить аналитику",
|
||||||
|
"insights_retry": "Повторить",
|
||||||
|
"insights_intra_hint": "Столбики показывают, из чего состоят приёмы пищи: белки, жиры и углеводы в ккал. Линии — сытость и настроение по шкале от 0 до 5.",
|
||||||
|
"insights_sleep_shade": "Затемнение — время сна",
|
||||||
|
"insights_wake_at": "Подъём в {time}",
|
||||||
|
"@insights_wake_at": {
|
||||||
|
"placeholders": {
|
||||||
|
"time": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insights_sleep_at": "Отход ко сну в {time}",
|
||||||
|
"@insights_sleep_at": {
|
||||||
|
"placeholders": {
|
||||||
|
"time": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insights_prev_day": "Предыдущий день",
|
||||||
|
"insights_next_day": "Следующий день",
|
||||||
|
"insights_pick_date": "Выбрать дату",
|
||||||
|
"insights_today": "Сегодня",
|
||||||
|
"insights_save_error": "Не удалось сохранить запись",
|
||||||
"startDateHasBeenSet": "Выбрана дата старта",
|
"startDateHasBeenSet": "Выбрана дата старта",
|
||||||
"order_paid": "Заказ оплачен",
|
"order_paid": "Заказ оплачен",
|
||||||
"done": "Завершен",
|
"done": "Завершен",
|
||||||
@@ -298,21 +495,29 @@
|
|||||||
"personal_diet_proposed_named": "Диета «{name}» предложена",
|
"personal_diet_proposed_named": "Диета «{name}» предложена",
|
||||||
"@personal_diet_proposed_named": {
|
"@personal_diet_proposed_named": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"name": {"type": "String"}
|
"name": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"default_user_name": "Пользователь",
|
"default_user_name": "Пользователь",
|
||||||
"personal_diet_defined_to_author": "Вы предложили персональную диету пользователю {userName}",
|
"personal_diet_defined_to_author": "Вы предложили персональную диету пользователю {userName}",
|
||||||
"@personal_diet_defined_to_author": {
|
"@personal_diet_defined_to_author": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"userName": {"type": "String"}
|
"userName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"personal_diet_defined_to_author_named": "Вы предложили персональную диету пользователю {userName}, выбранная диета: «{dietName}»",
|
"personal_diet_defined_to_author_named": "Вы предложили персональную диету пользователю {userName}, выбранная диета: «{dietName}»",
|
||||||
"@personal_diet_defined_to_author_named": {
|
"@personal_diet_defined_to_author_named": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"userName": {"type": "String"},
|
"userName": {
|
||||||
"dietName": {"type": "String"}
|
"type": "String"
|
||||||
|
},
|
||||||
|
"dietName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"personal_diet_not_proposed": "Диета не предложена",
|
"personal_diet_not_proposed": "Диета не предложена",
|
||||||
@@ -321,30 +526,48 @@
|
|||||||
"start_date_label": "Старт: {date}",
|
"start_date_label": "Старт: {date}",
|
||||||
"@start_date_label": {
|
"@start_date_label": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"date": {"type": "String"}
|
"date": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"personal_diet_duration_hint": "Диета рассчитана на {duration} {dayWord}. Предложить её можно только для плана с такой же длительностью.",
|
"personal_diet_duration_hint": "Диета рассчитана на {duration} {dayWord}. Предложить её можно только для плана с такой же длительностью.",
|
||||||
"@personal_diet_duration_hint": {
|
"@personal_diet_duration_hint": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"duration": {"type": "int"},
|
"duration": {
|
||||||
"dayWord": {"type": "String"}
|
"type": "int"
|
||||||
|
},
|
||||||
|
"dayWord": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"personal_diet_duration_mismatch": "Не подходит: диета на {dietDuration} {dietDayWord}, план на {planDuration} {planDayWord}.",
|
"personal_diet_duration_mismatch": "Не подходит: диета на {dietDuration} {dietDayWord}, план на {planDuration} {planDayWord}.",
|
||||||
"@personal_diet_duration_mismatch": {
|
"@personal_diet_duration_mismatch": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"dietDuration": {"type": "int"},
|
"dietDuration": {
|
||||||
"dietDayWord": {"type": "String"},
|
"type": "int"
|
||||||
"planDuration": {"type": "int"},
|
},
|
||||||
"planDayWord": {"type": "String"}
|
"dietDayWord": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"planDuration": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"planDayWord": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"plan_duration_label": "План на {duration} {dayWord}",
|
"plan_duration_label": "План на {duration} {dayWord}",
|
||||||
"@plan_duration_label": {
|
"@plan_duration_label": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"duration": {"type": "int"},
|
"duration": {
|
||||||
"dayWord": {"type": "String"}
|
"type": "int"
|
||||||
|
},
|
||||||
|
"dayWord": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"no_active_personal_diet_consultations": "Нет активных консультаций с персональной диетой"
|
"no_active_personal_diet_consultations": "Нет активных консультаций с персональной диетой"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name: eatvio_app
|
name: eatvio_app
|
||||||
description: A new Flutter project.
|
description: A new Flutter project.
|
||||||
version: 1.3.71+160
|
version: 1.3.72+161
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -31,24 +31,70 @@ void main() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
test('parses commented_from_diet state', () {
|
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);
|
expect(model.portionState, PortionState.commentedFromDiet);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parses reviewed_from_diet state', () {
|
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);
|
expect(model.portionState, PortionState.reviewedFromDiet);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('does not throw on unknown state from backend', () {
|
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);
|
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', () {
|
group('DiaryDayWrapperModel', () {
|
||||||
test('fromJson parses diary correctly', () {
|
test('fromJson parses diary correctly', () {
|
||||||
final json = {
|
final json = {
|
||||||
@@ -57,7 +103,11 @@ void main() {
|
|||||||
'date': '2024-01-15',
|
'date': '2024-01-15',
|
||||||
'closed': 0,
|
'closed': 0,
|
||||||
'comment': 'Test comment',
|
'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': [],
|
'meals': [],
|
||||||
'chosen_plan_id': null,
|
'chosen_plan_id': null,
|
||||||
'day_of_chosen_diet': null,
|
'day_of_chosen_diet': null,
|
||||||
@@ -78,7 +128,11 @@ void main() {
|
|||||||
date: '2024-01-15',
|
date: '2024-01-15',
|
||||||
closed: 0,
|
closed: 0,
|
||||||
comment: 'Test comment',
|
comment: 'Test comment',
|
||||||
typeOfDiary: TypeOfDiaryModel(hasChosenDiet: false, hasChosenPlan: false, diaryState: null),
|
typeOfDiary: TypeOfDiaryModel(
|
||||||
|
hasChosenDiet: false,
|
||||||
|
hasChosenPlan: false,
|
||||||
|
diaryState: null,
|
||||||
|
),
|
||||||
meals: [],
|
meals: [],
|
||||||
chosenPlanId: null,
|
chosenPlanId: null,
|
||||||
dayOfChosenDiet: null,
|
dayOfChosenDiet: null,
|
||||||
@@ -119,7 +173,12 @@ void main() {
|
|||||||
|
|
||||||
group('DiaryPhotoModel', () {
|
group('DiaryPhotoModel', () {
|
||||||
test('fromJson parses all fields correctly', () {
|
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);
|
final model = DiaryPhotoModel.fromJson(json);
|
||||||
|
|
||||||
@@ -130,7 +189,12 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('toJson serializes all fields correctly', () {
|
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();
|
final json = model.toJson();
|
||||||
|
|
||||||
@@ -201,7 +265,11 @@ void main() {
|
|||||||
|
|
||||||
group('ProductInMeal', () {
|
group('ProductInMeal', () {
|
||||||
test('fromJson parses all fields correctly', () {
|
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);
|
final model = ProductInMeal.fromJson(json);
|
||||||
|
|
||||||
@@ -211,7 +279,11 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('toJson serializes all fields correctly', () {
|
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();
|
final json = model.toJson();
|
||||||
|
|
||||||
@@ -228,7 +300,11 @@ void main() {
|
|||||||
date: '2024-01-15',
|
date: '2024-01-15',
|
||||||
closed: 0,
|
closed: 0,
|
||||||
comment: 'Test',
|
comment: 'Test',
|
||||||
typeOfDiary: TypeOfDiaryModel(hasChosenDiet: false, hasChosenPlan: false, diaryState: null),
|
typeOfDiary: TypeOfDiaryModel(
|
||||||
|
hasChosenDiet: false,
|
||||||
|
hasChosenPlan: false,
|
||||||
|
diaryState: null,
|
||||||
|
),
|
||||||
meals: [],
|
meals: [],
|
||||||
chosenPlanId: null,
|
chosenPlanId: null,
|
||||||
dayOfChosenDiet: null,
|
dayOfChosenDiet: null,
|
||||||
@@ -248,7 +324,11 @@ void main() {
|
|||||||
retire: '22:00',
|
retire: '22:00',
|
||||||
closed: 0,
|
closed: 0,
|
||||||
comment: 'Test comment',
|
comment: 'Test comment',
|
||||||
typeOfDiary: TypeOfDiaryModel(hasChosenDiet: false, hasChosenPlan: false, diaryState: null),
|
typeOfDiary: TypeOfDiaryModel(
|
||||||
|
hasChosenDiet: false,
|
||||||
|
hasChosenPlan: false,
|
||||||
|
diaryState: null,
|
||||||
|
),
|
||||||
meals: [],
|
meals: [],
|
||||||
chosenPlanId: null,
|
chosenPlanId: null,
|
||||||
dayOfChosenDiet: null,
|
dayOfChosenDiet: null,
|
||||||
@@ -272,7 +352,11 @@ void main() {
|
|||||||
date: '2024-01-15',
|
date: '2024-01-15',
|
||||||
closed: 0,
|
closed: 0,
|
||||||
comment: 'Test',
|
comment: 'Test',
|
||||||
typeOfDiary: TypeOfDiaryModel(hasChosenDiet: true, hasChosenPlan: false, diaryState: null),
|
typeOfDiary: TypeOfDiaryModel(
|
||||||
|
hasChosenDiet: true,
|
||||||
|
hasChosenPlan: false,
|
||||||
|
diaryState: null,
|
||||||
|
),
|
||||||
meals: [],
|
meals: [],
|
||||||
chosenPlanId: null,
|
chosenPlanId: null,
|
||||||
dayOfChosenDiet: 5,
|
dayOfChosenDiet: 5,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:eatvio_app/core/app_config.dart';
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/app_initializer.dart';
|
import 'package:eatvio_app/core/app_initializer.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
@@ -15,6 +17,8 @@ import 'package:eatvio_app/features/diary/models/diary_day_model.dart';
|
|||||||
import 'package:eatvio_app/features/diary/models/nutrition_model.dart';
|
import 'package:eatvio_app/features/diary/models/nutrition_model.dart';
|
||||||
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
|
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
|
||||||
import 'package:eatvio_app/features/diary/presentation/widgets/meal_widget.dart';
|
import 'package:eatvio_app/features/diary/presentation/widgets/meal_widget.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
|
||||||
import 'package:eatvio_app/generated/l10n.dart';
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
@@ -23,6 +27,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:mockito/annotations.dart';
|
import 'package:mockito/annotations.dart';
|
||||||
import 'package:mockito/mockito.dart';
|
import 'package:mockito/mockito.dart';
|
||||||
|
|
||||||
|
import '../../../../fixtures/doubles/insights_doubles.dart';
|
||||||
import 'diary_screen_smoke_test.mocks.dart';
|
import 'diary_screen_smoke_test.mocks.dart';
|
||||||
|
|
||||||
@GenerateNiceMocks([
|
@GenerateNiceMocks([
|
||||||
@@ -44,6 +49,9 @@ void main() {
|
|||||||
AppConfig(buildType: AppBuildType.tests)
|
AppConfig(buildType: AppBuildType.tests)
|
||||||
..setBaseUrl('http://localhost'),
|
..setBaseUrl('http://localhost'),
|
||||||
);
|
);
|
||||||
|
sl.registerLazySingleton<InsightsRepository>(
|
||||||
|
() => FakeInsightsRepository(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Widget wrap(Widget child) {
|
Widget wrap(Widget child) {
|
||||||
@@ -66,11 +74,13 @@ void main() {
|
|||||||
bool isDiaryFinished = false,
|
bool isDiaryFinished = false,
|
||||||
bool isDiaryCommented = false,
|
bool isDiaryCommented = false,
|
||||||
int closed = 0,
|
int closed = 0,
|
||||||
|
String? ownerSlug,
|
||||||
|
String wakeup = '07:00',
|
||||||
}) {
|
}) {
|
||||||
final diary = DiaryDayEntity(
|
final diary = DiaryDayEntity(
|
||||||
id: 1,
|
id: 1,
|
||||||
date: date,
|
date: date,
|
||||||
wakeup: '07:00',
|
wakeup: wakeup,
|
||||||
retire: '23:00',
|
retire: '23:00',
|
||||||
closed: closed,
|
closed: closed,
|
||||||
comment: '',
|
comment: '',
|
||||||
@@ -93,6 +103,7 @@ void main() {
|
|||||||
chosenPlanId: hasChosenPlan ? 10 : null,
|
chosenPlanId: hasChosenPlan ? 10 : null,
|
||||||
dayOfChosenDiet: null,
|
dayOfChosenDiet: null,
|
||||||
diet: null,
|
diet: null,
|
||||||
|
ownerSlug: ownerSlug,
|
||||||
);
|
);
|
||||||
const nutrition = NutritionDataModel(
|
const nutrition = NutritionDataModel(
|
||||||
dailyNutritionIntake: NutritionModel(
|
dailyNutritionIntake: NutritionModel(
|
||||||
@@ -383,6 +394,241 @@ void main() {
|
|||||||
expect(find.byType(Text), findsWidgets);
|
expect(find.byType(Text), findsWidgets);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Future<void> pumpDiaryWithInsights(
|
||||||
|
WidgetTester tester, {
|
||||||
|
required bool isConsultantView,
|
||||||
|
String? ownerSlug,
|
||||||
|
String? diaryOwnerSlug,
|
||||||
|
Stream<DiaryState>? diaryStates,
|
||||||
|
required FakeInsightsRepository insightsRepository,
|
||||||
|
}) async {
|
||||||
|
final diaryCubit = MockDiaryCubit();
|
||||||
|
final state = loadedState(
|
||||||
|
hasChosenPlan: false,
|
||||||
|
ownerSlug: diaryOwnerSlug,
|
||||||
|
).copyWith(isConsultantView: isConsultantView);
|
||||||
|
when(diaryCubit.state).thenReturn(state);
|
||||||
|
when(
|
||||||
|
diaryCubit.stream,
|
||||||
|
).thenAnswer((_) => diaryStates ?? const Stream<DiaryState>.empty());
|
||||||
|
when(diaryCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||||
|
when(
|
||||||
|
diaryCubit.fetchDiaryV2(
|
||||||
|
needLoading: anyNamed('needLoading'),
|
||||||
|
diaryId: anyNamed('diaryId'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async => true);
|
||||||
|
|
||||||
|
final commentsCubit = MockConsultantCommentsCubit();
|
||||||
|
when(commentsCubit.state).thenReturn(
|
||||||
|
ConsultantCommentsState(
|
||||||
|
commentsModel: ConsultantCommentModel(comment: ''),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
when(
|
||||||
|
commentsCubit.stream,
|
||||||
|
).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||||
|
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||||
|
|
||||||
|
sl.registerFactory<DiaryCubit>(() => diaryCubit);
|
||||||
|
sl.registerFactory<ConsultantCommentsCubit>(() => commentsCubit);
|
||||||
|
sl.unregister<InsightsRepository>();
|
||||||
|
sl.registerLazySingleton<InsightsRepository>(() => insightsRepository);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrap(
|
||||||
|
isConsultantView
|
||||||
|
? UserDiaryScreen(diaryId: 1, ownerSlug: ownerSlug)
|
||||||
|
: DiaryScreen(date: DateTime(2025, 1, 1)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'own diary shows insights card for own data with add button enabled',
|
||||||
|
(tester) async {
|
||||||
|
final insightsRepository = FakeInsightsRepository();
|
||||||
|
|
||||||
|
await pumpDiaryWithInsights(
|
||||||
|
tester,
|
||||||
|
isConsultantView: false,
|
||||||
|
insightsRepository: insightsRepository,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byType(InsightsCard), findsOneWidget);
|
||||||
|
final card = tester.widget<InsightsCard>(find.byType(InsightsCard));
|
||||||
|
expect(card.slug, isNull);
|
||||||
|
expect(card.canAdd, isTrue);
|
||||||
|
expect(card.initialIntraDate, '2025-01-01');
|
||||||
|
expect(insightsRepository.serverRequests.single.slug, isNull);
|
||||||
|
expect(
|
||||||
|
insightsRepository.serverRequests.single.intraDate,
|
||||||
|
'2025-01-01',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'consultant view shows insights card of the client without add button',
|
||||||
|
(tester) async {
|
||||||
|
final insightsRepository = FakeInsightsRepository();
|
||||||
|
|
||||||
|
await pumpDiaryWithInsights(
|
||||||
|
tester,
|
||||||
|
isConsultantView: true,
|
||||||
|
ownerSlug: 'client',
|
||||||
|
insightsRepository: insightsRepository,
|
||||||
|
);
|
||||||
|
|
||||||
|
final card = tester.widget<InsightsCard>(find.byType(InsightsCard));
|
||||||
|
expect(card.slug, 'client');
|
||||||
|
expect(card.canAdd, isFalse);
|
||||||
|
expect(insightsRepository.serverRequests.single.slug, 'client');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'consultant view takes the owner slug from the diary (user.slug)',
|
||||||
|
(tester) async {
|
||||||
|
final insightsRepository = FakeInsightsRepository();
|
||||||
|
|
||||||
|
await pumpDiaryWithInsights(
|
||||||
|
tester,
|
||||||
|
isConsultantView: true,
|
||||||
|
diaryOwnerSlug: 'client-from-diary',
|
||||||
|
insightsRepository: insightsRepository,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
tester.widget<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 {
|
||||||
|
final insightsRepository = FakeInsightsRepository();
|
||||||
|
|
||||||
|
await pumpDiaryWithInsights(
|
||||||
|
tester,
|
||||||
|
isConsultantView: true,
|
||||||
|
insightsRepository: insightsRepository,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byType(InsightsCard), findsNothing);
|
||||||
|
expect(insightsRepository.serverRequests, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('consultant view hides insights card on 403', (tester) async {
|
||||||
|
final insightsRepository = FakeInsightsRepository(
|
||||||
|
error: const InsightsForbidden(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await pumpDiaryWithInsights(
|
||||||
|
tester,
|
||||||
|
isConsultantView: true,
|
||||||
|
ownerSlug: 'client',
|
||||||
|
insightsRepository: insightsRepository,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('insights_card')), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('shows add button for a finished day inside the edit window', (
|
testWidgets('shows add button for a finished day inside the edit window', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
285
test/features/insights/domain/insights_text_builder_test.dart
Normal file
285
test/features/insights/domain/insights_text_builder_test.dart
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
import 'package:eatvio_app/features/insights/domain/insights_text_builder.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||||
|
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
||||||
|
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
WeightModel _weight(double weight, {double? bmi, int day = 1}) {
|
||||||
|
final date = DateTime(2026, 11, day, 12);
|
||||||
|
return WeightModel(
|
||||||
|
id: day,
|
||||||
|
userId: 1,
|
||||||
|
weight: weight,
|
||||||
|
createdAt: date,
|
||||||
|
updatedAt: date,
|
||||||
|
bmi: bmi,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
CircumferenceModel _circumference({
|
||||||
|
double waist = 80,
|
||||||
|
double chest = 95,
|
||||||
|
double hips = 100,
|
||||||
|
int day = 1,
|
||||||
|
}) {
|
||||||
|
final date = DateTime(2026, 11, day, 12);
|
||||||
|
return CircumferenceModel(
|
||||||
|
id: day,
|
||||||
|
userId: 1,
|
||||||
|
chestCircumference: chest,
|
||||||
|
waistCircumference: waist,
|
||||||
|
hipCircumference: hips,
|
||||||
|
createdAt: date,
|
||||||
|
updatedAt: date,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late InsightsTextBuilder builder;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
final s = await S.load(const Locale('ru'));
|
||||||
|
builder = InsightsTextBuilder(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('форматирование', () {
|
||||||
|
test('formatNumber: один знак без хвостового нуля', () {
|
||||||
|
expect(builder.formatNumber(3), '3');
|
||||||
|
expect(builder.formatNumber(3.5), '3.5');
|
||||||
|
expect(builder.formatNumber(3.04), '3');
|
||||||
|
expect(builder.formatNumber(3.26), '3.3');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatLabel: dd.MM', () {
|
||||||
|
expect(builder.formatLabel(DateTime(2026, 11, 9, 12)), '09.11');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('periodLabel: все периоды', () {
|
||||||
|
expect(builder.periodLabel(BodyPeriod.month), 'месяц');
|
||||||
|
expect(builder.periodLabel(BodyPeriod.threeMonths), '3 месяца');
|
||||||
|
expect(builder.periodLabel(BodyPeriod.sixMonths), '6 месяцев');
|
||||||
|
expect(builder.periodLabel(BodyPeriod.year), 'год');
|
||||||
|
expect(builder.periodLabel(BodyPeriod.all), 'всё время');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('вес', () {
|
||||||
|
test('снижение', () {
|
||||||
|
final model = builder.buildWeight([
|
||||||
|
_weight(74.5),
|
||||||
|
_weight(73.2, day: 9),
|
||||||
|
], BodyPeriod.month);
|
||||||
|
|
||||||
|
expect(model.deltaText, 'Снизили на 1.3 кг за месяц');
|
||||||
|
expect(model.delta, -1.3);
|
||||||
|
expect(model.last, 73.2);
|
||||||
|
expect(model.headline, '73.2');
|
||||||
|
expect(model.unit, 'кг');
|
||||||
|
expect(model.labels, ['01.11', '09.11']);
|
||||||
|
expect(model.values, [74.5, 73.2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('набор', () {
|
||||||
|
final model = builder.buildWeight([
|
||||||
|
_weight(70),
|
||||||
|
_weight(72, day: 3),
|
||||||
|
], BodyPeriod.threeMonths);
|
||||||
|
|
||||||
|
expect(model.deltaText, 'Набрали 2 кг за 3 месяца');
|
||||||
|
expect(model.delta, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('порог 0.05: меньше — не изменился', () {
|
||||||
|
final model = builder.buildWeight([
|
||||||
|
_weight(70),
|
||||||
|
_weight(70.04, day: 3),
|
||||||
|
], BodyPeriod.year);
|
||||||
|
|
||||||
|
expect(model.deltaText, 'Вес не изменился за год');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('порог 0.05: ровно 0.25 — уже изменение', () {
|
||||||
|
final model = builder.buildWeight([
|
||||||
|
_weight(70),
|
||||||
|
_weight(70.25, day: 3),
|
||||||
|
], BodyPeriod.all);
|
||||||
|
|
||||||
|
expect(model.deltaText, 'Набрали 0.3 кг за всё время');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('одна точка: недостаточно данных, но headline есть', () {
|
||||||
|
final model = builder.buildWeight([_weight(72)], BodyPeriod.month);
|
||||||
|
|
||||||
|
expect(model.deltaText, 'Недостаточно данных');
|
||||||
|
expect(model.delta, isNull);
|
||||||
|
expect(model.headline, '72.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('нет точек', () {
|
||||||
|
final model = builder.buildWeight([], BodyPeriod.month);
|
||||||
|
|
||||||
|
expect(model.deltaText, 'Недостаточно данных');
|
||||||
|
expect(model.last, isNull);
|
||||||
|
expect(model.headline, '—');
|
||||||
|
expect(model.values, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('категории ИМТ (границы)', () {
|
||||||
|
final cases = <double, String>{
|
||||||
|
15.9: 'выраженный дефицит массы',
|
||||||
|
16.0: 'недостаточная масса',
|
||||||
|
18.4: 'недостаточная масса',
|
||||||
|
18.5: 'норма',
|
||||||
|
24.9: 'норма',
|
||||||
|
25.0: 'избыточная масса',
|
||||||
|
29.9: 'избыточная масса',
|
||||||
|
30.0: 'ожирение I ст.',
|
||||||
|
34.9: 'ожирение I ст.',
|
||||||
|
35.0: 'ожирение II ст.',
|
||||||
|
39.9: 'ожирение II ст.',
|
||||||
|
40.0: 'ожирение III ст.',
|
||||||
|
55.0: 'ожирение III ст.',
|
||||||
|
};
|
||||||
|
|
||||||
|
cases.forEach((bmi, expected) {
|
||||||
|
test('$bmi → $expected', () {
|
||||||
|
expect(builder.bmiCategory(bmi), expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ИМТ', () {
|
||||||
|
test('без роста: пусто и подсказка', () {
|
||||||
|
final model = builder.buildBmi(
|
||||||
|
[_weight(70, bmi: 22)],
|
||||||
|
BodyPeriod.month,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.values, isEmpty);
|
||||||
|
expect(model.headline, '—');
|
||||||
|
expect(model.hint, 'Укажите рост, чтобы посчитать ИМТ.');
|
||||||
|
expect(model.deltaText, 'Укажите рост, чтобы посчитать ИМТ.');
|
||||||
|
expect(model.category, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('нулевой рост считается отсутствующим', () {
|
||||||
|
final model = builder.buildBmi(
|
||||||
|
[_weight(70, bmi: 22)],
|
||||||
|
BodyPeriod.month,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.values, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ИМТ ниже', () {
|
||||||
|
final model = builder.buildBmi(
|
||||||
|
[_weight(80, bmi: 26.5), _weight(75, bmi: 24.2, day: 5)],
|
||||||
|
BodyPeriod.threeMonths,
|
||||||
|
176,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.deltaText, 'ИМТ ниже на 2.3 за 3 месяца · норма');
|
||||||
|
expect(model.category, 'норма');
|
||||||
|
expect(model.headline, '24.2');
|
||||||
|
expect(model.last, 24.2);
|
||||||
|
expect(model.delta, -2.3);
|
||||||
|
expect(model.hint, contains('Считаем при росте 176 см.'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ИМТ выше', () {
|
||||||
|
final model = builder.buildBmi(
|
||||||
|
[_weight(70, bmi: 24), _weight(80, bmi: 27, day: 5)],
|
||||||
|
BodyPeriod.month,
|
||||||
|
170,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.deltaText, 'ИМТ выше на 3 за месяц · избыточная масса');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ИМТ не изменился', () {
|
||||||
|
final model = builder.buildBmi(
|
||||||
|
[_weight(70, bmi: 22), _weight(70, bmi: 22.01, day: 5)],
|
||||||
|
BodyPeriod.year,
|
||||||
|
170,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.deltaText, 'ИМТ не изменился за год · норма');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('точки с bmi = null пропускаются', () {
|
||||||
|
final model = builder.buildBmi(
|
||||||
|
[_weight(70, bmi: null), _weight(71, bmi: 22, day: 3)],
|
||||||
|
BodyPeriod.month,
|
||||||
|
170,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.values, [22]);
|
||||||
|
expect(model.labels, ['03.11']);
|
||||||
|
expect(model.deltaText, 'Недостаточно данных');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('рост есть, точек нет', () {
|
||||||
|
final model = builder.buildBmi([], BodyPeriod.month, 170);
|
||||||
|
|
||||||
|
expect(model.values, isEmpty);
|
||||||
|
expect(model.deltaText, 'Недостаточно данных');
|
||||||
|
expect(model.hint, contains('170'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('замеры', () {
|
||||||
|
test('по каждой части: меньше / больше / без изменений', () {
|
||||||
|
final model = builder.buildMeasure([
|
||||||
|
_circumference(waist: 82, chest: 96, hips: 100),
|
||||||
|
_circumference(waist: 80.5, chest: 98, hips: 100, day: 9),
|
||||||
|
], BodyPeriod.month);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
model.deltaText,
|
||||||
|
'Талия меньше на 1.5 см, Грудь больше на 2 см, Бёдра без изменений за месяц',
|
||||||
|
);
|
||||||
|
expect(model.headline, '81');
|
||||||
|
expect(model.waist, [82, 80.5]);
|
||||||
|
expect(model.labels, ['01.11', '09.11']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('одна точка — недостаточно данных', () {
|
||||||
|
final model = builder.buildMeasure([_circumference()], BodyPeriod.month);
|
||||||
|
|
||||||
|
expect(model.deltaText, 'Недостаточно данных');
|
||||||
|
expect(model.headline, '80');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('нет точек', () {
|
||||||
|
final model = builder.buildMeasure([], BodyPeriod.month);
|
||||||
|
|
||||||
|
expect(model.headline, '—');
|
||||||
|
expect(model.deltaText, 'Недостаточно данных');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('build', () {
|
||||||
|
test('сортирует по дате, daily/intra = null', () {
|
||||||
|
final payload = builder.build(
|
||||||
|
weights: [_weight(73, bmi: 23, day: 9), _weight(75, bmi: 24, day: 1)],
|
||||||
|
circumferences: [
|
||||||
|
_circumference(waist: 79, day: 9),
|
||||||
|
_circumference(waist: 81),
|
||||||
|
],
|
||||||
|
period: BodyPeriod.month,
|
||||||
|
heightCm: 175,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(payload.weight.values, [75, 73]);
|
||||||
|
expect(payload.bmi.values, [24, 23]);
|
||||||
|
expect(payload.measure.waist, [81, 79]);
|
||||||
|
expect(payload.daily, isNull);
|
||||||
|
expect(payload.intra, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
Map<String, dynamic> _fixture(String name) {
|
||||||
|
return jsonDecode(File('test/fixtures/insights/$name').readAsStringSync())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('InsightsPayloadModel', () {
|
||||||
|
test('парсит полный payload (weight/bmi/measure/daily/intra)', () {
|
||||||
|
final model = InsightsPayloadModel.fromJson(
|
||||||
|
_fixture('insights_payload.json'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.weight.values, [74.5, 73.9, 73.2]);
|
||||||
|
expect(model.weight.last, 73.2);
|
||||||
|
expect(model.weight.delta, -1.3);
|
||||||
|
expect(model.weight.unit, 'кг');
|
||||||
|
|
||||||
|
expect(model.measure.waist, [82.0, 80.5]);
|
||||||
|
expect(model.measure.chest, [96.0, 96.0]);
|
||||||
|
|
||||||
|
expect(model.daily, isNotNull);
|
||||||
|
expect(model.daily!.kcal, [1850.0, 2010.0, 1930.0]);
|
||||||
|
expect(model.daily!.average, 1930);
|
||||||
|
|
||||||
|
expect(model.intra, isNotNull);
|
||||||
|
expect(model.intra!.bars, hasLength(2));
|
||||||
|
expect(model.intra!.bars.first.x, 8.5);
|
||||||
|
expect(model.intra!.bars.first.c, 240);
|
||||||
|
expect(model.intra!.satiety.last.y, 4);
|
||||||
|
expect(model.intra!.wake, 7.5);
|
||||||
|
expect(model.intra!.isToday, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bmi.values пустой и last/delta/category = null', () {
|
||||||
|
final model = InsightsPayloadModel.fromJson(
|
||||||
|
_fixture('insights_payload.json'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.bmi.values, isEmpty);
|
||||||
|
expect(model.bmi.last, isNull);
|
||||||
|
expect(model.bmi.delta, isNull);
|
||||||
|
expect(model.bmi.category, isNull);
|
||||||
|
expect(model.bmi.deltaText, contains('рост'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('пустой intra без wake/sleep; daily == null', () {
|
||||||
|
final model = InsightsPayloadModel.fromJson(
|
||||||
|
_fixture('insights_payload_minimal.json'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.intra!.wake, isNull);
|
||||||
|
expect(model.intra!.sleep, isNull);
|
||||||
|
expect(model.intra!.bars, isEmpty);
|
||||||
|
expect(model.daily, isNull);
|
||||||
|
expect(model.weight.last, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('daily и intra необязательны', () {
|
||||||
|
final json = _fixture('insights_payload_minimal.json')
|
||||||
|
..remove('daily')
|
||||||
|
..remove('intra');
|
||||||
|
|
||||||
|
final model = InsightsPayloadModel.fromJson(json);
|
||||||
|
|
||||||
|
expect(model.daily, isNull);
|
||||||
|
expect(model.intra, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
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,76 @@
|
|||||||
|
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('insightsSplineType — monotonic, чтобы кривая не уходила ниже точек', () {
|
||||||
|
expect(insightsSplineType, SplineType.monotonic);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('buildPoints', () {
|
||||||
|
test('сохраняет индекс и дату даже при повторяющихся labels', () {
|
||||||
|
final points = buildPoints(
|
||||||
|
['05.07', '05.07', '06.07', '07.07'],
|
||||||
|
[10, 20, 30, 40],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(points.map((p) => p.index).toList(), [0, 1, 2, 3]);
|
||||||
|
expect(points.map((p) => p.label).toList(), [
|
||||||
|
'05.07',
|
||||||
|
'05.07',
|
||||||
|
'06.07',
|
||||||
|
'07.07',
|
||||||
|
]);
|
||||||
|
expect(points.map((p) => p.value).toList(), [10, 20, 30, 40]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('обрезает по короткому массиву', () {
|
||||||
|
expect(buildPoints(['a', 'b'], [1]), hasLength(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('buildInsightsIndexAxis', () {
|
||||||
|
test('ось строится по числу точек, а не по уникальным строкам дат', () {
|
||||||
|
final labels = [
|
||||||
|
'05.07',
|
||||||
|
'06.07',
|
||||||
|
'07.07',
|
||||||
|
'08.07',
|
||||||
|
'09.07',
|
||||||
|
'10.07',
|
||||||
|
'11.07',
|
||||||
|
];
|
||||||
|
final axis = buildInsightsIndexAxis(labels);
|
||||||
|
|
||||||
|
expect(axis.minimum, 0);
|
||||||
|
expect(axis.maximum, 6);
|
||||||
|
expect(axis.interval, 1);
|
||||||
|
expect(axis.axisLabelFormatter, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('для длинного периода ставит интервал больше 1', () {
|
||||||
|
final labels = List.generate(30, (i) => '${i + 1}.07');
|
||||||
|
final axis = buildInsightsIndexAxis(labels);
|
||||||
|
|
||||||
|
expect(axis.maximum, 29);
|
||||||
|
expect(axis.interval, greaterThan(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('paddedRange', () {
|
||||||
|
test('даёт локальный диапазон вокруг данных', () {
|
||||||
|
final range = paddedRange([70, 72], minPadding: 1);
|
||||||
|
|
||||||
|
expect(range.min, lessThan(70));
|
||||||
|
expect(range.max, greaterThan(72));
|
||||||
|
expect(range.min, greaterThan(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clampZero не уводит минимум ниже нуля', () {
|
||||||
|
final range = paddedRange([2, 8], minPadding: 5, clampZero: true);
|
||||||
|
|
||||||
|
expect(range.min, 0);
|
||||||
|
expect(range.max, greaterThan(8));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/cubit/insights_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/cubit/insights_panel.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/cubit/insights_state.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import '../../../../fixtures/doubles/insights_doubles.dart';
|
||||||
|
|
||||||
|
/// Серверный режим (`kbjuEnabled: true`): `GET /api/profile/insights*`.
|
||||||
|
void main() {
|
||||||
|
late FakeInsightsRepository repository;
|
||||||
|
late IntraChartModel today;
|
||||||
|
late IntraChartModel yesterday;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
repository = FakeInsightsRepository();
|
||||||
|
today = repository.payload.intra!;
|
||||||
|
yesterday = today.copyWith(
|
||||||
|
date: '2026-11-08',
|
||||||
|
dateLabel: '8 ноября',
|
||||||
|
isToday: false,
|
||||||
|
);
|
||||||
|
repository.intraByDate['2026-11-08'] = yesterday;
|
||||||
|
repository.intraByDate['2026-11-09'] = today;
|
||||||
|
});
|
||||||
|
|
||||||
|
InsightsCubit buildCubit({String? slug}) {
|
||||||
|
return InsightsCubit(repository: repository, slug: slug, kbjuEnabled: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
InsightsStateLoaded loaded(InsightsCubit cubit) =>
|
||||||
|
cubit.state as InsightsStateLoaded;
|
||||||
|
|
||||||
|
group('load', () {
|
||||||
|
test('запрашивает серверный payload с дефолтными периодами', () async {
|
||||||
|
final cubit = buildCubit();
|
||||||
|
|
||||||
|
await cubit.load();
|
||||||
|
|
||||||
|
expect(repository.serverRequests, [
|
||||||
|
(
|
||||||
|
slug: null,
|
||||||
|
bodyPeriod: BodyPeriod.threeMonths,
|
||||||
|
dailyPeriod: DailyPeriod.twoWeeks,
|
||||||
|
intraDate: null,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
expect(repository.insightsRequests, isEmpty);
|
||||||
|
expect(loaded(cubit).payload.daily, isNotNull);
|
||||||
|
expect(loaded(cubit).dailyPeriod, DailyPeriod.twoWeeks);
|
||||||
|
expect(loaded(cubit).intraDate, isNull);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('рост профиля не запрашивается: ИМТ считает сервер', () async {
|
||||||
|
repository.heightCm = null;
|
||||||
|
final cubit = buildCubit(slug: 'client');
|
||||||
|
|
||||||
|
await cubit.load();
|
||||||
|
|
||||||
|
expect(repository.serverRequests.single.slug, 'client');
|
||||||
|
expect(loaded(cubit).payload.bmi, repository.payload.bmi);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('403 скрывает блок', () async {
|
||||||
|
repository.error = const InsightsForbidden();
|
||||||
|
final cubit = buildCubit(slug: 'client');
|
||||||
|
|
||||||
|
await cubit.load();
|
||||||
|
|
||||||
|
expect(cubit.state, isA<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.week);
|
||||||
|
expect(loaded(cubit).payload, before);
|
||||||
|
expect(loaded(cubit).isRefreshing, isFalse);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('смена периода тела передаёт текущий период КБЖУ', () async {
|
||||||
|
final cubit = buildCubit();
|
||||||
|
await cubit.load();
|
||||||
|
await cubit.setDailyPeriod(DailyPeriod.week);
|
||||||
|
repository.serverRequests.clear();
|
||||||
|
|
||||||
|
await cubit.setBodyPeriod(BodyPeriod.month);
|
||||||
|
|
||||||
|
expect(repository.serverRequests.single.dailyPeriod, DailyPeriod.week);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('«В течение дня»', () {
|
||||||
|
test(
|
||||||
|
'setIntraDate запрашивает только intra и подменяет её в payload',
|
||||||
|
() async {
|
||||||
|
final cubit = buildCubit();
|
||||||
|
await cubit.load();
|
||||||
|
repository.serverRequests.clear();
|
||||||
|
|
||||||
|
await cubit.setIntraDate(DateTime(2026, 11, 8));
|
||||||
|
|
||||||
|
expect(repository.serverRequests, isEmpty);
|
||||||
|
expect(repository.intraRequests, [(slug: null, date: '2026-11-08')]);
|
||||||
|
final state = loaded(cubit);
|
||||||
|
expect(state.payload.intra, yesterday);
|
||||||
|
expect(state.payload.weight, repository.payload.weight);
|
||||||
|
expect(state.intraDate, '2026-11-08');
|
||||||
|
expect(state.isRefreshing, isFalse);
|
||||||
|
await cubit.close();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('slug передаётся в запрос чужого дня', () async {
|
||||||
|
final cubit = buildCubit(slug: 'client');
|
||||||
|
await cubit.load();
|
||||||
|
|
||||||
|
await cubit.setIntraDate(DateTime(2026, 11, 8));
|
||||||
|
|
||||||
|
expect(repository.intraRequests.single.slug, 'client');
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'быстрые «назад» запрашивают разные дни (оптимистичная дата)',
|
||||||
|
() async {
|
||||||
|
repository.fetchIntraDelay = const Duration(milliseconds: 30);
|
||||||
|
repository.intraByDate['2026-11-07'] = yesterday.copyWith(
|
||||||
|
date: '2026-11-07',
|
||||||
|
dateLabel: '7 ноября',
|
||||||
|
);
|
||||||
|
final cubit = buildCubit();
|
||||||
|
await cubit.load();
|
||||||
|
|
||||||
|
final first = cubit.showPreviousDay();
|
||||||
|
final second = cubit.showPreviousDay();
|
||||||
|
await Future.wait([first, second]);
|
||||||
|
|
||||||
|
expect(repository.intraRequests.map((e) => e.date).toList(), [
|
||||||
|
'2026-11-08',
|
||||||
|
'2026-11-07',
|
||||||
|
]);
|
||||||
|
expect(loaded(cubit).payload.intra!.date, '2026-11-07');
|
||||||
|
await cubit.close();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('полный ответ не затирает более свежий intra и UI-выбор', () async {
|
||||||
|
repository.fetchInsightsDelay = const Duration(milliseconds: 40);
|
||||||
|
final cubit = buildCubit();
|
||||||
|
await cubit.load();
|
||||||
|
repository.fetchInsightsDelay = const Duration(milliseconds: 80);
|
||||||
|
repository.serverRequests.clear();
|
||||||
|
|
||||||
|
final periodChange = cubit.setBodyPeriod(BodyPeriod.year);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||||
|
cubit.setPanel(InsightsPanel.measures);
|
||||||
|
cubit.toggleExpanded();
|
||||||
|
await cubit.setIntraDate(DateTime(2026, 11, 8));
|
||||||
|
await periodChange;
|
||||||
|
|
||||||
|
final state = loaded(cubit);
|
||||||
|
expect(state.bodyPeriod, BodyPeriod.year);
|
||||||
|
expect(state.payload.weight, repository.payload.weight);
|
||||||
|
expect(state.payload.intra, yesterday);
|
||||||
|
expect(state.intraDate, '2026-11-08');
|
||||||
|
expect(state.panel, InsightsPanel.measures);
|
||||||
|
expect(state.isExpanded, isTrue);
|
||||||
|
expect(state.isRefreshing, isFalse);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('load с initialIntraDate запрашивает этот день', () async {
|
||||||
|
final cubit = buildCubit();
|
||||||
|
|
||||||
|
await cubit.load(intraDate: '2026-11-08');
|
||||||
|
|
||||||
|
expect(repository.serverRequests.single.intraDate, '2026-11-08');
|
||||||
|
expect(loaded(cubit).intraDate, '2026-11-08');
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('showPreviousDay и showNextDay листают по дню', () async {
|
||||||
|
final cubit = buildCubit();
|
||||||
|
await cubit.load();
|
||||||
|
|
||||||
|
await cubit.showPreviousDay();
|
||||||
|
expect(repository.intraRequests.last.date, '2026-11-08');
|
||||||
|
expect(loaded(cubit).payload.intra!.isToday, isFalse);
|
||||||
|
|
||||||
|
await cubit.showNextDay();
|
||||||
|
expect(repository.intraRequests.last.date, '2026-11-09');
|
||||||
|
expect(loaded(cubit).payload.intra!.isToday, isTrue);
|
||||||
|
expect(loaded(cubit).intraDate, isNull);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('showPreviousDay переходит через границу месяца', () async {
|
||||||
|
repository.payload = repository.payload.copyWith(
|
||||||
|
intra: today.copyWith(date: '2026-11-01', isToday: false),
|
||||||
|
);
|
||||||
|
final cubit = buildCubit();
|
||||||
|
await cubit.load();
|
||||||
|
|
||||||
|
await cubit.showPreviousDay();
|
||||||
|
|
||||||
|
expect(repository.intraRequests.single.date, '2026-10-31');
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('showNextDay на сегодняшнем дне ничего не запрашивает', () async {
|
||||||
|
final cubit = buildCubit();
|
||||||
|
await cubit.load();
|
||||||
|
|
||||||
|
await cubit.showNextDay();
|
||||||
|
|
||||||
|
expect(repository.intraRequests, isEmpty);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('сервер вернул другую дату (clamp) — берём её', () async {
|
||||||
|
repository.intraByDate['2026-11-20'] = today;
|
||||||
|
final cubit = buildCubit();
|
||||||
|
await cubit.load();
|
||||||
|
|
||||||
|
await cubit.setIntraDate(DateTime(2026, 11, 20));
|
||||||
|
|
||||||
|
expect(loaded(cubit).intraDate, isNull);
|
||||||
|
expect(loaded(cubit).payload.intra!.date, '2026-11-09');
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('пустой день: intra с пустыми сериями остаётся объектом', () async {
|
||||||
|
final empty = today.copyWith(
|
||||||
|
date: '2026-11-07',
|
||||||
|
dateLabel: '7 ноября',
|
||||||
|
isToday: false,
|
||||||
|
bars: const [],
|
||||||
|
satiety: const [],
|
||||||
|
mood: const [],
|
||||||
|
wake: null,
|
||||||
|
sleep: null,
|
||||||
|
wakeText: null,
|
||||||
|
sleepText: null,
|
||||||
|
);
|
||||||
|
repository.intraByDate['2026-11-07'] = empty;
|
||||||
|
final cubit = buildCubit();
|
||||||
|
await cubit.load();
|
||||||
|
|
||||||
|
await cubit.setIntraDate(DateTime(2026, 11, 7));
|
||||||
|
|
||||||
|
expect(loaded(cubit).payload.intra, empty);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ошибка запроса дня не стирает показанные данные', () async {
|
||||||
|
final cubit = buildCubit();
|
||||||
|
await cubit.load();
|
||||||
|
repository.intraError = DioException(requestOptions: RequestOptions());
|
||||||
|
|
||||||
|
await cubit.setIntraDate(DateTime(2026, 11, 8));
|
||||||
|
|
||||||
|
final state = loaded(cubit);
|
||||||
|
expect(state.payload.intra, today);
|
||||||
|
expect(state.intraDate, isNull);
|
||||||
|
expect(state.isRefreshing, isFalse);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('403 при листании скрывает блок', () async {
|
||||||
|
final cubit = buildCubit(slug: 'client');
|
||||||
|
await cubit.load();
|
||||||
|
repository.intraError = const InsightsForbidden();
|
||||||
|
|
||||||
|
await cubit.showPreviousDay();
|
||||||
|
|
||||||
|
expect(cubit.state, isA<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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/cubit/insights_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/cubit/insights_panel.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/cubit/insights_state.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
class _MockInsightsRepository extends Mock implements InsightsRepository {}
|
||||||
|
|
||||||
|
const _payload = InsightsPayloadModel(
|
||||||
|
weight: WeightChartModel(values: [70, 71], headline: '71.0'),
|
||||||
|
bmi: BmiChartModel(),
|
||||||
|
measure: MeasureChartModel(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const _payload2 = InsightsPayloadModel(
|
||||||
|
weight: WeightChartModel(values: [72], headline: '72.0'),
|
||||||
|
bmi: BmiChartModel(),
|
||||||
|
measure: MeasureChartModel(),
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late _MockInsightsRepository repository;
|
||||||
|
|
||||||
|
setUpAll(() {
|
||||||
|
registerFallbackValue(BodyPeriod.month);
|
||||||
|
registerFallbackValue(DailyPeriod.week);
|
||||||
|
});
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
repository = _MockInsightsRepository();
|
||||||
|
when(
|
||||||
|
() => repository.getHeightCm(slug: any(named: 'slug')),
|
||||||
|
).thenAnswer((_) async => 176);
|
||||||
|
when(
|
||||||
|
() => repository.getInsights(
|
||||||
|
slug: any(named: 'slug'),
|
||||||
|
bodyPeriod: any(named: 'bodyPeriod'),
|
||||||
|
heightCm: any(named: 'heightCm'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async => _payload);
|
||||||
|
when(
|
||||||
|
() => repository.fetchInsights(
|
||||||
|
slug: any(named: 'slug'),
|
||||||
|
bodyPeriod: any(named: 'bodyPeriod'),
|
||||||
|
dailyPeriod: any(named: 'dailyPeriod'),
|
||||||
|
intraDate: any(named: 'intraDate'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async => _payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
InsightsCubit buildCubit({
|
||||||
|
String? slug,
|
||||||
|
bool canAdd = false,
|
||||||
|
bool kbjuEnabled = false,
|
||||||
|
}) {
|
||||||
|
return InsightsCubit(
|
||||||
|
repository: repository,
|
||||||
|
slug: slug,
|
||||||
|
canAdd: canAdd,
|
||||||
|
kbjuEnabled: kbjuEnabled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
InsightsStateLoaded loaded(InsightsCubit cubit) =>
|
||||||
|
cubit.state as InsightsStateLoaded;
|
||||||
|
|
||||||
|
test('начальное состояние — initial', () {
|
||||||
|
expect(buildCubit().state, isA<InsightsStateInitial>());
|
||||||
|
});
|
||||||
|
|
||||||
|
group('load', () {
|
||||||
|
test('дефолты: период 3М, вкладка «Вес», блок свёрнут', () async {
|
||||||
|
final cubit = buildCubit();
|
||||||
|
final states = <InsightsState>[];
|
||||||
|
cubit.stream.listen(states.add);
|
||||||
|
|
||||||
|
await cubit.load();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(states.first, isA<InsightsStateLoading>());
|
||||||
|
final state = loaded(cubit);
|
||||||
|
expect(state.bodyPeriod, BodyPeriod.threeMonths);
|
||||||
|
expect(state.panel, InsightsPanel.weight);
|
||||||
|
expect(state.isExpanded, isFalse);
|
||||||
|
expect(state.isRefreshing, isFalse);
|
||||||
|
expect(state.showSleep, isTrue);
|
||||||
|
expect(state.payload, _payload);
|
||||||
|
verify(
|
||||||
|
() => repository.getInsights(
|
||||||
|
slug: null,
|
||||||
|
bodyPeriod: BodyPeriod.threeMonths,
|
||||||
|
heightCm: 176,
|
||||||
|
),
|
||||||
|
).called(1);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('пробрасывает slug владельца', () async {
|
||||||
|
final cubit = buildCubit(slug: 'client');
|
||||||
|
|
||||||
|
await cubit.load();
|
||||||
|
|
||||||
|
verify(() => repository.getHeightCm(slug: 'client')).called(1);
|
||||||
|
verify(
|
||||||
|
() => repository.getInsights(
|
||||||
|
slug: 'client',
|
||||||
|
bodyPeriod: BodyPeriod.threeMonths,
|
||||||
|
heightCm: 176,
|
||||||
|
),
|
||||||
|
).called(1);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('403 → hidden', () async {
|
||||||
|
when(
|
||||||
|
() => repository.getHeightCm(slug: any(named: 'slug')),
|
||||||
|
).thenThrow(const InsightsForbidden());
|
||||||
|
final cubit = buildCubit(slug: 'client');
|
||||||
|
|
||||||
|
await cubit.load();
|
||||||
|
|
||||||
|
expect(cubit.state, isA<InsightsStateHidden>());
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('403 на запросе точек → hidden', () async {
|
||||||
|
when(
|
||||||
|
() => repository.getInsights(
|
||||||
|
slug: any(named: 'slug'),
|
||||||
|
bodyPeriod: any(named: 'bodyPeriod'),
|
||||||
|
heightCm: any(named: 'heightCm'),
|
||||||
|
),
|
||||||
|
).thenThrow(const InsightsForbidden());
|
||||||
|
final cubit = buildCubit(slug: 'client');
|
||||||
|
|
||||||
|
await cubit.load();
|
||||||
|
|
||||||
|
expect(cubit.state, isA<InsightsStateHidden>());
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('другая ошибка → error', () async {
|
||||||
|
when(
|
||||||
|
() => repository.getInsights(
|
||||||
|
slug: any(named: 'slug'),
|
||||||
|
bodyPeriod: any(named: 'bodyPeriod'),
|
||||||
|
heightCm: any(named: 'heightCm'),
|
||||||
|
),
|
||||||
|
).thenThrow(Exception('boom'));
|
||||||
|
final cubit = buildCubit();
|
||||||
|
|
||||||
|
await cubit.load();
|
||||||
|
|
||||||
|
expect(cubit.state, isA<InsightsStateError>());
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('setBodyPeriod', () {
|
||||||
|
test(
|
||||||
|
'смена периода → перезапрос; рост не запрашивается повторно',
|
||||||
|
() async {
|
||||||
|
final cubit = buildCubit();
|
||||||
|
await cubit.load();
|
||||||
|
when(
|
||||||
|
() => repository.getInsights(
|
||||||
|
slug: null,
|
||||||
|
bodyPeriod: BodyPeriod.year,
|
||||||
|
heightCm: 176,
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async => _payload2);
|
||||||
|
|
||||||
|
await cubit.setBodyPeriod(BodyPeriod.year);
|
||||||
|
|
||||||
|
expect(loaded(cubit).bodyPeriod, BodyPeriod.year);
|
||||||
|
expect(loaded(cubit).payload, _payload2);
|
||||||
|
expect(loaded(cubit).isRefreshing, isFalse);
|
||||||
|
verify(() => repository.getHeightCm(slug: null)).called(1);
|
||||||
|
await cubit.close();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('тот же период — без запроса', () async {
|
||||||
|
final cubit = buildCubit();
|
||||||
|
await cubit.load();
|
||||||
|
clearInteractions(repository);
|
||||||
|
|
||||||
|
await cubit.setBodyPeriod(BodyPeriod.threeMonths);
|
||||||
|
|
||||||
|
verifyNever(
|
||||||
|
() => repository.getInsights(
|
||||||
|
slug: any(named: 'slug'),
|
||||||
|
bodyPeriod: any(named: 'bodyPeriod'),
|
||||||
|
heightCm: any(named: 'heightCm'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ошибка перезапроса оставляет выбранный период и старые данные', () async {
|
||||||
|
final cubit = buildCubit();
|
||||||
|
await cubit.load();
|
||||||
|
when(
|
||||||
|
() => repository.getInsights(
|
||||||
|
slug: null,
|
||||||
|
bodyPeriod: BodyPeriod.all,
|
||||||
|
heightCm: 176,
|
||||||
|
),
|
||||||
|
).thenThrow(Exception('boom'));
|
||||||
|
|
||||||
|
await cubit.setBodyPeriod(BodyPeriod.all);
|
||||||
|
|
||||||
|
expect(loaded(cubit).bodyPeriod, BodyPeriod.all);
|
||||||
|
expect(loaded(cubit).payload, _payload);
|
||||||
|
expect(loaded(cubit).isRefreshing, isFalse);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('403 при перезапросе → hidden', () async {
|
||||||
|
final cubit = buildCubit(slug: 'c');
|
||||||
|
await cubit.load();
|
||||||
|
when(
|
||||||
|
() => repository.getInsights(
|
||||||
|
slug: 'c',
|
||||||
|
bodyPeriod: BodyPeriod.month,
|
||||||
|
heightCm: 176,
|
||||||
|
),
|
||||||
|
).thenThrow(const InsightsForbidden());
|
||||||
|
|
||||||
|
await cubit.setBodyPeriod(BodyPeriod.month);
|
||||||
|
|
||||||
|
expect(cubit.state, isA<InsightsStateHidden>());
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('панели и серии', () {
|
||||||
|
test('toggleExpanded раскрывает и сворачивает', () async {
|
||||||
|
final cubit = buildCubit();
|
||||||
|
await cubit.load();
|
||||||
|
|
||||||
|
cubit.toggleExpanded();
|
||||||
|
expect(loaded(cubit).isExpanded, isTrue);
|
||||||
|
cubit.toggleExpanded();
|
||||||
|
expect(loaded(cubit).isExpanded, isFalse);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setPanel переключает вкладку', () async {
|
||||||
|
final cubit = buildCubit();
|
||||||
|
await cubit.load();
|
||||||
|
|
||||||
|
cubit.setPanel(InsightsPanel.measures);
|
||||||
|
|
||||||
|
expect(loaded(cubit).panel, InsightsPanel.measures);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('вкладки КБЖУ недоступны при выключенном флаге', () async {
|
||||||
|
final cubit = buildCubit();
|
||||||
|
await cubit.load();
|
||||||
|
|
||||||
|
cubit.setPanel(InsightsPanel.daily);
|
||||||
|
|
||||||
|
expect(loaded(cubit).panel, InsightsPanel.weight);
|
||||||
|
expect(cubit.panels, [
|
||||||
|
InsightsPanel.weight,
|
||||||
|
InsightsPanel.bmi,
|
||||||
|
InsightsPanel.measures,
|
||||||
|
]);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('вкладки КБЖУ доступны при включённом флаге', () async {
|
||||||
|
final cubit = buildCubit(kbjuEnabled: true);
|
||||||
|
await cubit.load();
|
||||||
|
|
||||||
|
cubit.setPanel(InsightsPanel.intra);
|
||||||
|
|
||||||
|
expect(loaded(cubit).panel, InsightsPanel.intra);
|
||||||
|
expect(cubit.panels, InsightsPanel.values);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggleSeries переключает только выбранную серию', () async {
|
||||||
|
final cubit = buildCubit();
|
||||||
|
await cubit.load();
|
||||||
|
|
||||||
|
cubit.toggleSeries(InsightsPanel.measures, 1);
|
||||||
|
expect(loaded(cubit).seriesVisibility.measure, [true, false, true]);
|
||||||
|
|
||||||
|
cubit.toggleSeries(InsightsPanel.measures, 1);
|
||||||
|
expect(loaded(cubit).seriesVisibility.measure, [true, true, true]);
|
||||||
|
|
||||||
|
cubit.toggleSeries(InsightsPanel.daily, 0);
|
||||||
|
expect(loaded(cubit).seriesVisibility.daily, [false, true, true, true]);
|
||||||
|
expect(loaded(cubit).seriesVisibility.measure, [true, true, true]);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'toggleSeries игнорирует неверный индекс и вкладку без серий',
|
||||||
|
() async {
|
||||||
|
final cubit = buildCubit();
|
||||||
|
await cubit.load();
|
||||||
|
final before = loaded(cubit).seriesVisibility;
|
||||||
|
|
||||||
|
cubit.toggleSeries(InsightsPanel.measures, 5);
|
||||||
|
cubit.toggleSeries(InsightsPanel.weight, 0);
|
||||||
|
|
||||||
|
expect(loaded(cubit).seriesVisibility, before);
|
||||||
|
await cubit.close();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('toggleSleep', () async {
|
||||||
|
final cubit = buildCubit();
|
||||||
|
await cubit.load();
|
||||||
|
|
||||||
|
cubit.toggleSleep();
|
||||||
|
|
||||||
|
expect(loaded(cubit).showSleep, isFalse);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('refresh и добавление записей', () {
|
||||||
|
test('refresh сохраняет вкладку, период, серии и раскрытие', () async {
|
||||||
|
final cubit = buildCubit();
|
||||||
|
await cubit.load();
|
||||||
|
cubit.toggleExpanded();
|
||||||
|
cubit.setPanel(InsightsPanel.measures);
|
||||||
|
cubit.toggleSeries(InsightsPanel.measures, 0);
|
||||||
|
when(
|
||||||
|
() => repository.getInsights(
|
||||||
|
slug: null,
|
||||||
|
bodyPeriod: BodyPeriod.threeMonths,
|
||||||
|
heightCm: 176,
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async => _payload2);
|
||||||
|
|
||||||
|
await cubit.refresh();
|
||||||
|
|
||||||
|
final state = loaded(cubit);
|
||||||
|
expect(state.payload, _payload2);
|
||||||
|
expect(state.panel, InsightsPanel.measures);
|
||||||
|
expect(state.isExpanded, isTrue);
|
||||||
|
expect(state.seriesVisibility.measure, [false, true, true]);
|
||||||
|
expect(state.isRefreshing, isFalse);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addWeight сохраняет и обновляет данные', () async {
|
||||||
|
when(
|
||||||
|
() => repository.addWeight(weight: any(named: 'weight')),
|
||||||
|
).thenAnswer((_) async {});
|
||||||
|
final cubit = buildCubit(canAdd: true);
|
||||||
|
await cubit.load();
|
||||||
|
when(
|
||||||
|
() => repository.getInsights(
|
||||||
|
slug: null,
|
||||||
|
bodyPeriod: BodyPeriod.threeMonths,
|
||||||
|
heightCm: 176,
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async => _payload2);
|
||||||
|
|
||||||
|
final isSaved = await cubit.addWeight(weight: 72);
|
||||||
|
|
||||||
|
expect(isSaved, isTrue);
|
||||||
|
verify(() => repository.addWeight(weight: 72)).called(1);
|
||||||
|
expect(loaded(cubit).payload, _payload2);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addCircumference сохраняет и обновляет данные', () async {
|
||||||
|
when(
|
||||||
|
() => repository.addCircumference(
|
||||||
|
chestCircumference: any(named: 'chestCircumference'),
|
||||||
|
waistCircumference: any(named: 'waistCircumference'),
|
||||||
|
hipCircumference: any(named: 'hipCircumference'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async {});
|
||||||
|
final cubit = buildCubit(canAdd: true);
|
||||||
|
await cubit.load();
|
||||||
|
when(
|
||||||
|
() => repository.getInsights(
|
||||||
|
slug: null,
|
||||||
|
bodyPeriod: BodyPeriod.threeMonths,
|
||||||
|
heightCm: 176,
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async => _payload2);
|
||||||
|
|
||||||
|
final isSaved = await cubit.addCircumference(
|
||||||
|
chestCircumference: 95,
|
||||||
|
waistCircumference: 80,
|
||||||
|
hipCircumference: 100,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(isSaved, isTrue);
|
||||||
|
|
||||||
|
verify(
|
||||||
|
() => repository.addCircumference(
|
||||||
|
chestCircumference: 95,
|
||||||
|
waistCircumference: 80,
|
||||||
|
hipCircumference: 100,
|
||||||
|
),
|
||||||
|
).called(1);
|
||||||
|
expect(loaded(cubit).payload, _payload2);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'ошибка сохранения не перезагружает данные и возвращает false',
|
||||||
|
() async {
|
||||||
|
when(
|
||||||
|
() => repository.addWeight(weight: any(named: 'weight')),
|
||||||
|
).thenThrow(Exception('boom'));
|
||||||
|
final cubit = buildCubit(canAdd: true);
|
||||||
|
await cubit.load();
|
||||||
|
clearInteractions(repository);
|
||||||
|
|
||||||
|
expect(await cubit.addWeight(weight: 72), isFalse);
|
||||||
|
|
||||||
|
verifyNever(
|
||||||
|
() => repository.getInsights(
|
||||||
|
slug: any(named: 'slug'),
|
||||||
|
bodyPeriod: any(named: 'bodyPeriod'),
|
||||||
|
heightCm: any(named: 'heightCm'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(loaded(cubit).payload, _payload);
|
||||||
|
await cubit.close();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/charts/bmi_chart.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/charts/measure_chart.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/charts/weight_chart.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
|
||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import '../../../../fixtures/doubles/insights_doubles.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
Widget wrap(Widget child) {
|
||||||
|
return MaterialApp(
|
||||||
|
theme: ThemeHelper().themeData(),
|
||||||
|
localizationsDelegates: const [
|
||||||
|
S.delegate,
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
supportedLocales: const [Locale('ru')],
|
||||||
|
home: Scaffold(body: SingleChildScrollView(child: child)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pumpCard(
|
||||||
|
WidgetTester tester,
|
||||||
|
FakeInsightsRepository repository, {
|
||||||
|
String? slug,
|
||||||
|
bool canAdd = false,
|
||||||
|
bool kbjuEnabled = false,
|
||||||
|
bool expand = true,
|
||||||
|
}) async {
|
||||||
|
tester.view.physicalSize = const Size(900, 2400);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrap(
|
||||||
|
InsightsCard(
|
||||||
|
slug: slug,
|
||||||
|
canAdd: canAdd,
|
||||||
|
repository: repository,
|
||||||
|
kbjuEnabled: kbjuEnabled,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
if (expand &&
|
||||||
|
find.byKey(const Key('insights_toggle')).evaluate().isNotEmpty) {
|
||||||
|
await tester.tap(find.byKey(const Key('insights_toggle')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
group('свёрнутое состояние', () {
|
||||||
|
testWidgets('по умолчанию свёрнут: виден заголовок, графиков нет', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pumpCard(tester, FakeInsightsRepository(), expand: false);
|
||||||
|
|
||||||
|
expect(find.text('Аналитика'), findsOneWidget);
|
||||||
|
expect(find.text('нажмите, чтобы посмотреть прогресс'), findsOneWidget);
|
||||||
|
expect(find.byType(WeightChart), findsNothing);
|
||||||
|
expect(find.byKey(const Key('insights_tab_weight')), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('тап по заголовку раскрывает и сворачивает блок', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pumpCard(tester, FakeInsightsRepository(), expand: false);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('insights_toggle')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(WeightChart), findsOneWidget);
|
||||||
|
expect(find.text('вес, замеры и питание'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('insights_toggle')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(WeightChart), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('вкладки', () {
|
||||||
|
testWidgets(
|
||||||
|
'показаны Вес, ИМТ, Замеры; вкладки КБЖУ скрыты при выключенном флаге',
|
||||||
|
(tester) async {
|
||||||
|
await pumpCard(tester, FakeInsightsRepository());
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('insights_tab_weight')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('insights_tab_bmi')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('insights_tab_measures')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('insights_tab_daily')), findsNothing);
|
||||||
|
expect(find.byKey(const Key('insights_tab_intra')), findsNothing);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('вкладки переключают панели', (tester) async {
|
||||||
|
await pumpCard(tester, FakeInsightsRepository());
|
||||||
|
|
||||||
|
expect(find.byType(WeightChart), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('insights_headline')), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('insights_tab_measures')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(MeasureChart), findsOneWidget);
|
||||||
|
expect(find.byType(WeightChart), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('insights_tab_bmi')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(BmiChart), findsOneWidget);
|
||||||
|
expect(find.byType(MeasureChart), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('insights_tab_weight')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(WeightChart), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('вкладки КБЖУ видны при включённом флаге', (tester) async {
|
||||||
|
await pumpCard(tester, FakeInsightsRepository(), kbjuEnabled: true);
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('insights_tab_daily')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('insights_tab_intra')), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('вес', () {
|
||||||
|
testWidgets('показывает крупное число и текст дельты из payload', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pumpCard(tester, FakeInsightsRepository());
|
||||||
|
|
||||||
|
expect(find.text('73.2'), findsOneWidget);
|
||||||
|
expect(find.text('кг'), findsWidgets);
|
||||||
|
expect(find.text('Снизили на 1.3 кг за период'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('пилюли периодов', () {
|
||||||
|
testWidgets(
|
||||||
|
'по умолчанию запрошены 3 месяца; смена периода перезапрашивает данные',
|
||||||
|
(tester) async {
|
||||||
|
final repository = FakeInsightsRepository();
|
||||||
|
await pumpCard(tester, repository);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
repository.insightsRequests.single.period,
|
||||||
|
BodyPeriod.threeMonths,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('insights_period_12')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(repository.insightsRequests.map((e) => e.period), [
|
||||||
|
BodyPeriod.threeMonths,
|
||||||
|
BodyPeriod.year,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('insights_period_all')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(repository.insightsRequests.last.period, BodyPeriod.all);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('повторный тап по активной пилюле не делает запрос', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final repository = FakeInsightsRepository();
|
||||||
|
await pumpCard(tester, repository);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('insights_period_3')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(repository.insightsRequests, hasLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('slug пробрасывается в запросы', (tester) async {
|
||||||
|
final repository = FakeInsightsRepository();
|
||||||
|
await pumpCard(tester, repository, slug: 'client');
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('insights_period_1')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(repository.insightsRequests.map((e) => e.slug), [
|
||||||
|
'client',
|
||||||
|
'client',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('кнопка «+»', () {
|
||||||
|
testWidgets('нет при canAdd = false', (tester) async {
|
||||||
|
await pumpCard(tester, FakeInsightsRepository());
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('insights_fab')), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('есть на вкладках «Вес» и «Замеры», нет на «ИМТ»', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pumpCard(tester, FakeInsightsRepository(), canAdd: true);
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('insights_fab')), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('insights_tab_bmi')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const Key('insights_fab')), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('insights_tab_measures')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const Key('insights_fab')), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('нет в свёрнутом состоянии', (tester) async {
|
||||||
|
await pumpCard(
|
||||||
|
tester,
|
||||||
|
FakeInsightsRepository(),
|
||||||
|
canAdd: true,
|
||||||
|
expand: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('insights_fab')), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'сохранение веса отправляет значение и перезапрашивает данные',
|
||||||
|
(tester) async {
|
||||||
|
final repository = FakeInsightsRepository();
|
||||||
|
await pumpCard(tester, repository, canAdd: true);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('insights_fab')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const Key('add_weight_kilo_picker')), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('add_sheet_save')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Начальное значение шторки — последний вес из payload (73.2).
|
||||||
|
expect(repository.addedWeights, [73.2]);
|
||||||
|
expect(repository.insightsRequests, hasLength(2));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'сохранение замеров отправляет значения и перезапрашивает данные',
|
||||||
|
(tester) async {
|
||||||
|
final repository = FakeInsightsRepository();
|
||||||
|
await pumpCard(tester, repository, canAdd: true);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('insights_tab_measures')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('insights_fab')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('add_circumference_waist_picker')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('add_sheet_save')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Последние значения из payload: грудь 96, талия 80.5 → 81 (округление), бёдра 100.
|
||||||
|
expect(repository.addedCircumferences, [
|
||||||
|
(chest: 96, waist: 81, hip: 100),
|
||||||
|
]);
|
||||||
|
expect(repository.insightsRequests, hasLength(2));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('серии замеров', () {
|
||||||
|
testWidgets('чипы переключают видимость серии', (tester) async {
|
||||||
|
await pumpCard(tester, FakeInsightsRepository());
|
||||||
|
await tester.tap(find.byKey(const Key('insights_tab_measures')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('insights_series_0')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('insights_series_1')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('insights_series_2')), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<Opacity>(
|
||||||
|
find
|
||||||
|
.ancestor(
|
||||||
|
of: find.byKey(const Key('insights_series_1')),
|
||||||
|
matching: find.byType(Opacity),
|
||||||
|
)
|
||||||
|
.first,
|
||||||
|
)
|
||||||
|
.opacity,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('insights_series_1')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<Opacity>(
|
||||||
|
find
|
||||||
|
.ancestor(
|
||||||
|
of: find.byKey(const Key('insights_series_1')),
|
||||||
|
matching: find.byType(Opacity),
|
||||||
|
)
|
||||||
|
.first,
|
||||||
|
)
|
||||||
|
.opacity,
|
||||||
|
0.45,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ИМТ', () {
|
||||||
|
testWidgets('без значений показывает только deltaText', (tester) async {
|
||||||
|
await pumpCard(tester, FakeInsightsRepository());
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('insights_tab_bmi')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Укажите рост, чтобы посчитать ИМТ.'), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('insights_bmi_chart')), findsNothing);
|
||||||
|
expect(find.byKey(const Key('insights_headline')), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('со значениями рисует график и подсказку', (tester) async {
|
||||||
|
final payload = loadInsightsPayloadFixture().copyWith.bmi(
|
||||||
|
labels: ['01.11', '09.11'],
|
||||||
|
values: [26.1, 24.2],
|
||||||
|
last: 24.2,
|
||||||
|
delta: -1.9,
|
||||||
|
headline: '24.2',
|
||||||
|
category: 'норма',
|
||||||
|
deltaText: 'ИМТ ниже на 1.9 за 3 месяца · норма',
|
||||||
|
hint: 'Считаем при росте 176 см.',
|
||||||
|
);
|
||||||
|
await pumpCard(tester, FakeInsightsRepository(payload: payload));
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('insights_tab_bmi')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('insights_bmi_chart')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('insights_bmi_hint')), findsOneWidget);
|
||||||
|
expect(find.text('ИМТ ниже на 1.9 за 3 месяца · норма'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('скрытие и ошибки', () {
|
||||||
|
testWidgets('403 → блок скрыт целиком', (tester) async {
|
||||||
|
await pumpCard(
|
||||||
|
tester,
|
||||||
|
FakeInsightsRepository(error: const InsightsForbidden()),
|
||||||
|
slug: 'client',
|
||||||
|
expand: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('insights_card')), findsNothing);
|
||||||
|
expect(find.text('Аналитика'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'ошибка загрузки показывает кнопку «Повторить», повтор грузит данные',
|
||||||
|
(tester) async {
|
||||||
|
final repository = FakeInsightsRepository(error: Exception('boom'));
|
||||||
|
await pumpCard(tester, repository, expand: false);
|
||||||
|
|
||||||
|
expect(find.text('Не удалось загрузить аналитику'), findsOneWidget);
|
||||||
|
repository.error = null;
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('insights_retry')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('insights_toggle')), findsOneWidget);
|
||||||
|
expect(find.text('Не удалось загрузить аналитику'), findsNothing);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/charts/daily_kbju_chart.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/charts/intra_day_chart.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/intra_date_nav.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/intra_legend.dart';
|
||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||||
|
|
||||||
|
import '../../../../fixtures/doubles/insights_doubles.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
Widget wrap(Widget child) {
|
||||||
|
return MaterialApp(
|
||||||
|
theme: ThemeHelper().themeData(),
|
||||||
|
localizationsDelegates: const [
|
||||||
|
S.delegate,
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
supportedLocales: const [Locale('ru')],
|
||||||
|
home: Scaffold(body: SingleChildScrollView(child: child)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pumpKbjuCard(
|
||||||
|
WidgetTester tester,
|
||||||
|
FakeInsightsRepository repository,
|
||||||
|
String tabKey,
|
||||||
|
) async {
|
||||||
|
tester.view.physicalSize = const Size(900, 2600);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrap(InsightsCard(repository: repository, kbjuEnabled: true)),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('insights_toggle')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(Key(tabKey)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
group('КБЖУ по дням', () {
|
||||||
|
testWidgets('рисует график, шапку со средним и 4 чипа серий', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pumpKbjuCard(
|
||||||
|
tester,
|
||||||
|
FakeInsightsRepository(),
|
||||||
|
'insights_tab_daily',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byType(DailyKbjuChart), findsOneWidget);
|
||||||
|
expect(find.text('1930'), findsOneWidget);
|
||||||
|
expect(find.text('ккал в среднем'), findsOneWidget);
|
||||||
|
expect(find.text('Калораж вырос на 5% за период'), findsOneWidget);
|
||||||
|
for (var i = 0; i < 4; i++) {
|
||||||
|
expect(find.byKey(Key('insights_series_$i')), findsOneWidget);
|
||||||
|
}
|
||||||
|
expect(find.byKey(const Key('insights_series_4')), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('чипы скрывают серии', (tester) async {
|
||||||
|
await pumpKbjuCard(
|
||||||
|
tester,
|
||||||
|
FakeInsightsRepository(),
|
||||||
|
'insights_tab_daily',
|
||||||
|
);
|
||||||
|
|
||||||
|
SfCartesianChart chart() => tester.widget<SfCartesianChart>(
|
||||||
|
find.byKey(const Key('insights_daily_chart')),
|
||||||
|
);
|
||||||
|
expect(chart().series, hasLength(4));
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('insights_series_1')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(chart().series, hasLength(3));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('пилюли периода активны и перезапрашивают данные', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final repository = FakeInsightsRepository();
|
||||||
|
await pumpKbjuCard(tester, repository, 'insights_tab_daily');
|
||||||
|
repository.serverRequests.clear();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('insights_period_30')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(repository.serverRequests.single.dailyPeriod, DailyPeriod.month);
|
||||||
|
expect(
|
||||||
|
repository.serverRequests.single.bodyPeriod,
|
||||||
|
BodyPeriod.threeMonths,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('без daily показывает заглушку', (tester) async {
|
||||||
|
final payload = loadInsightsPayloadFixture().copyWith(daily: null);
|
||||||
|
await pumpKbjuCard(
|
||||||
|
tester,
|
||||||
|
FakeInsightsRepository(payload: payload),
|
||||||
|
'insights_tab_daily',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byType(DailyKbjuChart), findsNothing);
|
||||||
|
expect(find.text('Недостаточно данных'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('пустой daily показывает заглушку вместо графика', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pumpKbjuCard(
|
||||||
|
tester,
|
||||||
|
FakeInsightsRepository(
|
||||||
|
payload: loadInsightsPayloadFixture('insights_payload_minimal.json'),
|
||||||
|
),
|
||||||
|
'insights_tab_daily',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('insights_daily_chart')), findsNothing);
|
||||||
|
expect(find.text('Недостаточно данных'), findsWidgets);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('В течение дня', () {
|
||||||
|
testWidgets('рисует график, навигацию по дням, легенду и подсказку', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pumpKbjuCard(
|
||||||
|
tester,
|
||||||
|
FakeInsightsRepository(),
|
||||||
|
'insights_tab_intra',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byType(IntraDayChart), findsOneWidget);
|
||||||
|
expect(find.byType(IntraDateNav), findsOneWidget);
|
||||||
|
expect(find.text('Сегодня'), findsOneWidget);
|
||||||
|
expect(find.text('Подъём в 07:30'), findsOneWidget);
|
||||||
|
expect(find.text('Отход ко сну в 23:00'), findsOneWidget);
|
||||||
|
expect(find.text('Затемнение — время сна'), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('insights_intra_hint')), findsOneWidget);
|
||||||
|
for (var i = 0; i < 6; i++) {
|
||||||
|
expect(find.byKey(Key('insights_series_$i')), findsOneWidget);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'на сегодняшнем дне «следующий день» отключён, «предыдущий» нет',
|
||||||
|
(tester) async {
|
||||||
|
await pumpKbjuCard(
|
||||||
|
tester,
|
||||||
|
FakeInsightsRepository(),
|
||||||
|
'insights_tab_intra',
|
||||||
|
);
|
||||||
|
|
||||||
|
IconButton buttonIn(String key) => tester.widget<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, isNotNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('«предыдущий день» запрашивает только intra и меняет дату', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final repository = FakeInsightsRepository();
|
||||||
|
repository.intraByDate['2026-11-08'] = loadInsightsPayloadFixture().intra!
|
||||||
|
.copyWith(date: '2026-11-08', dateLabel: '8 ноября', isToday: false);
|
||||||
|
await pumpKbjuCard(tester, repository, 'insights_tab_intra');
|
||||||
|
repository.serverRequests.clear();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('insights_intra_prev')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(repository.intraRequests, [(slug: null, date: '2026-11-08')]);
|
||||||
|
expect(repository.serverRequests, isEmpty);
|
||||||
|
expect(find.text('8 ноября'), findsOneWidget);
|
||||||
|
expect(find.text('Сегодня'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('с прошедшего дня можно вернуться «следующим днём»', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final repository = FakeInsightsRepository();
|
||||||
|
final today = loadInsightsPayloadFixture().intra!;
|
||||||
|
repository.intraByDate['2026-11-08'] = today.copyWith(
|
||||||
|
date: '2026-11-08',
|
||||||
|
dateLabel: '8 ноября',
|
||||||
|
isToday: false,
|
||||||
|
);
|
||||||
|
repository.intraByDate['2026-11-09'] = today;
|
||||||
|
await pumpKbjuCard(tester, repository, 'insights_tab_intra');
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('insights_intra_prev')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('insights_intra_next')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(repository.intraRequests.last.date, '2026-11-09');
|
||||||
|
expect(find.text('Сегодня'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('чип «Сон и подъём» убирает затемнение и легенду не трогает', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pumpKbjuCard(
|
||||||
|
tester,
|
||||||
|
FakeInsightsRepository(),
|
||||||
|
'insights_tab_intra',
|
||||||
|
);
|
||||||
|
|
||||||
|
NumericAxis xAxis() =>
|
||||||
|
tester
|
||||||
|
.widget<SfCartesianChart>(
|
||||||
|
find.byKey(const Key('insights_intra_chart')),
|
||||||
|
)
|
||||||
|
.primaryXAxis
|
||||||
|
as NumericAxis;
|
||||||
|
|
||||||
|
// wake + sleep: две полосы затемнения и две линии.
|
||||||
|
expect(xAxis().plotBands, hasLength(4));
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('insights_series_5')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(xAxis().plotBands, isEmpty);
|
||||||
|
expect(find.text('Затемнение — время сна'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('чипы сытости и настроения скрывают линии', (tester) async {
|
||||||
|
await pumpKbjuCard(
|
||||||
|
tester,
|
||||||
|
FakeInsightsRepository(),
|
||||||
|
'insights_tab_intra',
|
||||||
|
);
|
||||||
|
|
||||||
|
int seriesCount() => tester
|
||||||
|
.widget<SfCartesianChart>(
|
||||||
|
find.byKey(const Key('insights_intra_chart')),
|
||||||
|
)
|
||||||
|
.series
|
||||||
|
.length;
|
||||||
|
|
||||||
|
// 3 столбика + сытость + настроение.
|
||||||
|
expect(seriesCount(), 5);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('insights_series_3')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(seriesCount(), 4);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('insights_series_4')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(seriesCount(), 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('пустые линии не создают серий (без экстраполяции)', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final intra = loadInsightsPayloadFixture().intra!.copyWith(
|
||||||
|
satiety: const [],
|
||||||
|
mood: const [],
|
||||||
|
);
|
||||||
|
final payload = loadInsightsPayloadFixture().copyWith(intra: intra);
|
||||||
|
await pumpKbjuCard(
|
||||||
|
tester,
|
||||||
|
FakeInsightsRepository(payload: payload),
|
||||||
|
'insights_tab_intra',
|
||||||
|
);
|
||||||
|
|
||||||
|
final chart = tester.widget<SfCartesianChart>(
|
||||||
|
find.byKey(const Key('insights_intra_chart')),
|
||||||
|
);
|
||||||
|
expect(chart.series, hasLength(3));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('без wake/sleep затемнения нет', (tester) async {
|
||||||
|
const intra = IntraChartModel(
|
||||||
|
bars: [IntraBarModel(x: 12, p: 100, f: 50, c: 200)],
|
||||||
|
date: '2026-11-09',
|
||||||
|
maxDate: '2026-11-09',
|
||||||
|
dateLabel: 'Сегодня',
|
||||||
|
isToday: true,
|
||||||
|
);
|
||||||
|
final payload = loadInsightsPayloadFixture().copyWith(intra: intra);
|
||||||
|
await pumpKbjuCard(
|
||||||
|
tester,
|
||||||
|
FakeInsightsRepository(payload: payload),
|
||||||
|
'insights_tab_intra',
|
||||||
|
);
|
||||||
|
|
||||||
|
final xAxis =
|
||||||
|
tester
|
||||||
|
.widget<SfCartesianChart>(
|
||||||
|
find.byKey(const Key('insights_intra_chart')),
|
||||||
|
)
|
||||||
|
.primaryXAxis
|
||||||
|
as NumericAxis;
|
||||||
|
expect(xAxis.plotBands, isEmpty);
|
||||||
|
expect(find.byKey(const Key('insights_legend_wake')), findsNothing);
|
||||||
|
expect(find.byKey(const Key('insights_legend_sleep')), findsNothing);
|
||||||
|
expect(find.byType(IntraLegend), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('день только со сном рисует затемнение, без заглушки', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final intra = loadInsightsPayloadFixture().intra!.copyWith(
|
||||||
|
bars: const [],
|
||||||
|
satiety: const [],
|
||||||
|
mood: const [],
|
||||||
|
);
|
||||||
|
await pumpKbjuCard(
|
||||||
|
tester,
|
||||||
|
FakeInsightsRepository(
|
||||||
|
payload: loadInsightsPayloadFixture().copyWith(intra: intra),
|
||||||
|
),
|
||||||
|
'insights_tab_intra',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('insights_intra_chart')), findsOneWidget);
|
||||||
|
expect(find.text('Недостаточно данных'), findsNothing);
|
||||||
|
final xAxis =
|
||||||
|
tester
|
||||||
|
.widget<SfCartesianChart>(
|
||||||
|
find.byKey(const Key('insights_intra_chart')),
|
||||||
|
)
|
||||||
|
.primaryXAxis
|
||||||
|
as NumericAxis;
|
||||||
|
expect(xAxis.plotBands, isNotEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('пустой день показывает заглушку', (tester) async {
|
||||||
|
await pumpKbjuCard(
|
||||||
|
tester,
|
||||||
|
FakeInsightsRepository(
|
||||||
|
payload: loadInsightsPayloadFixture('insights_payload_minimal.json'),
|
||||||
|
),
|
||||||
|
'insights_tab_intra',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('insights_intra_chart')), findsNothing);
|
||||||
|
expect(find.text('Недостаточно данных'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('IntraDateNav', () {
|
||||||
|
const model = IntraChartModel(
|
||||||
|
date: '2026-11-08',
|
||||||
|
maxDate: '2026-11-09',
|
||||||
|
dateLabel: '8 ноября',
|
||||||
|
isToday: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('вызывает колбэки предыдущего и следующего дня', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
var prev = 0;
|
||||||
|
var next = 0;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrap(
|
||||||
|
IntraDateNav(
|
||||||
|
model: model,
|
||||||
|
onPreviousDay: () => prev++,
|
||||||
|
onNextDay: () => next++,
|
||||||
|
onDatePicked: (_) {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('insights_intra_prev')));
|
||||||
|
await tester.tap(find.byKey(const Key('insights_intra_next')));
|
||||||
|
|
||||||
|
expect(prev, 1);
|
||||||
|
expect(next, 1);
|
||||||
|
expect(find.text('8 ноября'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('«следующий день» отключён, когда выбран сегодняшний день', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
var next = 0;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrap(
|
||||||
|
IntraDateNav(
|
||||||
|
model: model.copyWith(isToday: true, dateLabel: 'Сегодня'),
|
||||||
|
onPreviousDay: () {},
|
||||||
|
onNextDay: () => next++,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(
|
||||||
|
find.byKey(const Key('insights_intra_next')),
|
||||||
|
warnIfMissed: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(next, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'выбор даты: календарь ограничен maxDate и возвращает выбранный день',
|
||||||
|
(tester) async {
|
||||||
|
DateTime? picked;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrap(
|
||||||
|
IntraDateNav(
|
||||||
|
model: model,
|
||||||
|
onPreviousDay: () {},
|
||||||
|
onNextDay: () {},
|
||||||
|
onDatePicked: (date) => picked = date,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('insights_intra_pick')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final dialog = tester.widget<CalendarDatePicker>(
|
||||||
|
find.byType(CalendarDatePicker),
|
||||||
|
);
|
||||||
|
expect(dialog.lastDate, DateTime(2026, 11, 9));
|
||||||
|
expect(dialog.initialDate, DateTime(2026, 11, 8));
|
||||||
|
|
||||||
|
await tester.tap(find.text('5'));
|
||||||
|
await tester.tap(
|
||||||
|
find.text('OK').evaluate().isNotEmpty
|
||||||
|
? find.text('OK')
|
||||||
|
: find.text('ОК'),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(picked, DateTime(2026, 11, 5));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/domain/insights_text_builder.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/models/insights_response_model.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/services/insights_service.dart';
|
||||||
|
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
||||||
|
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||||
|
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
||||||
|
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
import '../../../fixtures/doubles/insights_doubles.dart';
|
||||||
|
|
||||||
|
class _MockProfileRepository extends Mock implements ProfileRepository {}
|
||||||
|
|
||||||
|
class _MockInsightsService extends Mock implements InsightsService {}
|
||||||
|
|
||||||
|
ProfileDetailsModel _details({int height = 176}) => ProfileDetailsModel(
|
||||||
|
firstName: 'A',
|
||||||
|
lastName: 'B',
|
||||||
|
birthdate: '1990-01-01',
|
||||||
|
gender: 'male',
|
||||||
|
country: 'RU',
|
||||||
|
city: 'Moscow',
|
||||||
|
lang: 'ru',
|
||||||
|
weight: 70,
|
||||||
|
height: height,
|
||||||
|
chestCircumference: 90,
|
||||||
|
waistCircumference: 80,
|
||||||
|
hipCircumference: 95,
|
||||||
|
bloodPressureUp: 120,
|
||||||
|
bloodPressureDown: 80,
|
||||||
|
hasHypo: false,
|
||||||
|
hasHyper: false,
|
||||||
|
aboutAlergy: '',
|
||||||
|
aboutTakingPils: '',
|
||||||
|
aboutSportHobbies: '',
|
||||||
|
favoriteFoods: '',
|
||||||
|
unfavoriteFoods: '',
|
||||||
|
additionalInfo: '',
|
||||||
|
isMoodFillable: false,
|
||||||
|
isBedtimeFillable: false,
|
||||||
|
activityCoefficient: 1.2,
|
||||||
|
);
|
||||||
|
|
||||||
|
DioException _dio(int status) => DioException(
|
||||||
|
requestOptions: RequestOptions(path: '/x'),
|
||||||
|
response: Response(
|
||||||
|
requestOptions: RequestOptions(path: '/x'),
|
||||||
|
statusCode: status,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late _MockProfileRepository profileRepository;
|
||||||
|
late _MockInsightsService insightsService;
|
||||||
|
late InsightsRepository repository;
|
||||||
|
|
||||||
|
final date1 = DateTime(2026, 11, 1, 12);
|
||||||
|
final date2 = DateTime(2026, 11, 9, 12);
|
||||||
|
|
||||||
|
final weights = [
|
||||||
|
WeightModel(
|
||||||
|
id: 1,
|
||||||
|
userId: 1,
|
||||||
|
weight: 75,
|
||||||
|
createdAt: date1,
|
||||||
|
updatedAt: date1,
|
||||||
|
bmi: 24,
|
||||||
|
),
|
||||||
|
WeightModel(
|
||||||
|
id: 2,
|
||||||
|
userId: 1,
|
||||||
|
weight: 73.5,
|
||||||
|
createdAt: date2,
|
||||||
|
updatedAt: date2,
|
||||||
|
bmi: 23.5,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
final circumferences = [
|
||||||
|
CircumferenceModel(
|
||||||
|
id: 1,
|
||||||
|
userId: 1,
|
||||||
|
chestCircumference: 95,
|
||||||
|
waistCircumference: 82,
|
||||||
|
hipCircumference: 100,
|
||||||
|
createdAt: date1,
|
||||||
|
updatedAt: date1,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
final s = await S.load(const Locale('ru'));
|
||||||
|
profileRepository = _MockProfileRepository();
|
||||||
|
insightsService = _MockInsightsService();
|
||||||
|
repository = InsightsRepository(
|
||||||
|
profileRepository: profileRepository,
|
||||||
|
insightsService: insightsService,
|
||||||
|
textBuilderFactory: () => InsightsTextBuilder(s),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('getInsights', () {
|
||||||
|
test('собирает payload из веса и замеров; daily/intra == null', () async {
|
||||||
|
when(
|
||||||
|
() => profileRepository.getWeight(period: '3', slug: null),
|
||||||
|
).thenAnswer((_) async => weights);
|
||||||
|
when(
|
||||||
|
() => profileRepository.getCircumferences(period: '3', slug: null),
|
||||||
|
).thenAnswer((_) async => circumferences);
|
||||||
|
|
||||||
|
final payload = await repository.getInsights(
|
||||||
|
bodyPeriod: BodyPeriod.threeMonths,
|
||||||
|
heightCm: 176,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(payload.weight.values, [75, 73.5]);
|
||||||
|
expect(payload.weight.deltaText, 'Снизили на 1.5 кг за 3 месяца');
|
||||||
|
expect(payload.bmi.values, [24, 23.5]);
|
||||||
|
expect(payload.measure.waist, [82]);
|
||||||
|
expect(payload.daily, isNull);
|
||||||
|
expect(payload.intra, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('пробрасывает slug и значение периода', () async {
|
||||||
|
when(
|
||||||
|
() => profileRepository.getWeight(period: 'all', slug: 'client'),
|
||||||
|
).thenAnswer((_) async => weights);
|
||||||
|
when(
|
||||||
|
() =>
|
||||||
|
profileRepository.getCircumferences(period: 'all', slug: 'client'),
|
||||||
|
).thenAnswer((_) async => circumferences);
|
||||||
|
|
||||||
|
await repository.getInsights(slug: 'client', bodyPeriod: BodyPeriod.all);
|
||||||
|
|
||||||
|
verify(
|
||||||
|
() => profileRepository.getWeight(period: 'all', slug: 'client'),
|
||||||
|
).called(1);
|
||||||
|
verify(
|
||||||
|
() =>
|
||||||
|
profileRepository.getCircumferences(period: 'all', slug: 'client'),
|
||||||
|
).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('403 → InsightsForbidden', () async {
|
||||||
|
when(
|
||||||
|
() => profileRepository.getWeight(period: '3', slug: 'client'),
|
||||||
|
).thenThrow(_dio(403));
|
||||||
|
when(
|
||||||
|
() => profileRepository.getCircumferences(period: '3', slug: 'client'),
|
||||||
|
).thenAnswer((_) async => circumferences);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
repository.getInsights(
|
||||||
|
slug: 'client',
|
||||||
|
bodyPeriod: BodyPeriod.threeMonths,
|
||||||
|
),
|
||||||
|
throwsA(isA<InsightsForbidden>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('прочие ошибки пробрасываются как есть', () async {
|
||||||
|
when(
|
||||||
|
() => profileRepository.getWeight(period: '3', slug: null),
|
||||||
|
).thenThrow(_dio(500));
|
||||||
|
when(
|
||||||
|
() => profileRepository.getCircumferences(period: '3', slug: null),
|
||||||
|
).thenAnswer((_) async => circumferences);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
repository.getInsights(bodyPeriod: BodyPeriod.threeMonths),
|
||||||
|
throwsA(isA<DioException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('getHeightCm', () {
|
||||||
|
test('свой профиль', () async {
|
||||||
|
when(
|
||||||
|
() => profileRepository.getMyProfileDetails(),
|
||||||
|
).thenAnswer((_) async => _details());
|
||||||
|
|
||||||
|
expect(await repository.getHeightCm(), 176);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('профиль клиента по slug', () async {
|
||||||
|
when(
|
||||||
|
() => profileRepository.getProfileDetailsBySlug('client'),
|
||||||
|
).thenAnswer((_) async => _details(height: 168));
|
||||||
|
|
||||||
|
expect(await repository.getHeightCm(slug: 'client'), 168);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('нулевой рост → null', () async {
|
||||||
|
when(
|
||||||
|
() => profileRepository.getMyProfileDetails(),
|
||||||
|
).thenAnswer((_) async => _details(height: 0));
|
||||||
|
|
||||||
|
expect(await repository.getHeightCm(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ошибка не 403 → null', () async {
|
||||||
|
when(() => profileRepository.getMyProfileDetails()).thenThrow(_dio(500));
|
||||||
|
|
||||||
|
expect(await repository.getHeightCm(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('403 → InsightsForbidden', () async {
|
||||||
|
when(
|
||||||
|
() => profileRepository.getProfileDetailsBySlug('client'),
|
||||||
|
).thenThrow(_dio(403));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
repository.getHeightCm(slug: 'client'),
|
||||||
|
throwsA(isA<InsightsForbidden>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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>()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
||||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
@@ -42,6 +43,51 @@ void main() {
|
|||||||
expect(json['created_at'], isA<String>());
|
expect(json['created_at'], isA<String>());
|
||||||
expect(json['updated_at'], isA<String>());
|
expect(json['updated_at'], isA<String>());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> json({Object? weight = 72.3, Object? bmi = 22.4}) => {
|
||||||
|
'id': 1,
|
||||||
|
'user_id': 2,
|
||||||
|
'weight': weight,
|
||||||
|
'created_at': '2026-11-01T10:00:00.000000Z',
|
||||||
|
'updated_at': '2026-11-01T10:00:00.000000Z',
|
||||||
|
'bmi': bmi,
|
||||||
|
};
|
||||||
|
|
||||||
|
test('парсит дробный weight', () {
|
||||||
|
final model = WeightModel.fromJson(json());
|
||||||
|
|
||||||
|
expect(model.weight, 72.3);
|
||||||
|
expect(model.bmi, 22.4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('парсит целый weight как double', () {
|
||||||
|
final model = WeightModel.fromJson(json(weight: 72));
|
||||||
|
|
||||||
|
expect(model.weight, 72.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('допускает bmi: null (рост не указан)', () {
|
||||||
|
final model = WeightModel.fromJson(json(bmi: null));
|
||||||
|
|
||||||
|
expect(model.bmi, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CircumferenceModel', () {
|
||||||
|
test('парсит целые и дробные значения (прореженные средние)', () {
|
||||||
|
final model = CircumferenceModel.fromJson({
|
||||||
|
'id': 1,
|
||||||
|
'user_id': 2,
|
||||||
|
'chest_circumference': 95,
|
||||||
|
'waist_circumference': 80.5,
|
||||||
|
'hip_circumference': 100,
|
||||||
|
'created_at': '2026-11-01T10:00:00.000000Z',
|
||||||
|
'updated_at': '2026-11-01T10:00:00.000000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.chestCircumference, 95.0);
|
||||||
|
expect(model.waistCircumference, 80.5);
|
||||||
|
expect(model.hipCircumference, 100.0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import 'package:eatvio_app/core/locator_service.dart';
|
|||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
import 'package:eatvio_app/features/chat/repositories/chat_repository.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/consultant/become_consultant/repositories/consultant_repository.dart';
|
||||||
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
|
||||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart'
|
||||||
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart' as profile_plans;
|
as profile_plans;
|
||||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
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/presentation/pages/profile_screen.dart';
|
||||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||||
@@ -21,9 +22,15 @@ import 'package:mockito/annotations.dart';
|
|||||||
import 'package:mockito/mockito.dart';
|
import 'package:mockito/mockito.dart';
|
||||||
import 'package:network_image_mock/network_image_mock.dart';
|
import 'package:network_image_mock/network_image_mock.dart';
|
||||||
|
|
||||||
|
import '../../../../fixtures/doubles/insights_doubles.dart';
|
||||||
import 'profile_screen_smoke_test.mocks.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() {
|
void main() {
|
||||||
group('ProfileScreen smoke', () {
|
group('ProfileScreen smoke', () {
|
||||||
late MockProfileRepository profileRepository;
|
late MockProfileRepository profileRepository;
|
||||||
@@ -32,12 +39,18 @@ void main() {
|
|||||||
late MockPlansRepository plansRepository;
|
late MockPlansRepository plansRepository;
|
||||||
|
|
||||||
setUpAll(() async {
|
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 {
|
setUp(() async {
|
||||||
await sl.reset();
|
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();
|
profileRepository = MockProfileRepository();
|
||||||
chatRepository = MockChatRepository();
|
chatRepository = MockChatRepository();
|
||||||
@@ -45,12 +58,25 @@ void main() {
|
|||||||
plansRepository = MockPlansRepository();
|
plansRepository = MockPlansRepository();
|
||||||
|
|
||||||
sl.registerLazySingleton<ProfileRepository>(() => profileRepository);
|
sl.registerLazySingleton<ProfileRepository>(() => profileRepository);
|
||||||
|
sl.registerLazySingleton<InsightsRepository>(
|
||||||
|
() => FakeInsightsRepository(),
|
||||||
|
);
|
||||||
sl.registerLazySingleton<ChatRepository>(() => chatRepository);
|
sl.registerLazySingleton<ChatRepository>(() => chatRepository);
|
||||||
sl.registerLazySingleton<ConsultantRepository>(() => consultantRepository);
|
sl.registerLazySingleton<ConsultantRepository>(
|
||||||
|
() => consultantRepository,
|
||||||
|
);
|
||||||
sl.registerLazySingleton<PlansRepository>(() => plansRepository);
|
sl.registerLazySingleton<PlansRepository>(() => plansRepository);
|
||||||
|
|
||||||
sl.registerFactory<ProfileCubit>(() => ProfileCubit(profileRepository: sl(), chatRepository: sl(), consultantRepository: sl()));
|
sl.registerFactory<ProfileCubit>(
|
||||||
sl.registerFactory<profile_plans.PlansCubit>(() => profile_plans.PlansCubit(plansRepository: sl()));
|
() => ProfileCubit(
|
||||||
|
profileRepository: sl(),
|
||||||
|
chatRepository: sl(),
|
||||||
|
consultantRepository: sl(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
sl.registerFactory<profile_plans.PlansCubit>(
|
||||||
|
() => profile_plans.PlansCubit(plansRepository: sl()),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Widget wrap(Widget child) {
|
Widget wrap(Widget child) {
|
||||||
@@ -115,10 +141,12 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
when(profileRepository.getMyProfile()).thenAnswer((_) async => profile);
|
when(profileRepository.getMyProfile()).thenAnswer((_) async => profile);
|
||||||
when(profileRepository.getMyProfileDetails()).thenAnswer((_) async => profileDetails);
|
when(
|
||||||
when(profileRepository.getBlogPostsByAuthorSlug(slug: anyNamed('slug'))).thenAnswer((_) async => []);
|
profileRepository.getMyProfileDetails(),
|
||||||
when(profileRepository.getWeight(period: anyNamed('period'))).thenAnswer((_) async => <WeightModel>[]);
|
).thenAnswer((_) async => profileDetails);
|
||||||
when(profileRepository.getCircumferences(period: anyNamed('period'))).thenAnswer((_) async => <CircumferenceModel>[]);
|
when(
|
||||||
|
profileRepository.getBlogPostsByAuthorSlug(slug: anyNamed('slug')),
|
||||||
|
).thenAnswer((_) async => []);
|
||||||
|
|
||||||
await mockNetworkImagesFor(() async {
|
await mockNetworkImagesFor(() async {
|
||||||
await tester.pumpWidget(wrap(const ProfileScreen()));
|
await tester.pumpWidget(wrap(const ProfileScreen()));
|
||||||
@@ -129,8 +157,12 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('renders error state', (tester) async {
|
testWidgets('renders error state', (tester) async {
|
||||||
when(profileRepository.getMyProfile()).thenThrow(Exception('Error loading profile'));
|
when(
|
||||||
when(profileRepository.getMyProfileDetails()).thenThrow(Exception('Error'));
|
profileRepository.getMyProfile(),
|
||||||
|
).thenThrow(Exception('Error loading profile'));
|
||||||
|
when(
|
||||||
|
profileRepository.getMyProfileDetails(),
|
||||||
|
).thenThrow(Exception('Error'));
|
||||||
|
|
||||||
await tester.pumpWidget(wrap(const ProfileScreen()));
|
await tester.pumpWidget(wrap(const ProfileScreen()));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -185,13 +217,19 @@ void main() {
|
|||||||
activityCoefficient: 1.5,
|
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.getMyProfile()).thenAnswer((_) async => profile);
|
||||||
when(profileRepository.getMyProfileDetails()).thenAnswer((_) async => profileDetails);
|
when(
|
||||||
when(profileRepository.getProfileDetailsBySlug(any)).thenAnswer((_) async => profileDetails);
|
profileRepository.getMyProfileDetails(),
|
||||||
when(profileRepository.getBlogPostsByAuthorSlug(slug: anyNamed('slug'))).thenAnswer((_) async => []);
|
).thenAnswer((_) async => profileDetails);
|
||||||
when(profileRepository.getWeight(period: anyNamed('period'))).thenAnswer((_) async => <WeightModel>[]);
|
when(
|
||||||
when(profileRepository.getCircumferences(period: anyNamed('period'))).thenAnswer((_) async => <CircumferenceModel>[]);
|
profileRepository.getProfileDetailsBySlug(any),
|
||||||
|
).thenAnswer((_) async => profileDetails);
|
||||||
|
when(
|
||||||
|
profileRepository.getBlogPostsByAuthorSlug(slug: anyNamed('slug')),
|
||||||
|
).thenAnswer((_) async => []);
|
||||||
|
|
||||||
await mockNetworkImagesFor(() async {
|
await mockNetworkImagesFor(() async {
|
||||||
await tester.pumpWidget(wrap(const ProfileScreen(slug: 'test-user')));
|
await tester.pumpWidget(wrap(const ProfileScreen(slug: 'test-user')));
|
||||||
@@ -199,7 +237,159 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(find.byType(ProfileScreen), findsOneWidget);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
|
||||||
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
|
||||||
import 'package:eatvio_app/features/profile/presentation/widgets/circumference_chart_widget.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:intl/date_symbol_data_local.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
group('CircumferenceChartWidget smoke', () {
|
|
||||||
setUpAll(() async {
|
|
||||||
await initializeDateFormatting('ru', null);
|
|
||||||
});
|
|
||||||
Widget wrap(Widget child) {
|
|
||||||
return MaterialApp(
|
|
||||||
theme: ThemeHelper().themeData(),
|
|
||||||
home: Scaffold(body: child),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
testWidgets('renders circumference chart widget with empty points', (tester) async {
|
|
||||||
await tester.pumpWidget(wrap(const CircumferenceChartWidget(circumferencePoints: [])));
|
|
||||||
await tester.pump();
|
|
||||||
await tester.pump(const Duration(milliseconds: 100));
|
|
||||||
|
|
||||||
expect(find.byType(CircumferenceChartWidget), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('renders circumference chart widget with points', (tester) async {
|
|
||||||
final points = [
|
|
||||||
CircumferenceModel(
|
|
||||||
id: 1,
|
|
||||||
userId: 1,
|
|
||||||
chestCircumference: 90,
|
|
||||||
waistCircumference: 80,
|
|
||||||
hipCircumference: 95,
|
|
||||||
createdAt: DateTime.now(),
|
|
||||||
updatedAt: DateTime.now(),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
await tester.pumpWidget(wrap(CircumferenceChartWidget(circumferencePoints: points)));
|
|
||||||
await tester.pump();
|
|
||||||
await tester.pump(const Duration(milliseconds: 100));
|
|
||||||
|
|
||||||
expect(find.byType(CircumferenceChartWidget), findsOneWidget);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import 'package:eatvio_app/core/app_config.dart';
|
|
||||||
import 'package:eatvio_app/core/app_initializer.dart';
|
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
|
||||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
|
||||||
import 'package:eatvio_app/features/profile/presentation/widgets/imt_chart_widget.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_portal/flutter_portal.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
|
||||||
import 'package:intl/date_symbol_data_local.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
group('ImtChartWidget', () {
|
|
||||||
setUp(() async {
|
|
||||||
await sl.reset();
|
|
||||||
await initializeDateFormatting('ru', null);
|
|
||||||
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
|
|
||||||
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
|
|
||||||
});
|
|
||||||
|
|
||||||
Widget createTestWidget(Widget child) {
|
|
||||||
return MaterialApp(
|
|
||||||
theme: ThemeHelper().themeData(),
|
|
||||||
home: Portal(
|
|
||||||
child: Scaffold(body: SingleChildScrollView(child: child)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
testWidgets('should display chart with empty weight points', (WidgetTester tester) async {
|
|
||||||
// Arrange
|
|
||||||
const widget = ImtChartWidget(weightPoints: []);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await tester.pumpWidget(createTestWidget(widget));
|
|
||||||
await tester.pump();
|
|
||||||
await tester.pump(const Duration(seconds: 1));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(find.byType(ImtChartWidget), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('should display chart with single weight point', (WidgetTester tester) async {
|
|
||||||
// Arrange
|
|
||||||
final weightPoints = [
|
|
||||||
WeightModel(
|
|
||||||
id: 1,
|
|
||||||
userId: 1,
|
|
||||||
weight: 70,
|
|
||||||
createdAt: DateTime.now(),
|
|
||||||
updatedAt: DateTime.now(),
|
|
||||||
bmi: 23.5,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
final widget = ImtChartWidget(weightPoints: weightPoints);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await tester.pumpWidget(createTestWidget(widget));
|
|
||||||
await tester.pump();
|
|
||||||
await tester.pump(const Duration(seconds: 1));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(find.byType(ImtChartWidget), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('should display chart with multiple weight points and show increase arrow', (WidgetTester tester) async {
|
|
||||||
// Arrange
|
|
||||||
final now = DateTime.now();
|
|
||||||
final weightPoints = [
|
|
||||||
WeightModel(
|
|
||||||
id: 1,
|
|
||||||
userId: 1,
|
|
||||||
weight: 70,
|
|
||||||
createdAt: now.subtract(const Duration(days: 7)),
|
|
||||||
updatedAt: now.subtract(const Duration(days: 7)),
|
|
||||||
bmi: 23.0,
|
|
||||||
),
|
|
||||||
WeightModel(
|
|
||||||
id: 2,
|
|
||||||
userId: 1,
|
|
||||||
weight: 72,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
bmi: 24.0,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
final widget = ImtChartWidget(weightPoints: weightPoints);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await tester.pumpWidget(createTestWidget(widget));
|
|
||||||
await tester.pump();
|
|
||||||
await tester.pump(const Duration(seconds: 1));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(find.byType(ImtChartWidget), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('should display chart with multiple weight points and show decrease arrow', (WidgetTester tester) async {
|
|
||||||
// Arrange
|
|
||||||
final now = DateTime.now();
|
|
||||||
final weightPoints = [
|
|
||||||
WeightModel(
|
|
||||||
id: 1,
|
|
||||||
userId: 1,
|
|
||||||
weight: 72,
|
|
||||||
createdAt: now.subtract(const Duration(days: 7)),
|
|
||||||
updatedAt: now.subtract(const Duration(days: 7)),
|
|
||||||
bmi: 24.0,
|
|
||||||
),
|
|
||||||
WeightModel(
|
|
||||||
id: 2,
|
|
||||||
userId: 1,
|
|
||||||
weight: 70,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
bmi: 23.0,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
final widget = ImtChartWidget(weightPoints: weightPoints);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await tester.pumpWidget(createTestWidget(widget));
|
|
||||||
await tester.pump();
|
|
||||||
await tester.pump(const Duration(seconds: 1));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(find.byType(ImtChartWidget), findsOneWidget);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
|
||||||
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
|
||||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
|
||||||
import 'package:eatvio_app/features/profile/presentation/widgets/circumference_chart_widget.dart';
|
|
||||||
import 'package:eatvio_app/features/profile/presentation/widgets/weight_chart_widget.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:intl/date_symbol_data_local.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
group('Profile chart widgets smoke', () {
|
|
||||||
setUpAll(() async {
|
|
||||||
Intl.defaultLocale = 'ru';
|
|
||||||
await initializeDateFormatting('ru');
|
|
||||||
});
|
|
||||||
|
|
||||||
Widget wrap(Widget child) {
|
|
||||||
return MaterialApp(
|
|
||||||
theme: ThemeHelper().themeData(),
|
|
||||||
home: Scaffold(body: SingleChildScrollView(child: child)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
testWidgets('WeightChartWidget renders empty state', (tester) async {
|
|
||||||
await tester.pumpWidget(wrap(const WeightChartWidget(weightPoints: [])));
|
|
||||||
await tester.pumpAndSettle(const Duration(milliseconds: 200));
|
|
||||||
expect(find.text('Добавьте Ваш вес'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('WeightChartWidget renders with one point', (tester) async {
|
|
||||||
final points = [WeightModel(id: 1, userId: 1, weight: 70, createdAt: DateTime(2025, 1, 1), updatedAt: DateTime(2025, 1, 1), bmi: 22.0)];
|
|
||||||
await tester.pumpWidget(wrap(WeightChartWidget(weightPoints: points)));
|
|
||||||
await tester.pumpAndSettle(const Duration(milliseconds: 200));
|
|
||||||
expect(find.textContaining('кг'), findsWidgets);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('CircumferenceChartWidget renders empty state', (tester) async {
|
|
||||||
await tester.pumpWidget(wrap(const CircumferenceChartWidget(circumferencePoints: [])));
|
|
||||||
await tester.pumpAndSettle(const Duration(milliseconds: 200));
|
|
||||||
expect(find.text('Добавьте Ваши замеры'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('CircumferenceChartWidget renders with one point', (tester) async {
|
|
||||||
final points = [
|
|
||||||
CircumferenceModel(
|
|
||||||
id: 1,
|
|
||||||
userId: 1,
|
|
||||||
chestCircumference: 100,
|
|
||||||
waistCircumference: 80,
|
|
||||||
hipCircumference: 90,
|
|
||||||
createdAt: DateTime(2025, 1, 1),
|
|
||||||
updatedAt: DateTime(2025, 1, 1),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
await tester.pumpWidget(wrap(CircumferenceChartWidget(circumferencePoints: points)));
|
|
||||||
await tester.pumpAndSettle(const Duration(milliseconds: 200));
|
|
||||||
|
|
||||||
expect(find.text('100'), findsOneWidget);
|
|
||||||
expect(find.text('80'), findsOneWidget);
|
|
||||||
expect(find.text('90'), findsOneWidget);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
|
||||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
|
||||||
import 'package:eatvio_app/features/profile/presentation/widgets/weight_chart_widget.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:intl/date_symbol_data_local.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
group('WeightChartWidget smoke', () {
|
|
||||||
setUpAll(() async {
|
|
||||||
await initializeDateFormatting('ru', null);
|
|
||||||
});
|
|
||||||
Widget wrap(Widget child) {
|
|
||||||
return MaterialApp(
|
|
||||||
theme: ThemeHelper().themeData(),
|
|
||||||
home: Scaffold(body: child),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
testWidgets('renders weight chart widget with empty points', (tester) async {
|
|
||||||
await tester.pumpWidget(wrap(const WeightChartWidget(weightPoints: [])));
|
|
||||||
await tester.pump();
|
|
||||||
await tester.pump(const Duration(milliseconds: 100));
|
|
||||||
|
|
||||||
expect(find.byType(WeightChartWidget), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('renders weight chart widget with points', (tester) async {
|
|
||||||
final points = [WeightModel(id: 1, userId: 1, weight: 70, bmi: 22.5, createdAt: DateTime.now(), updatedAt: DateTime.now())];
|
|
||||||
await tester.pumpWidget(wrap(WeightChartWidget(weightPoints: points)));
|
|
||||||
await tester.pump();
|
|
||||||
await tester.pump(const Duration(milliseconds: 100));
|
|
||||||
|
|
||||||
expect(find.byType(WeightChartWidget), findsOneWidget);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
|
||||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
|
||||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||||
import 'package:eatvio_app/features/user_details/presentation/cubit/user_details_cubit.dart';
|
import 'package:eatvio_app/features/user_details/presentation/cubit/user_details_cubit.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
@@ -28,8 +26,6 @@ void main() {
|
|||||||
expect(cubit.state.isLoading, isFalse);
|
expect(cubit.state.isLoading, isFalse);
|
||||||
expect(cubit.state.error, isNull);
|
expect(cubit.state.error, isNull);
|
||||||
expect(cubit.state.profileDetails, isNull);
|
expect(cubit.state.profileDetails, isNull);
|
||||||
expect(cubit.state.weightPoints, isNull);
|
|
||||||
expect(cubit.state.circumferencePoints, isNull);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getUserDetails - успешная загрузка деталей пользователя', () async {
|
test('getUserDetails - успешная загрузка деталей пользователя', () async {
|
||||||
@@ -62,37 +58,8 @@ void main() {
|
|||||||
activityCoefficient: 1.5,
|
activityCoefficient: 1.5,
|
||||||
);
|
);
|
||||||
|
|
||||||
final weightPoints = [
|
|
||||||
WeightModel(
|
|
||||||
id: 1,
|
|
||||||
userId: 1,
|
|
||||||
weight: 70,
|
|
||||||
createdAt: DateTime.now(),
|
|
||||||
updatedAt: DateTime.now(),
|
|
||||||
bmi: 22.9,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
final circumferencePoints = [
|
|
||||||
CircumferenceModel(
|
|
||||||
id: 1,
|
|
||||||
userId: 1,
|
|
||||||
chestCircumference: 100,
|
|
||||||
waistCircumference: 80,
|
|
||||||
hipCircumference: 90,
|
|
||||||
createdAt: DateTime.now(),
|
|
||||||
updatedAt: DateTime.now(),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
when(mockProfileRepository.getProfileDetailsBySlug(slug))
|
when(mockProfileRepository.getProfileDetailsBySlug(slug))
|
||||||
.thenAnswer((_) async => profileDetails);
|
.thenAnswer((_) async => profileDetails);
|
||||||
|
|
||||||
when(mockProfileRepository.getWeight(period: '1', slug: slug))
|
|
||||||
.thenAnswer((_) async => weightPoints);
|
|
||||||
|
|
||||||
when(mockProfileRepository.getCircumferences(period: '1', slug: slug))
|
|
||||||
.thenAnswer((_) async => circumferencePoints);
|
|
||||||
|
|
||||||
cubit.getUserDetails(slug);
|
cubit.getUserDetails(slug);
|
||||||
|
|
||||||
@@ -119,75 +86,6 @@ void main() {
|
|||||||
expect(cubit.state.error, equals(error.toString()));
|
expect(cubit.state.error, equals(error.toString()));
|
||||||
expect(cubit.state.isLoading, isFalse);
|
expect(cubit.state.isLoading, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getChartData - успешная загрузка данных графика', () async {
|
|
||||||
const slug = 'test-user';
|
|
||||||
const period = '1';
|
|
||||||
|
|
||||||
final weightPoints = [
|
|
||||||
WeightModel(
|
|
||||||
id: 1,
|
|
||||||
userId: 1,
|
|
||||||
weight: 70,
|
|
||||||
createdAt: DateTime.now(),
|
|
||||||
updatedAt: DateTime.now(),
|
|
||||||
bmi: 22.9,
|
|
||||||
),
|
|
||||||
WeightModel(
|
|
||||||
id: 2,
|
|
||||||
userId: 1,
|
|
||||||
weight: 71,
|
|
||||||
createdAt: DateTime.now().add(const Duration(days: 1)),
|
|
||||||
updatedAt: DateTime.now().add(const Duration(days: 1)),
|
|
||||||
bmi: 23.2,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
final circumferencePoints = [
|
|
||||||
CircumferenceModel(
|
|
||||||
id: 1,
|
|
||||||
userId: 1,
|
|
||||||
chestCircumference: 100,
|
|
||||||
waistCircumference: 80,
|
|
||||||
hipCircumference: 90,
|
|
||||||
createdAt: DateTime.now(),
|
|
||||||
updatedAt: DateTime.now(),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
when(mockProfileRepository.getWeight(period: period, slug: slug))
|
|
||||||
.thenAnswer((_) async => weightPoints);
|
|
||||||
|
|
||||||
when(mockProfileRepository.getCircumferences(period: period, slug: slug))
|
|
||||||
.thenAnswer((_) async => circumferencePoints);
|
|
||||||
|
|
||||||
cubit.getChartData(period: period, slug: slug);
|
|
||||||
|
|
||||||
// Ждем завершения асинхронных операций
|
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
|
||||||
|
|
||||||
expect(cubit.state.weightPoints, equals(weightPoints));
|
|
||||||
expect(cubit.state.circumferencePoints, equals(circumferencePoints));
|
|
||||||
verify(mockProfileRepository.getWeight(period: period, slug: slug)).called(1);
|
|
||||||
verify(mockProfileRepository.getCircumferences(period: period, slug: slug)).called(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getChartData - обработка ошибки', () async {
|
|
||||||
const slug = 'test-user';
|
|
||||||
const period = '1';
|
|
||||||
final error = Exception('Ошибка загрузки');
|
|
||||||
|
|
||||||
when(mockProfileRepository.getWeight(period: period, slug: slug))
|
|
||||||
.thenThrow(error);
|
|
||||||
|
|
||||||
cubit.getChartData(period: period, slug: slug);
|
|
||||||
|
|
||||||
// Ждем завершения асинхронных операций
|
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
|
||||||
|
|
||||||
expect(cubit.state.error, equals(error.toString()));
|
|
||||||
expect(cubit.state.isLoading, isFalse);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:eatvio_app/core/app_config.dart';
|
|||||||
import 'package:eatvio_app/core/app_initializer.dart';
|
import 'package:eatvio_app/core/app_initializer.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
|
||||||
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
||||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
||||||
@@ -17,6 +18,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:mockito/annotations.dart';
|
import 'package:mockito/annotations.dart';
|
||||||
import 'package:mockito/mockito.dart';
|
import 'package:mockito/mockito.dart';
|
||||||
|
|
||||||
|
import '../../../fixtures/doubles/insights_doubles.dart';
|
||||||
import 'user_details_screen_smoke_test.mocks.dart';
|
import 'user_details_screen_smoke_test.mocks.dart';
|
||||||
|
|
||||||
@GenerateNiceMocks([MockSpec<ProfileRepository>()])
|
@GenerateNiceMocks([MockSpec<ProfileRepository>()])
|
||||||
@@ -97,10 +99,11 @@ void main() {
|
|||||||
final cubit = UserDetailsCubit(profileRepository: profileRepository);
|
final cubit = UserDetailsCubit(profileRepository: profileRepository);
|
||||||
|
|
||||||
when(profileRepository.getProfileDetailsBySlug('test-slug')).thenAnswer((_) async => mockProfileDetails);
|
when(profileRepository.getProfileDetailsBySlug('test-slug')).thenAnswer((_) async => mockProfileDetails);
|
||||||
when(profileRepository.getWeight(period: '1', slug: 'test-slug')).thenAnswer((_) async => mockWeightPoints);
|
when(profileRepository.getWeight(period: '3', slug: 'test-slug')).thenAnswer((_) async => mockWeightPoints);
|
||||||
when(profileRepository.getCircumferences(period: '1', slug: 'test-slug')).thenAnswer((_) async => mockCircumferencePoints);
|
when(profileRepository.getCircumferences(period: '3', slug: 'test-slug')).thenAnswer((_) async => mockCircumferencePoints);
|
||||||
|
|
||||||
sl.registerLazySingleton<ProfileRepository>(() => profileRepository);
|
sl.registerLazySingleton<ProfileRepository>(() => profileRepository);
|
||||||
|
sl.registerLazySingleton<InsightsRepository>(() => FakeInsightsRepository());
|
||||||
sl.registerLazySingleton<UserDetailsCubit>(() => cubit);
|
sl.registerLazySingleton<UserDetailsCubit>(() => cubit);
|
||||||
|
|
||||||
await tester.pumpWidget(wrap(const UserDetailsScreen(slug: 'test-slug')));
|
await tester.pumpWidget(wrap(const UserDetailsScreen(slug: 'test-slug')));
|
||||||
@@ -117,10 +120,11 @@ void main() {
|
|||||||
final cubit = UserDetailsCubit(profileRepository: profileRepository);
|
final cubit = UserDetailsCubit(profileRepository: profileRepository);
|
||||||
|
|
||||||
when(profileRepository.getProfileDetailsBySlug('test-slug')).thenAnswer((_) async => mockProfileDetails);
|
when(profileRepository.getProfileDetailsBySlug('test-slug')).thenAnswer((_) async => mockProfileDetails);
|
||||||
when(profileRepository.getWeight(period: '1', slug: 'test-slug')).thenAnswer((_) async => mockWeightPoints);
|
when(profileRepository.getWeight(period: '3', slug: 'test-slug')).thenAnswer((_) async => mockWeightPoints);
|
||||||
when(profileRepository.getCircumferences(period: '1', slug: 'test-slug')).thenAnswer((_) async => mockCircumferencePoints);
|
when(profileRepository.getCircumferences(period: '3', slug: 'test-slug')).thenAnswer((_) async => mockCircumferencePoints);
|
||||||
|
|
||||||
sl.registerLazySingleton<ProfileRepository>(() => profileRepository);
|
sl.registerLazySingleton<ProfileRepository>(() => profileRepository);
|
||||||
|
sl.registerLazySingleton<InsightsRepository>(() => FakeInsightsRepository());
|
||||||
sl.registerLazySingleton<UserDetailsCubit>(() => cubit);
|
sl.registerLazySingleton<UserDetailsCubit>(() => cubit);
|
||||||
|
|
||||||
await tester.pumpWidget(wrap(const UserDetailsScreen(slug: 'test-slug')));
|
await tester.pumpWidget(wrap(const UserDetailsScreen(slug: 'test-slug')));
|
||||||
@@ -136,6 +140,7 @@ void main() {
|
|||||||
when(profileRepository.getProfileDetailsBySlug('test-slug')).thenThrow(Exception('Error loading profile'));
|
when(profileRepository.getProfileDetailsBySlug('test-slug')).thenThrow(Exception('Error loading profile'));
|
||||||
|
|
||||||
sl.registerLazySingleton<ProfileRepository>(() => profileRepository);
|
sl.registerLazySingleton<ProfileRepository>(() => profileRepository);
|
||||||
|
sl.registerLazySingleton<InsightsRepository>(() => FakeInsightsRepository());
|
||||||
sl.registerLazySingleton<UserDetailsCubit>(() => cubit);
|
sl.registerLazySingleton<UserDetailsCubit>(() => cubit);
|
||||||
|
|
||||||
await tester.pumpWidget(wrap(const UserDetailsScreen(slug: 'test-slug')));
|
await tester.pumpWidget(wrap(const UserDetailsScreen(slug: 'test-slug')));
|
||||||
|
|||||||
131
test/fixtures/doubles/insights_doubles.dart
vendored
Normal file
131
test/fixtures/doubles/insights_doubles.dart
vendored
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
InsightsPayloadModel loadInsightsPayloadFixture([
|
||||||
|
String name = 'insights_payload.json',
|
||||||
|
]) {
|
||||||
|
final json =
|
||||||
|
jsonDecode(File('test/fixtures/insights/$name').readAsStringSync())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
return InsightsPayloadModel.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Подмена [InsightsRepository] для виджет-тестов: без сети, с журналом вызовов.
|
||||||
|
class FakeInsightsRepository extends Fake implements InsightsRepository {
|
||||||
|
FakeInsightsRepository({
|
||||||
|
InsightsPayloadModel? payload,
|
||||||
|
this.error,
|
||||||
|
this.heightCm = 176,
|
||||||
|
}) : payload = payload ?? loadInsightsPayloadFixture();
|
||||||
|
|
||||||
|
InsightsPayloadModel payload;
|
||||||
|
|
||||||
|
/// Ответ [fetchIntra] по запрошенной дате (`YYYY-MM-DD`); для остальных дат
|
||||||
|
/// берётся `payload.intra`.
|
||||||
|
final Map<String, IntraChartModel> intraByDate = {};
|
||||||
|
|
||||||
|
/// Если задан — выбрасывается из [fetchIntra].
|
||||||
|
Object? intraError;
|
||||||
|
|
||||||
|
/// Если задан — выбрасывается из [getInsights] (например, [InsightsForbidden]).
|
||||||
|
Object? error;
|
||||||
|
int? heightCm;
|
||||||
|
|
||||||
|
/// Задержка ответа [fetchInsights] (для тестов гонок).
|
||||||
|
Duration? fetchInsightsDelay;
|
||||||
|
|
||||||
|
/// Задержка ответа [fetchIntra] (для тестов гонок).
|
||||||
|
Duration? fetchIntraDelay;
|
||||||
|
|
||||||
|
final List<({String? slug, BodyPeriod period})> insightsRequests = [];
|
||||||
|
final List<
|
||||||
|
({
|
||||||
|
String? slug,
|
||||||
|
BodyPeriod bodyPeriod,
|
||||||
|
DailyPeriod dailyPeriod,
|
||||||
|
String? intraDate,
|
||||||
|
})
|
||||||
|
>
|
||||||
|
serverRequests = [];
|
||||||
|
final List<({String? slug, String? date})> intraRequests = [];
|
||||||
|
final List<double> addedWeights = [];
|
||||||
|
final List<({int chest, int waist, int hip})> addedCircumferences = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int?> getHeightCm({String? slug}) async => heightCm;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<InsightsPayloadModel> getInsights({
|
||||||
|
String? slug,
|
||||||
|
required BodyPeriod bodyPeriod,
|
||||||
|
int? heightCm,
|
||||||
|
}) async {
|
||||||
|
insightsRequests.add((slug: slug, period: bodyPeriod));
|
||||||
|
final failure = error;
|
||||||
|
if (failure != null) {
|
||||||
|
throw failure;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<InsightsPayloadModel> fetchInsights({
|
||||||
|
String? slug,
|
||||||
|
required BodyPeriod bodyPeriod,
|
||||||
|
required DailyPeriod dailyPeriod,
|
||||||
|
String? intraDate,
|
||||||
|
}) async {
|
||||||
|
serverRequests.add((
|
||||||
|
slug: slug,
|
||||||
|
bodyPeriod: bodyPeriod,
|
||||||
|
dailyPeriod: dailyPeriod,
|
||||||
|
intraDate: intraDate,
|
||||||
|
));
|
||||||
|
final delay = fetchInsightsDelay;
|
||||||
|
if (delay != null) {
|
||||||
|
await Future<void>.delayed(delay);
|
||||||
|
}
|
||||||
|
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 delay = fetchIntraDelay;
|
||||||
|
if (delay != null) {
|
||||||
|
await Future<void>.delayed(delay);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> addCircumference({
|
||||||
|
required int chestCircumference,
|
||||||
|
required int waistCircumference,
|
||||||
|
required int hipCircumference,
|
||||||
|
}) async {
|
||||||
|
addedCircumferences.add((
|
||||||
|
chest: chestCircumference,
|
||||||
|
waist: waistCircumference,
|
||||||
|
hip: hipCircumference,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
122
test/fixtures/insights/insights_payload.json
vendored
Normal file
122
test/fixtures/insights/insights_payload.json
vendored
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
{
|
||||||
|
"weight": {
|
||||||
|
"labels": [
|
||||||
|
"01.11",
|
||||||
|
"05.11",
|
||||||
|
"09.11"
|
||||||
|
],
|
||||||
|
"values": [
|
||||||
|
74.5,
|
||||||
|
73.9,
|
||||||
|
73.2
|
||||||
|
],
|
||||||
|
"last": 73.2,
|
||||||
|
"delta": -1.3,
|
||||||
|
"headline": "73.2",
|
||||||
|
"unit": "кг",
|
||||||
|
"deltaText": "Снизили на 1.3 кг за период"
|
||||||
|
},
|
||||||
|
"bmi": {
|
||||||
|
"labels": [],
|
||||||
|
"values": [],
|
||||||
|
"last": null,
|
||||||
|
"delta": null,
|
||||||
|
"headline": "—",
|
||||||
|
"category": null,
|
||||||
|
"deltaText": "Укажите рост, чтобы посчитать ИМТ.",
|
||||||
|
"hint": ""
|
||||||
|
},
|
||||||
|
"measure": {
|
||||||
|
"labels": [
|
||||||
|
"01.11",
|
||||||
|
"09.11"
|
||||||
|
],
|
||||||
|
"waist": [
|
||||||
|
82,
|
||||||
|
80.5
|
||||||
|
],
|
||||||
|
"chest": [
|
||||||
|
96,
|
||||||
|
96
|
||||||
|
],
|
||||||
|
"hips": [
|
||||||
|
101,
|
||||||
|
100
|
||||||
|
],
|
||||||
|
"headline": "81",
|
||||||
|
"deltaText": "Талия меньше на 1.5 см за период"
|
||||||
|
},
|
||||||
|
"daily": {
|
||||||
|
"labels": [
|
||||||
|
"03.11",
|
||||||
|
"04.11",
|
||||||
|
"05.11"
|
||||||
|
],
|
||||||
|
"kcal": [
|
||||||
|
1850,
|
||||||
|
2010,
|
||||||
|
1930
|
||||||
|
],
|
||||||
|
"proteins": [
|
||||||
|
92,
|
||||||
|
101,
|
||||||
|
96
|
||||||
|
],
|
||||||
|
"fats": [
|
||||||
|
60,
|
||||||
|
66,
|
||||||
|
63
|
||||||
|
],
|
||||||
|
"carbs": [
|
||||||
|
210,
|
||||||
|
222,
|
||||||
|
215
|
||||||
|
],
|
||||||
|
"average": 1930,
|
||||||
|
"percent": 5,
|
||||||
|
"headline": "1930",
|
||||||
|
"deltaText": "Калораж вырос на 5% за период"
|
||||||
|
},
|
||||||
|
"intra": {
|
||||||
|
"bars": [
|
||||||
|
{
|
||||||
|
"x": 8.5,
|
||||||
|
"p": 120,
|
||||||
|
"f": 90,
|
||||||
|
"c": 240
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 13,
|
||||||
|
"p": 160,
|
||||||
|
"f": 130,
|
||||||
|
"c": 300
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"satiety": [
|
||||||
|
{
|
||||||
|
"x": 8.5,
|
||||||
|
"y": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 13,
|
||||||
|
"y": 4
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"mood": [
|
||||||
|
{
|
||||||
|
"x": 8.5,
|
||||||
|
"y": 4
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"wake": 7.5,
|
||||||
|
"sleep": 23,
|
||||||
|
"wakeLabel": "07:30",
|
||||||
|
"sleepLabel": "23:00",
|
||||||
|
"wakeText": "Подъём в 07:30",
|
||||||
|
"sleepText": "Отход ко сну в 23:00",
|
||||||
|
"date": "2026-11-09",
|
||||||
|
"maxDate": "2026-11-09",
|
||||||
|
"dateLabel": "Сегодня",
|
||||||
|
"isToday": true
|
||||||
|
}
|
||||||
|
}
|
||||||
38
test/fixtures/insights/insights_payload_minimal.json
vendored
Normal file
38
test/fixtures/insights/insights_payload_minimal.json
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"weight": {
|
||||||
|
"labels": [],
|
||||||
|
"values": [],
|
||||||
|
"last": null,
|
||||||
|
"delta": null,
|
||||||
|
"headline": "—",
|
||||||
|
"unit": "кг",
|
||||||
|
"deltaText": "Недостаточно данных"
|
||||||
|
},
|
||||||
|
"bmi": {
|
||||||
|
"labels": [],
|
||||||
|
"values": [],
|
||||||
|
"headline": "—",
|
||||||
|
"deltaText": "Укажите рост, чтобы посчитать ИМТ.",
|
||||||
|
"hint": ""
|
||||||
|
},
|
||||||
|
"measure": {
|
||||||
|
"labels": [],
|
||||||
|
"waist": [],
|
||||||
|
"chest": [],
|
||||||
|
"hips": [],
|
||||||
|
"headline": "—",
|
||||||
|
"deltaText": "Недостаточно данных"
|
||||||
|
},
|
||||||
|
"daily": null,
|
||||||
|
"intra": {
|
||||||
|
"bars": [],
|
||||||
|
"satiety": [],
|
||||||
|
"mood": [],
|
||||||
|
"wake": null,
|
||||||
|
"sleep": null,
|
||||||
|
"date": "2026-11-09",
|
||||||
|
"maxDate": "2026-11-09",
|
||||||
|
"dateLabel": "Сегодня",
|
||||||
|
"isToday": true
|
||||||
|
}
|
||||||
|
}
|
||||||
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