feat(insights): added kbju fetching for analitics

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

View File

@@ -17,6 +17,9 @@ abstract class DiaryDayEntity with _$DiaryDayEntity {
@JsonKey(name: "chosen_plan_id") required int? chosenPlanId,
@JsonKey(name: "day_of_chosen_diet") required int? dayOfChosenDiet,
@JsonKey(name: "chosen_diet", fromJson: parseDiet) required DiaryDietModel? diet,
/// Slug владельца дневника; `null`, если сервер его не вернул.
String? ownerSlug,
}) = _DiaryDayEntity;
const DiaryDayEntity._();

View File

@@ -48,6 +48,10 @@ abstract class DiaryDayModel with _$DiaryDayModel {
@JsonKey(name: "chosen_plan_id") required int? chosenPlanId,
@JsonKey(name: "day_of_chosen_diet") required int? dayOfChosenDiet,
@JsonKey(name: "chosen_diet", fromJson: parseDiet) required DiaryDietModel? diet,
/// Владелец дневника (`user` в `DiaryResource`): по `slug` консультант
/// запрашивает `/api/profile/insights/{slug}`.
@JsonKey(name: "user") DiaryOwnerModel? owner,
}) = _DiaryDayModel;
const DiaryDayModel._();
factory DiaryDayModel.fromJson(Map<String, dynamic> json) => _$DiaryDayModelFromJson(json);
@@ -68,6 +72,7 @@ abstract class DiaryDayModel with _$DiaryDayModel {
wakeup: wakeup,
retire: retire,
diet: diet,
ownerSlug: owner?.slug,
);
}
@@ -81,6 +86,13 @@ DiaryDietModel? parseDiet(dynamic json) {
return DiaryDietModel.fromJson(json['diet']);
}
@freezed
abstract class DiaryOwnerModel with _$DiaryOwnerModel {
const factory DiaryOwnerModel({int? id, String? slug}) = _DiaryOwnerModel;
factory DiaryOwnerModel.fromJson(Map<String, dynamic> json) => _$DiaryOwnerModelFromJson(json);
}
@freezed
abstract class DiaryDietModel with _$DiaryDietModel {
const factory DiaryDietModel({required int id, required int duration, required String name}) = _DiaryDietModel;

View File

@@ -22,6 +22,7 @@ import 'package:eatvio_app/features/diary/presentation/widgets/meal_widget.dart'
import 'package:eatvio_app/features/diary/presentation/widgets/wakeup_and_retire_widget.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -49,7 +50,12 @@ class UserDiaryScreen extends StatelessWidget {
}
class DiaryScreenContent extends StatefulWidget {
const DiaryScreenContent({super.key, this.diaryId, this.date, this.ownerSlug});
const DiaryScreenContent({
super.key,
this.diaryId,
this.date,
this.ownerSlug,
});
final int? diaryId;
final DateTime? date;
final String? ownerSlug;
@@ -60,9 +66,14 @@ class DiaryScreenContent extends StatefulWidget {
class _DiaryScreenContentState extends State<DiaryScreenContent> {
bool _isDiaryFinishing = false;
/// Slug владельца: из `user.slug` дневника, иначе из параметра экрана.
String? _ownerSlug(DiaryStateLoaded state) {
return state.diary.ownerSlug ?? widget.ownerSlug;
}
/// Консультант видит аналитику клиента только при известном slug владельца.
bool _canShowInsights(DiaryStateLoaded state) {
return !state.isConsultantView || widget.ownerSlug != null;
return !state.isConsultantView || _ownerSlug(state) != null;
}
@override
@@ -122,9 +133,9 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
],
if (state is DiaryStateLoaded && _canShowInsights(state))
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: InsightsCard(
slug: state.isConsultantView ? widget.ownerSlug : null,
padding: const EdgeInsets.fromLTRB(4, 12, 4, 12),
child: _DiaryInsights(
slug: state.isConsultantView ? _ownerSlug(state) : null,
canAdd: !state.isConsultantView,
),
),
@@ -267,3 +278,45 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
);
}
}
/// Блок «Аналитика» в дневнике. Для своего дневника перезапрашивает данные,
/// когда в том же дне меняются приёмы пищи или время подъёма/отбоя: от них
/// зависят КБЖУ-графики. Чужой дневник консультант не редактирует.
class _DiaryInsights extends StatefulWidget {
const _DiaryInsights({required this.slug, required this.canAdd});
final String? slug;
final bool canAdd;
@override
State<_DiaryInsights> createState() => _DiaryInsightsState();
}
class _DiaryInsightsState extends State<_DiaryInsights> {
int _refreshToken = 0;
bool _isOwnDiaryMutation(DiaryState previous, DiaryState current) {
if (!widget.canAdd ||
previous is! DiaryStateLoaded ||
current is! DiaryStateLoaded ||
previous.diary.id != current.diary.id) {
return false;
}
return !listEquals(previous.diary.meals, current.diary.meals) ||
previous.diary.wakeup != current.diary.wakeup ||
previous.diary.retire != current.diary.retire;
}
@override
Widget build(BuildContext context) {
return BlocListener<DiaryCubit, DiaryState>(
listenWhen: _isOwnDiaryMutation,
listener: (context, state) => setState(() => _refreshToken++),
child: InsightsCard(
slug: widget.slug,
canAdd: widget.canAdd,
refreshToken: _refreshToken,
),
);
}
}

