forked from eatvio/eatvio_app
401 lines
13 KiB
Dart
401 lines
13 KiB
Dart
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));
|
||
}
|
||
}
|
||
}
|