forked from eatvio/eatvio_app
feat(insights): review fixes
This commit is contained in:
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user