View File

@@ -1,6 +1,8 @@
/// Флаги поэтапного включения аналитики.
abstract final class InsightsFeatureFlags {
/// Вкладки «КБЖУ по дням» и «В течение дня» требуют бэкенд-эндпоинта
/// `GET /api/profile/insights` и включаются во второй итерации.
static const bool kbjuEnabled = false;
/// Вкладки «КБЖУ по дням» и «В течение дня». При `true` весь блок берёт
/// данные и тексты с сервера (`GET /api/profile/insights*`, eatvio/eatvio#3017),
/// при `false` — вкладки скрыты, а вес/ИМТ/замеры собираются на клиенте из
/// `/api/profile/measure/*`.
static const bool kbjuEnabled = true;
}

View File

@@ -0,0 +1,36 @@
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'insights_response_model.freezed.dart';
part 'insights_response_model.g.dart';
/// Ответ `GET /api/profile/insights[/{slug}]`: `{ "data": { weight, bmi, measure, daily, intra } }`.
@freezed
abstract class InsightsResponseModel with _$InsightsResponseModel {
const factory InsightsResponseModel({required InsightsPayloadModel data}) =
_InsightsResponseModel;
factory InsightsResponseModel.fromJson(Map<String, dynamic> json) =>
_$InsightsResponseModelFromJson(json);
}
/// Ответ `GET /api/profile/insights/intra` и `/insights/{slug}/intra`:
/// `{ "data": { "intra": { ... } } }`.
@freezed
abstract class InsightsIntraResponseModel with _$InsightsIntraResponseModel {
const factory InsightsIntraResponseModel({
required InsightsIntraDataModel data,
}) = _InsightsIntraResponseModel;
factory InsightsIntraResponseModel.fromJson(Map<String, dynamic> json) =>
_$InsightsIntraResponseModelFromJson(json);
}
@freezed
abstract class InsightsIntraDataModel with _$InsightsIntraDataModel {
const factory InsightsIntraDataModel({required IntraChartModel intra}) =
_InsightsIntraDataModel;
factory InsightsIntraDataModel.fromJson(Map<String, dynamic> json) =>
_$InsightsIntraDataModelFromJson(json);
}

View File

