forked from eatvio/eatvio_app
594 lines
19 KiB
Dart
594 lines
19 KiB
Dart
import 'package:eatvio_app/core/locator_service.dart';
|
||
import 'package:eatvio_app/core/utils/toasts.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/charts/bmi_chart.dart';
|
||
import 'package:eatvio_app/features/insights/presentation/charts/daily_kbju_chart.dart';
|
||
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
|
||
import 'package:eatvio_app/features/insights/presentation/charts/intra_day_chart.dart';
|
||
import 'package:eatvio_app/features/insights/presentation/charts/measure_chart.dart';
|
||
import 'package:eatvio_app/features/insights/presentation/charts/weight_chart.dart';
|
||
import 'package:eatvio_app/features/insights/presentation/cubit/insights_cubit.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/presentation/widgets/add_circumference_bottom_sheet.dart';
|
||
import 'package:eatvio_app/features/insights/presentation/widgets/add_weight_bottom_sheet.dart';
|
||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_fab.dart';
|
||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_head.dart';
|
||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_tabs.dart';
|
||
import 'package:eatvio_app/features/insights/presentation/widgets/intra_date_nav.dart';
|
||
import 'package:eatvio_app/features/insights/presentation/widgets/intra_legend.dart';
|
||
import 'package:eatvio_app/features/insights/presentation/widgets/period_pills.dart';
|
||
import 'package:eatvio_app/features/insights/presentation/widgets/series_chips.dart';
|
||
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
|
||
import 'package:eatvio_app/generated/l10n.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||
|
||
/// Сворачиваемый блок «Аналитика» (вес, ИМТ, замеры).
|
||
///
|
||
/// [slug] — владелец данных (`null` — текущий пользователь). При 403 блок
|
||
/// скрывается целиком. [canAdd] включает кнопку «+» на вкладках «Вес» и «Замеры».
|
||
class InsightsCard extends StatelessWidget {
|
||
const InsightsCard({
|
||
super.key,
|
||
this.slug,
|
||
this.canAdd = false,
|
||
this.repository,
|
||
this.kbjuEnabled = InsightsFeatureFlags.kbjuEnabled,
|
||
});
|
||
|
||
final String? slug;
|
||
final bool canAdd;
|
||
final InsightsRepository? repository;
|
||
final bool kbjuEnabled;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return KeyedSubtree(
|
||
key: ValueKey('insights_card_$slug'),
|
||
child: BlocProvider<InsightsCubit>(
|
||
create: (_) => InsightsCubit(
|
||
repository: repository ?? sl<InsightsRepository>(),
|
||
slug: slug,
|
||
canAdd: canAdd,
|
||
kbjuEnabled: kbjuEnabled,
|
||
)..load(),
|
||
child: const _InsightsView(),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _InsightsView extends StatelessWidget {
|
||
const _InsightsView();
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return BlocBuilder<InsightsCubit, InsightsState>(
|
||
builder: (context, state) {
|
||
return switch (state) {
|
||
InsightsStateLoaded() => _CardShell(
|
||
child: _LoadedContent(state: state),
|
||
),
|
||
InsightsStateError() => const _CardShell(child: _ErrorContent()),
|
||
_ => const SizedBox.shrink(),
|
||
};
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
class _CardShell extends StatelessWidget {
|
||
const _CardShell({required this.child});
|
||
|
||
final Widget child;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
key: const Key('insights_card'),
|
||
width: double.infinity,
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(8),
|
||
boxShadow: const [
|
||
BoxShadow(
|
||
color: InsightsColors.cardShadow,
|
||
blurRadius: 4,
|
||
offset: Offset(0, 2),
|
||
),
|
||
],
|
||
),
|
||
child: child,
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ErrorContent extends StatelessWidget {
|
||
const _ErrorContent();
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final s = S.of(context);
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
s.insights_load_error,
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
color: InsightsColors.subtitle,
|
||
),
|
||
),
|
||
),
|
||
TextButton(
|
||
key: const Key('insights_retry'),
|
||
onPressed: () => context.read<InsightsCubit>().load(),
|
||
child: Text(s.insights_retry),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _LoadedContent extends StatelessWidget {
|
||
const _LoadedContent({required this.state});
|
||
|
||
final InsightsStateLoaded state;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cubit = context.read<InsightsCubit>();
|
||
final showFab = cubit.canAdd && state.panel.supportsAdd;
|
||
|
||
return Stack(
|
||
children: [
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_Toggle(isExpanded: state.isExpanded, onTap: cubit.toggleExpanded),
|
||
AnimatedSize(
|
||
duration: const Duration(milliseconds: 200),
|
||
alignment: Alignment.topCenter,
|
||
child: state.isExpanded
|
||
? Padding(
|
||
padding: EdgeInsets.fromLTRB(
|
||
20,
|
||
0,
|
||
20,
|
||
showFab ? 24 + 44 : 24,
|
||
),
|
||
child: _Body(state: state),
|
||
)
|
||
: const SizedBox(width: double.infinity),
|
||
),
|
||
],
|
||
),
|
||
if (showFab && state.isExpanded)
|
||
Positioned(
|
||
right: 16,
|
||
bottom: 16,
|
||
child: InsightsFab(
|
||
tooltip: S.of(context).insights_add_record,
|
||
onPressed: () => _onAddPressed(context, cubit, state),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
void _onAddPressed(
|
||
BuildContext context,
|
||
InsightsCubit cubit,
|
||
InsightsStateLoaded state,
|
||
) {
|
||
final s = S.of(context);
|
||
if (state.panel == InsightsPanel.weight) {
|
||
showAddWeightBottomSheet(
|
||
context,
|
||
initialWeight: state.payload.weight.last,
|
||
onSave: (weight) async {
|
||
if (!await cubit.addWeight(weight: weight)) {
|
||
showErrorToast(s.insights_save_error);
|
||
}
|
||
},
|
||
);
|
||
} else if (state.panel == InsightsPanel.measures) {
|
||
final measure = state.payload.measure;
|
||
showAddCircumferenceBottomSheet(
|
||
context,
|
||
initial:
|
||
measure.waist.isEmpty ||
|
||
measure.chest.isEmpty ||
|
||
measure.hips.isEmpty
|
||
? null
|
||
: (
|
||
chest: measure.chest.last.round(),
|
||
waist: measure.waist.last.round(),
|
||
hip: measure.hips.last.round(),
|
||
),
|
||
onSave: (values) async {
|
||
final isSaved = await cubit.addCircumference(
|
||
chestCircumference: values.chest,
|
||
waistCircumference: values.waist,
|
||
hipCircumference: values.hip,
|
||
);
|
||
if (!isSaved) {
|
||
showErrorToast(s.insights_save_error);
|
||
}
|
||
},
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
class _Toggle extends StatelessWidget {
|
||
const _Toggle({required this.isExpanded, required this.onTap});
|
||
|
||
final bool isExpanded;
|
||
final VoidCallback onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final s = S.of(context);
|
||
return InkWell(
|
||
key: const Key('insights_toggle'),
|
||
borderRadius: BorderRadius.circular(8),
|
||
onTap: onTap,
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 34,
|
||
height: 34,
|
||
decoration: BoxDecoration(
|
||
color: InsightsColors.accentBackground,
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: const Icon(
|
||
Icons.show_chart_rounded,
|
||
size: 20,
|
||
color: InsightsColors.blue,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
s.insights_title,
|
||
style: const TextStyle(
|
||
fontSize: 17,
|
||
fontWeight: FontWeight.w700,
|
||
color: InsightsColors.title,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
key: const Key('insights_subtitle'),
|
||
isExpanded
|
||
? s.insights_subtitle_open
|
||
: s.insights_subtitle_closed,
|
||
style: const TextStyle(
|
||
fontSize: 12.5,
|
||
fontWeight: FontWeight.w600,
|
||
color: InsightsColors.subtitle,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
AnimatedRotation(
|
||
turns: isExpanded ? 0 : 0.5,
|
||
duration: const Duration(milliseconds: 200),
|
||
child: const Icon(
|
||
Icons.keyboard_arrow_up_rounded,
|
||
size: 26,
|
||
color: InsightsColors.blue,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _Body extends StatelessWidget {
|
||
const _Body({required this.state});
|
||
|
||
final InsightsStateLoaded state;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cubit = context.read<InsightsCubit>();
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
InsightsTabs(
|
||
panels: cubit.panels,
|
||
selected: state.panel,
|
||
onSelected: cubit.setPanel,
|
||
),
|
||
const SizedBox(height: 12),
|
||
AnimatedOpacity(
|
||
duration: const Duration(milliseconds: 150),
|
||
opacity: state.isRefreshing ? 0.5 : 1,
|
||
child: _PanelContent(state: state, canAdd: cubit.canAdd),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _PanelContent extends StatelessWidget {
|
||
const _PanelContent({required this.state, required this.canAdd});
|
||
|
||
final InsightsStateLoaded state;
|
||
final bool canAdd;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cubit = context.read<InsightsCubit>();
|
||
final s = S.of(context);
|
||
final payload = state.payload;
|
||
|
||
final Widget content;
|
||
switch (state.panel) {
|
||
case InsightsPanel.weight:
|
||
content = Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
InsightsHead(
|
||
headline: payload.weight.headline,
|
||
unit: payload.weight.unit,
|
||
deltaText: payload.weight.deltaText,
|
||
isDeltaHighlighted: payload.weight.delta != null,
|
||
),
|
||
const SizedBox(height: 12),
|
||
WeightChart(
|
||
model: payload.weight,
|
||
emptyText: canAdd
|
||
? s.insights_add_weight_empty
|
||
: s.insights_not_enough,
|
||
),
|
||
],
|
||
);
|
||
case InsightsPanel.bmi:
|
||
content = Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
if (payload.bmi.values.isNotEmpty) ...[
|
||
InsightsHead(
|
||
headline: payload.bmi.headline,
|
||
unit: s.insights_unit_bmi,
|
||
deltaText: payload.bmi.deltaText,
|
||
isDeltaHighlighted: payload.bmi.delta != null,
|
||
),
|
||
const SizedBox(height: 12),
|
||
],
|
||
BmiChart(model: payload.bmi),
|
||
],
|
||
);
|
||
case InsightsPanel.measures:
|
||
final visibility = state.seriesVisibility.measure;
|
||
content = Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
InsightsHead(
|
||
headline: payload.measure.headline,
|
||
unit: s.insights_unit_cm_waist,
|
||
deltaText: payload.measure.deltaText,
|
||
isDeltaHighlighted: payload.measure.waist.length > 1,
|
||
),
|
||
const SizedBox(height: 12),
|
||
SeriesChips(
|
||
chips: [
|
||
SeriesChipData(
|
||
label: s.insights_series_waist,
|
||
color: InsightsColors.waist,
|
||
isVisible: visibility[0],
|
||
),
|
||
SeriesChipData(
|
||
label: s.insights_series_chest,
|
||
color: InsightsColors.chest,
|
||
isVisible: visibility[1],
|
||
),
|
||
SeriesChipData(
|
||
label: s.insights_series_hips,
|
||
color: InsightsColors.hips,
|
||
isVisible: visibility[2],
|
||
),
|
||
],
|
||
onToggle: (index) =>
|
||
cubit.toggleSeries(InsightsPanel.measures, index),
|
||
),
|
||
const SizedBox(height: 12),
|
||
MeasureChart(
|
||
model: payload.measure,
|
||
visibility: visibility,
|
||
emptyText: canAdd
|
||
? s.insights_add_measures_empty
|
||
: s.insights_not_enough,
|
||
),
|
||
],
|
||
);
|
||
case InsightsPanel.daily:
|
||
return _DailyPanel(state: state);
|
||
case InsightsPanel.intra:
|
||
return _IntraPanel(state: state);
|
||
}
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
PeriodPills<BodyPeriod>(
|
||
periods: BodyPeriod.values,
|
||
selected: state.bodyPeriod,
|
||
labelOf: (period) => period.pillLabel(s),
|
||
keyOf: (period) => period.apiValue,
|
||
onSelected: cubit.setBodyPeriod,
|
||
),
|
||
const SizedBox(height: 16),
|
||
content,
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// «КБЖУ по дням». Период пока фиксирован: перезапрос появится вместе с
|
||
/// эндпоинтом `GET /api/profile/insights` (итерация 2).
|
||
class _DailyPanel extends StatelessWidget {
|
||
const _DailyPanel({required this.state});
|
||
|
||
final InsightsStateLoaded state;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final s = S.of(context);
|
||
final cubit = context.read<InsightsCubit>();
|
||
final daily = state.payload.daily;
|
||
final visibility = state.seriesVisibility.daily;
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
PeriodPills<DailyPeriod>(
|
||
periods: DailyPeriod.values,
|
||
selected: DailyPeriod.twoWeeks,
|
||
labelOf: (period) => period.pillLabel(s),
|
||
keyOf: (period) => period.apiValue,
|
||
),
|
||
const SizedBox(height: 16),
|
||
if (daily == null)
|
||
InsightsChartPlaceholder(text: s.insights_not_enough)
|
||
else ...[
|
||
InsightsHead(
|
||
headline: daily.headline,
|
||
unit: s.insights_unit_kcal_avg,
|
||
deltaText: daily.deltaText,
|
||
isDeltaHighlighted: daily.percent != null,
|
||
),
|
||
const SizedBox(height: 12),
|
||
SeriesChips(
|
||
chips: [
|
||
SeriesChipData(
|
||
label: s.insights_series_calories,
|
||
color: InsightsColors.calories,
|
||
isVisible: visibility[0],
|
||
),
|
||
SeriesChipData(
|
||
label: s.insights_series_proteins,
|
||
color: InsightsColors.proteins,
|
||
isVisible: visibility[1],
|
||
),
|
||
SeriesChipData(
|
||
label: s.insights_series_fats,
|
||
color: InsightsColors.fats,
|
||
isVisible: visibility[2],
|
||
),
|
||
SeriesChipData(
|
||
label: s.insights_series_carbs,
|
||
color: InsightsColors.carbs,
|
||
isVisible: visibility[3],
|
||
),
|
||
],
|
||
onToggle: (index) => cubit.toggleSeries(InsightsPanel.daily, index),
|
||
),
|
||
const SizedBox(height: 12),
|
||
DailyKbjuChart(model: daily, visibility: visibility),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// «В течение дня». Навигация по датам неактивна до итерации 2.
|
||
class _IntraPanel extends StatelessWidget {
|
||
const _IntraPanel({required this.state});
|
||
|
||
final InsightsStateLoaded state;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final s = S.of(context);
|
||
final cubit = context.read<InsightsCubit>();
|
||
final intra = state.payload.intra;
|
||
final visibility = state.seriesVisibility.intra;
|
||
|
||
if (intra == null) {
|
||
return InsightsChartPlaceholder(text: s.insights_not_enough);
|
||
}
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
IntraDateNav(model: intra),
|
||
const SizedBox(height: 16),
|
||
SeriesChips(
|
||
chips: [
|
||
SeriesChipData(
|
||
label: s.insights_series_proteins,
|
||
color: InsightsColors.proteins,
|
||
isVisible: visibility[0],
|
||
),
|
||
SeriesChipData(
|
||
label: s.insights_series_fats,
|
||
color: InsightsColors.fats,
|
||
isVisible: visibility[1],
|
||
),
|
||
SeriesChipData(
|
||
label: s.insights_series_carbs,
|
||
color: InsightsColors.carbs,
|
||
isVisible: visibility[2],
|
||
),
|
||
SeriesChipData(
|
||
label: s.insights_series_satiety,
|
||
color: InsightsColors.satiety,
|
||
isVisible: visibility[3],
|
||
),
|
||
SeriesChipData(
|
||
label: s.insights_series_mood,
|
||
color: InsightsColors.mood,
|
||
isVisible: visibility[4],
|
||
),
|
||
SeriesChipData(
|
||
label: s.insights_series_sleep,
|
||
color: InsightsColors.sleepLine,
|
||
isVisible: state.showSleep,
|
||
),
|
||
],
|
||
onToggle: (index) {
|
||
if (index == 5) {
|
||
cubit.toggleSleep();
|
||
} else {
|
||
cubit.toggleSeries(InsightsPanel.intra, index);
|
||
}
|
||
},
|
||
),
|
||
const SizedBox(height: 12),
|
||
IntraDayChart(
|
||
model: intra,
|
||
visibility: visibility,
|
||
showSleep: state.showSleep,
|
||
),
|
||
const SizedBox(height: 8),
|
||
IntraLegend(model: intra),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
s.insights_intra_hint,
|
||
key: const Key('insights_intra_hint'),
|
||
style: const TextStyle(fontSize: 13, color: InsightsColors.subtitle),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|