Files
eatvio_app/lib/features/insights/presentation/cubit/insights_cubit.dart
2026-11-11 16:05:11 +03:00

401 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:developer';
import 'package:eatvio_app/core/error_reporting/report_from.dart';
import 'package:eatvio_app/features/insights/insights_feature_flags.dart';
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
import 'package:eatvio_app/features/insights/models/insights_period.dart';
import 'package:eatvio_app/features/insights/presentation/cubit/insights_panel.dart';
import 'package:eatvio_app/features/insights/presentation/cubit/insights_state.dart';
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class InsightsCubit extends Cubit<InsightsState> {
InsightsCubit({
required this.repository,
this.slug,
this.canAdd = false,
this.kbjuEnabled = InsightsFeatureFlags.kbjuEnabled,
}) : super(const InsightsState.initial());
final InsightsRepository repository;
/// `null` — собственные данные, иначе slug владельца (консультант → клиент).
final String? slug;
/// Показывать ли кнопку «+» (только владелец).
final bool canAdd;
final bool kbjuEnabled;
bool _isHeightLoaded = false;
int? _heightCm;
/// Полный payload и вкладка «В течение дня» координируются отдельно: смена
/// дня не должна отменять загрузку периода, и наоборот.
int _fullRequestId = 0;
int _intraRequestId = 0;
int _inflightFull = 0;
int _inflightIntra = 0;
List<InsightsPanel> get panels =>
InsightsPanel.available(kbjuEnabled: kbjuEnabled);
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: intraDate,
isInitial: true,
);
}
/// Перезапрашивает данные, сохраняя вкладку, период, серии и раскрытие.
Future<void> refresh() async {
final current = state;
if (current is InsightsStateLoading || current is InsightsStateHidden) {
return;
}
if (current is! InsightsStateLoaded) {
return load();
}
await _fetch(
bodyPeriod: current.bodyPeriod,
dailyPeriod: current.dailyPeriod,
intraDate: current.intraDate,
isInitial: false,
);
}
Future<void> setBodyPeriod(BodyPeriod period) async {
final current = state;
if (current is! InsightsStateLoaded || current.bodyPeriod == period) {
return;
}
emit(current.copyWith(bodyPeriod: period, isRefreshing: true));
await _fetch(
bodyPeriod: period,
dailyPeriod: current.dailyPeriod,
intraDate: current.intraDate,
isInitial: false,
);
}
/// Период вкладки «КБЖУ по дням». Работает только с серверными данными.
Future<void> setDailyPeriod(DailyPeriod period) async {
final current = state;
if (!kbjuEnabled ||
current is! InsightsStateLoaded ||
current.dailyPeriod == period) {
return;
}
emit(current.copyWith(dailyPeriod: period, isRefreshing: true));
await _fetch(
bodyPeriod: current.bodyPeriod,
dailyPeriod: period,
intraDate: current.intraDate,
isInitial: false,
);
}
/// Показывает вкладку «В течение дня» за [day] (запрашивается только она).
Future<void> setIntraDate(DateTime day) {
return _loadIntra(_formatDate(day));
}
Future<void> showPreviousDay() async {
final base = _currentIntraDay();
if (base != null) {
await setIntraDate(DateTime(base.year, base.month, base.day - 1));
}
}
Future<void> showNextDay() async {
final current = state;
final base = _currentIntraDay();
if (base == null ||
current is! InsightsStateLoaded ||
!_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 = ++_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 != _intraRequestId || isClosed) {
return;
}
final latest = state;
if (latest is! InsightsStateLoaded) {
return;
}
emit(
latest.copyWith(
payload: latest.payload.copyWith(intra: intra),
intraDate: intra.isToday
? null
: (intra.date.isEmpty ? date : intra.date),
isRefreshing: _inflightFull > 0 || _inflightIntra > 1,
),
);
} on InsightsForbidden {
if (requestId == _intraRequestId && !isClosed) {
emit(const InsightsState.hidden());
}
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString(), stackTrace: st);
if (requestId == _intraRequestId && !isClosed) {
final latest = state;
if (latest is InsightsStateLoaded) {
emit(
latest.copyWith(
intraDate: previousIntraDate,
isRefreshing: _inflightFull > 0 || _inflightIntra > 1,
),
);
}
}
} finally {
_inflightIntra--;
_syncRefreshingFlag();
}
}
static String _formatDate(DateTime day) {
final month = day.month.toString().padLeft(2, '0');
final dayOfMonth = day.day.toString().padLeft(2, '0');
return '${day.year.toString().padLeft(4, '0')}-$month-$dayOfMonth';
}
void toggleExpanded() {
final current = state;
if (current is InsightsStateLoaded) {
emit(current.copyWith(isExpanded: !current.isExpanded));
}
}
void setPanel(InsightsPanel panel) {
final current = state;
if (current is! InsightsStateLoaded || !panels.contains(panel)) {
return;
}
emit(current.copyWith(panel: panel));
}
/// Включает/выключает серию [index] на вкладке [panel].
void toggleSeries(InsightsPanel panel, int index) {
final current = state;
if (current is! InsightsStateLoaded) {
return;
}
final visibility = current.seriesVisibility;
final source = switch (panel) {
InsightsPanel.measures => visibility.measure,
InsightsPanel.daily => visibility.daily,
InsightsPanel.intra => visibility.intra,
_ => null,
};
if (source == null || index < 0 || index >= source.length) {
return;
}
final updated = [...source]..[index] = !source[index];
final nextVisibility = switch (panel) {
InsightsPanel.measures => visibility.copyWith(measure: updated),
InsightsPanel.daily => visibility.copyWith(daily: updated),
_ => visibility.copyWith(intra: updated),
};
emit(current.copyWith(seriesVisibility: nextVisibility));
}
void toggleSleep() {
final current = state;
if (current is InsightsStateLoaded) {
emit(current.copyWith(showSleep: !current.showSleep));
}
}
/// Сохраняет вес и обновляет данные. Возвращает `false`, если сохранить не удалось.
Future<bool> addWeight({required double weight}) {
return _add(() => repository.addWeight(weight: weight));
}
/// Сохраняет замеры и обновляет данные. Возвращает `false`, если сохранить не удалось.
Future<bool> addCircumference({
required int chestCircumference,
required int waistCircumference,
required int hipCircumference,
}) {
return _add(
() => repository.addCircumference(
chestCircumference: chestCircumference,
waistCircumference: waistCircumference,
hipCircumference: hipCircumference,
),
);
}
Future<bool> _add(Future<void> Function() action) async {
try {
await action();
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString(), stackTrace: st);
return false;
}
await refresh();
return true;
}
Future<InsightsPayloadModel> _requestPayload({
required BodyPeriod bodyPeriod,
required DailyPeriod dailyPeriod,
required String? intraDate,
}) async {
if (kbjuEnabled) {
return repository.fetchInsights(
slug: slug,
bodyPeriod: bodyPeriod,
dailyPeriod: dailyPeriod,
intraDate: intraDate,
);
}
if (!_isHeightLoaded) {
_heightCm = await repository.getHeightCm(slug: slug);
_isHeightLoaded = true;
}
return repository.getInsights(
slug: slug,
bodyPeriod: bodyPeriod,
heightCm: _heightCm,
);
}
Future<void> _fetch({
required BodyPeriod bodyPeriod,
required DailyPeriod dailyPeriod,
required String? intraDate,
required bool isInitial,
}) async {
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 != _fullRequestId || isClosed) {
return;
}
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 == _fullRequestId && !isClosed) {
emit(const InsightsState.hidden());
}
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString(), stackTrace: st);
if (requestId != _fullRequestId || isClosed) {
return;
}
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));
}
}
}