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,

View File

@@ -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);
});

View File

@@ -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));
});
});
}

View File

@@ -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();

View File

@@ -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();

View File

@@ -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,

View File

@@ -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,
));
}
}