forked from eatvio/eatvio_app
feat(insights): first isights impemetation
This commit is contained in:
@@ -18,6 +18,7 @@ import 'package:eatvio_app/features/diets/repositories/diet_repository.dart';
|
||||
import 'package:eatvio_app/features/feed/repositories/feed_repository.dart';
|
||||
import 'package:eatvio_app/features/glossary/repositories/glossary_repository.dart';
|
||||
import 'package:eatvio_app/features/barcode/repositories/barcode_repository.dart';
|
||||
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/repositories/blocked_users_repository.dart';
|
||||
import 'package:eatvio_app/features/menu/repositories/notification_settings_repository.dart';
|
||||
import 'package:eatvio_app/features/my_diets/repositories/my_diets_repository.dart';
|
||||
@@ -55,6 +56,7 @@ class RepositoryRegistrations {
|
||||
sl.registerLazySingleton<VideoCallRepository>(() => VideoCallRepository(videoCallService: sl()));
|
||||
sl.registerLazySingleton<FeedRepository>(() => FeedRepository(feedService: sl()));
|
||||
sl.registerLazySingleton<ProfileRepository>(() => ProfileRepository(profileService: sl()));
|
||||
sl.registerLazySingleton<InsightsRepository>(() => InsightsRepository(profileRepository: sl()));
|
||||
sl.registerLazySingleton<CalendarRepository>(() => CalendarRepository(calendarService: sl()));
|
||||
sl.registerLazySingleton<SettingsRepository>(() => SettingsRepository(settingsService: sl()));
|
||||
sl.registerLazySingleton<ConsultantRepository>(() => ConsultantRepository(consultantService: sl()));
|
||||
|
||||
@@ -354,9 +354,10 @@ class OtherRoutes {
|
||||
path: UserDiaryScreen.path,
|
||||
name: UserDiaryScreen.name,
|
||||
builder: (context, state) {
|
||||
final data = state.extra as Map<String, int>;
|
||||
final data = state.extra as Map<String, Object?>;
|
||||
final diaryId = data['diaryId'] as int;
|
||||
return UserDiaryScreen(diaryId: diaryId);
|
||||
final ownerSlug = data['ownerSlug'] as String?;
|
||||
return UserDiaryScreen(diaryId: diaryId, ownerSlug: ownerSlug);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
|
||||
@@ -176,7 +176,7 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': day.$2.id,});
|
||||
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': day.$2.id, 'ownerSlug': widget.consultationsModel.userSlug});
|
||||
},
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
child: Ink(
|
||||
@@ -259,7 +259,7 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
|
||||
.where((e) => e.$2.state == DiaryStateEnum.finished || e.$2.state == DiaryStateEnum.lastDayFinished)
|
||||
.lastOrNull;
|
||||
if (lastActiveDay != null) {
|
||||
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': lastActiveDay.$2.id,});
|
||||
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': lastActiveDay.$2.id, 'ownerSlug': widget.consultationsModel.userSlug});
|
||||
return;
|
||||
}
|
||||
showErrorDialog(
|
||||
|
||||
@@ -20,6 +20,7 @@ import 'package:eatvio_app/features/diary/presentation/widgets/day_comment_widge
|
||||
import 'package:eatvio_app/features/diary/presentation/widgets/finish_day_button_widget.dart';
|
||||
import 'package:eatvio_app/features/diary/presentation/widgets/meal_widget.dart';
|
||||
import 'package:eatvio_app/features/diary/presentation/widgets/wakeup_and_retire_widget.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -36,18 +37,22 @@ class DiaryScreen extends StatelessWidget {
|
||||
class UserDiaryScreen extends StatelessWidget {
|
||||
static const path = '/user-diary-screen';
|
||||
static const name = 'userDiaryScreen';
|
||||
const UserDiaryScreen({super.key, required this.diaryId});
|
||||
const UserDiaryScreen({super.key, required this.diaryId, this.ownerSlug});
|
||||
final int diaryId;
|
||||
|
||||
/// Slug владельца дневника (клиента) — нужен консультанту для блока «Аналитика».
|
||||
final String? ownerSlug;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DiaryScreenContent(diaryId: diaryId);
|
||||
return DiaryScreenContent(diaryId: diaryId, ownerSlug: ownerSlug);
|
||||
}
|
||||
}
|
||||
|
||||
class DiaryScreenContent extends StatefulWidget {
|
||||
const DiaryScreenContent({super.key, this.diaryId, this.date});
|
||||
const DiaryScreenContent({super.key, this.diaryId, this.date, this.ownerSlug});
|
||||
final int? diaryId;
|
||||
final DateTime? date;
|
||||
final String? ownerSlug;
|
||||
@override
|
||||
State<DiaryScreenContent> createState() => _DiaryScreenContentState();
|
||||
}
|
||||
@@ -55,6 +60,11 @@ class DiaryScreenContent extends StatefulWidget {
|
||||
class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
bool _isDiaryFinishing = false;
|
||||
|
||||
/// Консультант видит аналитику клиента только при известном slug владельца.
|
||||
bool _canShowInsights(DiaryStateLoaded state) {
|
||||
return !state.isConsultantView || widget.ownerSlug != null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
@@ -110,6 +120,14 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
const SizedBox(height: 60),
|
||||
const Loader(),
|
||||
],
|
||||
if (state is DiaryStateLoaded && _canShowInsights(state))
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: InsightsCard(
|
||||
slug: state.isConsultantView ? widget.ownerSlug : null,
|
||||
canAdd: !state.isConsultantView,
|
||||
),
|
||||
),
|
||||
if (state is DiaryStateLoaded)
|
||||
WakeupAndRetireWidget(
|
||||
isEnabled:
|
||||
|
||||
222
lib/features/insights/domain/insights_text_builder.dart
Normal file
222
lib/features/insights/domain/insights_text_builder.dart
Normal file
@@ -0,0 +1,222 @@
|
||||
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/profile/models/circumference_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
|
||||
/// Собирает [InsightsPayloadModel] из сырых точек мобильного API.
|
||||
///
|
||||
/// Зеркало веб-`UserInsightsService`: порог изменения [deltaEpsilon], минимум две
|
||||
/// точки для дельты, те же категории ИМТ и формат подписей.
|
||||
class InsightsTextBuilder {
|
||||
InsightsTextBuilder(this.s);
|
||||
|
||||
final S s;
|
||||
|
||||
static const double deltaEpsilon = 0.05;
|
||||
static const String _emptyHeadline = '—';
|
||||
|
||||
InsightsPayloadModel build({
|
||||
required List<WeightModel> weights,
|
||||
required List<CircumferenceModel> circumferences,
|
||||
required BodyPeriod period,
|
||||
int? heightCm,
|
||||
}) {
|
||||
final sortedWeights = [...weights]
|
||||
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
final sortedCircumferences = [...circumferences]
|
||||
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
|
||||
return InsightsPayloadModel(
|
||||
weight: buildWeight(sortedWeights, period),
|
||||
bmi: buildBmi(sortedWeights, period, heightCm),
|
||||
measure: buildMeasure(sortedCircumferences, period),
|
||||
);
|
||||
}
|
||||
|
||||
WeightChartModel buildWeight(List<WeightModel> weights, BodyPeriod period) {
|
||||
final values = weights.map((e) => e.weight).toList();
|
||||
final last = values.isEmpty ? null : values.last;
|
||||
final delta = values.length < 2 ? null : _round(values.last - values.first);
|
||||
|
||||
return WeightChartModel(
|
||||
labels: weights.map((e) => formatLabel(e.createdAt)).toList(),
|
||||
values: values,
|
||||
last: last,
|
||||
delta: delta,
|
||||
headline: last == null ? _emptyHeadline : last.toStringAsFixed(1),
|
||||
unit: s.insights_unit_kg,
|
||||
deltaText: _weightDeltaText(values, period),
|
||||
);
|
||||
}
|
||||
|
||||
BmiChartModel buildBmi(
|
||||
List<WeightModel> weights,
|
||||
BodyPeriod period,
|
||||
int? heightCm,
|
||||
) {
|
||||
final hasHeight = heightCm != null && heightCm > 0;
|
||||
if (!hasHeight) {
|
||||
return BmiChartModel(
|
||||
headline: _emptyHeadline,
|
||||
deltaText: s.insights_need_height,
|
||||
hint: s.insights_need_height,
|
||||
);
|
||||
}
|
||||
|
||||
final points = weights.where((e) => e.bmi != null).toList();
|
||||
final values = points.map((e) => e.bmi!).toList();
|
||||
final last = values.isEmpty ? null : values.last;
|
||||
final delta = values.length < 2 ? null : _round(values.last - values.first);
|
||||
final category = last == null ? null : bmiCategory(last);
|
||||
|
||||
return BmiChartModel(
|
||||
labels: points.map((e) => formatLabel(e.createdAt)).toList(),
|
||||
values: values,
|
||||
last: last,
|
||||
delta: delta,
|
||||
headline: last == null ? _emptyHeadline : last.toStringAsFixed(1),
|
||||
category: category,
|
||||
deltaText: _bmiDeltaText(values, period, category),
|
||||
hint: s.insights_bmi_hint(heightCm.toString()),
|
||||
);
|
||||
}
|
||||
|
||||
MeasureChartModel buildMeasure(
|
||||
List<CircumferenceModel> circumferences,
|
||||
BodyPeriod period,
|
||||
) {
|
||||
final waist = circumferences.map((e) => e.waistCircumference).toList();
|
||||
final chest = circumferences.map((e) => e.chestCircumference).toList();
|
||||
final hips = circumferences.map((e) => e.hipCircumference).toList();
|
||||
|
||||
return MeasureChartModel(
|
||||
labels: circumferences.map((e) => formatLabel(e.createdAt)).toList(),
|
||||
waist: waist,
|
||||
chest: chest,
|
||||
hips: hips,
|
||||
headline: waist.isEmpty ? _emptyHeadline : waist.last.round().toString(),
|
||||
deltaText: _measureDeltaText(
|
||||
waist: waist,
|
||||
chest: chest,
|
||||
hips: hips,
|
||||
period: period,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Категория по таблице ВОЗ из постановки #2120.
|
||||
String bmiCategory(double bmi) {
|
||||
if (bmi < 16) {
|
||||
return s.insights_bmi_severe_underweight;
|
||||
}
|
||||
if (bmi < 18.5) {
|
||||
return s.insights_bmi_underweight;
|
||||
}
|
||||
if (bmi < 25) {
|
||||
return s.insights_bmi_normal;
|
||||
}
|
||||
if (bmi < 30) {
|
||||
return s.insights_bmi_overweight;
|
||||
}
|
||||
if (bmi < 35) {
|
||||
return s.insights_bmi_obesity_1;
|
||||
}
|
||||
if (bmi < 40) {
|
||||
return s.insights_bmi_obesity_2;
|
||||
}
|
||||
return s.insights_bmi_obesity_3;
|
||||
}
|
||||
|
||||
String periodLabel(BodyPeriod period) {
|
||||
switch (period) {
|
||||
case BodyPeriod.month:
|
||||
return s.insights_period_label_1;
|
||||
case BodyPeriod.threeMonths:
|
||||
return s.insights_period_label_3;
|
||||
case BodyPeriod.sixMonths:
|
||||
return s.insights_period_label_6;
|
||||
case BodyPeriod.year:
|
||||
return s.insights_period_label_12;
|
||||
case BodyPeriod.all:
|
||||
return s.insights_period_label_all;
|
||||
}
|
||||
}
|
||||
|
||||
String formatLabel(DateTime date) {
|
||||
final local = date.toLocal();
|
||||
final day = local.day.toString().padLeft(2, '0');
|
||||
final month = local.month.toString().padLeft(2, '0');
|
||||
return '$day.$month';
|
||||
}
|
||||
|
||||
/// Одна цифра после запятой без хвостового нуля: `3`, `3.5`.
|
||||
String formatNumber(double value) {
|
||||
final text = value.toStringAsFixed(1);
|
||||
return text.endsWith('.0') ? text.substring(0, text.length - 2) : text;
|
||||
}
|
||||
|
||||
String _weightDeltaText(List<double> values, BodyPeriod period) {
|
||||
if (values.length < 2) {
|
||||
return s.insights_not_enough;
|
||||
}
|
||||
final diff = values.last - values.first;
|
||||
final periodText = periodLabel(period);
|
||||
if (diff.abs() < deltaEpsilon) {
|
||||
return s.insights_weight_same(periodText);
|
||||
}
|
||||
final value = formatNumber(diff.abs());
|
||||
return diff < 0
|
||||
? s.insights_weight_lost(value, periodText)
|
||||
: s.insights_weight_gained(value, periodText);
|
||||
}
|
||||
|
||||
String _bmiDeltaText(
|
||||
List<double> values,
|
||||
BodyPeriod period,
|
||||
String? category,
|
||||
) {
|
||||
if (values.length < 2 || category == null) {
|
||||
return s.insights_not_enough;
|
||||
}
|
||||
final diff = values.last - values.first;
|
||||
final periodText = periodLabel(period);
|
||||
if (diff.abs() < deltaEpsilon) {
|
||||
return s.insights_bmi_same(periodText, category);
|
||||
}
|
||||
final value = formatNumber(diff.abs());
|
||||
return diff < 0
|
||||
? s.insights_bmi_lower(value, periodText, category)
|
||||
: s.insights_bmi_higher(value, periodText, category);
|
||||
}
|
||||
|
||||
String _measureDeltaText({
|
||||
required List<double> waist,
|
||||
required List<double> chest,
|
||||
required List<double> hips,
|
||||
required BodyPeriod period,
|
||||
}) {
|
||||
if (waist.length < 2) {
|
||||
return s.insights_not_enough;
|
||||
}
|
||||
final parts = [
|
||||
_measurePart(s.insights_series_waist, waist),
|
||||
_measurePart(s.insights_series_chest, chest),
|
||||
_measurePart(s.insights_series_hips, hips),
|
||||
];
|
||||
return s.insights_measures_summary(parts.join(', '), periodLabel(period));
|
||||
}
|
||||
|
||||
String _measurePart(String name, List<double> values) {
|
||||
final diff = values.last - values.first;
|
||||
if (diff.abs() < deltaEpsilon) {
|
||||
return s.insights_measure_same(name);
|
||||
}
|
||||
final value = formatNumber(diff.abs());
|
||||
return diff < 0
|
||||
? s.insights_measure_less(name, value)
|
||||
: s.insights_measure_more(name, value);
|
||||
}
|
||||
|
||||
double _round(double value) => (value * 10).round() / 10;
|
||||
}
|
||||
6
lib/features/insights/insights_feature_flags.dart
Normal file
6
lib/features/insights/insights_feature_flags.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
/// Флаги поэтапного включения аналитики.
|
||||
abstract final class InsightsFeatureFlags {
|
||||
/// Вкладки «КБЖУ по дням» и «В течение дня» требуют бэкенд-эндпоинта
|
||||
/// `GET /api/profile/insights` и включаются во второй итерации.
|
||||
static const bool kbjuEnabled = false;
|
||||
}
|
||||
132
lib/features/insights/models/insights_payload_model.dart
Normal file
132
lib/features/insights/models/insights_payload_model.dart
Normal file
@@ -0,0 +1,132 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'insights_payload_model.freezed.dart';
|
||||
part 'insights_payload_model.g.dart';
|
||||
|
||||
/// Структура повторяет веб-`UserInsightsService::chartPayload()`, чтобы во второй
|
||||
/// итерации заменить только источник данных.
|
||||
@freezed
|
||||
abstract class InsightsPayloadModel with _$InsightsPayloadModel {
|
||||
const factory InsightsPayloadModel({
|
||||
required WeightChartModel weight,
|
||||
required BmiChartModel bmi,
|
||||
required MeasureChartModel measure,
|
||||
DailyChartModel? daily,
|
||||
IntraChartModel? intra,
|
||||
}) = _InsightsPayloadModel;
|
||||
|
||||
factory InsightsPayloadModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$InsightsPayloadModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class WeightChartModel with _$WeightChartModel {
|
||||
const factory WeightChartModel({
|
||||
@Default([]) List<String> labels,
|
||||
@Default([]) List<double> values,
|
||||
double? last,
|
||||
double? delta,
|
||||
@Default('') String headline,
|
||||
@Default('') String unit,
|
||||
@Default('') String deltaText,
|
||||
}) = _WeightChartModel;
|
||||
|
||||
factory WeightChartModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$WeightChartModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class BmiChartModel with _$BmiChartModel {
|
||||
const factory BmiChartModel({
|
||||
@Default([]) List<String> labels,
|
||||
@Default([]) List<double> values,
|
||||
double? last,
|
||||
double? delta,
|
||||
@Default('') String headline,
|
||||
String? category,
|
||||
@Default('') String deltaText,
|
||||
@Default('') String hint,
|
||||
}) = _BmiChartModel;
|
||||
|
||||
factory BmiChartModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$BmiChartModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class MeasureChartModel with _$MeasureChartModel {
|
||||
const factory MeasureChartModel({
|
||||
@Default([]) List<String> labels,
|
||||
@Default([]) List<double> waist,
|
||||
@Default([]) List<double> chest,
|
||||
@Default([]) List<double> hips,
|
||||
@Default('') String headline,
|
||||
@Default('') String deltaText,
|
||||
}) = _MeasureChartModel;
|
||||
|
||||
factory MeasureChartModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$MeasureChartModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class DailyChartModel with _$DailyChartModel {
|
||||
const factory DailyChartModel({
|
||||
@Default([]) List<String> labels,
|
||||
@Default([]) List<double> kcal,
|
||||
@Default([]) List<double> proteins,
|
||||
@Default([]) List<double> fats,
|
||||
@Default([]) List<double> carbs,
|
||||
double? average,
|
||||
double? percent,
|
||||
@Default('') String headline,
|
||||
@Default('') String deltaText,
|
||||
}) = _DailyChartModel;
|
||||
|
||||
factory DailyChartModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$DailyChartModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class IntraChartModel with _$IntraChartModel {
|
||||
const factory IntraChartModel({
|
||||
@Default([]) List<IntraBarModel> bars,
|
||||
@Default([]) List<ScalePointModel> satiety,
|
||||
@Default([]) List<ScalePointModel> mood,
|
||||
double? wake,
|
||||
double? sleep,
|
||||
String? wakeLabel,
|
||||
String? sleepLabel,
|
||||
String? wakeText,
|
||||
String? sleepText,
|
||||
@Default('') String date,
|
||||
@Default('') String maxDate,
|
||||
@Default('') String dateLabel,
|
||||
@Default(false) bool isToday,
|
||||
}) = _IntraChartModel;
|
||||
|
||||
factory IntraChartModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$IntraChartModelFromJson(json);
|
||||
}
|
||||
|
||||
/// Приём пищи: время `x` (часы 0–24) и калории из белков/жиров/углеводов.
|
||||
@freezed
|
||||
abstract class IntraBarModel with _$IntraBarModel {
|
||||
const factory IntraBarModel({
|
||||
required double x,
|
||||
@Default(0) double p,
|
||||
@Default(0) double f,
|
||||
@Default(0) double c,
|
||||
}) = _IntraBarModel;
|
||||
|
||||
factory IntraBarModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$IntraBarModelFromJson(json);
|
||||
}
|
||||
|
||||
/// Точка шкалы 0–5 (сытость/настроение) в момент времени `x`.
|
||||
@freezed
|
||||
abstract class ScalePointModel with _$ScalePointModel {
|
||||
const factory ScalePointModel({required double x, required double y}) =
|
||||
_ScalePointModel;
|
||||
|
||||
factory ScalePointModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$ScalePointModelFromJson(json);
|
||||
}
|
||||
26
lib/features/insights/models/insights_period.dart
Normal file
26
lib/features/insights/models/insights_period.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
/// Период для вкладок «Вес», «ИМТ» и «Замеры». Значение совпадает с `{period}` в
|
||||
/// `/api/profile/measure/weight/{period}`.
|
||||
enum BodyPeriod {
|
||||
month('1'),
|
||||
threeMonths('3'),
|
||||
sixMonths('6'),
|
||||
year('12'),
|
||||
all('all');
|
||||
|
||||
const BodyPeriod(this.apiValue);
|
||||
|
||||
final String apiValue;
|
||||
}
|
||||
|
||||
/// Период для вкладки «КБЖУ по дням» (используется со второй итерации).
|
||||
enum DailyPeriod {
|
||||
week('7'),
|
||||
twoWeeks('14'),
|
||||
month('30'),
|
||||
threeMonths('90'),
|
||||
all('all');
|
||||
|
||||
const DailyPeriod(this.apiValue);
|
||||
|
||||
final String apiValue;
|
||||
}
|
||||
84
lib/features/insights/presentation/charts/bmi_chart.dart
Normal file
84
lib/features/insights/presentation/charts/bmi_chart.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
|
||||
/// График ИМТ с цветными зонами: норма 18.5–25, избыточная масса 25–30, ожирение 30–40.
|
||||
class BmiChart extends StatelessWidget {
|
||||
const BmiChart({super.key, required this.model});
|
||||
|
||||
final BmiChartModel model;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final points = buildPoints(model.labels, model.values);
|
||||
if (points.isEmpty) {
|
||||
return InsightsChartPlaceholder(text: model.deltaText);
|
||||
}
|
||||
final s = S.of(context);
|
||||
final range = paddedRange(points.map((e) => e.value), minPadding: 2);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: insightsChartHeight,
|
||||
child: SfCartesianChart(
|
||||
key: const Key('insights_bmi_chart'),
|
||||
margin: EdgeInsets.zero,
|
||||
plotAreaBorderWidth: 0,
|
||||
primaryXAxis: buildInsightsCategoryAxis(),
|
||||
primaryYAxis: buildInsightsValueAxis(
|
||||
unit: s.insights_unit_bmi,
|
||||
min: range.min,
|
||||
max: range.max,
|
||||
plotBands: const [
|
||||
PlotBand(
|
||||
start: 18.5,
|
||||
end: 25,
|
||||
color: InsightsColors.bmiNormalZone,
|
||||
),
|
||||
PlotBand(
|
||||
start: 25,
|
||||
end: 30,
|
||||
color: InsightsColors.bmiOverweightZone,
|
||||
),
|
||||
PlotBand(
|
||||
start: 30,
|
||||
end: 40,
|
||||
color: InsightsColors.bmiObesityZone,
|
||||
),
|
||||
],
|
||||
),
|
||||
trackballBehavior: buildInsightsTrackball(),
|
||||
series: <CartesianSeries<InsightsPoint, String>>[
|
||||
SplineAreaSeries<InsightsPoint, String>(
|
||||
name: s.insights_tab_bmi,
|
||||
dataSource: points,
|
||||
xValueMapper: (point, _) => point.label,
|
||||
yValueMapper: (point, _) => point.value,
|
||||
borderColor: InsightsColors.blue,
|
||||
borderWidth: 3,
|
||||
gradient: buildInsightsAreaGradient(InsightsColors.blue),
|
||||
markerSettings: buildInsightsMarker(InsightsColors.blue),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (model.hint.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
model.hint,
|
||||
key: const Key('insights_bmi_hint'),
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: InsightsColors.subtitle,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
113
lib/features/insights/presentation/charts/daily_kbju_chart.dart
Normal file
113
lib/features/insights/presentation/charts/daily_kbju_chart.dart
Normal file
@@ -0,0 +1,113 @@
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
|
||||
/// КБЖУ по дням: калории — площадь на левой оси («ккал»), Б/Ж/У — линии на
|
||||
/// правой оси («граммы»). [visibility] — Калории, Белки, Жиры, Углеводы.
|
||||
class DailyKbjuChart extends StatelessWidget {
|
||||
const DailyKbjuChart({
|
||||
super.key,
|
||||
required this.model,
|
||||
required this.visibility,
|
||||
});
|
||||
|
||||
final DailyChartModel model;
|
||||
final List<bool> visibility;
|
||||
|
||||
static const String _gramsAxisName = 'grams';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
final calories = buildPoints(model.labels, model.kcal);
|
||||
if (calories.isEmpty) {
|
||||
return InsightsChartPlaceholder(text: s.insights_not_enough);
|
||||
}
|
||||
final proteins = buildPoints(model.labels, model.proteins);
|
||||
final fats = buildPoints(model.labels, model.fats);
|
||||
final carbs = buildPoints(model.labels, model.carbs);
|
||||
|
||||
final grams = [
|
||||
(
|
||||
name: s.insights_series_proteins,
|
||||
color: InsightsColors.proteins,
|
||||
points: proteins,
|
||||
),
|
||||
(name: s.insights_series_fats, color: InsightsColors.fats, points: fats),
|
||||
(
|
||||
name: s.insights_series_carbs,
|
||||
color: InsightsColors.carbs,
|
||||
points: carbs,
|
||||
),
|
||||
];
|
||||
|
||||
return SizedBox(
|
||||
height: insightsChartHeight,
|
||||
child: SfCartesianChart(
|
||||
key: const Key('insights_daily_chart'),
|
||||
margin: EdgeInsets.zero,
|
||||
plotAreaBorderWidth: 0,
|
||||
primaryXAxis: buildInsightsCategoryAxis(),
|
||||
primaryYAxis: NumericAxis(
|
||||
title: AxisTitle(
|
||||
text: s.insights_unit_kcal,
|
||||
textStyle: insightsAxisLabelStyle,
|
||||
),
|
||||
labelStyle: insightsAxisLabelStyle,
|
||||
minimum: 0,
|
||||
majorTickLines: const MajorTickLines(size: 0),
|
||||
axisLine: const AxisLine(width: 0),
|
||||
majorGridLines: MajorGridLines(
|
||||
color: InsightsColors.border.withValues(alpha: 0.8),
|
||||
dashArray: const [4, 4],
|
||||
),
|
||||
),
|
||||
axes: <ChartAxis>[
|
||||
NumericAxis(
|
||||
name: _gramsAxisName,
|
||||
opposedPosition: true,
|
||||
minimum: 0,
|
||||
title: AxisTitle(
|
||||
text: s.insights_unit_grams,
|
||||
textStyle: insightsAxisLabelStyle,
|
||||
),
|
||||
labelStyle: insightsAxisLabelStyle,
|
||||
majorTickLines: const MajorTickLines(size: 0),
|
||||
axisLine: const AxisLine(width: 0),
|
||||
majorGridLines: const MajorGridLines(width: 0),
|
||||
),
|
||||
],
|
||||
trackballBehavior: buildInsightsTrackball(),
|
||||
series: <CartesianSeries<InsightsPoint, String>>[
|
||||
if (_isVisible(0))
|
||||
SplineAreaSeries<InsightsPoint, String>(
|
||||
name: s.insights_series_calories,
|
||||
dataSource: calories,
|
||||
xValueMapper: (point, _) => point.label,
|
||||
yValueMapper: (point, _) => point.value,
|
||||
borderColor: InsightsColors.calories,
|
||||
borderWidth: 3,
|
||||
gradient: buildInsightsAreaGradient(InsightsColors.calories),
|
||||
),
|
||||
for (var i = 0; i < grams.length; i++)
|
||||
if (_isVisible(i + 1))
|
||||
SplineSeries<InsightsPoint, String>(
|
||||
name: grams[i].name,
|
||||
yAxisName: _gramsAxisName,
|
||||
dataSource: grams[i].points,
|
||||
xValueMapper: (point, _) => point.label,
|
||||
yValueMapper: (point, _) => point.value,
|
||||
color: grams[i].color,
|
||||
width: 2.5,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isVisible(int index) =>
|
||||
index < visibility.length ? visibility[index] : true;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
|
||||
/// Точка графика: подпись оси X (`dd.MM`) и значение.
|
||||
class InsightsPoint {
|
||||
const InsightsPoint(this.label, this.value);
|
||||
|
||||
final String label;
|
||||
final double value;
|
||||
}
|
||||
|
||||
/// Склеивает `labels[]` и `values[]` (по минимальной длине).
|
||||
List<InsightsPoint> buildPoints(List<String> labels, List<double> values) {
|
||||
final count = math.min(labels.length, values.length);
|
||||
return [for (var i = 0; i < count; i++) InsightsPoint(labels[i], values[i])];
|
||||
}
|
||||
|
||||
/// Границы оси Y с отступом, чтобы линия не прижималась к краям (и ось не начиналась с нуля).
|
||||
({double min, double max}) paddedRange(
|
||||
Iterable<double> values, {
|
||||
double minPadding = 1,
|
||||
}) {
|
||||
if (values.isEmpty) {
|
||||
return (min: 0, max: 1);
|
||||
}
|
||||
final lowest = values.reduce(math.min);
|
||||
final highest = values.reduce(math.max);
|
||||
final padding = math.max((highest - lowest) * 0.2, minPadding);
|
||||
return (
|
||||
min: (lowest - padding).floorToDouble(),
|
||||
max: (highest + padding).ceilToDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
const TextStyle insightsAxisLabelStyle = TextStyle(
|
||||
fontSize: 11,
|
||||
color: InsightsColors.subtitle,
|
||||
);
|
||||
|
||||
CategoryAxis buildInsightsCategoryAxis() {
|
||||
return const CategoryAxis(
|
||||
arrangeByIndex: true,
|
||||
labelStyle: insightsAxisLabelStyle,
|
||||
majorGridLines: MajorGridLines(width: 0),
|
||||
majorTickLines: MajorTickLines(size: 0),
|
||||
axisLine: AxisLine(color: InsightsColors.border, width: 1),
|
||||
edgeLabelPlacement: EdgeLabelPlacement.shift,
|
||||
labelIntersectAction: AxisLabelIntersectAction.hide,
|
||||
);
|
||||
}
|
||||
|
||||
NumericAxis buildInsightsValueAxis({
|
||||
required String unit,
|
||||
required double min,
|
||||
required double max,
|
||||
List<PlotBand> plotBands = const [],
|
||||
}) {
|
||||
return NumericAxis(
|
||||
minimum: min,
|
||||
maximum: max,
|
||||
labelFormat: '{value} $unit',
|
||||
labelStyle: insightsAxisLabelStyle,
|
||||
majorTickLines: const MajorTickLines(size: 0),
|
||||
axisLine: const AxisLine(width: 0),
|
||||
majorGridLines: MajorGridLines(
|
||||
color: InsightsColors.border.withValues(alpha: 0.8),
|
||||
dashArray: const [4, 4],
|
||||
),
|
||||
plotBands: plotBands,
|
||||
);
|
||||
}
|
||||
|
||||
TrackballBehavior buildInsightsTrackball() {
|
||||
return TrackballBehavior(
|
||||
enable: true,
|
||||
activationMode: ActivationMode.singleTap,
|
||||
tooltipDisplayMode: TrackballDisplayMode.groupAllPoints,
|
||||
lineType: TrackballLineType.vertical,
|
||||
tooltipSettings: const InteractiveTooltip(),
|
||||
);
|
||||
}
|
||||
|
||||
MarkerSettings buildInsightsMarker(Color color) {
|
||||
return MarkerSettings(
|
||||
isVisible: true,
|
||||
width: 4,
|
||||
height: 4,
|
||||
color: Colors.white,
|
||||
borderColor: color,
|
||||
borderWidth: 2,
|
||||
);
|
||||
}
|
||||
|
||||
LinearGradient buildInsightsAreaGradient(Color color) {
|
||||
return LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [color.withValues(alpha: 0.18), color.withValues(alpha: 0)],
|
||||
);
|
||||
}
|
||||
|
||||
/// Пустое состояние графика (нет данных).
|
||||
class InsightsChartPlaceholder extends StatelessWidget {
|
||||
const InsightsChartPlaceholder({super.key, required this.text});
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: insightsChartHeight,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(
|
||||
text,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: InsightsColors.subtitle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
210
lib/features/insights/presentation/charts/intra_day_chart.dart
Normal file
210
lib/features/insights/presentation/charts/intra_day_chart.dart
Normal file
@@ -0,0 +1,210 @@
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
|
||||
/// График «В течение дня»: столбики Б/Ж/У (ккал) по приёмам пищи на оси 0–24 ч,
|
||||
/// линии сытости и настроения (шкала 0–5) и затемнение времени сна.
|
||||
///
|
||||
/// [visibility] — Белки, Жиры, Углеводы, Сытость, Настроение.
|
||||
class IntraDayChart extends StatelessWidget {
|
||||
const IntraDayChart({
|
||||
super.key,
|
||||
required this.model,
|
||||
required this.visibility,
|
||||
required this.showSleep,
|
||||
});
|
||||
|
||||
final IntraChartModel model;
|
||||
final List<bool> visibility;
|
||||
final bool showSleep;
|
||||
|
||||
static const String _scaleAxisName = 'scale';
|
||||
static const double _barWidthPx = 26;
|
||||
static const double _axesReservedWidthPx = 80;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
if (model.bars.isEmpty && model.satiety.isEmpty && model.mood.isEmpty) {
|
||||
return InsightsChartPlaceholder(text: s.insights_not_enough);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: insightsChartHeight,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final plotWidth = (constraints.maxWidth - _axesReservedWidthPx).clamp(
|
||||
120.0,
|
||||
double.infinity,
|
||||
);
|
||||
final barFraction = (_barWidthPx / (plotWidth / 24)).clamp(0.1, 1.0);
|
||||
return SfCartesianChart(
|
||||
key: const Key('insights_intra_chart'),
|
||||
margin: EdgeInsets.zero,
|
||||
plotAreaBorderWidth: 0,
|
||||
primaryXAxis: NumericAxis(
|
||||
minimum: 0,
|
||||
maximum: 24,
|
||||
interval: 2,
|
||||
labelStyle: insightsAxisLabelStyle,
|
||||
majorGridLines: const MajorGridLines(width: 0),
|
||||
majorTickLines: const MajorTickLines(size: 0),
|
||||
axisLine: const AxisLine(color: InsightsColors.border, width: 1),
|
||||
axisLabelFormatter: (details) => ChartAxisLabel(
|
||||
'${details.value.toInt().toString().padLeft(2, '0')}:00',
|
||||
details.textStyle,
|
||||
),
|
||||
plotBands: _sleepBands(),
|
||||
),
|
||||
primaryYAxis: NumericAxis(
|
||||
minimum: 0,
|
||||
title: AxisTitle(
|
||||
text: s.insights_unit_kcal_per_meal,
|
||||
textStyle: insightsAxisLabelStyle,
|
||||
),
|
||||
labelStyle: insightsAxisLabelStyle,
|
||||
majorTickLines: const MajorTickLines(size: 0),
|
||||
axisLine: const AxisLine(width: 0),
|
||||
majorGridLines: MajorGridLines(
|
||||
color: InsightsColors.border.withValues(alpha: 0.8),
|
||||
dashArray: const [4, 4],
|
||||
),
|
||||
),
|
||||
axes: const <ChartAxis>[
|
||||
NumericAxis(
|
||||
name: _scaleAxisName,
|
||||
opposedPosition: true,
|
||||
minimum: 0,
|
||||
maximum: 5,
|
||||
interval: 1,
|
||||
labelStyle: insightsAxisLabelStyle,
|
||||
majorTickLines: MajorTickLines(size: 0),
|
||||
axisLine: AxisLine(width: 0),
|
||||
majorGridLines: MajorGridLines(width: 0),
|
||||
),
|
||||
],
|
||||
trackballBehavior: buildInsightsTrackball(),
|
||||
series: <CartesianSeries<dynamic, double>>[
|
||||
if (_isVisible(0))
|
||||
_barSeries(
|
||||
s.insights_series_proteins,
|
||||
InsightsColors.proteins,
|
||||
(bar) => bar.p,
|
||||
barFraction,
|
||||
),
|
||||
if (_isVisible(1))
|
||||
_barSeries(
|
||||
s.insights_series_fats,
|
||||
InsightsColors.fats,
|
||||
(bar) => bar.f,
|
||||
barFraction,
|
||||
),
|
||||
if (_isVisible(2))
|
||||
_barSeries(
|
||||
s.insights_series_carbs,
|
||||
InsightsColors.carbs,
|
||||
(bar) => bar.c,
|
||||
barFraction,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(6),
|
||||
),
|
||||
),
|
||||
if (_isVisible(3) && model.satiety.isNotEmpty)
|
||||
_scaleSeries(
|
||||
s.insights_series_satiety,
|
||||
InsightsColors.satiety,
|
||||
model.satiety,
|
||||
),
|
||||
if (_isVisible(4) && model.mood.isNotEmpty)
|
||||
_scaleSeries(
|
||||
s.insights_series_mood,
|
||||
InsightsColors.mood,
|
||||
model.mood,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isVisible(int index) =>
|
||||
index < visibility.length ? visibility[index] : true;
|
||||
|
||||
CartesianSeries<IntraBarModel, double> _barSeries(
|
||||
String name,
|
||||
Color color,
|
||||
double Function(IntraBarModel bar) valueOf,
|
||||
double widthFraction, {
|
||||
BorderRadius borderRadius = BorderRadius.zero,
|
||||
}) {
|
||||
return StackedColumnSeries<IntraBarModel, double>(
|
||||
name: name,
|
||||
dataSource: model.bars,
|
||||
xValueMapper: (bar, _) => bar.x,
|
||||
yValueMapper: (bar, _) => valueOf(bar),
|
||||
color: color,
|
||||
width: widthFraction,
|
||||
spacing: 0,
|
||||
borderRadius: borderRadius,
|
||||
);
|
||||
}
|
||||
|
||||
CartesianSeries<ScalePointModel, double> _scaleSeries(
|
||||
String name,
|
||||
Color color,
|
||||
List<ScalePointModel> points,
|
||||
) {
|
||||
return SplineSeries<ScalePointModel, double>(
|
||||
name: name,
|
||||
yAxisName: _scaleAxisName,
|
||||
dataSource: points,
|
||||
xValueMapper: (point, _) => point.x,
|
||||
yValueMapper: (point, _) => point.y,
|
||||
splineType: SplineType.monotonic,
|
||||
color: color,
|
||||
width: 2.5,
|
||||
markerSettings: MarkerSettings(
|
||||
isVisible: true,
|
||||
width: 3,
|
||||
height: 3,
|
||||
color: color,
|
||||
borderColor: color,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Затемнение `[0, wake]` и `[sleep, 24]` плюс пунктиры подъёма и отхода ко сну.
|
||||
List<PlotBand> _sleepBands() {
|
||||
if (!showSleep) {
|
||||
return const [];
|
||||
}
|
||||
final wake = model.wake;
|
||||
final sleep = model.sleep;
|
||||
return [
|
||||
if (wake != null && wake > 0)
|
||||
PlotBand(start: 0, end: wake, color: InsightsColors.sleepShade),
|
||||
if (sleep != null && sleep < 24 && (wake == null || sleep > wake))
|
||||
PlotBand(start: sleep, end: 24, color: InsightsColors.sleepShade),
|
||||
if (wake != null)
|
||||
PlotBand(
|
||||
start: wake,
|
||||
end: wake,
|
||||
borderColor: InsightsColors.wake,
|
||||
borderWidth: 1.5,
|
||||
dashArray: const [5, 4],
|
||||
),
|
||||
if (sleep != null)
|
||||
PlotBand(
|
||||
start: sleep,
|
||||
end: sleep,
|
||||
borderColor: InsightsColors.sleepLine,
|
||||
borderWidth: 1.5,
|
||||
dashArray: const [5, 4],
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
86
lib/features/insights/presentation/charts/measure_chart.dart
Normal file
86
lib/features/insights/presentation/charts/measure_chart.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
|
||||
/// Три линии: талия, грудь, бёдра. [visibility] — видимость серий в этом порядке.
|
||||
class MeasureChart extends StatelessWidget {
|
||||
const MeasureChart({
|
||||
super.key,
|
||||
required this.model,
|
||||
required this.visibility,
|
||||
required this.emptyText,
|
||||
});
|
||||
|
||||
final MeasureChartModel model;
|
||||
final List<bool> visibility;
|
||||
final String emptyText;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final waist = buildPoints(model.labels, model.waist);
|
||||
final chest = buildPoints(model.labels, model.chest);
|
||||
final hips = buildPoints(model.labels, model.hips);
|
||||
if (waist.isEmpty && chest.isEmpty && hips.isEmpty) {
|
||||
return InsightsChartPlaceholder(text: emptyText);
|
||||
}
|
||||
final s = S.of(context);
|
||||
final series = [
|
||||
(
|
||||
name: s.insights_series_waist,
|
||||
color: InsightsColors.waist,
|
||||
points: waist,
|
||||
),
|
||||
(
|
||||
name: s.insights_series_chest,
|
||||
color: InsightsColors.chest,
|
||||
points: chest,
|
||||
),
|
||||
(name: s.insights_series_hips, color: InsightsColors.hips, points: hips),
|
||||
];
|
||||
final visibleValues = <double>[
|
||||
for (var i = 0; i < series.length; i++)
|
||||
if (_isVisible(i)) ...series[i].points.map((e) => e.value),
|
||||
];
|
||||
final range = paddedRange(
|
||||
visibleValues.isEmpty
|
||||
? series.expand((e) => e.points).map((e) => e.value)
|
||||
: visibleValues,
|
||||
minPadding: 2,
|
||||
);
|
||||
|
||||
return SizedBox(
|
||||
height: insightsChartHeight,
|
||||
child: SfCartesianChart(
|
||||
key: const Key('insights_measure_chart'),
|
||||
margin: EdgeInsets.zero,
|
||||
plotAreaBorderWidth: 0,
|
||||
primaryXAxis: buildInsightsCategoryAxis(),
|
||||
primaryYAxis: buildInsightsValueAxis(
|
||||
unit: s.insights_unit_cm,
|
||||
min: range.min,
|
||||
max: range.max,
|
||||
),
|
||||
trackballBehavior: buildInsightsTrackball(),
|
||||
series: <CartesianSeries<InsightsPoint, String>>[
|
||||
for (var i = 0; i < series.length; i++)
|
||||
if (_isVisible(i))
|
||||
SplineSeries<InsightsPoint, String>(
|
||||
name: series[i].name,
|
||||
dataSource: series[i].points,
|
||||
xValueMapper: (point, _) => point.label,
|
||||
yValueMapper: (point, _) => point.value,
|
||||
color: series[i].color,
|
||||
width: 3,
|
||||
markerSettings: buildInsightsMarker(series[i].color),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isVisible(int index) =>
|
||||
index < visibility.length ? visibility[index] : true;
|
||||
}
|
||||
53
lib/features/insights/presentation/charts/weight_chart.dart
Normal file
53
lib/features/insights/presentation/charts/weight_chart.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
|
||||
class WeightChart extends StatelessWidget {
|
||||
const WeightChart({super.key, required this.model, required this.emptyText});
|
||||
|
||||
final WeightChartModel model;
|
||||
|
||||
/// Текст при отсутствии точек («Добавьте Ваш вес»).
|
||||
final String emptyText;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final points = buildPoints(model.labels, model.values);
|
||||
if (points.isEmpty) {
|
||||
return InsightsChartPlaceholder(text: emptyText);
|
||||
}
|
||||
final s = S.of(context);
|
||||
final range = paddedRange(points.map((e) => e.value));
|
||||
|
||||
return SizedBox(
|
||||
height: insightsChartHeight,
|
||||
child: SfCartesianChart(
|
||||
key: const Key('insights_weight_chart'),
|
||||
margin: EdgeInsets.zero,
|
||||
plotAreaBorderWidth: 0,
|
||||
primaryXAxis: buildInsightsCategoryAxis(),
|
||||
primaryYAxis: buildInsightsValueAxis(
|
||||
unit: s.insights_unit_kg,
|
||||
min: range.min,
|
||||
max: range.max,
|
||||
),
|
||||
trackballBehavior: buildInsightsTrackball(),
|
||||
series: <CartesianSeries<InsightsPoint, String>>[
|
||||
SplineAreaSeries<InsightsPoint, String>(
|
||||
name: s.insights_tab_weight,
|
||||
dataSource: points,
|
||||
xValueMapper: (point, _) => point.label,
|
||||
yValueMapper: (point, _) => point.value,
|
||||
borderColor: InsightsColors.blue,
|
||||
borderWidth: 3,
|
||||
gradient: buildInsightsAreaGradient(InsightsColors.blue),
|
||||
markerSettings: buildInsightsMarker(InsightsColors.blue),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
94
lib/features/insights/repositories/insights_repository.dart
Normal file
94
lib/features/insights/repositories/insights_repository.dart
Normal file
@@ -0,0 +1,94 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/features/insights/domain/insights_text_builder.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/profile/repositories/profile_repository.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
|
||||
/// Данные аналитики недоступны текущему пользователю (HTTP 403): консультация
|
||||
/// не активна либо это чужой профиль без доступа.
|
||||
class InsightsForbidden implements Exception {
|
||||
const InsightsForbidden();
|
||||
|
||||
@override
|
||||
String toString() => 'InsightsForbidden';
|
||||
}
|
||||
|
||||
typedef InsightsTextBuilderFactory = InsightsTextBuilder Function();
|
||||
|
||||
/// Итерация 1: собирает [InsightsPayloadModel] из существующих эндпоинтов
|
||||
/// профиля. Во второй итерации источник заменяется одним запросом
|
||||
/// `GET /api/profile/insights`.
|
||||
class InsightsRepository {
|
||||
InsightsRepository({
|
||||
required this.profileRepository,
|
||||
InsightsTextBuilderFactory? textBuilderFactory,
|
||||
}) : _textBuilderFactory =
|
||||
textBuilderFactory ?? (() => InsightsTextBuilder(S.current));
|
||||
|
||||
final ProfileRepository profileRepository;
|
||||
final InsightsTextBuilderFactory _textBuilderFactory;
|
||||
|
||||
/// Рост в сантиметрах для расчёта подсказки ИМТ; `null`, если не указан или
|
||||
/// профиль не удалось загрузить (кроме 403 — он пробрасывается как [InsightsForbidden]).
|
||||
Future<int?> getHeightCm({String? slug}) async {
|
||||
try {
|
||||
final details = slug == null
|
||||
? await profileRepository.getMyProfileDetails()
|
||||
: await profileRepository.getProfileDetailsBySlug(slug);
|
||||
return details.height > 0 ? details.height : null;
|
||||
} on DioException catch (e) {
|
||||
_throwIfForbidden(e);
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<InsightsPayloadModel> getInsights({
|
||||
String? slug,
|
||||
required BodyPeriod bodyPeriod,
|
||||
int? heightCm,
|
||||
}) async {
|
||||
try {
|
||||
final (weights, circumferences) = await (
|
||||
profileRepository.getWeight(period: bodyPeriod.apiValue, slug: slug),
|
||||
profileRepository.getCircumferences(
|
||||
period: bodyPeriod.apiValue,
|
||||
slug: slug,
|
||||
),
|
||||
).wait;
|
||||
return _textBuilderFactory().build(
|
||||
weights: weights,
|
||||
circumferences: circumferences,
|
||||
period: bodyPeriod,
|
||||
heightCm: heightCm,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
_throwIfForbidden(e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addWeight({required double weight}) {
|
||||
return profileRepository.addWeight(weight: weight);
|
||||
}
|
||||
|
||||
Future<void> addCircumference({
|
||||
required int chestCircumference,
|
||||
required int waistCircumference,
|
||||
required int hipCircumference,
|
||||
}) {
|
||||
return profileRepository.addCircumference(
|
||||
chestCircumference: chestCircumference,
|
||||
waistCircumference: waistCircumference,
|
||||
hipCircumference: hipCircumference,
|
||||
);
|
||||
}
|
||||
|
||||
void _throwIfForbidden(DioException e) {
|
||||
if (e.response?.statusCode == 403) {
|
||||
throw const InsightsForbidden();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,9 @@ abstract class CircumferenceModel with _$CircumferenceModel {
|
||||
const factory CircumferenceModel({
|
||||
@JsonKey(name: "id") required int id,
|
||||
@JsonKey(name: "user_id") required int userId,
|
||||
@JsonKey(name: "chest_circumference") required int chestCircumference,
|
||||
@JsonKey(name: "waist_circumference") required int waistCircumference,
|
||||
@JsonKey(name: "hip_circumference") required int hipCircumference,
|
||||
@JsonKey(name: "chest_circumference") required double chestCircumference,
|
||||
@JsonKey(name: "waist_circumference") required double waistCircumference,
|
||||
@JsonKey(name: "hip_circumference") required double hipCircumference,
|
||||
@JsonKey(name: "created_at") required DateTime createdAt,
|
||||
@JsonKey(name: "updated_at") required DateTime updatedAt,
|
||||
}) = _CircumferenceModel;
|
||||
|
||||
@@ -9,10 +9,10 @@ abstract class WeightModel with _$WeightModel {
|
||||
const factory WeightModel({
|
||||
@JsonKey(name: "id") required int id,
|
||||
@JsonKey(name: "user_id") required int userId,
|
||||
@JsonKey(name: "weight") required int weight,
|
||||
@JsonKey(name: "weight") required double weight,
|
||||
@JsonKey(name: "created_at") required DateTime createdAt,
|
||||
@JsonKey(name: "updated_at") required DateTime updatedAt,
|
||||
@JsonKey(name: "bmi") required double bmi,
|
||||
@JsonKey(name: "bmi") double? bmi,
|
||||
}) = _WeightModel;
|
||||
|
||||
factory WeightModel.fromJson(Map<String, dynamic> json) => _$WeightModelFromJson(json);
|
||||
|
||||
@@ -17,7 +17,6 @@ class ProfileCubit extends Cubit<ProfileState> {
|
||||
final ProfileRepository profileRepository;
|
||||
final ChatRepository chatRepository;
|
||||
final ConsultantRepository consultantRepository;
|
||||
String _currentPeriod = '1';
|
||||
|
||||
/// Bottom-nav / own profile instance, used to refresh posts after create/update/delete.
|
||||
static ProfileCubit? myProfileCubit;
|
||||
@@ -55,10 +54,9 @@ class ProfileCubit extends Cubit<ProfileState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> getMyProfile({String period = '1'}) async {
|
||||
Future<void> getMyProfile() async {
|
||||
myProfileCubit = this;
|
||||
emit(const ProfileState.loading());
|
||||
_currentPeriod = period;
|
||||
try {
|
||||
final profile = await profileRepository.getMyProfile();
|
||||
List<ConsultantCertPhotoModel> certPhotos = await consultantRepository.getConsultantCertPhotos();
|
||||
@@ -69,8 +67,6 @@ class ProfileCubit extends Cubit<ProfileState> {
|
||||
isMyProfile: true,
|
||||
profileDetails: await profileRepository.getMyProfileDetails(),
|
||||
blogPosts: await profileRepository.getBlogPostsByAuthorSlug(slug: profile.slug),
|
||||
weightPoints: await profileRepository.getWeight(period: period),
|
||||
circumferencePoints: await profileRepository.getCircumferences(period: period),
|
||||
consultantInfo: profile.roles.contains('consultant') ? await consultantRepository.getConsultantRequestStatus(profile.slug) : null,
|
||||
certPhotos: certPhotos,
|
||||
),
|
||||
@@ -110,8 +106,6 @@ class ProfileCubit extends Cubit<ProfileState> {
|
||||
profile: (state as ProfileStateLoaded).profile,
|
||||
profileDetails: (state as ProfileStateLoaded).profileDetails,
|
||||
blogPosts: feedBlogPostList,
|
||||
weightPoints: (state as ProfileStateLoaded).weightPoints,
|
||||
circumferencePoints: (state as ProfileStateLoaded).circumferencePoints,
|
||||
consultantInfo: (state as ProfileStateLoaded).consultantInfo,
|
||||
isMyProfile: (state as ProfileStateLoaded).isMyProfile,
|
||||
certPhotos: (state as ProfileStateLoaded).certPhotos,
|
||||
@@ -126,7 +120,6 @@ class ProfileCubit extends Cubit<ProfileState> {
|
||||
waistCircumference: waistCircumference,
|
||||
hipCircumference: hipCircumference,
|
||||
);
|
||||
await getMyProfile(period: _currentPeriod);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
emit(ProfileState.error(errorMessage: e.toString()));
|
||||
@@ -136,7 +129,6 @@ class ProfileCubit extends Cubit<ProfileState> {
|
||||
Future<void> addWeight({required double weight}) async {
|
||||
try {
|
||||
await profileRepository.addWeight(weight: weight);
|
||||
await getMyProfile(period: _currentPeriod);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
emit(ProfileState.error(errorMessage: e.toString()));
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'profile_state.freezed.dart';
|
||||
@@ -20,8 +18,6 @@ abstract class ProfileState with _$ProfileState {
|
||||
required List<FeedBlogPostModel> blogPosts,
|
||||
required bool isMyProfile,
|
||||
required List<ConsultantCertPhotoModel> certPhotos,
|
||||
List<WeightModel>? weightPoints,
|
||||
List<CircumferenceModel>? circumferencePoints,
|
||||
ConsultationRequestModel? consultantInfo,
|
||||
}) = ProfileStateLoaded;
|
||||
|
||||
|
||||
@@ -18,21 +18,17 @@ import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/prof
|
||||
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
||||
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/plan_widget.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_state.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/pages/subs_screen.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/circumference_chart_widget.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/expandable_page_view.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/imt_chart_widget.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/profile_blog_post_widget.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/toggle_cert_photo_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/weight_chart_widget.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
@@ -60,21 +56,8 @@ class ProfileScreen extends StatefulWidget {
|
||||
class _ProfileScreenState extends State<ProfileScreen> {
|
||||
late final ProfileCubit profileCubit = sl<ProfileCubit>();
|
||||
final PlansCubit plansCubit = sl<PlansCubit>();
|
||||
final List<String> chipsLabelList = ['Вес', 'Замеры', 'ИМТ'];
|
||||
final List<String> chipsIntervalLabelList = [
|
||||
'1М',
|
||||
'3М',
|
||||
'6М',
|
||||
'1Г',
|
||||
'ВСЁ ВРЕМЯ',
|
||||
];
|
||||
final List<String> chipsIntervalList = ['1', '3', '6', '12', 'all'];
|
||||
final TextEditingController controller = TextEditingController();
|
||||
|
||||
List<WeightModel> weightPoints = [];
|
||||
List<CircumferenceModel> circumferencePoints = [];
|
||||
int _selectedChipIndex = 0;
|
||||
int _selectedIntervalChipIndex = 0;
|
||||
late bool canSub;
|
||||
bool isFirstFetch = true;
|
||||
int _currentBullet = 0;
|
||||
@@ -96,10 +79,6 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||
bloc: profileCubit,
|
||||
builder: (BuildContext context, ProfileState state) {
|
||||
if (state is ProfileStateLoaded) {
|
||||
if (state.circumferencePoints != null) {
|
||||
circumferencePoints = state.circumferencePoints!;
|
||||
weightPoints = state.weightPoints!;
|
||||
}
|
||||
if (isFirstFetch) {
|
||||
canSub = !state.profile.isEntitySubscribedByMe;
|
||||
if (state.profile.roles.contains('consultant') && !hidePlans) {
|
||||
@@ -442,10 +421,11 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||
child: _badges(state),
|
||||
),
|
||||
if (state.profile.roles.contains('consultant')) _socialLinks(state),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: _charts(),
|
||||
),
|
||||
if (state.isMyProfile)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 16),
|
||||
child: InsightsCard(canAdd: true),
|
||||
),
|
||||
if (state.profile.roles.contains('consultant')) _education(state),
|
||||
if (state.profile.roles.contains('consultant'))
|
||||
_diplomasAndCertificates(state),
|
||||
@@ -1722,114 +1702,4 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Material _charts() {
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
_choiceChipWidget(0),
|
||||
const SizedBox(width: 8),
|
||||
_choiceChipWidget(1),
|
||||
const SizedBox(width: 8),
|
||||
_choiceChipWidget(2),
|
||||
],
|
||||
),
|
||||
),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
switch (_selectedChipIndex) {
|
||||
case 0:
|
||||
return WeightChartWidget(
|
||||
weightPoints: weightPoints,
|
||||
profileCubit: profileCubit,
|
||||
);
|
||||
case 1:
|
||||
return CircumferenceChartWidget(
|
||||
circumferencePoints: circumferencePoints,
|
||||
profileCubit: profileCubit,
|
||||
);
|
||||
case 2:
|
||||
return ImtChartWidget(weightPoints: weightPoints);
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const SizedBox(width: 16),
|
||||
_choiceIntervalChipWidget(0),
|
||||
const SizedBox(width: 8),
|
||||
_choiceIntervalChipWidget(1),
|
||||
const SizedBox(width: 8),
|
||||
_choiceIntervalChipWidget(2),
|
||||
const SizedBox(width: 8),
|
||||
_choiceIntervalChipWidget(3),
|
||||
const SizedBox(width: 8),
|
||||
_choiceIntervalChipWidget(4),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ChoiceChip _choiceIntervalChipWidget(int index) {
|
||||
return ChoiceChip(
|
||||
selectedColor: Colors.grey.withValues(alpha: 0.1),
|
||||
pressElevation: 0,
|
||||
showCheckmark: false,
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
label: Text(chipsIntervalLabelList[index]),
|
||||
labelStyle: TextStyle(
|
||||
color: appTheme.gray600,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 12,
|
||||
),
|
||||
selected: _selectedIntervalChipIndex == index,
|
||||
onSelected: (bool selected) {
|
||||
profileCubit.getMyProfile(period: chipsIntervalList[index]);
|
||||
setState(() {
|
||||
_selectedIntervalChipIndex = index;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
ChoiceChip _choiceChipWidget(int index) {
|
||||
return ChoiceChip(
|
||||
selectedColor: appTheme.blueA400,
|
||||
pressElevation: 0,
|
||||
showCheckmark: false,
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
label: Text(chipsLabelList[index]),
|
||||
labelStyle: TextStyle(
|
||||
color: index == _selectedChipIndex ? Colors.white : appTheme.gray600,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 12,
|
||||
),
|
||||
selected: _selectedChipIndex == index,
|
||||
backgroundColor: appTheme.gray100,
|
||||
onSelected: (bool selected) {
|
||||
setState(() {
|
||||
_selectedChipIndex = index;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,389 +0,0 @@
|
||||
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
|
||||
class CircumferenceChartWidget extends StatefulWidget {
|
||||
final List<CircumferenceModel> circumferencePoints;
|
||||
final ProfileCubit? profileCubit;
|
||||
|
||||
const CircumferenceChartWidget({
|
||||
super.key,
|
||||
required this.circumferencePoints,
|
||||
this.profileCubit,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CircumferenceChartWidget> createState() => _CircumferenceChartWidgetState();
|
||||
}
|
||||
|
||||
class _CircumferenceChartWidgetState extends State<CircumferenceChartWidget> {
|
||||
late FixedExtentScrollController chestController;
|
||||
late FixedExtentScrollController waistController;
|
||||
late FixedExtentScrollController hipController;
|
||||
late int chest;
|
||||
late int waist;
|
||||
late int hip;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.circumferencePoints.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${widget.circumferencePoints.last.chestCircumference}',
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 40,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (widget.circumferencePoints.length == 1)
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.black12,
|
||||
),
|
||||
),
|
||||
if (widget.circumferencePoints.length > 1)
|
||||
widget.circumferencePoints.last.chestCircumference > widget.circumferencePoints[widget.circumferencePoints.length - 2].chestCircumference
|
||||
? const Icon(
|
||||
Icons.arrow_drop_up,
|
||||
size: 40,
|
||||
color: Colors.grey,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 40,
|
||||
color: Colors.grey,
|
||||
),
|
||||
Text(
|
||||
'${widget.circumferencePoints.last.waistCircumference}',
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 40,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (widget.circumferencePoints.length == 1)
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.black12,
|
||||
),
|
||||
),
|
||||
if (widget.circumferencePoints.length > 1)
|
||||
widget.circumferencePoints.last.waistCircumference > widget.circumferencePoints[widget.circumferencePoints.length - 2].waistCircumference
|
||||
? const Icon(
|
||||
Icons.arrow_drop_up,
|
||||
size: 40,
|
||||
color: Colors.grey,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 40,
|
||||
color: Colors.grey,
|
||||
),
|
||||
Text(
|
||||
'${widget.circumferencePoints.last.hipCircumference}',
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 40,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (widget.circumferencePoints.length == 1)
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.black12,
|
||||
),
|
||||
),
|
||||
if (widget.circumferencePoints.length > 1)
|
||||
widget.circumferencePoints.last.hipCircumference > widget.circumferencePoints[widget.circumferencePoints.length - 2].hipCircumference
|
||||
? const Icon(
|
||||
Icons.arrow_drop_up,
|
||||
size: 40,
|
||||
color: Colors.grey,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 40,
|
||||
color: Colors.grey,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
SfCartesianChart(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
enableAxisAnimation: true,
|
||||
plotAreaBorderWidth: 0,
|
||||
trackballBehavior: TrackballBehavior(
|
||||
enable: true,
|
||||
activationMode: ActivationMode.singleTap,
|
||||
tooltipDisplayMode: TrackballDisplayMode.groupAllPoints,
|
||||
tooltipSettings: const InteractiveTooltip(),
|
||||
lineType: TrackballLineType.vertical,
|
||||
),
|
||||
primaryXAxis: DateTimeCategoryAxis(
|
||||
tickPosition: TickPosition.inside,
|
||||
majorTickLines: const MajorTickLines(
|
||||
size: 0,
|
||||
),
|
||||
edgeLabelPlacement: EdgeLabelPlacement.shift,
|
||||
dateFormat: DateFormat.MMMd('ru'),
|
||||
labelStyle: const TextStyle(
|
||||
color: Colors.grey,
|
||||
),
|
||||
majorGridLines: MajorGridLines(dashArray: const [1], color: Colors.grey.withValues(alpha: 0.3)),
|
||||
axisLine: AxisLine(
|
||||
color: Colors.grey.withValues(alpha: 0.3),
|
||||
width: 0.4,
|
||||
),
|
||||
),
|
||||
primaryYAxis: const NumericAxis(
|
||||
isVisible: false,
|
||||
labelFormat: '{value} см',
|
||||
axisLine: AxisLine(
|
||||
width: 1,
|
||||
),
|
||||
majorTickLines: MajorTickLines(size: 0),
|
||||
),
|
||||
series: _getSplineSeries(),
|
||||
tooltipBehavior: TooltipBehavior(enable: true),
|
||||
),
|
||||
if (widget.profileCubit != null)
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: FloatingActionButton.small(
|
||||
heroTag: 'addCircumference',
|
||||
onPressed: () {
|
||||
if (widget.circumferencePoints.isNotEmpty) {
|
||||
chest = widget.circumferencePoints.last.chestCircumference;
|
||||
waist = widget.circumferencePoints.last.waistCircumference;
|
||||
hip = widget.circumferencePoints.last.hipCircumference;
|
||||
chestController = FixedExtentScrollController(initialItem: chest - 20);
|
||||
waistController = FixedExtentScrollController(initialItem: waist - 20);
|
||||
hipController = FixedExtentScrollController(initialItem: hip - 20);
|
||||
} else {
|
||||
chestController = FixedExtentScrollController();
|
||||
waistController = FixedExtentScrollController();
|
||||
hipController = FixedExtentScrollController();
|
||||
}
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CupertinoPicker(
|
||||
magnification: 1.22,
|
||||
squeeze: 1.2,
|
||||
useMagnifier: true,
|
||||
scrollController: chestController,
|
||||
itemExtent: 24,
|
||||
onSelectedItemChanged: (int value) {
|
||||
chest = value;
|
||||
},
|
||||
children: [
|
||||
for (int i = 20; i < 200; i++) Text('$i'),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: CupertinoPicker(
|
||||
magnification: 1.22,
|
||||
squeeze: 1.2,
|
||||
useMagnifier: true,
|
||||
scrollController: waistController,
|
||||
itemExtent: 24,
|
||||
onSelectedItemChanged: (int value) {
|
||||
waist = value;
|
||||
},
|
||||
children: [
|
||||
for (int i = 20; i < 200; i++) Text('$i'),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: CupertinoPicker(
|
||||
magnification: 1.22,
|
||||
squeeze: 1.2,
|
||||
useMagnifier: true,
|
||||
scrollController: hipController,
|
||||
itemExtent: 24,
|
||||
onSelectedItemChanged: (int value) {
|
||||
hip = value;
|
||||
},
|
||||
children: [
|
||||
for (int i = 20; i < 200; i++) Text('$i'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
widget.profileCubit?.addCircumference(
|
||||
chestCircumference: chest + 20,
|
||||
waistCircumference: waist + 20,
|
||||
hipCircumference: hip + 20,
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
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: const Center(
|
||||
child: Text(
|
||||
'Сохранить',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
backgroundColor: appTheme.blueA400,
|
||||
shape: const CircleBorder(),
|
||||
child: const Icon(
|
||||
Icons.add,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.circumferencePoints.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 48),
|
||||
child: Text(
|
||||
'Добавьте Ваши замеры',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<CartesianSeries<CircumferenceModel, DateTime>> _getSplineSeries() {
|
||||
const Color seriesColor1 = Colors.red;
|
||||
final Color seriesColor2 = appTheme.blueA400;
|
||||
const Color seriesColor3 = Colors.green;
|
||||
|
||||
return <CartesianSeries<CircumferenceModel, DateTime>>[
|
||||
SplineAreaSeries<CircumferenceModel, DateTime>(
|
||||
markerSettings: const MarkerSettings(
|
||||
isVisible: true,
|
||||
color: seriesColor1,
|
||||
height: 8,
|
||||
width: 8,
|
||||
),
|
||||
dataLabelSettings: const DataLabelSettings(
|
||||
labelAlignment: ChartDataLabelAlignment.top,
|
||||
isVisible: true,
|
||||
),
|
||||
dataSource: widget.circumferencePoints,
|
||||
borderColor: seriesColor1,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
seriesColor1.withValues(alpha: 0.14),
|
||||
Colors.transparent,
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: const Alignment(0, 0.6),
|
||||
),
|
||||
name: 'Грудь',
|
||||
xValueMapper: (CircumferenceModel value, int index) => value.createdAt,
|
||||
yValueMapper: (CircumferenceModel value, int index) => value.chestCircumference,
|
||||
),
|
||||
SplineAreaSeries<CircumferenceModel, DateTime>(
|
||||
markerSettings: MarkerSettings(
|
||||
isVisible: true,
|
||||
color: seriesColor2,
|
||||
height: 8,
|
||||
width: 8,
|
||||
),
|
||||
dataLabelSettings: const DataLabelSettings(
|
||||
labelAlignment: ChartDataLabelAlignment.top,
|
||||
isVisible: true,
|
||||
),
|
||||
dataSource: widget.circumferencePoints,
|
||||
borderColor: seriesColor2,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
seriesColor2.withValues(alpha: 0.14),
|
||||
Colors.transparent,
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: const Alignment(0, 0.6),
|
||||
),
|
||||
name: 'Талия',
|
||||
xValueMapper: (CircumferenceModel value, int index) => value.createdAt,
|
||||
yValueMapper: (CircumferenceModel value, int index) => value.waistCircumference,
|
||||
),
|
||||
SplineAreaSeries<CircumferenceModel, DateTime>(
|
||||
markerSettings: const MarkerSettings(
|
||||
isVisible: true,
|
||||
color: seriesColor3,
|
||||
height: 8,
|
||||
width: 8,
|
||||
),
|
||||
dataLabelSettings: const DataLabelSettings(
|
||||
labelAlignment: ChartDataLabelAlignment.top,
|
||||
isVisible: true,
|
||||
),
|
||||
dataSource: widget.circumferencePoints,
|
||||
borderColor: seriesColor3,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
seriesColor3.withValues(alpha: 0.14),
|
||||
Colors.transparent,
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: const Alignment(0, 0.6),
|
||||
),
|
||||
name: 'Бёдра',
|
||||
xValueMapper: (CircumferenceModel value, int index) => value.createdAt,
|
||||
yValueMapper: (CircumferenceModel value, int index) => value.hipCircumference,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class ImtChartWidget extends StatelessWidget {
|
||||
final List<WeightModel> weightPoints;
|
||||
|
||||
const ImtChartWidget({
|
||||
super.key,
|
||||
required this.weightPoints,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
if (weightPoints.isNotEmpty)
|
||||
Text(
|
||||
weightPoints.last.bmi.toStringAsFixed(2),
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 40,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (weightPoints.length > 1)
|
||||
weightPoints.last.bmi > weightPoints[weightPoints.length - 2].bmi
|
||||
? const Icon(
|
||||
Icons.arrow_drop_up,
|
||||
size: 40,
|
||||
color: Colors.grey,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 40,
|
||||
color: Colors.grey,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
SfCartesianChart(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
enableAxisAnimation: true,
|
||||
plotAreaBorderWidth: 0,
|
||||
trackballBehavior: TrackballBehavior(
|
||||
enable: true,
|
||||
activationMode: ActivationMode.singleTap,
|
||||
tooltipDisplayMode: TrackballDisplayMode.groupAllPoints,
|
||||
tooltipSettings: const InteractiveTooltip(),
|
||||
lineType: TrackballLineType.vertical,
|
||||
),
|
||||
primaryXAxis: DateTimeCategoryAxis(
|
||||
tickPosition: TickPosition.inside,
|
||||
majorTickLines: const MajorTickLines(
|
||||
size: 0,
|
||||
),
|
||||
edgeLabelPlacement: EdgeLabelPlacement.shift,
|
||||
dateFormat: DateFormat.MMMd('ru'),
|
||||
labelStyle: const TextStyle(
|
||||
color: Colors.grey,
|
||||
),
|
||||
majorGridLines: MajorGridLines(dashArray: const [1], color: Colors.grey.withValues(alpha: 0.3)),
|
||||
axisLine: AxisLine(
|
||||
color: Colors.grey.withValues(alpha: 0.3),
|
||||
width: 0.4,
|
||||
),
|
||||
),
|
||||
primaryYAxis: const NumericAxis(
|
||||
isVisible: false,
|
||||
),
|
||||
series: _getSplineSeries(),
|
||||
tooltipBehavior: TooltipBehavior(enable: true),
|
||||
),
|
||||
if (weightPoints.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(
|
||||
'Индекс Массы Тела рассчитывается при указанных весе и росте',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<CartesianSeries<WeightModel, DateTime>> _getSplineSeries() {
|
||||
return <CartesianSeries<WeightModel, DateTime>>[
|
||||
SplineAreaSeries<WeightModel, DateTime>(
|
||||
markerSettings: MarkerSettings(
|
||||
isVisible: true,
|
||||
color: appTheme.blueA400,
|
||||
height: 8,
|
||||
width: 8,
|
||||
),
|
||||
dataLabelSettings: const DataLabelSettings(
|
||||
labelAlignment: ChartDataLabelAlignment.top,
|
||||
isVisible: true,
|
||||
),
|
||||
dataSource: weightPoints,
|
||||
borderColor: appTheme.blueA400,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
appTheme.blueA400.withValues(alpha: 0.14),
|
||||
Colors.transparent,
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: const Alignment(0, 0.6),
|
||||
),
|
||||
name: 'ИМТ',
|
||||
xValueMapper: (WeightModel value, int index) => value.createdAt,
|
||||
yValueMapper: (WeightModel value, int index) => double.parse(value.bmi.toStringAsFixed(2)),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
|
||||
class WeightChartWidget extends StatefulWidget {
|
||||
final List<WeightModel> weightPoints;
|
||||
final ProfileCubit? profileCubit;
|
||||
|
||||
const WeightChartWidget({
|
||||
super.key,
|
||||
required this.weightPoints,
|
||||
this.profileCubit,
|
||||
});
|
||||
|
||||
@override
|
||||
State<WeightChartWidget> createState() => _WeightChartWidgetState();
|
||||
}
|
||||
|
||||
class _WeightChartWidgetState extends State<WeightChartWidget> {
|
||||
late FixedExtentScrollController weightKiloController;
|
||||
late FixedExtentScrollController weightGramsController;
|
||||
int weightKilo = 20;
|
||||
int weightGrams = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.weightPoints.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${widget.weightPoints.last.weight.toStringAsFixed(1)} кг',
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 40,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (widget.weightPoints.length == 1)
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.black12,
|
||||
),
|
||||
),
|
||||
if (widget.weightPoints.length > 1)
|
||||
widget.weightPoints.last.weight > widget.weightPoints[widget.weightPoints.length - 2].weight
|
||||
? Icon(
|
||||
Icons.arrow_drop_up,
|
||||
size: 40,
|
||||
color: Colors.grey.withValues(alpha: 0.4),
|
||||
)
|
||||
: Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 40,
|
||||
color: Colors.grey.withValues(alpha: 0.4),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
SfCartesianChart(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
enableAxisAnimation: true,
|
||||
plotAreaBorderWidth: 0,
|
||||
trackballBehavior: TrackballBehavior(
|
||||
enable: true,
|
||||
activationMode: ActivationMode.singleTap,
|
||||
tooltipDisplayMode: TrackballDisplayMode.groupAllPoints,
|
||||
tooltipSettings: const InteractiveTooltip(),
|
||||
lineType: TrackballLineType.vertical,
|
||||
),
|
||||
primaryXAxis: DateTimeCategoryAxis(
|
||||
tickPosition: TickPosition.inside,
|
||||
majorTickLines: const MajorTickLines(
|
||||
size: 0,
|
||||
),
|
||||
edgeLabelPlacement: EdgeLabelPlacement.shift,
|
||||
dateFormat: DateFormat.MMMd('ru'),
|
||||
labelStyle: const TextStyle(
|
||||
color: Colors.grey,
|
||||
),
|
||||
majorGridLines: MajorGridLines(dashArray: const [1], color: Colors.grey.withValues(alpha: 0.3)),
|
||||
axisLine: AxisLine(
|
||||
color: Colors.grey.withValues(alpha: 0.3),
|
||||
width: 0.4,
|
||||
),
|
||||
),
|
||||
primaryYAxis: const NumericAxis(
|
||||
isVisible: false,
|
||||
labelFormat: '{value} кг',
|
||||
majorGridLines: MajorGridLines(dashArray: [1], color: Colors.grey),
|
||||
axisLine: AxisLine(
|
||||
width: 1,
|
||||
),
|
||||
majorTickLines: MajorTickLines(size: 0),
|
||||
),
|
||||
series: _getSplineSeries(),
|
||||
tooltipBehavior: TooltipBehavior(enable: true),
|
||||
),
|
||||
if (widget.profileCubit != null)
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: FloatingActionButton.small(
|
||||
heroTag: 'addWeight',
|
||||
onPressed: () {
|
||||
if (widget.weightPoints.isNotEmpty) {
|
||||
try {
|
||||
weightKilo = int.parse(widget.weightPoints.last.weight.toString().split('.')[0]);
|
||||
weightGrams = int.parse(widget.weightPoints.last.weight.toString().split('.')[1][0]);
|
||||
} catch (e) {
|
||||
weightGrams = 0;
|
||||
}
|
||||
weightKiloController = FixedExtentScrollController(initialItem: weightKilo - 20);
|
||||
weightGramsController = FixedExtentScrollController(initialItem: weightGrams);
|
||||
} else {
|
||||
weightKiloController = FixedExtentScrollController();
|
||||
weightGramsController = FixedExtentScrollController();
|
||||
}
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CupertinoPicker(
|
||||
magnification: 1.22,
|
||||
squeeze: 1.2,
|
||||
useMagnifier: true,
|
||||
scrollController: weightKiloController,
|
||||
itemExtent: 24,
|
||||
onSelectedItemChanged: (int value) {
|
||||
weightKilo = value + 20;
|
||||
},
|
||||
children: [
|
||||
for (int i = 20; i < 300; i++) Text('$i'),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: CupertinoPicker(
|
||||
magnification: 1.22,
|
||||
squeeze: 1.2,
|
||||
useMagnifier: true,
|
||||
scrollController: weightGramsController,
|
||||
itemExtent: 24,
|
||||
onSelectedItemChanged: (int value) {
|
||||
weightGrams = value;
|
||||
},
|
||||
children: [
|
||||
for (int i = 0; i < 10; i++) Text('$i'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
widget.profileCubit?.addWeight(weight: double.parse('$weightKilo.$weightGrams'));
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
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: const Center(
|
||||
child: Text(
|
||||
'Сохранить',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
backgroundColor: appTheme.blueA400,
|
||||
shape: const CircleBorder(),
|
||||
child: const Icon(
|
||||
Icons.add,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.weightPoints.isEmpty) const Text('Добавьте Ваш вес'),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<CartesianSeries<WeightModel, DateTime>> _getSplineSeries() {
|
||||
return <CartesianSeries<WeightModel, DateTime>>[
|
||||
SplineAreaSeries<WeightModel, DateTime>(
|
||||
markerSettings: MarkerSettings(
|
||||
isVisible: true,
|
||||
color: appTheme.blueA400,
|
||||
height: 8,
|
||||
width: 8,
|
||||
),
|
||||
dataLabelSettings: const DataLabelSettings(
|
||||
labelAlignment: ChartDataLabelAlignment.top,
|
||||
isVisible: true,
|
||||
),
|
||||
dataSource: widget.weightPoints,
|
||||
borderColor: appTheme.blueA400,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
appTheme.blueA400.withValues(alpha: 0.14),
|
||||
Colors.transparent,
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: const Alignment(0, 0.6),
|
||||
),
|
||||
name: 'Вес',
|
||||
xValueMapper: (WeightModel value, int index) => value.createdAt,
|
||||
yValueMapper: (WeightModel value, int index) => value.weight,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -14,23 +14,10 @@ class UserDetailsCubit extends Cubit<UserDetailsState> {
|
||||
try {
|
||||
final profileDetails = await profileRepository.getProfileDetailsBySlug(slug);
|
||||
emit(state.copyWith(profileDetails: profileDetails, isLoading: false));
|
||||
getChartData(slug: slug);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
emit(state.copyWith(error: e.toString(), isLoading: false));
|
||||
}
|
||||
}
|
||||
|
||||
void getChartData({String period = '1', required String slug}) async {
|
||||
try {
|
||||
final weightPoints = await profileRepository.getWeight(period: period, slug: slug);
|
||||
final circumferencePoints = await profileRepository.getCircumferences(period: period, slug: slug);
|
||||
emit(state.copyWith(weightPoints: weightPoints, circumferencePoints: circumferencePoints));
|
||||
} catch (e, stackTrace) {
|
||||
await reportFrom(e, stackTrace, this);
|
||||
log(e.toString(), stackTrace: stackTrace);
|
||||
emit(state.copyWith(error: e.toString(), isLoading: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'user_details_state.freezed.dart';
|
||||
@@ -11,7 +9,5 @@ abstract class UserDetailsState with _$UserDetailsState {
|
||||
@Default(false) bool isLoading,
|
||||
String? error,
|
||||
ProfileDetailsModel? profileDetails,
|
||||
List<WeightModel>? weightPoints,
|
||||
List<CircumferenceModel>? circumferencePoints,
|
||||
}) = _UserDetailsState;
|
||||
}
|
||||
@@ -8,11 +8,7 @@ import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
|
||||
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
|
||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||
import 'package:eatvio_app/features/diets/presentation/widgets/more_options.dart';
|
||||
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/circumference_chart_widget.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/imt_chart_widget.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/weight_chart_widget.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.dart';
|
||||
import 'package:eatvio_app/features/user_details/presentation/cubit/user_details_cubit.dart';
|
||||
import 'package:eatvio_app/features/user_details/presentation/cubit/user_details_state.dart';
|
||||
import 'package:eatvio_app/features/user_details/presentation/widgets/expandable_bouble.dart';
|
||||
@@ -44,13 +40,6 @@ class UserDetailsContent extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _UserDetailsContentState extends State<UserDetailsContent> {
|
||||
final List<String> chipsLabelList = ['Вес', 'Замеры', 'ИМТ'];
|
||||
final List<String> chipsIntervalLabelList = ['1М', '3М', '6М', '1Г', 'ВСЁ ВРЕМЯ'];
|
||||
final List<String> chipsIntervalList = ['1', '3', '6', '12', 'all'];
|
||||
|
||||
List<WeightModel> weightPoints = [];
|
||||
List<CircumferenceModel> circumferencePoints = []; int _selectedChipIndex = 0;
|
||||
int _selectedIntervalChipIndex = 0;
|
||||
bool isMoreOptionsVisible = false;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -59,8 +48,6 @@ class _UserDetailsContentState extends State<UserDetailsContent> {
|
||||
builder: (context, state) {
|
||||
final profileDetails = state.profileDetails;
|
||||
if (profileDetails != null && state.isLoading == false) {
|
||||
weightPoints = state.weightPoints ?? [];
|
||||
circumferencePoints = state.circumferencePoints ?? [];
|
||||
return Scaffold(
|
||||
backgroundColor: appTheme.gray100,
|
||||
appBar: AppBar(
|
||||
@@ -104,7 +91,7 @@ class _UserDetailsContentState extends State<UserDetailsContent> {
|
||||
spacing: 12,
|
||||
children: [
|
||||
const SizedBox.shrink(),
|
||||
_charts(),
|
||||
InsightsCard(slug: widget.slug),
|
||||
ExpandableBouble(
|
||||
initiallyExpanded: true,
|
||||
title: s.general_info,
|
||||
@@ -309,92 +296,4 @@ class _UserDetailsContentState extends State<UserDetailsContent> {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Material _charts() {
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(children: [_choiceChipWidget(0), const SizedBox(width: 8), _choiceChipWidget(1), const SizedBox(width: 8), _choiceChipWidget(2)]),
|
||||
),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
switch (_selectedChipIndex) {
|
||||
case 0:
|
||||
return WeightChartWidget(weightPoints: weightPoints);
|
||||
case 1:
|
||||
return CircumferenceChartWidget(circumferencePoints: circumferencePoints);
|
||||
case 2:
|
||||
return ImtChartWidget(weightPoints: weightPoints);
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const SizedBox(width: 16),
|
||||
_choiceIntervalChipWidget(0),
|
||||
const SizedBox(width: 8),
|
||||
_choiceIntervalChipWidget(1),
|
||||
const SizedBox(width: 8),
|
||||
_choiceIntervalChipWidget(2),
|
||||
const SizedBox(width: 8),
|
||||
_choiceIntervalChipWidget(3),
|
||||
const SizedBox(width: 8),
|
||||
_choiceIntervalChipWidget(4),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ChoiceChip _choiceIntervalChipWidget(int index) {
|
||||
return ChoiceChip(
|
||||
selectedColor: Colors.grey.withValues(alpha: 0.1),
|
||||
pressElevation: 0,
|
||||
showCheckmark: false,
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
label: Text(chipsIntervalLabelList[index]),
|
||||
labelStyle: TextStyle(color: appTheme.gray600, fontWeight: FontWeight.w500, fontSize: 12),
|
||||
selected: _selectedIntervalChipIndex == index,
|
||||
onSelected: (bool selected) {
|
||||
context.read<UserDetailsCubit>().getChartData(period: chipsIntervalList[index], slug: widget.slug);
|
||||
setState(() {
|
||||
_selectedIntervalChipIndex = index;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
ChoiceChip _choiceChipWidget(int index) {
|
||||
return ChoiceChip(
|
||||
selectedColor: appTheme.blueA400,
|
||||
pressElevation: 0,
|
||||
showCheckmark: false,
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
label: Text(chipsLabelList[index]),
|
||||
labelStyle: TextStyle(color: index == _selectedChipIndex ? Colors.white : appTheme.gray600, fontWeight: FontWeight.w500, fontSize: 12),
|
||||
selected: _selectedChipIndex == index,
|
||||
backgroundColor: appTheme.gray100,
|
||||
onSelected: (bool selected) {
|
||||
setState(() {
|
||||
_selectedChipIndex = index;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,32 +22,62 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
|
||||
static String m0(date) => "Консультант прокомментировал день ${date}";
|
||||
|
||||
static String m1(planName) => "План «${planName}» начался";
|
||||
static String m1(value, period, category) =>
|
||||
"BMI higher by ${value} over ${period} · ${category}";
|
||||
|
||||
static String m2(planName, name) =>
|
||||
static String m2(height) =>
|
||||
"Green zone is normal (18.5–25), yellow is overweight, pink is obesity. Calculated for a height of ${height} cm.";
|
||||
|
||||
static String m3(value, period, category) =>
|
||||
"BMI lower by ${value} over ${period} · ${category}";
|
||||
|
||||
static String m4(period, category) =>
|
||||
"BMI unchanged over ${period} · ${category}";
|
||||
|
||||
static String m5(name, value) => "${name} down ${value} cm";
|
||||
|
||||
static String m6(name, value) => "${name} up ${value} cm";
|
||||
|
||||
static String m7(name) => "${name} unchanged";
|
||||
|
||||
static String m8(parts, period) => "${parts} over ${period}";
|
||||
|
||||
static String m9(time) => "Bedtime at ${time}";
|
||||
|
||||
static String m10(time) => "Wake-up at ${time}";
|
||||
|
||||
static String m11(value, period) => "Gained ${value} kg over ${period}";
|
||||
|
||||
static String m12(value, period) => "Lost ${value} kg over ${period}";
|
||||
|
||||
static String m13(period) => "Weight unchanged over ${period}";
|
||||
|
||||
static String m14(planName) => "План «${planName}» начался";
|
||||
|
||||
static String m15(planName, name) =>
|
||||
"План «${planName}» с пользователем ${name} начался";
|
||||
|
||||
static String m3(percent, amount) =>
|
||||
static String m16(percent, amount) =>
|
||||
"Discount ${percent}% — you save ${amount} ₽";
|
||||
|
||||
static String m4(step) => "шаг ${step} из 2";
|
||||
static String m17(step) => "шаг ${step} из 2";
|
||||
|
||||
static String m5(planName, name) =>
|
||||
static String m18(planName, name) =>
|
||||
"Завтра начинается план «${planName}» с пользователем ${name}";
|
||||
|
||||
static String m6(planName) =>
|
||||
static String m19(planName) =>
|
||||
"Завтра начнется прохождение плана «${planName}»";
|
||||
|
||||
static String m7(name, planName) =>
|
||||
static String m20(name, planName) =>
|
||||
"Пользователь ${name} отменил подписку на план «${planName}»";
|
||||
|
||||
static String m8(name, planName) =>
|
||||
static String m21(name, planName) =>
|
||||
"Пользователь ${name} оплатил план «${planName}»";
|
||||
|
||||
static String m9(name, planName) =>
|
||||
static String m22(name, planName) =>
|
||||
"Пользователь ${name} подписался на план «${planName}»";
|
||||
|
||||
static String m10(planName) => "Вы отменили подписку на план «${planName}»";
|
||||
static String m23(planName) => "Вы отменили подписку на план «${planName}»";
|
||||
|
||||
final messages = _notInlinedMessages(_notInlinedMessages);
|
||||
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
|
||||
@@ -503,6 +533,126 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"initial_measurements": MessageLookupByLibrary.simpleMessage(
|
||||
"Первичные замеры",
|
||||
),
|
||||
"insights_add_measures_empty": MessageLookupByLibrary.simpleMessage(
|
||||
"Add your measurements",
|
||||
),
|
||||
"insights_add_record": MessageLookupByLibrary.simpleMessage("Add entry"),
|
||||
"insights_add_weight_empty": MessageLookupByLibrary.simpleMessage(
|
||||
"Add your weight",
|
||||
),
|
||||
"insights_bmi_higher": m1,
|
||||
"insights_bmi_hint": m2,
|
||||
"insights_bmi_lower": m3,
|
||||
"insights_bmi_normal": MessageLookupByLibrary.simpleMessage("normal"),
|
||||
"insights_bmi_obesity_1": MessageLookupByLibrary.simpleMessage(
|
||||
"obesity class I",
|
||||
),
|
||||
"insights_bmi_obesity_2": MessageLookupByLibrary.simpleMessage(
|
||||
"obesity class II",
|
||||
),
|
||||
"insights_bmi_obesity_3": MessageLookupByLibrary.simpleMessage(
|
||||
"obesity class III",
|
||||
),
|
||||
"insights_bmi_overweight": MessageLookupByLibrary.simpleMessage(
|
||||
"overweight",
|
||||
),
|
||||
"insights_bmi_same": m4,
|
||||
"insights_bmi_severe_underweight": MessageLookupByLibrary.simpleMessage(
|
||||
"severe underweight",
|
||||
),
|
||||
"insights_bmi_underweight": MessageLookupByLibrary.simpleMessage(
|
||||
"underweight",
|
||||
),
|
||||
"insights_intra_hint": MessageLookupByLibrary.simpleMessage(
|
||||
"Bars show what each meal consists of: protein, fat and carbs in kcal. Lines show satiety and mood on a 0–5 scale.",
|
||||
),
|
||||
"insights_load_error": MessageLookupByLibrary.simpleMessage(
|
||||
"Failed to load analytics",
|
||||
),
|
||||
"insights_measure_less": m5,
|
||||
"insights_measure_more": m6,
|
||||
"insights_measure_same": m7,
|
||||
"insights_measures_summary": m8,
|
||||
"insights_need_height": MessageLookupByLibrary.simpleMessage(
|
||||
"Set your height to calculate BMI.",
|
||||
),
|
||||
"insights_next_day": MessageLookupByLibrary.simpleMessage("Next day"),
|
||||
"insights_not_enough": MessageLookupByLibrary.simpleMessage(
|
||||
"Not enough data",
|
||||
),
|
||||
"insights_period_1m": MessageLookupByLibrary.simpleMessage("1M"),
|
||||
"insights_period_1w": MessageLookupByLibrary.simpleMessage("1W"),
|
||||
"insights_period_1y": MessageLookupByLibrary.simpleMessage("1Y"),
|
||||
"insights_period_2w": MessageLookupByLibrary.simpleMessage("2W"),
|
||||
"insights_period_3m": MessageLookupByLibrary.simpleMessage("3M"),
|
||||
"insights_period_6m": MessageLookupByLibrary.simpleMessage("6M"),
|
||||
"insights_period_all": MessageLookupByLibrary.simpleMessage("All time"),
|
||||
"insights_period_label_1": MessageLookupByLibrary.simpleMessage("month"),
|
||||
"insights_period_label_12": MessageLookupByLibrary.simpleMessage("year"),
|
||||
"insights_period_label_3": MessageLookupByLibrary.simpleMessage("3 months"),
|
||||
"insights_period_label_6": MessageLookupByLibrary.simpleMessage("6 months"),
|
||||
"insights_period_label_all": MessageLookupByLibrary.simpleMessage(
|
||||
"all time",
|
||||
),
|
||||
"insights_pick_date": MessageLookupByLibrary.simpleMessage("Pick a date"),
|
||||
"insights_prev_day": MessageLookupByLibrary.simpleMessage("Previous day"),
|
||||
"insights_retry": MessageLookupByLibrary.simpleMessage("Retry"),
|
||||
"insights_save_error": MessageLookupByLibrary.simpleMessage(
|
||||
"Failed to save the entry",
|
||||
),
|
||||
"insights_series_calories": MessageLookupByLibrary.simpleMessage(
|
||||
"Calories",
|
||||
),
|
||||
"insights_series_carbs": MessageLookupByLibrary.simpleMessage("Carbs"),
|
||||
"insights_series_chest": MessageLookupByLibrary.simpleMessage("Chest"),
|
||||
"insights_series_fats": MessageLookupByLibrary.simpleMessage("Fat"),
|
||||
"insights_series_hips": MessageLookupByLibrary.simpleMessage("Hips"),
|
||||
"insights_series_mood": MessageLookupByLibrary.simpleMessage("Mood"),
|
||||
"insights_series_proteins": MessageLookupByLibrary.simpleMessage("Protein"),
|
||||
"insights_series_satiety": MessageLookupByLibrary.simpleMessage("Satiety"),
|
||||
"insights_series_sleep": MessageLookupByLibrary.simpleMessage(
|
||||
"Sleep and wake-up",
|
||||
),
|
||||
"insights_series_waist": MessageLookupByLibrary.simpleMessage("Waist"),
|
||||
"insights_sleep_at": m9,
|
||||
"insights_sleep_shade": MessageLookupByLibrary.simpleMessage(
|
||||
"Shading — sleep time",
|
||||
),
|
||||
"insights_subtitle_closed": MessageLookupByLibrary.simpleMessage(
|
||||
"tap to see your progress",
|
||||
),
|
||||
"insights_subtitle_open": MessageLookupByLibrary.simpleMessage(
|
||||
"weight, measurements and nutrition",
|
||||
),
|
||||
"insights_tab_bmi": MessageLookupByLibrary.simpleMessage("BMI"),
|
||||
"insights_tab_daily": MessageLookupByLibrary.simpleMessage(
|
||||
"Nutrition by day",
|
||||
),
|
||||
"insights_tab_intra": MessageLookupByLibrary.simpleMessage(
|
||||
"Throughout the day",
|
||||
),
|
||||
"insights_tab_measures": MessageLookupByLibrary.simpleMessage(
|
||||
"Measurements",
|
||||
),
|
||||
"insights_tab_weight": MessageLookupByLibrary.simpleMessage("Weight"),
|
||||
"insights_title": MessageLookupByLibrary.simpleMessage("Analytics"),
|
||||
"insights_today": MessageLookupByLibrary.simpleMessage("Today"),
|
||||
"insights_unit_bmi": MessageLookupByLibrary.simpleMessage("BMI"),
|
||||
"insights_unit_cm": MessageLookupByLibrary.simpleMessage("cm"),
|
||||
"insights_unit_cm_waist": MessageLookupByLibrary.simpleMessage("cm waist"),
|
||||
"insights_unit_grams": MessageLookupByLibrary.simpleMessage("grams"),
|
||||
"insights_unit_kcal": MessageLookupByLibrary.simpleMessage("kcal"),
|
||||
"insights_unit_kcal_avg": MessageLookupByLibrary.simpleMessage(
|
||||
"kcal on average",
|
||||
),
|
||||
"insights_unit_kcal_per_meal": MessageLookupByLibrary.simpleMessage(
|
||||
"kcal per meal",
|
||||
),
|
||||
"insights_unit_kg": MessageLookupByLibrary.simpleMessage("kg"),
|
||||
"insights_wake_at": m10,
|
||||
"insights_weight_gained": m11,
|
||||
"insights_weight_lost": m12,
|
||||
"insights_weight_same": m13,
|
||||
"invite": MessageLookupByLibrary.simpleMessage("Пригласить"),
|
||||
"invited_people_count": MessageLookupByLibrary.simpleMessage(
|
||||
"Количество приглашенных",
|
||||
@@ -684,11 +834,11 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"Физическая форма",
|
||||
),
|
||||
"place_of_living": MessageLookupByLibrary.simpleMessage("Место проживания"),
|
||||
"planStarted": m1,
|
||||
"planStarted": m14,
|
||||
"planWillBeConfirmedAfterThreeDays": MessageLookupByLibrary.simpleMessage(
|
||||
"Если вы не подтвердите, то план через три дня подтвердится автоматически",
|
||||
),
|
||||
"planWithUserStarted": m2,
|
||||
"planWithUserStarted": m15,
|
||||
"plan_author": MessageLookupByLibrary.simpleMessage("Автор плана"),
|
||||
"plan_desc": MessageLookupByLibrary.simpleMessage("Описание плана"),
|
||||
"plan_desc_example": MessageLookupByLibrary.simpleMessage(
|
||||
@@ -731,7 +881,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"Promo code applied!",
|
||||
),
|
||||
"promo_code": MessageLookupByLibrary.simpleMessage("Promo code"),
|
||||
"promo_discount_hint": m3,
|
||||
"promo_discount_hint": m16,
|
||||
"promo_invalid": MessageLookupByLibrary.simpleMessage(
|
||||
"Promo code not found or expired",
|
||||
),
|
||||
@@ -857,7 +1007,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"step_by_step_photo_recipe": MessageLookupByLibrary.simpleMessage(
|
||||
"Пошаговый фоторецепт",
|
||||
),
|
||||
"step_widget": m4,
|
||||
"step_widget": m17,
|
||||
"successfully_published": MessageLookupByLibrary.simpleMessage(
|
||||
"успешно опубликован",
|
||||
),
|
||||
@@ -882,8 +1032,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"toOffer": MessageLookupByLibrary.simpleMessage("Предложить"),
|
||||
"to_draft": MessageLookupByLibrary.simpleMessage("В черновик"),
|
||||
"to_order": MessageLookupByLibrary.simpleMessage("Заказать"),
|
||||
"tommorowPlanWithUserWillStart": m5,
|
||||
"tomorrowYourPlanWillStart": m6,
|
||||
"tommorowPlanWithUserWillStart": m18,
|
||||
"tomorrowYourPlanWillStart": m19,
|
||||
"try_again": MessageLookupByLibrary.simpleMessage("Попробовать снова"),
|
||||
"try_again_2": MessageLookupByLibrary.simpleMessage("Попробуйте ещё раз"),
|
||||
"typeAmoutOfReturn": MessageLookupByLibrary.simpleMessage(
|
||||
@@ -917,7 +1067,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"Верхние показатели давления:",
|
||||
),
|
||||
"upper": MessageLookupByLibrary.simpleMessage("Верхнее"),
|
||||
"userCanceldThePlanSubscription": m7,
|
||||
"userCanceldThePlanSubscription": m20,
|
||||
"userConfirmedPlanCompletion": MessageLookupByLibrary.simpleMessage(
|
||||
"Пользователь подтвердил прохождения плана. В ближайшее время Вы получите",
|
||||
),
|
||||
@@ -925,8 +1075,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
MessageLookupByLibrary.simpleMessage(
|
||||
"Пользователь подтвердил прохождения плана.",
|
||||
),
|
||||
"userPaidThePlan": m8,
|
||||
"userSubscribedToPlan": m9,
|
||||
"userPaidThePlan": m21,
|
||||
"userSubscribedToPlan": m22,
|
||||
"user_authorization": MessageLookupByLibrary.simpleMessage(
|
||||
"Аутентификация",
|
||||
),
|
||||
@@ -1056,7 +1206,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"youCanComplain": MessageLookupByLibrary.simpleMessage(
|
||||
"Если вас что-то не устроило, вы можете подать жалобу",
|
||||
),
|
||||
"youCanceledSubscriptionToPlan": m10,
|
||||
"youCanceledSubscriptionToPlan": m23,
|
||||
"youShureYouWantToCancelOrder": MessageLookupByLibrary.simpleMessage(
|
||||
"Вы уверены, что хотите отменить заказ?",
|
||||
),
|
||||
|
||||
@@ -20,7 +20,37 @@ typedef String MessageIfAbsent(String messageStr, List<dynamic> args);
|
||||
class MessageLookup extends MessageLookupByLibrary {
|
||||
String get localeName => 'ru';
|
||||
|
||||
static String m3(percent, amount) =>
|
||||
static String m1(value, period, category) =>
|
||||
"ИМТ выше на ${value} за ${period} · ${category}";
|
||||
|
||||
static String m2(height) =>
|
||||
"Зелёная зона — норма (18.5–25), жёлтая — избыточный вес, розовая — ожирение. Считаем при росте ${height} см.";
|
||||
|
||||
static String m3(value, period, category) =>
|
||||
"ИМТ ниже на ${value} за ${period} · ${category}";
|
||||
|
||||
static String m4(period, category) =>
|
||||
"ИМТ не изменился за ${period} · ${category}";
|
||||
|
||||
static String m5(name, value) => "${name} меньше на ${value} см";
|
||||
|
||||
static String m6(name, value) => "${name} больше на ${value} см";
|
||||
|
||||
static String m7(name) => "${name} без изменений";
|
||||
|
||||
static String m8(parts, period) => "${parts} за ${period}";
|
||||
|
||||
static String m9(time) => "Отход ко сну в ${time}";
|
||||
|
||||
static String m10(time) => "Подъём в ${time}";
|
||||
|
||||
static String m11(value, period) => "Набрали ${value} кг за ${period}";
|
||||
|
||||
static String m12(value, period) => "Снизили на ${value} кг за ${period}";
|
||||
|
||||
static String m13(period) => "Вес не изменился за ${period}";
|
||||
|
||||
static String m16(percent, amount) =>
|
||||
"Скидка ${percent}% — вы экономите ${amount} ₽";
|
||||
|
||||
final messages = _notInlinedMessages(_notInlinedMessages);
|
||||
@@ -212,6 +242,124 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"initial_measurements": MessageLookupByLibrary.simpleMessage(
|
||||
"Первичные замеры",
|
||||
),
|
||||
"insights_add_measures_empty": MessageLookupByLibrary.simpleMessage(
|
||||
"Добавьте Ваши замеры",
|
||||
),
|
||||
"insights_add_record": MessageLookupByLibrary.simpleMessage(
|
||||
"Добавить запись",
|
||||
),
|
||||
"insights_add_weight_empty": MessageLookupByLibrary.simpleMessage(
|
||||
"Добавьте Ваш вес",
|
||||
),
|
||||
"insights_bmi_higher": m1,
|
||||
"insights_bmi_hint": m2,
|
||||
"insights_bmi_lower": m3,
|
||||
"insights_bmi_normal": MessageLookupByLibrary.simpleMessage("норма"),
|
||||
"insights_bmi_obesity_1": MessageLookupByLibrary.simpleMessage(
|
||||
"ожирение I ст.",
|
||||
),
|
||||
"insights_bmi_obesity_2": MessageLookupByLibrary.simpleMessage(
|
||||
"ожирение II ст.",
|
||||
),
|
||||
"insights_bmi_obesity_3": MessageLookupByLibrary.simpleMessage(
|
||||
"ожирение III ст.",
|
||||
),
|
||||
"insights_bmi_overweight": MessageLookupByLibrary.simpleMessage(
|
||||
"избыточная масса",
|
||||
),
|
||||
"insights_bmi_same": m4,
|
||||
"insights_bmi_severe_underweight": MessageLookupByLibrary.simpleMessage(
|
||||
"выраженный дефицит массы",
|
||||
),
|
||||
"insights_bmi_underweight": MessageLookupByLibrary.simpleMessage(
|
||||
"недостаточная масса",
|
||||
),
|
||||
"insights_intra_hint": MessageLookupByLibrary.simpleMessage(
|
||||
"Столбики показывают, из чего состоят приёмы пищи: белки, жиры и углеводы в ккал. Линии — сытость и настроение по шкале от 0 до 5.",
|
||||
),
|
||||
"insights_load_error": MessageLookupByLibrary.simpleMessage(
|
||||
"Не удалось загрузить аналитику",
|
||||
),
|
||||
"insights_measure_less": m5,
|
||||
"insights_measure_more": m6,
|
||||
"insights_measure_same": m7,
|
||||
"insights_measures_summary": m8,
|
||||
"insights_need_height": MessageLookupByLibrary.simpleMessage(
|
||||
"Укажите рост, чтобы посчитать ИМТ.",
|
||||
),
|
||||
"insights_next_day": MessageLookupByLibrary.simpleMessage("Следующий день"),
|
||||
"insights_not_enough": MessageLookupByLibrary.simpleMessage(
|
||||
"Недостаточно данных",
|
||||
),
|
||||
"insights_period_1m": MessageLookupByLibrary.simpleMessage("1М"),
|
||||
"insights_period_1w": MessageLookupByLibrary.simpleMessage("1Н"),
|
||||
"insights_period_1y": MessageLookupByLibrary.simpleMessage("1Г"),
|
||||
"insights_period_2w": MessageLookupByLibrary.simpleMessage("2Н"),
|
||||
"insights_period_3m": MessageLookupByLibrary.simpleMessage("3М"),
|
||||
"insights_period_6m": MessageLookupByLibrary.simpleMessage("6М"),
|
||||
"insights_period_all": MessageLookupByLibrary.simpleMessage("Всё время"),
|
||||
"insights_period_label_1": MessageLookupByLibrary.simpleMessage("месяц"),
|
||||
"insights_period_label_12": MessageLookupByLibrary.simpleMessage("год"),
|
||||
"insights_period_label_3": MessageLookupByLibrary.simpleMessage("3 месяца"),
|
||||
"insights_period_label_6": MessageLookupByLibrary.simpleMessage(
|
||||
"6 месяцев",
|
||||
),
|
||||
"insights_period_label_all": MessageLookupByLibrary.simpleMessage(
|
||||
"всё время",
|
||||
),
|
||||
"insights_pick_date": MessageLookupByLibrary.simpleMessage("Выбрать дату"),
|
||||
"insights_prev_day": MessageLookupByLibrary.simpleMessage(
|
||||
"Предыдущий день",
|
||||
),
|
||||
"insights_retry": MessageLookupByLibrary.simpleMessage("Повторить"),
|
||||
"insights_save_error": MessageLookupByLibrary.simpleMessage(
|
||||
"Не удалось сохранить запись",
|
||||
),
|
||||
"insights_series_calories": MessageLookupByLibrary.simpleMessage("Калории"),
|
||||
"insights_series_carbs": MessageLookupByLibrary.simpleMessage("Углеводы"),
|
||||
"insights_series_chest": MessageLookupByLibrary.simpleMessage("Грудь"),
|
||||
"insights_series_fats": MessageLookupByLibrary.simpleMessage("Жиры"),
|
||||
"insights_series_hips": MessageLookupByLibrary.simpleMessage("Бёдра"),
|
||||
"insights_series_mood": MessageLookupByLibrary.simpleMessage("Настроение"),
|
||||
"insights_series_proteins": MessageLookupByLibrary.simpleMessage("Белки"),
|
||||
"insights_series_satiety": MessageLookupByLibrary.simpleMessage("Сытость"),
|
||||
"insights_series_sleep": MessageLookupByLibrary.simpleMessage(
|
||||
"Сон и подъём",
|
||||
),
|
||||
"insights_series_waist": MessageLookupByLibrary.simpleMessage("Талия"),
|
||||
"insights_sleep_at": m9,
|
||||
"insights_sleep_shade": MessageLookupByLibrary.simpleMessage(
|
||||
"Затемнение — время сна",
|
||||
),
|
||||
"insights_subtitle_closed": MessageLookupByLibrary.simpleMessage(
|
||||
"нажмите, чтобы посмотреть прогресс",
|
||||
),
|
||||
"insights_subtitle_open": MessageLookupByLibrary.simpleMessage(
|
||||
"вес, замеры и питание",
|
||||
),
|
||||
"insights_tab_bmi": MessageLookupByLibrary.simpleMessage("ИМТ"),
|
||||
"insights_tab_daily": MessageLookupByLibrary.simpleMessage("КБЖУ по дням"),
|
||||
"insights_tab_intra": MessageLookupByLibrary.simpleMessage("В течение дня"),
|
||||
"insights_tab_measures": MessageLookupByLibrary.simpleMessage("Замеры"),
|
||||
"insights_tab_weight": MessageLookupByLibrary.simpleMessage("Вес"),
|
||||
"insights_title": MessageLookupByLibrary.simpleMessage("Аналитика"),
|
||||
"insights_today": MessageLookupByLibrary.simpleMessage("Сегодня"),
|
||||
"insights_unit_bmi": MessageLookupByLibrary.simpleMessage("ИМТ"),
|
||||
"insights_unit_cm": MessageLookupByLibrary.simpleMessage("см"),
|
||||
"insights_unit_cm_waist": MessageLookupByLibrary.simpleMessage("см талия"),
|
||||
"insights_unit_grams": MessageLookupByLibrary.simpleMessage("граммы"),
|
||||
"insights_unit_kcal": MessageLookupByLibrary.simpleMessage("ккал"),
|
||||
"insights_unit_kcal_avg": MessageLookupByLibrary.simpleMessage(
|
||||
"ккал в среднем",
|
||||
),
|
||||
"insights_unit_kcal_per_meal": MessageLookupByLibrary.simpleMessage(
|
||||
"ккал за приём",
|
||||
),
|
||||
"insights_unit_kg": MessageLookupByLibrary.simpleMessage("кг"),
|
||||
"insights_wake_at": m10,
|
||||
"insights_weight_gained": m11,
|
||||
"insights_weight_lost": m12,
|
||||
"insights_weight_same": m13,
|
||||
"label": MessageLookupByLibrary.simpleMessage("Название"),
|
||||
"language": MessageLookupByLibrary.simpleMessage("Язык"),
|
||||
"last_name": MessageLookupByLibrary.simpleMessage("Фамилия"),
|
||||
@@ -338,7 +486,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"Промокод применён!",
|
||||
),
|
||||
"promo_code": MessageLookupByLibrary.simpleMessage("Промокод"),
|
||||
"promo_discount_hint": m3,
|
||||
"promo_discount_hint": m16,
|
||||
"promo_invalid": MessageLookupByLibrary.simpleMessage(
|
||||
"Промокод не найден или истёк",
|
||||
),
|
||||
|
||||
@@ -4553,6 +4553,661 @@ class S {
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Analytics`
|
||||
String get insights_title {
|
||||
return Intl.message(
|
||||
'Analytics',
|
||||
name: 'insights_title',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `tap to see your progress`
|
||||
String get insights_subtitle_closed {
|
||||
return Intl.message(
|
||||
'tap to see your progress',
|
||||
name: 'insights_subtitle_closed',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `weight, measurements and nutrition`
|
||||
String get insights_subtitle_open {
|
||||
return Intl.message(
|
||||
'weight, measurements and nutrition',
|
||||
name: 'insights_subtitle_open',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Weight`
|
||||
String get insights_tab_weight {
|
||||
return Intl.message(
|
||||
'Weight',
|
||||
name: 'insights_tab_weight',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `BMI`
|
||||
String get insights_tab_bmi {
|
||||
return Intl.message('BMI', name: 'insights_tab_bmi', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `Measurements`
|
||||
String get insights_tab_measures {
|
||||
return Intl.message(
|
||||
'Measurements',
|
||||
name: 'insights_tab_measures',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Nutrition by day`
|
||||
String get insights_tab_daily {
|
||||
return Intl.message(
|
||||
'Nutrition by day',
|
||||
name: 'insights_tab_daily',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Throughout the day`
|
||||
String get insights_tab_intra {
|
||||
return Intl.message(
|
||||
'Throughout the day',
|
||||
name: 'insights_tab_intra',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `1M`
|
||||
String get insights_period_1m {
|
||||
return Intl.message('1M', name: 'insights_period_1m', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `3M`
|
||||
String get insights_period_3m {
|
||||
return Intl.message('3M', name: 'insights_period_3m', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `6M`
|
||||
String get insights_period_6m {
|
||||
return Intl.message('6M', name: 'insights_period_6m', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `1Y`
|
||||
String get insights_period_1y {
|
||||
return Intl.message('1Y', name: 'insights_period_1y', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `All time`
|
||||
String get insights_period_all {
|
||||
return Intl.message(
|
||||
'All time',
|
||||
name: 'insights_period_all',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `1W`
|
||||
String get insights_period_1w {
|
||||
return Intl.message('1W', name: 'insights_period_1w', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `2W`
|
||||
String get insights_period_2w {
|
||||
return Intl.message('2W', name: 'insights_period_2w', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `Waist`
|
||||
String get insights_series_waist {
|
||||
return Intl.message(
|
||||
'Waist',
|
||||
name: 'insights_series_waist',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Chest`
|
||||
String get insights_series_chest {
|
||||
return Intl.message(
|
||||
'Chest',
|
||||
name: 'insights_series_chest',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Hips`
|
||||
String get insights_series_hips {
|
||||
return Intl.message(
|
||||
'Hips',
|
||||
name: 'insights_series_hips',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Calories`
|
||||
String get insights_series_calories {
|
||||
return Intl.message(
|
||||
'Calories',
|
||||
name: 'insights_series_calories',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Protein`
|
||||
String get insights_series_proteins {
|
||||
return Intl.message(
|
||||
'Protein',
|
||||
name: 'insights_series_proteins',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Fat`
|
||||
String get insights_series_fats {
|
||||
return Intl.message(
|
||||
'Fat',
|
||||
name: 'insights_series_fats',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Carbs`
|
||||
String get insights_series_carbs {
|
||||
return Intl.message(
|
||||
'Carbs',
|
||||
name: 'insights_series_carbs',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Satiety`
|
||||
String get insights_series_satiety {
|
||||
return Intl.message(
|
||||
'Satiety',
|
||||
name: 'insights_series_satiety',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Mood`
|
||||
String get insights_series_mood {
|
||||
return Intl.message(
|
||||
'Mood',
|
||||
name: 'insights_series_mood',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Sleep and wake-up`
|
||||
String get insights_series_sleep {
|
||||
return Intl.message(
|
||||
'Sleep and wake-up',
|
||||
name: 'insights_series_sleep',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `kg`
|
||||
String get insights_unit_kg {
|
||||
return Intl.message('kg', name: 'insights_unit_kg', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `BMI`
|
||||
String get insights_unit_bmi {
|
||||
return Intl.message('BMI', name: 'insights_unit_bmi', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `cm`
|
||||
String get insights_unit_cm {
|
||||
return Intl.message('cm', name: 'insights_unit_cm', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `cm waist`
|
||||
String get insights_unit_cm_waist {
|
||||
return Intl.message(
|
||||
'cm waist',
|
||||
name: 'insights_unit_cm_waist',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `kcal`
|
||||
String get insights_unit_kcal {
|
||||
return Intl.message('kcal', name: 'insights_unit_kcal', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `kcal on average`
|
||||
String get insights_unit_kcal_avg {
|
||||
return Intl.message(
|
||||
'kcal on average',
|
||||
name: 'insights_unit_kcal_avg',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `grams`
|
||||
String get insights_unit_grams {
|
||||
return Intl.message(
|
||||
'grams',
|
||||
name: 'insights_unit_grams',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `kcal per meal`
|
||||
String get insights_unit_kcal_per_meal {
|
||||
return Intl.message(
|
||||
'kcal per meal',
|
||||
name: 'insights_unit_kcal_per_meal',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `month`
|
||||
String get insights_period_label_1 {
|
||||
return Intl.message(
|
||||
'month',
|
||||
name: 'insights_period_label_1',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `3 months`
|
||||
String get insights_period_label_3 {
|
||||
return Intl.message(
|
||||
'3 months',
|
||||
name: 'insights_period_label_3',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `6 months`
|
||||
String get insights_period_label_6 {
|
||||
return Intl.message(
|
||||
'6 months',
|
||||
name: 'insights_period_label_6',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `year`
|
||||
String get insights_period_label_12 {
|
||||
return Intl.message(
|
||||
'year',
|
||||
name: 'insights_period_label_12',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `all time`
|
||||
String get insights_period_label_all {
|
||||
return Intl.message(
|
||||
'all time',
|
||||
name: 'insights_period_label_all',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Not enough data`
|
||||
String get insights_not_enough {
|
||||
return Intl.message(
|
||||
'Not enough data',
|
||||
name: 'insights_not_enough',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Set your height to calculate BMI.`
|
||||
String get insights_need_height {
|
||||
return Intl.message(
|
||||
'Set your height to calculate BMI.',
|
||||
name: 'insights_need_height',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Lost {value} kg over {period}`
|
||||
String insights_weight_lost(String value, String period) {
|
||||
return Intl.message(
|
||||
'Lost $value kg over $period',
|
||||
name: 'insights_weight_lost',
|
||||
desc: '',
|
||||
args: [value, period],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Gained {value} kg over {period}`
|
||||
String insights_weight_gained(String value, String period) {
|
||||
return Intl.message(
|
||||
'Gained $value kg over $period',
|
||||
name: 'insights_weight_gained',
|
||||
desc: '',
|
||||
args: [value, period],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Weight unchanged over {period}`
|
||||
String insights_weight_same(String period) {
|
||||
return Intl.message(
|
||||
'Weight unchanged over $period',
|
||||
name: 'insights_weight_same',
|
||||
desc: '',
|
||||
args: [period],
|
||||
);
|
||||
}
|
||||
|
||||
/// `BMI lower by {value} over {period} · {category}`
|
||||
String insights_bmi_lower(String value, String period, String category) {
|
||||
return Intl.message(
|
||||
'BMI lower by $value over $period · $category',
|
||||
name: 'insights_bmi_lower',
|
||||
desc: '',
|
||||
args: [value, period, category],
|
||||
);
|
||||
}
|
||||
|
||||
/// `BMI higher by {value} over {period} · {category}`
|
||||
String insights_bmi_higher(String value, String period, String category) {
|
||||
return Intl.message(
|
||||
'BMI higher by $value over $period · $category',
|
||||
name: 'insights_bmi_higher',
|
||||
desc: '',
|
||||
args: [value, period, category],
|
||||
);
|
||||
}
|
||||
|
||||
/// `BMI unchanged over {period} · {category}`
|
||||
String insights_bmi_same(String period, String category) {
|
||||
return Intl.message(
|
||||
'BMI unchanged over $period · $category',
|
||||
name: 'insights_bmi_same',
|
||||
desc: '',
|
||||
args: [period, category],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Green zone is normal (18.5–25), yellow is overweight, pink is obesity. Calculated for a height of {height} cm.`
|
||||
String insights_bmi_hint(String height) {
|
||||
return Intl.message(
|
||||
'Green zone is normal (18.5–25), yellow is overweight, pink is obesity. Calculated for a height of $height cm.',
|
||||
name: 'insights_bmi_hint',
|
||||
desc: '',
|
||||
args: [height],
|
||||
);
|
||||
}
|
||||
|
||||
/// `severe underweight`
|
||||
String get insights_bmi_severe_underweight {
|
||||
return Intl.message(
|
||||
'severe underweight',
|
||||
name: 'insights_bmi_severe_underweight',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `underweight`
|
||||
String get insights_bmi_underweight {
|
||||
return Intl.message(
|
||||
'underweight',
|
||||
name: 'insights_bmi_underweight',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `normal`
|
||||
String get insights_bmi_normal {
|
||||
return Intl.message(
|
||||
'normal',
|
||||
name: 'insights_bmi_normal',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `overweight`
|
||||
String get insights_bmi_overweight {
|
||||
return Intl.message(
|
||||
'overweight',
|
||||
name: 'insights_bmi_overweight',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `obesity class I`
|
||||
String get insights_bmi_obesity_1 {
|
||||
return Intl.message(
|
||||
'obesity class I',
|
||||
name: 'insights_bmi_obesity_1',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `obesity class II`
|
||||
String get insights_bmi_obesity_2 {
|
||||
return Intl.message(
|
||||
'obesity class II',
|
||||
name: 'insights_bmi_obesity_2',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `obesity class III`
|
||||
String get insights_bmi_obesity_3 {
|
||||
return Intl.message(
|
||||
'obesity class III',
|
||||
name: 'insights_bmi_obesity_3',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `{name} down {value} cm`
|
||||
String insights_measure_less(String name, String value) {
|
||||
return Intl.message(
|
||||
'$name down $value cm',
|
||||
name: 'insights_measure_less',
|
||||
desc: '',
|
||||
args: [name, value],
|
||||
);
|
||||
}
|
||||
|
||||
/// `{name} up {value} cm`
|
||||
String insights_measure_more(String name, String value) {
|
||||
return Intl.message(
|
||||
'$name up $value cm',
|
||||
name: 'insights_measure_more',
|
||||
desc: '',
|
||||
args: [name, value],
|
||||
);
|
||||
}
|
||||
|
||||
/// `{name} unchanged`
|
||||
String insights_measure_same(String name) {
|
||||
return Intl.message(
|
||||
'$name unchanged',
|
||||
name: 'insights_measure_same',
|
||||
desc: '',
|
||||
args: [name],
|
||||
);
|
||||
}
|
||||
|
||||
/// `{parts} over {period}`
|
||||
String insights_measures_summary(String parts, String period) {
|
||||
return Intl.message(
|
||||
'$parts over $period',
|
||||
name: 'insights_measures_summary',
|
||||
desc: '',
|
||||
args: [parts, period],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Add entry`
|
||||
String get insights_add_record {
|
||||
return Intl.message(
|
||||
'Add entry',
|
||||
name: 'insights_add_record',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Add your weight`
|
||||
String get insights_add_weight_empty {
|
||||
return Intl.message(
|
||||
'Add your weight',
|
||||
name: 'insights_add_weight_empty',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Add your measurements`
|
||||
String get insights_add_measures_empty {
|
||||
return Intl.message(
|
||||
'Add your measurements',
|
||||
name: 'insights_add_measures_empty',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Failed to load analytics`
|
||||
String get insights_load_error {
|
||||
return Intl.message(
|
||||
'Failed to load analytics',
|
||||
name: 'insights_load_error',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Retry`
|
||||
String get insights_retry {
|
||||
return Intl.message('Retry', name: 'insights_retry', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `Bars show what each meal consists of: protein, fat and carbs in kcal. Lines show satiety and mood on a 0–5 scale.`
|
||||
String get insights_intra_hint {
|
||||
return Intl.message(
|
||||
'Bars show what each meal consists of: protein, fat and carbs in kcal. Lines show satiety and mood on a 0–5 scale.',
|
||||
name: 'insights_intra_hint',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Shading — sleep time`
|
||||
String get insights_sleep_shade {
|
||||
return Intl.message(
|
||||
'Shading — sleep time',
|
||||
name: 'insights_sleep_shade',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Wake-up at {time}`
|
||||
String insights_wake_at(String time) {
|
||||
return Intl.message(
|
||||
'Wake-up at $time',
|
||||
name: 'insights_wake_at',
|
||||
desc: '',
|
||||
args: [time],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Bedtime at {time}`
|
||||
String insights_sleep_at(String time) {
|
||||
return Intl.message(
|
||||
'Bedtime at $time',
|
||||
name: 'insights_sleep_at',
|
||||
desc: '',
|
||||
args: [time],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Previous day`
|
||||
String get insights_prev_day {
|
||||
return Intl.message(
|
||||
'Previous day',
|
||||
name: 'insights_prev_day',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Next day`
|
||||
String get insights_next_day {
|
||||
return Intl.message(
|
||||
'Next day',
|
||||
name: 'insights_next_day',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Pick a date`
|
||||
String get insights_pick_date {
|
||||
return Intl.message(
|
||||
'Pick a date',
|
||||
name: 'insights_pick_date',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Today`
|
||||
String get insights_today {
|
||||
return Intl.message('Today', name: 'insights_today', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `Failed to save the entry`
|
||||
String get insights_save_error {
|
||||
return Intl.message(
|
||||
'Failed to save the entry',
|
||||
name: 'insights_save_error',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AppLocalizationDelegate extends LocalizationsDelegate<S> {
|
||||
|
||||
@@ -537,6 +537,198 @@
|
||||
"video_call_close": "Close",
|
||||
"video_call_notification_in_progress": "Call in progress",
|
||||
"video_call_notification_return": "Return to call",
|
||||
"video_call_notification_end": "End call"
|
||||
"video_call_notification_end": "End call",
|
||||
"insights_title": "Analytics",
|
||||
"insights_subtitle_closed": "tap to see your progress",
|
||||
"insights_subtitle_open": "weight, measurements and nutrition",
|
||||
"insights_tab_weight": "Weight",
|
||||
"insights_tab_bmi": "BMI",
|
||||
"insights_tab_measures": "Measurements",
|
||||
"insights_tab_daily": "Nutrition by day",
|
||||
"insights_tab_intra": "Throughout the day",
|
||||
"insights_period_1m": "1M",
|
||||
"insights_period_3m": "3M",
|
||||
"insights_period_6m": "6M",
|
||||
"insights_period_1y": "1Y",
|
||||
"insights_period_all": "All time",
|
||||
"insights_period_1w": "1W",
|
||||
"insights_period_2w": "2W",
|
||||
"insights_series_waist": "Waist",
|
||||
"insights_series_chest": "Chest",
|
||||
"insights_series_hips": "Hips",
|
||||
"insights_series_calories": "Calories",
|
||||
"insights_series_proteins": "Protein",
|
||||
"insights_series_fats": "Fat",
|
||||
"insights_series_carbs": "Carbs",
|
||||
"insights_series_satiety": "Satiety",
|
||||
"insights_series_mood": "Mood",
|
||||
"insights_series_sleep": "Sleep and wake-up",
|
||||
"insights_unit_kg": "kg",
|
||||
"insights_unit_bmi": "BMI",
|
||||
"insights_unit_cm": "cm",
|
||||
"insights_unit_cm_waist": "cm waist",
|
||||
"insights_unit_kcal": "kcal",
|
||||
"insights_unit_kcal_avg": "kcal on average",
|
||||
"insights_unit_grams": "grams",
|
||||
"insights_unit_kcal_per_meal": "kcal per meal",
|
||||
"insights_period_label_1": "month",
|
||||
"insights_period_label_3": "3 months",
|
||||
"insights_period_label_6": "6 months",
|
||||
"insights_period_label_12": "year",
|
||||
"insights_period_label_all": "all time",
|
||||
"insights_not_enough": "Not enough data",
|
||||
"insights_need_height": "Set your height to calculate BMI.",
|
||||
"insights_weight_lost": "Lost {value} kg over {period}",
|
||||
"@insights_weight_lost": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"period": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_weight_gained": "Gained {value} kg over {period}",
|
||||
"@insights_weight_gained": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"period": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_weight_same": "Weight unchanged over {period}",
|
||||
"@insights_weight_same": {
|
||||
"placeholders": {
|
||||
"period": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_bmi_lower": "BMI lower by {value} over {period} · {category}",
|
||||
"@insights_bmi_lower": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"period": {
|
||||
"type": "String"
|
||||
},
|
||||
"category": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_bmi_higher": "BMI higher by {value} over {period} · {category}",
|
||||
"@insights_bmi_higher": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"period": {
|
||||
"type": "String"
|
||||
},
|
||||
"category": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_bmi_same": "BMI unchanged over {period} · {category}",
|
||||
"@insights_bmi_same": {
|
||||
"placeholders": {
|
||||
"period": {
|
||||
"type": "String"
|
||||
},
|
||||
"category": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_bmi_hint": "Green zone is normal (18.5–25), yellow is overweight, pink is obesity. Calculated for a height of {height} cm.",
|
||||
"@insights_bmi_hint": {
|
||||
"placeholders": {
|
||||
"height": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_bmi_severe_underweight": "severe underweight",
|
||||
"insights_bmi_underweight": "underweight",
|
||||
"insights_bmi_normal": "normal",
|
||||
"insights_bmi_overweight": "overweight",
|
||||
"insights_bmi_obesity_1": "obesity class I",
|
||||
"insights_bmi_obesity_2": "obesity class II",
|
||||
"insights_bmi_obesity_3": "obesity class III",
|
||||
"insights_measure_less": "{name} down {value} cm",
|
||||
"@insights_measure_less": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
},
|
||||
"value": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_measure_more": "{name} up {value} cm",
|
||||
"@insights_measure_more": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
},
|
||||
"value": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_measure_same": "{name} unchanged",
|
||||
"@insights_measure_same": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_measures_summary": "{parts} over {period}",
|
||||
"@insights_measures_summary": {
|
||||
"placeholders": {
|
||||
"parts": {
|
||||
"type": "String"
|
||||
},
|
||||
"period": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_add_record": "Add entry",
|
||||
"insights_add_weight_empty": "Add your weight",
|
||||
"insights_add_measures_empty": "Add your measurements",
|
||||
"insights_load_error": "Failed to load analytics",
|
||||
"insights_retry": "Retry",
|
||||
"insights_intra_hint": "Bars show what each meal consists of: protein, fat and carbs in kcal. Lines show satiety and mood on a 0–5 scale.",
|
||||
"insights_sleep_shade": "Shading — sleep time",
|
||||
"insights_wake_at": "Wake-up at {time}",
|
||||
"@insights_wake_at": {
|
||||
"placeholders": {
|
||||
"time": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_sleep_at": "Bedtime at {time}",
|
||||
"@insights_sleep_at": {
|
||||
"placeholders": {
|
||||
"time": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_prev_day": "Previous day",
|
||||
"insights_next_day": "Next day",
|
||||
"insights_pick_date": "Pick a date",
|
||||
"insights_today": "Today",
|
||||
"insights_save_error": "Failed to save the entry"
|
||||
}
|
||||
|
||||
|
||||
@@ -282,5 +282,198 @@
|
||||
"video_call_notification_in_progress": "Звонок идёт",
|
||||
"video_call_notification_return": "Вернуться к звонку",
|
||||
"video_call_notification_end": "Завершить звонок",
|
||||
"blog_post_image_min_size_error": "Минимальный размер изображения — 500×500 px"
|
||||
"blog_post_image_min_size_error": "Минимальный размер изображения — 500×500 px",
|
||||
"insights_title": "Аналитика",
|
||||
"insights_subtitle_closed": "нажмите, чтобы посмотреть прогресс",
|
||||
"insights_subtitle_open": "вес, замеры и питание",
|
||||
"insights_tab_weight": "Вес",
|
||||
"insights_tab_bmi": "ИМТ",
|
||||
"insights_tab_measures": "Замеры",
|
||||
"insights_tab_daily": "КБЖУ по дням",
|
||||
"insights_tab_intra": "В течение дня",
|
||||
"insights_period_1m": "1М",
|
||||
"insights_period_3m": "3М",
|
||||
"insights_period_6m": "6М",
|
||||
"insights_period_1y": "1Г",
|
||||
"insights_period_all": "Всё время",
|
||||
"insights_period_1w": "1Н",
|
||||
"insights_period_2w": "2Н",
|
||||
"insights_series_waist": "Талия",
|
||||
"insights_series_chest": "Грудь",
|
||||
"insights_series_hips": "Бёдра",
|
||||
"insights_series_calories": "Калории",
|
||||
"insights_series_proteins": "Белки",
|
||||
"insights_series_fats": "Жиры",
|
||||
"insights_series_carbs": "Углеводы",
|
||||
"insights_series_satiety": "Сытость",
|
||||
"insights_series_mood": "Настроение",
|
||||
"insights_series_sleep": "Сон и подъём",
|
||||
"insights_unit_kg": "кг",
|
||||
"insights_unit_bmi": "ИМТ",
|
||||
"insights_unit_cm": "см",
|
||||
"insights_unit_cm_waist": "см талия",
|
||||
"insights_unit_kcal": "ккал",
|
||||
"insights_unit_kcal_avg": "ккал в среднем",
|
||||
"insights_unit_grams": "граммы",
|
||||
"insights_unit_kcal_per_meal": "ккал за приём",
|
||||
"insights_period_label_1": "месяц",
|
||||
"insights_period_label_3": "3 месяца",
|
||||
"insights_period_label_6": "6 месяцев",
|
||||
"insights_period_label_12": "год",
|
||||
"insights_period_label_all": "всё время",
|
||||
"insights_not_enough": "Недостаточно данных",
|
||||
"insights_need_height": "Укажите рост, чтобы посчитать ИМТ.",
|
||||
"insights_weight_lost": "Снизили на {value} кг за {period}",
|
||||
"@insights_weight_lost": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"period": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_weight_gained": "Набрали {value} кг за {period}",
|
||||
"@insights_weight_gained": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"period": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_weight_same": "Вес не изменился за {period}",
|
||||
"@insights_weight_same": {
|
||||
"placeholders": {
|
||||
"period": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_bmi_lower": "ИМТ ниже на {value} за {period} · {category}",
|
||||
"@insights_bmi_lower": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"period": {
|
||||
"type": "String"
|
||||
},
|
||||
"category": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_bmi_higher": "ИМТ выше на {value} за {period} · {category}",
|
||||
"@insights_bmi_higher": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"period": {
|
||||
"type": "String"
|
||||
},
|
||||
"category": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_bmi_same": "ИМТ не изменился за {period} · {category}",
|
||||
"@insights_bmi_same": {
|
||||
"placeholders": {
|
||||
"period": {
|
||||
"type": "String"
|
||||
},
|
||||
"category": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_bmi_hint": "Зелёная зона — норма (18.5–25), жёлтая — избыточный вес, розовая — ожирение. Считаем при росте {height} см.",
|
||||
"@insights_bmi_hint": {
|
||||
"placeholders": {
|
||||
"height": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_bmi_severe_underweight": "выраженный дефицит массы",
|
||||
"insights_bmi_underweight": "недостаточная масса",
|
||||
"insights_bmi_normal": "норма",
|
||||
"insights_bmi_overweight": "избыточная масса",
|
||||
"insights_bmi_obesity_1": "ожирение I ст.",
|
||||
"insights_bmi_obesity_2": "ожирение II ст.",
|
||||
"insights_bmi_obesity_3": "ожирение III ст.",
|
||||
"insights_measure_less": "{name} меньше на {value} см",
|
||||
"@insights_measure_less": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
},
|
||||
"value": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_measure_more": "{name} больше на {value} см",
|
||||
"@insights_measure_more": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
},
|
||||
"value": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_measure_same": "{name} без изменений",
|
||||
"@insights_measure_same": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_measures_summary": "{parts} за {period}",
|
||||
"@insights_measures_summary": {
|
||||
"placeholders": {
|
||||
"parts": {
|
||||
"type": "String"
|
||||
},
|
||||
"period": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_add_record": "Добавить запись",
|
||||
"insights_add_weight_empty": "Добавьте Ваш вес",
|
||||
"insights_add_measures_empty": "Добавьте Ваши замеры",
|
||||
"insights_load_error": "Не удалось загрузить аналитику",
|
||||
"insights_retry": "Повторить",
|
||||
"insights_intra_hint": "Столбики показывают, из чего состоят приёмы пищи: белки, жиры и углеводы в ккал. Линии — сытость и настроение по шкале от 0 до 5.",
|
||||
"insights_sleep_shade": "Затемнение — время сна",
|
||||
"insights_wake_at": "Подъём в {time}",
|
||||
"@insights_wake_at": {
|
||||
"placeholders": {
|
||||
"time": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_sleep_at": "Отход ко сну в {time}",
|
||||
"@insights_sleep_at": {
|
||||
"placeholders": {
|
||||
"time": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_prev_day": "Предыдущий день",
|
||||
"insights_next_day": "Следующий день",
|
||||
"insights_pick_date": "Выбрать дату",
|
||||
"insights_today": "Сегодня",
|
||||
"insights_save_error": "Не удалось сохранить запись"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user