diff --git a/lib/features/diary/presentation/pages/diary_screen.dart b/lib/features/diary/presentation/pages/diary_screen.dart index 41818a20..8b9431b6 100644 --- a/lib/features/diary/presentation/pages/diary_screen.dart +++ b/lib/features/diary/presentation/pages/diary_screen.dart @@ -137,6 +137,7 @@ class _DiaryScreenContentState extends State { 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 { /// когда в том же дне меняются приёмы пищи или время подъёма/отбоя: от них /// зависят КБЖУ-графики. Чужой дневник консультант не редактирует. 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, ), ); } diff --git a/lib/features/insights/presentation/charts/bmi_chart.dart b/lib/features/insights/presentation/charts/bmi_chart.dart index 62f3ee92..eedfbf3e 100644 --- a/lib/features/insights/presentation/charts/bmi_chart.dart +++ b/lib/features/insights/presentation/charts/bmi_chart.dart @@ -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: >[ - SplineAreaSeries( + series: >[ + SplineAreaSeries( 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), diff --git a/lib/features/insights/presentation/charts/daily_kbju_chart.dart b/lib/features/insights/presentation/charts/daily_kbju_chart.dart index b0d3db46..dca6d310 100644 --- a/lib/features/insights/presentation/charts/daily_kbju_chart.dart +++ b/lib/features/insights/presentation/charts/daily_kbju_chart.dart @@ -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: >[ + series: >[ if (_isVisible(0)) - SplineAreaSeries( + SplineAreaSeries( 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( + SplineSeries( 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, ), diff --git a/lib/features/insights/presentation/charts/insights_chart_common.dart b/lib/features/insights/presentation/charts/insights_chart_common.dart index 4128a7b1..07a2c35a 100644 --- a/lib/features/insights/presentation/charts/insights_chart_common.dart +++ b/lib/features/insights/presentation/charts/insights_chart_common.dart @@ -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 buildPoints(List labels, List 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 values, { double minPadding = 1, + bool clampZero = false, }) { if (values.isEmpty) { return (min: 0, max: 1); @@ -29,26 +44,56 @@ List buildPoints(List labels, List 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 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, diff --git a/lib/features/insights/presentation/charts/intra_day_chart.dart b/lib/features/insights/presentation/charts/intra_day_chart.dart index 813d0e98..05d90a78 100644 --- a/lib/features/insights/presentation/charts/intra_day_chart.dart +++ b/lib/features/insights/presentation/charts/intra_day_chart.dart @@ -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( diff --git a/lib/features/insights/presentation/charts/measure_chart.dart b/lib/features/insights/presentation/charts/measure_chart.dart index c0dcc562..33bd3e35 100644 --- a/lib/features/insights/presentation/charts/measure_chart.dart +++ b/lib/features/insights/presentation/charts/measure_chart.dart @@ -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: >[ + series: >[ for (var i = 0; i < series.length; i++) if (_isVisible(i)) - SplineSeries( + SplineSeries( 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), diff --git a/lib/features/insights/presentation/charts/weight_chart.dart b/lib/features/insights/presentation/charts/weight_chart.dart index 37f57bc4..0a85860a 100644 --- a/lib/features/insights/presentation/charts/weight_chart.dart +++ b/lib/features/insights/presentation/charts/weight_chart.dart @@ -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: >[ - SplineAreaSeries( + series: >[ + SplineAreaSeries( 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), diff --git a/lib/features/insights/presentation/cubit/insights_cubit.dart b/lib/features/insights/presentation/cubit/insights_cubit.dart index b388d51b..4c1a4cd1 100644 --- a/lib/features/insights/presentation/cubit/insights_cubit.dart +++ b/lib/features/insights/presentation/cubit/insights_cubit.dart @@ -29,18 +29,26 @@ class InsightsCubit extends Cubit { bool _isHeightLoaded = false; int? _heightCm; - int _requestId = 0; + + /// Полный payload и вкладка «В течение дня» координируются отдельно: смена + /// дня не должна отменять загрузку периода, и наоборот. + int _fullRequestId = 0; + int _intraRequestId = 0; + int _inflightFull = 0; + int _inflightIntra = 0; List get panels => InsightsPanel.available(kbjuEnabled: kbjuEnabled); - Future load() async { + bool get _isBusy => _inflightFull > 0 || _inflightIntra > 0; + + Future 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 { 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 { bodyPeriod: period, dailyPeriod: current.dailyPeriod, intraDate: current.intraDate, - previous: current, + isInitial: false, ); } @@ -89,7 +96,7 @@ class InsightsCubit extends Cubit { bodyPeriod: current.bodyPeriod, dailyPeriod: period, intraDate: current.intraDate, - previous: current, + isInitial: false, ); } @@ -110,51 +117,87 @@ class InsightsCubit extends Cubit { 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 _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 { 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)); } } } diff --git a/lib/features/insights/presentation/widgets/insights_card.dart b/lib/features/insights/presentation/widgets/insights_card.dart index eb3fbb67..df1d1fa9 100644 --- a/lib/features/insights/presentation/widgets/insights_card.dart +++ b/lib/features/insights/presentation/widgets/insights_card.dart @@ -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(); if (oldWidget.refreshToken != widget.refreshToken) { - context.read().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); + } } } diff --git a/lib/features/insights/repositories/insights_repository.dart b/lib/features/insights/repositories/insights_repository.dart index ef174ce8..4da18a0d 100644 --- a/lib/features/insights/repositories/insights_repository.dart +++ b/lib/features/insights/repositories/insights_repository.dart @@ -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 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 getInsights({ - String? slug, - required BodyPeriod bodyPeriod, - int? heightCm, - }) async { + Future 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 fetchInsights({ - String? slug, - required BodyPeriod bodyPeriod, - required DailyPeriod dailyPeriod, - String? intraDate, - }) async { + Future 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 addCircumference({ - required int chestCircumference, - required int waistCircumference, - required int hipCircumference, - }) { + Future addCircumference({required int chestCircumference, required int waistCircumference, required int hipCircumference}) { return profileRepository.addCircumference( chestCircumference: chestCircumference, waistCircumference: waistCircumference, diff --git a/test/features/diary/presentation/pages/diary_screen_smoke_test.dart b/test/features/diary/presentation/pages/diary_screen_smoke_test.dart index 4a0afc24..e2e2ad49 100644 --- a/test/features/diary/presentation/pages/diary_screen_smoke_test.dart +++ b/test/features/diary/presentation/pages/diary_screen_smoke_test.dart @@ -49,7 +49,9 @@ void main() { AppConfig(buildType: AppBuildType.tests) ..setBaseUrl('http://localhost'), ); - sl.registerLazySingleton(() => FakeInsightsRepository()); + sl.registerLazySingleton( + () => 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.empty()); + when( + diaryCubit.stream, + ).thenAnswer((_) => diaryStates ?? const Stream.empty()); when(diaryCubit.close()).thenAnswer((_) async => Future.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.empty()); + when(commentsCubit.state).thenReturn( + ConsultantCommentsState( + commentsModel: ConsultantCommentModel(comment: ''), + ), + ); + when( + commentsCubit.stream, + ).thenAnswer((_) => const Stream.empty()); when(commentsCubit.close()).thenAnswer((_) async => Future.value()); sl.registerFactory(() => diaryCubit); @@ -420,43 +436,84 @@ void main() { sl.registerLazySingleton(() => 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(find.byType(InsightsCard)).slug, isNull); - expect(tester.widget(find.byType(InsightsCard)).canAdd, isTrue); - expect(insightsRepository.serverRequests.single.slug, isNull); - }); + expect(find.byType(InsightsCard), findsOneWidget); + final card = tester.widget(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(find.byType(InsightsCard)); - expect(card.slug, 'client'); - expect(card.canAdd, isFalse); - expect(insightsRepository.serverRequests.single.slug, 'client'); - }); + final card = tester.widget(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(find.byType(InsightsCard)).slug, 'client-from-diary'); - expect(insightsRepository.serverRequests.single.slug, 'client-from-diary'); - }); + expect( + tester.widget(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(find.byType(InsightsCard)).slug, 'from-diary'); + expect( + tester.widget(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.broadcast(); - addTearDown(states.close); + testWidgets( + 'own diary refreshes insights when meals or wake-up time change', + (tester) async { + final insightsRepository = FakeInsightsRepository(); + final states = StreamController.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.broadcast(); - addTearDown(states.close); + testWidgets( + 'own diary does not refresh insights when nothing relevant changed', + (tester) async { + final insightsRepository = FakeInsightsRepository(); + final states = StreamController.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.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); }); diff --git a/test/features/insights/presentation/charts/insights_chart_common_test.dart b/test/features/insights/presentation/charts/insights_chart_common_test.dart new file mode 100644 index 00000000..d3e0e435 --- /dev/null +++ b/test/features/insights/presentation/charts/insights_chart_common_test.dart @@ -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)); + }); + }); +} diff --git a/test/features/insights/presentation/cubit/insights_cubit_server_test.dart b/test/features/insights/presentation/cubit/insights_cubit_server_test.dart index 6af6cf58..8382c534 100644 --- a/test/features/insights/presentation/cubit/insights_cubit_server_test.dart +++ b/test/features/insights/presentation/cubit/insights_cubit_server_test.dart @@ -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.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(); diff --git a/test/features/insights/presentation/cubit/insights_cubit_test.dart b/test/features/insights/presentation/cubit/insights_cubit_test.dart index bd1d8469..f23d9b0f 100644 --- a/test/features/insights/presentation/cubit/insights_cubit_test.dart +++ b/test/features/insights/presentation/cubit/insights_cubit_test.dart @@ -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(); diff --git a/test/features/insights/presentation/widgets/insights_kbju_test.dart b/test/features/insights/presentation/widgets/insights_kbju_test.dart index 7c0f8cae..deb66788 100644 --- a/test/features/insights/presentation/widgets/insights_kbju_test.dart +++ b/test/features/insights/presentation/widgets/insights_kbju_test.dart @@ -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( - find.descendant( - of: find.byKey(Key(key)), - matching: find.byType(IconButton), - ), - ); + IconButton buttonIn(String key) => tester.widget( + 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( + find.byKey(const Key('insights_intra_chart')), + ) + .primaryXAxis + as NumericAxis; + expect(xAxis.plotBands, isNotEmpty); + }); + testWidgets('пустой день показывает заглушку', (tester) async { await pumpKbjuCard( tester, diff --git a/test/fixtures/doubles/insights_doubles.dart b/test/fixtures/doubles/insights_doubles.dart index 3e97a70d..5e07a3a8 100644 --- a/test/fixtures/doubles/insights_doubles.dart +++ b/test/fixtures/doubles/insights_doubles.dart @@ -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; +InsightsPayloadModel loadInsightsPayloadFixture([ + String name = 'insights_payload.json', +]) { + final json = + jsonDecode(File('test/fixtures/insights/$name').readAsStringSync()) + as Map; 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 addedWeights = []; final List<({int chest, int waist, int hip})> addedCircumferences = []; @@ -38,7 +60,11 @@ class FakeInsightsRepository extends Fake implements InsightsRepository { Future getHeightCm({String? slug}) async => heightCm; @override - Future getInsights({String? slug, required BodyPeriod bodyPeriod, int? heightCm}) async { + Future 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.delayed(delay); + } final failure = error; if (failure != null) { throw failure; @@ -65,6 +100,10 @@ class FakeInsightsRepository extends Fake implements InsightsRepository { @override Future fetchIntra({String? slug, String? intraDate}) async { intraRequests.add((slug: slug, date: intraDate)); + final delay = fetchIntraDelay; + if (delay != null) { + await Future.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, + )); } }