forked from eatvio/eatvio_app
feat(insights): first isights impemetation
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const int _minValue = 20;
|
||||
const int _maxValue = 199;
|
||||
|
||||
/// Значения замеров в сантиметрах.
|
||||
typedef CircumferenceValues = ({int chest, int waist, int hip});
|
||||
|
||||
/// Выбор замеров груди, талии и бёдер. [onSave] вызывается после закрытия шторки.
|
||||
Future<void> showAddCircumferenceBottomSheet(
|
||||
BuildContext context, {
|
||||
CircumferenceValues? initial,
|
||||
required Future<void> Function(CircumferenceValues values) onSave,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
builder: (_) =>
|
||||
AddCircumferenceBottomSheet(initial: initial, onSave: onSave),
|
||||
);
|
||||
}
|
||||
|
||||
class AddCircumferenceBottomSheet extends StatefulWidget {
|
||||
const AddCircumferenceBottomSheet({
|
||||
super.key,
|
||||
this.initial,
|
||||
required this.onSave,
|
||||
});
|
||||
|
||||
final CircumferenceValues? initial;
|
||||
final Future<void> Function(CircumferenceValues values) onSave;
|
||||
|
||||
@override
|
||||
State<AddCircumferenceBottomSheet> createState() =>
|
||||
_AddCircumferenceBottomSheetState();
|
||||
}
|
||||
|
||||
class _AddCircumferenceBottomSheetState
|
||||
extends State<AddCircumferenceBottomSheet> {
|
||||
late final FixedExtentScrollController _chestController;
|
||||
late final FixedExtentScrollController _waistController;
|
||||
late final FixedExtentScrollController _hipController;
|
||||
late int _chest;
|
||||
late int _waist;
|
||||
late int _hip;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_chest = _clamp(widget.initial?.chest);
|
||||
_waist = _clamp(widget.initial?.waist);
|
||||
_hip = _clamp(widget.initial?.hip);
|
||||
_chestController = FixedExtentScrollController(
|
||||
initialItem: _chest - _minValue,
|
||||
);
|
||||
_waistController = FixedExtentScrollController(
|
||||
initialItem: _waist - _minValue,
|
||||
);
|
||||
_hipController = FixedExtentScrollController(initialItem: _hip - _minValue);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_chestController.dispose();
|
||||
_waistController.dispose();
|
||||
_hipController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
int _clamp(int? value) => (value ?? _minValue).clamp(_minValue, _maxValue);
|
||||
|
||||
void _save() {
|
||||
final values = (chest: _chest, waist: _waist, hip: _hip);
|
||||
Navigator.of(context).pop();
|
||||
widget.onSave(values);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final title in [
|
||||
s.insights_series_chest,
|
||||
s.insights_series_waist,
|
||||
s.insights_series_hips,
|
||||
])
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: Row(
|
||||
children: [
|
||||
_picker(
|
||||
const Key('add_circumference_chest_picker'),
|
||||
_chestController,
|
||||
(value) => _chest = value,
|
||||
),
|
||||
_picker(
|
||||
const Key('add_circumference_waist_picker'),
|
||||
_waistController,
|
||||
(value) => _waist = value,
|
||||
),
|
||||
_picker(
|
||||
const Key('add_circumference_hip_picker'),
|
||||
_hipController,
|
||||
(value) => _hip = value,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: InkWell(
|
||||
key: const Key('add_sheet_save'),
|
||||
onTap: _save,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.blueA400,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
s.save,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _picker(
|
||||
Key key,
|
||||
FixedExtentScrollController controller,
|
||||
ValueChanged<int> onChanged,
|
||||
) {
|
||||
return Expanded(
|
||||
child: CupertinoPicker(
|
||||
key: key,
|
||||
magnification: 1.22,
|
||||
squeeze: 1.2,
|
||||
useMagnifier: true,
|
||||
scrollController: controller,
|
||||
itemExtent: 24,
|
||||
onSelectedItemChanged: (index) => onChanged(index + _minValue),
|
||||
children: [for (int i = _minValue; i <= _maxValue; i++) Text('$i')],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const int _minKilo = 20;
|
||||
const int _maxKilo = 299;
|
||||
|
||||
/// Выбор веса (кг + десятые). [onSave] вызывается после закрытия шторки.
|
||||
Future<void> showAddWeightBottomSheet(
|
||||
BuildContext context, {
|
||||
double? initialWeight,
|
||||
required Future<void> Function(double weight) onSave,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
builder: (_) =>
|
||||
AddWeightBottomSheet(initialWeight: initialWeight, onSave: onSave),
|
||||
);
|
||||
}
|
||||
|
||||
class AddWeightBottomSheet extends StatefulWidget {
|
||||
const AddWeightBottomSheet({
|
||||
super.key,
|
||||
this.initialWeight,
|
||||
required this.onSave,
|
||||
});
|
||||
|
||||
final double? initialWeight;
|
||||
final Future<void> Function(double weight) onSave;
|
||||
|
||||
@override
|
||||
State<AddWeightBottomSheet> createState() => _AddWeightBottomSheetState();
|
||||
}
|
||||
|
||||
class _AddWeightBottomSheetState extends State<AddWeightBottomSheet> {
|
||||
late final FixedExtentScrollController _kiloController;
|
||||
late final FixedExtentScrollController _gramsController;
|
||||
late int _kilo;
|
||||
late int _grams;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final initial = widget.initialWeight;
|
||||
final tenths = initial == null
|
||||
? 0
|
||||
: (initial * 10).round().clamp(_minKilo * 10, _maxKilo * 10 + 9);
|
||||
_kilo = initial == null ? _minKilo : tenths ~/ 10;
|
||||
_grams = initial == null ? 0 : tenths % 10;
|
||||
_kiloController = FixedExtentScrollController(
|
||||
initialItem: _kilo - _minKilo,
|
||||
);
|
||||
_gramsController = FixedExtentScrollController(initialItem: _grams);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_kiloController.dispose();
|
||||
_gramsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _save() {
|
||||
final weight = double.parse('$_kilo.$_grams');
|
||||
Navigator.of(context).pop();
|
||||
widget.onSave(weight);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CupertinoPicker(
|
||||
key: const Key('add_weight_kilo_picker'),
|
||||
magnification: 1.22,
|
||||
squeeze: 1.2,
|
||||
useMagnifier: true,
|
||||
scrollController: _kiloController,
|
||||
itemExtent: 24,
|
||||
onSelectedItemChanged: (value) => _kilo = value + _minKilo,
|
||||
children: [
|
||||
for (int i = _minKilo; i <= _maxKilo; i++) Text('$i'),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: CupertinoPicker(
|
||||
key: const Key('add_weight_grams_picker'),
|
||||
magnification: 1.22,
|
||||
squeeze: 1.2,
|
||||
useMagnifier: true,
|
||||
scrollController: _gramsController,
|
||||
itemExtent: 24,
|
||||
onSelectedItemChanged: (value) => _grams = value,
|
||||
children: [for (int i = 0; i < 10; i++) Text('$i')],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_SaveButton(onTap: _save),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SaveButton extends StatelessWidget {
|
||||
const _SaveButton({required this.onTap});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: InkWell(
|
||||
key: const Key('add_sheet_save'),
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.blueA400,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
S.of(context).save,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
593
lib/features/insights/presentation/widgets/insights_card.dart
Normal file
593
lib/features/insights/presentation/widgets/insights_card.dart
Normal file
@@ -0,0 +1,593 @@
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/painting.dart';
|
||||
|
||||
/// Токены из `latest-tmpls/src/scss/pages/diary.scss` (`&__analytics*`).
|
||||
abstract final class InsightsColors {
|
||||
static const Color blue = Color(0xFF2F80ED);
|
||||
static const Color accentBackground = Color(0xFFF6FAFF);
|
||||
static const Color title = Color(0xFF323F4B);
|
||||
static const Color subtitle = Color(0xFF7B8794);
|
||||
static const Color border = Color(0xFFE6E6E6);
|
||||
static const Color positive = Color(0xFF019A3E);
|
||||
|
||||
static const Color calories = Color(0xFF323F4B);
|
||||
static const Color proteins = Color(0xFF2F80ED);
|
||||
static const Color fats = Color(0xFFF2994A);
|
||||
static const Color carbs = Color(0xFF0D6EFD);
|
||||
static const Color satiety = Color(0xFF019A3E);
|
||||
static const Color mood = Color(0xFFEB555C);
|
||||
|
||||
static const Color waist = Color(0xFF2F80ED);
|
||||
static const Color chest = Color(0xFF9B51E0);
|
||||
static const Color hips = Color(0xFFF2994A);
|
||||
|
||||
static const Color wake = Color(0xFFF2994A);
|
||||
static const Color sleepLine = Color(0xFF7B8794);
|
||||
static const Color sleepShade = Color(0x1A7B8794);
|
||||
|
||||
static const Color bmiNormalZone = Color(0x14019A3E);
|
||||
static const Color bmiOverweightZone = Color(0x1AF2994A);
|
||||
static const Color bmiObesityZone = Color(0x14EB555C);
|
||||
|
||||
static const Color cardShadow = Color(0x26000000);
|
||||
static const Color fabShadow = Color(0x592F80ED);
|
||||
}
|
||||
|
||||
/// Высота области графика.
|
||||
const double insightsChartHeight = 270;
|
||||
42
lib/features/insights/presentation/widgets/insights_fab.dart
Normal file
42
lib/features/insights/presentation/widgets/insights_fab.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Круглая кнопка «+» (52px) справа-снизу карточки.
|
||||
class InsightsFab extends StatelessWidget {
|
||||
const InsightsFab({super.key, required this.onPressed, this.tooltip});
|
||||
|
||||
final VoidCallback onPressed;
|
||||
final String? tooltip;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: tooltip ?? '',
|
||||
child: Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: const BoxDecoration(
|
||||
color: InsightsColors.blue,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: InsightsColors.fabShadow,
|
||||
blurRadius: 20,
|
||||
offset: Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
key: const Key('insights_fab'),
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: onPressed,
|
||||
child: const Icon(Icons.add, color: Colors.white, size: 28),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Крупное число + единица + строка с дельтой.
|
||||
class InsightsHead extends StatelessWidget {
|
||||
const InsightsHead({
|
||||
super.key,
|
||||
required this.headline,
|
||||
required this.unit,
|
||||
required this.deltaText,
|
||||
this.isDeltaHighlighted = true,
|
||||
});
|
||||
|
||||
final String headline;
|
||||
final String unit;
|
||||
final String deltaText;
|
||||
|
||||
/// Зелёная дельта при наличии изменения, иначе нейтральный серый.
|
||||
final bool isDeltaHighlighted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
headline,
|
||||
key: const Key('insights_headline'),
|
||||
style: const TextStyle(
|
||||
fontSize: 34,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: InsightsColors.title,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
if (unit.isNotEmpty) ...[
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
unit,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: InsightsColors.subtitle,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (deltaText.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
deltaText,
|
||||
key: const Key('insights_delta'),
|
||||
style: TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isDeltaHighlighted
|
||||
? InsightsColors.positive
|
||||
: InsightsColors.subtitle,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:eatvio_app/features/insights/presentation/cubit/insights_panel.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
extension InsightsPanelLabel on InsightsPanel {
|
||||
String label(S s) {
|
||||
switch (this) {
|
||||
case InsightsPanel.weight:
|
||||
return s.insights_tab_weight;
|
||||
case InsightsPanel.bmi:
|
||||
return s.insights_tab_bmi;
|
||||
case InsightsPanel.measures:
|
||||
return s.insights_tab_measures;
|
||||
case InsightsPanel.daily:
|
||||
return s.insights_tab_daily;
|
||||
case InsightsPanel.intra:
|
||||
return s.insights_tab_intra;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Горизонтально прокручиваемые вкладки (`l-tab`).
|
||||
class InsightsTabs extends StatelessWidget {
|
||||
const InsightsTabs({
|
||||
super.key,
|
||||
required this.panels,
|
||||
required this.selected,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final List<InsightsPanel> panels;
|
||||
final InsightsPanel selected;
|
||||
final ValueChanged<InsightsPanel> onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (final panel in panels)
|
||||
InkWell(
|
||||
key: Key('insights_tab_${panel.name}'),
|
||||
onTap: () => onSelected(panel),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: panel == selected
|
||||
? InsightsColors.blue
|
||||
: Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
panel.label(s),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: panel == selected
|
||||
? FontWeight.w700
|
||||
: FontWeight.w600,
|
||||
color: panel == selected
|
||||
? InsightsColors.blue
|
||||
: InsightsColors.subtitle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
130
lib/features/insights/presentation/widgets/intra_date_nav.dart
Normal file
130
lib/features/insights/presentation/widgets/intra_date_nav.dart
Normal file
@@ -0,0 +1,130 @@
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Навигация по дням для вкладки «В течение дня»: `‹ дата ›` и выбор даты.
|
||||
///
|
||||
/// Колбэки `null` — навигация неактивна (данные других дней ещё не подключены).
|
||||
class IntraDateNav extends StatelessWidget {
|
||||
const IntraDateNav({
|
||||
super.key,
|
||||
required this.model,
|
||||
this.onPreviousDay,
|
||||
this.onNextDay,
|
||||
this.onDatePicked,
|
||||
});
|
||||
|
||||
final IntraChartModel model;
|
||||
final VoidCallback? onPreviousDay;
|
||||
final VoidCallback? onNextDay;
|
||||
final ValueChanged<DateTime>? onDatePicked;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
final canGoNext = !model.isToday && onNextDay != null;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: InsightsColors.accentBackground,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_NavButton(
|
||||
key: const Key('insights_intra_prev'),
|
||||
icon: Icons.chevron_left_rounded,
|
||||
tooltip: s.insights_prev_day,
|
||||
onPressed: onPreviousDay,
|
||||
),
|
||||
InkWell(
|
||||
key: const Key('insights_intra_pick'),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
onTap: onDatePicked == null ? null : () => _pickDate(context),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: 72),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.calendar_today_rounded,
|
||||
size: 14,
|
||||
color: InsightsColors.blue,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
model.dateLabel,
|
||||
key: const Key('insights_intra_date_label'),
|
||||
style: const TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: InsightsColors.title,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_NavButton(
|
||||
key: const Key('insights_intra_next'),
|
||||
icon: Icons.chevron_right_rounded,
|
||||
tooltip: s.insights_next_day,
|
||||
onPressed: canGoNext ? onNextDay : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDate(BuildContext context) async {
|
||||
final lastDate = DateTime.tryParse(model.maxDate) ?? DateTime.now();
|
||||
final current = DateTime.tryParse(model.date) ?? lastDate;
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: current.isAfter(lastDate) ? lastDate : current,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: lastDate,
|
||||
helpText: S.of(context).insights_pick_date,
|
||||
);
|
||||
if (picked != null) {
|
||||
onDatePicked?.call(picked);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _NavButton extends StatelessWidget {
|
||||
const _NavButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Opacity(
|
||||
opacity: onPressed == null ? 0.35 : 1,
|
||||
child: SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
tooltip: tooltip,
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon, color: InsightsColors.blue),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
96
lib/features/insights/presentation/widgets/intra_legend.dart
Normal file
96
lib/features/insights/presentation/widgets/intra_legend.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Легенда под графиком «В течение дня»: подъём, отход ко сну, затемнение сна.
|
||||
class IntraLegend extends StatelessWidget {
|
||||
const IntraLegend({super.key, required this.model});
|
||||
|
||||
final IntraChartModel model;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
final wakeText =
|
||||
model.wakeText ??
|
||||
(model.wakeLabel == null ? null : s.insights_wake_at(model.wakeLabel!));
|
||||
final sleepText =
|
||||
model.sleepText ??
|
||||
(model.sleepLabel == null
|
||||
? null
|
||||
: s.insights_sleep_at(model.sleepLabel!));
|
||||
|
||||
return Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
if (wakeText != null)
|
||||
_LegendItem(
|
||||
key: const Key('insights_legend_wake'),
|
||||
color: InsightsColors.wake,
|
||||
text: wakeText,
|
||||
isDashed: true,
|
||||
),
|
||||
if (sleepText != null)
|
||||
_LegendItem(
|
||||
key: const Key('insights_legend_sleep'),
|
||||
color: InsightsColors.sleepLine,
|
||||
text: sleepText,
|
||||
isDashed: true,
|
||||
),
|
||||
_LegendItem(
|
||||
key: const Key('insights_legend_shade'),
|
||||
color: InsightsColors.sleepShade,
|
||||
text: s.insights_sleep_shade,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LegendItem extends StatelessWidget {
|
||||
const _LegendItem({
|
||||
super.key,
|
||||
required this.color,
|
||||
required this.text,
|
||||
this.isDashed = false,
|
||||
});
|
||||
|
||||
final Color color;
|
||||
final String text;
|
||||
final bool isDashed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
isDashed
|
||||
? Container(
|
||||
width: 18,
|
||||
height: 0,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: color, width: 2)),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 18,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: InsightsColors.subtitle,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
115
lib/features/insights/presentation/widgets/period_pills.dart
Normal file
115
lib/features/insights/presentation/widgets/period_pills.dart
Normal file
@@ -0,0 +1,115 @@
|
||||
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
extension BodyPeriodLabel on BodyPeriod {
|
||||
String pillLabel(S s) {
|
||||
switch (this) {
|
||||
case BodyPeriod.month:
|
||||
return s.insights_period_1m;
|
||||
case BodyPeriod.threeMonths:
|
||||
return s.insights_period_3m;
|
||||
case BodyPeriod.sixMonths:
|
||||
return s.insights_period_6m;
|
||||
case BodyPeriod.year:
|
||||
return s.insights_period_1y;
|
||||
case BodyPeriod.all:
|
||||
return s.insights_period_all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension DailyPeriodLabel on DailyPeriod {
|
||||
String pillLabel(S s) {
|
||||
switch (this) {
|
||||
case DailyPeriod.week:
|
||||
return s.insights_period_1w;
|
||||
case DailyPeriod.twoWeeks:
|
||||
return s.insights_period_2w;
|
||||
case DailyPeriod.month:
|
||||
return s.insights_period_1m;
|
||||
case DailyPeriod.threeMonths:
|
||||
return s.insights_period_3m;
|
||||
case DailyPeriod.all:
|
||||
return s.insights_period_all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Пилюли выбора периода (`1М 3М 6М 1Г Всё время`).
|
||||
class PeriodPills<T> extends StatelessWidget {
|
||||
const PeriodPills({
|
||||
super.key,
|
||||
required this.periods,
|
||||
required this.selected,
|
||||
required this.labelOf,
|
||||
required this.keyOf,
|
||||
this.onSelected,
|
||||
});
|
||||
|
||||
final List<T> periods;
|
||||
final T selected;
|
||||
final String Function(T period) labelOf;
|
||||
final String Function(T period) keyOf;
|
||||
|
||||
/// `null` — пилюли неинтерактивны (данные для периода ещё не подключены).
|
||||
final ValueChanged<T>? onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (final period in periods)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: _Pill(
|
||||
key: Key('insights_period_${keyOf(period)}'),
|
||||
label: labelOf(period),
|
||||
isActive: period == selected,
|
||||
onTap: onSelected == null ? null : () => onSelected!(period),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Pill extends StatelessWidget {
|
||||
const _Pill({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.isActive,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool isActive;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: isActive ? InsightsColors.blue : InsightsColors.accentBackground,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 7),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isActive ? Colors.white : InsightsColors.subtitle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
76
lib/features/insights/presentation/widgets/series_chips.dart
Normal file
76
lib/features/insights/presentation/widgets/series_chips.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SeriesChipData {
|
||||
const SeriesChipData({
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.isVisible,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Color color;
|
||||
final bool isVisible;
|
||||
}
|
||||
|
||||
/// Чипы-переключатели серий под графиком.
|
||||
class SeriesChips extends StatelessWidget {
|
||||
const SeriesChips({super.key, required this.chips, required this.onToggle});
|
||||
|
||||
final List<SeriesChipData> chips;
|
||||
final ValueChanged<int> onToggle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (var i = 0; i < chips.length; i++)
|
||||
Opacity(
|
||||
opacity: chips[i].isVisible ? 1 : 0.45,
|
||||
child: InkWell(
|
||||
key: Key('insights_series_$i'),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
onTap: () => onToggle(i),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 7,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: InsightsColors.border, width: 1.5),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: chips[i].color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
chips[i].label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: InsightsColors.title,
|
||||
decoration: chips[i].isVisible
|
||||
? TextDecoration.none
|
||||
: TextDecoration.lineThrough,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user