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,
|
||||
|
||||
@@ -49,7 +49,9 @@ void main() {
|
||||
AppConfig(buildType: AppBuildType.tests)
|
||||
..setBaseUrl('http://localhost'),
|
||||
);
|
||||
sl.registerLazySingleton<InsightsRepository>(() => FakeInsightsRepository());
|
||||
sl.registerLazySingleton<InsightsRepository>(
|
||||
() => FakeInsightsRepository(),
|
||||
);
|
||||
});
|
||||
|
||||
Widget wrap(Widget child) {
|
||||
@@ -401,17 +403,31 @@ void main() {
|
||||
required FakeInsightsRepository insightsRepository,
|
||||
}) async {
|
||||
final diaryCubit = MockDiaryCubit();
|
||||
final state = loadedState(hasChosenPlan: false, ownerSlug: diaryOwnerSlug).copyWith(isConsultantView: isConsultantView);
|
||||
final state = loadedState(
|
||||
hasChosenPlan: false,
|
||||
ownerSlug: diaryOwnerSlug,
|
||||
).copyWith(isConsultantView: isConsultantView);
|
||||
when(diaryCubit.state).thenReturn(state);
|
||||
when(diaryCubit.stream).thenAnswer((_) => diaryStates ?? const Stream<DiaryState>.empty());
|
||||
when(
|
||||
diaryCubit.stream,
|
||||
).thenAnswer((_) => diaryStates ?? const Stream<DiaryState>.empty());
|
||||
when(diaryCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(
|
||||
diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId')),
|
||||
diaryCubit.fetchDiaryV2(
|
||||
needLoading: anyNamed('needLoading'),
|
||||
diaryId: anyNamed('diaryId'),
|
||||
),
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
final commentsCubit = MockConsultantCommentsCubit();
|
||||
when(commentsCubit.state).thenReturn(ConsultantCommentsState(commentsModel: ConsultantCommentModel(comment: '')));
|
||||
when(commentsCubit.stream).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.state).thenReturn(
|
||||
ConsultantCommentsState(
|
||||
commentsModel: ConsultantCommentModel(comment: ''),
|
||||
),
|
||||
);
|
||||
when(
|
||||
commentsCubit.stream,
|
||||
).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
sl.registerFactory<DiaryCubit>(() => diaryCubit);
|
||||
@@ -420,43 +436,84 @@ void main() {
|
||||
sl.registerLazySingleton<InsightsRepository>(() => insightsRepository);
|
||||
|
||||
await tester.pumpWidget(
|
||||
wrap(isConsultantView ? UserDiaryScreen(diaryId: 1, ownerSlug: ownerSlug) : DiaryScreen(date: DateTime(2025, 1, 1))),
|
||||
wrap(
|
||||
isConsultantView
|
||||
? UserDiaryScreen(diaryId: 1, ownerSlug: ownerSlug)
|
||||
: DiaryScreen(date: DateTime(2025, 1, 1)),
|
||||
),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
}
|
||||
|
||||
testWidgets('own diary shows insights card for own data with add button enabled', (tester) async {
|
||||
final insightsRepository = FakeInsightsRepository();
|
||||
testWidgets(
|
||||
'own diary shows insights card for own data with add button enabled',
|
||||
(tester) async {
|
||||
final insightsRepository = FakeInsightsRepository();
|
||||
|
||||
await pumpDiaryWithInsights(tester, isConsultantView: false, insightsRepository: insightsRepository);
|
||||
await pumpDiaryWithInsights(
|
||||
tester,
|
||||
isConsultantView: false,
|
||||
insightsRepository: insightsRepository,
|
||||
);
|
||||
|
||||
expect(find.byType(InsightsCard), findsOneWidget);
|
||||
expect(tester.widget<InsightsCard>(find.byType(InsightsCard)).slug, isNull);
|
||||
expect(tester.widget<InsightsCard>(find.byType(InsightsCard)).canAdd, isTrue);
|
||||
expect(insightsRepository.serverRequests.single.slug, isNull);
|
||||
});
|
||||
expect(find.byType(InsightsCard), findsOneWidget);
|
||||
final card = tester.widget<InsightsCard>(find.byType(InsightsCard));
|
||||
expect(card.slug, isNull);
|
||||
expect(card.canAdd, isTrue);
|
||||
expect(card.initialIntraDate, '2025-01-01');
|
||||
expect(insightsRepository.serverRequests.single.slug, isNull);
|
||||
expect(
|
||||
insightsRepository.serverRequests.single.intraDate,
|
||||
'2025-01-01',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('consultant view shows insights card of the client without add button', (tester) async {
|
||||
final insightsRepository = FakeInsightsRepository();
|
||||
testWidgets(
|
||||
'consultant view shows insights card of the client without add button',
|
||||
(tester) async {
|
||||
final insightsRepository = FakeInsightsRepository();
|
||||
|
||||
await pumpDiaryWithInsights(tester, isConsultantView: true, ownerSlug: 'client', insightsRepository: insightsRepository);
|
||||
await pumpDiaryWithInsights(
|
||||
tester,
|
||||
isConsultantView: true,
|
||||
ownerSlug: 'client',
|
||||
insightsRepository: insightsRepository,
|
||||
);
|
||||
|
||||
final card = tester.widget<InsightsCard>(find.byType(InsightsCard));
|
||||
expect(card.slug, 'client');
|
||||
expect(card.canAdd, isFalse);
|
||||
expect(insightsRepository.serverRequests.single.slug, 'client');
|
||||
});
|
||||
final card = tester.widget<InsightsCard>(find.byType(InsightsCard));
|
||||
expect(card.slug, 'client');
|
||||
expect(card.canAdd, isFalse);
|
||||
expect(insightsRepository.serverRequests.single.slug, 'client');
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('consultant view takes the owner slug from the diary (user.slug)', (tester) async {
|
||||
final insightsRepository = FakeInsightsRepository();
|
||||
testWidgets(
|
||||
'consultant view takes the owner slug from the diary (user.slug)',
|
||||
(tester) async {
|
||||
final insightsRepository = FakeInsightsRepository();
|
||||
|
||||
await pumpDiaryWithInsights(tester, isConsultantView: true, diaryOwnerSlug: 'client-from-diary', insightsRepository: insightsRepository);
|
||||
await pumpDiaryWithInsights(
|
||||
tester,
|
||||
isConsultantView: true,
|
||||
diaryOwnerSlug: 'client-from-diary',
|
||||
insightsRepository: insightsRepository,
|
||||
);
|
||||
|
||||
expect(tester.widget<InsightsCard>(find.byType(InsightsCard)).slug, 'client-from-diary');
|
||||
expect(insightsRepository.serverRequests.single.slug, 'client-from-diary');
|
||||
});
|
||||
expect(
|
||||
tester.widget<InsightsCard>(find.byType(InsightsCard)).slug,
|
||||
'client-from-diary',
|
||||
);
|
||||
expect(
|
||||
insightsRepository.serverRequests.single.slug,
|
||||
'client-from-diary',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('diary owner slug wins over the route parameter', (tester) async {
|
||||
testWidgets('diary owner slug wins over the route parameter', (
|
||||
tester,
|
||||
) async {
|
||||
final insightsRepository = FakeInsightsRepository();
|
||||
|
||||
await pumpDiaryWithInsights(
|
||||
@@ -467,37 +524,58 @@ void main() {
|
||||
insightsRepository: insightsRepository,
|
||||
);
|
||||
|
||||
expect(tester.widget<InsightsCard>(find.byType(InsightsCard)).slug, 'from-diary');
|
||||
expect(
|
||||
tester.widget<InsightsCard>(find.byType(InsightsCard)).slug,
|
||||
'from-diary',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('own diary refreshes insights when meals or wake-up time change', (tester) async {
|
||||
final insightsRepository = FakeInsightsRepository();
|
||||
final states = StreamController<DiaryState>.broadcast();
|
||||
addTearDown(states.close);
|
||||
testWidgets(
|
||||
'own diary refreshes insights when meals or wake-up time change',
|
||||
(tester) async {
|
||||
final insightsRepository = FakeInsightsRepository();
|
||||
final states = StreamController<DiaryState>.broadcast();
|
||||
addTearDown(states.close);
|
||||
|
||||
await pumpDiaryWithInsights(tester, isConsultantView: false, diaryStates: states.stream, insightsRepository: insightsRepository);
|
||||
expect(insightsRepository.serverRequests, hasLength(1));
|
||||
await pumpDiaryWithInsights(
|
||||
tester,
|
||||
isConsultantView: false,
|
||||
diaryStates: states.stream,
|
||||
insightsRepository: insightsRepository,
|
||||
);
|
||||
expect(insightsRepository.serverRequests, hasLength(1));
|
||||
|
||||
states.add(loadedState(hasChosenPlan: false, wakeup: '06:30'));
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
states.add(loadedState(hasChosenPlan: false, wakeup: '06:30'));
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(insightsRepository.serverRequests, hasLength(2));
|
||||
});
|
||||
expect(insightsRepository.serverRequests, hasLength(2));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('own diary does not refresh insights when nothing relevant changed', (tester) async {
|
||||
final insightsRepository = FakeInsightsRepository();
|
||||
final states = StreamController<DiaryState>.broadcast();
|
||||
addTearDown(states.close);
|
||||
testWidgets(
|
||||
'own diary does not refresh insights when nothing relevant changed',
|
||||
(tester) async {
|
||||
final insightsRepository = FakeInsightsRepository();
|
||||
final states = StreamController<DiaryState>.broadcast();
|
||||
addTearDown(states.close);
|
||||
|
||||
await pumpDiaryWithInsights(tester, isConsultantView: false, diaryStates: states.stream, insightsRepository: insightsRepository);
|
||||
await pumpDiaryWithInsights(
|
||||
tester,
|
||||
isConsultantView: false,
|
||||
diaryStates: states.stream,
|
||||
insightsRepository: insightsRepository,
|
||||
);
|
||||
|
||||
states.add(loadedState(hasChosenPlan: false));
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
states.add(loadedState(hasChosenPlan: false));
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(insightsRepository.serverRequests, hasLength(1));
|
||||
});
|
||||
expect(insightsRepository.serverRequests, hasLength(1));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('consultant view does not refresh insights on diary changes', (tester) async {
|
||||
testWidgets('consultant view does not refresh insights on diary changes', (
|
||||
tester,
|
||||
) async {
|
||||
final insightsRepository = FakeInsightsRepository();
|
||||
final states = StreamController<DiaryState>.broadcast();
|
||||
addTearDown(states.close);
|
||||
@@ -510,25 +588,43 @@ void main() {
|
||||
insightsRepository: insightsRepository,
|
||||
);
|
||||
|
||||
states.add(loadedState(hasChosenPlan: false, wakeup: '06:30').copyWith(isConsultantView: true));
|
||||
states.add(
|
||||
loadedState(
|
||||
hasChosenPlan: false,
|
||||
wakeup: '06:30',
|
||||
).copyWith(isConsultantView: true),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(insightsRepository.serverRequests, hasLength(1));
|
||||
});
|
||||
|
||||
testWidgets('consultant view without owner slug hides insights card', (tester) async {
|
||||
testWidgets('consultant view without owner slug hides insights card', (
|
||||
tester,
|
||||
) async {
|
||||
final insightsRepository = FakeInsightsRepository();
|
||||
|
||||
await pumpDiaryWithInsights(tester, isConsultantView: true, insightsRepository: insightsRepository);
|
||||
await pumpDiaryWithInsights(
|
||||
tester,
|
||||
isConsultantView: true,
|
||||
insightsRepository: insightsRepository,
|
||||
);
|
||||
|
||||
expect(find.byType(InsightsCard), findsNothing);
|
||||
expect(insightsRepository.serverRequests, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('consultant view hides insights card on 403', (tester) async {
|
||||
final insightsRepository = FakeInsightsRepository(error: const InsightsForbidden());
|
||||
final insightsRepository = FakeInsightsRepository(
|
||||
error: const InsightsForbidden(),
|
||||
);
|
||||
|
||||
await pumpDiaryWithInsights(tester, isConsultantView: true, ownerSlug: 'client', insightsRepository: insightsRepository);
|
||||
await pumpDiaryWithInsights(
|
||||
tester,
|
||||
isConsultantView: true,
|
||||
ownerSlug: 'client',
|
||||
insightsRepository: insightsRepository,
|
||||
);
|
||||
|
||||
expect(find.byKey(const Key('insights_card')), findsNothing);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
|
||||
void main() {
|
||||
test('insightsSplineType — monotonic, чтобы кривая не уходила ниже точек', () {
|
||||
expect(insightsSplineType, SplineType.monotonic);
|
||||
});
|
||||
|
||||
group('buildPoints', () {
|
||||
test('сохраняет индекс и дату даже при повторяющихся labels', () {
|
||||
final points = buildPoints(
|
||||
['05.07', '05.07', '06.07', '07.07'],
|
||||
[10, 20, 30, 40],
|
||||
);
|
||||
|
||||
expect(points.map((p) => p.index).toList(), [0, 1, 2, 3]);
|
||||
expect(points.map((p) => p.label).toList(), [
|
||||
'05.07',
|
||||
'05.07',
|
||||
'06.07',
|
||||
'07.07',
|
||||
]);
|
||||
expect(points.map((p) => p.value).toList(), [10, 20, 30, 40]);
|
||||
});
|
||||
|
||||
test('обрезает по короткому массиву', () {
|
||||
expect(buildPoints(['a', 'b'], [1]), hasLength(1));
|
||||
});
|
||||
});
|
||||
|
||||
group('buildInsightsIndexAxis', () {
|
||||
test('ось строится по числу точек, а не по уникальным строкам дат', () {
|
||||
final labels = [
|
||||
'05.07',
|
||||
'06.07',
|
||||
'07.07',
|
||||
'08.07',
|
||||
'09.07',
|
||||
'10.07',
|
||||
'11.07',
|
||||
];
|
||||
final axis = buildInsightsIndexAxis(labels);
|
||||
|
||||
expect(axis.minimum, 0);
|
||||
expect(axis.maximum, 6);
|
||||
expect(axis.interval, 1);
|
||||
expect(axis.axisLabelFormatter, isNotNull);
|
||||
});
|
||||
|
||||
test('для длинного периода ставит интервал больше 1', () {
|
||||
final labels = List.generate(30, (i) => '${i + 1}.07');
|
||||
final axis = buildInsightsIndexAxis(labels);
|
||||
|
||||
expect(axis.maximum, 29);
|
||||
expect(axis.interval, greaterThan(1));
|
||||
});
|
||||
});
|
||||
|
||||
group('paddedRange', () {
|
||||
test('даёт локальный диапазон вокруг данных', () {
|
||||
final range = paddedRange([70, 72], minPadding: 1);
|
||||
|
||||
expect(range.min, lessThan(70));
|
||||
expect(range.max, greaterThan(72));
|
||||
expect(range.min, greaterThan(0));
|
||||
});
|
||||
|
||||
test('clampZero не уводит минимум ниже нуля', () {
|
||||
final range = paddedRange([2, 8], minPadding: 5, clampZero: true);
|
||||
|
||||
expect(range.min, 0);
|
||||
expect(range.max, greaterThan(8));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -121,7 +121,7 @@ void main() {
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('ошибка возвращает прежний период и оставляет данные', () async {
|
||||
test('ошибка оставляет выбранный период и старые данные', () async {
|
||||
final cubit = buildCubit();
|
||||
await cubit.load();
|
||||
final before = loaded(cubit).payload;
|
||||
@@ -129,7 +129,7 @@ void main() {
|
||||
|
||||
await cubit.setDailyPeriod(DailyPeriod.week);
|
||||
|
||||
expect(loaded(cubit).dailyPeriod, DailyPeriod.twoWeeks);
|
||||
expect(loaded(cubit).dailyPeriod, DailyPeriod.week);
|
||||
expect(loaded(cubit).payload, before);
|
||||
expect(loaded(cubit).isRefreshing, isFalse);
|
||||
await cubit.close();
|
||||
@@ -179,6 +179,65 @@ void main() {
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test(
|
||||
'быстрые «назад» запрашивают разные дни (оптимистичная дата)',
|
||||
() async {
|
||||
repository.fetchIntraDelay = const Duration(milliseconds: 30);
|
||||
repository.intraByDate['2026-11-07'] = yesterday.copyWith(
|
||||
date: '2026-11-07',
|
||||
dateLabel: '7 ноября',
|
||||
);
|
||||
final cubit = buildCubit();
|
||||
await cubit.load();
|
||||
|
||||
final first = cubit.showPreviousDay();
|
||||
final second = cubit.showPreviousDay();
|
||||
await Future.wait([first, second]);
|
||||
|
||||
expect(repository.intraRequests.map((e) => e.date).toList(), [
|
||||
'2026-11-08',
|
||||
'2026-11-07',
|
||||
]);
|
||||
expect(loaded(cubit).payload.intra!.date, '2026-11-07');
|
||||
await cubit.close();
|
||||
},
|
||||
);
|
||||
|
||||
test('полный ответ не затирает более свежий intra и UI-выбор', () async {
|
||||
repository.fetchInsightsDelay = const Duration(milliseconds: 40);
|
||||
final cubit = buildCubit();
|
||||
await cubit.load();
|
||||
repository.fetchInsightsDelay = const Duration(milliseconds: 80);
|
||||
repository.serverRequests.clear();
|
||||
|
||||
final periodChange = cubit.setBodyPeriod(BodyPeriod.year);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
cubit.setPanel(InsightsPanel.measures);
|
||||
cubit.toggleExpanded();
|
||||
await cubit.setIntraDate(DateTime(2026, 11, 8));
|
||||
await periodChange;
|
||||
|
||||
final state = loaded(cubit);
|
||||
expect(state.bodyPeriod, BodyPeriod.year);
|
||||
expect(state.payload.weight, repository.payload.weight);
|
||||
expect(state.payload.intra, yesterday);
|
||||
expect(state.intraDate, '2026-11-08');
|
||||
expect(state.panel, InsightsPanel.measures);
|
||||
expect(state.isExpanded, isTrue);
|
||||
expect(state.isRefreshing, isFalse);
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('load с initialIntraDate запрашивает этот день', () async {
|
||||
final cubit = buildCubit();
|
||||
|
||||
await cubit.load(intraDate: '2026-11-08');
|
||||
|
||||
expect(repository.serverRequests.single.intraDate, '2026-11-08');
|
||||
expect(loaded(cubit).intraDate, '2026-11-08');
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('showPreviousDay и showNextDay листают по дню', () async {
|
||||
final cubit = buildCubit();
|
||||
await cubit.load();
|
||||
|
||||
@@ -200,7 +200,7 @@ void main() {
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('ошибка перезапроса возвращает прежний период и данные', () async {
|
||||
test('ошибка перезапроса оставляет выбранный период и старые данные', () async {
|
||||
final cubit = buildCubit();
|
||||
await cubit.load();
|
||||
when(
|
||||
@@ -213,7 +213,7 @@ void main() {
|
||||
|
||||
await cubit.setBodyPeriod(BodyPeriod.all);
|
||||
|
||||
expect(loaded(cubit).bodyPeriod, BodyPeriod.threeMonths);
|
||||
expect(loaded(cubit).bodyPeriod, BodyPeriod.all);
|
||||
expect(loaded(cubit).payload, _payload);
|
||||
expect(loaded(cubit).isRefreshing, isFalse);
|
||||
await cubit.close();
|
||||
|
||||
@@ -97,7 +97,10 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repository.serverRequests.single.dailyPeriod, DailyPeriod.month);
|
||||
expect(repository.serverRequests.single.bodyPeriod, BodyPeriod.threeMonths);
|
||||
expect(
|
||||
repository.serverRequests.single.bodyPeriod,
|
||||
BodyPeriod.threeMonths,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('без daily показывает заглушку', (tester) async {
|
||||
@@ -150,32 +153,32 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('на сегодняшнем дне «следующий день» отключён, «предыдущий» нет', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpKbjuCard(
|
||||
tester,
|
||||
FakeInsightsRepository(),
|
||||
'insights_tab_intra',
|
||||
);
|
||||
testWidgets(
|
||||
'на сегодняшнем дне «следующий день» отключён, «предыдущий» нет',
|
||||
(tester) async {
|
||||
await pumpKbjuCard(
|
||||
tester,
|
||||
FakeInsightsRepository(),
|
||||
'insights_tab_intra',
|
||||
);
|
||||
|
||||
IconButton buttonIn(String key) => tester.widget<IconButton>(
|
||||
find.descendant(
|
||||
of: find.byKey(Key(key)),
|
||||
matching: find.byType(IconButton),
|
||||
),
|
||||
);
|
||||
IconButton buttonIn(String key) => tester.widget<IconButton>(
|
||||
find.descendant(
|
||||
of: find.byKey(Key(key)),
|
||||
matching: find.byType(IconButton),
|
||||
),
|
||||
);
|
||||
|
||||
expect(buttonIn('insights_intra_next').onPressed, isNull);
|
||||
expect(buttonIn('insights_intra_prev').onPressed, isNotNull);
|
||||
});
|
||||
expect(buttonIn('insights_intra_next').onPressed, isNull);
|
||||
expect(buttonIn('insights_intra_prev').onPressed, isNotNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('«предыдущий день» запрашивает только intra и меняет дату', (
|
||||
tester,
|
||||
) async {
|
||||
final repository = FakeInsightsRepository();
|
||||
repository.intraByDate['2026-11-08'] = loadInsightsPayloadFixture()
|
||||
.intra!
|
||||
repository.intraByDate['2026-11-08'] = loadInsightsPayloadFixture().intra!
|
||||
.copyWith(date: '2026-11-08', dateLabel: '8 ноября', isToday: false);
|
||||
await pumpKbjuCard(tester, repository, 'insights_tab_intra');
|
||||
repository.serverRequests.clear();
|
||||
@@ -312,6 +315,34 @@ void main() {
|
||||
expect(find.byType(IntraLegend), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('день только со сном рисует затемнение, без заглушки', (
|
||||
tester,
|
||||
) async {
|
||||
final intra = loadInsightsPayloadFixture().intra!.copyWith(
|
||||
bars: const [],
|
||||
satiety: const [],
|
||||
mood: const [],
|
||||
);
|
||||
await pumpKbjuCard(
|
||||
tester,
|
||||
FakeInsightsRepository(
|
||||
payload: loadInsightsPayloadFixture().copyWith(intra: intra),
|
||||
),
|
||||
'insights_tab_intra',
|
||||
);
|
||||
|
||||
expect(find.byKey(const Key('insights_intra_chart')), findsOneWidget);
|
||||
expect(find.text('Недостаточно данных'), findsNothing);
|
||||
final xAxis =
|
||||
tester
|
||||
.widget<SfCartesianChart>(
|
||||
find.byKey(const Key('insights_intra_chart')),
|
||||
)
|
||||
.primaryXAxis
|
||||
as NumericAxis;
|
||||
expect(xAxis.plotBands, isNotEmpty);
|
||||
});
|
||||
|
||||
testWidgets('пустой день показывает заглушку', (tester) async {
|
||||
await pumpKbjuCard(
|
||||
tester,
|
||||
|
||||
57
test/fixtures/doubles/insights_doubles.dart
vendored
57
test/fixtures/doubles/insights_doubles.dart
vendored
@@ -6,14 +6,22 @@ import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
InsightsPayloadModel loadInsightsPayloadFixture([String name = 'insights_payload.json']) {
|
||||
final json = jsonDecode(File('test/fixtures/insights/$name').readAsStringSync()) as Map<String, dynamic>;
|
||||
InsightsPayloadModel loadInsightsPayloadFixture([
|
||||
String name = 'insights_payload.json',
|
||||
]) {
|
||||
final json =
|
||||
jsonDecode(File('test/fixtures/insights/$name').readAsStringSync())
|
||||
as Map<String, dynamic>;
|
||||
return InsightsPayloadModel.fromJson(json);
|
||||
}
|
||||
|
||||
/// Подмена [InsightsRepository] для виджет-тестов: без сети, с журналом вызовов.
|
||||
class FakeInsightsRepository extends Fake implements InsightsRepository {
|
||||
FakeInsightsRepository({InsightsPayloadModel? payload, this.error, this.heightCm = 176}) : payload = payload ?? loadInsightsPayloadFixture();
|
||||
FakeInsightsRepository({
|
||||
InsightsPayloadModel? payload,
|
||||
this.error,
|
||||
this.heightCm = 176,
|
||||
}) : payload = payload ?? loadInsightsPayloadFixture();
|
||||
|
||||
InsightsPayloadModel payload;
|
||||
|
||||
@@ -28,8 +36,22 @@ class FakeInsightsRepository extends Fake implements InsightsRepository {
|
||||
Object? error;
|
||||
int? heightCm;
|
||||
|
||||
/// Задержка ответа [fetchInsights] (для тестов гонок).
|
||||
Duration? fetchInsightsDelay;
|
||||
|
||||
/// Задержка ответа [fetchIntra] (для тестов гонок).
|
||||
Duration? fetchIntraDelay;
|
||||
|
||||
final List<({String? slug, BodyPeriod period})> insightsRequests = [];
|
||||
final List<({String? slug, BodyPeriod bodyPeriod, DailyPeriod dailyPeriod, String? intraDate})> serverRequests = [];
|
||||
final List<
|
||||
({
|
||||
String? slug,
|
||||
BodyPeriod bodyPeriod,
|
||||
DailyPeriod dailyPeriod,
|
||||
String? intraDate,
|
||||
})
|
||||
>
|
||||
serverRequests = [];
|
||||
final List<({String? slug, String? date})> intraRequests = [];
|
||||
final List<double> addedWeights = [];
|
||||
final List<({int chest, int waist, int hip})> addedCircumferences = [];
|
||||
@@ -38,7 +60,11 @@ class FakeInsightsRepository extends Fake implements InsightsRepository {
|
||||
Future<int?> getHeightCm({String? slug}) async => heightCm;
|
||||
|
||||
@override
|
||||
Future<InsightsPayloadModel> getInsights({String? slug, required BodyPeriod bodyPeriod, int? heightCm}) async {
|
||||
Future<InsightsPayloadModel> getInsights({
|
||||
String? slug,
|
||||
required BodyPeriod bodyPeriod,
|
||||
int? heightCm,
|
||||
}) async {
|
||||
insightsRequests.add((slug: slug, period: bodyPeriod));
|
||||
final failure = error;
|
||||
if (failure != null) {
|
||||
@@ -54,7 +80,16 @@ class FakeInsightsRepository extends Fake implements InsightsRepository {
|
||||
required DailyPeriod dailyPeriod,
|
||||
String? intraDate,
|
||||
}) async {
|
||||
serverRequests.add((slug: slug, bodyPeriod: bodyPeriod, dailyPeriod: dailyPeriod, intraDate: intraDate));
|
||||
serverRequests.add((
|
||||
slug: slug,
|
||||
bodyPeriod: bodyPeriod,
|
||||
dailyPeriod: dailyPeriod,
|
||||
intraDate: intraDate,
|
||||
));
|
||||
final delay = fetchInsightsDelay;
|
||||
if (delay != null) {
|
||||
await Future<void>.delayed(delay);
|
||||
}
|
||||
final failure = error;
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
@@ -65,6 +100,10 @@ class FakeInsightsRepository extends Fake implements InsightsRepository {
|
||||
@override
|
||||
Future<IntraChartModel> fetchIntra({String? slug, String? intraDate}) async {
|
||||
intraRequests.add((slug: slug, date: intraDate));
|
||||
final delay = fetchIntraDelay;
|
||||
if (delay != null) {
|
||||
await Future<void>.delayed(delay);
|
||||
}
|
||||
final failure = intraError ?? error;
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
@@ -83,6 +122,10 @@ class FakeInsightsRepository extends Fake implements InsightsRepository {
|
||||
required int waistCircumference,
|
||||
required int hipCircumference,
|
||||
}) async {
|
||||
addedCircumferences.add((chest: chestCircumference, waist: waistCircumference, hip: hipCircumference));
|
||||
addedCircumferences.add((
|
||||
chest: chestCircumference,
|
||||
waist: waistCircumference,
|
||||
hip: hipCircumference,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user