@@ -2,6 +2,7 @@ import 'dart:developer';
import 'package:eatvio_app/core/error_reporting/report_from.dart';
import 'package:eatvio_app/features/insights/insights_feature_flags.dart';
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
import 'package:eatvio_app/features/insights/models/insights_period.dart';
import 'package:eatvio_app/features/insights/presentation/cubit/insights_panel.dart';
import 'package:eatvio_app/features/insights/presentation/cubit/insights_state.dart';
@@ -35,17 +36,30 @@ class InsightsCubit extends Cubit<InsightsState> {
Future<void> load() async {
emit(const InsightsState.loading());
await _fetch(bodyPeriod: BodyPeriod.threeMonths, previous: null);
await _fetch(
bodyPeriod: BodyPeriod.threeMonths,
dailyPeriod: DailyPeriod.twoWeeks,
intraDate: null,
previous: null,
);
}
/// Перезапрашивает данные, сохраняя вкладку, период, серии и раскрытие.
Future<void> refresh() async {
final current = state;
if (current is InsightsStateLoading || current is InsightsStateHidden) {
return;
}
if (current is! InsightsStateLoaded) {
return load();
}
emit(current.copyWith(isRefreshing: true));
await _fetch(bodyPeriod: current.bodyPeriod, previous: current);
await _fetch(
bodyPeriod: current.bodyPeriod,
dailyPeriod: current.dailyPeriod,
intraDate: current.intraDate,
previous: current,
);
}
Future<void> setBodyPeriod(BodyPeriod period) async {
@@ -54,7 +68,100 @@ class InsightsCubit extends Cubit<InsightsState> {
return;
}
emit(current.copyWith(bodyPeriod: period, isRefreshing: true));
await _fetch(bodyPeriod: period, previous: current);
await _fetch(
bodyPeriod: period,
dailyPeriod: current.dailyPeriod,
intraDate: current.intraDate,
previous: current,
);
}
/// Период вкладки «КБЖУ по дням». Работает только с серверными данными.
Future<void> setDailyPeriod(DailyPeriod period) async {
final current = state;
if (!kbjuEnabled ||
current is! InsightsStateLoaded ||
current.dailyPeriod == period) {
return;
}
emit(current.copyWith(dailyPeriod: period, isRefreshing: true));
await _fetch(
bodyPeriod: current.bodyPeriod,
dailyPeriod: period,
intraDate: current.intraDate,
previous: current,
);
}
/// Показывает вкладку «В течение дня» за [day] (запрашивается только она).
Future<void> setIntraDate(DateTime day) {
return _loadIntra(_formatDate(day));
}
Future<void> showPreviousDay() async {
final base = _currentIntraDay();
if (base != null) {
await setIntraDate(DateTime(base.year, base.month, base.day - 1));
}
}
Future<void> showNextDay() async {
final current = state;
final base = _currentIntraDay();
if (base == null ||
current is! InsightsStateLoaded ||
(current.payload.intra?.isToday ?? true)) {
return;
}
await setIntraDate(DateTime(base.year, base.month, base.day + 1));
}
DateTime? _currentIntraDay() {
final current = state;
if (!kbjuEnabled || current is! InsightsStateLoaded) {
return null;
}
return DateTime.tryParse(current.payload.intra?.date ?? '');
}
Future<void> _loadIntra(String date) async {
final current = state;
if (!kbjuEnabled || current is! InsightsStateLoaded) {
return;
}
final requestId = ++_requestId;
emit(current.copyWith(isRefreshing: true));
try {
final intra = await repository.fetchIntra(slug: slug, intraDate: date);
if (requestId != _requestId || isClosed) {
return;
}
emit(
current.copyWith(
payload: current.payload.copyWith(intra: intra),
intraDate: intra.isToday
? null
: (intra.date.isEmpty ? date : intra.date),
isRefreshing: false,
),
);
} on InsightsForbidden {
if (requestId == _requestId && !isClosed) {
emit(const InsightsState.hidden());
}
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString(), stackTrace: st);
if (requestId == _requestId && !isClosed) {
emit(current.copyWith(isRefreshing: false));
}
}
}
static String _formatDate(DateTime day) {
final month = day.month.toString().padLeft(2, '0');
final dayOfMonth = day.day.toString().padLeft(2, '0');
return '${day.year.toString().padLeft(4, '0')}-$month-$dayOfMonth';
}
void toggleExpanded() {
@@ -136,30 +243,59 @@ class InsightsCubit extends Cubit<InsightsState> {
return true;
}
Future<InsightsPayloadModel> _requestPayload({
required BodyPeriod bodyPeriod,
required DailyPeriod dailyPeriod,
required String? intraDate,
}) async {
if (kbjuEnabled) {
return repository.fetchInsights(
slug: slug,
bodyPeriod: bodyPeriod,
dailyPeriod: dailyPeriod,
intraDate: intraDate,
);
}
if (!_isHeightLoaded) {
_heightCm = await repository.getHeightCm(slug: slug);
_isHeightLoaded = true;
}
return repository.getInsights(
slug: slug,
bodyPeriod: bodyPeriod,
heightCm: _heightCm,
);
}
Future<void> _fetch({
required BodyPeriod bodyPeriod,
required DailyPeriod dailyPeriod,
required String? intraDate,
required InsightsStateLoaded? previous,
}) async {
final requestId = ++_requestId;
try {
if (!_isHeightLoaded) {
_heightCm = await repository.getHeightCm(slug: slug);
_isHeightLoaded = true;
}
final payload = await repository.getInsights(
slug: slug,
final payload = await _requestPayload(
bodyPeriod: bodyPeriod,
heightCm: _heightCm,
dailyPeriod: dailyPeriod,
intraDate: intraDate,
);
if (requestId != _requestId || isClosed) {
return;
}
emit(
previous == null
? InsightsState.loaded(payload: payload, bodyPeriod: bodyPeriod)
? InsightsState.loaded(
payload: payload,
bodyPeriod: bodyPeriod,
dailyPeriod: dailyPeriod,
intraDate: intraDate,
)
: previous.copyWith(
payload: payload,
bodyPeriod: bodyPeriod,
dailyPeriod: dailyPeriod,
intraDate: intraDate,
isRefreshing: false,
),
);

View File

@@ -35,6 +35,10 @@ abstract class InsightsState with _$InsightsState {
const factory InsightsState.loaded({
required InsightsPayloadModel payload,
@Default(BodyPeriod.threeMonths) BodyPeriod bodyPeriod,
@Default(DailyPeriod.twoWeeks) DailyPeriod dailyPeriod,
/// День вкладки «В течение дня» (`YYYY-MM-DD`); `null` — сегодня.
String? intraDate,
@Default(InsightsPanel.weight) InsightsPanel panel,
@Default(InsightsSeriesVisibility())
InsightsSeriesVisibility seriesVisibility,

View File

@@ -37,6 +37,7 @@ class InsightsCard extends StatelessWidget {
this.canAdd = false,
this.repository,
this.kbjuEnabled = InsightsFeatureFlags.kbjuEnabled,
this.refreshToken = 0,
});
final String? slug;
@@ -44,6 +45,10 @@ class InsightsCard extends StatelessWidget {
final InsightsRepository? repository;
final bool kbjuEnabled;
/// При смене значения блок перезапрашивает данные (например, после правок
/// в дневнике), сохраняя вкладку, период и выбранный день.
final int refreshToken;
@override
Widget build(BuildContext context) {
return KeyedSubtree(
@@ -55,14 +60,29 @@ class InsightsCard extends StatelessWidget {
canAdd: canAdd,
kbjuEnabled: kbjuEnabled,
)..load(),
child: const _InsightsView(),
child: _InsightsView(refreshToken: refreshToken),
),
);
}
}
class _InsightsView extends StatelessWidget {
const _InsightsView();
class _InsightsView extends StatefulWidget {
const _InsightsView({required this.refreshToken});
final int refreshToken;
@override
State<_InsightsView> createState() => _InsightsViewState();
}
class _InsightsViewState extends State<_InsightsView> {
@override
void didUpdateWidget(covariant _InsightsView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.refreshToken != widget.refreshToken) {
context.read<InsightsCubit>().refresh();
}
}
@override
Widget build(BuildContext context) {
@@ -113,7 +133,7 @@ class _ErrorContent extends StatelessWidget {
Widget build(BuildContext context) {
final s = S.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Expanded(
@@ -158,9 +178,9 @@ class _LoadedContent extends StatelessWidget {
child: state.isExpanded
? Padding(
padding: EdgeInsets.fromLTRB(
20,
16,
0,
20,
16,
showFab ? 24 + 44 : 24,
),
child: _Body(state: state),
@@ -241,7 +261,7 @@ class _Toggle extends StatelessWidget {
borderRadius: BorderRadius.circular(8),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Row(
children: [
Container(
@@ -443,8 +463,7 @@ class _PanelContent extends StatelessWidget {
}
}
/// «КБЖУ по дням». Период пока фиксирован: перезапрос появится вместе с
/// эндпоинтом `GET /api/profile/insights` (итерация 2).
/// «КБЖУ по дням». Смена периода перезапрашивает `GET /api/profile/insights`.
class _DailyPanel extends StatelessWidget {
const _DailyPanel({required this.state});
@@ -462,9 +481,10 @@ class _DailyPanel extends StatelessWidget {
children: [
PeriodPills<DailyPeriod>(
periods: DailyPeriod.values,
selected: DailyPeriod.twoWeeks,
selected: state.dailyPeriod,
labelOf: (period) => period.pillLabel(s),
keyOf: (period) => period.apiValue,
onSelected: cubit.setDailyPeriod,
),
const SizedBox(height: 16),
if (daily == null)
@@ -474,7 +494,7 @@ class _DailyPanel extends StatelessWidget {
headline: daily.headline,
unit: s.insights_unit_kcal_avg,
deltaText: daily.deltaText,
isDeltaHighlighted: daily.percent != null,
isDeltaHighlighted: (daily.percent ?? 0) != 0,
),
const SizedBox(height: 12),
SeriesChips(
@@ -510,7 +530,7 @@ class _DailyPanel extends StatelessWidget {
}
}
/// «В течение дня». Навигация по датам неактивна до итерации 2.
/// «В течение дня». Листание дней запрашивает только `/insights/intra`.
class _IntraPanel extends StatelessWidget {
const _IntraPanel({required this.state});
@@ -530,7 +550,12 @@ class _IntraPanel extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IntraDateNav(model: intra),
IntraDateNav(
model: intra,
onPreviousDay: cubit.showPreviousDay,
onNextDay: cubit.showNextDay,
onDatePicked: cubit.setIntraDate,
),
const SizedBox(height: 16),
SeriesChips(
chips: [

View File

@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
import 'package:eatvio_app/features/insights/domain/insights_text_builder.dart';
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
import 'package:eatvio_app/features/insights/models/insights_period.dart';
import 'package:eatvio_app/features/insights/services/insights_service.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:eatvio_app/generated/l10n.dart';
@@ -16,17 +17,21 @@ class InsightsForbidden implements Exception {
typedef InsightsTextBuilderFactory = InsightsTextBuilder Function();
/// Итерация 1: собирает [InsightsPayloadModel] из существующих эндпоинтов
/// профиля. Во второй итерации источник заменяется одним запросом
/// `GET /api/profile/insights`.
/// Источники данных блока «Аналитика»:
/// - [fetchInsights] / [fetchIntra] — серверные `GET /api/profile/insights*`
/// (графики и тексты считает `UserInsightsService`, включая КБЖУ);
/// - [getInsights] — собирает payload на клиенте из эндпоинтов веса и замеров
/// (используется, пока выключен `InsightsFeatureFlags.kbjuEnabled`).
class InsightsRepository {
InsightsRepository({
required this.profileRepository,
required this.insightsService,
InsightsTextBuilderFactory? textBuilderFactory,
}) : _textBuilderFactory =
textBuilderFactory ?? (() => InsightsTextBuilder(S.current));
final ProfileRepository profileRepository;
final InsightsService insightsService;
final InsightsTextBuilderFactory _textBuilderFactory;
/// Рост в сантиметрах для расчёта подсказки ИМТ; `null`, если не указан или
@@ -70,6 +75,49 @@ class InsightsRepository {
}
}
/// Полный payload с сервера. [intraDate] — `YYYY-MM-DD`, `null` — сегодня.
Future<InsightsPayloadModel> fetchInsights({
String? slug,
required BodyPeriod bodyPeriod,
required DailyPeriod dailyPeriod,
String? intraDate,
}) async {
try {
final response = slug == null
? await insightsService.getInsights(
bodyPeriod: bodyPeriod.apiValue,
dailyPeriod: dailyPeriod.apiValue,
intraDate: intraDate,
)
: await insightsService.getInsightsBySlug(
slug: slug,
bodyPeriod: bodyPeriod.apiValue,
dailyPeriod: dailyPeriod.apiValue,
intraDate: intraDate,
);
return response.data;
} on DioException catch (e) {
_throwIfForbidden(e);
rethrow;
}
}
/// Только вкладка «В течение дня» за [intraDate] (`YYYY-MM-DD`).
Future<IntraChartModel> fetchIntra({String? slug, String? intraDate}) async {
try {
final response = slug == null
? await insightsService.getIntra(intraDate: intraDate)
: await insightsService.getIntraBySlug(
slug: slug,
intraDate: intraDate,
);
return response.data.intra;
} on DioException catch (e) {
_throwIfForbidden(e);
rethrow;
}
}
Future<void> addWeight({required double weight}) {
return profileRepository.addWeight(weight: weight);
}

View File

@@ -0,0 +1,43 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/features/insights/models/insights_response_model.dart';
import 'package:retrofit/retrofit.dart';
part 'insights_service.g.dart';
/// Эндпоинты инсайтов (eatvio/eatvio#3017). Сервер приводит будущую
/// `intra_date` к сегодняшней, неверные значения периода дают 422, чужой
/// недоступный профиль — 403.
@RestApi()
abstract class InsightsService {
factory InsightsService(Dio dio, {String baseUrl}) = _InsightsService;
/// Свои графики целиком.
@GET('/api/profile/insights')
Future<InsightsResponseModel> getInsights({
@Query('body_period') String? bodyPeriod,
@Query('daily_period') String? dailyPeriod,
@Query('intra_date') String? intraDate,
});
/// Чужие графики целиком (клиент консультанта, публичный профиль).
@GET('/api/profile/insights/{slug}')
Future<InsightsResponseModel> getInsightsBySlug({
@Path() required String slug,
@Query('body_period') String? bodyPeriod,
@Query('daily_period') String? dailyPeriod,
@Query('intra_date') String? intraDate,
});
/// Только «в течение дня» для себя.
@GET('/api/profile/insights/intra')
Future<InsightsIntraResponseModel> getIntra({
@Query('intra_date') String? intraDate,
});
/// Только «в течение дня» для другого пользователя.
@GET('/api/profile/insights/{slug}/intra')
Future<InsightsIntraResponseModel> getIntraBySlug({
@Path() required String slug,
@Query('intra_date') String? intraDate,
});
}

View File

@@ -260,6 +260,14 @@ class _ProfileScreenState extends State<ProfileScreen> {
padding: const EdgeInsets.only(bottom: 16),
child: _badges(state),
),
// Доступ решает сервер: консультант видит клиента, пока консультация
// не завершена, остальные — только публичные профили; иначе 403 и блок скрыт.
if (!state.profile.isBlockedByMe &&
!state.profile.isBlockedByAuthor)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: InsightsCard(slug: state.profile.slug),
),
if (state.profile.isBlockedByAuthor)
Column(
mainAxisAlignment: MainAxisAlignment.center,