feat(insights): review fixes

This commit is contained in:
misha
2026-11-11 16:05:11 +03:00
parent 5228d43f15
commit db3a32c12e
16 changed files with 694 additions and 232 deletions

View File

@@ -137,6 +137,7 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
child: _DiaryInsights(
slug: state.isConsultantView ? _ownerSlug(state) : null,
canAdd: !state.isConsultantView,
diaryDate: state.diary.date,
),
),
if (state is DiaryStateLoaded)
@@ -283,11 +284,18 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
/// когда в том же дне меняются приёмы пищи или время подъёма/отбоя: от них
/// зависят КБЖУ-графики. Чужой дневник консультант не редактирует.
class _DiaryInsights extends StatefulWidget {
const _DiaryInsights({required this.slug, required this.canAdd});
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();
}
@@ -316,6 +324,7 @@ class _DiaryInsightsState extends State<_DiaryInsights> {
slug: widget.slug,
canAdd: widget.canAdd,
refreshToken: _refreshToken,
initialIntraDate: widget.diaryDate,
),
);
}

View File

@@ -18,7 +18,13 @@ class BmiChart extends StatelessWidget {
return InsightsChartPlaceholder(text: model.deltaText);
}
final s = S.of(context);
final range = paddedRange(points.map((e) => e.value), minPadding: 2);
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,
@@ -27,13 +33,13 @@ class BmiChart extends StatelessWidget {
height: insightsChartHeight,
child: SfCartesianChart(
key: const Key('insights_bmi_chart'),
margin: EdgeInsets.zero,
margin: insightsChartMargin,
plotAreaBorderWidth: 0,
primaryXAxis: buildInsightsCategoryAxis(),
primaryXAxis: buildInsightsIndexAxis(model.labels),
primaryYAxis: buildInsightsValueAxis(
unit: s.insights_unit_bmi,
min: range.min,
max: range.max,
min: min,
max: max,
plotBands: const [
PlotBand(
start: 18.5,
@@ -53,12 +59,13 @@ class BmiChart extends StatelessWidget {
],
),
trackballBehavior: buildInsightsTrackball(),
series: <CartesianSeries<InsightsPoint, String>>[
SplineAreaSeries<InsightsPoint, String>(
series: <CartesianSeries<InsightsPoint, num>>[
SplineAreaSeries<InsightsPoint, num>(
name: s.insights_tab_bmi,
dataSource: points,
xValueMapper: (point, _) => point.label,
xValueMapper: (point, _) => point.index,
yValueMapper: (point, _) => point.value,
splineType: insightsSplineType,
borderColor: InsightsColors.blue,
borderWidth: 3,
gradient: buildInsightsAreaGradient(InsightsColors.blue),

View File

@@ -30,6 +30,24 @@ class DailyKbjuChart extends StatelessWidget {
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,
@@ -48,16 +66,17 @@ class DailyKbjuChart extends StatelessWidget {
height: insightsChartHeight,
child: SfCartesianChart(
key: const Key('insights_daily_chart'),
margin: EdgeInsets.zero,
margin: insightsChartMargin,
plotAreaBorderWidth: 0,
primaryXAxis: buildInsightsCategoryAxis(),
primaryXAxis: buildInsightsIndexAxis(model.labels),
primaryYAxis: NumericAxis(
title: AxisTitle(
text: s.insights_unit_kcal,
textStyle: insightsAxisLabelStyle,
),
labelStyle: insightsAxisLabelStyle,
minimum: 0,
minimum: kcalRange.min,
maximum: kcalRange.max,
majorTickLines: const MajorTickLines(size: 0),
axisLine: const AxisLine(width: 0),
majorGridLines: MajorGridLines(
@@ -69,7 +88,8 @@ class DailyKbjuChart extends StatelessWidget {
NumericAxis(
name: _gramsAxisName,
opposedPosition: true,
minimum: 0,
minimum: gramsRange.min,
maximum: gramsRange.max,
title: AxisTitle(
text: s.insights_unit_grams,
textStyle: insightsAxisLabelStyle,
@@ -81,25 +101,27 @@ class DailyKbjuChart extends StatelessWidget {
),
],
trackballBehavior: buildInsightsTrackball(),
series: <CartesianSeries<InsightsPoint, String>>[
series: <CartesianSeries<InsightsPoint, num>>[
if (_isVisible(0))
SplineAreaSeries<InsightsPoint, String>(
SplineAreaSeries<InsightsPoint, num>(
name: s.insights_series_calories,
dataSource: calories,
xValueMapper: (point, _) => point.label,
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, String>(
SplineSeries<InsightsPoint, num>(
name: grams[i].name,
yAxisName: _gramsAxisName,
dataSource: grams[i].points,
xValueMapper: (point, _) => point.label,
xValueMapper: (point, _) => point.index,
yValueMapper: (point, _) => point.value,
splineType: insightsSplineType,
color: grams[i].color,
width: 2.5,
),

View File

@@ -4,10 +4,19 @@ import 'package:eatvio_app/features/insights/presentation/widgets/insights_color
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
/// Точка графика: подпись оси X (`dd.MM`) и значение.
/// Точка графика: индекс по оси X (как у Chart.js), подпись `dd.MM` и значение.
///
/// Индекс обязателен: Syncfusion `CategoryAxis` склеивает одинаковые строки
/// (`05.07` несколько раз → одна категория). На вебе labels идут по индексу,
/// поэтому здесь X = index, а дата рисуется через [axisLabelFormatter].
class InsightsPoint {
const InsightsPoint(this.label, this.value);
const InsightsPoint({
required this.index,
required this.label,
required this.value,
});
final int index;
final String label;
final double value;
}
@@ -15,13 +24,19 @@ class InsightsPoint {
/// Склеивает `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(labels[i], values[i])];
return [
for (var i = 0; i < count; i++)
InsightsPoint(index: i, label: labels[i], value: values[i]),
];
}
/// Границы оси Y с отступом, чтобы линия не прижималась к краям (и ось не начиналась с нуля).
/// Локальный диапазон оси 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);
@@ -29,26 +44,56 @@ List<InsightsPoint> buildPoints(List<String> labels, List<double> values) {
final lowest = values.reduce(math.min);
final highest = values.reduce(math.max);
final padding = math.max((highest - lowest) * 0.2, minPadding);
return (
min: (lowest - padding).floorToDouble(),
max: (highest + padding).ceilToDouble(),
);
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,
);
CategoryAxis buildInsightsCategoryAxis() {
return const CategoryAxis(
arrangeByIndex: true,
/// Ось 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: MajorGridLines(width: 0),
majorTickLines: MajorTickLines(size: 0),
axisLine: AxisLine(color: InsightsColors.border, width: 1),
majorGridLines: const MajorGridLines(width: 0),
majorTickLines: const MajorTickLines(size: 0),
axisLine: const AxisLine(color: InsightsColors.border, width: 1),
edgeLabelPlacement: EdgeLabelPlacement.shift,
labelIntersectAction: AxisLabelIntersectAction.hide,
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);
},
);
}
@@ -61,6 +106,8 @@ NumericAxis buildInsightsValueAxis({
return NumericAxis(
minimum: min,
maximum: max,
// Доп. воздух, чтобы сплайн не врезался в край plot area.
rangePadding: ChartRangePadding.additional,
labelFormat: '{value} $unit',
labelStyle: insightsAxisLabelStyle,
majorTickLines: const MajorTickLines(size: 0),
@@ -73,6 +120,9 @@ NumericAxis buildInsightsValueAxis({
);
}
/// Небольшой внутренний отступ, чтобы кривая и подписи не резались контейнером.
const EdgeInsets insightsChartMargin = EdgeInsets.fromLTRB(0, 8, 0, 4);
TrackballBehavior buildInsightsTrackball() {
return TrackballBehavior(
enable: true,

View File

@@ -28,7 +28,11 @@ class IntraDayChart extends StatelessWidget {
@override
Widget build(BuildContext context) {
final s = S.of(context);
if (model.bars.isEmpty && model.satiety.isEmpty && model.mood.isEmpty) {
if (model.bars.isEmpty &&
model.satiety.isEmpty &&
model.mood.isEmpty &&
model.wake == null &&
model.sleep == null) {
return InsightsChartPlaceholder(text: s.insights_not_enough);
}
@@ -43,7 +47,7 @@ class IntraDayChart extends StatelessWidget {
final barFraction = (_barWidthPx / (plotWidth / 24)).clamp(0.1, 1.0);
return SfCartesianChart(
key: const Key('insights_intra_chart'),
margin: EdgeInsets.zero,
margin: insightsChartMargin,
plotAreaBorderWidth: 0,
primaryXAxis: NumericAxis(
minimum: 0,
@@ -164,7 +168,7 @@ class IntraDayChart extends StatelessWidget {
dataSource: points,
xValueMapper: (point, _) => point.x,
yValueMapper: (point, _) => point.y,
splineType: SplineType.monotonic,
splineType: insightsSplineType,
color: color,
width: 2.5,
markerSettings: MarkerSettings(

View File

@@ -55,23 +55,24 @@ class MeasureChart extends StatelessWidget {
height: insightsChartHeight,
child: SfCartesianChart(
key: const Key('insights_measure_chart'),
margin: EdgeInsets.zero,
margin: insightsChartMargin,
plotAreaBorderWidth: 0,
primaryXAxis: buildInsightsCategoryAxis(),
primaryXAxis: buildInsightsIndexAxis(model.labels),
primaryYAxis: buildInsightsValueAxis(
unit: s.insights_unit_cm,
min: range.min,
max: range.max,
),
trackballBehavior: buildInsightsTrackball(),
series: <CartesianSeries<InsightsPoint, String>>[
series: <CartesianSeries<InsightsPoint, num>>[
for (var i = 0; i < series.length; i++)
if (_isVisible(i))
SplineSeries<InsightsPoint, String>(
SplineSeries<InsightsPoint, num>(
name: series[i].name,
dataSource: series[i].points,
xValueMapper: (point, _) => point.label,
xValueMapper: (point, _) => point.index,
yValueMapper: (point, _) => point.value,
splineType: insightsSplineType,
color: series[i].color,
width: 3,
markerSettings: buildInsightsMarker(series[i].color),

View File

@@ -26,21 +26,22 @@ class WeightChart extends StatelessWidget {
height: insightsChartHeight,
child: SfCartesianChart(
key: const Key('insights_weight_chart'),
margin: EdgeInsets.zero,
margin: insightsChartMargin,
plotAreaBorderWidth: 0,
primaryXAxis: buildInsightsCategoryAxis(),
primaryXAxis: buildInsightsIndexAxis(model.labels),
primaryYAxis: buildInsightsValueAxis(
unit: s.insights_unit_kg,
min: range.min,
max: range.max,
),
trackballBehavior: buildInsightsTrackball(),
series: <CartesianSeries<InsightsPoint, String>>[
SplineAreaSeries<InsightsPoint, String>(
series: <CartesianSeries<InsightsPoint, num>>[
SplineAreaSeries<InsightsPoint, num>(
name: s.insights_tab_weight,
dataSource: points,
xValueMapper: (point, _) => point.label,
xValueMapper: (point, _) => point.index,
yValueMapper: (point, _) => point.value,
splineType: insightsSplineType,
borderColor: InsightsColors.blue,
borderWidth: 3,
gradient: buildInsightsAreaGradient(InsightsColors.blue),

View File

@@ -29,18 +29,26 @@ class InsightsCubit extends Cubit<InsightsState> {
bool _isHeightLoaded = false;
int? _heightCm;
int _requestId = 0;
/// Полный payload и вкладка «В течение дня» координируются отдельно: смена
/// дня не должна отменять загрузку периода, и наоборот.
int _fullRequestId = 0;
int _intraRequestId = 0;
int _inflightFull = 0;
int _inflightIntra = 0;
List<InsightsPanel> get panels =>
InsightsPanel.available(kbjuEnabled: kbjuEnabled);
Future<void> load() async {
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: null,
previous: null,
intraDate: intraDate,
isInitial: true,
);
}
@@ -53,12 +61,11 @@ class InsightsCubit extends Cubit<InsightsState> {
if (current is! InsightsStateLoaded) {
return load();
}
emit(current.copyWith(isRefreshing: true));
await _fetch(
bodyPeriod: current.bodyPeriod,
dailyPeriod: current.dailyPeriod,
intraDate: current.intraDate,
previous: current,
isInitial: false,
);
}
@@ -72,7 +79,7 @@ class InsightsCubit extends Cubit<InsightsState> {
bodyPeriod: period,
dailyPeriod: current.dailyPeriod,
intraDate: current.intraDate,
previous: current,
isInitial: false,
);
}
@@ -89,7 +96,7 @@ class InsightsCubit extends Cubit<InsightsState> {
bodyPeriod: current.bodyPeriod,
dailyPeriod: period,
intraDate: current.intraDate,
previous: current,
isInitial: false,
);
}
@@ -110,51 +117,87 @@ class InsightsCubit extends Cubit<InsightsState> {
final base = _currentIntraDay();
if (base == null ||
current is! InsightsStateLoaded ||
(current.payload.intra?.isToday ?? true)) {
!_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 = ++_requestId;
emit(current.copyWith(isRefreshing: true));
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 != _requestId || isClosed) {
if (requestId != _intraRequestId || isClosed) {
return;
}
final latest = state;
if (latest is! InsightsStateLoaded) {
return;
}
emit(
current.copyWith(
payload: current.payload.copyWith(intra: intra),
latest.copyWith(
payload: latest.payload.copyWith(intra: intra),
intraDate: intra.isToday
? null
: (intra.date.isEmpty ? date : intra.date),
isRefreshing: false,
isRefreshing: _inflightFull > 0 || _inflightIntra > 1,
),
);
} on InsightsForbidden {
if (requestId == _requestId && !isClosed) {
if (requestId == _intraRequestId && !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));
if (requestId == _intraRequestId && !isClosed) {
final latest = state;
if (latest is InsightsStateLoaded) {
emit(
latest.copyWith(
intraDate: previousIntraDate,
isRefreshing: _inflightFull > 0 || _inflightIntra > 1,
),
);
}
}
} finally {
_inflightIntra--;
_syncRefreshingFlag();
}
}
@@ -271,50 +314,87 @@ class InsightsCubit extends Cubit<InsightsState> {
required BodyPeriod bodyPeriod,
required DailyPeriod dailyPeriod,
required String? intraDate,
required InsightsStateLoaded? previous,
required bool isInitial,
}) async {
final requestId = ++_requestId;
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 != _requestId || isClosed) {
if (requestId != _fullRequestId || isClosed) {
return;
}
emit(
previous == null
? InsightsState.loaded(
payload: payload,
bodyPeriod: bodyPeriod,
dailyPeriod: dailyPeriod,
intraDate: intraDate,
)
: previous.copyWith(
payload: payload,
bodyPeriod: bodyPeriod,
dailyPeriod: dailyPeriod,
intraDate: intraDate,
isRefreshing: false,
),
);
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 == _requestId && !isClosed) {
if (requestId == _fullRequestId && !isClosed) {
emit(const InsightsState.hidden());
}
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString(), stackTrace: st);
if (requestId != _requestId || isClosed) {
if (requestId != _fullRequestId || isClosed) {
return;
}
// Ошибка перезапроса не должна стирать уже показанные данные.
emit(
previous == null
? InsightsState.error(message: e.toString())
: previous.copyWith(isRefreshing: false),
);
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));
}
}
}

View File

@@ -38,6 +38,7 @@ class InsightsCard extends StatelessWidget {
this.repository,
this.kbjuEnabled = InsightsFeatureFlags.kbjuEnabled,
this.refreshToken = 0,
this.initialIntraDate,
});
final String? slug;
@@ -49,6 +50,10 @@ class InsightsCard extends StatelessWidget {
/// в дневнике), сохраняя вкладку, период и выбранный день.
final int refreshToken;
/// День вкладки «В течение дня» при первой загрузке (`YYYY-MM-DD`).
/// В дневнике совпадает с открытой датой; `null` — сегодня.
final String? initialIntraDate;
@override
Widget build(BuildContext context) {
return KeyedSubtree(
@@ -59,17 +64,24 @@ class InsightsCard extends StatelessWidget {
slug: slug,
canAdd: canAdd,
kbjuEnabled: kbjuEnabled,
)..load(),
child: _InsightsView(refreshToken: refreshToken),
)..load(intraDate: initialIntraDate),
child: _InsightsView(
refreshToken: refreshToken,
initialIntraDate: initialIntraDate,
),
),
);
}
}
class _InsightsView extends StatefulWidget {
const _InsightsView({required this.refreshToken});
const _InsightsView({
required this.refreshToken,
required this.initialIntraDate,
});
final int refreshToken;
final String? initialIntraDate;
@override
State<_InsightsView> createState() => _InsightsViewState();
@@ -79,8 +91,18 @@ class _InsightsViewState extends State<_InsightsView> {
@override
void didUpdateWidget(covariant _InsightsView oldWidget) {
super.didUpdateWidget(oldWidget);
final cubit = context.read<InsightsCubit>();
if (oldWidget.refreshToken != widget.refreshToken) {
context.read<InsightsCubit>().refresh();
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);
}
}
}

View File

@@ -23,12 +23,8 @@ typedef InsightsTextBuilderFactory = InsightsTextBuilder Function();
/// - [getInsights] — собирает payload на клиенте из эндпоинтов веса и замеров
/// (используется, пока выключен `InsightsFeatureFlags.kbjuEnabled`).
class InsightsRepository {
InsightsRepository({
required this.profileRepository,
required this.insightsService,
InsightsTextBuilderFactory? textBuilderFactory,
}) : _textBuilderFactory =
textBuilderFactory ?? (() => InsightsTextBuilder(S.current));
InsightsRepository({required this.profileRepository, required this.insightsService, InsightsTextBuilderFactory? textBuilderFactory})
: _textBuilderFactory = textBuilderFactory ?? (() => InsightsTextBuilder(S.current));
final ProfileRepository profileRepository;
final InsightsService insightsService;
@@ -38,9 +34,7 @@ class InsightsRepository {
/// профиль не удалось загрузить (кроме 403 — он пробрасывается как [InsightsForbidden]).
Future<int?> getHeightCm({String? slug}) async {
try {
final details = slug == null
? await profileRepository.getMyProfileDetails()
: await profileRepository.getProfileDetailsBySlug(slug);
final details = slug == null ? await profileRepository.getMyProfileDetails() : await profileRepository.getProfileDetailsBySlug(slug);
return details.height > 0 ? details.height : null;
} on DioException catch (e) {
_throwIfForbidden(e);
@@ -50,25 +44,13 @@ class InsightsRepository {
}
}
Future<InsightsPayloadModel> getInsights({
String? slug,
required BodyPeriod bodyPeriod,
int? heightCm,
}) async {
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,
),
profileRepository.getCircumferences(period: bodyPeriod.apiValue, slug: slug),
).wait;
return _textBuilderFactory().build(
weights: weights,
circumferences: circumferences,
period: bodyPeriod,
heightCm: heightCm,
);
return _textBuilderFactory().build(weights: weights, circumferences: circumferences, period: bodyPeriod, heightCm: heightCm);
} on DioException catch (e) {
_throwIfForbidden(e);
rethrow;
@@ -76,25 +58,11 @@ class InsightsRepository {
}
/// Полный payload с сервера. [intraDate] — `YYYY-MM-DD`, `null` — сегодня.
Future<InsightsPayloadModel> fetchInsights({
String? slug,
required BodyPeriod bodyPeriod,
required DailyPeriod dailyPeriod,
String? intraDate,
}) async {
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,
);
? 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);
@@ -107,10 +75,7 @@ class InsightsRepository {
try {
final response = slug == null
? await insightsService.getIntra(intraDate: intraDate)
: await insightsService.getIntraBySlug(
slug: slug,
intraDate: intraDate,
);
: await insightsService.getIntraBySlug(slug: slug, intraDate: intraDate);
return response.data.intra;
} on DioException catch (e) {
_throwIfForbidden(e);
@@ -122,11 +87,7 @@ class InsightsRepository {
return profileRepository.addWeight(weight: weight);
}
Future<void> addCircumference({
required int chestCircumference,
required int waistCircumference,
required int hipCircumference,
}) {
Future<void> addCircumference({required int chestCircumference, required int waistCircumference, required int hipCircumference}) {
return profileRepository.addCircumference(
chestCircumference: chestCircumference,
waistCircumference: waistCircumference,