forked from eatvio/eatvio_app
feat(insights): first isights impemetation
This commit is contained in:
184
lib/features/insights/presentation/cubit/insights_cubit.dart
Normal file
184
lib/features/insights/presentation/cubit/insights_cubit.dart
Normal file
@@ -0,0 +1,184 @@
|
||||
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_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;
|
||||
int _requestId = 0;
|
||||
|
||||
List<InsightsPanel> get panels =>
|
||||
InsightsPanel.available(kbjuEnabled: kbjuEnabled);
|
||||
|
||||
Future<void> load() async {
|
||||
emit(const InsightsState.loading());
|
||||
await _fetch(bodyPeriod: BodyPeriod.threeMonths, previous: null);
|
||||
}
|
||||
|
||||
/// Перезапрашивает данные, сохраняя вкладку, период, серии и раскрытие.
|
||||
Future<void> refresh() async {
|
||||
final current = state;
|
||||
if (current is! InsightsStateLoaded) {
|
||||
return load();
|
||||
}
|
||||
emit(current.copyWith(isRefreshing: true));
|
||||
await _fetch(bodyPeriod: current.bodyPeriod, previous: current);
|
||||
}
|
||||
|
||||
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, previous: current);
|
||||
}
|
||||
|
||||
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<void> _fetch({
|
||||
required BodyPeriod bodyPeriod,
|
||||
required InsightsStateLoaded? previous,
|
||||
}) async {
|
||||
final requestId = ++_requestId;
|
||||
try {
|
||||
if (!_isHeightLoaded) {
|
||||
_heightCm = await repository.getHeightCm(slug: slug);
|
||||
_isHeightLoaded = true;
|
||||
}
|
||||
final payload = await repository.getInsights(
|
||||
slug: slug,
|
||||
bodyPeriod: bodyPeriod,
|
||||
heightCm: _heightCm,
|
||||
);
|
||||
if (requestId != _requestId || isClosed) {
|
||||
return;
|
||||
}
|
||||
emit(
|
||||
previous == null
|
||||
? InsightsState.loaded(payload: payload, bodyPeriod: bodyPeriod)
|
||||
: previous.copyWith(
|
||||
payload: payload,
|
||||
bodyPeriod: bodyPeriod,
|
||||
isRefreshing: false,
|
||||
),
|
||||
);
|
||||
} on InsightsForbidden {
|
||||
if (requestId == _requestId && !isClosed) {
|
||||
emit(const InsightsState.hidden());
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString(), stackTrace: st);
|
||||
if (requestId != _requestId || isClosed) {
|
||||
return;
|
||||
}
|
||||
// Ошибка перезапроса не должна стирать уже показанные данные.
|
||||
emit(
|
||||
previous == null
|
||||
? InsightsState.error(message: e.toString())
|
||||
: previous.copyWith(isRefreshing: false),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
18
lib/features/insights/presentation/cubit/insights_panel.dart
Normal file
18
lib/features/insights/presentation/cubit/insights_panel.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
/// Вкладки блока «Аналитика».
|
||||
enum InsightsPanel {
|
||||
weight,
|
||||
bmi,
|
||||
measures,
|
||||
daily,
|
||||
intra;
|
||||
|
||||
/// Вкладки КБЖУ доступны только при включённом флаге.
|
||||
bool get isKbju => this == daily || this == intra;
|
||||
|
||||
/// Кнопка «+» есть только у вкладок с ручным вводом.
|
||||
bool get supportsAdd => this == weight || this == measures;
|
||||
|
||||
static List<InsightsPanel> available({required bool kbjuEnabled}) {
|
||||
return values.where((panel) => kbjuEnabled || !panel.isKbju).toList();
|
||||
}
|
||||
}
|
||||
47
lib/features/insights/presentation/cubit/insights_state.dart
Normal file
47
lib/features/insights/presentation/cubit/insights_state.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
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:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'insights_state.freezed.dart';
|
||||
|
||||
/// Видимость серий (чипы под графиком): `true` — серия показана.
|
||||
@freezed
|
||||
abstract class InsightsSeriesVisibility with _$InsightsSeriesVisibility {
|
||||
const factory InsightsSeriesVisibility({
|
||||
/// Талия, Грудь, Бёдра.
|
||||
@Default([true, true, true]) List<bool> measure,
|
||||
|
||||
/// Калории, Белки, Жиры, Углеводы.
|
||||
@Default([true, true, true, true]) List<bool> daily,
|
||||
|
||||
/// Белки, Жиры, Углеводы, Сытость, Настроение.
|
||||
@Default([true, true, true, true, true]) List<bool> intra,
|
||||
}) = _InsightsSeriesVisibility;
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class InsightsState with _$InsightsState {
|
||||
const factory InsightsState.initial() = InsightsStateInitial;
|
||||
|
||||
const factory InsightsState.loading() = InsightsStateLoading;
|
||||
|
||||
/// 403: данные недоступны, блок не показывается.
|
||||
const factory InsightsState.hidden() = InsightsStateHidden;
|
||||
|
||||
const factory InsightsState.error({required String message}) =
|
||||
InsightsStateError;
|
||||
|
||||
const factory InsightsState.loaded({
|
||||
required InsightsPayloadModel payload,
|
||||
@Default(BodyPeriod.threeMonths) BodyPeriod bodyPeriod,
|
||||
@Default(InsightsPanel.weight) InsightsPanel panel,
|
||||
@Default(InsightsSeriesVisibility())
|
||||
InsightsSeriesVisibility seriesVisibility,
|
||||
@Default(true) bool showSleep,
|
||||
@Default(false) bool isExpanded,
|
||||
|
||||
/// Идёт перезапрос (смена периода / обновление) — старые данные остаются на экране.
|
||||
@Default(false) bool isRefreshing,
|
||||
}) = InsightsStateLoaded;
|
||||
}
|
||||
Reference in New Issue
Block a user