forked from eatvio/eatvio_app
feat(insights): added kbju fetching for analitics
This commit is contained in:
@@ -17,6 +17,9 @@ abstract class DiaryDayEntity with _$DiaryDayEntity {
|
||||
@JsonKey(name: "chosen_plan_id") required int? chosenPlanId,
|
||||
@JsonKey(name: "day_of_chosen_diet") required int? dayOfChosenDiet,
|
||||
@JsonKey(name: "chosen_diet", fromJson: parseDiet) required DiaryDietModel? diet,
|
||||
|
||||
/// Slug владельца дневника; `null`, если сервер его не вернул.
|
||||
String? ownerSlug,
|
||||
}) = _DiaryDayEntity;
|
||||
|
||||
const DiaryDayEntity._();
|
||||
|
||||
@@ -48,6 +48,10 @@ abstract class DiaryDayModel with _$DiaryDayModel {
|
||||
@JsonKey(name: "chosen_plan_id") required int? chosenPlanId,
|
||||
@JsonKey(name: "day_of_chosen_diet") required int? dayOfChosenDiet,
|
||||
@JsonKey(name: "chosen_diet", fromJson: parseDiet) required DiaryDietModel? diet,
|
||||
|
||||
/// Владелец дневника (`user` в `DiaryResource`): по `slug` консультант
|
||||
/// запрашивает `/api/profile/insights/{slug}`.
|
||||
@JsonKey(name: "user") DiaryOwnerModel? owner,
|
||||
}) = _DiaryDayModel;
|
||||
const DiaryDayModel._();
|
||||
factory DiaryDayModel.fromJson(Map<String, dynamic> json) => _$DiaryDayModelFromJson(json);
|
||||
@@ -68,6 +72,7 @@ abstract class DiaryDayModel with _$DiaryDayModel {
|
||||
wakeup: wakeup,
|
||||
retire: retire,
|
||||
diet: diet,
|
||||
ownerSlug: owner?.slug,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,6 +86,13 @@ DiaryDietModel? parseDiet(dynamic json) {
|
||||
return DiaryDietModel.fromJson(json['diet']);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class DiaryOwnerModel with _$DiaryOwnerModel {
|
||||
const factory DiaryOwnerModel({int? id, String? slug}) = _DiaryOwnerModel;
|
||||
|
||||
factory DiaryOwnerModel.fromJson(Map<String, dynamic> json) => _$DiaryOwnerModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class DiaryDietModel with _$DiaryDietModel {
|
||||
const factory DiaryDietModel({required int id, required int duration, required String name}) = _DiaryDietModel;
|
||||
|
||||
@@ -22,6 +22,7 @@ import 'package:eatvio_app/features/diary/presentation/widgets/meal_widget.dart'
|
||||
import 'package:eatvio_app/features/diary/presentation/widgets/wakeup_and_retire_widget.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
@@ -49,7 +50,12 @@ class UserDiaryScreen extends StatelessWidget {
|
||||
}
|
||||
|
||||
class DiaryScreenContent extends StatefulWidget {
|
||||
const DiaryScreenContent({super.key, this.diaryId, this.date, this.ownerSlug});
|
||||
const DiaryScreenContent({
|
||||
super.key,
|
||||
this.diaryId,
|
||||
this.date,
|
||||
this.ownerSlug,
|
||||
});
|
||||
final int? diaryId;
|
||||
final DateTime? date;
|
||||
final String? ownerSlug;
|
||||
@@ -60,9 +66,14 @@ class DiaryScreenContent extends StatefulWidget {
|
||||
class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
bool _isDiaryFinishing = false;
|
||||
|
||||
/// Slug владельца: из `user.slug` дневника, иначе из параметра экрана.
|
||||
String? _ownerSlug(DiaryStateLoaded state) {
|
||||
return state.diary.ownerSlug ?? widget.ownerSlug;
|
||||
}
|
||||
|
||||
/// Консультант видит аналитику клиента только при известном slug владельца.
|
||||
bool _canShowInsights(DiaryStateLoaded state) {
|
||||
return !state.isConsultantView || widget.ownerSlug != null;
|
||||
return !state.isConsultantView || _ownerSlug(state) != null;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -122,9 +133,9 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
],
|
||||
if (state is DiaryStateLoaded && _canShowInsights(state))
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: InsightsCard(
|
||||
slug: state.isConsultantView ? widget.ownerSlug : null,
|
||||
padding: const EdgeInsets.fromLTRB(4, 12, 4, 12),
|
||||
child: _DiaryInsights(
|
||||
slug: state.isConsultantView ? _ownerSlug(state) : null,
|
||||
canAdd: !state.isConsultantView,
|
||||
),
|
||||
),
|
||||
@@ -267,3 +278,45 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Блок «Аналитика» в дневнике. Для своего дневника перезапрашивает данные,
|
||||
/// когда в том же дне меняются приёмы пищи или время подъёма/отбоя: от них
|
||||
/// зависят КБЖУ-графики. Чужой дневник консультант не редактирует.
|
||||
class _DiaryInsights extends StatefulWidget {
|
||||
const _DiaryInsights({required this.slug, required this.canAdd});
|
||||
|
||||
final String? slug;
|
||||
final bool canAdd;
|
||||
|
||||
@override
|
||||
State<_DiaryInsights> createState() => _DiaryInsightsState();
|
||||
}
|
||||
|
||||
class _DiaryInsightsState extends State<_DiaryInsights> {
|
||||
int _refreshToken = 0;
|
||||
|
||||
bool _isOwnDiaryMutation(DiaryState previous, DiaryState current) {
|
||||
if (!widget.canAdd ||
|
||||
previous is! DiaryStateLoaded ||
|
||||
current is! DiaryStateLoaded ||
|
||||
previous.diary.id != current.diary.id) {
|
||||
return false;
|
||||
}
|
||||
return !listEquals(previous.diary.meals, current.diary.meals) ||
|
||||
previous.diary.wakeup != current.diary.wakeup ||
|
||||
previous.diary.retire != current.diary.retire;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<DiaryCubit, DiaryState>(
|
||||
listenWhen: _isOwnDiaryMutation,
|
||||
listener: (context, state) => setState(() => _refreshToken++),
|
||||
child: InsightsCard(
|
||||
slug: widget.slug,
|
||||
canAdd: widget.canAdd,
|
||||
refreshToken: _refreshToken,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/// Флаги поэтапного включения аналитики.
|
||||
abstract final class InsightsFeatureFlags {
|
||||
/// Вкладки «КБЖУ по дням» и «В течение дня» требуют бэкенд-эндпоинта
|
||||
/// `GET /api/profile/insights` и включаются во второй итерации.
|
||||
static const bool kbjuEnabled = false;
|
||||
/// Вкладки «КБЖУ по дням» и «В течение дня». При `true` весь блок берёт
|
||||
/// данные и тексты с сервера (`GET /api/profile/insights*`, eatvio/eatvio#3017),
|
||||
/// при `false` — вкладки скрыты, а вес/ИМТ/замеры собираются на клиенте из
|
||||
/// `/api/profile/measure/*`.
|
||||
static const bool kbjuEnabled = true;
|
||||
}
|
||||
|
||||
36
lib/features/insights/models/insights_response_model.dart
Normal file
36
lib/features/insights/models/insights_response_model.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'insights_response_model.freezed.dart';
|
||||
part 'insights_response_model.g.dart';
|
||||
|
||||
/// Ответ `GET /api/profile/insights[/{slug}]`: `{ "data": { weight, bmi, measure, daily, intra } }`.
|
||||
@freezed
|
||||
abstract class InsightsResponseModel with _$InsightsResponseModel {
|
||||
const factory InsightsResponseModel({required InsightsPayloadModel data}) =
|
||||
_InsightsResponseModel;
|
||||
|
||||
factory InsightsResponseModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$InsightsResponseModelFromJson(json);
|
||||
}
|
||||
|
||||
/// Ответ `GET /api/profile/insights/intra` и `/insights/{slug}/intra`:
|
||||
/// `{ "data": { "intra": { ... } } }`.
|
||||
@freezed
|
||||
abstract class InsightsIntraResponseModel with _$InsightsIntraResponseModel {
|
||||
const factory InsightsIntraResponseModel({
|
||||
required InsightsIntraDataModel data,
|
||||
}) = _InsightsIntraResponseModel;
|
||||
|
||||
factory InsightsIntraResponseModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$InsightsIntraResponseModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class InsightsIntraDataModel with _$InsightsIntraDataModel {
|
||||
const factory InsightsIntraDataModel({required IntraChartModel intra}) =
|
||||
_InsightsIntraDataModel;
|
||||
|
||||
factory InsightsIntraDataModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$InsightsIntraDataModelFromJson(json);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'dart:developer';
|
||||
|
||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
import 'package:eatvio_app/features/insights/insights_feature_flags.dart';
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/cubit/insights_panel.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/cubit/insights_state.dart';
|
||||
@@ -35,17 +36,30 @@ class InsightsCubit extends Cubit<InsightsState> {
|
||||
|
||||
Future<void> load() async {
|
||||
emit(const InsightsState.loading());
|
||||
await _fetch(bodyPeriod: BodyPeriod.threeMonths, previous: null);
|
||||
await _fetch(
|
||||
bodyPeriod: BodyPeriod.threeMonths,
|
||||
dailyPeriod: DailyPeriod.twoWeeks,
|
||||
intraDate: null,
|
||||
previous: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Перезапрашивает данные, сохраняя вкладку, период, серии и раскрытие.
|
||||
Future<void> refresh() async {
|
||||
final current = state;
|
||||
if (current is InsightsStateLoading || current is InsightsStateHidden) {
|
||||
return;
|
||||
}
|
||||
if (current is! InsightsStateLoaded) {
|
||||
return load();
|
||||
}
|
||||
emit(current.copyWith(isRefreshing: true));
|
||||
await _fetch(bodyPeriod: current.bodyPeriod, previous: current);
|
||||
await _fetch(
|
||||
bodyPeriod: current.bodyPeriod,
|
||||
dailyPeriod: current.dailyPeriod,
|
||||
intraDate: current.intraDate,
|
||||
previous: current,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setBodyPeriod(BodyPeriod period) async {
|
||||
@@ -54,7 +68,100 @@ class InsightsCubit extends Cubit<InsightsState> {
|
||||
return;
|
||||
}
|
||||
emit(current.copyWith(bodyPeriod: period, isRefreshing: true));
|
||||
await _fetch(bodyPeriod: period, previous: current);
|
||||
await _fetch(
|
||||
bodyPeriod: period,
|
||||
dailyPeriod: current.dailyPeriod,
|
||||
intraDate: current.intraDate,
|
||||
previous: current,
|
||||
);
|
||||
}
|
||||
|
||||
/// Период вкладки «КБЖУ по дням». Работает только с серверными данными.
|
||||
Future<void> setDailyPeriod(DailyPeriod period) async {
|
||||
final current = state;
|
||||
if (!kbjuEnabled ||
|
||||
current is! InsightsStateLoaded ||
|
||||
current.dailyPeriod == period) {
|
||||
return;
|
||||
}
|
||||
emit(current.copyWith(dailyPeriod: period, isRefreshing: true));
|
||||
await _fetch(
|
||||
bodyPeriod: current.bodyPeriod,
|
||||
dailyPeriod: period,
|
||||
intraDate: current.intraDate,
|
||||
previous: current,
|
||||
);
|
||||
}
|
||||
|
||||
/// Показывает вкладку «В течение дня» за [day] (запрашивается только она).
|
||||
Future<void> setIntraDate(DateTime day) {
|
||||
return _loadIntra(_formatDate(day));
|
||||
}
|
||||
|
||||
Future<void> showPreviousDay() async {
|
||||
final base = _currentIntraDay();
|
||||
if (base != null) {
|
||||
await setIntraDate(DateTime(base.year, base.month, base.day - 1));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showNextDay() async {
|
||||
final current = state;
|
||||
final base = _currentIntraDay();
|
||||
if (base == null ||
|
||||
current is! InsightsStateLoaded ||
|
||||
(current.payload.intra?.isToday ?? true)) {
|
||||
return;
|
||||
}
|
||||
await setIntraDate(DateTime(base.year, base.month, base.day + 1));
|
||||
}
|
||||
|
||||
DateTime? _currentIntraDay() {
|
||||
final current = state;
|
||||
if (!kbjuEnabled || current is! InsightsStateLoaded) {
|
||||
return null;
|
||||
}
|
||||
return DateTime.tryParse(current.payload.intra?.date ?? '');
|
||||
}
|
||||
|
||||
Future<void> _loadIntra(String date) async {
|
||||
final current = state;
|
||||
if (!kbjuEnabled || current is! InsightsStateLoaded) {
|
||||
return;
|
||||
}
|
||||
final requestId = ++_requestId;
|
||||
emit(current.copyWith(isRefreshing: true));
|
||||
try {
|
||||
final intra = await repository.fetchIntra(slug: slug, intraDate: date);
|
||||
if (requestId != _requestId || isClosed) {
|
||||
return;
|
||||
}
|
||||
emit(
|
||||
current.copyWith(
|
||||
payload: current.payload.copyWith(intra: intra),
|
||||
intraDate: intra.isToday
|
||||
? null
|
||||
: (intra.date.isEmpty ? date : intra.date),
|
||||
isRefreshing: false,
|
||||
),
|
||||
);
|
||||
} on InsightsForbidden {
|
||||
if (requestId == _requestId && !isClosed) {
|
||||
emit(const InsightsState.hidden());
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString(), stackTrace: st);
|
||||
if (requestId == _requestId && !isClosed) {
|
||||
emit(current.copyWith(isRefreshing: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static String _formatDate(DateTime day) {
|
||||
final month = day.month.toString().padLeft(2, '0');
|
||||
final dayOfMonth = day.day.toString().padLeft(2, '0');
|
||||
return '${day.year.toString().padLeft(4, '0')}-$month-$dayOfMonth';
|
||||
}
|
||||
|
||||
void toggleExpanded() {
|
||||
@@ -136,30 +243,59 @@ class InsightsCubit extends Cubit<InsightsState> {
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<InsightsPayloadModel> _requestPayload({
|
||||
required BodyPeriod bodyPeriod,
|
||||
required DailyPeriod dailyPeriod,
|
||||
required String? intraDate,
|
||||
}) async {
|
||||
if (kbjuEnabled) {
|
||||
return repository.fetchInsights(
|
||||
slug: slug,
|
||||
bodyPeriod: bodyPeriod,
|
||||
dailyPeriod: dailyPeriod,
|
||||
intraDate: intraDate,
|
||||
);
|
||||
}
|
||||
if (!_isHeightLoaded) {
|
||||
_heightCm = await repository.getHeightCm(slug: slug);
|
||||
_isHeightLoaded = true;
|
||||
}
|
||||
return repository.getInsights(
|
||||
slug: slug,
|
||||
bodyPeriod: bodyPeriod,
|
||||
heightCm: _heightCm,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _fetch({
|
||||
required BodyPeriod bodyPeriod,
|
||||
required DailyPeriod dailyPeriod,
|
||||
required String? intraDate,
|
||||
required InsightsStateLoaded? previous,
|
||||
}) async {
|
||||
final requestId = ++_requestId;
|
||||
try {
|
||||
if (!_isHeightLoaded) {
|
||||
_heightCm = await repository.getHeightCm(slug: slug);
|
||||
_isHeightLoaded = true;
|
||||
}
|
||||
final payload = await repository.getInsights(
|
||||
slug: slug,
|
||||
final payload = await _requestPayload(
|
||||
bodyPeriod: bodyPeriod,
|
||||
heightCm: _heightCm,
|
||||
dailyPeriod: dailyPeriod,
|
||||
intraDate: intraDate,
|
||||
);
|
||||
if (requestId != _requestId || isClosed) {
|
||||
return;
|
||||
}
|
||||
emit(
|
||||
previous == null
|
||||
? InsightsState.loaded(payload: payload, bodyPeriod: bodyPeriod)
|
||||
? InsightsState.loaded(
|
||||
payload: payload,
|
||||
bodyPeriod: bodyPeriod,
|
||||
dailyPeriod: dailyPeriod,
|
||||
intraDate: intraDate,
|
||||
)
|
||||
: previous.copyWith(
|
||||
payload: payload,
|
||||
bodyPeriod: bodyPeriod,
|
||||
dailyPeriod: dailyPeriod,
|
||||
intraDate: intraDate,
|
||||
isRefreshing: false,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -35,6 +35,10 @@ abstract class InsightsState with _$InsightsState {
|
||||
const factory InsightsState.loaded({
|
||||
required InsightsPayloadModel payload,
|
||||
@Default(BodyPeriod.threeMonths) BodyPeriod bodyPeriod,
|
||||
@Default(DailyPeriod.twoWeeks) DailyPeriod dailyPeriod,
|
||||
|
||||
/// День вкладки «В течение дня» (`YYYY-MM-DD`); `null` — сегодня.
|
||||
String? intraDate,
|
||||
@Default(InsightsPanel.weight) InsightsPanel panel,
|
||||
@Default(InsightsSeriesVisibility())
|
||||
InsightsSeriesVisibility seriesVisibility,
|
||||
|
||||
@@ -37,6 +37,7 @@ class InsightsCard extends StatelessWidget {
|
||||
this.canAdd = false,
|
||||
this.repository,
|
||||
this.kbjuEnabled = InsightsFeatureFlags.kbjuEnabled,
|
||||
this.refreshToken = 0,
|
||||
});
|
||||
|
||||
final String? slug;
|
||||
@@ -44,6 +45,10 @@ class InsightsCard extends StatelessWidget {
|
||||
final InsightsRepository? repository;
|
||||
final bool kbjuEnabled;
|
||||
|
||||
/// При смене значения блок перезапрашивает данные (например, после правок
|
||||
/// в дневнике), сохраняя вкладку, период и выбранный день.
|
||||
final int refreshToken;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return KeyedSubtree(
|
||||
@@ -55,14 +60,29 @@ class InsightsCard extends StatelessWidget {
|
||||
canAdd: canAdd,
|
||||
kbjuEnabled: kbjuEnabled,
|
||||
)..load(),
|
||||
child: const _InsightsView(),
|
||||
child: _InsightsView(refreshToken: refreshToken),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InsightsView extends StatelessWidget {
|
||||
const _InsightsView();
|
||||
class _InsightsView extends StatefulWidget {
|
||||
const _InsightsView({required this.refreshToken});
|
||||
|
||||
final int refreshToken;
|
||||
|
||||
@override
|
||||
State<_InsightsView> createState() => _InsightsViewState();
|
||||
}
|
||||
|
||||
class _InsightsViewState extends State<_InsightsView> {
|
||||
@override
|
||||
void didUpdateWidget(covariant _InsightsView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.refreshToken != widget.refreshToken) {
|
||||
context.read<InsightsCubit>().refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -113,7 +133,7 @@ class _ErrorContent extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -158,9 +178,9 @@ class _LoadedContent extends StatelessWidget {
|
||||
child: state.isExpanded
|
||||
? Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
20,
|
||||
16,
|
||||
0,
|
||||
20,
|
||||
16,
|
||||
showFab ? 24 + 44 : 24,
|
||||
),
|
||||
child: _Body(state: state),
|
||||
@@ -241,7 +261,7 @@ class _Toggle extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
@@ -443,8 +463,7 @@ class _PanelContent extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// «КБЖУ по дням». Период пока фиксирован: перезапрос появится вместе с
|
||||
/// эндпоинтом `GET /api/profile/insights` (итерация 2).
|
||||
/// «КБЖУ по дням». Смена периода перезапрашивает `GET /api/profile/insights`.
|
||||
class _DailyPanel extends StatelessWidget {
|
||||
const _DailyPanel({required this.state});
|
||||
|
||||
@@ -462,9 +481,10 @@ class _DailyPanel extends StatelessWidget {
|
||||
children: [
|
||||
PeriodPills<DailyPeriod>(
|
||||
periods: DailyPeriod.values,
|
||||
selected: DailyPeriod.twoWeeks,
|
||||
selected: state.dailyPeriod,
|
||||
labelOf: (period) => period.pillLabel(s),
|
||||
keyOf: (period) => period.apiValue,
|
||||
onSelected: cubit.setDailyPeriod,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (daily == null)
|
||||
@@ -474,7 +494,7 @@ class _DailyPanel extends StatelessWidget {
|
||||
headline: daily.headline,
|
||||
unit: s.insights_unit_kcal_avg,
|
||||
deltaText: daily.deltaText,
|
||||
isDeltaHighlighted: daily.percent != null,
|
||||
isDeltaHighlighted: (daily.percent ?? 0) != 0,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SeriesChips(
|
||||
@@ -510,7 +530,7 @@ class _DailyPanel extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// «В течение дня». Навигация по датам неактивна до итерации 2.
|
||||
/// «В течение дня». Листание дней запрашивает только `/insights/intra`.
|
||||
class _IntraPanel extends StatelessWidget {
|
||||
const _IntraPanel({required this.state});
|
||||
|
||||
@@ -530,7 +550,12 @@ class _IntraPanel extends StatelessWidget {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
IntraDateNav(model: intra),
|
||||
IntraDateNav(
|
||||
model: intra,
|
||||
onPreviousDay: cubit.showPreviousDay,
|
||||
onNextDay: cubit.showNextDay,
|
||||
onDatePicked: cubit.setIntraDate,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SeriesChips(
|
||||
chips: [
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/features/insights/domain/insights_text_builder.dart';
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||
import 'package:eatvio_app/features/insights/services/insights_service.dart';
|
||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
|
||||
@@ -16,17 +17,21 @@ class InsightsForbidden implements Exception {
|
||||
|
||||
typedef InsightsTextBuilderFactory = InsightsTextBuilder Function();
|
||||
|
||||
/// Итерация 1: собирает [InsightsPayloadModel] из существующих эндпоинтов
|
||||
/// профиля. Во второй итерации источник заменяется одним запросом
|
||||
/// `GET /api/profile/insights`.
|
||||
/// Источники данных блока «Аналитика»:
|
||||
/// - [fetchInsights] / [fetchIntra] — серверные `GET /api/profile/insights*`
|
||||
/// (графики и тексты считает `UserInsightsService`, включая КБЖУ);
|
||||
/// - [getInsights] — собирает payload на клиенте из эндпоинтов веса и замеров
|
||||
/// (используется, пока выключен `InsightsFeatureFlags.kbjuEnabled`).
|
||||
class InsightsRepository {
|
||||
InsightsRepository({
|
||||
required this.profileRepository,
|
||||
required this.insightsService,
|
||||
InsightsTextBuilderFactory? textBuilderFactory,
|
||||
}) : _textBuilderFactory =
|
||||
textBuilderFactory ?? (() => InsightsTextBuilder(S.current));
|
||||
|
||||
final ProfileRepository profileRepository;
|
||||
final InsightsService insightsService;
|
||||
final InsightsTextBuilderFactory _textBuilderFactory;
|
||||
|
||||
/// Рост в сантиметрах для расчёта подсказки ИМТ; `null`, если не указан или
|
||||
@@ -70,6 +75,49 @@ class InsightsRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// Полный payload с сервера. [intraDate] — `YYYY-MM-DD`, `null` — сегодня.
|
||||
Future<InsightsPayloadModel> fetchInsights({
|
||||
String? slug,
|
||||
required BodyPeriod bodyPeriod,
|
||||
required DailyPeriod dailyPeriod,
|
||||
String? intraDate,
|
||||
}) async {
|
||||
try {
|
||||
final response = slug == null
|
||||
? await insightsService.getInsights(
|
||||
bodyPeriod: bodyPeriod.apiValue,
|
||||
dailyPeriod: dailyPeriod.apiValue,
|
||||
intraDate: intraDate,
|
||||
)
|
||||
: await insightsService.getInsightsBySlug(
|
||||
slug: slug,
|
||||
bodyPeriod: bodyPeriod.apiValue,
|
||||
dailyPeriod: dailyPeriod.apiValue,
|
||||
intraDate: intraDate,
|
||||
);
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
_throwIfForbidden(e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Только вкладка «В течение дня» за [intraDate] (`YYYY-MM-DD`).
|
||||
Future<IntraChartModel> fetchIntra({String? slug, String? intraDate}) async {
|
||||
try {
|
||||
final response = slug == null
|
||||
? await insightsService.getIntra(intraDate: intraDate)
|
||||
: await insightsService.getIntraBySlug(
|
||||
slug: slug,
|
||||
intraDate: intraDate,
|
||||
);
|
||||
return response.data.intra;
|
||||
} on DioException catch (e) {
|
||||
_throwIfForbidden(e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addWeight({required double weight}) {
|
||||
return profileRepository.addWeight(weight: weight);
|
||||
}
|
||||
|
||||
43
lib/features/insights/services/insights_service.dart
Normal file
43
lib/features/insights/services/insights_service.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/features/insights/models/insights_response_model.dart';
|
||||
import 'package:retrofit/retrofit.dart';
|
||||
|
||||
part 'insights_service.g.dart';
|
||||
|
||||
/// Эндпоинты инсайтов (eatvio/eatvio#3017). Сервер приводит будущую
|
||||
/// `intra_date` к сегодняшней, неверные значения периода дают 422, чужой
|
||||
/// недоступный профиль — 403.
|
||||
@RestApi()
|
||||
abstract class InsightsService {
|
||||
factory InsightsService(Dio dio, {String baseUrl}) = _InsightsService;
|
||||
|
||||
/// Свои графики целиком.
|
||||
@GET('/api/profile/insights')
|
||||
Future<InsightsResponseModel> getInsights({
|
||||
@Query('body_period') String? bodyPeriod,
|
||||
@Query('daily_period') String? dailyPeriod,
|
||||
@Query('intra_date') String? intraDate,
|
||||
});
|
||||
|
||||
/// Чужие графики целиком (клиент консультанта, публичный профиль).
|
||||
@GET('/api/profile/insights/{slug}')
|
||||
Future<InsightsResponseModel> getInsightsBySlug({
|
||||
@Path() required String slug,
|
||||
@Query('body_period') String? bodyPeriod,
|
||||
@Query('daily_period') String? dailyPeriod,
|
||||
@Query('intra_date') String? intraDate,
|
||||
});
|
||||
|
||||
/// Только «в течение дня» для себя.
|
||||
@GET('/api/profile/insights/intra')
|
||||
Future<InsightsIntraResponseModel> getIntra({
|
||||
@Query('intra_date') String? intraDate,
|
||||
});
|
||||
|
||||
/// Только «в течение дня» для другого пользователя.
|
||||
@GET('/api/profile/insights/{slug}/intra')
|
||||
Future<InsightsIntraResponseModel> getIntraBySlug({
|
||||
@Path() required String slug,
|
||||
@Query('intra_date') String? intraDate,
|
||||
});
|
||||
}
|
||||
@@ -260,6 +260,14 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: _badges(state),
|
||||
),
|
||||
// Доступ решает сервер: консультант видит клиента, пока консультация
|
||||
// не завершена, остальные — только публичные профили; иначе 403 и блок скрыт.
|
||||
if (!state.profile.isBlockedByMe &&
|
||||
!state.profile.isBlockedByAuthor)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: InsightsCard(slug: state.profile.slug),
|
||||
),
|
||||
if (state.profile.isBlockedByAuthor)
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
|
||||
Reference in New Issue
Block a user