Files
eatvio_app/lib/features/insights/presentation/widgets/insights_card.dart
2026-11-09 15:35:10 +03:00

594 lines
19 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 '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),
),
],
);
}
}